// DOMTANKS - SERVER - COPYRIGHT © 2025 by SEBASTIAN KRAUS

import express from 'express';
import chalk from 'chalk';
import { WebSocketServer } from 'ws';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';

const app = express();

app.use(express.static("./client"));

// Regular HTTP server to land on
const httpServer = app.listen(9876);

// Set up WS Server
const wsServer = new WebSocketServer({ port: 9877 });

// read obstacles file
const fileObstacles = initObstacles();

// WS Server Events
wsServer.on("connection", function(ws) {   
    ws.id = uuidv4();
    ws.dir = [0,0];
    ws.pos = [0,0];
    ws.health = 100;
    ws.isAlive = true;
    ws.isNew = true;
    ws.initOnClient = false;
    ws.name = "Player #" + Date.now().toString(16).slice(-5);
    ws.tankColor = "hsl(" + Math.random() * 360 + " 100% 50%)";
    ws.barrelRotation = 0;
    clientLog(ws, "Connected!");

    // (GLOBAL) Start Game Loop if not already running
    if(!gameRunning) {
        gameData.clients = Array.from(wsServer.clients);
        gameData.settings.obstacles = fileObstacles;
        setInterval(wsGameLoop, 1000 / FPS);
        serverLog("Someone connected, gameloop started!");
    }

    // Received message from Client
    ws.on("message", function(msg) {  
        msg = JSON.parse(msg);

        // Ping
        if(msg.pong) wsHeartbeat(ws);
        
        // game initialized
        if(msg.init) {
            ws.initOnClient = true;
        }

        // Player Movement
        if(msg.moveDir && ws.isAlive) {
            clientLog(ws, "New Move: " + msg.moveDir);
            this.dir = [...msg.moveDir];

            // normalize direction (for diagonal movement and against cheaters)
            this.dir = normalize2D(this.dir);
        }

        // Player Shot
        if(msg.shoot && ws.isAlive) {
            clientLog(ws, "New Shot: " + msg.shootPos);
            gameData.projectiles.push({isNew: true, clickPos: msg.shootPos, ownerId: ws.id});
        }

        // Player Name change
        if(msg.name && ws.isAlive) {
            clientLog(ws, "Player name change: " + msg.name);
            ws.name = msg.name;
        }  

        if(msg.barrelRotation) {
            ws.barrelRotation = msg.barrelRotation + "deg";
        }

        // do for all connected clients
        /*wsServer.clients.forEach(function each(client) {

            // check if client is ready
            if (client.readyState === WebSocket.OPEN) {
              client.send(msg.toString());
              serverLog(msg.toString());
            }
        })*/
    });
    ws.on("error", console.error);
});

function wsHeartbeat(ws) {
    ws.connectionIsAlive = true;
  }

// Disconnect timeouted Clients
const wsTimeoutInterval = setInterval(function ping() {
    try {
        wsServer.clients.forEach(function each(ws) {
        if (ws.connectionIsAlive === false) {
            ws.send(JSON.stringify({"timeout":"true"}));
            clientLog(ws, "Terminated because of timeout!");
            removeTank(gameData.sendClients.findIndex(client => client.id == ws.id));
            return ws.terminate();
        }
    
        ws.connectionIsAlive = false;
        ws.send(JSON.stringify({"ping":1}));
        });
    } catch(error) {
        // do nothing really
    }
}, 5000);
  
wsServer.on('close', function close() {
  clearInterval(wsTimeoutInterval);
});


// Once WS Server connection is established, connection can be upgraded from HTTP to WS
httpServer.on('upgrade', async function upgrade(request, socket, head) {      

    // Emit connection when request accepted
    wsServer.handleUpgrade(request, socket, head, function done(ws) {
      wsServer.emit('connection', ws, request);
      clientLog(ws, "Connection upgraded from HTTP to WS!");
    });
});

function clientLog(ws, str) {
    console.log(chalk.yellow(`[DomTanks] ${new Date().toLocaleTimeString()}: Client ${ws.id}: ${str}`));
}

function serverLog(str, carriageReturn = false) {
    process.stdout.write(chalk.cyan(`[DomTanks] ${new Date().toLocaleTimeString()}: ${str}${carriageReturn?'\r':'\n'}`));
}


///////////////// GAME LOGIC ////////////////

const FPS = 30;
const DEFAULT_TANK_SPEED = 0.25;
const DEFAULT_PROJECTILE_SPEED = 0.5;
const GAMEAREA_WIDTH = 1500;
const GAMEAREA_HEIGHT = 1500;
const TANK_SIZE = 25;
const PROJECTILE_SIZE = 8;

let gameRunning = false;
let gameData = {};
let lastGameData = {};
gameData.settings = {};
gameData.settings.gameAreaSize = [GAMEAREA_WIDTH, GAMEAREA_HEIGHT];
gameData.settings.tankSize = TANK_SIZE;
gameData.settings.projectileSize = PROJECTILE_SIZE;
gameData.projectiles = [];
gameData.sendClients = [];
let lastTimestamp = Date.now();

function wsGameLoop() {

    // No one connected? Clear the loop
    if(Array.from(wsServer.clients).length === 0) {
        clearInterval(this);
        serverLog("Nobody connected, gameloop cleared!");
        gameRunning = false;
        return;
    }

    gameRunning = true;

    // Set up next round
    makeGameData(wsServer.clients);

    const changeOccured = hasGameDataChanged();

    // send next round to clients ONLY if something changed
    if(changeOccured) {
        wsServer.clients.forEach(client => {
            let personalGameData = structuredClone(gameData);
            personalGameData.myId = client.id;
            if(client.initOnClient) delete personalGameData.settings;
            client.send(JSON.stringify(personalGameData));
        });

        serverLog(`Sending gameData to ${Array.from(wsServer.clients).length} clients.`, true);
        lastGameData = structuredClone(gameData);
    }

    updatelastTimestamp();
}

function makeGameData(clients) {
    gameData.clients = Array.from(clients);

    gameData.projectiles.forEach((proj,i) => {
        if(proj.isNew) {
            let ownerId = gameData.clients.findIndex(client => client.id === gameData.projectiles[i].ownerId);
            gameData.projectiles[i].pos = [gameData.clients[ownerId].pos[0] + TANK_SIZE / 2 - PROJECTILE_SIZE / 2, 
                                            gameData.clients[ownerId].pos[1] + TANK_SIZE / 2 -  PROJECTILE_SIZE / 2];
            gameData.projectiles[i].dir = getNormalizedDirection([gameData.projectiles[i].pos[0] + PROJECTILE_SIZE / 2,
                                                                    gameData.projectiles[i].pos[1] + PROJECTILE_SIZE / 2], 
                                                                    gameData.projectiles[i].clickPos);
            gameData.projectiles[i].id = uuidv4();
            gameData.projectiles[i].isNew = false;
        }
        
        // move projectile further
        let newPos = getNewPos(gameData.projectiles[i].pos, gameData.projectiles[i].dir, DEFAULT_PROJECTILE_SPEED);

        // check if projectile collided with tank
        let tankCollisionIndex = getProjectileCollisonWithTank(proj.pos, i, gameData.clients);
        if(tankCollisionIndex != -1 && gameData.clients[tankCollisionIndex].id != proj.ownerId) {
            
            gameData.clients[tankCollisionIndex].health -= 10;
            if(gameData.clients[tankCollisionIndex].health <= 0) {
                gameData.clients[tankCollisionIndex].health = 0;
                killTank(gameData.clients[tankCollisionIndex]);
            }

            // delete projectile that hit tank
            gameData.projectiles.splice(i, 1);

        // delete projectiles that are out of bounds
        } else if(isProjectileCollidingWithObstacle(proj.pos, gameData.settings.obstacles)) gameData.projectiles.splice(i, 1);
        else if(isPosInBounds(newPos, PROJECTILE_SIZE)) gameData.projectiles[i].pos = newPos;
        else gameData.projectiles.splice(i, 1);
    });

    gameData.clients.forEach((client,i) => {
        // initialize new Client
        if(client.isNew) {
            
            do { 
                gameData.clients[i].pos = getRandomSpawnPos();
            // loop until valid spawn pos is found
            } while(getCollisionWithOtherTank(gameData.clients[i].pos,i,gameData.clients) !== -1 || isTankCollidingWithObstacle(gameData.clients[i].pos, gameData.settings.obstacles));

            gameData.clients[i].isNew = false;
        
        // stuff for the connected clients
        } else { 
            if(Math.abs(client.dir[0]) + Math.abs(client.dir[1]) !== 0) {
                gameData.clients[i].pos = getNewPosWithCollisions(i);
            }
        }
        
    });

    gameData.deltaTime = getDeltaTime();
    lastTimestamp = Date.now();

    gameData.clients.forEach((client,i) => {
        gameData.sendClients[i] = {};
        gameData.sendClients[i].health = client.health;
        gameData.sendClients[i].isAlive = client.isAlive;
        gameData.sendClients[i].pos = client.pos;
        gameData.sendClients[i].id = client.id;
        gameData.sendClients[i].name = client.name;
        gameData.sendClients[i].tankColor = client.tankColor;
        gameData.sendClients[i].barrelRotation = client.barrelRotation;
    });

    gameData.sendClients.forEach((client,i) => {
        if(gameData.clients.findIndex(c => c.id == client.id) == -1) delete gameData.sendClients[i];
    });

    delete gameData.clients;

    return gameData;
}

function hasGameDataChanged() {
    let gd = structuredClone(gameData);
    let lgd = structuredClone(lastGameData);
    gd.deltaTime = 0;
    lgd.deltaTime = 0;
    return JSON.stringify(gd) != JSON.stringify(lgd);
}

function initObstacles() {
    try {
        return JSON.parse(fs.readFileSync("obstacles.json")).obstacles;
    } catch(err) {
        console.log(err);
        return;
    }
}

function isProjectileCollidingWithObstacle(projPos, obstacles) {
    return obstacles.some(obstacle => calcBoxCollision({"left": projPos[0], "top": projPos[1], "width": PROJECTILE_SIZE, "height": PROJECTILE_SIZE}, obstacle));
}

function isTankCollidingWithObstacle(tankPos, obstacles) {
    return obstacles.some(obstacle => calcBoxCollision({"left": tankPos[0], "top": tankPos[1], "width": TANK_SIZE, "height": TANK_SIZE}, obstacle));
}

function getNewPos(oldPos, newDir, speed) {
    if(oldPos == null) oldPos = [0,0];
    let x = oldPos[0] + newDir[0] * speed * getDeltaTime();
    let y = oldPos[1] + newDir[1] * speed * getDeltaTime();
    return [x, y];
}

function getDeltaTime() {
    return Date.now() - lastTimestamp;
}

function updatelastTimestamp() {
    lastTimestamp = Date.now();
}

function normalize2D([x, y]) {
    if(!(Math.abs(x)+Math.abs(y))) return [0, 0];
    let dirLength = Math.sqrt(Math.abs(x)**2+Math.abs(y)**2);
    return [x/dirLength,y/dirLength];
}

// returns index of tank it collides with or -1 if none found
function getCollisionWithOtherTank(tankPos, tankIndex, tanks) {
    let collisionTankIndex = -1;
    if(tanks.length === 1) return -1;
    tanks.forEach((otherTank, otherIndex) => {
        if(tankIndex !== otherIndex) {
            if(calcDiskCollision(tankPos, otherTank.pos, TANK_SIZE, TANK_SIZE)) collisionTankIndex = otherIndex;
        }
    });
    return collisionTankIndex;
}

function getProjectileCollisonWithTank(projPos, projIndex, tanks) {
    let collisionTankIndex = -1;
    tanks.forEach((tank, tankIndex) => {
        if(calcDiskCollision(projPos, tank.pos, PROJECTILE_SIZE, TANK_SIZE)) collisionTankIndex = tankIndex;
    });
    return collisionTankIndex;
}

function calcDiskCollision(a, b, aSize, bSize) {
    const ax = a[0] + aSize / 2;
    const ay = a[1] + aSize / 2;
    const bx = b[0] + bSize / 2;
    const by = b[1] + bSize / 2;
    
    return getDistanceSquared([ax,ay],[bx,by]) <= ((aSize+bSize)/2)**2;
}

function calcBoxCollision(a, b) {
    return (
        a.left < b.left + b.width &&
        a.left + a.width > b.left &&
        a.top < b.top + b.height &&
        a.top + a.height > b.top
    );
}

function correctPosForBounds(pos) {
    // Game Area Bounds Restriction
    let x = Math.max(0, pos[0]);
    let y = Math.max(0, pos[1]);
    x = Math.min(GAMEAREA_WIDTH - TANK_SIZE, x);
    y = Math.min(GAMEAREA_HEIGHT - TANK_SIZE, y);
    return [x,y];
}

function isPosInBounds(pos, size) {
    
    if(pos[0] < 0) return false;
    if(pos[1] < 0) return false;
    if(GAMEAREA_WIDTH - size < pos[0]) return false;
    if(GAMEAREA_HEIGHT - size < pos[1]) return false;
    return true;
}

function getDistanceSquared(a, b) {
    return (b[0] - a[0])**2 + (b[1] - a[1])**2;
}
function getDistance(a, b) {
    return Math.sqrt(getDistanceSquared(a, b));
}

function getRandomSpawnPos() {
    return [Math.round(Math.random() * (GAMEAREA_WIDTH - TANK_SIZE)), Math.round(Math.random() * (GAMEAREA_HEIGHT - TANK_SIZE))];
}

function calcNearestValidTankPos(tank, otherTank) {
    let distance = getDistance(tank.pos, otherTank.pos) - TANK_SIZE;
    return [tank.pos[0] + normalize2D(tank.dir)[0] * distance, tank.pos[1] + normalize2D(tank.dir)[1] * distance];
}

function getNormalizedDirection(a, b) {
    return normalize2D([b[0]-a[0], b[1]-a[1]]);
}

function killTank(client) {
    client.health = 0;
    client.isAlive = false;
    client.dir = [0,0];
}

function removeTank(clientIndex) {
    delete gameData.sendClients[clientIndex];
}

function getNewPosWithCollisions(i) {
    let newPos = getNewPos(gameData.clients[i].pos, gameData.clients[i].dir, DEFAULT_TANK_SPEED);
    // collision with bounds
    newPos = correctPosForBounds(newPos);

    // collision with other tank
    let collisionIndex = getCollisionWithOtherTank(newPos,i,gameData.clients);
    if(collisionIndex !== -1)
        return calcNearestValidTankPos(gameData.clients[i], gameData.clients[collisionIndex]);

    // collision with obstacle 
    if(isTankCollidingWithObstacle(newPos, gameData.settings.obstacles)) {
        if(!isTankCollidingWithObstacle([newPos[0],gameData.clients[i].pos[1]], gameData.settings.obstacles)) return [newPos[0],gameData.clients[i].pos[1]];
        else if(!isTankCollidingWithObstacle([gameData.clients[i].pos[0],newPos[1]], gameData.settings.obstacles)) return [gameData.clients[i].pos[0],newPos[1]];
        return gameData.clients[i].pos;
    }

    return newPos;
}