initial im

This commit is contained in:
Sascha Nitsch 2025-03-03 02:17:50 +01:00
commit 51f0415617
23 changed files with 1512 additions and 0 deletions

3
client-hodejs/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
package-lock.json
*.js

13
client-hodejs/esbuild.mjs Normal file
View file

@ -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: './'
})

View file

@ -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"
}
}

134
client-hodejs/rabbit.ts Normal file
View file

@ -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();

120
client-hodejs/robobase.ts Normal file
View file

@ -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 }

1
client-hodejs/robotcmd.ts Symbolic link
View file

@ -0,0 +1 @@
../server-nodejs/robotcmd.ts

View file

@ -0,0 +1 @@
../server-nodejs/robotstatus.ts

View file

@ -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();

View file

@ -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();

3
server-nodejs/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
*.js
package-lock.json

13
server-nodejs/esbuild.mjs Normal file
View file

@ -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',
})

View file

@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket Chat</title>
<style type="text/css">
.hidden {
display:none;
}
#log {
display:inline-block; width:500px; height:500px; box-sizing: border-box;
vertical-align:top;
}
#arena {
display:inline-block; width: 1000px; height: 1000px; border:1px solid #000;
position:relative;
overflow: hidden;
}
#input {
display:block; width:600px; box-sizing: border-box;
}
#arena .robot {
position:absolute;
width: 8px;
height: 20px;
}
#arena .robot .model {
width: 8px;
height: 20px;
}
#arena .robot .cannon {
width:2px;
left:3px;
top: -31px;
height:15px;
background:#f00;
display:block;
position:relative;
transform-origin: bottom center;
}
#arena .robot .radar {
transform-origin: bottom left;
left: 4px;
top: -433px;
position: relative;
}
#arena .bullet {
position: absolute;
background: #f00;
width:4px;
height:4px;
}
</style>
</head>
<body>
<div id="arena"></div>
<textarea id="log" cols="30" rows="10"></textarea>
<button id="clear" type="button">Clear Watch</button><br />
<script>
window.robotLookup = {};
function getRobot(uuid) {
if (!window.robotLookup[uuid]) {
const div = document.createElement("div");
div.setAttribute("id", uuid);
div.setAttribute("class", "robot");
const img = document.createElement("img");
img.setAttribute("src", "tank.png");
img.setAttribute("class", "model");
div.appendChild(img);
const span = document.createElement("span");
span.setAttribute("class", "cannon");
div.appendChild(span);
const radar = document.createElement("img");
radar.setAttribute("src", "radar.png");
radar.setAttribute("class", "radar");
div.appendChild(radar);
const arena = document.getElementById('arena');
arena.appendChild(div);
window.robotLookup[uuid] = {
"cannonElem": span,
'imgElem': img,
'modelElem': div,
'radarElem': radar,
'lastRadar': 0,
};
}
return window.robotLookup[uuid];
}
const textarea = document.getElementById("log");
const clear = document.getElementById('clear');
const arena = document.getElementById('arena');
const bullets = [];
for(let i = 0; i < 10; ++i) {
const bullet = document.createElement('div');
bullet.setAttribute("class", "bullet hidden");
bullets.push(bullet);
arena.appendChild(bullet);
}
function openWebSocket() {
const websocket = new WebSocket("ws://localhost:3000/monitor");
websocket.onopen = function() {
console.log("connection opened");
}
websocket.onclose = function() {
console.log("connection closed");
for (uuid in window.robotLookup) {
window.robotLookup[uuid]['modelElem'].remove();
};
window.robotLookup = [];
setTimeout(function(){
openWebSocket();
}, 10000);
}
websocket.onmessage = handleMessage;
}
function handleMessage(e) {
const tokens = e.data.split(" ");
let robot;
switch (tokens[0]) {
case 'ready':
case 'connected':
break;
case 'disconnected':
robot = getRobot(tokens[1]);
robot['modelElem'].remove();
delete window.robotLookup[tokens[1]]
break;
case 'name':
robot = getRobot(tokens[1]);
robot["name"] = tokens[2];
break;
case 'model':
robot = getRobot(tokens[1]);
robot["model"] = tokens[2];
break;
case 'bullet':
const activeBullets = JSON.parse(tokens[1]);
for (let i = 0; i < 10; ++i) {
if (i >= activeBullets.length) {
bullets[i].classList.add("hidden");
} else {
bullets[i].classList.remove("hidden");
bullets[i].style.left = (2*activeBullets[i][0]-2) + 'px';
bullets[i].style.top = (2*activeBullets[i][1]-2) + 'px';
}
}
break;
case 'status':
robot = getRobot(tokens[1]);
const status = JSON.parse(tokens[2]);
robot["status"] = status;
const model = robot['modelElem'];
model.style.left = (Math.floor(status["posX"]*2) - 5) + 'px';
model.style.top = (Math.floor(status["posY"]*2) - 12.5) + 'px';
model.style.transform = "rotate(" + Math.floor(status["orientation"])+ "deg)";
const cannon = robot['cannonElem'];
cannon.style.transform = "rotate(" + Math.floor(status["gunOrient"])+ "deg)";
const radar = robot['radarElem'];
let pos = Math.floor(status["radarPos"]);
let diff = (pos - robot['lastRadar']);
if (diff < -180) diff +=360;
if (diff > 180) diff -=360;
// pos = 0;
//console.log(diff);
if (diff < 0) { // swiping clock wise
radar.style.transform = "rotate(" + pos + "deg)";
} else {
radar.style.transform = "rotate(" + pos + "deg) scaleX(-1)";
}
robot['lastRadar'] = Math.floor(status["radarPos"]);
break;
default:
console.log("received message:", tokens);
textarea.value += e.data+"\r\n";
}
}
openWebSocket();
clear.addEventListener('click', function() {
textarea.value = '';
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

17
server-nodejs/index.ts Normal file
View file

@ -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);
});
});

View file

@ -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"
}
}

182
server-nodejs/robot.ts Normal file
View file

@ -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 };

51
server-nodejs/robotcmd.ts Normal file
View file

@ -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 }

View file

@ -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};

View file

@ -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 }

View file

@ -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<String, WebSocket>;
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(<MyWebSocket>this);
}
ws.on('close', ws.closeHandler);
ws.on('pong', ws.heartbeat);
ws.on('message', function (this: WebSocket, data: Buffer, isBinary: boolean) {
const t = <MyWebSocket>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 = <MyWebSocket>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 }

View file

@ -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 }

269
server-nodejs/worldsim.ts Normal file
View file

@ -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<String, Robot>, 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<string, Robot>
private staged: Set<string>;
private ready: Set<string>
private numRobots: number;
private numRunsLeft: number;
private resolvePromise?: Function;
private winner: string;
private width: number;
private height: number;
private bullets: Array<Bullet>
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<string> {
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<number> = [];
const bulletPos: Array<Array<number>> = [];
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<string> = [];
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 }