feat(frontend): LanderCanvas + App wiring cursor and state to WebSocket
This commit is contained in:
@@ -1,3 +1,34 @@
|
|||||||
export function App() {
|
import { useGameSocket } from './hooks/useGameSocket';
|
||||||
return <div style={{ padding: 20 }}>Frontend placeholder</div>;
|
import { LanderCanvas } from './LanderCanvas';
|
||||||
|
|
||||||
|
// Vite dev server proxies `/ws` → `ws://localhost:5100`. In prod the backend
|
||||||
|
// serves the built static files, so same-origin works there too.
|
||||||
|
function buildWsUrl(): string {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/ws/game`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { connected, state, sendCursor } = useGameSocket(buildWsUrl());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanderCanvas state={state} onCursor={sendCursor} />
|
||||||
|
<StatusBar connected={connected} step={state?.step ?? 0} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBarProps { connected: boolean; step: number; }
|
||||||
|
function StatusBar({ connected, step }: StatusBarProps) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 8, left: 8, padding: '4px 10px',
|
||||||
|
background: 'rgba(0,0,0,0.5)', borderRadius: 4, fontSize: 12,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: connected ? '#5f5' : '#f55' }}>●</span>{' '}
|
||||||
|
{connected ? 'connected' : 'disconnected'} · step {step}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
Frontend/src/LanderCanvas.tsx
Normal file
52
Frontend/src/LanderCanvas.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { StateFrame } from './protocol';
|
||||||
|
import { renderFrame } from './render';
|
||||||
|
import { useAnimationLoop } from './hooks/useAnimationLoop';
|
||||||
|
import { useCursorSender } from './hooks/useCursorSender';
|
||||||
|
|
||||||
|
export interface LanderCanvasProps {
|
||||||
|
state: StateFrame | null;
|
||||||
|
onCursor: (x: number, y: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-viewport canvas. Redraws every animation frame reading the latest
|
||||||
|
* `state` prop (no interpolation). Mouse movement is captured on the canvas
|
||||||
|
* and forwarded through `onCursor`.
|
||||||
|
*/
|
||||||
|
export function LanderCanvas({ state, onCursor }: LanderCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const stateRef = useRef<StateFrame | null>(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
// Keep the canvas's backing store in sync with its CSS size + devicePixelRatio.
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio ?? 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.floor(rect.width * dpr);
|
||||||
|
canvas.height = Math.floor(rect.height * dpr);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
return () => window.removeEventListener('resize', resize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useAnimationLoop(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
renderFrame(canvas, stateRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
useCursorSender(canvasRef as React.RefObject<HTMLElement>, onCursor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ display: 'block', width: '100vw', height: '100vh', cursor: 'crosshair' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
Frontend/src/hooks/useAnimationLoop.ts
Normal file
17
Frontend/src/hooks/useAnimationLoop.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/** Calls `callback` once per browser frame. Runs while mounted. */
|
||||||
|
export function useAnimationLoop(callback: (now: number) => void) {
|
||||||
|
const cbRef = useRef(callback);
|
||||||
|
cbRef.current = callback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let rafId = 0;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
cbRef.current(now);
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user