Skip to content

Commit 26c416f

Browse files
feat(voyage): add Space Voyage 3D mini-game at /voyage
A spacefaring cousin of /explore: captain a flying star-galleon through three altitude "dimensions" (low/mid/high orbit) to chart every TanStack library as a glowing planet — go high, go low. What's included: - Self-contained vanilla Three.js engine (reuses the existing ship.glb + modelLoader; independent of the Island Explorer game store): starfield, nebula backdrop, flying ship with banking + stardust trail, chase camera, layered altitude bands, and click-to-visit planet raycasting. - Combat: forward-firing cannons (hold Space), pirate enemy ships with patrol/pursue/fire AI, projectiles, player hull + damage + shipwreck/ respawn with grace, gentle hull regen. - Rewards: per-world firework + toast + doubloons, and a "Voyage Complete" victory screen once all worlds are charted. - End-game boss gauntlet: three escalating bosses (Bronze/Silver/Gold) that hunt you across dimensions, with a boss health bar, escorts, doubloon bounties, and a Grand Champion finale. - HUD: altitude gauge, discovery progress, hull + pirates-sunk, crosshair, nearby-world card, and mobile touch controls. Lazy-loaded route keeps the Three.js bundle out of the main chunk. Note: src/routeTree.gen.ts was hand-edited to register /voyage (the router generator's watcher didn't fire in this worktree); a normal dev/build run will regenerate it identically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4363ad0 commit 26c416f

8 files changed

Lines changed: 2875 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Main Space Voyage component for /voyage.
2+
// A flying star-galleon explores TanStack libraries scattered across three
3+
// altitude "dimensions" — a spacefaring cousin of the Island Explorer.
4+
5+
import { useState } from 'react'
6+
import { VoyageScene } from './VoyageScene'
7+
import { VoyageHUD } from './ui/VoyageHUD'
8+
import type { VoyageEngine } from './engine/VoyageEngine'
9+
10+
const LOADING_MESSAGES = [
11+
['Hoisting the solar sails...', 'Mind the cosmic wind'],
12+
['Charting the star lanes...', 'X marks the nebula'],
13+
['Waking the stardust crew...', 'They sleep in zero-g'],
14+
['Tuning the dimension drive...', 'High, low, and in between'],
15+
['Polishing the brass telescope...', 'For spotting distant worlds'],
16+
['Counting the constellations...', 'We lost count at infinity'],
17+
['Feeding the ship cat...', 'Even pirates need a navigator'],
18+
['Calibrating the gravity anchor...', 'Down is relative out here'],
19+
]
20+
21+
function LoadingOverlay() {
22+
const [messageIndex] = useState(() =>
23+
Math.floor(Math.random() * LOADING_MESSAGES.length),
24+
)
25+
const [headline, subtext] = LOADING_MESSAGES[messageIndex]
26+
27+
return (
28+
<div className="absolute inset-0 bg-gradient-to-b from-[#0a0820] via-[#070a1a] to-black flex items-center justify-center z-50">
29+
<div className="text-center">
30+
<div className="w-16 h-16 mx-auto mb-4 border-4 border-white/20 border-t-cyan-300 rounded-full animate-spin" />
31+
<p className="text-white text-lg font-medium">{headline}</p>
32+
<p className="text-white/50 text-sm mt-2">{subtext}</p>
33+
</div>
34+
</div>
35+
)
36+
}
37+
38+
export default function SpaceVoyage() {
39+
const [isLoading, setIsLoading] = useState(true)
40+
const [engine, setEngine] = useState<VoyageEngine | null>(null)
41+
42+
return (
43+
<div className="relative w-full h-[calc(100dvh-var(--navbar-height))] bg-black overflow-hidden">
44+
{isLoading && <LoadingOverlay />}
45+
46+
{/* 3D scene */}
47+
<div className="absolute inset-0">
48+
<VoyageScene onLoadingChange={setIsLoading} onEngineReady={setEngine} />
49+
</div>
50+
51+
{/* Vignette for depth */}
52+
<div
53+
className="absolute inset-0 pointer-events-none"
54+
style={{
55+
background:
56+
'radial-gradient(ellipse at center, transparent 45%, rgba(0,0,10,0.55) 100%)',
57+
}}
58+
/>
59+
60+
{!isLoading && <VoyageHUD engine={engine} />}
61+
</div>
62+
)
63+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { VoyageEngine } from './engine/VoyageEngine'
3+
4+
interface VoyageSceneProps {
5+
onLoadingChange?: (loading: boolean) => void
6+
onEngineReady?: (engine: VoyageEngine | null) => void
7+
}
8+
9+
export function VoyageScene({
10+
onLoadingChange,
11+
onEngineReady,
12+
}: VoyageSceneProps) {
13+
const containerRef = useRef<HTMLDivElement>(null)
14+
const canvasRef = useRef<HTMLCanvasElement>(null)
15+
const engineRef = useRef<VoyageEngine | null>(null)
16+
const [isReady, setIsReady] = useState(false)
17+
18+
// Wait until the container has real dimensions before booting the engine.
19+
useEffect(() => {
20+
const container = containerRef.current
21+
if (!container) return
22+
23+
const checkSize = () => {
24+
if (container.clientWidth > 0 && container.clientHeight > 0) {
25+
setIsReady(true)
26+
}
27+
}
28+
29+
checkSize()
30+
requestAnimationFrame(checkSize)
31+
32+
const observer = new ResizeObserver(checkSize)
33+
observer.observe(container)
34+
return () => observer.disconnect()
35+
}, [])
36+
37+
useEffect(() => {
38+
if (!isReady) return
39+
const canvas = canvasRef.current
40+
const container = containerRef.current
41+
if (!canvas || !container) return
42+
43+
const width = container.clientWidth
44+
const height = container.clientHeight
45+
const dpr = Math.min(window.devicePixelRatio, 2)
46+
canvas.width = width * dpr
47+
canvas.height = height * dpr
48+
canvas.style.width = `${width}px`
49+
canvas.style.height = `${height}px`
50+
51+
const engine = new VoyageEngine(canvas)
52+
engineRef.current = engine
53+
54+
const resizeObserver = new ResizeObserver((entries) => {
55+
const entry = entries[0]
56+
if (entry && engineRef.current) {
57+
const { width, height } = entry.contentRect
58+
engineRef.current.resize(width, height)
59+
}
60+
})
61+
resizeObserver.observe(container)
62+
63+
onLoadingChange?.(true)
64+
engine
65+
.init()
66+
.then(() => {
67+
engine.start()
68+
onLoadingChange?.(false)
69+
onEngineReady?.(engine)
70+
})
71+
.catch((err) => {
72+
console.error('VoyageEngine init failed:', err)
73+
onLoadingChange?.(false)
74+
})
75+
76+
return () => {
77+
resizeObserver.disconnect()
78+
onEngineReady?.(null)
79+
engine.dispose()
80+
engineRef.current = null
81+
}
82+
}, [isReady, onLoadingChange, onEngineReady])
83+
84+
return (
85+
<div ref={containerRef} style={{ width: '100%', height: '100%' }}>
86+
<canvas
87+
ref={canvasRef}
88+
style={{ display: 'block', background: '#04060e' }}
89+
/>
90+
</div>
91+
)
92+
}

0 commit comments

Comments
 (0)