commit 51f04156176f9d6752a1dd0deb0e779d96a272c6 Author: Sascha Nitsch Date: Mon Mar 3 02:17:50 2025 +0100 initial im diff --git a/client-hodejs/.gitignore b/client-hodejs/.gitignore new file mode 100644 index 0000000..a6ea870 --- /dev/null +++ b/client-hodejs/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +*.js diff --git a/client-hodejs/esbuild.mjs b/client-hodejs/esbuild.mjs new file mode 100644 index 0000000..8d081da --- /dev/null +++ b/client-hodejs/esbuild.mjs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import * as esbuild from 'esbuild' + +await esbuild.build({ + entryPoints: ['rabbit.ts', 'targetpractice.ts', 'simplehunter.ts'], + bundle: true, + platform: 'node', + target: ['node22'], + packages: 'external', + outdir: './' +}) diff --git a/client-hodejs/package.json b/client-hodejs/package.json new file mode 100644 index 0000000..6ce2152 --- /dev/null +++ b/client-hodejs/package.json @@ -0,0 +1,21 @@ +{ + "name": "robotank-base", + "version": "0.0.1", + "description": "base robot functions for the robotank simulation", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "node esbuild.mjs" + }, + "author": "", + "license": "GPL-3.0-or-later", + "dependencies": { + "ws": "^8.18.1" + }, + "optionalDependencies": { + "bufferutil": "^4.0.9" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/client-hodejs/rabbit.ts b/client-hodejs/rabbit.ts new file mode 100644 index 0000000..162425b --- /dev/null +++ b/client-hodejs/rabbit.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { RobotStatus } from './robotstatus'; +import { RoboBase } from './robobase'; + +class Rabbit extends RoboBase { + carrotX: number = 0; + carrotY: number = 0; + running = false; + mode = 'MUNCH'; + munchtime = 0; + ready() { + console.log('rabbit is ready'); + } + + start() { + console.log('simulation started'); + this.cmd.powerRight = 0; + this.cmd.powerLeft = 0; + this.cmd.radarMin = 270; + this.cmd.radarMax = 90; + this.sendCmd(); + this.running = true; + } + + statusReady(status: RobotStatus) { + if (!this.running) { + return; + } + switch (this.mode) { + case 'RUN': + const dx = this.carrotX - status.posX; + const dy = this.carrotY - status.posY; + const dir = Math.atan2(dx, -dy) * 180 / Math.PI; + const dst = Math.sqrt(dx * dx + dy * dy); + let rot = dir - status.orientation; + if (rot < -180) { + rot += 360; + } + if (rot > 180) { + rot -= 360; + } + rot = Math.min(Math.max(-90, rot), 90); + let desiredSpeed = 1; + if (dst < 30) { + desiredSpeed = dst / 40; + } + const speedDiff = Math.sin(rot * Math.PI / 180.0); + // desired speed of left chain + let dsl = desiredSpeed + speedDiff; + // desired speed of right chain + let dsr = desiredSpeed - speedDiff; + // prevent overflows, reduce to limits but keep relative difference if possible + if (dsl > 1) { + dsr -= Math.max(dsl - 1, -1); + dsl = 1; + } else if (dsr > 1) { + dsl -= Math.max(dsr - 1, -1); + dsr = 1; + } + this.cmd.powerLeft = dsl; + this.cmd.powerRight = dsr; + // close and slow enough? + if (dst < 5 && Math.abs(status.chainSpeedLeft) < .1 && Math.abs(status.chainSpeedRight) < .1) { + this.mode = 'MUNCH'; + this.cmd.powerLeft = 0; + this.cmd.powerRight = 0; + this.munchtime = 5 * 20; // 5 sec + console.log("munch"); + } + break; + case "MUNCH": + if (this.munchtime) { + --this.munchtime; + } else { + this.mode = 'RUN'; + this.carrotX = Math.random() * (this.width - 20) + 10; + this.carrotY = Math.random() * (this.height - 20) + 10; + console.log("target reached, new target", this.carrotX, this.carrotY); + } + break; + } + if (status.contactPoints && status.contactPoints.length > 0) { + status.contactPoints.forEach((cp) => { + // adjust angle by radar rotation + const angle = cp.angle + status.radarPos; + // global angle from position + const globalAngle = angle + status.orientation; + const targetX = Math.sin(globalAngle * Math.PI / 180)* cp.dist + status.posX; + const targetY = -Math.cos(globalAngle * Math.PI / 180)* cp.dist + status.posY; + // run the other way +- 45 degree + let newTargetAngle = globalAngle + 180 + Math.random()*90 - 45 + if (newTargetAngle >= 360) { + newTargetAngle -= 360; + } + if (newTargetAngle < 0) { + newTargetAngle += 360; + } + // target is 50-100 units away + const newTargetDistance = Math.random() * 50 + 50; + this.carrotX = status.posX + Math.sin(newTargetAngle * Math.PI / 180) * newTargetDistance; + this.carrotY = status.posY - Math.cos(newTargetAngle * Math.PI / 180) * newTargetDistance; + if (this.carrotX > this.width - 10) { + this.carrotX = this.width - 10; + } + if (this.carrotY > this.height - 10) { + this.carrotY = this.height - 10; + } + if (this.carrotX < 0) { + this.carrotX = 10; + } + if (this.carrotY < 0) { + this.carrotY = 10; + } + console.log("enemy at", targetX, targetY, "run to", this.carrotX, this.carrotY); + }); + } + this.sendCmd(); + } + + lost() { + console.log('we lost'); + this.disconnect(); + } + + won() { + console.log('we won'); + this.disconnect(); + } +} + +var rabbit = new Rabbit('rabbit', 'scout'); +rabbit.connect(); diff --git a/client-hodejs/robobase.ts b/client-hodejs/robobase.ts new file mode 100644 index 0000000..87db1da --- /dev/null +++ b/client-hodejs/robobase.ts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import WebSocket from 'ws'; +import { RobotCmd } from './robotcmd'; +import { RobotStatus } from './robotstatus'; + +abstract class RoboBase { + private pingTimeout: NodeJS.Timeout; + private socket: WebSocket; + private name: string; + private model: string; + protected cmd: RobotCmd; + protected width: number; + protected height: number; + private lastUpdate: number; + protected deltaTime: number; + + constructor(name: string, model:string) { + this.name = name; + this.model = model; + this.cmd = new RobotCmd(); + this.width = 0; + this.height = 0; + this.lastUpdate = 0; + this.deltaTime = 0; + } + + connect() { + this.socket = new WebSocket('ws://127.0.0.1:3000/robot', undefined, { + perMessageDeflate: false, + autoPong: true + }); + this.socket.addEventListener('error', this.error); + this.socket.addEventListener('open', () => { + this.open(); + this.heartbeat(); + }); + this.socket.addEventListener('ping', this.heartbeat); + this.socket.addEventListener('message', this.message); + this.socket.addEventListener('close', () => { + clearTimeout(this.pingTimeout); + }); + } + + disconnect() { + this.socket.close(); + } + + error(...param) { + console.error(param); + } + + private heartbeat = () => { + clearTimeout(this.pingTimeout); + this.pingTimeout = setTimeout(() => { + this.socket.terminate(); + }, 30000 + 1000); + } + + message = (event: MessageEvent) => { + this.heartbeat(); + const msgtext = event.data.toString(); + const tokens = msgtext.split(' '); + if (tokens.length == 1) { + switch (tokens[0]) { + case 'lost': + this.lost(); + break; + case 'ready': + this.ready(); + break; + case 'start': + this.start(); + break; + case 'won': + this.won(); + break; + default: + console.log('msg', tokens[0]) + } + } else if (tokens.length == 2) { + switch (tokens[0]) { + case 'specs': + const specs = JSON.parse(tokens[1]); + this.width = specs['width']; + this.height = specs['height']; + break; + case 'status': + const status:RobotStatus = JSON.parse(tokens[1]); + this.deltaTime = status.simulationTime - this.lastUpdate; + this.lastUpdate = status.simulationTime; + this.statusReady(status); + break; + default: + console.log('unknown command tk2 ', msgtext); + } + } else { + console.log('unknown command tk3', msgtext); + } + } + + open() { + this.socket.send('name ' + this.name); + this.socket.send('model ' + this.model); + } + + sendCmd() { + this.socket.send('cmd ' + JSON.stringify(this.cmd.toObject())); + this.cmd.fire = false; + } + + abstract lost(): void; + abstract ready(): void; + abstract start(): void; + abstract statusReady(status: RobotStatus): void; + abstract won(): void; +} + +export { RoboBase } diff --git a/client-hodejs/robotcmd.ts b/client-hodejs/robotcmd.ts new file mode 120000 index 0000000..e075483 --- /dev/null +++ b/client-hodejs/robotcmd.ts @@ -0,0 +1 @@ +../server-nodejs/robotcmd.ts \ No newline at end of file diff --git a/client-hodejs/robotstatus.ts b/client-hodejs/robotstatus.ts new file mode 120000 index 0000000..57ef5b5 --- /dev/null +++ b/client-hodejs/robotstatus.ts @@ -0,0 +1 @@ +../server-nodejs/robotstatus.ts \ No newline at end of file diff --git a/client-hodejs/simplehunter.ts b/client-hodejs/simplehunter.ts new file mode 100644 index 0000000..718564f --- /dev/null +++ b/client-hodejs/simplehunter.ts @@ -0,0 +1,132 @@ +import { RobotStatus } from "./robotstatus"; +import { RoboBase } from "./robobase"; + +class SimpleHunter extends RoboBase { + targetX: number = 0; + targetY: number = 0; + fireCoolDown: number = 0; + mode: string = "SCAN"; + ready() { + console.log("simpleHunter is ready"); + this.cmd.radarMin = -1; + this.cmd.radarMax = -1; + this.sendCmd(); + } + start() { + console.log("simulation started"); + this.targetX = Math.random() * (this.width - 20) + 10; + this.targetY = Math.random() * (this.height - 20) + 10; + console.log("target", this.targetX, this.targetY); + } + driveToTarget(status: RobotStatus) { + const dx = this.targetX - status.posX; + const dy = this.targetY - status.posY; + const dir = Math.atan2(dx, -dy) * 180 / Math.PI; + const dst = Math.sqrt(dx * dx + dy * dy); + let rot = dir - status.orientation; + if (rot < -180) { + rot += 360; + } + if (rot > 180) { + rot -= 360; + } + rot = Math.min(Math.max(-90, rot), 90); + let desiredSpeed = 1; + if (this.mode === 'SCAN') { + if (dst < 30) { + desiredSpeed = dst / 40; + } + } else { + if (dst < 75) { + desiredSpeed = dst - 50 / 25; + } + } + const speedDiff = Math.sin(rot * Math.PI / 180.0); + // desired speed of left chain + let dsl = desiredSpeed + speedDiff; + // desired speed of right chain + let dsr = desiredSpeed - speedDiff; + // prevent overflows, reduce to limits but keep relative difference if possible + if (dsl > 1) { + dsr -= Math.max(dsl - 1, -1); + dsl = 1; + } else if (dsr > 1) { + dsl -= Math.max(dsr - 1, -1); + dsr = 1; + } + this.cmd.powerLeft = dsl; + this.cmd.powerRight = dsr; + // close and slow enough? + if (dst < 5 && Math.abs(status.chainSpeedLeft) < .1 && Math.abs(status.chainSpeedRight) < .1 && this.mode === 'SCAN') { + this.targetX = Math.random() * (this.width - 20) + 10; + this.targetY = Math.random() * (this.height - 20) + 10; + console.log("target reached, new target", this.targetX, this.targetY); + } + } + + statusReady(status: RobotStatus) { + this.driveToTarget(status); + if (this.fireCoolDown > 0) { + this.fireCoolDown -= this.deltaTime; + } + if (status.contactPoints && status.contactPoints.length > 0) { + status.contactPoints.forEach((cp) => { + // adjust angle by radar rotation + const angle = (cp.angle + status.radarPos) % 360; + // global angle from position + let newTargetAngle = angle + status.orientation; + if (newTargetAngle >= 360) { + newTargetAngle -= 360; + } + if (newTargetAngle < 0) { + newTargetAngle += 360; + } + this.targetX = Math.sin(newTargetAngle * Math.PI / 180)* (cp.dist - 50) + status.posX; + this.targetY = -Math.cos(newTargetAngle * Math.PI / 180)* (cp.dist - 50) + status.posY; + this.cmd.gunTarget = angle; + console.log("enemy at", this.targetX, this.targetY, "dist", cp.dist, "angle", angle); + this.mode = 'SEEK'; + this.cmd.radarMin = angle - 20; + this.cmd.radarMax = angle + 20; + // shoot if aimed close enough + if (cp.dist < 100 && cp.dist > 25) { + console.log("diff", angle, status.gunOrient, Math.abs((angle-status.gunOrient)%360)); + if (this.fireCoolDown <= 0 && (Math.abs(angle-status.gunOrient) < 2)) { + this.cmd.fire = true; + console.log("fire", cp.dist); + this.fireCoolDown = 10; + } + } + }); + } else { + if (this.mode === 'SEEK') { // lost track + this.cmd.radarMin = (this.cmd.radarMin + 359.5) % 360; + this.cmd.radarMax = (this.cmd.radarMax + .5) % 360; + //console.log("seek radar", this.cmd.radarMin, this.cmd.radarMax); + if (this.cmd.radarMax - this.cmd.radarMin > 180) { + this.cmd.radarMin = -1; + this.cmd.radarMax = -1; + this.mode = 'SCAN'; + console.log("scan"); + } + } + } + if (status.causedDamage > 0) { + console.log("we hit with damage", status.causedDamage); + } + this.sendCmd(); + } + + lost() { + console.log("we lost"); + this.disconnect(); + } + won() { + console.log("we won"); + this.disconnect(); + } + +} + +var simpleHunter = new SimpleHunter("targetPractise", "scout"); +simpleHunter.connect(); \ No newline at end of file diff --git a/client-hodejs/targetpractice.ts b/client-hodejs/targetpractice.ts new file mode 100644 index 0000000..19faa83 --- /dev/null +++ b/client-hodejs/targetpractice.ts @@ -0,0 +1,39 @@ +import { RobotStatus } from "./robotstatus"; +import { RoboBase } from "./robobase"; + +class TargetPractice extends RoboBase { + lastHealth = 100; + ready() { + console.log("targetPractice is ready"); + this.cmd.radarMin = -1; + this.cmd.radarMax = -1; + this.cmd.gunTarget = 0; + this.sendCmd(); + } + start() { + console.log("simulation started"); + this.cmd.radarMin = -1; + this.cmd.radarMax = -1; + this.sendCmd(); + } + statusReady(status: RobotStatus) { + if (status.health != this.lastHealth) { + console.log("got hit life left:", status.health); + this.lastHealth = status.health; + } + // no actions, no send + // this.sendCmd(); + } + lost() { + console.log("we lost"); + this.disconnect(); + } + won() { + console.log("we won, how?"); + this.disconnect(); + } + +} + +var targetPractice = new TargetPractice("targetPractice", "scout"); +targetPractice.connect(); \ No newline at end of file diff --git a/server-nodejs/.gitignore b/server-nodejs/.gitignore new file mode 100644 index 0000000..7a25e29 --- /dev/null +++ b/server-nodejs/.gitignore @@ -0,0 +1,3 @@ +node_modules +*.js +package-lock.json diff --git a/server-nodejs/esbuild.mjs b/server-nodejs/esbuild.mjs new file mode 100644 index 0000000..9ba9d71 --- /dev/null +++ b/server-nodejs/esbuild.mjs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import * as esbuild from 'esbuild' + +await esbuild.build({ + entryPoints: ['index.ts'], + bundle: true, + platform: 'node', + target: ['node10.4'], + packages: 'external', + outfile: 'index.js', +}) diff --git a/server-nodejs/htdocs/index.html b/server-nodejs/htdocs/index.html new file mode 100644 index 0000000..d7d67c8 --- /dev/null +++ b/server-nodejs/htdocs/index.html @@ -0,0 +1,186 @@ + + + + + WebSocket Chat + + + +
+ +
+ + + + \ No newline at end of file diff --git a/server-nodejs/htdocs/radar.png b/server-nodejs/htdocs/radar.png new file mode 100644 index 0000000..f8c9d0a Binary files /dev/null and b/server-nodejs/htdocs/radar.png differ diff --git a/server-nodejs/htdocs/tank.png b/server-nodejs/htdocs/tank.png new file mode 100644 index 0000000..581d88e Binary files /dev/null and b/server-nodejs/htdocs/tank.png differ diff --git a/server-nodejs/index.ts b/server-nodejs/index.ts new file mode 100644 index 0000000..1360fba --- /dev/null +++ b/server-nodejs/index.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { MyWebSocketServer } from './websockets/server'; +import { WorldSim } from './worldsim'; + +const worldSim = new WorldSim(); +const webserver = new MyWebSocketServer(worldSim); +webserver.start(); +worldSim.start(2) +.then( () => { + webserver.stop().then( () => { + //console.log("stopped", process._getActiveRequests());//,process._getActiveHandles(), ); + //console.log("stopped", process._getActiveHandles()); + process.exit(0); + }); +}); diff --git a/server-nodejs/package.json b/server-nodejs/package.json new file mode 100644 index 0000000..a07d7db --- /dev/null +++ b/server-nodejs/package.json @@ -0,0 +1,19 @@ +{ + "name": "server-nodejs", + "version": "0.0.1", + "description": "Server for robotank", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "node esbuild.mjs" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "^0.25.0", + "ws": "^8.18.1" + }, + "optionalDependencies": { + "bufferutil": "^4.0.9" + } +} diff --git a/server-nodejs/robot.ts b/server-nodejs/robot.ts new file mode 100644 index 0000000..2d13581 --- /dev/null +++ b/server-nodejs/robot.ts @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { WorldSim } from './worldsim'; +import { RobotCmd } from './robotcmd'; +import { RobotStatusExt } from './robotstatus'; + +class Robot { + private name: string; + private model: string; + private status: RobotStatusExt; + private cmd: RobotCmd; + private world: WorldSim; + private uuid: string; + private width: number; + private height: number; + private fireCooldown: number; + private topSpeed: number = 20; + private radarRange: number = 200; + + constructor(worldsim: WorldSim, uuid: string, width: number, height: number) { + this.world = worldsim; + this.uuid = uuid; + this.name = ''; + this.model = ''; + this.status = new RobotStatusExt(); + this.width = width; + this.height = height; + this.status.posX = Math.random()*(width - 20) + 10, + this.status.posY = Math.random()*(height - 20) + 10, + this.status.orientation = Math.random()*360; + this.fireCooldown = 0; + this.cmd = new RobotCmd; + this.cmd.mergeObject({ + powerLeft: 0, + powerRight: 0, + radarMin: 0, + radarMax: 360, + gunTarget: 0, + fire: false + }); + } + + causedDamage(amount: number) { + this.status.causedDamage = amount; + } + damage(amount: number) { + this.status.health -= amount; + } + + getStatus() : RobotStatusExt { + return this.status; + } + + getX() : number { + return this.status.posX; + } + + getY() : number { + return this.status.posY; + } + + isReady() : boolean { + return this.name !== '' && this.model !== ''; + } + + moveRobot(dt: number) { + if (this.cmd.fire && this.fireCooldown <= 0) { + this.world.fire(this.uuid, this.status.posX, this.status.posY, this.status.orientation + this.status.gunOrient); + this.fireCooldown = 10; // 10 seconds to reload + } + if (this.fireCooldown > 0) { + this.fireCooldown -= dt; + } + this.status.chainSpeedLeft += (0.3*(this.cmd.powerLeft-this.status.chainSpeedLeft)); + this.status.chainSpeedRight += (0.3*(this.cmd.powerRight-this.status.chainSpeedRight)); + const speed = (this.status.chainSpeedLeft + this.status.chainSpeedRight)/2.0; + this.status.posX += speed * Math.sin(this.status.orientation * Math.PI / 180.0) * dt * this.topSpeed; + this.status.posY -= speed * Math.cos(this.status.orientation * Math.PI / 180.0) * dt * this.topSpeed; + if (this.status.posX < 0) { + this.status.posX = 0; + this.damage(1); + } + if (this.status.posY < 0) { + this.status.posY = 0; + this.damage(1); + } + if (this.status.posX > this.width) { + this.status.posX = this.width; + this.damage(1); + } + if (this.status.posY > this.height) { + this.status.posY = this.height; + this.damage(1); + } + + this.status.orientation += (this.status.chainSpeedLeft - this.status.chainSpeedRight) * 30 * dt; + if (this.status.orientation >= 360.0) this.status.orientation -= 360.0; + if (this.status.orientation < 0.0) this.status.orientation += 360.0; + if (this.cmd.radarMax == this.cmd.radarMin && this.cmd.radarMax == -1) { // full 360 + this.status.radarPos += 60 * dt; // 60 degree per sec + if (this.status.radarPos > 360) this.status.radarPos -= 360; + } else if (this.status.radarInc) { + this.status.radarPos = (this.status.radarPos + 60 * dt) % 360; + if (Math.abs(this.cmd.radarMax - this.status.radarPos) < 30 && this.cmd.radarMax <= this.status.radarPos) { + this.status.radarInc = false; + } + } else { + this.status.radarPos = (360 + this.status.radarPos - 60 * dt) % 360; + if (Math.abs(this.cmd.radarMin - this.status.radarPos)< 30 && this.cmd.radarMin < this.status.radarPos) { + this.status.radarInc = true; + } + } + if (Math.abs(this.status.gunOrient - this.cmd.gunTarget) > 0.1) { + const relTarget = this.cmd.gunTarget; + const relPos = this.status.gunOrient; + const diffcw = ((relPos - relTarget)+360)%360; + const diffccw = ((relTarget-relPos)+360)%360; + //console.log(relTarget, relPos, diffcw, diffccw, diffcw > diffccw); + const diff = Math.min(Math.abs((relTarget - relPos)%360), 2.5) * (diffcw > diffccw ? 1 : -1); + this.status.gunOrient = (this.status.gunOrient + diff + 360) %360; + } + } + + setName(name: string) { + this.name = name; + } + + setModel(model: string) { + // TODO, read from file + this.model = model; + } + setSimulationTime(time: number) { + this.status.simulationTime = time; + } + + updateCmd(newCmd: RobotCmd) { + this.cmd.mergeObject(newCmd); + if (this.cmd.powerLeft > 1) { + this.cmd.powerLeft = 1; + } + if (this.cmd.powerLeft < -1) { + this.cmd.powerLeft = -1; + } + if (this.cmd.powerRight > 1) { + this.cmd.powerRight = 1; + } + if (this.cmd.powerRight < -1) { + this.cmd.powerRight = -1; + } + } + + updateRadar() { + this.status.contactPoints = []; + this.world.robots.forEach((robot) => { + if (robot != this) { + // x distance to other robot + const dx = robot.status.posX - this.status.posX; + // y distance to other robot + const dy = robot.status.posY - this.status.posY; + // direction to other robot + const dir = Math.atan2(dx, -dy) / Math.PI * 180; + // distance to other robot + const dst = Math.sqrt(dx * dx + dy * dy); + // relative rotation to other robot + let rot = dir - this.status.orientation - this.status.radarPos; + // ensure -180 > rot < 180 + while (rot > 180) { + rot -= 360; + } + while (rot < -180) { + rot += 360; + } + if (((this.status.radarInc && rot>=-6 && rot <=0) || (!this.status.radarInc && rot>=0 && rot <=6)) && dst < this.radarRange) { + this.status.contactPoints.push({angle: rot, dist: dst}); + } + } + }); + } +} + +export { Robot }; diff --git a/server-nodejs/robotcmd.ts b/server-nodejs/robotcmd.ts new file mode 100644 index 0000000..6089a81 --- /dev/null +++ b/server-nodejs/robotcmd.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +class RobotCmd { + powerLeft: number = 0; + powerRight: number = 0; + radarMin: number = -1; + radarMax: number = -1; + gunTarget: number = -1; + fire?: boolean; + + toObject() { + const o: Object = {}; + if (this.powerLeft !== -9999) { + o['powerLeft'] = this.powerLeft; + } + if (this.powerRight !== -9999) { + o['powerRight'] = this.powerRight; + } + o['radarMin'] = this.radarMin; + o['radarMax'] = this.radarMax; + if (this.gunTarget !== -1) { + o['gunTarget'] = this.gunTarget; + } + if (this.fire === true) { + o['fire'] = true; + } + return o; + } + + mergeObject(o: Object) { + if (o['powerLeft'] !== undefined) { + this.powerLeft = o['powerLeft']; + } + if (o['powerRight'] !== undefined) { + this.powerRight = o['powerRight']; + } + if (o['radarMin'] !== undefined) { + this.radarMin = o['radarMin']; + } + if (o['radarMax'] !== undefined) { + this.radarMax = o['radarMax']; + } + if (o['gunTarget'] !== undefined) { + this.gunTarget = o['gunTarget']; + } + this.fire = o['fire'] === true; + } +} + +export { RobotCmd } \ No newline at end of file diff --git a/server-nodejs/robotstatus.ts b/server-nodejs/robotstatus.ts new file mode 100644 index 0000000..b66dde4 --- /dev/null +++ b/server-nodejs/robotstatus.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +interface ContactPoint { + dist: number; + angle: number; +} +class RobotStatus { + /** + * current health 0 = death, max depends on robot + */ + health: number; + /** + * x position, range 0..1000 + */ + posX: number; + /** + * x position, range 0..1000 + */ + posY: number; + /** + * rotation, range 0..360, 0: north, 90: east + */ + orientation: number; + /** + * gun orientation (relative to tank), range 0..360, 0 front, 90 right + */ + gunOrient: number + /** + * radar_position 0..360, 0 front, 90 right + */ + radarPos: number + contactPoints: ContactPoint[]; + chainSpeedLeft: number; + chainSpeedRight: number; + simulationTime: number; + causedDamage: number; + + constructor() { + this.health = 100; + this.posX = 0; + this.posY = 0; + this.orientation = 0; + this.gunOrient = 0; + this.radarPos = 0; + this.contactPoints = []; + this.chainSpeedLeft = 0; + this.chainSpeedRight = 0; + this.simulationTime = 0; + this.causedDamage = 0; + } + private prec(input: number): number { + return Math.round(input * 100) / 100; + } + toJSON() : string { + return JSON.stringify({ + health: this.health, + posX: this.prec(this.posX), + posY: this.prec(this.posY), + orientation: this.prec(this.orientation), + gunOrient: this.prec(this.gunOrient), + radarPos: this.prec(this.radarPos), + contactPoints: this.contactPoints, + chainSpeedLeft: this.prec(this.chainSpeedLeft), + chainSpeedRight: this.prec(this.chainSpeedRight), + simulationTime: this.prec(this.simulationTime), + causedDamage: this.prec(this.causedDamage), + }); + } +} + +class RobotStatusExt extends RobotStatus { + radarInc: boolean; + constructor() { + super(); + this.radarInc = true; + } + toJSON() : string { + return super.toJSON(); + } +} + +export {RobotStatus, RobotStatusExt}; \ No newline at end of file diff --git a/server-nodejs/websockets/monitor.ts b/server-nodejs/websockets/monitor.ts new file mode 100644 index 0000000..589129f --- /dev/null +++ b/server-nodejs/websockets/monitor.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { WebSocketServer } from "ws"; + +class MonitorWS extends WebSocketServer { + constructor(options: any) { + super(options); + const that = this; + this.on('connection', (ws: any) => { + ws.on('error', this.errorHandler); + ws.on('disconnect', this.errorHandler); + ws.on('message', function (this: WebSocket, data: Buffer, isBinary: boolean ){ + that.message(this, data, isBinary); + }); + // ... + ws.send("Connected"); + }); + } + + errorHandler = (a: any,b: any, c:any) => { + console.log(a,b,c); + } + + broadcast = (msg: string) => { + if (this.clients === undefined) { + return; + } + this.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) {; + client.send(msg); + } + }) + } + + message = (ws: WebSocket, data: Buffer, isBinary:boolean) => { + //const message = data.toString(); + //console.log('received monitor: ', message); + } +} + +export { MonitorWS } diff --git a/server-nodejs/websockets/robot.ts b/server-nodejs/websockets/robot.ts new file mode 100644 index 0000000..65b782f --- /dev/null +++ b/server-nodejs/websockets/robot.ts @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { randomUUID, UUID } from 'crypto'; +import { WebSocketServer } from 'ws'; +import { WorldSim } from '../worldsim'; +import WebSocket from 'ws'; + +interface MyWebSocket extends WebSocket { + isAlive: boolean; + websocketid: string; + heartbeat(): void; + closeHandler(this: WebSocket): void; +} + +class RobotWS extends WebSocketServer { + private idLookup: Map; + private worldSim: WorldSim; + constructor(options: any, worldsim: WorldSim) { + super(options); + this.worldSim = worldsim; + this.idLookup = new Map(); + const that = this; + this.on('connection', function connection(ws: MyWebSocket) { + ws.isAlive = true; + ws.websocketid = randomUUID().toString(); + worldsim.register(ws.websocketid); + that.idLookup.set(ws.websocketid, ws); + ws.heartbeat = function() { + this.isAlive = true; + } + ws.closeHandler = function(this: WebSocket) { + that.closeHandler(this); + } + ws.on('close', ws.closeHandler); + ws.on('pong', ws.heartbeat); + ws.on('message', function (this: WebSocket, data: Buffer, isBinary: boolean) { + const t = this; + t.heartbeat(); + that.message(t, data, isBinary); + }); + }); + const interval = setInterval(this.ping, 10000); + this.on('close', function close() { + //console.log("close", a); + clearInterval(interval); + }); + } + + broadcast = (msg: string) => { + if (this.clients === undefined) { + return; + } + this.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(msg); + } + }) + } + + closeHandler = (ws: MyWebSocket) => { + this.worldSim.unregister(ws.websocketid); + this.idLookup.delete(ws.websocketid); + } + + message = (ws: MyWebSocket, data: Buffer, isBinary:boolean) => { + const message = data.toString(); + const uuid = ws.websocketid; + const tokens = message.split(" "); + if (tokens.length == 2) { + switch(tokens[0]) { + case 'cmd': + this.worldSim.cmd(uuid, JSON.parse(tokens[1])); + break; + case 'model': + this.worldSim.setModel(uuid, tokens[1]) + break; + case 'name': + this.worldSim.setName(uuid, tokens[1]); + break; + default: + console.log("unknown command", message); + } + } + } + + ping = () => { + if (this.clients === undefined) { + return; + } + this.clients.forEach(function each(ws: WebSocket) { + const mws = ws; + if (mws.isAlive === false) { + return mws.terminate(); + } + mws.isAlive = false; + mws.ping(); + }); + } + send(uuid: string, msg: string) { + const ws = this.idLookup.get(uuid); + ws?.send(msg); + } +} + +export { RobotWS } diff --git a/server-nodejs/websockets/server.ts b/server-nodejs/websockets/server.ts new file mode 100644 index 0000000..34d7862 --- /dev/null +++ b/server-nodejs/websockets/server.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { createServer, Server } from 'http'; +import { MonitorWS } from './monitor'; +import { RobotWS } from './robot'; +import { WorldSim } from '../worldsim'; +import fs from 'fs'; + +class MyWebSocketServer { + private server: Server; + private monitor: MonitorWS; + private robot: RobotWS; + constructor(worldSim: WorldSim) { + this.server = createServer(); + this.monitor = new MonitorWS({ noServer: true }); + this.robot = new RobotWS({ noServer: true }, worldSim); + worldSim.setMonitors(this.monitor); + worldSim.setRobots(this.robot); + } + start() { + this.server.on('request', (request, res) => { + const { pathname } = new URL(request.url || '', 'ws://base.url'); + let filename = 'htdocs' + pathname; + if (pathname.endsWith('/')) { + filename += 'index.html'; + } + try { + const stream = fs.createReadStream(filename); + stream.on("error", () => { + res.statusCode = 404; + res.end("Not found"); + console.log("404", filename); + }); + stream.on("open", () => { + if (filename.endsWith('.html')) { + res.setHeader("Content-Type", "text/html"); + } else if (filename.endsWith('.css')) { + res.setHeader("Content-Type", "text/css"); + } else if (filename.endsWith('.js')) { + res.setHeader("Content-Type", "text/javascript"); + } + stream.pipe(res); + }); + } + catch (e) { + request.statusCode = 404; + } + + }); + + this.server.on('upgrade', (request, socket, head) => { + const { pathname } = new URL(request.url || '', 'ws://base.url'); + if (pathname === '/monitor') { + this.monitor.handleUpgrade(request, socket, head, (ws) =>{ + this.monitor.emit('connection', ws, request); + }); + } else if (pathname === '/robot') { + this.robot.handleUpgrade(request, socket, head, (ws) => { + this.robot.emit('connection', ws, request); + }); + } else { + console.log("nope"); + socket.destroy(); + } + }); + + this.server.listen(3000); + } + + async stop() { + this.server.closeAllConnections(); +// await this.httpTerminator.terminate(); + } +} + +export { MyWebSocketServer } \ No newline at end of file diff --git a/server-nodejs/worldsim.ts b/server-nodejs/worldsim.ts new file mode 100644 index 0000000..5dfe0d0 --- /dev/null +++ b/server-nodejs/worldsim.ts @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Sascha Nitsch https://contentnation.net/@grumpydevelop + +import { MonitorWS } from "./websockets/monitor"; +import { RobotWS } from "./websockets/robot"; +import { Robot } from "./robot"; +import { RobotCmd } from "./robotcmd"; + +class Bullet { + private source: string; + public x: number; + public y: number; + private direction: number + private closest: number; + constructor(uuid: string, x: number, y: number, direction: number) { + this.source = uuid; + this.x = x; + this.y = y; + this.direction = direction * Math.PI / 180; + this.closest = 100; + } + advance(dt: number, robots: Map, width: number, height: number) { + this.x += 100*Math.sin(this.direction) * dt; + this.y -= 100*Math.cos(this.direction) * dt; + if (this.x > width || this.x < 0 || this.y > height || this.y < 0) { + return true; + } + let damage = false; + robots.forEach((robot, uuid) => { + if (uuid === this.source) { + return; + } + const rX = robot.getX(); + const rY = robot.getY(); + if (Math.abs(rX - this.x) < 20 && Math.abs(rY - this.y) < 20) { // quick check + const dist = Math.sqrt((rX - this.x)*(rX - this.x) + (rY - this.y) * (rY - this.y)); + //console.log("potential hit on", uuid, "from ",this.source, dist); + if (dist<10) { + if (dist > this.closest) { + const damageAmount = Math.pow(1.6, 10-this.closest) + robot.damage(damageAmount); + const sourceRobot = robots.get(this.source); + sourceRobot?.causedDamage(damageAmount); + damage = true; + } else { + this.closest = dist; + } + } + } + }); + return damage; + } +} + +class WorldSim { + private monitors: MonitorWS; + private simulationTime: number; + private worldTickTimer: NodeJS.Timeout; + private robotsWS: RobotWS; + public robots: Map + private staged: Set; + private ready: Set + private numRobots: number; + private numRunsLeft: number; + private resolvePromise?: Function; + private winner: string; + private width: number; + private height: number; + private bullets: Array + + constructor() { + this.simulationTime = 0; + this.robots = new Map(); + this.staged = new Set(); + this.ready = new Set(); + this.numRobots = 0; + this.numRunsLeft = 99999; + this.winner = ""; + this.width = 500; + this.height = 500; + this.bullets = []; + } + + cmd(uuid: string, cmd: RobotCmd) { + const robot = this.robots.get(uuid); + if (robot) { + robot.updateCmd(cmd); + } + } + + fire(uuid: string, posX, posY, direction) : void { + const bullet = new Bullet(uuid, posX, posY, direction); + this.bullets.push(bullet); + } + + getTime() : number { + return this.simulationTime; + } + register(uuid: string) { + const robot = new Robot(this, uuid, this.width, this.height); + this.robots.set(uuid, robot); + this.monitors.broadcast("connected " + uuid); + this.staged.add(uuid); + } + + robotReady(uuid: string) { + this.monitors.broadcast("ready " + uuid); + this.robotsWS.send(uuid, "ready"); + this.staged.delete(uuid); + this.ready.add(uuid); + if (this.staged.size == 0 && this.ready.size == this.numRobots) { + --this.numRunsLeft; + this.monitors.broadcast("start"); + this.ready.forEach((uuid: string) => { + this.robotsWS.send(uuid, "start"); + }); + this.worldTickTimer = setInterval(this.worldTick, 50); + } + } + + setModel(uuid: string, model: string){ + const robot = this.robots.get(uuid); + if (robot === undefined) { + return; + } + robot.setModel(model); + this.monitors.broadcast("model " + uuid + " " + model); + this.robotsWS.send(uuid, "specs " + JSON.stringify({width: this.width, height: this.height})); + const status = robot.getStatus(); + const statusString = status.toJSON(); + this.robotsWS.send(uuid, "status " + statusString); + this.monitors.broadcast('status ' + uuid + ' ' + statusString); + if(robot.isReady()) { + this.robotReady(uuid); + } + } + + setMonitors(monitors: MonitorWS) { + this.monitors = monitors; + } + + setName(uuid: string, name: string) { + //console.log("set name for ", uuid, "to", name); + const robot = this.robots.get(uuid); + if (robot === undefined) { + return; + } + robot.setName(name); + this.monitors.broadcast("name " + uuid + " " + name); + + if(robot.isReady()) { + this.robotReady(uuid); + } + } + + setRobots(robots: RobotWS) { + this.robotsWS = robots; + } + + start(numRobots: number) : Promise { + this.numRobots = numRobots; + return new Promise((resolve) => { + this.resolvePromise = resolve; + }); + } + + unregister(uuid: string) { + this.monitors.broadcast("disconnected " + uuid); + this.robots.delete(uuid); + this.ready.delete(uuid); + this.staged.delete(uuid); + if (this.numRunsLeft == 0 && this.staged.size == 0 && this.ready.size == 0) { + console.log("shut down"); + if (this.resolvePromise) { + this.resolvePromise(this.winner); + } + } + } + lastTick = 0; + worldTick = () => { + const now = Date.now(); + const dt = this.lastTick ? ((now - this.lastTick) / 1000) : 0.1; + this.lastTick = now; + this.simulationTime += dt; + + this.ready.forEach((uuid: string) => { + const robot = this.robots.get(uuid); + robot?.setSimulationTime(this.simulationTime); + robot?.causedDamage(0); + }); + // update bullets and check for hits + // run twice with half dt + for (let j = 0; j < 2; ++j) { + const toDelete:Array = []; + const bulletPos: Array> = []; + this.bullets.forEach((bullet, i) => { + if (bullet.advance(dt / 2, this.robots, this.width, this.height)) { + toDelete.push(i); + } else { + bulletPos.push([bullet.x, bullet.y]); + } + }); + if (toDelete.length) { + toDelete.reverse(); + toDelete.forEach((index) => { + this.bullets.splice(index, 1); + }); + } + if (this.bullets.length > 0 || toDelete.length > 0) { + this.monitors.broadcast('bullet ' + JSON.stringify(bulletPos)); + } + } + + // move robots according to "physics" + this.ready.forEach((uuid: string) => { + const robot = this.robots.get(uuid); + robot?.moveRobot(dt); + }); + + // update radar + this.ready.forEach((uuid: string) => { + const robot = this.robots.get(uuid); + robot?.updateRadar(); + }); + + + const killed: Array = []; + this.ready.forEach((uuid: string) => { + const robot = this.robots.get(uuid); + if (robot !== undefined) { + const status = robot.getStatus(); + if (status.health <= 0) { + // robot got killed + killed.push(uuid); + } else { + const statusString = status.toJSON(); + /*if (status.contactPoints.length > 0) { + console.log(statusString); + }*/ + this.robotsWS.send(uuid, "status " + statusString); + this.monitors.broadcast('status ' + uuid + ' ' + statusString); + } + } + }); + + killed.forEach((uuid) => { + this.monitors.broadcast("lost " + uuid); + this.robotsWS.send(uuid, "lost"); + this.staged.add(uuid); + this.ready.delete(uuid); + }); + + if (this.ready.size == 1) { + this.ready.forEach((uuid) => { + this.monitors.broadcast("won " + uuid); + this.robotsWS.send(uuid, "won"); + this.staged.add(uuid); + this.ready.delete(uuid); + this.winner = uuid; + }); + clearInterval(this.worldTickTimer); + } + if (this.ready.size == 0) { + clearInterval(this.worldTickTimer); + } + } +} + +export { WorldSim } \ No newline at end of file