Skip to content

Commit af1bedb

Browse files
Merge remote-tracking branch 'origin/main' into stevechan
2 parents 225482d + e0fc236 commit af1bedb

7 files changed

Lines changed: 427 additions & 92 deletions

File tree

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,20 @@ yarn-error.log*
4848
/outputs/
4949
/work/
5050
/tmp/
51+
/.codex_tmp_*/
52+
*.pid
5153

5254
# Local editor and OS files
5355
/.idea/
5456
/.vscode/
57+
/.vs/
5558
.DS_Store
5659
Thumbs.db
5760
Desktop.ini
5861
*.swp
5962
*.swo
6063
*.pem
61-
*.blend1
64+
65+
# Blender backup and unsaved scratch files
66+
*.blend[0-9]
67+
**/Untitled.blend

app/fortune-ball.tsx

Lines changed: 165 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Canvas, useFrame, type ThreeEvent } from "@react-three/fiber";
77
import { BallCollider, CuboidCollider, Physics, RigidBody, type RapierRigidBody } from "@react-three/rapier";
88
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
99
import { 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

1212
export 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
122122
const GRAVITY = -9.4;
123123
// 손가락으로 밀었을 때 방향 전환이 바로 느껴지도록 회전 임펄스를 넉넉히 준다.
124124
const 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

127128
function 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+
806879
const NOTE_ASSET_VERSION = "20260813-folded-reference-2";
807880
const NOTE_MODEL = `/models/fortune-note.glb?v=${NOTE_ASSET_VERSION}`;
808881
const 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`);

artifacts/dubai-wakppu.blend

294 KB
Binary file not shown.
-199 KB
Binary file not shown.
117 KB
Binary file not shown.

public/models/wakppu/dubai.glb

-53.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)