From 6fa74f69322eea6f80363ff9115b12bf358a6839 Mon Sep 17 00:00:00 2001 From: Shane Garrity Date: Wed, 7 Jan 2026 02:28:50 -0500 Subject: [PATCH 1/5] fix(client): avoid unnecessary rerenders with use-latest-request --- client/src/App/components/Home.tsx | 5 +- client/src/App/components/Worlds.tsx | 5 +- .../src/App/hooks/use-latest-request.test.tsx | 45 +++++++--- client/src/App/hooks/use-latest-request.ts | 85 ++++++++++--------- 4 files changed, 83 insertions(+), 57 deletions(-) diff --git a/client/src/App/components/Home.tsx b/client/src/App/components/Home.tsx index e42db354..f2765424 100644 --- a/client/src/App/components/Home.tsx +++ b/client/src/App/components/Home.tsx @@ -21,8 +21,9 @@ const getWelcomeMessage = (guildUser: APIGuildMember) => { * The home page */ export const Home = () => { - const useAuthorizedGuildMemberData = useLatestRequest('/api/v1/authorized'); - const { data: guildUser, error } = useAuthorizedGuildMemberData(); + const { data: guildUser, error } = useLatestRequest({ + url: '/api/v1/authorized', + }); if (guildUser) { return ( diff --git a/client/src/App/components/Worlds.tsx b/client/src/App/components/Worlds.tsx index 40e3c464..b2168d89 100644 --- a/client/src/App/components/Worlds.tsx +++ b/client/src/App/components/Worlds.tsx @@ -17,8 +17,9 @@ const WorldContext = createContext(null); export const useWorldContext = () => useContext(WorldContext); export const Worlds = () => { - const useWrapped = useLatestRequest('/api/v1/worlds'); - const { data: worlds } = useWrapped(); + const { data: worlds } = useLatestRequest({ + url: '/api/v1/worlds', + }); return ( diff --git a/client/src/App/hooks/use-latest-request.test.tsx b/client/src/App/hooks/use-latest-request.test.tsx index f35df964..119d4da7 100644 --- a/client/src/App/hooks/use-latest-request.test.tsx +++ b/client/src/App/hooks/use-latest-request.test.tsx @@ -24,8 +24,11 @@ describe('useRequest', () => { it('wraps successful requests', async () => { (axios as jest.Mocked).request.mockResolvedValue({ data: 'data!' }); - const useSuccessfulRequest = useLatestRequest('/api/v1/success'); - const { result } = renderHook(useSuccessfulRequest); + const { result } = renderHook(useLatestRequest, { + initialProps: { + url: '/api/v1/success', + }, + }); await act(async () => { await jest.runAllTimersAsync(); }); expect(result.current.isLoading).toBe(false); @@ -36,10 +39,14 @@ describe('useRequest', () => { it('wraps requests with configurable options', async () => { (axios as jest.Mocked).request.mockResolvedValue({ data: 'data!' }); - const useCustomizableRequest = useLatestRequest('/api/v1/customizable', { - baseURL: 'https://www.trshcmpctr.com', + renderHook(useLatestRequest, { + initialProps: { + config: { + baseURL: 'https://www.trshcmpctr.com', + }, + url: '/api/v1/customizable', + }, }); - renderHook(useCustomizableRequest); await act(async () => { await jest.runAllTimersAsync(); }); expect(axios.request).toHaveBeenCalledTimes(1); @@ -53,8 +60,11 @@ describe('useRequest', () => { Error('request-error') ); - const useFailedRequest = useLatestRequest('/api/v1/failed'); - const { result } = renderHook(useFailedRequest); + const { result } = renderHook(useLatestRequest, { + initialProps: { + url: '/api/v1/failed', + }, + }); await act(async () => { await jest.runAllTimersAsync(); }); expect(result.current.isLoading).toBe(false); @@ -71,8 +81,11 @@ describe('useRequest', () => { ); (axios as jest.Mocked).isCancel.mockReturnValue(true); - const useCanceledRequest = useLatestRequest('/api/v1/canceled'); - const { result } = renderHook(useCanceledRequest); + const { result } = renderHook(useLatestRequest, { + initialProps: { + url: '/api/v1/canceled', + }, + }); await act(async () => { await jest.runAllTimersAsync(); }); // The host component isn't done loading when a stale request is canceled @@ -93,8 +106,11 @@ describe('useRequest', () => { }) ); - const usePendingRequest = useLatestRequest('/api/v1/pending'); - const { result } = renderHook(usePendingRequest); + const { result } = renderHook(useLatestRequest, { + initialProps: { + url: '/api/v1/pending', + }, + }); await act(async () => { await jest.advanceTimersByTimeAsync(mockedRequestTimeout - 1); }); expect(result.current.isLoading).toBe(true); @@ -114,8 +130,11 @@ describe('useRequest', () => { new Promise(jest.fn()) ); - const usePendingRequest = useLatestRequest('/api/v1/pending'); - const { result, unmount } = renderHook(usePendingRequest); + const { result, unmount } = renderHook(useLatestRequest, { + initialProps: { + url: '/api/v1/pending', + }, + }); await act(async () => { await jest.runAllTimersAsync(); }); expect(result.current.isLoading).toBe(true); diff --git a/client/src/App/hooks/use-latest-request.ts b/client/src/App/hooks/use-latest-request.ts index 0f4018a2..c1300c92 100644 --- a/client/src/App/hooks/use-latest-request.ts +++ b/client/src/App/hooks/use-latest-request.ts @@ -2,9 +2,14 @@ import axios, { AxiosResponse, isCancel, type AxiosRequestConfig } from 'axios'; import { useEffect, useState } from 'react'; interface UseLatestRequestReturnType { - data: T | null, - error: Error | null, - isLoading: boolean, + data: T | null; + error: Error | null; + isLoading: boolean; +} + +interface UseLatestRequestProps { + config?: AxiosRequestConfig; + url: string; } /** @@ -14,48 +19,48 @@ interface UseLatestRequestReturnType { * @param config Request configuration * @returns A hook for wrapping network requests in a standard interface */ -export const useLatestRequest = (url: string, config?: AxiosRequestConfig) => { - const useRequestWithCancel = (): UseLatestRequestReturnType => { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); +export const useLatestRequest = ({ + config, + url, +}: UseLatestRequestProps): UseLatestRequestReturnType => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - const controller = new AbortController(); + useEffect(() => { + const controller = new AbortController(); - if (data === null && error === null) { - axios.request({ - signal: controller.signal, - url, - ...(config ?? {}) + if (data === null && error === null) { + axios.request({ + signal: controller.signal, + url, + ...(config ?? {}) + }) + .then((response: AxiosResponse) => { + setData(response.data); + setIsLoading(false); }) - .then((response: AxiosResponse) => { - setData(response.data); + .catch((error: unknown) => { + // Aborting a request throws an error that should be ignored + // https://axios-http.com/docs/cancellation + // Canceled, stale requests do not change the loading state + // because either: + // 1. the hook was unmounted before the request completed; or + // 2. a more recent request is now in-flight + if (!isCancel(error)) { + setError(error as Error); setIsLoading(false); - }) - .catch((error: unknown) => { - // Aborting a request throws an error that should be ignored - // https://axios-http.com/docs/cancellation - // Canceled, stale requests do not change the loading state - // because either: - // 1. the hook was unmounted before the request completed; or - // 2. a more recent request is now in-flight - if (!isCancel(error)) { - setError(error as Error); - setIsLoading(false); - } - }); - } + } + }); + } - // Cancel stale in-flight requests when cleaned up - return () => { controller.abort(); }; - }, [data, error]); + // Cancel stale in-flight requests when cleaned up + return () => { controller.abort(); }; + }, [config, data, error, url]); - return { - data, - error, - isLoading, - }; + return { + data, + error, + isLoading, }; - return useRequestWithCancel; }; From b0c310f725d0a0d7e64efa0795dc76529083b296 Mon Sep 17 00:00:00 2001 From: Shane Garrity Date: Wed, 7 Jan 2026 02:31:46 -0500 Subject: [PATCH 2/5] build(client): add react-compiler --- client/babel.config.js | 8 ++++++++ client/package.json | 1 + common/config/rush/pnpm-lock.yaml | 10 ++++++++++ common/config/rush/repo-state.json | 2 +- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/client/babel.config.js b/client/babel.config.js index 8e0ee1e6..d2707ca4 100644 --- a/client/babel.config.js +++ b/client/babel.config.js @@ -18,6 +18,14 @@ export default { 'node_modules', ], + plugins: [ + ['babel-plugin-react-compiler', + { + panicThreshold: 'all_errors', + } + ], + ], + presets: [ [ '@babel/preset-env', { diff --git a/client/package.json b/client/package.json index b3f1fe0e..b3919c5a 100644 --- a/client/package.json +++ b/client/package.json @@ -75,6 +75,7 @@ "@types/react-dom": "~19.2.3", "babel-jest": "~30.2.0", "babel-loader": "~10.0.0", + "babel-plugin-react-compiler": "~1.0.0", "css-loader": "~7.1.1", "cypress": "13.17.0", "discord-api-types": "~0.38.33", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index cbe49bb4..01e1606f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: babel-loader: specifier: ~10.0.0 version: 10.0.0(@babel/core@7.28.5)(webpack@5.104.1) + babel-plugin-react-compiler: + specifier: ~1.0.0 + version: 1.0.0 css-loader: specifier: ~7.1.1 version: 7.1.2(webpack@5.104.1) @@ -2688,6 +2691,9 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + babel-preset-current-node-syntax@1.2.0: resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} peerDependencies: @@ -9683,6 +9689,10 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.28.5 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index da8d6b5f..13317ba6 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "334289700f4c127ac18ad0c64050dd2d464ee0be", + "pnpmShrinkwrapHash": "7be252d8325f52129a824d88bd4cc79f2646879c", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } From 3bb3adff9b918f4a61ed82d5efae86cdc4225e0c Mon Sep 17 00:00:00 2001 From: Shane Garrity Date: Wed, 7 Jan 2026 02:32:36 -0500 Subject: [PATCH 3/5] build(components): add react-compiler --- common/config/rush/pnpm-lock.yaml | 3 +++ common/config/rush/repo-state.json | 2 +- components/babel.config.js | 8 ++++++++ components/package.json | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 01e1606f..f0074b45 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: '@types/react': specifier: ~19.2.5 version: 19.2.7 + babel-plugin-react-compiler: + specifier: ~1.0.0 + version: 1.0.0 eslint: specifier: ~9.39.1 version: 9.39.2 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 13317ba6..f06cc099 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "7be252d8325f52129a824d88bd4cc79f2646879c", + "pnpmShrinkwrapHash": "f6102caf4c392f8190e0e7f30762d301e76402e9", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/components/babel.config.js b/components/babel.config.js index 84049695..f2ebc77c 100644 --- a/components/babel.config.js +++ b/components/babel.config.js @@ -4,6 +4,14 @@ export default { './src', ], + plugins: [ + ['babel-plugin-react-compiler', + { + panicThreshold: 'all_errors', + } + ], + ], + presets: [ [ '@babel/preset-env', { diff --git a/components/package.json b/components/package.json index 6551c3b1..9d880ef7 100644 --- a/components/package.json +++ b/components/package.json @@ -40,6 +40,7 @@ "@trshcmpctr/markdownlint-config": "workspace:*", "@trshcmpctr/typescript": "workspace:*", "@types/react": "~19.2.5", + "babel-plugin-react-compiler": "~1.0.0", "eslint": "~9.39.1", "globals": "~16.5.0", "markdownlint-cli2": "~0.20.0", From e749d4c08e7ee2be140529ea54a52d4f7ee5ba8a Mon Sep 17 00:00:00 2001 From: Shane Garrity Date: Tue, 13 Jan 2026 01:38:36 -0500 Subject: [PATCH 4/5] feat(client): wrap entire app in strict mode --- client/src/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/index.ts b/client/src/index.ts index 4b0e37ef..a4b46a54 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react'; +import { createElement, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App/App'; @@ -14,4 +14,7 @@ if (!container) { } const root = createRoot(container); -root.render(createElement(App)); +const StrictApp = createElement(StrictMode, { + children: createElement(App), +}); +root.render(StrictApp); From 8f0ba847ffeed3566b1de8418cd1d9d70afb0c81 Mon Sep 17 00:00:00 2001 From: Shane Garrity Date: Tue, 13 Jan 2026 01:40:09 -0500 Subject: [PATCH 5/5] feat(client): wip, replace voxcss with html canvas --- client/package.json | 1 - .../src/App/components/Game/CanvasScene.tsx | 176 +++++++++++++++++ client/src/App/components/Game/Game.tsx | 177 +++++++++++------- .../src/App/components/Game/KeyPad.test.tsx | 4 +- client/src/App/components/Game/KeyPad.tsx | 7 +- .../src/App/components/Game/game-context.ts | 2 +- .../Game/use-animation-frame-manager.test.ts | 67 +++++++ .../Game/use-animation-frame-manager.ts | 81 ++++++++ .../App/components/Game/use-game-loop.test.ts | 64 +------ .../src/App/components/Game/use-game-loop.ts | 32 ++-- .../components/Game/use-key-presses.test.ts | 6 + .../App/components/Game/use-key-presses.ts | 16 +- common/config/rush/pnpm-lock.yaml | 25 --- common/config/rush/repo-state.json | 2 +- 14 files changed, 479 insertions(+), 181 deletions(-) create mode 100644 client/src/App/components/Game/CanvasScene.tsx create mode 100644 client/src/App/components/Game/use-animation-frame-manager.test.ts create mode 100644 client/src/App/components/Game/use-animation-frame-manager.ts diff --git a/client/package.json b/client/package.json index b3919c5a..d93a24c6 100644 --- a/client/package.json +++ b/client/package.json @@ -41,7 +41,6 @@ "watch": "webpack --watch" }, "dependencies": { - "@layoutit/voxcss": "~0.1.8", "@trshcmpctr/components": "workspace:*", "axios": "~1.13.2", "core-js": "~3.47.0", diff --git a/client/src/App/components/Game/CanvasScene.tsx b/client/src/App/components/Game/CanvasScene.tsx new file mode 100644 index 00000000..704e8520 --- /dev/null +++ b/client/src/App/components/Game/CanvasScene.tsx @@ -0,0 +1,176 @@ +import { RefObject, useEffect, useRef } from 'react'; + +import { useAnimationFrameManager } from './use-animation-frame-manager'; + +const defaultResolutionScale = 45; + +const defaultHeight = defaultResolutionScale * 9; +const defaultWidth = defaultResolutionScale * 16; + +interface Point2D { + x: number; + y: number; +} + +interface Point3D extends Point2D { + z: number; +} + +/** + * Draw a square at a point on the screen + * @param ctx Canvas 2D rendering context + * @param point Center point of the square + * @param size Size in pixels of the square + */ +const drawPoint = (ctx: CanvasRenderingContext2D, point: Point2D, size: number) => { + if (size <= 0) { + throw new Error(`Problem drawing point with size=${String(size)}`); + } + ctx.fillStyle = 'lime'; + ctx.fillRect(point.x - size/2, point.y - size/2, size, size); +}; + +/** + * Transform projection coordinates for canvas + * @param point Projection point coordinates + * @returns Canvas point coordinates + */ +const convertPoint = (point: Point2D) => { + return { + x: (point.x + 1)/2 * defaultWidth, + y: (1 - (point.y + 1)/2) * defaultHeight, + }; +}; + +/** + * Project a 3D point onto a 2D screen + * @param point Point in 3D space + * @returns Point in 2D space + */ +const projectPoint = (point: Point3D): Point2D | null => { + if (point.z > 0) { + return { + x: point.x/point.z, + y: point.y/point.z, + }; + } + return null; +}; + +const drawBackground = (ctx: CanvasRenderingContext2D) => { + ctx.fillStyle = '#404040'; + ctx.beginPath(); + ctx.fillRect(0, 0, defaultWidth, defaultHeight); + ctx.closePath(); +}; + +const drawCube = (ctx: CanvasRenderingContext2D, playerPosition: Point3D) => { + const cubePoints = [ + // Far, top left + { ...playerPosition, x: playerPosition.x - 1, y: playerPosition.y + 1, z: playerPosition.z + 1 }, + // Far, top right + { ...playerPosition, x: playerPosition.x + 1, y: playerPosition.y + 1, z: playerPosition.z + 1 }, + // Far, bottom right + { ...playerPosition, x: playerPosition.x + 1, y: playerPosition.y - 1, z: playerPosition.z + 1 }, + // Far, bottom left + { ...playerPosition, x: playerPosition.x - 1, y: playerPosition.y - 1, z: playerPosition.z + 1 }, + // Near, top left + { ...playerPosition, x: playerPosition.x - 1, y: playerPosition.y + 1, z: playerPosition.z - 1 }, + // Near, top right + { ...playerPosition, x: playerPosition.x + 1, y: playerPosition.y + 1, z: playerPosition.z - 1 }, + // Near, bottom right + { ...playerPosition, x: playerPosition.x + 1, y: playerPosition.y - 1, z: playerPosition.z - 1 }, + // Near, bottom left + { ...playerPosition, x: playerPosition.x - 1, y: playerPosition.y - 1, z: playerPosition.z - 1 }, + ]; + + const cubeEdges = [ + // Far face + [0, 1, 2, 3], + // Near face + [4, 5, 6, 7], + // Connect far and near + [0, 4], + [1, 5], + [2, 6], + [3, 7], + ]; + + cubePoints + .map(p => projectPoint(p)) + .filter(p => p !== null) + .map(p => convertPoint(p)) + .forEach(p => { + drawPoint(ctx, p, 5); + }); + + cubeEdges + .map(f => { + f.forEach(i => { + const p1In3D = cubePoints[i]; + const p2In3D = cubePoints[f[(i + 1) % f.length]]; + const p1Projected = projectPoint(p1In3D); + const p2Projected = projectPoint(p2In3D); + if (p1Projected === null || p2Projected === null) { + return; + } + const p1Canvas = convertPoint(p1Projected); + const p2Canvas = convertPoint(p2Projected); + ctx.beginPath(); + ctx.strokeStyle = 'lime'; + ctx.moveTo(p1Canvas.x, p1Canvas.y); + ctx.lineTo(p2Canvas.x, p2Canvas.y); + ctx.stroke(); + ctx.closePath(); + }); + }); +}; + +interface CanvasSceneProps { + height?: number; + playerPosition: RefObject; + width?: number; +} + +export const CanvasScene = ({ + height = defaultHeight, + playerPosition, + width = defaultWidth, +}: CanvasSceneProps) => { + const canvas = useRef(null); + const ctx = useRef(null); + const { addTask, removeTask, start, stop } = useAnimationFrameManager({ targetFps: 60 }); + + useEffect(() => { + if (!canvas.current) { + return; + } + ctx.current = canvas.current.getContext('2d'); + + const renderTask = () => { + if (!ctx.current) { + throw new Error('Missing canvas rendering context'); + } + ctx.current.clearRect(0, 0, defaultWidth, defaultHeight); + drawBackground(ctx.current); + drawCube(ctx.current, playerPosition.current); + }; + + addTask(renderTask); + start(); + + return () => { + stop(); + removeTask(renderTask); + }; + }, [addTask, playerPosition, removeTask, start, stop]); + + return ( + + ); +}; diff --git a/client/src/App/components/Game/Game.tsx b/client/src/App/components/Game/Game.tsx index 456977d6..ed954808 100644 --- a/client/src/App/components/Game/Game.tsx +++ b/client/src/App/components/Game/Game.tsx @@ -1,21 +1,15 @@ -import { VoxCamera, VoxScene } from '@layoutit/voxcss/react'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { Flex } from '@trshcmpctr/components'; import { Nav } from '../Nav'; +import { CanvasScene } from './CanvasScene'; import { GameContextProvider } from './game-context'; import { KeyPad } from './KeyPad'; import { PlayPause } from './PlayPause'; import { useGameLoop } from './use-game-loop'; import { useKeyPresses } from './use-key-presses'; -const Time = ({ time }: { time: number }) => { - return ( -

t: {time}

- ); -}; - /** * TODO: map event key value to alt display label */ @@ -25,66 +19,114 @@ const keyboardControls = [ [' '], ]; -const onTick = () => { /* empty */ }; +const initialPlayerPosition = { + x: 0, + y: 0, + z: 2, +}; export const Game = () => { + const playerPosition = useRef({ + ...initialPlayerPosition, + }); + + const tickPlayerState = useRef<{ + xMotion: 'left' | 'right' | 'neutral'; + yMotion: 'forward' | 'backward' | 'neutral'; + }>({ + xMotion: 'neutral', + yMotion: 'neutral', + }); + + const onKeyDown = (keyName: string) => { + if (keyName === 'w') { + if (tickPlayerState.current.yMotion === 'neutral') { + tickPlayerState.current.yMotion = 'forward'; + } else if (tickPlayerState.current.yMotion === 'backward') { + tickPlayerState.current.yMotion = 'neutral'; + } + return; + } + if (keyName === 's') { + if (tickPlayerState.current.yMotion === 'neutral') { + tickPlayerState.current.yMotion = 'backward'; + } else if (tickPlayerState.current.yMotion === 'forward') { + tickPlayerState.current.yMotion = 'neutral'; + } + return; + } + if (keyName === 'a') { + if (tickPlayerState.current.xMotion === 'neutral') { + tickPlayerState.current.xMotion = 'left'; + } else if (tickPlayerState.current.xMotion === 'right') { + tickPlayerState.current.xMotion = 'neutral'; + } + return; + } + if (keyName === 'd') { + if (tickPlayerState.current.xMotion === 'neutral') { + tickPlayerState.current.xMotion = 'right'; + } else if (tickPlayerState.current.xMotion === 'left') { + tickPlayerState.current.xMotion = 'neutral'; + } + return; + } + }; + + const onKeyUp = (keyName: string) => { + if (keyName === 'w') { + tickPlayerState.current.yMotion = 'neutral'; + return; + } + if (keyName === 's') { + tickPlayerState.current.yMotion = 'neutral'; + return; + } + if (keyName === 'a') { + tickPlayerState.current.xMotion = 'neutral'; + return; + } + if (keyName === 'd') { + tickPlayerState.current.xMotion = 'neutral'; + return; + } + }; + + const { keyPresses } = useKeyPresses({ + keyRows: keyboardControls, + onKeyDown, + onKeyUp, + }); + + const onTick = () => { + if (tickPlayerState.current.xMotion === 'neutral' && tickPlayerState.current.yMotion === 'neutral') { + return; + } + if (tickPlayerState.current.xMotion === 'left') { + playerPosition.current.x -= (0.125); + } + if (tickPlayerState.current.xMotion === 'right') { + playerPosition.current.x += (0.125); + } + if (tickPlayerState.current.yMotion === 'forward') { + playerPosition.current.z += (0.125); + } + if (tickPlayerState.current.yMotion === 'backward') { + playerPosition.current.z -= (0.125); + } + }; + const { isPaused, - time, togglePaused, } = useGameLoop({ onTick, - startTime: 0, - tickDuration: 50, }); - const { keyPresses } = useKeyPresses({ keyRows: keyboardControls }); const value = useMemo(() => ({ isPaused, keyPresses, - time, - }), [isPaused, keyPresses, time]); - - const voxels = [ - { shape: 'spike', x: 7, y: 7, z: 0, rot: 90 }, - { shape: 'ramp', x: 6, y: 7, z: 0 }, - { shape: 'ramp', x: 5, y: 7, z: 0 }, - { shape: 'spike', x: 4, y: 7, z: 0 }, - { shape: 'ramp', x: 7, y: 6, z: 0, rot: 90 }, - { shape: 'cube', x: 6, y: 6, z: 0 }, - { shape: 'cube', x: 5, y: 6, z: 0 }, - { shape: 'ramp', x: 4, y: 6, z: 0, rot: 270 }, - { shape: 'ramp', x: 7, y: 5, z: 0, rot: 90 }, - { shape: 'cube', x: 6, y: 5, z: 0 }, - { shape: 'cube', x: 5, y: 5, z: 0 }, - { shape: 'spike', x: 2, y: 5, z: 0 }, - { shape: 'ramp', x: 7, y: 4, z: 0, rot: 90 }, - { shape: 'cube', x: 6, y: 4, z: 0 }, - { shape: 'cube', x: 5, y: 4, z: 0 }, - { shape: 'cube', x: 4, y: 4, z: 0 }, - { shape: 'cube', x: 3, y: 4, z: 0 }, - { shape: 'ramp', x: 3, y: 5, z: 0 }, - { shape: 'ramp', x: 7, y: 3, z: 0, rot: 90 }, - { shape: 'cube', x: 6, y: 3, z: 0 }, - { shape: 'cube', x: 5, y: 3, z: 0 }, - { shape: 'cube', x: 4, y: 3, z: 0 }, - { shape: 'cube', x: 3, y: 3, z: 0 }, - { shape: 'ramp', x: 2, y: 3, z: 0, rot: 270 }, - { shape: 'spike', x: 7, y: 2, z: 0, rot: 180 }, - { shape: 'ramp', x: 6, y: 2, z: 0, rot: 180 }, - { shape: 'ramp', x: 5, y: 2, z: 0, rot: 180 }, - { shape: 'ramp', x: 4, y: 2, z: 0, rot: 180 }, - { shape: 'ramp', x: 3, y: 2, z: 0, rot: 180 }, - { shape: 'spike', x: 2, y: 2, z: 0, rot: 270 }, - { shape: 'ramp', x: 2, y: 4, z: 0, rot: 270 }, - { shape: 'wedge', x: 4, y: 5, z: 0 }, - { shape: 'ramp', x: 4, y: 4, z: 1 }, - { shape: 'spike', x: 3, y: 4, z: 1 }, - { shape: 'ramp', x: 4, y: 3, z: 1, rot: 180 }, - { shape: 'spike', x: 3, y: 3, z: 1, rot: 270 }, - { shape: 'spike', x: 5, y: 3, z: 1, rot: 180 }, - { shape: 'spike', x: 5, y: 4, z: 1, rot: 90 }, - ]; + }), [isPaused, keyPresses]); return ( <> @@ -117,24 +159,17 @@ export const Game = () => { > - - - - + diff --git a/client/src/App/components/Game/KeyPad.test.tsx b/client/src/App/components/Game/KeyPad.test.tsx index a7aa8e0c..e4c02a68 100644 --- a/client/src/App/components/Game/KeyPad.test.tsx +++ b/client/src/App/components/Game/KeyPad.test.tsx @@ -20,10 +20,10 @@ describe('KeyPad', () => { value={{ isPaused: true, keyPresses: new Map(), - time: 0, }} > @@ -41,7 +41,6 @@ describe('KeyPad', () => { value={{ isPaused: true, keyPresses, - time: 0, }} > { value={{ isPaused: true, keyPresses: new Map(), - time: 0, }} > { const { keyPresses } = useGameContext(); @@ -29,6 +29,11 @@ export const KeyPad = ({ {keyRows.map((row, rowIndex) => ( { isPaused: boolean; - time: number; + // time: number; } const GameContext = createContext(null); diff --git a/client/src/App/components/Game/use-animation-frame-manager.test.ts b/client/src/App/components/Game/use-animation-frame-manager.test.ts new file mode 100644 index 00000000..a9b85f81 --- /dev/null +++ b/client/src/App/components/Game/use-animation-frame-manager.test.ts @@ -0,0 +1,67 @@ +import { renderHook } from '@testing-library/react'; + +import { useAnimationFrameManager } from './use-animation-frame-manager'; + +describe('useAnimationFrameManager', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('runs added tasks', () => { + const { result } = renderHook(useAnimationFrameManager, { + initialProps: { + targetFps: 60, + }, + }); + const task1 = jest.fn(); + const task2 = jest.fn(); + result.current.addTask(task1); + result.current.start(); + jest.advanceTimersToNextFrame(); + expect(task1).toHaveBeenCalledTimes(1); + result.current.addTask(task2); + jest.advanceTimersToNextFrame(); + expect(task1).toHaveBeenCalledTimes(2); + expect(task2).toHaveBeenCalledTimes(1); + }); + + it('stops running added tasks', () => { + const { result } = renderHook(useAnimationFrameManager, { + initialProps: { + targetFps: 60, + }, + }); + const task = jest.fn(); + result.current.addTask(task); + result.current.start(); + jest.advanceTimersToNextFrame(); + jest.advanceTimersToNextFrame(); + expect(task).toHaveBeenCalledTimes(2); + result.current.stop(); + jest.advanceTimersToNextFrame(); + jest.advanceTimersToNextFrame(); + jest.advanceTimersToNextFrame(); + expect(task).toHaveBeenCalledTimes(2); + }); + + it('removes added tasks', () => { + const { result } = renderHook(useAnimationFrameManager, { + initialProps: { + targetFps: 60, + }, + }); + const task = jest.fn(); + result.current.addTask(task); + result.current.start(); + jest.advanceTimersToNextFrame(); + expect(task).toHaveBeenCalledTimes(1); + result.current.removeTask(task); + jest.advanceTimersToNextFrame(); + expect(task).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/App/components/Game/use-animation-frame-manager.ts b/client/src/App/components/Game/use-animation-frame-manager.ts new file mode 100644 index 00000000..81d6a0e7 --- /dev/null +++ b/client/src/App/components/Game/use-animation-frame-manager.ts @@ -0,0 +1,81 @@ +import { useRef } from 'react'; + +interface UseAnimationManager { + /** + * Target rate per second to run animation frame tasks. + * A higher target than the device is capable of has no effect. + * Supports running different (higher) framerate devices at (roughly) the same speed (slower), + * e.g. run 120 fps screen frames 1/2 as often as 60 fps screens. + */ + targetFps: number; +} + +/** + * Hook for managing animation frame tasks + */ +export const useAnimationFrameManager = ({ targetFps }: UseAnimationManager) => { + const animationTasks = useRef(new Set()); + const lastFrameTime = useRef(0); + const pendingFrame = useRef(null); + + const addTask = (task: FrameRequestCallback) => { + animationTasks.current.add(task); + }; + + /** + * Called per-frame to run any added tasks. + * Skips animation frames to achieve the target fps. + * Schedules itself for the next frame once all tasks are complete. + * @param timestamp The end time of the previous frame render + * in milliseconds since 'time origin': + * in this context, navigation start + * or more simply, document load + */ + const animate = (timestamp: DOMHighResTimeStamp) => { + const elapsed = timestamp - lastFrameTime.current; + // Smooth fps with a reduced target when comparing elapsed time + // to avoid slightly fast frames incurring full frame skips. + const nearFps = 1000 / targetFps * 0.7; + if (elapsed > nearFps) { + animationTasks.current.forEach(task => { + task(timestamp); + }); + lastFrameTime.current = timestamp; + } + pendingFrame.current = requestAnimationFrame(animate); + }; + + const removeTask = (task: FrameRequestCallback) => { + animationTasks.current.delete(task); + }; + + const start = () => { + pendingFrame.current = requestAnimationFrame(animate); + }; + + const stop = () => { + if (pendingFrame.current) { + cancelAnimationFrame(pendingFrame.current); + pendingFrame.current = null; + } + }; + + return { + /** + * Add a task to run on each animation frame + */ + addTask, + /** + * Remove a previously added task + */ + removeTask, + /** + * Start running added tasks + */ + start, + /** + * Stop running added tasks + */ + stop, + }; +}; diff --git a/client/src/App/components/Game/use-game-loop.test.ts b/client/src/App/components/Game/use-game-loop.test.ts index 122f5454..49d4fdcb 100644 --- a/client/src/App/components/Game/use-game-loop.test.ts +++ b/client/src/App/components/Game/use-game-loop.test.ts @@ -4,6 +4,7 @@ import { useGameLoop } from './use-game-loop'; describe('useGameLoop', () => { beforeEach(() => { + jest.clearAllMocks(); jest.useFakeTimers(); }); @@ -12,70 +13,21 @@ describe('useGameLoop', () => { jest.useRealTimers(); }); - it('starts paused', async () => { + it('starts un-paused', () => { const initialProps = { onTick: jest.fn(), - startTime: 0, - tickDuration: 50, }; - const { rerender, result } = renderHook(useGameLoop, { initialProps }); - await jest.advanceTimersByTimeAsync(150); - rerender(initialProps); - expect(result.current.isPaused).toEqual(true); - expect(result.current.time).toEqual(0); - expect(initialProps.onTick).toHaveBeenCalledTimes(0); - }); - - it('when unpaused, advances ticks after tick duration and runs tick callback', async () => { - const initialProps = { - onTick: jest.fn(), - startTime: 0, - tickDuration: 50, - }; - const { rerender, result } = renderHook(useGameLoop, { initialProps }); - result.current.togglePaused(); - rerender(initialProps); + const { result } = renderHook(useGameLoop, { initialProps }); + jest.advanceTimersToNextFrame(); expect(result.current.isPaused).toEqual(false); - await jest.advanceTimersByTimeAsync(initialProps.tickDuration); - expect(initialProps.onTick).toHaveBeenCalledTimes(1); - rerender(initialProps); - expect(result.current.time).toEqual(1); }); - it('advances multiple ticks when unpaused for multiple ticks', async () => { + it('when unpaused, runs tick callback', () => { const initialProps = { onTick: jest.fn(), - startTime: 0, - tickDuration: 50, }; - const { rerender, result } = renderHook(useGameLoop, { initialProps }); - result.current.togglePaused(); - rerender(initialProps); - await jest.advanceTimersByTimeAsync(initialProps.tickDuration * 5); - expect(initialProps.onTick).toHaveBeenCalledTimes(5); - rerender(initialProps); - expect(result.current.time).toEqual(5); - }); - - it('does not run next scheduled tick callback if paused before tick completes', async () => { - const initialProps = { - onTick: jest.fn(), - startTime: 0, - tickDuration: 50, - }; - const { rerender, result } = renderHook(useGameLoop, { initialProps }); - result.current.togglePaused(); - rerender(initialProps); - // Advance time 1ms less than 5 ticks - await jest.advanceTimersByTimeAsync((initialProps.tickDuration * 5) - 1); - expect(initialProps.onTick).toHaveBeenCalledTimes(4); - rerender(initialProps); - expect(result.current.time).toEqual(4); - result.current.togglePaused(); - rerender(initialProps); - await jest.advanceTimersByTimeAsync(initialProps.tickDuration); - expect(result.current.isPaused).toEqual(true); - expect(initialProps.onTick).toHaveBeenCalledTimes(4); - expect(result.current.time).toEqual(4); + renderHook(useGameLoop, { initialProps }); + jest.advanceTimersToNextFrame(); + expect(initialProps.onTick).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/App/components/Game/use-game-loop.ts b/client/src/App/components/Game/use-game-loop.ts index a09b34e2..d59146b7 100644 --- a/client/src/App/components/Game/use-game-loop.ts +++ b/client/src/App/components/Game/use-game-loop.ts @@ -5,40 +5,34 @@ interface UseGameLoop { * Work to perform on every tick */ onTick: () => void; - /** - * Starting tick - */ - startTime: number; - /** - * Length in ms of every tick - */ - tickDuration: number; } -export const useGameLoop = ({ onTick, startTime, tickDuration }: UseGameLoop) => { - const loop = useRef(null); - const [isPaused, setIsPaused] = useState(true); - const [time, setTime] = useState(startTime); +export const useGameLoop = ({ onTick }: UseGameLoop) => { + const loop = useRef(null); + const [isPaused, setIsPaused] = useState(false); const togglePaused = useCallback(() => { setIsPaused(value => !value); }, []); useEffect(() => { + const onFrame = () => { + onTick(); + loop.current = requestAnimationFrame(onFrame); + }; + if (!isPaused) { - loop.current = setInterval(() => { - onTick(); - setTime(previousTime => previousTime + 1); - }, tickDuration); + loop.current = requestAnimationFrame(onFrame); } return () => { - clearInterval(loop.current ?? undefined); + if (loop.current) { + cancelAnimationFrame(loop.current); + } }; - }, [isPaused, onTick, tickDuration]); + }, [isPaused, onTick]); return { isPaused, - time, togglePaused, }; }; diff --git a/client/src/App/components/Game/use-key-presses.test.ts b/client/src/App/components/Game/use-key-presses.test.ts index 1c28049f..91c59369 100644 --- a/client/src/App/components/Game/use-key-presses.test.ts +++ b/client/src/App/components/Game/use-key-presses.test.ts @@ -11,6 +11,8 @@ describe('useKeyPresses', () => { keyRows: [ ['a', 's', 'd', 'f'], ], + onKeyDown: jest.fn(), + onKeyUp: jest.fn(), }, }); expect(result.current.keyPresses.size).toEqual(0); @@ -25,6 +27,8 @@ describe('useKeyPresses', () => { keyRows: [ ['a', 's', 'd', 'f'], ], + onKeyDown: jest.fn(), + onKeyUp: jest.fn(), }, }); await user.keyboard('asdf'); @@ -38,6 +42,8 @@ describe('useKeyPresses', () => { keyRows: [ ['a', 's', 'd', 'f'], ], + onKeyDown: jest.fn(), + onKeyUp: jest.fn(), }, }); await user.keyboard('qwer'); diff --git a/client/src/App/components/Game/use-key-presses.ts b/client/src/App/components/Game/use-key-presses.ts index ff560a80..7b074e5c 100644 --- a/client/src/App/components/Game/use-key-presses.ts +++ b/client/src/App/components/Game/use-key-presses.ts @@ -6,12 +6,20 @@ interface UseKeyPressesProps { * grouped into rows, i.e. keyRows[row][key] */ keyRows: string[][]; + /** + * FIXME: + */ + onKeyDown: (keyName: string) => void; + /** + * FIXME: + */ + onKeyUp: (keyName: string) => void; } /** * Hook for tracking keyboard key presses */ -export const useKeyPresses = ({ keyRows }: UseKeyPressesProps) => { +export const useKeyPresses = ({ keyRows, onKeyDown, onKeyUp }: UseKeyPressesProps) => { const [keyPresses, setKeyPresses] = useState>(new Map()); /** @@ -26,7 +34,8 @@ export const useKeyPresses = ({ keyRows }: UseKeyPressesProps) => { next.set(e.key, true); return next; }); - }, [keyRows]); + onKeyDown(e.key); + }, [keyRows, onKeyDown]); /** * Called whenever a keyboard key is released @@ -40,7 +49,8 @@ export const useKeyPresses = ({ keyRows }: UseKeyPressesProps) => { next.set(e.key, false); return next; }); - }, [keyRows]); + onKeyUp(e.key); + }, [keyRows, onKeyUp]); useEffect(() => { document.addEventListener('keydown', keydownHandler); diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index f0074b45..a664cf45 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -12,9 +12,6 @@ importers: ../../client: dependencies: - '@layoutit/voxcss': - specifier: ~0.1.8 - version: 0.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@trshcmpctr/components': specifier: workspace:* version: link:../components @@ -1665,23 +1662,6 @@ packages: peerDependencies: tslib: '2' - '@layoutit/voxcss@0.1.8': - resolution: {integrity: sha512-kYxK1GHS0UpNqcacP+LDU+wcYTJyYXH+ord9qW8vJsWvPcXA6TpDMz2Byhks4QIQU8f5uikWfvL2q/8fzCvtRg==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - svelte: ^4.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - svelte: - optional: true - vue: - optional: true - '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -8414,11 +8394,6 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 - '@layoutit/voxcss@0.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - '@leichtgewicht/ip-codec@2.0.5': {} '@mapbox/node-pre-gyp@2.0.3': diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index f06cc099..7082da19 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "f6102caf4c392f8190e0e7f30762d301e76402e9", + "pnpmShrinkwrapHash": "ab880f9b486a4c09570d0737d8ea8f05a907a017", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" }