Skip to content

Commit fa3771a

Browse files
committed
sheeeeesh
1 parent ea3e6e9 commit fa3771a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+8661
-169
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,4 @@ Motely.API/wwwroot/jammy-seed-finder/main.js
100100
/JamlFilters
101101
/Motely.HomeControlAPI/wwwroot
102102
Motely.HomeControlAPI/Properties/launchSettings.json
103+
/Motley.TestUI/assethelp
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useFrame, useThree } from '@react-three/fiber'
2+
import type { RapierRigidBody } from '@react-three/rapier'
3+
import { useRef, type RefObject } from 'react'
4+
import * as THREE from 'three'
5+
6+
const _quat = new THREE.Quaternion()
7+
const _behind = new THREE.Vector3()
8+
const _look = new THREE.Vector3()
9+
const _camPos = new THREE.Vector3()
10+
const _lookAt = new THREE.Vector3()
11+
12+
type Props = Readonly<{
13+
carRef: RefObject<RapierRigidBody | null>
14+
}>
15+
16+
/**
17+
* Chase cam: follows Rapier chassis with smoothed position + look-at (momentum visible on yaw).
18+
*/
19+
export function AdventureCamera({ carRef }: Props) {
20+
const { camera } = useThree()
21+
const smoothPos = useRef(new THREE.Vector3(0, 2.4, 8))
22+
const smoothLook = useRef(new THREE.Vector3(0, 0.5, -20))
23+
24+
useFrame((_, dt) => {
25+
const rb = carRef.current
26+
if (!rb) {
27+
smoothPos.current.lerp(new THREE.Vector3(0, 2.35, 9), 1 - Math.exp(-4 * dt))
28+
smoothLook.current.lerp(new THREE.Vector3(0, 0.8, -30), 1 - Math.exp(-4 * dt))
29+
camera.position.copy(smoothPos.current)
30+
camera.lookAt(smoothLook.current)
31+
return
32+
}
33+
34+
const t = rb.translation()
35+
const r = rb.rotation()
36+
_quat.set(r.x, r.y, r.z, r.w)
37+
38+
_behind.set(0, 2.05, 7.8).applyQuaternion(_quat)
39+
_look.set(0, 0.4, -16).applyQuaternion(_quat)
40+
41+
_camPos.set(t.x + _behind.x, t.y + _behind.y, t.z + _behind.z)
42+
_lookAt.set(t.x, t.y, t.z).add(_look)
43+
44+
const a = 1 - Math.exp(-5.2 * dt)
45+
const b = 1 - Math.exp(-6.5 * dt)
46+
smoothPos.current.lerp(_camPos, a)
47+
smoothLook.current.lerp(_lookAt, b)
48+
camera.position.copy(smoothPos.current)
49+
camera.lookAt(smoothLook.current)
50+
})
51+
52+
return null
53+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
'use client'
2+
3+
import { useFrame } from '@react-three/fiber'
4+
import type { RapierRigidBody } from '@react-three/rapier'
5+
import { useEffect, useRef, type MutableRefObject, type RefObject } from 'react'
6+
import { useAdventureHudStore } from './adventureHudStore'
7+
import type { DriveSnapWire } from './adventureDriveWire'
8+
import { keysToBits } from './adventureDriveWire'
9+
10+
type Keys = { w: boolean; s: boolean; a: boolean; d: boolean }
11+
12+
type Props = Readonly<{
13+
room: string
14+
seat: 'driver' | 'passenger'
15+
carRef: RefObject<RapierRigidBody | null>
16+
scrollRef: MutableRefObject<number>
17+
keysRef: MutableRefObject<Keys>
18+
}>
19+
20+
const MIN_SEND_MS = 45
21+
const MAX_GAP_MS = 650
22+
23+
function encodeSnap(rb: RapierRigidBody, scroll: number, k: Keys): DriveSnapWire {
24+
const t = rb.translation()
25+
const r = rb.rotation()
26+
const v = rb.linvel()
27+
const o = rb.angvel()
28+
return {
29+
p: [t.x, t.y, t.z],
30+
q: [r.x, r.y, r.z, r.w],
31+
v: [v.x, v.y, v.z],
32+
o: [o.x, o.y, o.z],
33+
s: scroll,
34+
k: keysToBits(k),
35+
}
36+
}
37+
38+
async function postSnap(room: string, snap: DriveSnapWire): Promise<void> {
39+
const enc = encodeURIComponent(room.trim())
40+
await fetch(`/api/room/${enc}/drive`, {
41+
method: 'POST',
42+
headers: { 'Content-Type': 'application/json' },
43+
body: JSON.stringify({ snap }),
44+
})
45+
}
46+
47+
export function AdventureDriveSync({ room, seat, carRef, scrollRef, keysRef }: Props) {
48+
const prevKeys = useRef<Keys>({ w: false, s: false, a: false, d: false })
49+
const lastSendMs = useRef(0)
50+
const lastRemoteUpdatedAt = useRef(0)
51+
52+
// Driver: send on WASD edge + heartbeat if gap too long while moving / holding keys.
53+
useFrame(() => {
54+
const r = room.trim()
55+
if (!r || seat !== 'driver') return
56+
const rb = carRef.current
57+
if (!rb) return
58+
59+
const k = keysRef.current
60+
const pk = prevKeys.current
61+
const edge = k.w !== pk.w || k.s !== pk.s || k.a !== pk.a || k.d !== pk.d
62+
pk.w = k.w
63+
pk.s = k.s
64+
pk.a = k.a
65+
pk.d = k.d
66+
67+
const lv = rb.linvel()
68+
const speedXZ = Math.hypot(lv.x, lv.z)
69+
const holding = k.w || k.s || k.a || k.d
70+
const now = performance.now()
71+
const gap = now - lastSendMs.current
72+
73+
const needHeartbeat = gap >= MAX_GAP_MS && (holding || speedXZ > 0.12)
74+
if (!edge && !needHeartbeat) return
75+
if (gap < MIN_SEND_MS && !edge) return
76+
77+
lastSendMs.current = now
78+
const snap = encodeSnap(rb, scrollRef.current, k)
79+
void postSnap(r, snap)
80+
})
81+
82+
// Passenger: poll remote snap and snap the rigid body.
83+
useEffect(() => {
84+
const r = room.trim()
85+
if (!r || seat !== 'passenger') return
86+
87+
let cancelled = false
88+
const tick = async () => {
89+
const rb = carRef.current
90+
if (!rb || cancelled) return
91+
try {
92+
const enc = encodeURIComponent(r)
93+
const res = await fetch(`/api/room/${enc}/drive`)
94+
if (!res.ok || cancelled) return
95+
const j = (await res.json()) as { updatedAt?: number; snap?: DriveSnapWire | null }
96+
if (!j.snap || cancelled) return
97+
if (j.updatedAt && j.updatedAt === lastRemoteUpdatedAt.current) return
98+
lastRemoteUpdatedAt.current = j.updatedAt ?? 0
99+
100+
const s = j.snap
101+
rb.setTranslation({ x: s.p[0], y: s.p[1], z: s.p[2] }, true)
102+
rb.setRotation({ x: s.q[0], y: s.q[1], z: s.q[2], w: s.q[3] }, true)
103+
rb.setLinvel({ x: s.v[0], y: s.v[1], z: s.v[2] }, true)
104+
rb.setAngvel({ x: s.o[0], y: s.o[1], z: s.o[2] }, true)
105+
scrollRef.current = s.s
106+
const speedXZ = Math.hypot(s.v[0], s.v[2])
107+
useAdventureHudStore.getState().setDrive(s.s, speedXZ)
108+
} catch {
109+
/* ignore transient network errors */
110+
}
111+
}
112+
113+
const id = window.setInterval(() => void tick(), 130)
114+
void tick()
115+
return () => {
116+
cancelled = true
117+
window.clearInterval(id)
118+
}
119+
}, [room, seat, carRef, scrollRef])
120+
121+
return null
122+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Environment, Stars } from '@react-three/drei'
2+
import { Physics, RigidBody, CuboidCollider } from '@react-three/rapier'
3+
import type { RapierRigidBody } from '@react-three/rapier'
4+
import { Suspense, useEffect, useRef, useState } from 'react'
5+
import { useBalatroStore } from '../balatro/store/balatroStore'
6+
import { R3FErrorBoundary } from '../components/R3FErrorBoundary'
7+
import { PHYSICS } from '../r3f.config'
8+
import { AdventureCamera } from './AdventureCamera'
9+
import { setAdventureBillboardStatus } from './adventureBillboardStatusStore'
10+
import { loadHighwayBillboardJokersMotely } from './highwayBillboardJokersMotely'
11+
import { DeliStrip } from './DeliStrip'
12+
import { InfiniteJokerBillboards } from './InfiniteJokerBillboards'
13+
import { AdventureDriveSync } from './AdventureDriveSync'
14+
import { PhysicsVehicle } from './PhysicsVehicle'
15+
import type { BalatroJokerCenter } from '../balatro/spriteAtlas/jokerRegistry'
16+
17+
/** Billboard sampling: Motely WASM ante‑1 shopQueue first, TS stream top‑up. */
18+
const BILLBOARD_DECK = 'Red'
19+
const BILLBOARD_STAKE = 'White'
20+
21+
type Keys = { w: boolean; s: boolean; a: boolean; d: boolean }
22+
23+
export type AdventureDriveSeat = 'driver' | 'passenger'
24+
25+
type AdventureSceneProps = Readonly<{
26+
driveRoom?: string
27+
driveSeat?: AdventureDriveSeat
28+
}>
29+
30+
export function AdventureScene({ driveRoom = '', driveSeat = 'driver' }: AdventureSceneProps = {}) {
31+
const carRef = useRef<RapierRigidBody>(null)
32+
const scrollRef = useRef(0)
33+
const keysRef = useRef<Keys>({ w: false, s: false, a: false, d: false })
34+
const gameSeed = useBalatroStore((s) => s.gameSeed)
35+
const [billboardJokers, setBillboardJokers] = useState<BalatroJokerCenter[] | null>(null)
36+
37+
useEffect(() => {
38+
let cancelled = false
39+
setAdventureBillboardStatus({
40+
billboardLoading: true,
41+
billboardError: null,
42+
billboardJokerCount: 0,
43+
billboardNote: null,
44+
})
45+
void loadHighwayBillboardJokersMotely(gameSeed, BILLBOARD_DECK, BILLBOARD_STAKE).then((result) => {
46+
if (cancelled) return
47+
setBillboardJokers(result.jokers.length > 0 ? result.jokers : null)
48+
setAdventureBillboardStatus({
49+
billboardLoading: false,
50+
billboardError: result.jokers.length === 0 ? (result.note ?? 'No billboard jokers.') : null,
51+
billboardJokerCount: result.jokers.length,
52+
billboardNote: result.note,
53+
})
54+
})
55+
return () => {
56+
cancelled = true
57+
}
58+
}, [gameSeed])
59+
60+
useEffect(() => {
61+
const down = (e: KeyboardEvent) => {
62+
if (e.code === 'KeyW' || e.code === 'ArrowUp') keysRef.current.w = true
63+
if (e.code === 'KeyS' || e.code === 'ArrowDown') keysRef.current.s = true
64+
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keysRef.current.a = true
65+
if (e.code === 'KeyD' || e.code === 'ArrowRight') keysRef.current.d = true
66+
}
67+
const up = (e: KeyboardEvent) => {
68+
if (e.code === 'KeyW' || e.code === 'ArrowUp') keysRef.current.w = false
69+
if (e.code === 'KeyS' || e.code === 'ArrowDown') keysRef.current.s = false
70+
if (e.code === 'KeyA' || e.code === 'ArrowLeft') keysRef.current.a = false
71+
if (e.code === 'KeyD' || e.code === 'ArrowRight') keysRef.current.d = false
72+
}
73+
window.addEventListener('keydown', down)
74+
window.addEventListener('keyup', up)
75+
return () => {
76+
window.removeEventListener('keydown', down)
77+
window.removeEventListener('keyup', up)
78+
}
79+
}, [])
80+
81+
return (
82+
<>
83+
<AdventureCamera carRef={carRef} />
84+
<color attach="background" args={['#06060d']} />
85+
<fog attach="fog" args={['#06060d', 32, 240]} />
86+
87+
<ambientLight intensity={0.35} />
88+
<directionalLight
89+
position={[18, 28, 8]}
90+
intensity={1.1}
91+
castShadow
92+
shadow-mapSize={[1024, 1024]}
93+
shadow-camera-far={120}
94+
shadow-camera-left={-40}
95+
shadow-camera-right={40}
96+
shadow-camera-top={40}
97+
shadow-camera-bottom={-40}
98+
/>
99+
100+
<Stars radius={160} depth={52} count={3500} factor={3} saturation={0} fade speed={0.4} />
101+
<Environment preset="night" />
102+
103+
<Physics gravity={PHYSICS.GRAVITY} timeStep={PHYSICS.TIME_STEP} interpolate>
104+
<RigidBody type="fixed" colliders={false} position={[0, -0.08, -140]}>
105+
<CuboidCollider args={[8, 0.2, 260]} friction={1.25} restitution={0.05} />
106+
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
107+
<planeGeometry args={[16, 520]} />
108+
<meshStandardMaterial color="#15151f" roughness={0.92} metalness={0.02} />
109+
</mesh>
110+
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.004, 0]}>
111+
<planeGeometry args={[0.12, 520]} />
112+
<meshStandardMaterial color="#f1c40f" emissive="#f1c40f" emissiveIntensity={0.15} />
113+
</mesh>
114+
</RigidBody>
115+
116+
<PhysicsVehicle
117+
ref={carRef}
118+
keysRef={keysRef}
119+
scrollRef={scrollRef}
120+
controlsEnabled={driveSeat !== 'passenger'}
121+
/>
122+
{driveRoom.trim() ? (
123+
<AdventureDriveSync
124+
room={driveRoom}
125+
seat={driveSeat}
126+
carRef={carRef}
127+
scrollRef={scrollRef}
128+
keysRef={keysRef}
129+
/>
130+
) : null}
131+
132+
<R3FErrorBoundary>
133+
<Suspense fallback={null}>
134+
<InfiniteJokerBillboards scrollRef={scrollRef} jokers={billboardJokers} />
135+
<DeliStrip />
136+
</Suspense>
137+
</R3FErrorBoundary>
138+
</Physics>
139+
</>
140+
)
141+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useTexture } from '@react-three/drei'
2+
import { useEffect, useMemo } from 'react'
3+
import * as THREE from 'three'
4+
import { BALATRO_JOKER_ATLAS, BALATRO_JOKERS } from '../balatro/spriteAtlas/jokerRegistry'
5+
import { cloneAtlasSliceForJoker, jokerPlaneSizeFromTexture } from './jokerAtlasSlice'
6+
7+
const DELI_CENTER = BALATRO_JOKERS.find((j) => j.key === 'j_loyalty_card')
8+
9+
const STORE_Z: readonly number[] = [-6, -22, -38]
10+
11+
const RIGHT_X = 6.8
12+
13+
/**
14+
* Starter shops on the right shoulder — first stretch of the highway only (analyzer stub).
15+
*/
16+
export function DeliStrip() {
17+
const baseAtlas = useTexture(BALATRO_JOKER_ATLAS.publicPath)
18+
baseAtlas.colorSpace = THREE.SRGBColorSpace
19+
baseAtlas.magFilter = THREE.NearestFilter
20+
baseAtlas.minFilter = THREE.NearestFilter
21+
22+
const { signTex, size } = useMemo(() => {
23+
if (!DELI_CENTER) return { signTex: null as THREE.Texture | null, size: { w: 2, h: 2.8 } }
24+
const signTex = cloneAtlasSliceForJoker(baseAtlas, DELI_CENTER)
25+
const size = jokerPlaneSizeFromTexture(signTex)
26+
return { signTex, size }
27+
}, [baseAtlas])
28+
29+
useEffect(() => {
30+
return () => {
31+
signTex?.dispose()
32+
}
33+
}, [signTex])
34+
35+
if (!DELI_CENTER || !signTex) return null
36+
37+
return (
38+
<group>
39+
{STORE_Z.map((z) => (
40+
<group key={z} position={[RIGHT_X, 0, z]}>
41+
<mesh position={[0, 1.1, 0]} castShadow receiveShadow>
42+
<boxGeometry args={[4.2, 2.2, 3.2]} />
43+
<meshStandardMaterial color="#3d3548" roughness={0.88} metalness={0.08} />
44+
</mesh>
45+
<mesh position={[-2.18, 1.35, 0]} rotation={[0, -Math.PI / 2, 0]}>
46+
<planeGeometry args={[size.w * 0.85, size.h * 0.85]} />
47+
<meshStandardMaterial map={signTex} transparent roughness={0.75} />
48+
</mesh>
49+
<mesh position={[0, 2.35, 1.62]}>
50+
<boxGeometry args={[3.6, 0.45, 0.12]} />
51+
<meshStandardMaterial
52+
color="#c0392b"
53+
emissive="#e74c3c"
54+
emissiveIntensity={0.35}
55+
roughness={0.6}
56+
/>
57+
</mesh>
58+
</group>
59+
))}
60+
</group>
61+
)
62+
}

0 commit comments

Comments
 (0)