@@ -7,7 +7,7 @@ import { Canvas, useFrame, type ThreeEvent } from "@react-three/fiber";
77import { BallCollider , CuboidCollider , Physics , RigidBody , type RapierRigidBody } from "@react-three/rapier" ;
88import { AnimatePresence , animate , motion , useMotionValue , useReducedMotion } from "motion/react" ;
99import { Suspense , useCallback , useEffect , useMemo , useRef , useState , type MutableRefObject } from "react" ;
10- import { Box3 , Quaternion , RepeatWrapping , SRGBColorSpace , Vector3 , type Group , type Material , type Mesh } from "three" ;
10+ import { Box3 , Quaternion , RepeatWrapping , SRGBColorSpace , Vector3 , type Group , type Material , type Mesh , type Texture } from "three" ;
1111
1212export type BallKind = "ceramic" | "crystal" | "mochi" | "dubai" | "butter_bar" | "butter_rice_cake" | "brick_cake" | "slice_cake" ;
1313
@@ -122,7 +122,8 @@ const angularKinds = new Set<BallKind>(["butter_bar", "butter_rice_cake", "brick
122122const GRAVITY = - 9.4 ;
123123// 손가락으로 밀었을 때 방향 전환이 바로 느껴지도록 회전 임펄스를 넉넉히 준다.
124124const TORQUE = 2.1 ;
125- const WAKPPU_ASSET_VERSION = "20260813-physical-jelly-1" ;
125+ const WAKPPU_ASSET_VERSION = "20260814-dubai-shell-dough-1" ;
126+ const DUBAI_CORE_MODEL = `/models/wakppu/dubai-core.glb?v=${ WAKPPU_ASSET_VERSION } ` ;
126127
127128function modelPath ( kind : BallKind , broken = false ) {
128129 return `/models/wakppu/${ kind } ${ broken ? "-broken" : "" } .glb?v=${ WAKPPU_ASSET_VERSION } ` ;
@@ -578,31 +579,32 @@ type LoadedFragment = {
578579 rotation : [ number , number , number ] ;
579580} ;
580581
581- function FragmentPiece ( { piece, fragment, tilt, index } : { piece : LoadedFragment ; fragment : FragmentSpec ; tilt : DeviceTiltRef ; index : number } ) {
582+ function FragmentPiece ( { kind , piece, fragment, tilt, index } : { kind : BallKind ; piece : LoadedFragment ; fragment : FragmentSpec ; tilt : DeviceTiltRef ; index : number } ) {
582583 const body = useRef < RapierRigidBody > ( null ) ;
583584 const pointer = useRef < { x : number ; y : number } | null > ( null ) ;
584585 const [ released , setReleased ] = useState ( false ) ;
586+ const brittleShell = kind === "dubai" ;
585587 useTiltPhysics ( body , tilt , 0.62 ) ;
586588
587589 useEffect ( ( ) => {
588- const delay = 100 + index * 45 ;
590+ const delay = brittleShell ? 32 + index * 11 : 100 + index * 45 ;
589591 const timer = window . setTimeout ( ( ) => setReleased ( true ) , delay ) ;
590592 return ( ) => window . clearTimeout ( timer ) ;
591- } , [ index ] ) ;
593+ } , [ brittleShell , index ] ) ;
592594
593595 useEffect ( ( ) => {
594596 if ( ! released ) return ;
595597 body . current ?. setLinvel ( {
596- x : 0 ,
597- y : - 0.035 ,
598- z : 0 ,
598+ x : brittleShell ? fragment . velocity [ 0 ] * 0.2 : 0 ,
599+ y : brittleShell ? 0.24 + fragment . velocity [ 1 ] * 0.13 : - 0.035 ,
600+ z : brittleShell ? fragment . velocity [ 2 ] * 0.2 : 0 ,
599601 } , true ) ;
600602 body . current ?. setAngvel ( {
601- x : fragment . spin [ 0 ] * 0. 025,
602- y : fragment . spin [ 1 ] * 0. 018,
603- z : fragment . spin [ 2 ] * 0. 025,
603+ x : fragment . spin [ 0 ] * ( brittleShell ? 0.42 : 0. 025) ,
604+ y : fragment . spin [ 1 ] * ( brittleShell ? 0.36 : 0. 018) ,
605+ z : fragment . spin [ 2 ] * ( brittleShell ? 0.42 : 0. 025) ,
604606 } , true ) ;
605- } , [ fragment , index , released ] ) ;
607+ } , [ brittleShell , fragment , index , released ] ) ;
606608
607609 function grab ( event : ThreeEvent < PointerEvent > ) {
608610 event . stopPropagation ( ) ;
@@ -629,10 +631,11 @@ function FragmentPiece({ piece, fragment, tilt, index }: { piece: LoadedFragment
629631 colliders = "hull"
630632 position = { piece . position }
631633 rotation = { piece . rotation }
632- restitution = { 0.03 }
633- friction = { 0.94 }
634- linearDamping = { 0.5 }
635- angularDamping = { 0.82 }
634+ mass = { brittleShell ? 0.16 : undefined }
635+ restitution = { brittleShell ? 0.18 : 0.03 }
636+ friction = { brittleShell ? 0.68 : 0.94 }
637+ linearDamping = { brittleShell ? 0.16 : 0.5 }
638+ angularDamping = { brittleShell ? 0.28 : 0.82 }
636639 >
637640 < mesh
638641 castShadow
@@ -678,6 +681,7 @@ function Fragments({ kind, tilt }: { kind: BallKind; tilt: DeviceTiltRef }) {
678681 return (
679682 < FragmentPiece
680683 key = { index }
684+ kind = { kind }
681685 piece = { piece }
682686 fragment = { fragment }
683687 tilt = { tilt }
@@ -687,122 +691,191 @@ function Fragments({ kind, tilt }: { kind: BallKind; tilt: DeviceTiltRef }) {
687691 } ) ;
688692}
689693
690- // Blender로 모델링한 한 장짜리 포춘쿠키 쪽지. 앞뒤 잎이 둥근 접힘으로 이어지고
691- // 자유단 길이와 휨이 조금씩 다르다. scripts/fortune-note.py 로 다시 만들 수 있다.
692- function JellyCore ( { kind, tilt } : { kind : BallKind ; tilt : DeviceTiltRef } ) {
694+ // Rapier 강체 위에 점탄성 표면 변형을 얹은 내부 반죽.
695+ const doughColors : Record < BallKind , string > = {
696+ ceramic : "#d7b985" ,
697+ crystal : "#c8b5d8" ,
698+ mochi : "#e5c9bb" ,
699+ dubai : "#8e5d3a" ,
700+ butter_bar : "#d5aa58" ,
701+ butter_rice_cake : "#dec98e" ,
702+ brick_cake : "#bd8b55" ,
703+ slice_cake : "#d9b56f" ,
704+ } ;
705+
706+ type DoughBodyProps = {
707+ kind : BallKind ;
708+ tilt : DeviceTiltRef ;
709+ colorMap ?: Texture ;
710+ normalMap ?: Texture ;
711+ model ?: Group ;
712+ colliderRadius ?: number ;
713+ visualRadius ?: number ;
714+ } ;
715+
716+ function DoughBody ( {
717+ kind,
718+ tilt,
719+ colorMap,
720+ normalMap,
721+ model,
722+ colliderRadius = 0.52 ,
723+ visualRadius = 0.57 ,
724+ } : DoughBodyProps ) {
693725 const body = useRef < RapierRigidBody > ( null ) ;
694726 const visual = useRef < Group > ( null ) ;
695- const sourceColorMap = useTexture ( `/textures/wakppu/${ kind } -interior.webp` ) ;
696- const sourceNormalMap = useTexture ( `/textures/wakppu/${ kind } -interior-normal.webp` ) ;
697- const colorMap = useMemo ( ( ) => {
698- const texture = sourceColorMap . clone ( ) ;
699- texture . colorSpace = SRGBColorSpace ;
700- texture . wrapS = RepeatWrapping ;
701- texture . wrapT = RepeatWrapping ;
702- texture . repeat . set ( 1.35 , 1.35 ) ;
703- texture . anisotropy = 4 ;
704- texture . needsUpdate = true ;
705- return texture ;
706- } , [ sourceColorMap ] ) ;
707- const normalMap = useMemo ( ( ) => {
708- const texture = sourceNormalMap . clone ( ) ;
709- texture . wrapS = RepeatWrapping ;
710- texture . wrapT = RepeatWrapping ;
711- texture . repeat . set ( 1.35 , 1.35 ) ;
712- texture . needsUpdate = true ;
713- return texture ;
714- } , [ sourceNormalMap ] ) ;
715727 const previousVelocity = useRef ( new Vector3 ( ) ) ;
716- const wobble = useRef ( { compression : 0 , velocity : - 2.8 , lean : 0 , leanVelocity : 0.9 } ) ;
717- useTiltPhysics ( body , tilt , 0.62 ) ;
728+ const dough = useRef ( { compression : 0.08 , velocity : 0 , lean : 0 , leanVelocity : 0 } ) ;
729+ useTiltPhysics ( body , tilt , 0.28 ) ;
718730
719- function excite ( compression = 0.24 , lean = 0.45 ) {
720- wobble . current . velocity - = compression * 9.5 ;
721- wobble . current . leanVelocity += lean ;
731+ function knead ( compression = 0.2 , lean = 0.3 ) {
732+ dough . current . velocity + = compression * 5.2 ;
733+ dough . current . leanVelocity += lean ;
722734 }
723735
724736 useFrame ( ( state , delta ) => {
725737 if ( ! visual . current || ! body . current ) return ;
726738 const frame = Math . min ( delta , 0.032 ) ;
727- const current = wobble . current ;
739+ const current = dough . current ;
728740 const velocity = body . current . linvel ( ) ;
729741 const lastVelocity = previousVelocity . current ;
730742 const verticalImpulse = velocity . y - lastVelocity . y ;
731743
732- if ( verticalImpulse > 0.72 ) {
733- current . velocity - = Math . min ( 3.2 , verticalImpulse * 0.48 ) ;
734- current . leanVelocity += ( velocity . x - velocity . z ) * 0.025 ;
744+ if ( verticalImpulse > 0.34 ) {
745+ current . velocity + = Math . min ( 1.7 , verticalImpulse * 0.32 ) ;
746+ current . leanVelocity += ( velocity . x - velocity . z ) * 0.018 ;
735747 }
736748 lastVelocity . set ( velocity . x , velocity . y , velocity . z ) ;
737749
738- current . velocity += ( - 92 * current . compression - 12.5 * current . velocity ) * frame ;
750+ // Rapier에는 소프트바디가 없으므로, 충돌은 무거운 강체가 받고 표면만
751+ // 부피를 보존하며 천천히 퍼지고 복원되게 해 반죽의 점탄성을 만든다.
752+ const horizontalSpeed = Math . hypot ( velocity . x , velocity . z ) ;
753+ const restingCompression = 0.13 + Math . min ( 0.035 , horizontalSpeed * 0.012 ) ;
754+ current . velocity += ( ( restingCompression - current . compression ) * 24 - current . velocity * 9.6 ) * frame ;
739755 current . compression += current . velocity * frame ;
740- current . compression = clamp ( current . compression , - 0.28 , 0.32 ) ;
756+ current . compression = clamp ( current . compression , 0.04 , 0.27 ) ;
741757
742- current . leanVelocity += ( - 58 * current . lean - 9.5 * current . leanVelocity ) * frame ;
758+ current . leanVelocity += ( - 24 * current . lean - 8.4 * current . leanVelocity ) * frame ;
743759 current . lean += current . leanVelocity * frame ;
744- current . lean = clamp ( current . lean , - 0.22 , 0.22 ) ;
760+ current . lean = clamp ( current . lean , - 0.16 , 0.16 ) ;
745761
746- const speed = Math . sqrt ( velocity . x ** 2 + velocity . y ** 2 + velocity . z ** 2 ) ;
747- const stretch = Math . min ( 0.13 , speed * 0.016 ) ;
748- const verticalScale = clamp ( 1 + current . compression + stretch , 0.68 , 1.42 ) ;
749- const volumeScale = 1 / Math . sqrt ( verticalScale ) ;
750- const residual = Math . min ( 0.055 , Math . abs ( current . velocity ) * 0.018 ) ;
751- const ripple = Math . sin ( state . clock . elapsedTime * 15.5 ) * residual ;
752- const mochiScale = kind === "mochi" ? 0.93 : 1 ;
762+ const verticalScale = 1 - current . compression ;
763+ const spread = 1 / Math . sqrt ( verticalScale ) ;
764+ const slowFold = Math . sin ( state . clock . elapsedTime * 3.1 ) * Math . min ( 0.018 , Math . abs ( current . velocity ) * 0.01 ) ;
753765
754766 visual . current . scale . set (
755- volumeScale * ( 1 + ripple ) ,
756- verticalScale * mochiScale ,
757- volumeScale * ( 1 - ripple ) ,
767+ spread * ( 1 + slowFold + current . lean * 0.055 ) ,
768+ verticalScale ,
769+ spread * ( 1 - slowFold - current . lean * 0.055 ) ,
758770 ) ;
759- visual . current . rotation . x = current . lean * 0.42 ;
760- visual . current . rotation . z = - current . lean * 0.7 ;
771+ // 찌그러질 때 바닥 면은 제자리에 남겨 공중에서 줄어드는 느낌을 막는다.
772+ visual . current . position . y = - visualRadius * current . compression ;
773+ visual . current . rotation . x = current . lean * 0.22 ;
774+ visual . current . rotation . z = - current . lean * 0.38 ;
761775 } ) ;
762776
763777 function poke ( event : ThreeEvent < PointerEvent > ) {
764778 event . stopPropagation ( ) ;
765779 const direction = event . point . clone ( ) . normalize ( ) ;
766- body . current ?. applyImpulse ( { x : direction . x * 0.38 , y : 0.72 , z : direction . z * 0.38 } , true ) ;
767- body . current ?. applyTorqueImpulse ( { x : - direction . z * 0.22 , y : 0.16 , z : direction . x * 0.22 } , true ) ;
768- excite ( 0.28 , direction . x * 0.7 + 0.35 ) ;
780+ body . current ?. applyImpulse ( { x : direction . x * 0.11 , y : 0.16 , z : direction . z * 0.11 } , true ) ;
781+ body . current ?. applyTorqueImpulse ( { x : - direction . z * 0.055 , y : 0.025 , z : direction . x * 0.055 } , true ) ;
782+ knead ( 0.22 , direction . x * 0.32 + 0.12 ) ;
769783 }
770784
771785 return (
772786 < RigidBody
773787 ref = { body }
774788 colliders = { false }
775789 position = { [ 0 , 0.28 , 0 ] }
776- mass = { 0.72 }
777- restitution = { 0.42 }
778- friction = { 0.78 }
779- linearDamping = { 0.34 }
780- angularDamping = { 0.46 }
790+ mass = { model ? 3.4 : 2.7 }
791+ restitution = { 0.015 }
792+ friction = { 1.35 }
793+ linearDamping = { 1.15 }
794+ angularDamping = { 2.4 }
781795 canSleep = { false }
782- onCollisionEnter = { ( ) => excite ( 0.2 , - wobble . current . lean * 0.8 + 0.24 ) }
796+ onCollisionEnter = { ( ) => knead ( 0.18 , - dough . current . lean * 0.45 + 0.08 ) }
783797 >
784- < BallCollider args = { [ 0.52 ] } />
785- < group ref = { visual } >
786- < mesh castShadow receiveShadow onPointerDown = { poke } >
787- < sphereGeometry args = { [ 0.57 , 36 , 24 ] } />
788- < meshPhysicalMaterial
789- map = { colorMap }
790- normalMap = { normalMap }
791- normalScale = { [ 0.18 , 0.18 ] }
792- roughness = { kind === "dubai" ? 0.34 : 0.24 }
793- metalness = { 0 }
794- transmission = { 0.08 }
795- thickness = { 0.52 }
796- ior = { 1.42 }
797- clearcoat = { 0.82 }
798- clearcoatRoughness = { 0.1 }
799- />
800- </ mesh >
798+ < BallCollider args = { [ colliderRadius ] } />
799+ < group ref = { visual } onPointerDown = { poke } >
800+ { model
801+ ? < primitive object = { model } />
802+ : (
803+ < mesh castShadow receiveShadow >
804+ < sphereGeometry args = { [ 0.57 , 48 , 32 ] } />
805+ < meshPhysicalMaterial
806+ map = { colorMap }
807+ normalMap = { normalMap }
808+ color = { colorMap ? "#ffffff" : doughColors [ kind ] }
809+ normalScale = { [ 0.26 , 0.26 ] }
810+ roughness = { 0.86 }
811+ metalness = { 0 }
812+ transmission = { 0 }
813+ clearcoat = { 0.04 }
814+ clearcoatRoughness = { 0.82 }
815+ />
816+ </ mesh >
817+ ) }
801818 </ group >
802819 </ RigidBody >
803820 ) ;
804821}
805822
823+ function TexturedDoughCore ( { kind, tilt } : { kind : BallKind ; tilt : DeviceTiltRef } ) {
824+ const sourceColorMap = useTexture ( `/textures/wakppu/${ kind } -interior.webp` ) ;
825+ const sourceNormalMap = useTexture ( `/textures/wakppu/${ kind } -interior-normal.webp` ) ;
826+ const colorMap = useMemo ( ( ) => {
827+ const texture = sourceColorMap . clone ( ) ;
828+ texture . colorSpace = SRGBColorSpace ;
829+ texture . wrapS = RepeatWrapping ;
830+ texture . wrapT = RepeatWrapping ;
831+ texture . repeat . set ( 1.5 , 1.5 ) ;
832+ texture . anisotropy = 4 ;
833+ texture . needsUpdate = true ;
834+ return texture ;
835+ } , [ sourceColorMap ] ) ;
836+ const normalMap = useMemo ( ( ) => {
837+ const texture = sourceNormalMap . clone ( ) ;
838+ texture . wrapS = RepeatWrapping ;
839+ texture . wrapT = RepeatWrapping ;
840+ texture . repeat . set ( 1.5 , 1.5 ) ;
841+ texture . needsUpdate = true ;
842+ return texture ;
843+ } , [ sourceNormalMap ] ) ;
844+
845+ return < DoughBody kind = { kind } tilt = { tilt } colorMap = { colorMap } normalMap = { normalMap } /> ;
846+ }
847+
848+ function DubaiDoughCore ( { tilt } : { tilt : DeviceTiltRef } ) {
849+ const { scene } = useGLTF ( DUBAI_CORE_MODEL ) ;
850+ const model = useMemo ( ( ) => {
851+ const copy = scene . clone ( true ) ;
852+ copy . traverse ( ( child ) => {
853+ const mesh = child as Mesh ;
854+ if ( ! mesh . isMesh ) return ;
855+ mesh . castShadow = true ;
856+ mesh . receiveShadow = true ;
857+ } ) ;
858+ return copy ;
859+ } , [ scene ] ) ;
860+
861+ return (
862+ < DoughBody
863+ kind = "dubai"
864+ tilt = { tilt }
865+ model = { model }
866+ colliderRadius = { 0.76 }
867+ visualRadius = { 0.86 }
868+ />
869+ ) ;
870+ }
871+
872+ function DoughCore ( { kind, tilt } : { kind : BallKind ; tilt : DeviceTiltRef } ) {
873+ if ( kind === "dubai" ) return < DubaiDoughCore tilt = { tilt } /> ;
874+ return angularKinds . has ( kind )
875+ ? < DoughBody kind = { kind } tilt = { tilt } />
876+ : < TexturedDoughCore kind = { kind } tilt = { tilt } /> ;
877+ }
878+
806879const NOTE_ASSET_VERSION = "20260813-folded-reference-2" ;
807880const NOTE_MODEL = `/models/fortune-note.glb?v=${ NOTE_ASSET_VERSION } ` ;
808881const NOTE_SCALE = 0.8 ;
@@ -924,7 +997,7 @@ function Scene({ kind, damage, broken, pulled, onDamage, onPull, tilt }: { kind:
924997 { broken ? (
925998 < >
926999 < Fragments kind = { kind } tilt = { tilt } />
927- { ! angularKinds . has ( kind ) && < JellyCore kind = { kind } tilt = { tilt } /> }
1000+ < DoughCore kind = { kind } tilt = { tilt } />
9281001 { ! pulled && < FortuneNote3D tilt = { tilt } onPull = { onPull } /> }
9291002 </ >
9301003 ) : < Ball kind = { kind } damage = { damage } onDamage = { onDamage } tilt = { tilt } /> }
@@ -967,6 +1040,7 @@ export function FortuneBall({ fortune, aside, ballKind, onReveal }: FortuneBallP
9671040 useEffect ( ( ) => {
9681041 useGLTF . preload ( modelPath ( ballKind , true ) ) ;
9691042 useGLTF . preload ( NOTE_MODEL ) ;
1043+ if ( ballKind === "dubai" ) useGLTF . preload ( DUBAI_CORE_MODEL ) ;
9701044 if ( ! angularKinds . has ( ballKind ) ) {
9711045 useTexture . preload ( `/textures/wakppu/${ ballKind } -interior.webp` ) ;
9721046 useTexture . preload ( `/textures/wakppu/${ ballKind } -interior-normal.webp` ) ;
0 commit comments