From 36089eb70811f8d9352a13f01ba6ba47cd3832c5 Mon Sep 17 00:00:00 2001 From: Cody Adam Date: Wed, 24 Jan 2024 00:20:32 +0100 Subject: [PATCH 1/2] Refactor canvas component and useMouse hook --- packages/client/src/app/canvas.tsx | 131 +++-------------------- packages/client/src/hooks/useMouse.ts | 13 +-- packages/client/src/lib/Entity.ts | 42 ++++++++ packages/client/src/lib/Food.ts | 23 ++++ packages/client/src/lib/Game.ts | 113 +++++++++++++++++++ packages/client/src/lib/MyPlayer.ts | 5 + packages/client/src/lib/Player.ts | 59 ++++++++++ packages/client/src/lib/shared-state.tsx | 4 +- packages/server/src/index.ts | 4 +- packages/shared/src/types.ts | 8 +- packages/shared/src/utils.ts | 4 +- 11 files changed, 272 insertions(+), 134 deletions(-) create mode 100644 packages/client/src/lib/Entity.ts create mode 100644 packages/client/src/lib/Food.ts create mode 100644 packages/client/src/lib/Game.ts create mode 100644 packages/client/src/lib/MyPlayer.ts create mode 100644 packages/client/src/lib/Player.ts diff --git a/packages/client/src/app/canvas.tsx b/packages/client/src/app/canvas.tsx index e5b79fe..4170f87 100644 --- a/packages/client/src/app/canvas.tsx +++ b/packages/client/src/app/canvas.tsx @@ -3,10 +3,9 @@ import { useApi } from '@/hooks/useApi'; import { useMouse } from '@/hooks/useMouse'; import { useScreen } from '@/hooks/useScreen'; -import { worldToScreen } from '@/utils/position'; -import { TPS } from '@viper-vortex/shared'; -import { throttle } from 'lodash'; -import { useEffect, useRef, useState } from 'react'; +import { Game } from '@/lib/Game'; +import { useEffect, useRef } from 'react'; + export type Camera = { offset: { x: number; @@ -14,119 +13,23 @@ export type Camera = { }; zoom: number; } + export function Canvas({ centered }: { centered?: boolean }) { const api = useApi(); - const ref = useRef(null); - const [camera, setCamera] = useState({ offset: { x: 0, y: 0 }, zoom: 1 }); - const [curPos, curWorldPos] = useMouse(ref, camera) + const canvasRef = useRef(null); + const gameRef = useRef(null); + const cursorScreen = useMouse(canvasRef) const screen = useScreen(); - // CENTER THE CAMERA - useEffect(() => { - if (!api.scene) return; - if (!centered) { - if (camera.offset.x === 0 && camera.offset.y === 0) return; - setCamera(prev => ({ ...prev, offset: { x: 50, y: 100 } })); - return; - } - const playerHead = api.me?.body[0]; - if (!playerHead) return; - const screenPlayerHead = worldToScreen(playerHead, camera); - const newCameraOffset = { - x: screen.width / 2 - screenPlayerHead.x, - y: screen.height / 2 - screenPlayerHead.y, - }; - setCamera(prev => ({ ...prev, offset: newCameraOffset })); - }, [api.me, api.scene, camera, centered, screen]); - - // MOVE THE PLAYER - useEffect(() => { - if (!api.scene || !api.me) return; - - const movePlayer = throttle(() => { - if (!api.scene || !api.me) return; - const playerHead = api.me.body[0]; - if (!playerHead) return; - const angle = Math.atan2(curWorldPos.y - playerHead.y, curWorldPos.x - playerHead.x); - api.move({ angle, isSprinting: false }); - }, 1000 / TPS); - - movePlayer(); - return () => { - movePlayer.cancel(); - }; - }, [api, curWorldPos.x, curWorldPos.y]); - - - // DRAW THE SCENE useEffect(() => { - const canvas = ref.current; - if (!canvas) return; - const c = canvas.getContext('2d'); - if (!c) return; - c.clearRect(0, 0, canvas.width, canvas.height); - - - // Draw a circle at the cursor position - c.beginPath(); - const screenCurPos = worldToScreen(curWorldPos, camera); - c.arc(screenCurPos.x, screenCurPos.y, 10, 0, 2 * Math.PI); - // use red color - c.fillStyle = 'red'; - c.strokeStyle = 'red'; - c.fill(); - - // Draw a line from the player's head to the cursor - if (api.me) { - const playerHead = api.me.body[0]; - if (playerHead) { - const screenHead = worldToScreen(playerHead, camera); - const screenCurPos = worldToScreen(curWorldPos, camera); - c.beginPath(); - c.moveTo(screenHead.x, screenHead.y); - c.lineTo(screenCurPos.x, screenCurPos.y); - c.fillStyle = 'red'; - c.strokeStyle = 'red'; - c.stroke(); - } - } - - if (!api.scene) return - - const screenOrigin = worldToScreen({ x: 0, y: 0 }, camera); - c.fillStyle = 'transparent'; - c.fillRect(0, 0, canvas.width, canvas.height); - c.rect(screenOrigin.x, screenOrigin.y, api.scene.width, api.scene.height); - c.strokeStyle = 'black'; - c.stroke(); - - - // Draw all food - api.scene.food.forEach(food => { - const screenFood = worldToScreen(food.position, camera); - c.beginPath(); - c.arc(screenFood.x, screenFood.y, 10, 0, 2 * Math.PI); // Draw a circle for each food - // use green color - c.fillStyle = 'green'; - c.fill(); - }); - - // Draw all players - api.scene.players.forEach(player => { - player.body.forEach((bodyPart) => { - const screenBodyPart = worldToScreen(bodyPart, camera); - c.beginPath(); - c.arc(screenBodyPart.x, screenBodyPart.y, 10, 0, 2 * Math.PI); - c.fillStyle = player.color; - c.fill(); - c.strokeStyle = player.color; - c.stroke(); - }); - }); - - }, [api, api.scene, camera, camera.offset.x, camera.offset.y, curPos, curWorldPos, curWorldPos.x, curWorldPos.y]); - - if (!api.scene) return "Scene not found"; - - return ; + const c = canvasRef.current?.getContext('2d'); + const scene = api.scene; + if (!c || !scene) return; + if (!canvasRef.current) + gameRef.current = new Game(scene); + const game = gameRef.current; + if (!game) return; + }, [api.scene, cursorScreen, screen]); + + return ; } \ No newline at end of file diff --git a/packages/client/src/hooks/useMouse.ts b/packages/client/src/hooks/useMouse.ts index c843b95..a4e2387 100644 --- a/packages/client/src/hooks/useMouse.ts +++ b/packages/client/src/hooks/useMouse.ts @@ -1,11 +1,8 @@ -import { type Camera } from "@/app/canvas"; -import { screenToWorld } from "@/utils/position"; import { type Position } from '@viper-vortex/shared'; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; export function useMouse( ref: React.RefObject | null | undefined, - camera: Camera, ) { const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 }); useEffect(() => { @@ -19,11 +16,7 @@ export function useMouse( return () => { elem.removeEventListener("mousemove", mouseMoveHandler); }; - }, [camera.offset.x, camera.offset.y, ref]); + }, [ref]); - const cursorWorldPosition = useMemo(() => { - return screenToWorld(cursorPosition, camera); - }, [camera, cursorPosition]); - - return [cursorPosition, cursorWorldPosition] as const; + return cursorPosition; } diff --git a/packages/client/src/lib/Entity.ts b/packages/client/src/lib/Entity.ts new file mode 100644 index 0000000..51acf30 --- /dev/null +++ b/packages/client/src/lib/Entity.ts @@ -0,0 +1,42 @@ +import { type Camera } from '@/app/canvas'; +import { type Position } from '@viper-vortex/shared'; + +export type UpdateContext = { + /** + * Canvas context to draw on + */ + c: CanvasRenderingContext2D, + /** + * Camera object to convert world coordinates to screen coordinates + */ + camera: Camera + /** + * Screen size + */ + screen: { + width: number, + height: number + }, + /** + * Cursor position in world coordinates + */ + cursor: Position +} + +export abstract class Entity { + constructor(public id: string){} + /** + * Called on each animation frames + * Used for animation or rendering + */ + abstract draw(props: UpdateContext) : void + + /** + * Called on a fixed amount of times per seconds + * Used for server communication + * (60 by defaults) + */ + fixedUpdate(_: UpdateContext) : void { + // nothing by default + } +} \ No newline at end of file diff --git a/packages/client/src/lib/Food.ts b/packages/client/src/lib/Food.ts new file mode 100644 index 0000000..82e2e6c --- /dev/null +++ b/packages/client/src/lib/Food.ts @@ -0,0 +1,23 @@ +import { worldToScreen } from '@/utils/position'; +import { type FoodDTO, type Position } from '@viper-vortex/shared'; +import { Entity, type UpdateContext } from './Entity'; + +export class Food extends Entity { + position: Position; + constructor(food: FoodDTO) { + super(food.id); + this.position = food.position; + } + + update(food: FoodDTO) { + this.position = food.position; + } + + draw({c,camera}: UpdateContext): void { + const screenFood = worldToScreen(this.position, camera); + c.beginPath(); + c.arc(screenFood.x, screenFood.y, 10, 0, 2 * Math.PI); + c.fillStyle = 'green'; + c.fill(); + } +} \ No newline at end of file diff --git a/packages/client/src/lib/Game.ts b/packages/client/src/lib/Game.ts new file mode 100644 index 0000000..b3aa229 --- /dev/null +++ b/packages/client/src/lib/Game.ts @@ -0,0 +1,113 @@ +import { Camera } from "@/app/canvas"; +import { screenToWorld, worldToScreen } from "@/utils/position"; +import { GameMap, Position } from "@viper-vortex/shared"; +import { type UpdateContext } from "./Entity"; +import { Food } from "./Food"; +import { Player } from "./Player"; +const ME_ID = "me"; +export class Game { + private players: Record = {}; + private food: Record = {}; + private width = 0; + private height = 0; + private camera: Camera = { offset: { x: 0, y: 0 }, zoom: 1 }; + private me: Player | undefined; + private cursor: Position = { x: 0, y: 0 }; + private screen: { width: number; height: number } = { + width: 0, + height: 0, + }; + private centered = true; + private c : CanvasRenderingContext2D | undefined; + + + setScene(map: GameMap) { + this.width = map.width; + this.height = map.height; + + const notSeen = new Set(Object.keys(this.players)) + map.players.forEach((player) => { + notSeen.delete(player.id); + if (player.id === ME_ID) { + if (!this.me) this.me = new Player(player); + else this.me.update(player); + return; + } + if (!this.players[player.id]) { + this.players[player.id] = new Player(player); + } else { + this.players[player.id]!.update(player); + } + }); + notSeen.forEach((id) => delete this.players[id]); + + const notSeenFood = new Set(Object.keys(this.food)); + map.food.forEach((food) => { + notSeenFood.delete(food.id); + if (!this.food[food.id]) { + this.food[food.id] = new Food(food); + } else { + this.food[food.id]!.update(food); + } + }); + notSeenFood.forEach((id) => delete this.food[id]); + } + + update() { + if (!this.c) return + this.c.clearRect(0, 0, this.screen.width, this.screen.height); + + this.drawBorder(context); + + if (this.centered) this.centerCamera(); + + Object.values(this.food).forEach((food) => { + food.draw(context); + }); + Object.values(this.players).forEach((player) => { + player.draw(context); + }); + + if (this.me) { + this.me.draw(context); + } + } + + setCursor(cursorScreen: Position) { + this.cursor = screenToWorld(cursorScreen, this.camera); + } + + setScreenSize(screen: { width: number; height: number }) { + this.screen = screen; + } + + + private centerCamera() { + const playerHead = this.me?.getHead(); + if (!playerHead) return; + const screenPlayerHead = worldToScreen(playerHead, this.camera); + const newCameraOffset = { + x: screen.width / 2 - screenPlayerHead.x, + y: screen.height / 2 - screenPlayerHead.y, + }; + this.camera.offset = newCameraOffset; + } + + private drawBorder() { + const c = this.c; + if (!c) return; + const screenOrigin = worldToScreen({ x: 0, y: 0 }, this.camera); + c.fillStyle = "transparent"; + c.fillRect(0, 0, screen.width, screen.height); + c.rect(screenOrigin.x, screenOrigin.y, this.width, this.height); + c.strokeStyle = "black"; + c.stroke(); + } + + fixedUpdate({ cursor }: UpdateContext) { + const playerHead = this.me?.getHead(); + if (!playerHead) return; + const angle = Math.atan2(cursor.y - playerHead.y, cursor.x - playerHead.x); + // api.move({ angle, isSprinting: false }); + } +} diff --git a/packages/client/src/lib/MyPlayer.ts b/packages/client/src/lib/MyPlayer.ts new file mode 100644 index 0000000..52df415 --- /dev/null +++ b/packages/client/src/lib/MyPlayer.ts @@ -0,0 +1,5 @@ +import { Player } from './Player'; + +export class MyPlayer extends Player { + +} \ No newline at end of file diff --git a/packages/client/src/lib/Player.ts b/packages/client/src/lib/Player.ts new file mode 100644 index 0000000..6a6eec9 --- /dev/null +++ b/packages/client/src/lib/Player.ts @@ -0,0 +1,59 @@ +import { worldToScreen } from "@/utils/position"; +import { type PlayerDTO, type Position } from "@viper-vortex/shared"; +import { Entity, type UpdateContext } from "./Entity"; + +export class Player extends Entity { + body: Position[]; + name: string; + color: string; + isSprinting: boolean; + angle: number; + + constructor(p: PlayerDTO) { + super(p.id); + this.body = p.body; + this.name = p.name; + this.color = p.color; + this.isSprinting = p.isSprinting; + this.angle = p.angle; + } + + update(p: PlayerDTO) { + this.body = p.body; + this.name = p.name; + this.color = p.color; + this.isSprinting = p.isSprinting; + this.angle = p.angle; + } + + draw({ c, camera }: UpdateContext): void { + + // if (IS ME) { + // const playerHead = this.getHead(); + // if (playerHead) { + // const screenHead = worldToScreen(playerHead, camera); + // const screenCurPos = worldToScreen(cursor, camera); + // c.beginPath(); + // c.moveTo(screenHead.x, screenHead.y); + // c.lineTo(screenCurPos.x, screenCurPos.y); + // c.fillStyle = 'red'; + // c.strokeStyle = 'red'; + // c.stroke(); + // } + // } + + this.body.forEach((bodyPart) => { + const screenBodyPart = worldToScreen(bodyPart, camera); + c.beginPath(); + c.arc(screenBodyPart.x, screenBodyPart.y, 10, 0, 2 * Math.PI); + c.fillStyle = this.color; + c.fill(); + c.strokeStyle = this.color; + c.stroke(); + }); + } + + getHead() { + return this.body[0]; + } +} diff --git a/packages/client/src/lib/shared-state.tsx b/packages/client/src/lib/shared-state.tsx index 3b3ce48..65c1d06 100644 --- a/packages/client/src/lib/shared-state.tsx +++ b/packages/client/src/lib/shared-state.tsx @@ -1,9 +1,9 @@ // SharedStateContext.js -import { type GameMap, type Player } from '@viper-vortex/shared'; +import { type GameMap, type PlayerDTO } from '@viper-vortex/shared'; import { createContext, useContext, useState } from "react"; type SharedState = { - me?: Player; + me?: PlayerDTO; scene?: GameMap; isConnected?: boolean; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f8930ac..72173eb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,7 +2,7 @@ import { Server } from "socket.io"; import { GameMap, newGameMap, - Player, + PlayerDTO, randomFood, SOCKET_EVENTS, TPS, @@ -25,7 +25,7 @@ setInterval(() => { io.on(SOCKET_EVENTS.CONNECT, (socket) => { console.log(`New connection: ${socket.id}`); - const player: Player = { + const player: PlayerDTO = { id: socket.id, name: "Player 1", color: "#000000", diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4544e3b..a85ecf9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,14 +4,14 @@ export type GameMap = { width: number; height: number; - players: Player[]; - food: Food[] + players: PlayerDTO[]; + food: FoodDTO[] } /** * Represents a player. */ -export type Player = { +export type PlayerDTO = { body: Position[]; id: string; name: string; @@ -28,7 +28,7 @@ export type Position = { y: number; } -export type Food = { +export type FoodDTO = { id: string; position: Position; } diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 91b54b2..4aa31df 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from "uuid"; -import { Food, GameMap } from "./types"; +import { FoodDTO, GameMap } from "./types"; /** * Create a new game map. @@ -17,7 +17,7 @@ export function newGameMap(): GameMap { } export function randomFood(number: number = 1) { - const food: Food[] = []; + const food: FoodDTO[] = []; for (let i = 0; i < number; i++) { food.push({ id: uuidv4(), From e982a3444339291ac1758f07ce31379162bfd4d3 Mon Sep 17 00:00:00 2001 From: Cody Adam Date: Wed, 24 Jan 2024 21:17:59 +0100 Subject: [PATCH 2/2] Giga refactor working --- packages/client/src/app/canvas.tsx | 79 +++++++-- packages/client/src/hooks/useApi.ts | 78 ++++----- packages/client/src/lib/Entity.ts | 31 +--- packages/client/src/lib/Food.ts | 23 --- packages/client/src/lib/Game.ts | 197 ++++++++++++++++------- packages/client/src/lib/MyPlayer.ts | 23 ++- packages/client/src/lib/Orb.ts | 26 +++ packages/client/src/lib/Player.ts | 56 +++---- packages/client/src/lib/TimeManager.ts | 79 +++++++++ packages/client/src/lib/shared-state.tsx | 11 +- packages/server/src/handleFrame.ts | 4 +- 11 files changed, 408 insertions(+), 199 deletions(-) delete mode 100644 packages/client/src/lib/Food.ts create mode 100644 packages/client/src/lib/Orb.ts create mode 100644 packages/client/src/lib/TimeManager.ts diff --git a/packages/client/src/app/canvas.tsx b/packages/client/src/app/canvas.tsx index 4170f87..a5db344 100644 --- a/packages/client/src/app/canvas.tsx +++ b/packages/client/src/app/canvas.tsx @@ -3,7 +3,7 @@ import { useApi } from '@/hooks/useApi'; import { useMouse } from '@/hooks/useMouse'; import { useScreen } from '@/hooks/useScreen'; -import { Game } from '@/lib/Game'; +import { Game, type Params } from '@/lib/Game'; import { useEffect, useRef } from 'react'; export type Camera = { @@ -14,22 +14,81 @@ export type Camera = { zoom: number; } -export function Canvas({ centered }: { centered?: boolean }) { +const game = new Game(); + +export function Canvas(params: Params) { const api = useApi(); const canvasRef = useRef(null); - const gameRef = useRef(null); const cursorScreen = useMouse(canvasRef) const screen = useScreen(); + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // esacpe + if (e.key === 'Escape') { + game.togglePause(); + } + // space + if (e.key === ' ') { + game.setSpriniting(true); + } + } + function handleKeyUp(e: KeyboardEvent) { + // space + if (e.key === ' ') { + game.setSpriniting(false); + } + } + + function handleMouseDown(e: MouseEvent) { + if (e.button === 0) { + game.setSpriniting(true); + } + } + function handleMouseUp(e: MouseEvent) { + if (e.button === 0) { + game.setSpriniting(false); + } + } + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('keyup', handleKeyUp); + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('keyup', handleKeyUp); + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('mouseup', handleMouseUp); + } + }, []); + + useEffect(() => { + game.setApi(api); + }, [api]); + + useEffect(() => { + game.setCursor(cursorScreen); + }, [cursorScreen]); + useEffect(() => { const c = canvasRef.current?.getContext('2d'); - const scene = api.scene; - if (!c || !scene) return; - if (!canvasRef.current) - gameRef.current = new Game(scene); - const game = gameRef.current; - if (!game) return; - }, [api.scene, cursorScreen, screen]); + if (!c) return; + game.setContext(c); + }, []); + + useEffect(() => { + game.updateParams(params); + }, [params]); + + useEffect(() => { + game.setScreenSize(screen); + }, [screen]); + + useEffect(() => { + if (!api.scene) return; + game.setScene(api.scene); + }, [api.scene]); return ; } \ No newline at end of file diff --git a/packages/client/src/hooks/useApi.ts b/packages/client/src/hooks/useApi.ts index 154c30d..5f0a138 100644 --- a/packages/client/src/hooks/useApi.ts +++ b/packages/client/src/hooks/useApi.ts @@ -4,72 +4,60 @@ import { type GameMap, type PlayerMove, } from "@viper-vortex/shared"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { io } from "socket.io-client"; -// "undefined" means the URL will be computed from the `window.location` object -const URL = - process.env.NODE_ENV === "production" ? undefined : "http://localhost:4000"; -const socket = io(URL!, { autoConnect: false }); - export function useApi(serverUrl?: string) { const { - sharedState: { isConnected, me, scene }, - updateSharedState, + sharedState: { isConnected, scene, socket }, + updateState, } = useSharedState(); - useEffect(() => { - if (serverUrl) { - // @ts-expect-error force the URL to be updated - socket.io.uri = serverUrl; - socket.disconnect().connect(); - } - }, [serverUrl]); + const connect = useCallback(() => { + const socket = io(serverUrl!, { autoConnect: true }); + updateState({ socket }); + }, [serverUrl, updateState]); - useEffect(() => { - if (!scene) return; - const newMe = scene.players.find((p) => p.id === socket.id); - if (JSON.stringify(newMe) !== JSON.stringify(me)) { - updateSharedState({ me: newMe }); - } - }, [me, scene, updateSharedState]); + const disconnect = useCallback(() => { + socket?.disconnect(); + updateState({ socket: undefined }); + }, [socket, updateState]); + + const move = useCallback( + (move: PlayerMove) => { + socket?.emit(SOCKET_EVENTS.MOVE, move); + }, + [socket], + ); useEffect(() => { function handleConnect() { - updateSharedState({ isConnected: true }); + updateState({ isConnected: true }); } function handleDisconnect() { - updateSharedState({ isConnected: false }); + updateState({ isConnected: false }); } function handleFrame(scene: GameMap) { - updateSharedState({ scene }); + updateState({ scene }); } - socket.on("connect", handleConnect); - socket.on("disconnect", handleDisconnect); - socket.on(SOCKET_EVENTS.FRAME, handleFrame); + socket?.on("connect", handleConnect); + socket?.on("disconnect", handleDisconnect); + socket?.on(SOCKET_EVENTS.FRAME, handleFrame); return () => { - socket.off("connect", handleConnect); - socket.off("disconnect", handleDisconnect); - socket.off(SOCKET_EVENTS.FRAME, handleFrame); + socket?.off("connect", handleConnect); + socket?.off("disconnect", handleDisconnect); + socket?.off(SOCKET_EVENTS.FRAME, handleFrame); }; - }, [updateSharedState]); + }, [updateState, socket]); return { - connect: () => { - socket.connect(); - }, + connect, socket, - onDisconnect: (fn: () => void) => { - socket.on("disconnect", fn); - }, - onConnect: (fn: () => void) => { - socket.on("connect", fn); - }, - move: (move: PlayerMove) => { - socket.emit(SOCKET_EVENTS.MOVE, move); - }, + disconnect, + move, scene, isConnected, - me, }; } + +export type Api = ReturnType; diff --git a/packages/client/src/lib/Entity.ts b/packages/client/src/lib/Entity.ts index 51acf30..1ecef53 100644 --- a/packages/client/src/lib/Entity.ts +++ b/packages/client/src/lib/Entity.ts @@ -1,42 +1,19 @@ -import { type Camera } from '@/app/canvas'; -import { type Position } from '@viper-vortex/shared'; - -export type UpdateContext = { - /** - * Canvas context to draw on - */ - c: CanvasRenderingContext2D, - /** - * Camera object to convert world coordinates to screen coordinates - */ - camera: Camera - /** - * Screen size - */ - screen: { - width: number, - height: number - }, - /** - * Cursor position in world coordinates - */ - cursor: Position -} +import { type Game } from './Game'; export abstract class Entity { - constructor(public id: string){} + constructor(public id: string, protected game: Game){} /** * Called on each animation frames * Used for animation or rendering */ - abstract draw(props: UpdateContext) : void + abstract draw() : void /** * Called on a fixed amount of times per seconds * Used for server communication * (60 by defaults) */ - fixedUpdate(_: UpdateContext) : void { + fixedUpdate() : void { // nothing by default } } \ No newline at end of file diff --git a/packages/client/src/lib/Food.ts b/packages/client/src/lib/Food.ts deleted file mode 100644 index 82e2e6c..0000000 --- a/packages/client/src/lib/Food.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { worldToScreen } from '@/utils/position'; -import { type FoodDTO, type Position } from '@viper-vortex/shared'; -import { Entity, type UpdateContext } from './Entity'; - -export class Food extends Entity { - position: Position; - constructor(food: FoodDTO) { - super(food.id); - this.position = food.position; - } - - update(food: FoodDTO) { - this.position = food.position; - } - - draw({c,camera}: UpdateContext): void { - const screenFood = worldToScreen(this.position, camera); - c.beginPath(); - c.arc(screenFood.x, screenFood.y, 10, 0, 2 * Math.PI); - c.fillStyle = 'green'; - c.fill(); - } -} \ No newline at end of file diff --git a/packages/client/src/lib/Game.ts b/packages/client/src/lib/Game.ts index b3aa229..4e6fea6 100644 --- a/packages/client/src/lib/Game.ts +++ b/packages/client/src/lib/Game.ts @@ -1,113 +1,196 @@ -import { Camera } from "@/app/canvas"; +import { type Camera } from "@/app/canvas"; import { screenToWorld, worldToScreen } from "@/utils/position"; -import { GameMap, Position } from "@viper-vortex/shared"; -import { type UpdateContext } from "./Entity"; -import { Food } from "./Food"; +import { type GameMap, type Position } from "@viper-vortex/shared"; +import { type Api } from "@/hooks/useApi"; +import { Orb } from "./Orb"; import { Player } from "./Player"; -const ME_ID = "me"; +import { TimeManager } from "./TimeManager"; +import { MyPlayer } from "./MyPlayer"; + +export type Params = { + centered: boolean; +}; export class Game { private players: Record = {}; - private food: Record = {}; - private width = 0; - private height = 0; - private camera: Camera = { offset: { x: 0, y: 0 }, zoom: 1 }; - private me: Player | undefined; - private cursor: Position = { x: 0, y: 0 }; - private screen: { width: number; height: number } = { - width: 0, - height: 0, - }; - private centered = true; - private c : CanvasRenderingContext2D | undefined; - + private orbs: Record = {}; + private mapSize = { width: 0, height: 0 }; + private me: MyPlayer | undefined; + private params: Params = { centered: true }; + private api: Api | undefined; + private time: TimeManager; + private isSprinting = false; + private angle = 0; + camera: Camera = { offset: { x: 0, y: 0 }, zoom: 1 }; + cursor: Position = { x: 0, y: 0 }; + screen = { width: 0, height: 0 }; + c: CanvasRenderingContext2D | undefined; + + constructor() { + this.time = new TimeManager(); + this.time.addUpdateCallback(this.update.bind(this)); + this.time.addFixedUpdateCallback(this.fixedUpdate.bind(this)); + this.time.start(); + + console.log("Game Instance Created"); + } setScene(map: GameMap) { - this.width = map.width; - this.height = map.height; + this.mapSize.width = map.width; + this.mapSize.height = map.height; - const notSeen = new Set(Object.keys(this.players)) + const notSeen = new Set(Object.keys(this.players)); map.players.forEach((player) => { notSeen.delete(player.id); - if (player.id === ME_ID) { - if (!this.me) this.me = new Player(player); + if (this.api?.socket?.id && player.id === this.api?.socket.id) { + if (!this.me) this.me = new MyPlayer(player, this); else this.me.update(player); return; } if (!this.players[player.id]) { - this.players[player.id] = new Player(player); + this.players[player.id] = new Player(player, this); } else { this.players[player.id]!.update(player); } }); notSeen.forEach((id) => delete this.players[id]); - const notSeenFood = new Set(Object.keys(this.food)); + const notSeenFood = new Set(Object.keys(this.orbs)); map.food.forEach((food) => { notSeenFood.delete(food.id); - if (!this.food[food.id]) { - this.food[food.id] = new Food(food); + if (!this.orbs[food.id]) { + this.orbs[food.id] = new Orb(food, this); } else { - this.food[food.id]!.update(food); + this.orbs[food.id]!.update(food); } }); - notSeenFood.forEach((id) => delete this.food[id]); + notSeenFood.forEach((id) => delete this.orbs[id]); } - update() { - if (!this.c) return - this.c.clearRect(0, 0, this.screen.width, this.screen.height); + setCursor(cursorScreen: Position) { + this.cursor = screenToWorld(cursorScreen, this.camera); + const playerHead = this.me?.getHead(); + if (!playerHead) return; + this.angle = Math.atan2( + this.cursor.y - playerHead.y, + this.cursor.x - playerHead.x, + ); + } - this.drawBorder(context); + setSpriniting(isSpriniting: boolean) { + this.isSprinting = isSpriniting; + } - if (this.centered) this.centerCamera(); + togglePause() { + this.time.togglePause(); + if (this.time.isPaused) { + // draw pause screen + if (!this.c) return; + this.c.fillStyle = "rgba(0, 0, 0, 0.5)"; + this.c.fillRect(0, 0, this.screen.width, this.screen.height); + this.c.fillStyle = "white"; + this.c.font = "50px Arial"; + this.c.textAlign = "center"; + this.c.fillText( + "Rendering Paused", + this.screen.width / 2, + this.screen.height / 2, + ); + } + } - Object.values(this.food).forEach((food) => { - food.draw(context); - }); - Object.values(this.players).forEach((player) => { - player.draw(context); - }); + updateParams(params: Partial) { + this.params = { ...this.params, ...params }; + } - if (this.me) { - this.me.draw(context); - } + setContext(c: CanvasRenderingContext2D) { + this.c = c; + this.c.imageSmoothingEnabled = false; + // set pixel ratio + const dpr = window.devicePixelRatio || 1; + const rect = c.canvas.getBoundingClientRect(); + c.canvas.width = rect.width * dpr; + c.canvas.height = rect.height * dpr; + c.scale(dpr, dpr); } - setCursor(cursorScreen: Position) { - this.cursor = screenToWorld(cursorScreen, this.camera); + setApi(api: Api) { + this.api = api; } setScreenSize(screen: { width: number; height: number }) { this.screen = screen; } - private centerCamera() { const playerHead = this.me?.getHead(); if (!playerHead) return; - const screenPlayerHead = worldToScreen(playerHead, this.camera); - const newCameraOffset = { - x: screen.width / 2 - screenPlayerHead.x, - y: screen.height / 2 - screenPlayerHead.y, + + this.camera.offset = { + x: -playerHead.x + this.screen.width / 2, + y: -playerHead.y + this.screen.height / 2, }; - this.camera.offset = newCameraOffset; } private drawBorder() { const c = this.c; if (!c) return; const screenOrigin = worldToScreen({ x: 0, y: 0 }, this.camera); + c.lineWidth = 2; c.fillStyle = "transparent"; c.fillRect(0, 0, screen.width, screen.height); - c.rect(screenOrigin.x, screenOrigin.y, this.width, this.height); + c.rect( + screenOrigin.x, + screenOrigin.y, + this.mapSize.width, + this.mapSize.height, + ); c.strokeStyle = "black"; c.stroke(); } - fixedUpdate({ cursor }: UpdateContext) { - const playerHead = this.me?.getHead(); - if (!playerHead) return; - const angle = Math.atan2(cursor.y - playerHead.y, cursor.x - playerHead.x); - // api.move({ angle, isSprinting: false }); + update() { + if (!this.c) return; + this.c.clearRect(0, 0, this.screen.width, this.screen.height); + + this.drawBorder(); + + if (this.params.centered) this.centerCamera(); + + Object.values(this.orbs).forEach((food) => { + food.draw(); + }); + Object.values(this.players).forEach((player) => { + player.draw(); + }); + + if (this.me) { + this.me.draw(); + } + + // show fps top right + this.c.fillStyle = "black"; + this.c.font = "20px Arial"; + this.c.textAlign = "right"; + this.c.fillText( + `FPS: ${Math.round(this.time.fps).toString()}`, + this.screen.width - 32, + 32, + ); + this.c.fillText( + `TPS: ${Math.round(this.time.tps).toString()}`, + this.screen.width - 32, + 64, + ); + this.c.fillText( + `Sprinting: ${this.isSprinting ? "Yes" : "No"}`, + this.screen.width - 32, + 128, + ); + } + + fixedUpdate() { + const api = this.api; + if (!api) return; + api.move({ angle: this.angle, isSprinting: this.isSprinting }); } } diff --git a/packages/client/src/lib/MyPlayer.ts b/packages/client/src/lib/MyPlayer.ts index 52df415..b4fbddf 100644 --- a/packages/client/src/lib/MyPlayer.ts +++ b/packages/client/src/lib/MyPlayer.ts @@ -1,5 +1,22 @@ -import { Player } from './Player'; +import { worldToScreen } from "@/utils/position"; +import { Player } from "./Player"; export class MyPlayer extends Player { - -} \ No newline at end of file + draw(): void { + super.draw(); + // const c = this.game.c; + // if (!c) return; + // // const playerHead = this.getHead(); + // // if (playerHead) { + // // const screenHead = worldToScreen(playerHead, this.game.camera); + // // const screenCurPos = worldToScreen(this.game.cursor, this.game.camera); + // // c.beginPath(); + // // c.moveTo(screenHead.x, screenHead.y); + // // c.lineTo(screenCurPos.x, screenCurPos.y); + // // c.fillStyle = "red"; + // // c.strokeStyle = "red"; + // // c.lineWidth = 2; + // // c.stroke(); + // // } + } +} diff --git a/packages/client/src/lib/Orb.ts b/packages/client/src/lib/Orb.ts new file mode 100644 index 0000000..4ccdfd5 --- /dev/null +++ b/packages/client/src/lib/Orb.ts @@ -0,0 +1,26 @@ +import { worldToScreen } from "@/utils/position"; +import { type FoodDTO, type Position } from "@viper-vortex/shared"; +import { Entity } from "./Entity"; +import { type Game } from "./Game"; + +export class Orb extends Entity { + position: Position; + constructor(food: FoodDTO, game: Game) { + super(food.id, game); + this.position = food.position; + } + + update(food: FoodDTO) { + this.position = food.position; + } + + draw(): void { + const c = this.game.c; + if (!c) return; + const screenFood = worldToScreen(this.position, this.game.camera); + c.beginPath(); + c.arc(screenFood.x, screenFood.y, 10, 0, 2 * Math.PI); + c.fillStyle = "green"; + c.fill(); + } +} diff --git a/packages/client/src/lib/Player.ts b/packages/client/src/lib/Player.ts index 6a6eec9..6e9e5f3 100644 --- a/packages/client/src/lib/Player.ts +++ b/packages/client/src/lib/Player.ts @@ -1,6 +1,7 @@ import { worldToScreen } from "@/utils/position"; import { type PlayerDTO, type Position } from "@viper-vortex/shared"; -import { Entity, type UpdateContext } from "./Entity"; +import { Entity } from "./Entity"; +import { type Game } from "./Game"; export class Player extends Entity { body: Position[]; @@ -9,8 +10,8 @@ export class Player extends Entity { isSprinting: boolean; angle: number; - constructor(p: PlayerDTO) { - super(p.id); + constructor(p: PlayerDTO, game: Game) { + super(p.id, game); this.body = p.body; this.name = p.name; this.color = p.color; @@ -26,31 +27,32 @@ export class Player extends Entity { this.angle = p.angle; } - draw({ c, camera }: UpdateContext): void { - - // if (IS ME) { - // const playerHead = this.getHead(); - // if (playerHead) { - // const screenHead = worldToScreen(playerHead, camera); - // const screenCurPos = worldToScreen(cursor, camera); - // c.beginPath(); - // c.moveTo(screenHead.x, screenHead.y); - // c.lineTo(screenCurPos.x, screenCurPos.y); - // c.fillStyle = 'red'; - // c.strokeStyle = 'red'; - // c.stroke(); - // } - // } - - this.body.forEach((bodyPart) => { - const screenBodyPart = worldToScreen(bodyPart, camera); - c.beginPath(); - c.arc(screenBodyPart.x, screenBodyPart.y, 10, 0, 2 * Math.PI); - c.fillStyle = this.color; - c.fill(); - c.strokeStyle = this.color; - c.stroke(); + draw(): void { + const c = this.game.c; + if (!c || this.body.length === 0) return; + + c.beginPath(); + + let prevScreenBodyPart = worldToScreen(this.body[0]!, this.game.camera); + c.moveTo(prevScreenBodyPart.x, prevScreenBodyPart.y); + + this.body.forEach((bodyPart, index) => { + if (index === 0) return; + const screenBodyPart = worldToScreen(bodyPart, this.game.camera); + c.lineTo(screenBodyPart.x, screenBodyPart.y); + prevScreenBodyPart = screenBodyPart; }); + + if (this.body.length === 1) { + c.lineTo(prevScreenBodyPart.x, prevScreenBodyPart.y); + } + + c.lineWidth = 20; + c.strokeStyle = this.color; + c.lineCap = "round"; + c.lineJoin = "round"; + + c.stroke(); } getHead() { diff --git a/packages/client/src/lib/TimeManager.ts b/packages/client/src/lib/TimeManager.ts new file mode 100644 index 0000000..1988cb6 --- /dev/null +++ b/packages/client/src/lib/TimeManager.ts @@ -0,0 +1,79 @@ +import { TPS } from "@viper-vortex/shared"; + +const TARGET_FIXED_DELTA_TIME = 1 / TPS; +const MILLISECONDS_TO_SECONDS = 1 / 1000; + +export class TimeManager { + private startTime = performance.now(); + private lastUpdate = performance.now(); + private lastFixedUpdate = performance.now(); + private tickCount = 0; + private fixedTickCount = 0; + private deltaTime = 1; + private fixedDeltaTime = 1; + private updateCallbacks: Array<(deltaTime: number) => void> = []; + private fixedUpdateCallbacks: Array<(deltaTime: number) => void> = []; + isPaused = true; + fps = 0; + tps = 0; + + addUpdateCallback(callback: (deltaTime: number) => void) { + this.updateCallbacks.push(callback); + } + + addFixedUpdateCallback(callback: (deltaTime: number) => void) { + this.fixedUpdateCallbacks.push(callback); + } + + start() { + this.startTime = performance.now(); + this.lastUpdate = performance.now(); + this.lastFixedUpdate = performance.now(); + this.tickCount = 0; + this.fixedTickCount = 0; + this.deltaTime = 1; + this.fixedDeltaTime = 1; + this.isPaused = false; + this.fps = 0; + this.tps = 0; + requestAnimationFrame(this.update.bind(this)); + } + + togglePause() { + this.isPaused = !this.isPaused; + if (!this.isPaused) { + requestAnimationFrame(this.update.bind(this)); + } + } + + clear() { + this.updateCallbacks = []; + this.fixedUpdateCallbacks = []; + } + + private update() { + if (this.isPaused) return; + + const now = performance.now(); + this.deltaTime = (now - this.lastUpdate) * MILLISECONDS_TO_SECONDS; + this.lastUpdate = now; + this.tickCount++; + this.updateCallbacks.forEach((callback) => callback(this.deltaTime)); + const rawFps = 1 / this.deltaTime; + this.fps = 0.99 * this.fps + 0.01 * rawFps; // smooth fps + + this.fixedDeltaTime = + (now - this.lastFixedUpdate) * MILLISECONDS_TO_SECONDS; + if (this.fixedDeltaTime > TARGET_FIXED_DELTA_TIME) { + const rawTps = 1 / this.fixedDeltaTime; + this.tps = 0.9 * this.tps + 0.1 * rawTps; // smooth tps + this.lastFixedUpdate = now; + this.fixedTickCount++; + this.fixedUpdateCallbacks.forEach((callback) => + callback(this.fixedDeltaTime), + ); + } + + requestAnimationFrame(this.update.bind(this)); + } +} diff --git a/packages/client/src/lib/shared-state.tsx b/packages/client/src/lib/shared-state.tsx index 65c1d06..d2971c1 100644 --- a/packages/client/src/lib/shared-state.tsx +++ b/packages/client/src/lib/shared-state.tsx @@ -1,15 +1,16 @@ // SharedStateContext.js -import { type GameMap, type PlayerDTO } from '@viper-vortex/shared'; +import { type GameMap } from '@viper-vortex/shared'; import { createContext, useContext, useState } from "react"; +import { type Socket } from 'socket.io-client'; type SharedState = { - me?: PlayerDTO; scene?: GameMap; isConnected?: boolean; + socket?: Socket; } type Context = { sharedState: SharedState, - updateSharedState: (newState: Partial) => void + updateState: (newState: Partial) => void } const SharedStateContext = createContext({} as Context); @@ -20,9 +21,9 @@ export function useSharedState() { export function SharedStateProvider({ children }: { children?: React.ReactNode }) { const [sharedState, setSharedState] = useState({ - me: undefined, scene: undefined, isConnected: false, + socket: undefined, }); const updateSharedState = (newState: Partial) => { @@ -30,7 +31,7 @@ export function SharedStateProvider({ children }: { children?: React.ReactNode } }; return ( - + {children} ); diff --git a/packages/server/src/handleFrame.ts b/packages/server/src/handleFrame.ts index e8d5dc0..9d55582 100644 --- a/packages/server/src/handleFrame.ts +++ b/packages/server/src/handleFrame.ts @@ -28,8 +28,8 @@ export default function handleFrame(gameMap: GameMap) { for (let i = 0; i < gameMap.food.length; i++) { const food = gameMap.food[i]; if ( - Math.abs(food.x - head.x) < RADIUS && - Math.abs(food.y - head.y) < RADIUS + Math.abs(food.position.x - head.x) < RADIUS && + Math.abs(food.position.y - head.y) < RADIUS ) { // collision // remove the food