├── .npmrc ├── shared ├── types │ ├── vec2.d.ts │ ├── player.d.ts │ └── game.d.ts ├── tsconfig.json ├── package.json ├── utils │ └── string.ts ├── constants │ ├── common.ts │ └── colors.ts └── lib │ └── ws-state │ ├── client.ts │ └── server.ts ├── .vscode └── settings.json ├── git-auto-pull.sh ├── viewer ├── next.config.js ├── pages │ ├── _app.tsx │ └── index.tsx ├── styles │ └── globals.css ├── next-env.d.ts ├── hooks │ └── onMount.tsx ├── package.json ├── tsconfig.json ├── components │ ├── Game.tsx │ └── Schedule.tsx ├── services │ └── GameService.ts └── classes │ └── GameRenderer.ts ├── Dockerfile ├── server ├── tsconfig.json ├── package.json ├── index.ts ├── ClientSocket.ts ├── Bot.ts ├── Player.ts ├── Game.ts └── GameServer.ts ├── docker-compose.yml ├── package.json ├── README.md ├── LICENSE ├── ERRORCODES.md ├── .gitignore └── PROTOCOL.md /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /shared/types/vec2.d.ts: -------------------------------------------------------------------------------- 1 | type Vec2 = { x: number; y: number } 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 3 | 180 4 | ], 5 | "editor.tabSize": 2 6 | } -------------------------------------------------------------------------------- /git-auto-pull.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while [ 1 ] 4 | do 5 | git pull 6 | sleep 5s 7 | done 8 | -------------------------------------------------------------------------------- /shared/types/player.d.ts: -------------------------------------------------------------------------------- 1 | interface PlayerState { 2 | id: number 3 | alive: boolean 4 | name: string 5 | pos: Vec2 6 | moves: Vec2[] 7 | chat?: string 8 | } -------------------------------------------------------------------------------- /shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "moduleResolution": "node" 5 | }, 6 | "include": [ 7 | "./**/*.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /viewer/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | transpilePackages: ['@gpn-tron/shared'], 4 | }; 5 | 6 | module.exports = nextConfig; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /app 4 | COPY . ./ 5 | RUN yarn install 6 | 7 | EXPOSE 3000 8 | EXPOSE 4001 9 | EXPOSE 4000 10 | 11 | CMD sh -c "yarn dev" 12 | -------------------------------------------------------------------------------- /viewer/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | 3 | function App({ Component, pageProps }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default App; 10 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "moduleResolution": "node" 5 | }, 6 | "include": [ 7 | "../shared/types/**/*.d.ts", 8 | "./**/*.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /viewer/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #__next { 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | background: black; 8 | color: white; 9 | } 10 | 11 | * { 12 | margin: 0; 13 | padding: 0; 14 | } -------------------------------------------------------------------------------- /viewer/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gpn-tron-dev: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "3000:3000" 8 | - "4000:4000" 9 | - "4001:4001" 10 | network_mode: "host" 11 | restart: on-failure -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gpn-tron/shared", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": {}, 6 | "dependencies": { 7 | "fast-json-patch": "^3.1.1", 8 | "socket.io": "^4.6.2", 9 | "socket.io-client": "^4.6.2" 10 | }, 11 | "devDependencies": {} 12 | } 13 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gpn-tron/server", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "tsx watch index.ts" 7 | }, 8 | "dependencies": { 9 | "@gpn-tron/shared": "*", 10 | "multi-elo": "^2.2.0" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^20.2.5", 14 | "tsx": "^4.11.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /viewer/hooks/onMount.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useOnMount(callback) { 4 | const hasRunRef = useRef(false); 5 | 6 | useEffect(() => { 7 | let cleanup; 8 | if (!hasRunRef.current) { 9 | cleanup = callback; 10 | hasRunRef.current = true; 11 | } 12 | return cleanup; 13 | }, [callback]); 14 | } 15 | -------------------------------------------------------------------------------- /shared/utils/string.ts: -------------------------------------------------------------------------------- 1 | export const isStringValid = (string: string, minLength = 1) => { 2 | if (typeof string !== 'string') return false 3 | if (string.length < minLength) return false 4 | if (string.match(/.*\|.*/)) return false 5 | if (!/^[ -~]+$/.test(string)) return false 6 | return true 7 | } 8 | 9 | export const escapeString = (string: string) => { 10 | return string.replace(/(\||[^ -~])/g, '') 11 | } 12 | -------------------------------------------------------------------------------- /shared/constants/common.ts: -------------------------------------------------------------------------------- 1 | export const baseTickrate = 1 // How many ticks per second? 2 | export const tickIncreaseInterval = 10 // How much time to wait until increasing the tickrate by 1 3 | export const afkTimeout = 0 // After what amount of ms not sending data a user is count as afk? 4 | export const joinTimeout = 5000 // After what amount of ms not sending the join packet should a user time out? 5 | export const maxConnections = 1 // How many connections per ip are allowed? -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { GameServer } from "./GameServer" 2 | import { Bot } from "./Bot" 3 | 4 | const GAME_PORT = parseInt(process.env.GAME_PORT || '') || 4000 5 | 6 | async function main() { 7 | const gameServer = new GameServer(GAME_PORT) 8 | 9 | // Spawn a bot 10 | setTimeout(async () => { 11 | for (let i = 0; i < 2; i++) { 12 | const bot = new Bot('bot' + i, '127.0.0.1', GAME_PORT) 13 | } 14 | }, 1000) 15 | } 16 | 17 | main().catch(console.error) 18 | -------------------------------------------------------------------------------- /viewer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gpn-tron/viewer", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next dev" 7 | }, 8 | "dependencies": { 9 | "@gpn-tron/shared": "*", 10 | "@next/swc-wasm-nodejs": "13.2.3", 11 | "next": "13.2.3", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "recharts": "^2.6.2" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^20.2.5", 18 | "@types/react": "^18.2.7", 19 | "typescript": "^5.0.4" 20 | } 21 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "server", 5 | "shared", 6 | "viewer" 7 | ], 8 | "scripts": { 9 | "dev:server": "yarn workspace @gpn-tron/server run dev", 10 | "dev:viewer": "yarn workspace @gpn-tron/viewer run dev", 11 | "dev": "concurrently -n server,viewer \"yarn dev:server\" \"yarn dev:viewer\"", 12 | "autopull": "node git-auto-pull.js" 13 | }, 14 | "stackblitz": { 15 | "installDependencies": false, 16 | "startCommand": "yarn && yarn dev" 17 | }, 18 | "devDependencies": { 19 | "concurrently": "^8.1.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared/types/game.d.ts: -------------------------------------------------------------------------------- 1 | type ServerInfoList = { host: string; port: number }[] 2 | type ScoreboardEntry = { username: string; winRatio: number; wins: number; loses: number, elo: number } 3 | type ChartData = Record[] 4 | type Scoreboard = ScoreboardEntry[] 5 | type LastWinners = string[] 6 | 7 | interface ViewState { 8 | serverInfoList: ServerInfoList 9 | game?: GameState 10 | chartData: ChartData 11 | scoreboard: Scoreboard 12 | lastWinners: LastWinners 13 | } 14 | 15 | interface GameState { 16 | id: string 17 | width: number 18 | height: number 19 | players: PlayerState[] 20 | } 21 | -------------------------------------------------------------------------------- /shared/constants/colors.ts: -------------------------------------------------------------------------------- 1 | const crc32=function(r){for(var a,o=[],c=0;c<256;c++){a=c;for(var f=0;f<8;f++)a=1&a?3988292384^a>>>1:a>>>1;o[c]=a}for(var n=-1,t=0;t>>8^o[255&(n^r.charCodeAt(t))];return(-1^n)>>>0}; 2 | 3 | export const getColor = (n: number) => { 4 | const rgb = [0, 0, 0]; 5 | 6 | for (let i = 0; i < 24; i++) { 7 | rgb[i%3] <<= 1; 8 | rgb[i%3] |= n & 0x01; 9 | n >>= 1; 10 | } 11 | 12 | return '#' + rgb.reduce((a, c) => (c > 0x0f ? c.toString(16) : '0' + c.toString(16)) + a, '') 13 | } 14 | 15 | export const getColorByString = (str: string) => { 16 | return '#' + ('000000' + crc32(str).toString(16)).slice(-6) 17 | } 18 | -------------------------------------------------------------------------------- /viewer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "target": "ESNext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "../shared/types/**/*.d.ts", 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } -------------------------------------------------------------------------------- /viewer/components/Game.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from "react" 2 | import { GameRenderer } from "../classes/GameRenderer" 3 | 4 | export function Game() { 5 | const canvasRef = useRef(null) 6 | 7 | useEffect(() => { 8 | if (!canvasRef.current) return 9 | 10 | const canvas = canvasRef.current 11 | const renderer = new GameRenderer(canvas) 12 | 13 | const tickInterval = setInterval(() => { 14 | if (!canvas.parentElement) return 15 | renderer.render() 16 | }, 1000 / 30) 17 | 18 | return () => { 19 | clearInterval(tickInterval) 20 | } 21 | }, [canvasRef.current]) 22 | 23 | return ( 24 |
25 | 26 |
27 | ) 28 | } -------------------------------------------------------------------------------- /viewer/services/GameService.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import { WsStateClient } from "@gpn-tron/shared/lib/ws-state/client" 3 | 4 | export class GameService extends EventEmitter { 5 | #client: WsStateClient 6 | 7 | get game() { 8 | return this.#client.state.game 9 | } 10 | get chartData() { 11 | return this.#client.state.chartData 12 | } 13 | get scoreboard() { 14 | return this.#client.state.scoreboard 15 | } 16 | get serverInfoList() { 17 | return this.#client.state.serverInfoList 18 | } 19 | get lastWinners() { 20 | return this.#client.state.lastWinners 21 | } 22 | 23 | constructor() { 24 | super() 25 | this.#client = new WsStateClient(4001) 26 | 27 | this.#client.on('update', () => { 28 | this.emit('update') 29 | }) 30 | } 31 | } 32 | 33 | export default new GameService() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gpn-tron 2 | 3 | A bot clash project for the [GPN21](https://entropia.de/GPN21) (Gulasch Programmiernacht). 4 | 5 | It creates an TCP server where people can connect to and control their player. 6 | The challenge: Create a bot that beats the other players! 7 | 8 | ## Prequisites 9 | - [NodeJS](https://nodejs.org/en) 10 | `apt-get install nodejs npm` 11 | - [NPM](https://docs.npmjs.com) 12 | `see command above` 13 | - [Yarn](https://docs.npmjs.com) 14 | `apt-get install yarn` 15 | - An decent browser (For opening the viewer page) 16 | 17 | ## Installation 18 | 1. Clone this repo 19 | `git clone git@github.com:freehuntx/gpn-tron.git` 20 | 2. Enter the folder 21 | `cd gpn-tron` 22 | 3. Install the dependencies (You need yarn as this repo uses yarn workspace) 23 | `yarn` 24 | 25 | ## Start 26 | 1. Start the servers in dev mode 27 | `yarn dev` 28 | 2. Open the viewer in the browser 29 | `http://localhost:3000` 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 freehuntx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /shared/lib/ws-state/client.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import { io, Socket } from "socket.io-client" 3 | import { applyPatch } from "fast-json-patch" 4 | 5 | export class WsStateClient extends EventEmitter { 6 | #socket: Socket 7 | #state: WsStateType = {} as WsStateType 8 | 9 | constructor(port: number, protocol = 'ws') { 10 | super() 11 | 12 | this.#socket = io(`${protocol}://${typeof location !== 'undefined' ? location.hostname : '127.0.0.1'}:${port}`) 13 | 14 | this.#socket.on('init', state => { 15 | this.#state = state 16 | this.emit('update') 17 | }) 18 | 19 | this.#socket.on('patch', patch => { 20 | // Ensure to not keep references 21 | if (typeof structuredClone === 'function') { 22 | this.#state = structuredClone(applyPatch(this.#state, patch).newDocument) 23 | } else { 24 | this.#state = JSON.parse(JSON.stringify(applyPatch(this.#state, patch).newDocument)) 25 | } 26 | this.emit('update') 27 | }) 28 | } 29 | 30 | close() { 31 | this.#socket.close() 32 | } 33 | 34 | get state() { return this.#state } 35 | } 36 | -------------------------------------------------------------------------------- /shared/lib/ws-state/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer, Server } from "http" 2 | import { Server as IoServer } from "socket.io" 3 | import { generate, observe } from "fast-json-patch" 4 | 5 | const EXTERNAL_REACHABLE = true 6 | 7 | export class WsStateServer { 8 | #server: Server 9 | #io: IoServer 10 | #state: WsStateType 11 | 12 | constructor(port: number, initialState = {} as WsStateType, updateInterval = 1000 / 15) { 13 | this.#server = createServer() 14 | this.#io = new IoServer(this.#server, { cors: { origin: '*' } }) 15 | 16 | this.#io.on('connection', socket => { 17 | socket.emit('init', this.#state) 18 | }) 19 | 20 | this.#state = initialState 21 | 22 | const observer = observe(this.#state) 23 | setInterval(() => { 24 | const patch = generate(observer) 25 | if (patch.length) this.#io.emit('patch', patch) 26 | }, updateInterval) 27 | 28 | setTimeout(() => { 29 | this.#server.listen(port, EXTERNAL_REACHABLE ? undefined : '127.0.0.1') 30 | console.log('View server started on port:', port) 31 | }, 1) 32 | } 33 | 34 | close() { 35 | this.#io.close() 36 | } 37 | 38 | get state() { return this.#state } 39 | } 40 | -------------------------------------------------------------------------------- /ERRORCODES.md: -------------------------------------------------------------------------------- 1 | # Error codes 2 | 3 | Sometimes you will get error codes from the server. 4 | Here is a small documentation of those error codes. 5 | 6 | ## ERROR_SPAM 7 | You are spamming the server. 8 | 9 | ## ERROR_PACKET_OVERFLOW 10 | You are sending more than 1024 bytes to the server. 11 | 12 | ## ERROR_NO_MOVE 13 | You didnt send a move packet. Note: This takes either your last move or UP (default). 14 | 15 | ## ERROR_MAX_CONNECTIONS 16 | Your ip is already connected. IPv6 may workarounds this but please dont flood the server with connections :( 17 | 18 | ## ERROR_JOIN_TIMEOUT 19 | You didnt send the join packet. 20 | 21 | ## ERROR_EXPECTED_JOIN 22 | Instead of join you sent some other packet. 23 | 24 | ## ERROR_INVALID_USERNAME 25 | You used an invalid username. (e.g. not a string) 26 | 27 | ## ERROR_USERNAME_TOO_SHORT 28 | Username is too short. 29 | 30 | ## ERROR_USERNAME_TOO_LONG 31 | Username is too long. 32 | 33 | ## ERROR_USERNAME_INVALID_SYMBOLS 34 | You used invalid symbols for the username. 35 | 36 | ## ERROR_INVALID_PASSWORD 37 | You used an invalid password. (e.g. not a string) 38 | 39 | ## ERROR_PASSWORD_TOO_SHORT 40 | Password is too short. 41 | 42 | ## ERROR_PASSWORD_TOO_LONG 43 | Password is too long. 44 | 45 | ## ERROR_NO_PERMISSION 46 | You should not do this :^) (e.g. hijack bots) 47 | 48 | ## ERROR_WRONG_PASSWORD 49 | Your password was wrong. 50 | 51 | ## ERROR_ALREADY_CONNECTED 52 | You are already connected with this player. 53 | 54 | ## WARNING_UNKNOWN_MOVE 55 | You did not send a valid move packet. 56 | 57 | ## ERROR_DEAD_CANNOT_CHAT 58 | You are dead so shush :^) 59 | 60 | ## ERROR_INVALID_CHAT_MESSAGE 61 | Something is wrong with your chat message. 62 | 63 | ## ERROR_UNKNOWN_PACKET 64 | Server does not know what you want :D 65 | -------------------------------------------------------------------------------- /viewer/components/Schedule.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { useOnMount } from "../hooks/onMount"; 3 | 4 | export function Schedule() { 5 | const [talks, setTalks] = useState<{ title: string; start: Date; end: Date }[]>([]) 6 | const [currentTalks, setCurrentTalks] = useState([]) 7 | const [nextTalks, setNextTalks] = useState([]) 8 | 9 | const update = async () => { 10 | setTalks([]) 11 | setCurrentTalks([]) 12 | setNextTalks([]) 13 | 14 | try { 15 | const response = await fetch('https://cfp.gulas.ch/gpn22/schedule/v/0.26/widget/v2.json').then(res => res.json()) 16 | const date = new Date() 17 | setTalks(response.talks.map(e => { 18 | const room = response.rooms.find(r => r.id === e.room) 19 | return { 20 | id: e.id, 21 | title: typeof e.title === "string" ? e.title : (e.title.de || e.title.en) || "Unknown", 22 | room: typeof room.name === "string" ? room.name : (room.name.de || room.name.en || "Unknown"), 23 | start: new Date(e.start), 24 | end: new Date(e.end) 25 | } 26 | }).filter(e => e.end > date).sort((a,b) => +a.start - +b.start)) 27 | } catch(err) { 28 | console.error(err) 29 | } 30 | } 31 | 32 | useOnMount(() => { 33 | update() 34 | 35 | const updateInterval = setInterval(() => update(), 60000) 36 | return () => { 37 | alert("CLEAR") 38 | clearInterval(updateInterval) 39 | } 40 | }) 41 | 42 | useEffect(() => { 43 | const date = new Date() 44 | 45 | const newCurrentTalks = talks.filter(e => e.start <= date && e.end > date) 46 | let newNextTalks = talks.slice(newCurrentTalks.length) 47 | 48 | if (newNextTalks.length) { 49 | newNextTalks = newNextTalks.filter(e => e.start < new Date(+newNextTalks[0].start + 2*60*60*1000)) 50 | } 51 | 52 | setCurrentTalks(newCurrentTalks) 53 | setNextTalks(newNextTalks) 54 | }, [talks]) 55 | 56 | return
57 |

Current talks

58 |
59 | {currentTalks.map(({ id, title, room, start, end }) => ( 60 |
61 | {title} 62 |
63 | {room} ({start.toTimeString().split(' ')[0]} - {end.toTimeString().split(' ')[0]}) 64 |
65 |
))} 66 |
67 |

Next talks

68 | {nextTalks.map(({ id, title, room, start, end }) => ( 69 |
70 | {title} 71 |
72 | {room} ({start.toTimeString().split(' ')[0]} - {end.toTimeString().split(' ')[0]}) 73 |
74 |
))} 75 |
76 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore my test bots 2 | bots/ 3 | 4 | # Ignore yarn or pnpm to keep it npm only 5 | yarn.lock 6 | pnpm-lock.yaml 7 | package-lock.json 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | .pnpm-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # Snowpack dependency directory (https://snowpack.dev/) 54 | web_modules/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Optional stylelint cache 66 | .stylelintcache 67 | 68 | # Microbundle cache 69 | .rpt2_cache/ 70 | .rts2_cache_cjs/ 71 | .rts2_cache_es/ 72 | .rts2_cache_umd/ 73 | 74 | # Optional REPL history 75 | .node_repl_history 76 | 77 | # Output of 'npm pack' 78 | *.tgz 79 | 80 | # Yarn Integrity file 81 | .yarn-integrity 82 | 83 | # dotenv environment variable files 84 | .env 85 | .env.development.local 86 | .env.test.local 87 | .env.production.local 88 | .env.local 89 | 90 | # parcel-bundler cache (https://parceljs.org/) 91 | .cache 92 | .parcel-cache 93 | 94 | # Next.js build output 95 | .next 96 | out 97 | 98 | # Nuxt.js build / generate output 99 | .nuxt 100 | dist 101 | 102 | # Gatsby files 103 | .cache/ 104 | # Comment in the public line in if your project uses Gatsby and not Next.js 105 | # https://nextjs.org/blog/next-9-1#public-directory-support 106 | # public 107 | 108 | # vuepress build output 109 | .vuepress/dist 110 | 111 | # vuepress v2.x temp and cache directory 112 | .temp 113 | .cache 114 | 115 | # Docusaurus cache and generated files 116 | .docusaurus 117 | 118 | # Serverless directories 119 | .serverless/ 120 | 121 | # FuseBox cache 122 | .fusebox/ 123 | 124 | # DynamoDB Local files 125 | .dynamodb/ 126 | 127 | # TernJS port file 128 | .tern-port 129 | 130 | # Stores VSCode versions used for testing VSCode extensions 131 | .vscode-test 132 | 133 | # yarn v2 134 | .yarn/cache 135 | .yarn/unplugged 136 | .yarn/build-state.yml 137 | .yarn/install-state.gz 138 | .pnp.* 139 | -------------------------------------------------------------------------------- /server/ClientSocket.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "net" 2 | import { EventEmitter } from "events" 3 | //import { maxPacketsPerSecond } from "@gpn-tron/shared/constants/common" 4 | 5 | export class ClientSocket extends EventEmitter { 6 | #connected = false 7 | #ip: string 8 | #socket?: Socket 9 | #sendBuffer = "" 10 | #sendTimeout?: NodeJS.Timeout 11 | 12 | /** 13 | * Create a ClientSocket instance from a tcp socket instance 14 | * @param socket TCP Socket instance 15 | * @returns {ClientSocket | null} Returns a ClientSocket instance or null if there was an error 16 | */ 17 | static fromSocket(socket: Socket) { 18 | try { 19 | return new ClientSocket(socket) 20 | } catch (error) { 21 | console.error(error) 22 | return null 23 | } 24 | } 25 | 26 | constructor(socket: Socket) { 27 | super() 28 | 29 | if (socket.destroyed) throw new Error('Socket is not connected') 30 | if (!socket.remoteAddress) throw new Error('Socket has no valid ip') 31 | 32 | this.#connected = true 33 | this.#socket = socket 34 | this.#ip = socket.remoteAddress 35 | 36 | let buffer = '' 37 | 38 | this.#socket.on('data', chunk => { 39 | // More than x packets per second can be considered as spam. 40 | // Increase packet recv counter by 1 and check if its above the max 41 | //if (this.#recvPacketCount++ > maxPacketsPerSecond) { 42 | // return this.sendError('ERROR_SPAM', true) 43 | //} 44 | 45 | // After a second reduce the packet count by one again so this packet is just counted for 1 second 46 | //setTimeout(() => { 47 | // this.#recvPacketCount-- 48 | //}, 1000) 49 | 50 | buffer += chunk.toString() 51 | 52 | if (buffer.length > 1024) { 53 | this.sendError('ERROR_PACKET_OVERFLOW', true) 54 | return 55 | } 56 | 57 | while (this.#connected && buffer.includes('\n')) { 58 | const packetIndex = buffer.indexOf('\n') 59 | const packetStr = buffer.substring(0, packetIndex) 60 | buffer = buffer.substring(packetIndex + 1) 61 | this.#onPacket(packetStr) 62 | } 63 | }) 64 | 65 | this.#socket.on('close', () => this.disconnect()) 66 | this.#socket.on('end', () => this.disconnect()) 67 | this.#socket.on('error', this.#onError.bind(this)) 68 | } 69 | 70 | get connected(): boolean { return this.#connected } 71 | get ip(): string { return this.#ip } 72 | 73 | disconnect() { 74 | if (!this.#connected) return 75 | 76 | this.#connected = false 77 | this.#socket.removeAllListeners() 78 | this.#socket?.end() 79 | this.#socket?.destroy() 80 | this.#socket = undefined 81 | this.emit('disconnected') 82 | } 83 | 84 | send(type: string, ...args: any) { 85 | return this.rawSend(`${[type, ...args].join('|')}\n`) 86 | } 87 | 88 | rawSend(packet: string) { 89 | if (!this.connected || !this.#socket || this.#socket.destroyed) return 90 | 91 | this.#sendBuffer += packet 92 | 93 | clearTimeout(this.#sendTimeout) 94 | this.#sendTimeout = setTimeout(() => { 95 | try { 96 | this.#socket.write(this.#sendBuffer) 97 | this.#sendBuffer = "" 98 | } 99 | catch (error) { 100 | console.error(error) 101 | this.disconnect() 102 | } 103 | }, 1) 104 | } 105 | 106 | sendError(error: string, disconnect = false) { 107 | this.send('error', error) 108 | if (disconnect) this.disconnect() 109 | } 110 | 111 | #onPacket(packet: string) { 112 | if (!this.connected) return 113 | 114 | const args = packet.split('|').map(arg => /^\-?\d+(\.\d+)?$/.test(arg) ? Number(arg) : arg) 115 | const type = args.shift() 116 | this.emit('packet', type, ...args) 117 | } 118 | 119 | #onError(error: Error & { code: string }) { 120 | if (error?.code !== 'ECONNRESET') console.error(error) 121 | this.disconnect() 122 | } 123 | } -------------------------------------------------------------------------------- /server/Bot.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "net" 2 | 3 | export class Bot { 4 | #socket: Socket 5 | #ip: string 6 | #port: number 7 | #connected = false 8 | #name: string 9 | #pos: Vec2 = { x: 0, y: 0 } 10 | #playerId: number 11 | #width: number 12 | #height: number 13 | #fields: Array> 14 | #reconnectTimeout: NodeJS.Timeout 15 | 16 | constructor(name: string, ip: string, port: number) { 17 | this.#name = name 18 | this.#ip = ip 19 | this.#port = port 20 | 21 | this.connect() 22 | } 23 | 24 | connect() { 25 | let data = '' 26 | 27 | this.#socket = new Socket() 28 | 29 | this.#socket.on('data', chunk => { 30 | data += chunk.toString() 31 | 32 | while (this.#connected && data.includes('\n')) { 33 | const packetIndex = data.indexOf('\n') 34 | const packetStr = data.substring(0, packetIndex) 35 | data = data.substring(packetIndex + 1) 36 | this.onPacket(packetStr) 37 | } 38 | }) 39 | 40 | this.#socket.on('close', () => { 41 | this.disconnect() 42 | }) 43 | 44 | this.#socket.on('error', (error) => { 45 | console.error(error) 46 | this.disconnect() 47 | }) 48 | 49 | this.#socket.connect(this.#port, this.#ip, () => { 50 | this.#connected = true 51 | this.send('join', this.#name, 'password') 52 | }) 53 | } 54 | 55 | disconnect() { 56 | this.#socket?.removeAllListeners() 57 | this.#socket?.destroy() 58 | this.#socket = undefined 59 | this.#connected = false 60 | 61 | clearTimeout(this.#reconnectTimeout) 62 | this.#reconnectTimeout = setTimeout(() => { 63 | this.connect() 64 | }, 1000) 65 | } 66 | 67 | send(type: string, ...args: any) { 68 | if (!this.#connected || !this.#socket || this.#socket.destroyed) return 69 | try { 70 | this.#socket.write(`${[type, ...args].join('|')}\n`) 71 | } 72 | catch (error) { 73 | console.error(error) 74 | this.disconnect() 75 | } 76 | } 77 | 78 | onPacket(packet: string) { 79 | if (!this.#connected) return 80 | 81 | const args = packet.split('|').map(arg => /^\-?\d+(\.\d+)?$/.test(arg) ? Number(arg) : arg) 82 | const type = args.shift() 83 | 84 | if (type === 'motd') { 85 | } 86 | else if (type === 'error') { 87 | console.log('error', ...args) 88 | } 89 | else if (type === 'game') { 90 | const [width, height, playerId] = args as number[] 91 | this.#playerId = playerId 92 | this.#pos = { x: 0, y: 0 } 93 | this.#width = width 94 | this.#height = height 95 | this.#fields = Array(width).fill(null).map(() => Array(height).fill(-1)) 96 | } 97 | else if (type === 'die') { 98 | for (let x = 0; x < this.#width; x++) { 99 | for (let y = 0; y < this.#height; y++) { 100 | const fieldPlayerId = this.#fields[x][y] 101 | if (fieldPlayerId === -1) continue 102 | if (args.indexOf(fieldPlayerId) === -1) continue 103 | this.#fields[x][y] = -1 104 | } 105 | } 106 | } 107 | else if (type === 'lose' || type === 'win') { 108 | this.#playerId = undefined 109 | } 110 | else if (type === 'pos') { 111 | const [playerId, x, y] = args as number[] 112 | this.#fields[x][y] = playerId 113 | 114 | if (playerId === this.#playerId) { 115 | this.#pos.x = x 116 | this.#pos.y = y 117 | } 118 | } 119 | else if (type === 'tick') { 120 | const { x, y } = this.#pos 121 | const possibleMoves: Record = {} 122 | 123 | if (y === 0 && this.#fields[x][this.#height - 1] === -1) { 124 | possibleMoves.up = true 125 | } 126 | if (y > 0 && this.#fields[x][y - 1] === -1) { 127 | possibleMoves.up = true 128 | } 129 | if (x === this.#width - 1 && this.#fields[0][y] === -1) { 130 | possibleMoves.right = true 131 | } 132 | if (x < this.#width - 1 && this.#fields[x + 1][y] === -1) { 133 | possibleMoves.right = true 134 | } 135 | if (y === this.#height - 1 && this.#fields[x][0] === -1) { 136 | possibleMoves.down = true 137 | } 138 | if (y < this.#height - 1 && this.#fields[x][y + 1] === -1) { 139 | possibleMoves.down = true 140 | } 141 | if (x === 0 && this.#fields[this.#width - 1][y] === -1) { 142 | possibleMoves.left = true 143 | } 144 | if (x > 0 && this.#fields[x - 1][y] === -1) { 145 | possibleMoves.left = true 146 | } 147 | 148 | const possibleArr = Object.keys(possibleMoves) 149 | if (possibleArr.length) this.send('move', possibleArr[Math.floor(Math.random() * possibleArr.length)]) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # Protocol 2 | 3 | The protocol is string based. 4 | Every packet must and will end with a newline (\n). 5 | E.g: `pos|10|50|5\n` 6 | **Note:** _All next examples in this documentation are given without \n. But dont forget it in your parsing!_ 7 | 8 | ## Packet structure 9 | 10 | The general packet structure looks like this: 11 | \\<...arguments\> 12 | E.g: `game|5|1|2|3` 13 | Where game is the packet type, 5 the first argument, 1 the second, 2 the third and 3 the fourth 14 | 15 | ## Packet types 16 | 17 | ### motd 18 | 19 | The motd packet is sent by the server when you connect to it. 20 | motd means "Message of the day". 21 | 22 | **Name:** motd 23 | **Sender:** Server 24 | **Arguments:** 25 | | # | Type | Description | 26 | |---|--------|------------------------| 27 | | 1 | String | The message of the day | 28 | 29 | **Example:** `motd|Hello how are you? :)` 30 | 31 | ### join 32 | 33 | The join packet is the first packet the client has to send to the server when connecting. 34 | Remember the password otherwise you cant use the username again! 35 | 36 | **Name:** join 37 | **Sender:** Client 38 | **Arguments:** 39 | | # | Type | Description | 40 | |---|--------|--------------| 41 | | 1 | String | The username | 42 | | 2 | String | The password | 43 | 44 | **Example:** `join|Cool Guy|mysupersecret` 45 | 46 | ### error 47 | 48 | The error packet is sent by the server if something went wrong. 49 | 50 | **Name:** error 51 | **Sender:** Server 52 | **Arguments:** 53 | | # | Type | Description | 54 | |---|--------|--------------| 55 | | 1 | String | The error according to [ERRORCODES.md](ERRORCODES.md) | 56 | 57 | **Example:** `error|INVALID_USERNAME` 58 | 59 | ### game 60 | 61 | The game packet is sent by the server to inform the client about the new game round. 62 | It contains information about the map size and the current player id. 63 | 64 | **Name:** game 65 | **Sender:** Server 66 | **Arguments:** 67 | | # | Type | Description | 68 | |---|--------|-------------------------------| 69 | | 1 | Number | The width of the current map | 70 | | 2 | Number | The height of the current map | 71 | | 3 | Number | The current player id | 72 | 73 | **Example:** `game|100|100|5` 74 | 75 | ### pos 76 | 77 | The pos packet is sent by the server to inform the client about a players current position. 78 | 79 | **Name:** pos 80 | **Sender:** Server 81 | **Arguments:** 82 | | # | Type | Description | 83 | |---|--------|--------------------------------------------------------------------| 84 | | 1 | Number | The player id | 85 | | 2 | Number | x position of the player | 86 | | 3 | Number | y position of the player | 87 | 88 | **Example:** `pos|5|3|8` 89 | 90 | ### player 91 | 92 | The player packet is sent by the server to share informations of an player. 93 | 94 | **Name:** player 95 | **Sender:** Server 96 | **Arguments:** 97 | | # | Type | Description | 98 | |---|--------|--------------------------------------------------------------------| 99 | | 1 | Number | The player id | 100 | | 2 | String | The name of the player | 101 | 102 | **Example:** `player|3|Coolguy` 103 | 104 | ### tick 105 | 106 | The tick packet is sent by the server after a turn has been done. Its the best to send a move packet after this! 107 | 108 | **Name:** tick 109 | **Sender:** Server 110 | **Arguments:** None 111 | 112 | **Example:** `tick` 113 | 114 | ### die 115 | 116 | The die packet is sent by the server to inform the client about a players who died. 117 | 118 | **Name:** die 119 | **Sender:** Server 120 | **Arguments:** 121 | | # | Type | Description | 122 | |---|--------|--------------------------------------------------------------------| 123 | | 1... | Number | The player id | 124 | 125 | **Example (1 dead player):** `die|5` 126 | **Example (4 dead player):** `die|5|8|9|13` 127 | 128 | ### move 129 | 130 | The move packet is sent by the client to decide where to move. 131 | 132 | **Name:** move 133 | **Sender:** Client 134 | **Arguments:** 135 | | # | Type | Description | 136 | |---|--------|-------------------------| 137 | | 1 | String | up, right, down or left | 138 | 139 | **Example:** `move|up` 140 | 141 | ### chat 142 | 143 | The chat packet is sent by the client to send a cool chat message :>. 144 | 145 | **Name:** chat 146 | **Sender:** Client 147 | **Arguments:** 148 | | # | Type | Description | 149 | |---|--------|-----------------------------| 150 | | 1 | String | The chat message to display | 151 | 152 | **Example:** `chat|I am so cool` 153 | 154 | ### message 155 | 156 | The message packet is sent by the server to inform about a chat message of another player 157 | 158 | **Name:** message 159 | **Sender:** Server 160 | **Arguments:** 161 | | # | Type | Description | 162 | |---|--------|-----------------------------| 163 | | 1 | Number | The player id | 164 | | 2 | String | The chat message to display | 165 | 166 | **Example:** `message|7|I am so cool` 167 | 168 | ### win 169 | 170 | The win packet is sent by the server to inform the client they won. 171 | 172 | **Name:** win 173 | **Sender:** Server 174 | **Arguments:** 175 | | # | Type | Description | 176 | |---|--------|-----------------| 177 | | 1 | Number | amount of wins | 178 | | 2 | Number | amount of losses | 179 | 180 | **Example:** `win|1|20` 181 | 182 | ### lose 183 | 184 | The lose packet is sent by the server to inform the client they lost. 185 | 186 | **Name:** lose 187 | **Sender:** Server 188 | **Arguments:** 189 | | # | Type | Description | 190 | |---|--------|-----------------| 191 | | 1 | Number | amount of wins | 192 | | 2 | Number | amount of losses | 193 | 194 | **Example:** `lose|1|20` 195 | -------------------------------------------------------------------------------- /server/Player.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import { escapeString, isStringValid } from "@gpn-tron/shared/utils/string" 3 | import { ClientSocket } from "./ClientSocket" 4 | import { ScoreHistory, ScoreType } from "./Game" 5 | 6 | export enum Move { 7 | NONE, 8 | UP, 9 | RIGHT, 10 | DOWN, 11 | LEFT 12 | } 13 | 14 | export class Player extends EventEmitter { 15 | #socket?: ClientSocket 16 | #id = -1 17 | #alive: boolean 18 | #username: string 19 | #password: string 20 | #chatMessage?: string 21 | #pos = { x: 0, y: 0 } 22 | #moves: Vec2[] = [] 23 | #move: Move = Move.NONE 24 | #lastMove: Move = Move.NONE 25 | #scoreHistory: ScoreHistory = [] 26 | #eloScore = 1000 27 | #state: PlayerState 28 | #chatTimeout: NodeJS.Timeout 29 | 30 | constructor(username: string, password: string) { 31 | super() 32 | this.#username = username 33 | this.#password = password 34 | 35 | this.#initializeState() 36 | } 37 | 38 | get id(): number { return this.#id } 39 | set id(id: number) { 40 | this.#id = id 41 | this.#state.id = id 42 | } 43 | get username(): string { return this.#username } 44 | get password(): string { return this.#password } 45 | get alive(): boolean { return this.#alive } 46 | get chatMessage(): string { return this.#chatMessage } 47 | get pos(): Vec2 { return this.#pos } 48 | get moves(): Vec2[] { return this.#moves } 49 | get connected(): boolean { return !!this.#socket?.connected } 50 | get eloScore(): number { return this.#eloScore } 51 | set eloScore(eloScore: number) { this.#eloScore = eloScore } 52 | get state() { return this.#state } 53 | 54 | // Returns the time filtered scores. Everything above 2 hours is removed. 55 | get scoreHistory(): ScoreHistory { 56 | const now = Date.now() 57 | this.#scoreHistory = this.#scoreHistory.filter(({ time }) => now - time <= (2 * 60 * 60 * 1000)) 58 | return this.#scoreHistory 59 | } 60 | set scoreHistory(newHistory: ScoreHistory) { 61 | this.#scoreHistory = newHistory 62 | } 63 | get wins(): number { 64 | return this.scoreHistory.filter(({ type }) => type === ScoreType.WIN).length 65 | } 66 | get loses(): number { 67 | return this.scoreHistory.filter(({ type }) => type === ScoreType.LOOSE).length 68 | } 69 | get winRatio(): number { 70 | const games = this.wins + this.loses 71 | return games > 0 ? this.wins / games : 0 72 | } 73 | 74 | #initializeState() { 75 | this.#state = { 76 | id: this.#id, 77 | alive: false, 78 | name: this.#username, 79 | pos: this.#pos, 80 | moves: [] 81 | } 82 | } 83 | 84 | setSocket(socket: ClientSocket) { 85 | this.disconnect() 86 | 87 | this.#socket = socket 88 | this.#socket.on('packet', this.#onPacket.bind(this)) 89 | this.#socket.on('disconnected', this.disconnect.bind(this)) 90 | } 91 | 92 | spawn(x: number, y: number) { 93 | this.#alive = true 94 | this.#moves = [] 95 | this.#state.alive = true 96 | this.#state.moves = [] 97 | this.setPos(x, y) 98 | } 99 | 100 | setPos(x: number, y: number) { 101 | this.#pos.x = x 102 | this.#pos.y = y 103 | this.#state.pos.x = x 104 | this.#state.pos.y = y 105 | this.#moves.push({ x, y }) 106 | this.#state.moves.push({ x, y }) 107 | } 108 | 109 | disconnect() { 110 | let disconnected = this.connected 111 | 112 | this.#socket?.removeAllListeners() 113 | this.#socket?.disconnect() 114 | this.#socket = undefined 115 | 116 | if (disconnected) this.emit('disconnected') 117 | } 118 | 119 | send(type: string, ...args: any) { 120 | if (!this.connected) return 121 | this.#socket?.send(type, ...args) 122 | } 123 | 124 | rawSend(packet: string) { 125 | if (!this.connected) return 126 | this.#socket?.rawSend(packet) 127 | } 128 | 129 | readMove(): Move { 130 | let move = Move.UP // Default move 131 | 132 | if (this.#move === Move.NONE) { 133 | this.sendError('ERROR_NO_MOVE') 134 | 135 | if (this.#lastMove !== Move.NONE) { 136 | move = this.#lastMove 137 | } 138 | } else { 139 | this.#lastMove = this.#move 140 | move = this.#move 141 | this.#move = Move.NONE 142 | } 143 | 144 | return move 145 | } 146 | 147 | win() { 148 | this.#scoreHistory.push({ type: ScoreType.WIN, time: Date.now() }) 149 | this.send('win', this.wins, this.loses) 150 | } 151 | 152 | lose() { 153 | this.#scoreHistory.push({ type: ScoreType.LOOSE, time: Date.now() }) 154 | this.send('lose', this.wins, this.loses) 155 | this.kill() 156 | } 157 | 158 | kill() { 159 | if (!this.#alive) return 160 | this.#alive = false 161 | this.#state.alive = false 162 | } 163 | 164 | sendError(error: string, disconnect = false) { 165 | this.#socket?.sendError(error, disconnect) 166 | } 167 | 168 | #onPacket(packetType: string, ...args: any) { 169 | if (packetType === 'move') { 170 | const [direction] = args 171 | if (direction === 'up') this.#move = Move.UP 172 | else if (direction === 'right') this.#move = Move.RIGHT 173 | else if (direction === 'down') this.#move = Move.DOWN 174 | else if (direction === 'left') this.#move = Move.LEFT 175 | else this.sendError('WARNING_UNKNOWN_MOVE') 176 | } 177 | else if (packetType === 'chat') { 178 | const chatMessage = escapeString(args[0] || '') 179 | 180 | if (!this.#alive) { 181 | this.sendError('ERROR_DEAD_CANNOT_CHAT') 182 | } 183 | else if (!isStringValid(chatMessage)) { 184 | this.sendError('ERROR_INVALID_CHAT_MESSAGE') 185 | } else { 186 | this.#chatMessage = chatMessage 187 | this.#state.chat = chatMessage 188 | 189 | this.emit('chat', chatMessage) 190 | 191 | // Clear the chat message in 5 seconds 192 | clearTimeout(this.#chatTimeout) 193 | this.#chatTimeout = setTimeout(() => { 194 | this.#chatMessage = undefined 195 | this.#state.chat = undefined 196 | }, 5000) 197 | } 198 | } else { 199 | console.log('UNKNOWN PACKET') 200 | this.sendError('ERROR_UNKNOWN_PACKET') 201 | } 202 | } 203 | } -------------------------------------------------------------------------------- /server/Game.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events" 2 | import { MultiElo } from "multi-elo" 3 | import { baseTickrate, tickIncreaseInterval } from "@gpn-tron/shared/constants/common" 4 | import { Player, Move } from "./Player" 5 | 6 | export enum ScoreType { 7 | LOOSE, 8 | WIN 9 | } 10 | 11 | export type Score = { 12 | type: ScoreType 13 | time: number 14 | } 15 | 16 | export type ScoreHistory = Score[] 17 | 18 | export class Game extends EventEmitter { 19 | #id: string 20 | #players: Player[] 21 | #width: number 22 | #height: number 23 | #fields: Array> 24 | #state: GameState 25 | #tickRate = baseTickrate 26 | #startTime = Date.now() 27 | 28 | get state() { 29 | return this.#state 30 | } 31 | get alivePlayers(): Player[] { 32 | return this.#players.filter(({ alive }) => alive) 33 | } 34 | get deadPlayers(): Player[] { 35 | return this.#players.filter(({ alive }) => !alive) 36 | } 37 | 38 | constructor(players: Player[]) { 39 | super() 40 | 41 | this.#id = Math.random().toString(32).slice(2) 42 | this.#players = players 43 | 44 | this.#initializePlayers() 45 | this.#initializeFields() 46 | this.#initializeGame() 47 | this.#initializeState() 48 | 49 | setTimeout(() => this.#onTick(), 1000 / baseTickrate) 50 | } 51 | 52 | broadcastToAlive(type: string, ...args: any) { 53 | for (const player of this.alivePlayers) { 54 | player.send(type, ...args) 55 | } 56 | } 57 | 58 | broadcast(type: string, ...args: any) { 59 | this.#players.forEach(player => { 60 | player.send(type, ...args) 61 | }) 62 | } 63 | 64 | #removePlayerFromFields(player: Player) { 65 | player.moves.forEach(({ x, y }) => { 66 | this.#fields[x][y] = -1 67 | }) 68 | } 69 | 70 | #initializePlayers() { 71 | // Shuffle the players 72 | this.#players.sort(() => 0.5 - Math.random()) 73 | 74 | for (let i=0; i state), 85 | } 86 | } 87 | 88 | #initializeFields() { 89 | this.#width = this.#players.length * 2 90 | this.#height = this.#players.length * 2 91 | this.#fields = Array(this.#width).fill(null).map(() => Array(this.#height).fill(-1)) 92 | 93 | for (let i = 0; i < this.#players.length; i++) { 94 | const x = i * 2 95 | const y = i * 2 96 | this.#fields[x][y] = i // Set the current player id to the spawn field 97 | this.#players[i].spawn(x, y) 98 | } 99 | } 100 | 101 | #initializeGame() { 102 | const onEndRemover = [] 103 | this.once('end', () => { 104 | onEndRemover.forEach(fn => fn()) 105 | }) 106 | 107 | for (const player of this.alivePlayers) { 108 | player.send('game', this.#width, this.#height, player.id) 109 | 110 | // Watch for chat messages and share them with all players 111 | const onChat = message => { 112 | this.broadcastToAlive('message', player.id, message) 113 | } 114 | player.on('chat', onChat) 115 | 116 | onEndRemover.push(() => { 117 | player.off('chat', onChat) 118 | }) 119 | } 120 | 121 | this.#broadcastPlayerPacket() 122 | this.#broadcastPos() 123 | this.broadcastToAlive('tick') 124 | } 125 | 126 | #broadcastPlayerPacket() { 127 | let playerPacket = '' 128 | for (const player of this.alivePlayers) { 129 | playerPacket += `player|${player.id}|${player.username}\n` 130 | } 131 | for (const player of this.alivePlayers) { 132 | player.rawSend(playerPacket) 133 | } 134 | } 135 | 136 | #broadcastPos() { 137 | let updatePacket = '' 138 | for (const player of this.alivePlayers) { 139 | const { x, y } = player.pos 140 | updatePacket += `pos|${player.id}|${x}|${y}\n` 141 | } 142 | 143 | for (const player of this.alivePlayers) { 144 | player.rawSend(updatePacket) 145 | } 146 | } 147 | 148 | #onTick() { 149 | const newDeadPlayers: Player[] = [] 150 | 151 | // Remove disconnected players 152 | this.alivePlayers.filter(({ connected }) => !connected).forEach(player => { 153 | newDeadPlayers.push(player) 154 | player.kill() 155 | this.#removePlayerFromFields(player) 156 | }) 157 | 158 | // Update player position 159 | for (const player of this.alivePlayers) { 160 | const move = player.readMove() 161 | let { x, y } = player.pos 162 | 163 | if (move === Move.UP) { 164 | if (y === 0) y = this.#height - 1 165 | else y-- 166 | } 167 | else if (move === Move.RIGHT) { 168 | if (x === this.#width - 1) x = 0 169 | else x++ 170 | } 171 | else if (move === Move.DOWN) { 172 | if (y === this.#height - 1) y = 0 173 | else y++ 174 | } 175 | else if (move === Move.LEFT) { 176 | if (x === 0) x = this.#width - 1 177 | else x-- 178 | } 179 | 180 | player.setPos(x, y) 181 | } 182 | 183 | // Apply move to fields 184 | for (const player of this.alivePlayers) { 185 | const { x, y } = player.pos 186 | const fieldPlayerIndex = this.#fields[x][y] 187 | const fieldPlayer = this.#players[fieldPlayerIndex] 188 | 189 | // If field is free move to it 190 | if (!fieldPlayer) { 191 | this.#fields[x][y] = player.id 192 | continue 193 | } 194 | 195 | // If both people entered the field at the same time, kill both 196 | if (fieldPlayer !== player && fieldPlayer.pos.x === x && fieldPlayer.pos.y === y) { 197 | newDeadPlayers.push(fieldPlayer) 198 | fieldPlayer.kill() 199 | } 200 | 201 | newDeadPlayers.push(player) 202 | player.kill() 203 | } 204 | 205 | // Cleanup fields of dead players and make them lose 206 | newDeadPlayers.forEach(player => { 207 | this.#removePlayerFromFields(player) 208 | player.lose() 209 | }) 210 | 211 | // Inform about dead players and pos updates 212 | let updatePacket = '' 213 | if (newDeadPlayers.length) { 214 | updatePacket += `die|${newDeadPlayers.map(({ id }) => id).join('|')}\n` 215 | } 216 | for (const player of this.alivePlayers) { 217 | const { x, y } = player.pos 218 | updatePacket += `pos|${player.id}|${x}|${y}\n` 219 | } 220 | 221 | for (const player of this.alivePlayers) { 222 | player.rawSend(updatePacket) 223 | } 224 | 225 | // Check for game end 226 | let shouldEnd = false 227 | if (this.#players.length === 1 && this.alivePlayers.length === 0) shouldEnd = true 228 | else if (this.#players.length > 1 && this.alivePlayers.length <= 1) shouldEnd = true 229 | 230 | if (shouldEnd) { 231 | const winners: Player[] = this.alivePlayers 232 | winners.forEach(p => p.win()) 233 | 234 | const losers = this.deadPlayers 235 | 236 | // Update ELO scores 237 | if (winners.length && losers.length) { 238 | const playersInOrder = [...winners, ...losers]; 239 | const placesInOrder = [...(winners.map(player => 1)), ...(losers.map(player => 2))]; 240 | const newEloScores = MultiElo.getNewRatings(playersInOrder.map(player => player.eloScore), placesInOrder); 241 | for (let i = 0; i < playersInOrder.length; i++) { 242 | playersInOrder[i].eloScore = newEloScores[i]; 243 | } 244 | } 245 | 246 | this.emit('end', winners) 247 | } else { 248 | this.broadcastToAlive('tick') 249 | 250 | // Dynamically define tickrate 251 | const timeDiff = Date.now() - this.#startTime 252 | this.#tickRate = baseTickrate + Math.floor(timeDiff / 1000 / tickIncreaseInterval) 253 | 254 | setTimeout(() => this.#onTick(), 1000 / this.#tickRate) 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /viewer/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { getColorByString } from "@gpn-tron/shared/constants/colors" 3 | import { 4 | LineChart, 5 | Line, 6 | XAxis, 7 | YAxis, 8 | Legend, 9 | ResponsiveContainer 10 | } from "recharts" 11 | import { Game } from "../components/Game" 12 | import gameService from "../services/GameService" 13 | import { Schedule } from "../components/Schedule" 14 | 15 | export default function Home() { 16 | const [active, setActive] = useState(false) 17 | const [serverInfoList, setServerInfoList] = useState([]) 18 | const [lastWinners, setLastWinners] = useState([]) 19 | const [scoreboard, setScoreboard] = useState([]) 20 | const [chartData, setChartData] = useState([]) 21 | const [chatMessages, setChatMessages] = useState<{ date: number; from: string; message: string }[]>([]) 22 | 23 | const lines: Record = {}; 24 | chartData.forEach((point) => { 25 | Object.keys(point).sort().forEach((key, index) => { 26 | if (key === "name") return; 27 | lines[key] = { 28 | key, 29 | stroke: getColorByString(key) 30 | }; 31 | }); 32 | }); 33 | 34 | useEffect(() => { 35 | const playersLastMessages = {} 36 | let tmpChatMessages: { date: number; from: string; message: string }[] = [] 37 | 38 | const onUpdate = () => { 39 | setChartData(gameService.chartData) 40 | setScoreboard(gameService.scoreboard) 41 | 42 | for (const player of gameService.game?.players || []) { 43 | if (!player.chat) { 44 | playersLastMessages[player.name] = undefined 45 | continue 46 | } 47 | if (playersLastMessages[player.name] === player.chat) continue 48 | playersLastMessages[player.name] = player.chat 49 | tmpChatMessages.push({ date: Date.now(), from: player.name, message: player.chat }) 50 | tmpChatMessages = tmpChatMessages.slice(-30) 51 | } 52 | setChatMessages(tmpChatMessages) 53 | } 54 | gameService.on('update', onUpdate) 55 | 56 | return () => { 57 | gameService.off('update', onUpdate) 58 | } 59 | }, []) 60 | 61 | useEffect(() => { 62 | // Do this to prevent SSR 63 | setActive(true) 64 | 65 | gameService.on('update', () => { 66 | setServerInfoList(gameService.serverInfoList) 67 | setLastWinners(gameService.lastWinners) 68 | }) 69 | }, []) 70 | 71 | if (!active || !gameService.game) return null 72 | return ( 73 | <> 74 |
75 | {/* Infobox */} 76 |
82 |

GPN Tron

83 | Connect via TCP and join the fun :) 84 |
85 | You can also watch the current game via the viewer port. 86 |
87 |
88 | Wanna share your bot code? Upload to Github with #gpn-tron 89 |
90 | {/* ConnectionInfo */} 91 |
97 |

Ports:

98 |
    99 |
  • - 3000 [HTTP] (View server)
  • 100 |
  • - {serverInfoList[0]?.port || 4000} [TCP] (Game server)
  • 101 |
102 |

Hostnames:

103 |
    104 | {serverInfoList.map(({ host, port }) => ( 105 |
  • - {host}
  • 106 | ))} 107 |
108 |
109 | {/* Scoreboard */} 110 |
117 |

Scoreboard (Last 2 Hours)

118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | {scoreboard.map(({ username, winRatio, wins, loses, elo }, index) => ( 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | ))} 141 | {scoreboard.length === 0 && ( 142 | 143 | 144 | 145 | )} 146 | 147 |
NameWRELOWinsLosses
{index + 1}.{username} {lastWinners.indexOf(username) !== -1 && '🎉'}{winRatio.toFixed(2)}{elo.toFixed(0)}{wins}{loses}
Nobody scored yet :(
148 |
149 | {/* Chart */} 150 |
160 | 161 | 162 | 163 | 164 | 165 | {Object.values(lines).map(({ key, stroke }) => ( 166 | 167 | ))} 168 | 169 | 170 |
171 | {/*Game*/} 172 |
180 | 181 |
182 | {/* Fahrplan */} 183 |
193 | 194 |
195 | {/* Chat */} 196 |
206 |

Chat

207 |
210 | {[...chatMessages].reverse().map(({ date, from, message }) => ( 211 |
212 | {from} ({new Date(date).toISOString().replace(/^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+).*$/, '$3.$2.$1 - $4:$5:$6')}) 213 |
{message} 214 |
215 | ))} 216 |
217 |
218 |
219 | 220 | ) 221 | } 222 | -------------------------------------------------------------------------------- /server/GameServer.ts: -------------------------------------------------------------------------------- 1 | import { networkInterfaces, tmpdir } from "os" 2 | import { existsSync, readFileSync, writeFileSync } from "fs" 3 | import { createServer, Server, Socket } from "net" 4 | import { joinTimeout, maxConnections } from "@gpn-tron/shared/constants/common" 5 | import { isStringValid } from "@gpn-tron/shared/utils/string" 6 | import { WsStateServer } from "@gpn-tron/shared/lib/ws-state/server" 7 | import { ClientSocket } from "./ClientSocket" 8 | import { Player } from "./Player" 9 | import { Game, ScoreType } from "./Game" 10 | 11 | const VIEW_PORT = parseInt(process.env.VIEW_PORT || '') || 4001 12 | const GAME_DATA_PATH = `${tmpdir()}/gpn-tron-data.json` 13 | const HOSTNAMES = Object.values(networkInterfaces()) 14 | .map(e => e || []) 15 | .flat() 16 | .filter(e => !e.internal) 17 | .map(({ address }) => address) 18 | HOSTNAMES.unshift('gpn-tron.duckdns.org') 19 | 20 | export class GameServer { 21 | #port: number // Port number of the game tcp server 22 | #tcpServer: Server // TCP Server instance 23 | #viewServer: WsStateServer // View server instance 24 | #connectionIpMap: Record = {} // Used to count the amount of connections per ip 25 | #players: Record = {} // Map of players. Key=username, Value=player 26 | #game?: Game // Game instance (if a game is active) 27 | 28 | constructor(port: number) { 29 | this.#port = port 30 | this.#tcpServer = createServer(this.#onSocket.bind(this)) 31 | this.#tcpServer.on('error', error => console.error(error)) 32 | 33 | this.#initViewServer() 34 | this.#loadGameData() 35 | this.#updateScoreboard() 36 | 37 | setTimeout(() => { 38 | this.#tcpServer.listen(this.#port) 39 | console.log('Game server started on port:', this.#port) 40 | }, 1) 41 | 42 | setTimeout(() => this.#startGame(), 1000) 43 | } 44 | 45 | #initViewServer() { 46 | this.#viewServer = new WsStateServer(VIEW_PORT, { 47 | serverInfoList: HOSTNAMES.map(host => ({ 48 | host, 49 | port: this.#port 50 | })), 51 | chartData: [], 52 | scoreboard: [], 53 | lastWinners: [] 54 | }) 55 | } 56 | 57 | /** 58 | * This method will load stored game data 59 | */ 60 | #loadGameData() { 61 | // Create the file if it was not found 62 | if (!existsSync(GAME_DATA_PATH)) { 63 | writeFileSync(GAME_DATA_PATH, '{}') // Empty object is default 64 | } 65 | 66 | try { 67 | const gameData = JSON.parse(readFileSync(GAME_DATA_PATH).toString()) 68 | if (!gameData.players) gameData.players = {} 69 | 70 | const playerdata: Record = gameData.players 71 | for (const [username, { password, scoreHistory, eloScore }] of Object.entries(playerdata)) { 72 | if (!this.#players[username]) this.#players[username] = new Player(username, password) 73 | if (scoreHistory) this.#players[username].scoreHistory = scoreHistory 74 | if (eloScore) this.#players[username].eloScore = eloScore; 75 | } 76 | } catch (error) { } 77 | } 78 | 79 | /** 80 | * This method will store game data 81 | */ 82 | #storeGameData() { 83 | // Create the file if it was not found 84 | if (!existsSync(GAME_DATA_PATH)) { 85 | writeFileSync(GAME_DATA_PATH, '{}') // Empty object is default 86 | } 87 | 88 | try { 89 | const gameData = JSON.parse(readFileSync(GAME_DATA_PATH).toString()) 90 | if (!gameData.players) gameData.players = {} 91 | 92 | for (const { username, password, scoreHistory, eloScore } of Object.values(this.#players)) { 93 | gameData.players[username] = { password, scoreHistory, eloScore } 94 | } 95 | 96 | writeFileSync(GAME_DATA_PATH, JSON.stringify(gameData, null, 2)) 97 | } catch (error) { } 98 | } 99 | 100 | #updateScoreboard() { 101 | const scoreboardPlayers: Player[] = Object.values(this.#players) 102 | .sort((a, b) => { 103 | const winRatioDiff = b.winRatio - a.winRatio 104 | if (winRatioDiff !== 0) return winRatioDiff 105 | const winDiff = b.wins - a.wins 106 | if (winDiff !== 0) return winDiff 107 | return b.loses - a.loses 108 | }) 109 | .slice(0, 10) 110 | 111 | this.#viewServer.state.scoreboard = scoreboardPlayers 112 | .map(({ username, winRatio, wins, loses, eloScore }) => ({ username, winRatio, wins, loses, elo: eloScore })) 113 | 114 | this.#updateChartData(scoreboardPlayers) 115 | } 116 | 117 | #updateChartData(players: Player[]) { 118 | const chartPoints = 20 119 | const chartData = Array(chartPoints).fill(undefined).map((_, index) => { 120 | const chartPoint: Record = { 121 | name: index 122 | } 123 | 124 | for (const player of players) { 125 | const historyIndex = player.scoreHistory.length - (chartPoints - 1 - index) 126 | const wins = player.scoreHistory.slice(0, historyIndex).filter(e => e.type === ScoreType.WIN).length 127 | const loses = player.scoreHistory.slice(0, historyIndex).filter(e => e.type === ScoreType.LOOSE).length 128 | const games = wins + loses 129 | const winRatio = games > 0 ? wins / games : 0 130 | 131 | chartPoint[player.username] = winRatio 132 | } 133 | 134 | return chartPoint 135 | }) 136 | 137 | this.#viewServer.state.chartData = chartData 138 | } 139 | 140 | /** 141 | * This method will create a game instance and add current connected players to it. 142 | * The method will call itself to keep games running. 143 | * @param difficulty A number that decides the map difficulty 144 | */ 145 | #startGame() { 146 | if (this.#game) throw new Error('Game in progress') 147 | 148 | const connectedPlayers = Object.values(this.#players).filter(player => player.connected) 149 | if (!connectedPlayers.length) { 150 | setTimeout(() => this.#startGame(), 1000) 151 | return 152 | } 153 | 154 | this.#game = new Game(connectedPlayers) // Create a new game 155 | this.#viewServer.state.game = this.#game.state 156 | 157 | // Lets listen to the game end event 158 | this.#game.once('end', (winners: Player[]) => { 159 | this.#game = undefined 160 | 161 | this.#viewServer.state.lastWinners = winners.map(({ username }) => username) 162 | 163 | // Store the current game data 164 | this.#storeGameData() 165 | 166 | this.#updateScoreboard() 167 | 168 | // Since the game did end lets create a new one 169 | setTimeout(() => this.#startGame(), 1000) 170 | }) 171 | } 172 | 173 | /** 174 | * Our callback which is called as soon as a peer connects to the tcp server 175 | * @param socket The tcp client socket that connected to this server 176 | */ 177 | async #onSocket(socket: Socket) { 178 | const clientSocket = ClientSocket.fromSocket(socket) // Lets try to create a ClientSocket instance which has alot of useful functions 179 | if (!clientSocket) return 180 | 181 | if (!this.#connectionIpMap[clientSocket.ip]) this.#connectionIpMap[clientSocket.ip] = 0 182 | this.#connectionIpMap[clientSocket.ip]++ 183 | 184 | clientSocket.on('disconnected', () => { 185 | this.#connectionIpMap[clientSocket.ip]-- 186 | }) 187 | 188 | if (maxConnections >= 0 && this.#connectionIpMap[clientSocket.ip] > maxConnections && !clientSocket.ip.endsWith('127.0.0.1')) { 189 | console.log("ERROR_MAX_CONNECTIONS:", clientSocket.ip) 190 | return clientSocket.sendError('ERROR_MAX_CONNECTIONS', true) 191 | } 192 | 193 | clientSocket.send('motd', 'You can find the protocol documentation here: https://github.com/freehuntx/gpn-tron/blob/master/PROTOCOL.md') 194 | 195 | // We need a timeout to detect if a client takes too long to join. 5 seconds should be fine 196 | const joinTimeoutTimer = setTimeout(() => { 197 | clientSocket.sendError('ERROR_JOIN_TIMEOUT', true) 198 | }, joinTimeout) 199 | 200 | // We listen once to the packet event. We expect the first packet to be a join packet 201 | clientSocket.once('packet', (packetType: string, username: string, password: string) => { 202 | if (packetType !== 'join') return clientSocket.sendError('ERROR_EXPECTED_JOIN', true) 203 | clearTimeout(joinTimeoutTimer) // Timeout is not needed as the client sent the join packet 204 | 205 | // Check the username 206 | if (typeof username !== "string") return clientSocket.sendError('ERROR_INVALID_USERNAME', true) 207 | if (username.length < 1) return clientSocket.sendError('ERROR_USERNAME_TOO_SHORT', true) 208 | if (username.length > 32) return clientSocket.sendError('ERROR_USERNAME_TOO_LONG', true) 209 | 210 | if (!isStringValid(username)) return clientSocket.sendError('ERROR_USERNAME_INVALID_SYMBOLS', true) 211 | 212 | // Check the password 213 | if (typeof password !== "string") return clientSocket.sendError('ERROR_INVALID_PASSWORD', true) 214 | if (password.length < 1) return clientSocket.sendError('ERROR_PASSWORD_TOO_SHORT', true) 215 | if (username.length > 128) return clientSocket.sendError('ERROR_PASSWORD_TOO_LONG', true) 216 | 217 | // Check for bots 218 | if (/^bot\d*$/.test(username)) { 219 | if (!clientSocket.ip.endsWith('127.0.0.1')) { 220 | return clientSocket.sendError('ERROR_NO_PERMISSION', true) 221 | } 222 | } 223 | 224 | // If we already have a player instance for this username lets use that 225 | let player = this.#players[username] 226 | 227 | if (!player) { 228 | // Create a new player if we dont know this user yet 229 | player = new Player(username, password) 230 | this.#players[username] = player 231 | } else { 232 | // There is a player with this name already? Check if the password is correct! 233 | if (player.password !== password) return clientSocket.sendError('ERROR_WRONG_PASSWORD', true) 234 | if (player.connected) { 235 | player.sendError('ERROR_ALREADY_CONNECTED', true) 236 | } 237 | } 238 | 239 | player.setSocket(clientSocket) // Lets update the socket of this player 240 | }) 241 | } 242 | } -------------------------------------------------------------------------------- /viewer/classes/GameRenderer.ts: -------------------------------------------------------------------------------- 1 | import { getColorByString } from "@gpn-tron/shared/constants/colors" 2 | import gameService from "../services/GameService" 3 | 4 | const wallSize = 1 5 | const floorSize = 16 6 | const roomSize = floorSize + wallSize 7 | 8 | const drawPlayerLine = (context: CanvasRenderingContext2D, playerRadius: number, color: string, from: Vec2, to: Vec2) => { 9 | context.strokeStyle = color 10 | context.lineWidth = playerRadius * 2 11 | context.beginPath() 12 | context.moveTo(from.x, from.y) 13 | context.lineTo(to.x, to.y) 14 | context.stroke() 15 | } 16 | 17 | export class GameRenderer { 18 | #canvas: HTMLCanvasElement 19 | #context: CanvasRenderingContext2D 20 | #offScreenCanvas = document.createElement('canvas') 21 | #offScreenContext = this.#offScreenCanvas.getContext('2d') 22 | #canvasPixelSize: number 23 | #viewFactor: number 24 | 25 | get factoredRoomSize() { 26 | return roomSize * this.#viewFactor 27 | } 28 | get factoredWallSize() { 29 | return wallSize * this.#viewFactor 30 | } 31 | get factoredHalfWallSize() { 32 | return this.factoredWallSize / 2 33 | } 34 | get factoredHalfRoomSize() { 35 | return this.factoredRoomSize / 2 36 | } 37 | get factoredFloorSize() { 38 | return floorSize * this.#viewFactor 39 | } 40 | get playerRadius() { 41 | return this.factoredFloorSize * 0.4 42 | } 43 | 44 | constructor(canvas: HTMLCanvasElement) { 45 | this.#canvas = canvas 46 | this.#context = canvas.getContext('2d') 47 | } 48 | 49 | #updateCanvasSize() { 50 | this.#canvasPixelSize = Math.min( 51 | this.#canvas.parentElement.clientHeight, 52 | this.#canvas.parentElement.clientWidth 53 | ) 54 | this.#offScreenCanvas.width = this.#canvasPixelSize 55 | this.#offScreenCanvas.height = this.#canvasPixelSize 56 | } 57 | 58 | #updateViewFactor() { 59 | const size = Math.max(gameService.game.width, gameService.game.height) 60 | const pixelSize = size * roomSize 61 | this.#viewFactor = this.#canvasPixelSize / pixelSize 62 | } 63 | 64 | #renderWalls() { 65 | // Render walls 66 | this.#offScreenContext.strokeStyle = 'white' 67 | this.#offScreenContext.lineWidth = 1 68 | for (let x = 0; x < gameService.game.width; x++) { 69 | const tmpX = x * this.factoredRoomSize 70 | 71 | this.#offScreenContext.beginPath() 72 | this.#offScreenContext.moveTo(tmpX, 0) 73 | this.#offScreenContext.lineTo(tmpX, this.#canvas.height) 74 | this.#offScreenContext.stroke() 75 | 76 | for (let y = 0; y < gameService.game.height; y++) { 77 | const tmpY = y * this.factoredRoomSize 78 | 79 | this.#offScreenContext.beginPath() 80 | this.#offScreenContext.moveTo(0, tmpY) 81 | this.#offScreenContext.lineTo(this.#canvas.width, tmpY) 82 | this.#offScreenContext.stroke() 83 | } 84 | } 85 | } 86 | 87 | #renderPlayers() { 88 | const { game } = gameService 89 | if (!game) return 90 | 91 | for (const player of game.players) { 92 | let { alive, name, pos: { x, y }, moves } = player 93 | if (!alive) continue 94 | 95 | const playerColor = getColorByString(name) 96 | x *= this.factoredRoomSize 97 | y *= this.factoredRoomSize 98 | x += this.factoredHalfRoomSize 99 | y += this.factoredHalfRoomSize 100 | 101 | // Render paths 102 | for (let moveIndex = 0; moveIndex < moves.length; moveIndex++) { 103 | if (moveIndex === 0) continue 104 | const prevPos = moves[moveIndex - 1] 105 | const pos = moves[moveIndex] 106 | 107 | let prevX = prevPos.x 108 | let prevY = prevPos.y 109 | let posX = pos.x 110 | let posY = pos.y 111 | 112 | if (prevPos.x === 0 && pos.x === game.width - 1) { 113 | prevX = 0 114 | posX = -1 115 | drawPlayerLine(this.#offScreenContext, this.playerRadius, playerColor, { 116 | x: game.width * this.factoredRoomSize + this.factoredRoomSize / 2, 117 | y: pos.y * this.factoredRoomSize + this.factoredRoomSize / 2 118 | }, { 119 | x: (game.width-1) * this.factoredRoomSize + this.factoredRoomSize / 2, 120 | y: pos.y * this.factoredRoomSize + this.factoredRoomSize / 2 121 | }) 122 | } 123 | if (prevPos.x === game.width - 1 && pos.x === 0) { 124 | prevX = game.width - 1 125 | posX = game.width 126 | drawPlayerLine(this.#offScreenContext, this.playerRadius, playerColor, { 127 | x: -1 * this.factoredRoomSize + this.factoredRoomSize / 2, 128 | y: pos.y * this.factoredRoomSize + this.factoredRoomSize / 2 129 | }, { 130 | x: 0 * this.factoredRoomSize + this.factoredRoomSize / 2, 131 | y: pos.y * this.factoredRoomSize + this.factoredRoomSize / 2 132 | }) 133 | } 134 | if (prevPos.y === 0 && pos.y === game.height - 1) { 135 | prevY = 0 136 | posY = -1 137 | drawPlayerLine(this.#offScreenContext, this.playerRadius, playerColor, { 138 | x: pos.x * this.factoredRoomSize + this.factoredRoomSize / 2, 139 | y: game.height * this.factoredRoomSize + this.factoredRoomSize / 2 140 | }, { 141 | x: pos.x * this.factoredRoomSize + this.factoredRoomSize / 2, 142 | y: (game.height-1) * this.factoredRoomSize + this.factoredRoomSize / 2 143 | }) 144 | } 145 | if (prevPos.y === game.height - 1 && pos.y === 0) { 146 | prevY = game.height - 1 147 | posY = game.height 148 | drawPlayerLine(this.#offScreenContext, this.playerRadius, playerColor, { 149 | x: pos.x * this.factoredRoomSize + this.factoredRoomSize / 2, 150 | y: -1 * this.factoredRoomSize + this.factoredRoomSize / 2 151 | }, { 152 | x: pos.x * this.factoredRoomSize + this.factoredRoomSize / 2, 153 | y: 0 * this.factoredRoomSize + this.factoredRoomSize / 2 154 | }) 155 | } 156 | 157 | const fromX = prevX * this.factoredRoomSize + this.factoredRoomSize / 2 158 | const fromY = prevY * this.factoredRoomSize + this.factoredRoomSize / 2 159 | const toX = posX * this.factoredRoomSize + this.factoredRoomSize / 2 160 | const toY = posY * this.factoredRoomSize + this.factoredRoomSize / 2 161 | 162 | // Draw start head 163 | this.#offScreenContext.fillStyle = playerColor 164 | this.#offScreenContext.beginPath() 165 | this.#offScreenContext.arc(x, y, this.playerRadius, 0, 2 * Math.PI, false); 166 | this.#offScreenContext.fill() 167 | 168 | // Draw player line 169 | drawPlayerLine(this.#offScreenContext, this.playerRadius, playerColor, { x: fromX, y: fromY }, { x: toX, y: toY }) 170 | 171 | // Draw corners 172 | this.#offScreenContext.beginPath() 173 | this.#offScreenContext.arc(fromX, fromY, this.playerRadius, 0, 2 * Math.PI, false); 174 | this.#offScreenContext.fill() 175 | } 176 | 177 | // Draw head 178 | this.#offScreenContext.fillStyle = playerColor 179 | this.#offScreenContext.beginPath() 180 | this.#offScreenContext.arc(x, y, this.playerRadius, 0, 2 * Math.PI, false); 181 | this.#offScreenContext.fill() 182 | } 183 | } 184 | 185 | #renderNames() { 186 | const { game } = gameService 187 | if (!game) return 188 | 189 | for (const player of game.players) { 190 | let { alive, name, pos: { x, y } } = player 191 | if (!alive) continue 192 | 193 | const playerColor = getColorByString(name) 194 | x *= this.factoredRoomSize 195 | y *= this.factoredRoomSize 196 | x += this.factoredHalfRoomSize 197 | y += this.factoredHalfRoomSize 198 | 199 | const textHeight = 18 200 | 201 | this.#offScreenContext.font = `bold ${textHeight}px serif` 202 | const nameMetrics = this.#offScreenContext.measureText(name) 203 | 204 | const nameX = x - nameMetrics.width / 2 - 10 205 | const nameY = y - textHeight * 3 - 5 206 | 207 | // Draw name box 208 | this.#offScreenContext.fillStyle = playerColor 209 | this.#offScreenContext.strokeStyle = 'white' 210 | this.#offScreenContext.lineWidth = 2 211 | this.#offScreenContext.beginPath() 212 | this.#offScreenContext.rect(nameX, nameY, nameMetrics.width + 10, textHeight + 10) 213 | this.#offScreenContext.fill() 214 | this.#offScreenContext.stroke() 215 | 216 | // Draw player name 217 | this.#offScreenContext.textBaseline = 'top' 218 | this.#offScreenContext.fillStyle = 'white' 219 | this.#offScreenContext.fillText(name, nameX + 5, nameY + 5) 220 | } 221 | } 222 | 223 | #renderChat() { 224 | const { game } = gameService 225 | if (!game) return 226 | 227 | for (const player of game.players) { 228 | let { alive, pos: { x, y }, moves, chat } = player 229 | if (!alive || !chat) continue 230 | 231 | x *= this.factoredRoomSize 232 | y *= this.factoredRoomSize 233 | x += this.factoredHalfRoomSize 234 | y += this.factoredHalfRoomSize 235 | this.#offScreenContext.fillStyle = 'white' 236 | this.#offScreenContext.fillRect(x - 10, y + this.factoredRoomSize - 20, this.#offScreenContext.measureText(chat).width + 20, 40) 237 | this.#offScreenContext.fillStyle = 'black' 238 | this.#offScreenContext.fillText(chat, x, y + this.factoredRoomSize) 239 | } 240 | } 241 | 242 | render() { 243 | if (!this.#canvas || !this.#canvas.parentElement || !gameService.game) return 244 | 245 | this.#updateCanvasSize() 246 | this.#updateViewFactor() 247 | 248 | // Clear frame 249 | this.#offScreenContext.fillStyle = '#090a35' 250 | this.#offScreenContext.clearRect(0, 0, this.#canvas.width, this.#canvas.height) 251 | this.#offScreenContext.fillRect(0, 0, this.#canvas.width, this.#canvas.height) 252 | 253 | this.#renderWalls() 254 | this.#renderPlayers() 255 | this.#renderNames() 256 | this.#renderChat() 257 | 258 | // Now push the rendering to real canvas 259 | this.#canvas.width = this.#offScreenCanvas.width 260 | this.#canvas.height = this.#offScreenCanvas.height 261 | this.#context.drawImage(this.#offScreenCanvas, 0, 0) 262 | } 263 | } 264 | --------------------------------------------------------------------------------