feat(voyage): add Space Voyage 3D mini-game at /voyage#958
feat(voyage): add Space Voyage 3D mini-game at /voyage#958jhislop-design wants to merge 1 commit into
Conversation
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>
📝 WalkthroughWalkthroughThis PR introduces a complete interactive 3D voyage experience at ChangesVoyage 3D Experience
Sequence DiagramsequenceDiagram
participant VoyagePage as Route: /voyage
participant SpaceVoyage
participant VoyageScene
participant VoyageEngine
participant useVoyageStore as Store
participant VoyageHUD
participant Input as Keyboard/Pointer
VoyagePage->>SpaceVoyage: render with Suspense
SpaceVoyage->>SpaceVoyage: useState(isLoading, engine)
SpaceVoyage->>VoyageScene: render unmounted with callbacks
VoyageScene->>VoyageEngine: new VoyageEngine(canvas)
VoyageEngine->>VoyageEngine: constructor: setup Three.js scene
VoyageScene->>VoyageEngine: init()
VoyageEngine->>VoyageEngine: load ship, build world, attach listeners
VoyageEngine->>Store: setBand(0) seed state
VoyageEngine-->>VoyageScene: init resolved
VoyageScene->>VoyageScene: onEngineReady(engine)
SpaceVoyage->>SpaceVoyage: setEngine(engine), setIsLoading(false)
SpaceVoyage->>VoyageHUD: render with engine
SpaceVoyage->>VoyageEngine: start()
loop game loop every frame
Input->>VoyageEngine: setKey(code, pressed) / changeBand(dir)
VoyageEngine->>VoyageEngine: updateShip/Camera/Planets/Enemies/Combat/Bursts
VoyageEngine->>Store: setBand/addDovered/addPirate/damageHealth/etc
VoyageEngine->>VoyageEngine: render scene
VoyageHUD->>Store: read band/nearby/health/doubloons/boss state
VoyageHUD->>VoyageHUD: render UI panels
end
Note over VoyageHUD,VoyageEngine: Player visits planet
VoyageEngine->>Store: addDiscovered(planetId)
Store->>Store: compute completed flag, increment lastCharted.tick
VoyageHUD->>VoyageHUD: charted toast appears
Note over VoyageHUD,VoyageEngine: Combat with pirate
Input->>VoyageEngine: firePlayer()
VoyageEngine->>Store: incrementPirateSunk()
VoyageHUD->>VoyageHUD: update pirates sunk counter
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
src/components/voyage/engine/VoyageEngine.ts (1)
301-342: 💤 Low valueConsider sharing the soft-circle texture between trail and burst materials.
makeSoftCircleTexture()is called twice (lines 308 and 341), creating two identical CanvasTextures. Sharing a single texture instance would reduce GPU memory usage.♻️ Suggested refactor
+ private softCircleTexture: THREE.CanvasTexture constructor(canvas: HTMLCanvasElement) { // ... earlier code ... + this.softCircleTexture = makeSoftCircleTexture() this.trailMat = new THREE.PointsMaterial({ size: 1.4, transparent: true, opacity: 0.9, vertexColors: true, depthWrite: false, blending: THREE.AdditiveBlending, - map: makeSoftCircleTexture(), + map: this.softCircleTexture, }) // ... this.burstMat = new THREE.PointsMaterial({ size: 2.4, transparent: true, opacity: 1, vertexColors: true, depthWrite: false, blending: THREE.AdditiveBlending, - map: makeSoftCircleTexture(), + map: this.softCircleTexture, })Then dispose it once in
dispose():+ this.softCircleTexture.dispose() this.burstMat.map?.dispose()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/voyage/engine/VoyageEngine.ts` around lines 301 - 342, The two calls to makeSoftCircleTexture() create duplicate CanvasTexture instances for this.trailMat and this.burstMat; instead, create a single shared texture (e.g., this.softCircleTexture = makeSoftCircleTexture()) and pass that same texture to both this.trailMat and this.burstMat; update the class dispose() method to call dispose() on this.softCircleTexture when cleaning up. Ensure references to trailMat, burstMat, makeSoftCircleTexture, and dispose() are used to locate the changes.src/components/voyage/planets.ts (2)
90-128: 💤 Low valueOptional: replace the magic
3withRING_RADII.lengthfor consistency.
radiushardcodes(j % 3)whileringRadiusalready usesj % RING_RADII.length. Keeping both tied toRING_RADII.lengthavoids divergence if the ring count changes later.♻️ Suggested tweak
- radius: 5.5 + (j % 3) * 1.6, + radius: 5.5 + (j % RING_RADII.length) * 1.6,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/voyage/planets.ts` around lines 90 - 128, In buildPlanets(), the radius computation uses a hardcoded 3 while ringRadius uses RING_RADII.length; change the radius expression from (j % 3) to (j % RING_RADII.length) so both place/ring calculations remain consistent if RING_RADII changes—update the radius line in the planets.push object accordingly (referencing buildPlanets, radius, and RING_RADII).
60-80: 💤 Low valueReduce drift by deriving
PLANET_COLORSfrom library brand colors
PLANET_COLORShardcodes per-library hex values, but each library already carries brand styling in~/librariesvia TailwindcolorFrom/colorTo.src/utils/npm-packages.tsincludesgetLibraryColor(library)to convertcolorFrom→ hex, but its Tailwind→hex map covers only somefrom-*classes and otherwise falls back todefaultColors[0], so a direct swap may not preserve current planet colors. Extend/adjust that conversion so voyage planets can use the same~/librariessource of truth instead of maintaining a parallelPLANET_COLORSmap.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/voyage/planets.ts` around lines 60 - 80, Replace the hardcoded PLANET_COLORS map with values derived from the libraries' Tailwind brand colors by calling getLibraryColor(library) (which converts colorFrom/colorTo into hex); update getLibraryColor in npm-packages (and its fallback/defaultColors logic) to include all used from-* Tailwind classes (or at least the specific classes referenced by your libraries) and ensure it returns the exact current hex values for those classes (or falls back to the original hex values currently in PLANET_COLORS) so voyage planets use the ~/libraries source of truth without visual drift.src/components/voyage/ui/VoyageHUD.tsx (1)
293-296: ⚡ Quick winOpen the "Visit" link in a new tab.
Same-tab navigation drops the player out of the in-progress voyage (charted worlds, doubloons, gauntlet state are all client-only and lost). Opening externally preserves the game.
♻️ Proposed change
<a href={nearby.url} + target="_blank" + rel="noopener noreferrer" className="absolute bottom-28 md:bottom-6 left-1/2 -translate-x-1/2 z-20 pointer-events-auto group" >🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/voyage/ui/VoyageHUD.tsx` around lines 293 - 296, The "Visit" anchor using nearby.url in VoyageHUD.tsx currently opens in the same tab and must open in a new tab to preserve in-memory voyage state; update the <a> element that references nearby.url (the anchor in the VoyageHUD component) to include target="_blank" and rel="noopener noreferrer" attributes so the link opens externally and avoids security issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/voyage/ui/VoyageHUD.tsx`:
- Around line 570-577: The two icon-only controls using TouchBtn with labels
ChevronUp and ChevronDown lack accessible names; update these TouchBtn usages to
include aria-label attributes (e.g., aria-label="Climb" for the ChevronUp button
and aria-label="Dive" for the ChevronDown button) so screen readers can announce
their purpose; you can still keep the onClick handlers calling
engine.changeBand(1) and engine.changeBand(-1) and the visual icons (ChevronUp,
ChevronDown) unchanged.
In `@src/components/voyage/VoyageScene.tsx`:
- Around line 64-82: The continuation after engine.init() must be guarded so it
doesn't act on a disposed/unmounted engine: add a cancellation flag (e.g., let
cancelled = false) or use an AbortController inside the effect and check it
before calling engine.start(), onLoadingChange(false) and onEngineReady(engine)
in the .then() and .catch() handlers; set cancelled = true (or abort) in the
cleanup before calling engine.dispose() and clearing engineRef/current and
disconnecting resizeObserver so any in-flight promise bails instead of touching
the torn-down engine instance.
---
Nitpick comments:
In `@src/components/voyage/engine/VoyageEngine.ts`:
- Around line 301-342: The two calls to makeSoftCircleTexture() create duplicate
CanvasTexture instances for this.trailMat and this.burstMat; instead, create a
single shared texture (e.g., this.softCircleTexture = makeSoftCircleTexture())
and pass that same texture to both this.trailMat and this.burstMat; update the
class dispose() method to call dispose() on this.softCircleTexture when cleaning
up. Ensure references to trailMat, burstMat, makeSoftCircleTexture, and
dispose() are used to locate the changes.
In `@src/components/voyage/planets.ts`:
- Around line 90-128: In buildPlanets(), the radius computation uses a hardcoded
3 while ringRadius uses RING_RADII.length; change the radius expression from (j
% 3) to (j % RING_RADII.length) so both place/ring calculations remain
consistent if RING_RADII changes—update the radius line in the planets.push
object accordingly (referencing buildPlanets, radius, and RING_RADII).
- Around line 60-80: Replace the hardcoded PLANET_COLORS map with values derived
from the libraries' Tailwind brand colors by calling getLibraryColor(library)
(which converts colorFrom/colorTo into hex); update getLibraryColor in
npm-packages (and its fallback/defaultColors logic) to include all used from-*
Tailwind classes (or at least the specific classes referenced by your libraries)
and ensure it returns the exact current hex values for those classes (or falls
back to the original hex values currently in PLANET_COLORS) so voyage planets
use the ~/libraries source of truth without visual drift.
In `@src/components/voyage/ui/VoyageHUD.tsx`:
- Around line 293-296: The "Visit" anchor using nearby.url in VoyageHUD.tsx
currently opens in the same tab and must open in a new tab to preserve in-memory
voyage state; update the <a> element that references nearby.url (the anchor in
the VoyageHUD component) to include target="_blank" and rel="noopener
noreferrer" attributes so the link opens externally and avoids security issues.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1e792992-d334-4164-938e-a20d13bda97b
📒 Files selected for processing (8)
src/components/voyage/SpaceVoyage.tsxsrc/components/voyage/VoyageScene.tsxsrc/components/voyage/engine/VoyageEngine.tssrc/components/voyage/planets.tssrc/components/voyage/store.tssrc/components/voyage/ui/VoyageHUD.tsxsrc/routeTree.gen.tssrc/routes/voyage.tsx
| <TouchBtn | ||
| label={<ChevronUp className="w-5 h-5" />} | ||
| onClick={() => engine.changeBand(1)} | ||
| /> | ||
| <TouchBtn | ||
| label={<ChevronDown className="w-5 h-5" />} | ||
| onClick={() => engine.changeBand(-1)} | ||
| /> |
There was a problem hiding this comment.
Add accessible names to icon-only controls.
The climb/dive buttons render only ChevronUp/ChevronDown icons with no text, so they expose no accessible name to screen readers. Add aria-labels.
♿ Proposed change
<TouchBtn
label={<ChevronUp className="w-5 h-5" />}
+ aria-label="Climb"
onClick={() => engine.changeBand(1)}
/>
<TouchBtn
label={<ChevronDown className="w-5 h-5" />}
+ aria-label="Dive"
onClick={() => engine.changeBand(-1)}
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <TouchBtn | |
| label={<ChevronUp className="w-5 h-5" />} | |
| onClick={() => engine.changeBand(1)} | |
| /> | |
| <TouchBtn | |
| label={<ChevronDown className="w-5 h-5" />} | |
| onClick={() => engine.changeBand(-1)} | |
| /> | |
| <TouchBtn | |
| label={<ChevronUp className="w-5 h-5" />} | |
| aria-label="Climb" | |
| onClick={() => engine.changeBand(1)} | |
| /> | |
| <TouchBtn | |
| label={<ChevronDown className="w-5 h-5" />} | |
| aria-label="Dive" | |
| onClick={() => engine.changeBand(-1)} | |
| /> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/voyage/ui/VoyageHUD.tsx` around lines 570 - 577, The two
icon-only controls using TouchBtn with labels ChevronUp and ChevronDown lack
accessible names; update these TouchBtn usages to include aria-label attributes
(e.g., aria-label="Climb" for the ChevronUp button and aria-label="Dive" for the
ChevronDown button) so screen readers can announce their purpose; you can still
keep the onClick handlers calling engine.changeBand(1) and engine.changeBand(-1)
and the visual icons (ChevronUp, ChevronDown) unchanged.
| engine | ||
| .init() | ||
| .then(() => { | ||
| engine.start() | ||
| onLoadingChange?.(false) | ||
| onEngineReady?.(engine) | ||
| }) | ||
| .catch((err) => { | ||
| console.error('VoyageEngine init failed:', err) | ||
| onLoadingChange?.(false) | ||
| }) | ||
|
|
||
| return () => { | ||
| resizeObserver.disconnect() | ||
| onEngineReady?.(null) | ||
| engine.dispose() | ||
| engineRef.current = null | ||
| } | ||
| }, [isReady, onLoadingChange, onEngineReady]) |
There was a problem hiding this comment.
Guard the async init() continuation against unmount/dispose.
If the component unmounts (or the effect re-runs) while init() is still pending, the cleanup at Line 76 disposes the engine, but the in-flight promise still resolves and calls engine.start() on an already-disposed engine, plus onEngineReady(engine)/onLoadingChange(false) with a dead instance. dispose() sets disposed = true and stops the loop, so start() re-entering here restarts the render loop against torn-down resources.
Track cancellation and bail out in both the then and catch.
🛠️ Proposed fix
const engine = new VoyageEngine(canvas)
engineRef.current = engine
+ let cancelled = false
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry && engineRef.current) {
const { width, height } = entry.contentRect
engineRef.current.resize(width, height)
}
})
resizeObserver.observe(container)
onLoadingChange?.(true)
engine
.init()
.then(() => {
+ if (cancelled) return
engine.start()
onLoadingChange?.(false)
onEngineReady?.(engine)
})
.catch((err) => {
console.error('VoyageEngine init failed:', err)
+ if (cancelled) return
onLoadingChange?.(false)
})
return () => {
+ cancelled = true
resizeObserver.disconnect()
onEngineReady?.(null)
engine.dispose()
engineRef.current = null
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/voyage/VoyageScene.tsx` around lines 64 - 82, The continuation
after engine.init() must be guarded so it doesn't act on a disposed/unmounted
engine: add a cancellation flag (e.g., let cancelled = false) or use an
AbortController inside the effect and check it before calling engine.start(),
onLoadingChange(false) and onEngineReady(engine) in the .then() and .catch()
handlers; set cancelled = true (or abort) in the cleanup before calling
engine.dispose() and clearing engineRef/current and disconnecting resizeObserver
so any in-flight promise bails instead of touching the torn-down engine
instance.
Overview
A fun, 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 — while fending off space pirates and, once you've charted them all, taking on an end-game boss gauntlet.Lives at
/voyage.What's included
Flight & discovery
ship.glb+modelLoader; independent of the Island Explorer game store).Combat
Rewards
End-game boss gauntlet
HUD: altitude gauge, discovery progress, hull + pirates-sunk, crosshair, nearby-world card, boss bar, victory/champion overlays, and mobile touch controls (d-pad + fire).
The route is lazy-loaded so the Three.js bundle stays out of the main chunk.
Files
src/routes/voyage.tsx— lazy-loaded route (mirrorsexplore.tsx)src/components/voyage/—SpaceVoyage(container),VoyageScene(canvas wrapper),planets.ts(data),store.ts(zustand HUD bridge),engine/VoyageEngine.ts(Three.js engine),ui/VoyageHUD.tsx(overlays)Reviewer notes
src/routeTree.gen.tsis hand-edited to register/voyage. The router generator's file watcher didn't fire in this worktree, so the entries were added manually (mirroring/explore). A normal dev/build run will regenerate the file identically sincevoyage.tsxexists.visibilitychange(intended) — backgrounded tabs don't simulate.tsc+oxlintclean; remaining lint warnings are pre-existing in unrelated files.🤖 Generated with Claude Code
Summary by CodeRabbit