-
-
Notifications
You must be signed in to change notification settings - Fork 353
feat(voyage): add Space Voyage 3D mini-game at /voyage #958
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jhislop-design
wants to merge
1
commit into
main
Choose a base branch
from
jonny/reverent-ptolemy-1557c5
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,875
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // Main Space Voyage component for /voyage. | ||
| // A flying star-galleon explores TanStack libraries scattered across three | ||
| // altitude "dimensions" — a spacefaring cousin of the Island Explorer. | ||
|
|
||
| import { useState } from 'react' | ||
| import { VoyageScene } from './VoyageScene' | ||
| import { VoyageHUD } from './ui/VoyageHUD' | ||
| import type { VoyageEngine } from './engine/VoyageEngine' | ||
|
|
||
| const LOADING_MESSAGES = [ | ||
| ['Hoisting the solar sails...', 'Mind the cosmic wind'], | ||
| ['Charting the star lanes...', 'X marks the nebula'], | ||
| ['Waking the stardust crew...', 'They sleep in zero-g'], | ||
| ['Tuning the dimension drive...', 'High, low, and in between'], | ||
| ['Polishing the brass telescope...', 'For spotting distant worlds'], | ||
| ['Counting the constellations...', 'We lost count at infinity'], | ||
| ['Feeding the ship cat...', 'Even pirates need a navigator'], | ||
| ['Calibrating the gravity anchor...', 'Down is relative out here'], | ||
| ] | ||
|
|
||
| function LoadingOverlay() { | ||
| const [messageIndex] = useState(() => | ||
| Math.floor(Math.random() * LOADING_MESSAGES.length), | ||
| ) | ||
| const [headline, subtext] = LOADING_MESSAGES[messageIndex] | ||
|
|
||
| return ( | ||
| <div className="absolute inset-0 bg-gradient-to-b from-[#0a0820] via-[#070a1a] to-black flex items-center justify-center z-50"> | ||
| <div className="text-center"> | ||
| <div className="w-16 h-16 mx-auto mb-4 border-4 border-white/20 border-t-cyan-300 rounded-full animate-spin" /> | ||
| <p className="text-white text-lg font-medium">{headline}</p> | ||
| <p className="text-white/50 text-sm mt-2">{subtext}</p> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function SpaceVoyage() { | ||
| const [isLoading, setIsLoading] = useState(true) | ||
| const [engine, setEngine] = useState<VoyageEngine | null>(null) | ||
|
|
||
| return ( | ||
| <div className="relative w-full h-[calc(100dvh-var(--navbar-height))] bg-black overflow-hidden"> | ||
| {isLoading && <LoadingOverlay />} | ||
|
|
||
| {/* 3D scene */} | ||
| <div className="absolute inset-0"> | ||
| <VoyageScene onLoadingChange={setIsLoading} onEngineReady={setEngine} /> | ||
| </div> | ||
|
|
||
| {/* Vignette for depth */} | ||
| <div | ||
| className="absolute inset-0 pointer-events-none" | ||
| style={{ | ||
| background: | ||
| 'radial-gradient(ellipse at center, transparent 45%, rgba(0,0,10,0.55) 100%)', | ||
| }} | ||
| /> | ||
|
|
||
| {!isLoading && <VoyageHUD engine={engine} />} | ||
| </div> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { useEffect, useRef, useState } from 'react' | ||
| import { VoyageEngine } from './engine/VoyageEngine' | ||
|
|
||
| interface VoyageSceneProps { | ||
| onLoadingChange?: (loading: boolean) => void | ||
| onEngineReady?: (engine: VoyageEngine | null) => void | ||
| } | ||
|
|
||
| export function VoyageScene({ | ||
| onLoadingChange, | ||
| onEngineReady, | ||
| }: VoyageSceneProps) { | ||
| const containerRef = useRef<HTMLDivElement>(null) | ||
| const canvasRef = useRef<HTMLCanvasElement>(null) | ||
| const engineRef = useRef<VoyageEngine | null>(null) | ||
| const [isReady, setIsReady] = useState(false) | ||
|
|
||
| // Wait until the container has real dimensions before booting the engine. | ||
| useEffect(() => { | ||
| const container = containerRef.current | ||
| if (!container) return | ||
|
|
||
| const checkSize = () => { | ||
| if (container.clientWidth > 0 && container.clientHeight > 0) { | ||
| setIsReady(true) | ||
| } | ||
| } | ||
|
|
||
| checkSize() | ||
| requestAnimationFrame(checkSize) | ||
|
|
||
| const observer = new ResizeObserver(checkSize) | ||
| observer.observe(container) | ||
| return () => observer.disconnect() | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (!isReady) return | ||
| const canvas = canvasRef.current | ||
| const container = containerRef.current | ||
| if (!canvas || !container) return | ||
|
|
||
| const width = container.clientWidth | ||
| const height = container.clientHeight | ||
| const dpr = Math.min(window.devicePixelRatio, 2) | ||
| canvas.width = width * dpr | ||
| canvas.height = height * dpr | ||
| canvas.style.width = `${width}px` | ||
| canvas.style.height = `${height}px` | ||
|
|
||
| const engine = new VoyageEngine(canvas) | ||
| engineRef.current = engine | ||
|
|
||
| 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(() => { | ||
| 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]) | ||
|
|
||
| return ( | ||
| <div ref={containerRef} style={{ width: '100%', height: '100%' }}> | ||
| <canvas | ||
| ref={canvasRef} | ||
| style={{ display: 'block', background: '#04060e' }} | ||
| /> | ||
| </div> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 callsengine.start()on an already-disposed engine, plusonEngineReady(engine)/onLoadingChange(false)with a dead instance.dispose()setsdisposed = trueand stops the loop, sostart()re-entering here restarts the render loop against torn-down resources.Track cancellation and bail out in both the
thenandcatch.🛠️ 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