First Commit

This commit is contained in:
evanpelle
2024-08-04 19:51:23 -07:00
commit 05f55c490f
53 changed files with 15862 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import {TerrainMap} from "../core/Game";
import {ServerMessage, ServerMessageSchema} from "../core/Schemas";
import {defaultSettings} from "../core/Settings";
import {loadTerrainMap} from "../core/TerrainMapLoader";
import {generateUniqueID} from "../core/Util";
import {ClientGame, createClientGame} from "./ClientGame";
import {v4 as uuidv4} from 'uuid';
// import WebSocket from 'ws';
class Client {
private hasJoined = false
private startButton: HTMLButtonElement | null;
private socket: WebSocket | null = null;
private terrainMap: Promise<TerrainMap>
private game: ClientGame
private lobbiesContainer: HTMLElement | null;
private lobbiesInterval: NodeJS.Timeout | null = null;
constructor() {
this.startButton = document.getElementById('startButton') as HTMLButtonElement | null;
this.lobbiesContainer = document.getElementById('lobbies-container');
}
initialize(): void {
this.terrainMap = loadTerrainMap()
this.startLobbyPolling()
}
private startLobbyPolling(): void {
this.fetchAndUpdateLobbies(); // Fetch immediately on start
this.lobbiesInterval = setInterval(() => this.fetchAndUpdateLobbies(), 1000);
}
private async fetchAndUpdateLobbies(): Promise<void> {
try {
const data = await this.fetchLobbies();
this.updateLobbiesDisplay(data.lobbies);
} catch (error) {
console.error('Error fetching and updating lobbies:', error);
}
}
private updateLobbiesDisplay(lobbies: Array<{id: string}>): void {
if (!this.lobbiesContainer) return;
this.lobbiesContainer.innerHTML = ''; // Clear existing lobbies
lobbies.forEach(lobby => {
const button = document.createElement('button');
button.textContent = `Join Lobby ${lobby.id}`;
button.onclick = () => this.joinLobby(lobby.id);
this.lobbiesContainer.appendChild(button);
});
// Join first lobby
if (!this.hasJoined && lobbies.length > 0) {
this.hasJoined = true
console.log(`joining lobby ${lobbies[0].id}`)
this.joinLobby(lobbies[0].id)
}
}
async fetchLobbies() {
const url = '/lobbies';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching lobbies:', error);
throw error;
}
}
private async joinLobby(lobbyID: string) {
this.terrainMap.then((map) => {
this.game = createClientGame(uuidv4().slice(0, 4), generateUniqueID(), lobbyID, defaultSettings, map)
this.game.joinLobby()
})
}
}
// Initialize the client when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Client().initialize();
});
+231
View File
@@ -0,0 +1,231 @@
import {Executor} from "../core/execution/Executor";
import {Cell, ClientID, MutableGame, LobbyID, PlayerEvent, PlayerID, PlayerInfo, MutablePlayer, TerrainMap, TileEvent, Player, Game, BoatEvent} from "../core/Game";
import {createGame} from "../core/GameImpl";
import {Ticker, TickEvent} from "../core/Ticker";
import {EventBus} from "../core/EventBus";
import {Settings} from "../core/Settings";
import {GameRenderer} from "./GameRenderer";
import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler"
import {ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas";
import {AttackIntent, Intent, SpawnIntent} from "../core/Schemas";
export function createClientGame(name: string, clientID: ClientID, lobbyID: LobbyID, settings: Settings, terrainMap: TerrainMap): ClientGame {
let eventBus = new EventBus()
let gs = createGame(terrainMap, eventBus)
let gameRenderer = new GameRenderer(gs, settings.theme(), document.createElement("canvas"))
let ticker = new Ticker(settings.tickIntervalMs(), eventBus)
return new ClientGame(
name,
clientID,
lobbyID,
ticker,
eventBus,
gs,
gameRenderer,
new InputHandler(eventBus),
new Executor(gs)
)
}
export class ClientGame {
private myPlayer: Player
private turns: Turn[] = []
private socket: WebSocket
private started = false
private ticksPerTurn = 1
private ticksThisTurn = 0
private currTurn = 0
constructor(
private playerName: string,
private id: ClientID,
private lobbyID: LobbyID,
private ticker: Ticker,
private eventBus: EventBus,
private gs: Game,
private renderer: GameRenderer,
private input: InputHandler,
private executor: Executor
) { }
public joinLobby() {
this.socket = new WebSocket(`ws://localhost:3000`)
this.socket.onopen = () => {
console.log('Connected to game server!');
this.socket.send(
JSON.stringify(
ClientJoinMessageSchema.parse({
type: "join",
lobbyID: this.lobbyID,
clientID: this.id
})
)
)
};
this.socket.onmessage = (event: MessageEvent) => {
const message: ServerMessage = ServerMessageSchema.parse(JSON.parse(event.data))
if (message.type == "start") {
console.log("starting game!")
this.start()
}
if (message.type == "turn") {
this.addTurn(message.turn)
}
};
}
public start() {
this.started = true
console.log('starting game!')
// TODO: make each class do this, or maybe have client intercept all requests?
//this.eventBus.on(TickEvent, (e) => this.tick(e))
this.eventBus.on(TileEvent, (e) => this.renderer.tileUpdate(e))
this.eventBus.on(PlayerEvent, (e) => this.playerEvent(e))
this.eventBus.on(BoatEvent, (e) => this.renderer.boatEvent(e))
this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e))
this.eventBus.on(ZoomEvent, (e) => this.renderer.onZoom(e))
this.eventBus.on(DragEvent, (e) => this.renderer.onMove(e))
this.renderer.initialize()
this.input.initialize()
this.executor.spawnBots(500)
setInterval(() => this.tick(), 10);
}
public addTurn(turn: Turn): void {
this.turns.push(turn)
}
public tick() {
if (this.ticksThisTurn >= this.ticksPerTurn) {
if (this.currTurn >= this.turns.length) {
return
}
this.executor.addTurn(this.turns[this.currTurn])
this.currTurn++
this.ticksThisTurn = 0
}
this.ticksThisTurn++
console.log('client ticking')
this.gs.tick()
}
private playerEvent(event: PlayerEvent) {
console.log('received new player event!')
// TODO: what if multiple players has same name
if (event.player.info().name == this.playerName) {
console.log('setting name')
this.myPlayer = event.player
}
this.renderer.playerEvent(event)
}
private inputEvent(event: MouseDownEvent) {
const cell = this.renderer.screenToWorldCoordinates(event.x, event.y)
const tile = this.gs.tile(cell)
if (!tile.hasOwner() && !this.hasSpawned()) {
this.sendSpawnIntent(cell)
return
}
if (!this.hasSpawned()) {
return
}
const owner = tile.owner()
const targetID = owner.isPlayer() ? owner.id() : null
if (tile.owner() != this.myPlayer) {
if (this.myPlayer.sharesBorderWith(tile.owner())) {
this.sendAttackIntent(targetID, cell)
} else {
// TODO verify on ocean
console.log('going to send boat')
this.sendBoatAttackIntent(targetID, cell)
}
}
}
private hasSpawned(): boolean {
return this.myPlayer != null
}
private sendSpawnIntent(cell: Cell) {
const spawn = JSON.stringify(
ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.id,
intent: {
type: "spawn",
name: this.playerName,
isBot: false,
x: cell.x,
y: cell.y
}
})
)
console.log(spawn)
if (this.socket.readyState === WebSocket.OPEN) {
console.log(`seding spawn intent: ${spawn}`)
this.socket.send(spawn)
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
}
}
private sendAttackIntent(targetID: PlayerID, cell: Cell) {
const attack = JSON.stringify(
ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.id,
intent: {
type: "attack",
attackerID: this.myPlayer.id(),
targetID: targetID,
troops: 2000,
targetX: cell.x,
targetY: cell.y
}
})
)
console.log(attack)
if (this.socket.readyState === WebSocket.OPEN) {
console.log(`sending attack intent: ${attack}`)
this.socket.send(attack)
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
}
}
private sendBoatAttackIntent(targetID: PlayerID, cell: Cell) {
const attack = JSON.stringify(
ClientIntentMessageSchema.parse({
type: "intent",
clientID: this.id,
intent: {
type: "boat",
attackerID: this.myPlayer.id(),
targetID: targetID,
troops: 2000,
x: cell.x,
y: cell.y,
}
})
)
console.log(attack)
if (this.socket.readyState === WebSocket.OPEN) {
console.log(`sending boat attack intent: ${attack}`)
this.socket.send(attack)
} else {
console.log('WebSocket is not open. Current state:', this.socket.readyState);
}
}
}
+261
View File
@@ -0,0 +1,261 @@
import {Colord} from "colord";
import {Cell, MutableGame, Game, PlayerEvent, Tile, TileEvent, Player, Execution, BoatEvent} from "../core/Game";
import {Theme} from "../core/Settings";
import {DragEvent, ZoomEvent} from "./InputHandler";
import {calculateBoundingBox, placeName} from "./NameBoxCalculator";
import {PseudoRandom} from "../core/PseudoRandom";
import {BoatAttackExecution} from "../core/execution/BoatAttackExecution";
class NameRender {
constructor(public lastRendered: number, public location: Cell, public fontSize: number) { }
}
export class GameRenderer {
private scale: number = .8
private offsetX: number = 0
private offsetY: number = 100
private context: CanvasRenderingContext2D
private imageData: ImageData
private nameRenders: Map<Player, NameRender> = new Map()
private rand = new PseudoRandom(10)
constructor(private gs: Game, private theme: Theme, private canvas: HTMLCanvasElement) {
this.context = canvas.getContext("2d")
}
initialize() {
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
// Set canvas style to fill the screen
this.canvas.style.position = 'fixed';
this.canvas.style.left = '0';
this.canvas.style.top = '0';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
this.imageData = this.context.getImageData(0, 0, this.gs.width(), this.gs.height())
this.initImageData()
document.body.appendChild(this.canvas);
window.addEventListener('resize', () => this.resizeCanvas());
this.resizeCanvas();
requestAnimationFrame(() => this.renderGame());
}
initImageData() {
this.gs.forEachTile((tile) => {
//const color = this.theme.terrainColor(tile.terrain())
this.paintTile(tile)
})
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
//this.redraw()
}
renderGame() {
// Clear the canvas
this.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.clearRect(0, 0, this.gs.width(), this.gs.height());
// Set background
this.context.fillStyle = this.theme.backgroundColor().toHex();
this.context.fillRect(0, 0, this.gs.width(), this.gs.height());
// Create a temporary canvas for the game content
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = this.gs.width();
tempCanvas.height = this.gs.height();
// Put the ImageData on the temp canvas
tempCtx.putImageData(this.imageData, 0, 0);
// Disable image smoothing for pixelated effect
if (this.scale > 3) {
this.context.imageSmoothingEnabled = false;
} else {
this.context.imageSmoothingEnabled = true;
}
// Apply zoom and pan
this.context.setTransform(
this.scale,
0,
0,
this.scale,
this.gs.width() / 2 - this.offsetX * this.scale,
this.gs.height() / 2 - this.offsetY * this.scale
);
// Draw the game content from the temp canvas
this.context.drawImage(
tempCanvas,
-this.gs.width() / 2,
-this.gs.height() / 2,
this.gs.width(),
this.gs.height()
);
let numCalcs = 0
for (const player of this.gs.players()) {
if (numCalcs < 50 && this.maybeRecalculatePlayerInfo(player)) {
numCalcs++
}
this.renderPlayerInfo(player)
}
// const paths = this.gs.executions().map(e => e as Execution).filter(e => e instanceof BoatAttackExecution).map(e => e as BoatAttackExecution).filter(e => e.path != null).map(e => e.path)
// paths.forEach(p => {
// p.forEach(t => {
// this.paintCell(t.cell(), new Colord({r: 255, g: 255, b: 255}))
// })
// })
requestAnimationFrame(() => this.renderGame());
}
maybeRecalculatePlayerInfo(player: Player): boolean {
if (!this.nameRenders.has(player)) {
this.nameRenders.set(player, new NameRender(0, null, null))
}
const render = this.nameRenders.get(player)
let wasUpdated = false
if (Date.now() - render.lastRendered > 1000) {
render.lastRendered = Date.now() + this.rand.nextInt(0, 100)
wasUpdated = true
const box = calculateBoundingBox(player)
const centerX = box.min.x + ((box.max.x - box.min.x) / 2)
const centerY = box.min.y + ((box.max.y - box.min.y) / 2)
render.location = new Cell(centerX, centerY)
render.fontSize = Math.max(Math.min(box.max.x - box.min.x, box.max.y - box.min.y) / player.info().name.length / 2, 1)
}
return wasUpdated
}
renderPlayerInfo(player: Player) {
if (!player.isAlive()) {
return
}
if (!this.nameRenders.has(player)) {
return
}
const render = this.nameRenders.get(player)
this.context.font = `${render.fontSize}px Arial`;
this.context.fillStyle = this.theme.playerInfoColor(player.id()).toHex();
this.context.textAlign = 'center';
this.context.textBaseline = 'middle';
const nameCenterX = render.location.x - this.gs.width() / 2
const nameCenterY = render.location.y - this.gs.height() / 2
this.context.fillText(player.info().name, nameCenterX, nameCenterY - render.fontSize / 2);
this.context.fillText(String(Math.floor(player.troops())), nameCenterX, nameCenterY + render.fontSize);
}
tileUpdate(event: TileEvent) {
this.paintTile(event.tile)
this.gs.neighbors(event.tile.cell()).forEach(c => this.paintTile(this.gs.tile(c)))
}
playerEvent(event: PlayerEvent) {
}
boatEvent(event: BoatEvent) {
this.paintCell(event.boat.cell(), new Colord({r: 255, g: 255, b: 255}))
this.gs.neighbors(event.boat.cell()).map(c => this.gs.tile(c)).forEach(t => this.paintTile(t))
}
resize(width: number, height: number): void {
this.canvas.width = Math.ceil(width / window.devicePixelRatio);
this.canvas.height = Math.ceil(height / window.devicePixelRatio);
}
paintTile(tile: Tile) {
// const index = (tile.cell().y * this.gs.width()) + tile.cell().x
// color.toRGB().writeToBuffer(this.imageData.data, index * 4)
let terrainColor = this.theme.terrainColor(tile.terrain())
this.paintCell(tile.cell(), terrainColor)
const owner = tile.owner()
if (owner.isPlayer()) {
if (tile.isBorder()) {
this.paintCell(tile.cell(), this.theme.borderColor(owner.id()))
} else {
this.paintCell(tile.cell(), this.theme.territoryColor(owner.id()))
}
}
}
paintCell(cell: Cell, color: Colord) {
const index = (cell.y * this.gs.width()) + cell.x
const offset = index * 4
this.imageData.data[offset] = color.rgba.r;
this.imageData.data[offset + 1] = color.rgba.g;
this.imageData.data[offset + 2] = color.rgba.b;
this.imageData.data[offset + 3] = color.rgba.a * 255 | 0
}
onZoom(event: ZoomEvent) {
const oldScale = this.scale;
const zoomFactor = 1 + event.delta / 600;
this.scale *= zoomFactor;
// Clamp the scale to prevent extreme zooming
this.scale = Math.max(0.1, Math.min(10, this.scale));
const canvasRect = this.canvas.getBoundingClientRect();
const canvasX = event.x - canvasRect.left;
const canvasY = event.y - canvasRect.top;
// Calculate the world point we want to zoom towards
const zoomPointX = (canvasX - this.gs.width() / 2) / oldScale + this.offsetX;
const zoomPointY = (canvasY - this.gs.height() / 2) / oldScale + this.offsetY;
// Adjust the offset
this.offsetX = zoomPointX - (canvasX - this.gs.width() / 2) / this.scale;
this.offsetY = zoomPointY - (canvasY - this.gs.height() / 2) / this.scale;
}
onMove(event: DragEvent) {
this.offsetX -= event.deltaX / this.scale;
this.offsetY -= event.deltaY / this.scale;
}
screenToWorldCoordinates(screenX: number, screenY: number): Cell {
const canvasRect = this.canvas.getBoundingClientRect();
const canvasX = screenX - canvasRect.left;
const canvasY = screenY - canvasRect.top;
// Calculate the world point we want to zoom towards
const centerX = (canvasX - this.gs.width() / 2) / this.scale + this.offsetX;
const centerY = (canvasY - this.gs.height() / 2) / this.scale + this.offsetY;
const gameX = centerX + this.gs.width() / 2
const gameY = centerY + this.gs.height() / 2
console.log(`zoom point ${centerX} ${centerY}`)
console.log(`Current scale: ${this.scale}`);
console.log(`Current offset: ${this.offsetX}, ${this.offsetY}`);
return new Cell(Math.floor(gameX), Math.floor(gameY));
}
}
+93
View File
@@ -0,0 +1,93 @@
import {EventBus, GameEvent} from "../core/EventBus";
import {Cell} from "../core/Game";
export class MouseUpEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
) { }
}
export class MouseDownEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
) { }
}
export class ZoomEvent implements GameEvent {
constructor(
public readonly x: number,
public readonly y: number,
public readonly delta: number
) { }
}
export class DragEvent implements GameEvent {
constructor(
public readonly deltaX: number,
public readonly deltaY: number,
) { }
}
export class InputHandler {
private lastMouseDownX: number = 0
private lastMouseDownY: number
private isMouseDown: boolean = false;
private lastMouseX: number = 0;
private lastMouseY: number = 0;
constructor(private eventBus: EventBus) { }
initialize() {
document.addEventListener("pointerdown", (e) => this.onPointerDown(e));
document.addEventListener("pointerup", (e) => this.onPointerUp(e));
document.addEventListener("wheel", (e) => this.onScroll(e), {passive: false});
document.addEventListener('mousedown', this.onMouseDown.bind(this));
document.addEventListener('mousemove', this.onMouseMove.bind(this));
document.addEventListener('mouseup', this.onMouseUp.bind(this));
document.addEventListener('mouseleave', this.onMouseUp.bind(this))
}
onPointerDown(event: PointerEvent) {
this.lastMouseDownX = event.x
this.lastMouseDownY = event.y
this.eventBus.emit(new MouseDownEvent(event.x, event.y))
}
onPointerUp(event: PointerEvent) {
const dist = Math.abs(event.x - this.lastMouseDownX) + Math.abs(event.y - this.lastMouseDownY);
if (dist < 10) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y))
}
}
private onScroll(event: WheelEvent) {
this.eventBus.emit(new ZoomEvent(event.x, event.y, event.deltaY))
}
private onMouseDown(event: MouseEvent) {
this.isMouseDown = true;
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
}
private onMouseMove(event: MouseEvent) {
if (!this.isMouseDown) return;
const deltaX = event.clientX - this.lastMouseX;
const deltaY = event.clientY - this.lastMouseY;
this.eventBus.emit(new DragEvent(deltaX, deltaY))
this.lastMouseX = event.clientX;
this.lastMouseY = event.clientY;
}
private onMouseUp(event: MouseEvent) {
this.isMouseDown = false;
}
}
+127
View File
@@ -0,0 +1,127 @@
import {Game, Player, Tile, Cell} from '../core/Game';
export interface Point {
x: number;
y: number;
}
export interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
export function placeName(game: Game, player: Player): [position: Cell, fontSize: number] {
const boundingBox = calculateBoundingBox(player);
const grid = createGrid(game, player, boundingBox);
const largestRectangle = findLargestInscribedRectangle(grid);
const center = new Cell(
largestRectangle.x + largestRectangle.width / 2,
largestRectangle.y + largestRectangle.height / 2,
)
const fontSize = calculateFontSize(largestRectangle, player.info().name);
return [center, fontSize]
}
export function calculateBoundingBox(player: Player): {min: Point; max: Point} {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
player.borderTiles().forEach((tile: Tile) => {
const cell = tile.cell();
minX = Math.min(minX, cell.x);
minY = Math.min(minY, cell.y);
maxX = Math.max(maxX, cell.x);
maxY = Math.max(maxY, cell.y);
});
return {min: {x: minX, y: minY}, max: {x: maxX, y: maxY}};
}
export function createGrid(game: Game, player: Player, boundingBox: {min: Point; max: Point}): boolean[][] {
const width = boundingBox.max.x - boundingBox.min.x + 1;
const height = boundingBox.max.y - boundingBox.min.y + 1;
const grid: boolean[][] = Array(width).fill(null).map(() => Array(height).fill(false));
for (let y = boundingBox.min.y; y <= boundingBox.max.y; y++) {
for (let x = boundingBox.min.x; x <= boundingBox.max.x; x++) {
const cell = new Cell(x, y);
if (game.isOnMap(cell)) {
const tile = game.tile(cell);
grid[x - boundingBox.min.x][y - boundingBox.min.y] = tile.owner() === player;
}
}
}
return grid;
}
export function findLargestInscribedRectangle(grid: boolean[][]): Rectangle {
const rows = grid[0].length;
const cols = grid.length;
const heights: number[] = new Array(cols).fill(0);
let largestRect: Rectangle = {x: 0, y: 0, width: 0, height: 0};
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
if (grid[col][row]) {
heights[row]++;
} else {
heights[row] = 0;
}
}
const rectForRow = largestRectangleInHistogram(heights);
if (rectForRow.width * rectForRow.height > largestRect.width * largestRect.height) {
largestRect = {
x: rectForRow.x,
y: row - rectForRow.height + 1,
width: rectForRow.width,
height: rectForRow.height
};
}
}
return largestRect;
}
export function largestRectangleInHistogram(widths: number[]): Rectangle {
const stack: number[] = [];
let maxArea = 0;
let largestRect: Rectangle = {x: 0, y: 0, width: 0, height: 0};
for (let i = 0; i <= widths.length; i++) {
const h = i === widths.length ? 0 : widths[i];
while (stack.length > 0 && h < widths[stack[stack.length - 1]]) {
const height = widths[stack.pop()!];
const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1;
if (height * width > maxArea) {
maxArea = height * width;
largestRect = {
x: stack.length === 0 ? 0 : stack[stack.length - 1] + 1,
y: 0,
width: width,
height: height
};
}
}
stack.push(i);
}
return largestRect;
}
export function calculateFontSize(rectangle: Rectangle, name: string): number {
// This is a simplified calculation. You might want to adjust it based on your specific font and rendering system.
const aspectRatio = name.length; // Assuming width:height ratio of 2:1 for each character
const widthConstrained = rectangle.width / name.length;
const heightConstrained = rectangle.height / 2;
return Math.min(widthConstrained, heightConstrained);
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Warfront</title>
</head>
<body>
<h1>Warfront</h1>
<button id="startButton">Start Game</button>
<div id="game-setup">
<div id="lobbies-container">
<h2>Available Lobbies</h2>
<!-- Lobby buttons will be inserted here -->
</div>
</div>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
export interface GameEvent { }
export interface EventConstructor<T extends GameEvent = GameEvent> {
new(...args: any[]): T;
}
export class EventBus {
private listeners: Map<EventConstructor, Array<(event: GameEvent) => void>> = new Map();
emit<T extends GameEvent>(event: T): void {
const eventConstructor = event.constructor as EventConstructor<T>;
const callbacks = this.listeners.get(eventConstructor);
if (callbacks) {
for (const callback of callbacks) {
callback(event);
}
}
}
on<T extends GameEvent>(
eventType: EventConstructor<T>,
callback: (event: T) => void
): void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
const callbacks = this.listeners.get(eventType)!;
callbacks.push(callback as (event: GameEvent) => void);
}
off<T extends GameEvent>(eventType: EventConstructor<T>, callback: (event: T) => void): void {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
const index = callbacks.indexOf(callback as (event: GameEvent) => void);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import {GameEvent} from "./EventBus"
export type ClientID = string
export type PlayerID = number // TODO: make string?
export type GameID = string
export type LobbyID = string
export class Cell {
constructor(
public readonly x,
public readonly y
) { }
toString(): string {return `Cell[${this.x},${this.y}]`}
}
export interface ExecutionView {
isActive(): boolean
owner(): Player
}
export interface Execution extends ExecutionView {
init(mg: MutableGame, ticks: number)
tick(ticks: number)
owner(): MutablePlayer
}
export class PlayerInfo {
constructor(
public readonly name: string,
public readonly isBot: boolean
) { }
}
// TODO: make terrain api better.
export class Terrain {
constructor(
public readonly expansionCost: number,
public readonly expansionTime: number,
) { }
}
export type TerrainType = typeof TerrainTypes[keyof typeof TerrainTypes];
export const TerrainTypes = {
Land: new Terrain(1, 1),
Water: new Terrain(0, 0)
}
export interface TerrainMap {
terrain(cell: Cell): Terrain
width(): number
height(): number
}
export interface Tile {
owner(): Player | TerraNullius
hasOwner(): boolean
isBorder(): boolean
isInterior(): boolean
cell(): Cell
terrain(): Terrain
game(): Game
neighbors(): Tile[]
onShore(): boolean
}
export interface Boat {
troops(): number
cell(): Cell
owner(): Player
target(): Player | TerraNullius
}
export interface MutableBoat extends Boat {
move(cell: Cell): void
owner(): MutablePlayer
target(): MutablePlayer | TerraNullius
setTroops(troops: number): void
}
export interface TerraNullius {
ownsTile(cell: Cell): boolean
isPlayer(): false
}
export interface Player {
info(): PlayerInfo
id(): PlayerID
troops(): number
boats(): Boat[]
ownsTile(cell: Cell): boolean
isAlive(): boolean
executions(): ExecutionView[]
borderTiles(): ReadonlySet<Tile>
borderTilesWith(other: Player | TerraNullius): ReadonlySet<Tile>
isPlayer(): this is Player
neighbors(): (Player | TerraNullius)[]
numTilesOwned(): number
sharesBorderWith(other: Player | TerraNullius): boolean
}
export interface MutablePlayer extends Player {
setTroops(troops: number): void
addTroops(troops: number): void
removeTroops(troops: number): void
conquer(cell: Cell): void
executions(): Execution[]
neighbors(): (MutablePlayer | TerraNullius)[]
boats(): MutableBoat[]
addBoat(troops: number, cell: Cell, target: Player | TerraNullius): MutableBoat
}
export interface Game {
// Throws exception is player not found
player(id: PlayerID): Player
players(): Player[]
tile(cell: Cell): Tile
isOnMap(cell: Cell): boolean
neighbors(cell: Cell): Cell[]
width(): number
height(): number
forEachTile(fn: (tile: Tile) => void): void
executions(): ExecutionView[]
terraNullius(): TerraNullius
tick()
addExecution(...exec: Execution[])
}
export interface MutableGame extends Game {
player(id: PlayerID): MutablePlayer
players(): MutablePlayer[]
addPlayer(playerInfo: PlayerInfo): MutablePlayer
executions(): Execution[]
removeInactiveExecutions(): void
removeExecution(exec: Execution)
}
export class TileEvent implements GameEvent {
constructor(public readonly tile: Tile) { }
}
export class PlayerEvent implements GameEvent {
constructor(public readonly player: Player) { }
}
export class BoatEvent implements GameEvent {
constructor(public readonly boat: Boat) { }
}
+415
View File
@@ -0,0 +1,415 @@
import {EventBus} from "./EventBus";
import {Cell, Execution, MutableGame, Game, MutablePlayer, PlayerEvent, PlayerID, PlayerInfo, Player, TerrainMap, TerrainType, TerrainTypes, TerraNullius, Tile, TileEvent, Boat, MutableBoat, BoatEvent} from "./Game";
export function createGame(terrainMap: TerrainMap, eventBus: EventBus): Game {
return new GameImpl(terrainMap, eventBus)
}
type CellString = string
class TileImpl implements Tile {
constructor(
private readonly gs: GameImpl,
public _owner: PlayerImpl | TerraNulliusImpl,
private readonly _cell: Cell,
private readonly _terrain: TerrainType
) { }
onShore(): boolean {
return this.neighbors()
.filter(t => t.terrain() == TerrainTypes.Water)
.length > 0
}
hasOwner(): boolean {return this._owner != this.gs._terraNullius}
owner(): MutablePlayer | TerraNullius {return this._owner}
isBorder(): boolean {return this.gs.isBorder(this)}
isInterior(): boolean {return this.hasOwner() && !this.isBorder()}
cell(): Cell {return this._cell}
terrain(): TerrainType {return this._terrain}
neighbors(): Tile[] {
return this.gs.neighbors(this._cell).map(c => this.gs.tile(c))
}
game(): Game {return this.gs}
}
export class BoatImpl implements MutableBoat {
constructor(
private g: GameImpl,
private _cell: Cell,
private _troops: number,
private _owner: PlayerImpl,
private _target: PlayerImpl | TerraNulliusImpl
) { }
move(cell: Cell): void {
this._cell = cell
this.g.fireBoatUpdateEvent(this)
}
setTroops(troops: number): void {
this._troops = troops
}
troops(): number {
return this._troops
}
cell(): Cell {
return this._cell
}
owner(): PlayerImpl {
return this._owner
}
target(): PlayerImpl | TerraNullius {
return this._target
}
}
export class PlayerImpl implements MutablePlayer {
public _boats: BoatImpl[] = []
public _borderTiles: Map<CellString, Tile> = new Map()
_borderWith: Map<Player | TerraNullius, Set<Tile>> = new Map()
public tiles: Map<CellString, Tile> = new Map<CellString, Tile>()
constructor(private gs: GameImpl, public readonly _id: PlayerID, public readonly playerInfo: PlayerInfo, private _troops) { }
addBoat(troops: number, cell: Cell, target: Player | TerraNullius): BoatImpl {
const b = new BoatImpl(this.gs, cell, troops, this, target as PlayerImpl | TerraNulliusImpl)
this._boats.push(b)
this.gs.fireBoatUpdateEvent(b)
return b
}
boats(): BoatImpl[] {
return this._boats
}
sharesBorderWith(other: Player | TerraNullius): boolean {
if (!this._borderWith.has(other)) {
return false
}
return this._borderWith.get(other).size > 0
}
numTilesOwned(): number {
return this.tiles.size
}
borderTiles(): ReadonlySet<Tile> {
return new Set(this._borderTiles.values())
}
neighbors(): (MutablePlayer | TerraNullius)[] {
const ns: (MutablePlayer | TerraNullius)[] = []
for (const [player, tiles] of this._borderWith) {
if (tiles.size > 0) {
ns.push(player as MutablePlayer)
}
}
return ns
}
addTroops(troops: number): void {
this._troops += troops
}
removeTroops(troops: number): void {
this._troops -= troops
}
isPlayer(): this is MutablePlayer {return true as const}
ownsTile(cell: Cell): boolean {return this.tiles.has(cell.toString())}
setTroops(troops: number) {this._troops = troops}
conquer(cell: Cell) {this.gs.conquer(this, cell)}
info(): PlayerInfo {return this.playerInfo}
id(): PlayerID {return this._id}
troops(): number {return this._troops}
isAlive(): boolean {return this.tiles.size > 0}
gameState(): MutableGame {return this.gs}
executions(): Execution[] {
return this.gs.executions().filter(exec => exec.owner().id() == this.id())
}
borderTilesWith(other: Player | TerraNullius): ReadonlySet<Tile> {
return this._borderWith.get(other) || new Set();
}
updateBorderWithTile(tile: Tile, oldOwner: Player | TerraNullius, newOwner: Player | TerraNullius) {
if (!this._borderWith.has(oldOwner)) {
this._borderWith.set(oldOwner, new Set())
}
if (!this._borderWith.has(newOwner)) {
this._borderWith.set(newOwner, new Set())
}
// Delete old neighbors
if (this.gs.tileNeighbors(tile).filter(t => t.owner() == newOwner).length == 0) {
this._borderWith.get(oldOwner).delete(tile)
}
}
addCalcBorderWithTile(tile: Tile) {
this.gs.neighbors(tile.cell()).map(c => this.gs.tile(c)).forEach(t => {
this.insertBorderWithTile(tile, t.owner())
})
}
removeCalcBorderWithTile(tile: Tile, oldNeighbor: Player | TerraNullius) {
const length = this.gs.neighbors(tile.cell()).map(c => this.gs.tile(c)).filter(t => t.owner() == oldNeighbor).length
if (length == 0) {
this.deleteBorderWithTile(tile, oldNeighbor)
}
}
insertBorderWithTile(tile: Tile, player: Player | TerraNullius) {
if (!this._borderWith.has(player)) {
this._borderWith.set(player, new Set())
}
if (player != this) {
this._borderWith.get(player).add(tile)
}
}
deleteBorderWithTile(tile: Tile, player: Player | TerraNullius) {
if (!this._borderWith.has(player)) {
this._borderWith.set(player, new Set())
}
this._borderWith.get(player).delete(tile)
}
}
class TerraNulliusImpl implements TerraNullius {
_borderWith: Map<Player | TerraNullius, Set<Tile>> = new Map()
public tiles: Map<Cell, Tile> = new Map<Cell, Tile>()
constructor(private gs: MutableGame) { }
id(): PlayerID {
return 0
}
ownsTile(cell: Cell): boolean {
return this.tiles.has(cell)
}
isPlayer(): false {return false as const}
}
export class TerrainMapImpl implements TerrainMap {
constructor(public readonly tiles: TerrainType[][]) { }
terrain(cell: Cell): TerrainType {
return this.tiles[cell.x][cell.y]
}
width(): number {
return this.tiles.length
}
height(): number {
return this.tiles[0].length
}
}
export class GameImpl implements MutableGame {
private ticks = 0
private unInitExecs: Execution[] = []
idCounter: PlayerID = 1; // Zero reserved for TerraNullius
map: TileImpl[][]
_players: Map<PlayerID, PlayerImpl> = new Map<PlayerID, PlayerImpl>
private execs: Execution[] = []
private _width: number
private _height: number
_terraNullius: TerraNulliusImpl
constructor(terrainMap: TerrainMap, private eventBus: EventBus) {
this._terraNullius = new TerraNulliusImpl(this)
this._width = terrainMap.width();
this._height = terrainMap.height();
this.map = new Array(this._width);
for (let x = 0; x < this._width; x++) {
this.map[x] = new Array(this._height);
for (let y = 0; y < this._height; y++) {
let cell = new Cell(x, y);
this.map[x][y] = new TileImpl(this, this._terraNullius, cell, terrainMap.terrain(cell));
}
}
}
tick() {
this.executions().forEach(e => e.tick(this.ticks))
this.unInitExecs.forEach(e => e.init(this, this.ticks))
this.removeInactiveExecutions()
this.execs.push(...this.unInitExecs)
this.unInitExecs = []
this.ticks++
}
terraNullius(): TerraNullius {
return this._terraNullius
}
removeInactiveExecutions(): void {
this.execs = this.execs.filter(e => e.isActive())
}
players(): MutablePlayer[] {
return Array.from(this._players.values()).filter(p => p.isAlive())
}
executions(): Execution[] {
return this.execs
}
addExecution(...exec: Execution[]) {
this.unInitExecs.push(...exec)
}
removeExecution(exec: Execution) {
this.execs.filter(execution => execution !== exec)
}
width(): number {
return this._width
}
height(): number {
return this._height
}
forEachTile(fn: (tile: Tile) => void): void {
for (let x = 0; x < this._width; x++) {
for (let y = 0; y < this._height; y++) {
fn(this.tile(new Cell(x, y)))
}
}
}
playerView(id: PlayerID): MutablePlayer {
return this.player(id)
}
addPlayer(playerInfo: PlayerInfo): MutablePlayer {
let id = this.idCounter
this.idCounter++
let player = new PlayerImpl(this, id, playerInfo, 10000)
this._players.set(id, player)
this.eventBus.emit(new PlayerEvent(player))
return player
}
player(id: PlayerID | null): MutablePlayer {
if (!this._players.has(id)) {
throw new Error(`Player with id ${id} not found`)
}
return this._players.get(id)
}
tile(cell: Cell): Tile {
this.assertIsOnMap(cell)
return this.map[cell.x][cell.y]
}
isOnMap(cell: Cell): boolean {
return cell.x >= 0
&& cell.x < this._width
&& cell.y >= 0
&& cell.y < this._height
}
neighbors(cell: Cell): Cell[] {
this.assertIsOnMap(cell)
return [
new Cell(cell.x + 1, cell.y),
new Cell(cell.x - 1, cell.y),
new Cell(cell.x, cell.y + 1),
new Cell(cell.x, cell.y - 1)
].filter(c => this.isOnMap(c))
}
tileNeighbors(tile: Tile): Tile[] {
return this.neighbors(tile.cell()).map(c => this.tile(c))
}
private assertIsOnMap(cell: Cell) {
if (!this.isOnMap(cell)) {
throw new Error(`cell ${cell.toString()} is not on map`)
}
}
conquer(owner: PlayerImpl, cell: Cell): void {
if (owner.ownsTile(cell)) {
throw new Error(`Player ${owner} already owns cell ${cell.toString()}`)
}
if (!owner.isPlayer()) {
throw new Error("Must be a player")
}
let tile = this.tile(cell) as TileImpl
let previousOwner = tile._owner
if (previousOwner.isPlayer()) {
previousOwner.tiles.delete(cell.toString())
previousOwner._borderTiles.delete(cell.toString())
}
tile._owner = owner
owner.tiles.set(cell.toString(), tile)
this.updateBorders(cell)
this.updateBordersWith(tile, previousOwner)
this.eventBus.emit(new TileEvent(tile))
}
private updateBorders(cell: Cell) {
const cells: Cell[] = []
cells.push(cell)
this.neighbors(cell).forEach(c => cells.push(c))
cells.map(c => this.tile(c)).filter(c => c.hasOwner()).forEach(t => {
if (this.isBorder(t)) {
(t.owner() as PlayerImpl)._borderTiles.set(t.cell().toString(), t)
} else {
(t.owner() as PlayerImpl)._borderTiles.delete(t.cell().toString())
}
})
}
private updateBordersWith(tile: TileImpl, previousOwner: PlayerImpl | TerraNulliusImpl) {
const newOwner = tile._owner
const neighbors = this.neighbors(tile.cell()).map(c => this.tile(c))
if (newOwner.isPlayer()) {
newOwner.addCalcBorderWithTile(tile)
}
neighbors.map(t => (t as TileImpl)).forEach(t => {
const p = t._owner
if (p.isPlayer()) {
p.addCalcBorderWithTile(t)
p.removeCalcBorderWithTile(t, previousOwner)
}
if (previousOwner.isPlayer()) {
previousOwner.deleteBorderWithTile(tile, p)
}
})
}
isBorder(tile: Tile): boolean {
this.assertIsOnMap(tile.cell())
if (!tile.hasOwner()) {
return false
}
for (const neighbor of this.neighbors(tile.cell())) {
let bordersEnemy = this.tile(neighbor).owner() != tile.owner()
if (bordersEnemy) {
return true
}
}
return false
}
public fireBoatUpdateEvent(boat: Boat) {
this.eventBus.emit(new BoatEvent(boat))
}
}
+33
View File
@@ -0,0 +1,33 @@
export class PseudoRandom {
private m: number = 0x80000000; // 2**31
private a: number = 1103515245;
private c: number = 12345;
private state: number;
constructor(seed: number) {
this.state = seed % this.m;
}
/**
* Generates the next pseudorandom number.
* @returns A number between 0 (inclusive) and 1 (exclusive).
*/
next(): number {
this.state = (this.a * this.state + this.c) % this.m;
return this.state / this.m;
}
/**
* Generates a random integer between min (inclusive) and max (exclusive).
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min) + min);
}
/**
* Generates a random float between min (inclusive) and max (exclusive).
*/
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
}
+100
View File
@@ -0,0 +1,100 @@
import {z} from 'zod';
export type Intent = SpawnIntent | AttackIntent | BoatAttackIntent
export type AttackIntent = z.infer<typeof AttackIntentSchema>
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>
export type Turn = z.infer<typeof TurnSchema>
export type ClientMessage = ClientIntentMessage | ClientJoinMessage
export type ServerMessage = ServerSyncMessage | ServerStartGameMessage
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>
export type ServerStartGameMessage = z.infer<typeof ServerStartGameMessageSchema>
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>
// Zod schemas
const BaseIntentSchema = z.object({
type: z.enum(['attack', 'spawn', 'boat']),
});
export const AttackIntentSchema = BaseIntentSchema.extend({
type: z.literal('attack'),
attackerID: z.number(),
targetID: z.number().nullable(),
troops: z.number(),
targetX: z.number(),
targetY: z.number()
});
export const SpawnIntentSchema = BaseIntentSchema.extend({
type: z.literal('spawn'),
name: z.string(),
isBot: z.boolean(),
x: z.number(),
y: z.number(),
})
export const BoatAttackIntentSchema = BaseIntentSchema.extend({
type: z.literal('boat'),
attackerID: z.number(),
targetID: z.number().nullable(),
troops: z.number(),
x: z.number(),
y: z.number(),
})
const IntentSchema = z.union([AttackIntentSchema, SpawnIntentSchema, BoatAttackIntentSchema]);
const TurnSchema = z.object({
turnNumber: z.number(),
intents: z.array(IntentSchema)
})
// Server
const ServerBaseMessageSchema = z.object({
type: z.string()
})
export const ServerTurnMessageSchema = ServerBaseMessageSchema.extend({
type: z.literal('turn'),
turn: TurnSchema,
})
export const ServerStartGameMessageSchema = ServerBaseMessageSchema.extend({
type: z.literal('start'),
})
export const ServerMessageSchema = z.union([ServerTurnMessageSchema, ServerStartGameMessageSchema]);
// Client
const ClientBaseMessageSchema = z.object({
type: z.string()
})
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('intent'),
clientID: z.string(),
//gameID: z.string(),
intent: IntentSchema
})
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
type: z.literal('join'),
clientID: z.string(),
lobbyID: z.string()
})
export const ClientMessageSchema = z.union([ClientIntentMessageSchema, ClientJoinMessageSchema]);
+84
View File
@@ -0,0 +1,84 @@
import {PlayerID, TerrainType, TerrainTypes} from "./Game";
import {Colord, colord} from "colord";
export interface Settings {
theme(): Theme;
turnIntervalMs(): number
tickIntervalMs(): number
ticksPerTurn(): number
lobbyCreationRate(): number
lobbyLifetime(): number
}
export interface Theme {
playerInfoColor(id: PlayerID): Colord;
territoryColor(id: PlayerID): Colord;
borderColor(id: PlayerID): Colord;
terrainColor(tile: TerrainType): Colord;
backgroundColor(): Colord;
font(): string;
shaderArgs(): {name: string; args: {[key: string]: any}}[];
}
export const defaultSettings = new class implements Settings {
ticksPerTurn(): number {
return 1
}
turnIntervalMs(): number {
return 1000 / 10
}
lobbyCreationRate(): number {
return 5 * 1000
}
lobbyLifetime(): number {
return 2 * 1000
}
theme(): Theme {return pastelTheme;}
tickIntervalMs(): number {
return 1000 / 20; // 50ms
}
}
const pastelTheme = new class implements Theme {
private background = colord({r: 100, g: 100, b: 100});
private land = colord({r: 244, g: 243, b: 198});
private water = colord({r: 160, g: 203, b: 231});
private territory = colord({r: 173, g: 216, b: 230});
playerInfoColor(id: PlayerID): Colord {
return colord({r: 0, g: 0, b: 0})
}
territoryColor(id: PlayerID): Colord {
return colord({r: (id * 10) % 250, g: (id * 100) % 250, b: (id) % 250});
}
borderColor(id: PlayerID): Colord {
const tc = this.territoryColor(id).rgba;
return colord({
r: Math.min(tc.r + 20, 255),
g: Math.min(tc.g + 20, 255),
b: Math.min(tc.b + 20, 255)
})
}
terrainColor(tile: TerrainType): Colord {
if (tile == TerrainTypes.Land) {
return this.land;
}
return this.water;
}
backgroundColor(): Colord {
return this.background;
}
font(): string {
return "Overpass";
}
shaderArgs(): {name: string; args: {[key: string]: any}}[] {
throw new Error("Method not implemented.");
}
}
+25
View File
@@ -0,0 +1,25 @@
import {Jimp as JimpType, JimpConstructors} from '@jimp/core';
import 'jimp';
import {TerrainMap, TerrainType, TerrainTypes} from './Game';
import {TerrainMapImpl} from './GameImpl';
declare const Jimp: JimpType & JimpConstructors;
export async function loadTerrainMap(): Promise<TerrainMap> {
const imageModule = await import(`../../resources/maps/World.png`);
const imageUrl = imageModule.default;
const image = await Jimp.read(imageUrl)
const {width, height} = image.bitmap;
const terrain: TerrainType[][] = Array(width).fill(null).map(() => Array(height).fill(TerrainTypes.Water));
image.scan(0, 0, width, height, function (x: number, y: number, idx: number) {
const red = this.bitmap.data[idx + 0];
if (red > 100) {
terrain[x][y] = TerrainTypes.Land;
}
})
return new TerrainMapImpl(terrain);
}
+33
View File
@@ -0,0 +1,33 @@
import {EventBus, GameEvent} from "./EventBus";
import {Settings} from "./Settings";
export class TickEvent implements GameEvent {
constructor(public readonly tickCount: number) { }
}
export class Ticker {
private ticker: NodeJS.Timeout;
private tickCount: number;
constructor(private tickInterval: number, private eventBus: EventBus) {
}
start() {
this.tickCount = 0;
this.ticker = setInterval(() => this.tick(), this.tickInterval);
}
stop() {
clearInterval(this.ticker);
}
private tick() {
this.eventBus.emit(new TickEvent(this.tickCount))
this.tickCount++;
}
getTickCount(): number {
return this.tickCount;
}
}
+11
View File
@@ -0,0 +1,11 @@
import {Cell} from "./Game";
export function generateUniqueID(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
export function manhattanDist(c1: Cell, c2: Cell): number {
return Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
}
+112
View File
@@ -0,0 +1,112 @@
import PriorityQueue from "priority-queue-typescript";
import {Cell, Execution, MutableGame, MutablePlayer, PlayerID, Player, TerrainTypes, TerraNullius, Tile} from "../Game";
import {PseudoRandom} from "../PseudoRandom";
import {manhattanDist} from "../Util";
export class AttackExecution implements Execution {
private active: boolean = true;
private toConquer: PriorityQueue<TileContainer> = new PriorityQueue<TileContainer>(11, (a: TileContainer, b: TileContainer) => a.priority - b.priority);
private random = new PseudoRandom(123)
private _owner: MutablePlayer
private target: MutablePlayer | TerraNullius
constructor(
private troops: number,
private _ownerID: PlayerID,
private targetID: PlayerID | null,
private targetCell: Cell | null
) { }
init(gs: MutableGame, ticks: number) {
this._owner = gs.player(this._ownerID)
this.target = this.targetID == null ? gs.terraNullius() : gs.player(this.targetID)
this.troops = Math.min(this._owner.troops(), this.troops)
this._owner.setTroops(this._owner.troops() - this.troops)
}
tick(ticks: number) {
if (!this.active) {
return
}
let numTilesPerTick = this._owner.borderTilesWith(this.target).size / 2
while (numTilesPerTick > 0) {
if (this.troops < 1) {
this.active = false
return
}
if (this.toConquer.size() == 0) {
this.calculateToConquer()
}
if (this.toConquer.size() == 0) {
this.active = false
this._owner.addTroops(this.troops)
return
}
const tileToConquer: Tile = this.toConquer.poll().tile
const onBorder = tileToConquer.neighbors().filter(t => t.owner() == this._owner).length > 0
if (tileToConquer.owner() != this.target || !onBorder) {
continue
}
this._owner.conquer(tileToConquer.cell())
this.troops -= 1
numTilesPerTick -= 1
}
}
private calculateToConquer() {
const border = this.owner().borderTilesWith(this.target)
const enemyBorder: Set<Tile> = new Set()
for (const b of border) {
b.neighbors()
.filter(t => t.terrain() == TerrainTypes.Land)
.filter(t => t.owner() == this.target)
.forEach(t => enemyBorder.add(t))
}
// let closestTile: Tile;
// let closestDist: number = Number.POSITIVE_INFINITY;
// for (const enemyTile of enemyBorder) {
// const dist = manhattanDist(enemyTile.cell(), this.targetCell)
// if (dist < closestDist) {
// closestTile = enemyTile
// }
// }
// tileByDist.forEach(t => console.log(`tile dist: ${manhattanDist(t.cell(), closestTile.cell())}`))
let tileByDist = []
if (this.targetCell == null) {
tileByDist = Array.from(enemyBorder).slice().sort((a, b) => this.random.next() - .5)
} else {
tileByDist = Array.from(enemyBorder).slice().sort((a, b) => manhattanDist(a.cell(), this.targetCell) - manhattanDist(b.cell(), this.targetCell))
}
for (let i = 0; i < Math.min(enemyBorder.size / 2, tileByDist.length); i++) {
const enemyTile = tileByDist[i]
const numOwnedByMe = enemyTile.neighbors()
.filter(t => t.terrain() == TerrainTypes.Land)
.filter(t => t.owner() == this._owner)
.length
// this.toConquer.add(new TileContainer(enemyTile, numOwnedByMe + (this.random.next() % 5) + (-5 * i / tileByDist.length)))
const r = this.random.next() % 4
this.toConquer.add(new TileContainer(enemyTile, r + numOwnedByMe * 1000))
}
}
owner(): MutablePlayer {
return this._owner
}
isActive(): boolean {
return this.active
}
}
class TileContainer {
constructor(public readonly tile: Tile, public readonly priority: number) { }
}
+158
View File
@@ -0,0 +1,158 @@
import PriorityQueue from "priority-queue-typescript";
import {Boat, Cell, Execution, MutableBoat, MutableGame, MutablePlayer, Player, PlayerID, Tile} from "../Game";
import {manhattanDist} from "../Util";
import {AttackExecution} from "./AttackExecution";
export class BoatAttackExecution implements Execution {
private lastMove: number
// TODO: make this configurable
private ticksPerMove = 1
private active = true
private mg: MutableGame
private attacker: MutablePlayer
private target: MutablePlayer
// TODO make private
public path: Tile[]
private src: Tile
private dst: Tile
private currTileIndex: number = 0
private boat: MutableBoat
constructor(
private attackerID: PlayerID,
private targetID: PlayerID | null,
private cell: Cell,
private troops: number
) { }
init(mg: MutableGame, ticks: number) {
if (this.targetID == null) {
throw new Error("attacking terranullius not supported")
}
this.lastMove = ticks
this.mg = mg
this.attacker = mg.player(this.attackerID)
this.target = mg.player(this.targetID)
this.troops = Math.min(this.troops, this.attacker.troops())
this.attacker.removeTroops(this.troops)
this.src = this.closestShoreTileToTarget(this.attacker, this.cell)
this.dst = this.closestShoreTileToTarget(this.target, this.cell)
this.path = this.computePath(this.src, this.dst)
if (this.path != null) {
console.log(`got path ${this.path.map(t => t.cell().toString())}`)
this.boat = this.attacker.addBoat(1000, this.src.cell(), this.target)
} else {
console.log('got null path')
this.active = false
}
}
tick(ticks: number) {
if (!this.active) {
return
}
if (ticks - this.lastMove < this.ticksPerMove) {
return
}
this.lastMove = ticks
this.currTileIndex++
if (this.currTileIndex >= this.path.length) {
if (this.dst.owner() == this.attacker) {
this.attacker.addTroops(this.troops)
this.active = false
return
}
this.attacker.conquer(this.dst.cell())
this.mg.addExecution(new AttackExecution(this.troops, this.attacker.id(), this.targetID, null))
this.active = false
return
}
const nextTile = this.path[this.currTileIndex]
this.boat.move(nextTile.cell())
}
owner(): MutablePlayer {
return this.attacker
}
isActive(): boolean {
return this.active
}
private closestShoreTileToTarget(player: Player, target: Cell): Tile {
const shoreTiles = Array.from(player.borderTiles()).filter(t => t.onShore())
return shoreTiles.reduce((closest, current) => {
const closestDistance = manhattanDist(target, closest.cell());
const currentDistance = manhattanDist(target, current.cell());
return currentDistance < closestDistance ? current : closest;
});
}
private computePath(src: Tile, dst: Tile): Tile[] {
if (!src.onShore() || !dst.onShore()) {
return null; // Both source and destination must be on water
}
const openSet = new PriorityQueue<{tile: Tile, fScore: number}>(
11,
(a, b) => a.fScore - b.fScore
);
const cameFrom = new Map<Tile, Tile>();
const gScore = new Map<Tile, number>();
gScore.set(src, 0);
openSet.add({tile: src, fScore: this.heuristic(src, dst)});
while (!openSet.empty()) {
const current = openSet.poll()!.tile;
if (current === dst) {
return this.reconstructPath(cameFrom, current);
}
for (const neighbor of current.neighbors()) {
if (!neighbor.onShore()) continue; // Skip non-water tiles
const tentativeGScore = gScore.get(current)! + 1; // Assuming uniform cost
if (!gScore.has(neighbor) || tentativeGScore < gScore.get(neighbor)!) {
cameFrom.set(neighbor, current);
gScore.set(neighbor, tentativeGScore);
const fScore = tentativeGScore + this.heuristic(neighbor, dst);
openSet.add({tile: neighbor, fScore: fScore});
}
}
}
return null; // No path found
}
private heuristic(a: Tile, b: Tile): number {
// Manhattan distance
return Math.abs(a.cell().x - b.cell().x) + Math.abs(a.cell().y - b.cell().y);
}
private reconstructPath(cameFrom: Map<Tile, Tile>, current: Tile): Tile[] {
const path = [current];
while (cameFrom.has(current)) {
current = cameFrom.get(current)!;
path.unshift(current);
}
return path;
}
}
+55
View File
@@ -0,0 +1,55 @@
import {Cell, Execution, MutableGame, MutablePlayer, PlayerID, PlayerInfo} from "../Game"
import {PseudoRandom} from "../PseudoRandom"
import {AttackExecution} from "./AttackExecution";
export class BotExecution implements Execution {
private ticks = 0
private active = true
private random: PseudoRandom;
private attackRate: number
private gs: MutableGame
constructor(private bot: MutablePlayer) {
this.random = new PseudoRandom(bot.id())
this.attackRate = this.random.nextInt(100, 500)
}
init(gs: MutableGame, ticks: number) {
this.gs = gs
}
tick(ticks: number) {
if (!this.bot.isAlive()) {
this.active = false
return
}
this.ticks++
if (this.ticks % this.attackRate == 0) {
const ns = this.bot.neighbors()
if (ns.length == 0) {
return
}
const toAttack = ns[this.random.nextInt(0, ns.length)]
this.gs.addExecution(new AttackExecution(
this.bot.troops() / 5,
this.bot.id(),
toAttack.isPlayer() ? toAttack.id() : null,
null
))
}
}
owner(): MutablePlayer {
return this.bot
}
isActive(): boolean {
return this.active
}
}
+60
View File
@@ -0,0 +1,60 @@
import {Cell, Game, TerrainTypes} from "../Game";
import {PseudoRandom} from "../PseudoRandom";
import {SpawnIntent} from "../Schemas";
import {getSpawnCells} from "./Util";
export class BotSpawner {
private cellToIndex;
private freeTiles: Cell[];
private numFreeTiles;
private random = new PseudoRandom(123);
constructor(private gs: Game) { }
spawnBots(numBots: number): SpawnIntent[] {
const bots: SpawnIntent[] = [];
this.cellToIndex = new Map<string, number>();
this.freeTiles = new Array();
this.numFreeTiles = 0;
this.gs.forEachTile(tile => {
if (tile.terrain() == TerrainTypes.Water) {
return;
}
if (tile.hasOwner()) {
return;
}
this.freeTiles.push(tile.cell());
this.cellToIndex.set(tile.cell().toString(), this.numFreeTiles);
this.numFreeTiles++;
});
for (let i = 0; i < numBots; i++) {
bots.push(this.spawnBot("Bot" + i));
}
return bots;
}
spawnBot(botName: string): SpawnIntent {
const rand = this.random.nextInt(0, this.numFreeTiles);
const spawn = this.freeTiles[rand];
const spawnCells = getSpawnCells(this.gs, spawn);
spawnCells.forEach(c => this.removeCell(c));
const spawnIntent: SpawnIntent = {
type: 'spawn',
name: botName,
isBot: true,
x: spawn.x,
y: spawn.y
};
return spawnIntent;
}
private removeCell(cell: Cell) {
const index = this.cellToIndex[cell.toString()];
this.freeTiles[index] = this.freeTiles[this.numFreeTiles - 1];
this.cellToIndex[this.freeTiles[index].toString()] = index;
this.numFreeTiles--;
}
}
+55
View File
@@ -0,0 +1,55 @@
import PriorityQueue from "priority-queue-typescript";
import {Cell, Execution, MutableGame, Game, MutablePlayer, PlayerInfo, TerraNullius, Tile} from "../Game";
import {AttackIntent, BoatAttackIntentSchema, Intent, Turn} from "../Schemas";
import {AttackExecution} from "./AttackExecution";
import {SpawnExecution} from "./SpawnExecution";
import {BotSpawner} from "./BotSpawner";
import {BoatAttackExecution} from "./BoatAttackExecution";
export class Executor {
constructor(private gs: Game) {
}
addTurn(turn: Turn) {
turn.intents.forEach(i => this.addIntent(i))
}
addIntent(intent: Intent) {
if (intent.type == "attack") {
this.gs.addExecution(
new AttackExecution(
intent.troops,
intent.attackerID,
intent.targetID,
new Cell(intent.targetX, intent.targetY)
)
)
} else if (intent.type == "spawn") {
this.gs.addExecution(
new SpawnExecution(
new PlayerInfo(intent.name, intent.isBot),
new Cell(intent.x, intent.y),
)
)
} else if (intent.type == "boat") {
this.gs.addExecution(
new BoatAttackExecution(
intent.attackerID,
intent.targetID,
new Cell(intent.x, intent.y),
intent.troops,
)
)
} else {
throw new Error(`intent type ${intent} not found`)
}
}
spawnBots(numBots: number): void {
new BotSpawner(this.gs).spawnBots(numBots).forEach(i => this.addIntent(i))
}
}
+25
View File
@@ -0,0 +1,25 @@
import {Execution, MutableGame, MutablePlayer, PlayerID} from "../Game"
export class PlayerExecution implements Execution {
private player: MutablePlayer
constructor(private playerID: PlayerID) {
}
init(gs: MutableGame, ticks: number) {
this.player = gs.player(this.playerID)
}
tick(ticks: number) {
this.player.addTroops(Math.sqrt(this.player.numTilesOwned() * this.player.troops() + 1000) / 1000)
}
owner(): MutablePlayer {
return this.player
}
isActive(): boolean {
return this.player.isAlive()
}
}
+42
View File
@@ -0,0 +1,42 @@
import {Cell, Execution, MutableGame, MutablePlayer, PlayerInfo} from "../Game"
import {BotExecution} from "./BotExecution"
import {PlayerExecution} from "./PlayerExecution"
import {getSpawnCells} from "./Util"
export class SpawnExecution implements Execution {
active: boolean = true
private gs: MutableGame
constructor(
private playerInfo: PlayerInfo,
private cell: Cell,
) { }
init(gs: MutableGame, ticks: number) {
this.gs = gs
}
tick(ticks: number) {
if (!this.isActive()) {
return
}
const player = this.gs.addPlayer(this.playerInfo)
getSpawnCells(this.gs, this.cell).forEach(c => {
console.log('conquering cell')
player.conquer(c)
})
this.gs.addExecution(new PlayerExecution(player.id()))
if (player.info().isBot) {
this.gs.addExecution(new BotExecution(player))
}
this.active = false
}
owner(): MutablePlayer {
return null
}
isActive(): boolean {
return this.active
}
}
+25
View File
@@ -0,0 +1,25 @@
import {Game, Cell, TerrainTypes} from "../Game";
export function getSpawnCells(gs: Game, cell: Cell): Cell[] {
let result: Cell[] = [];
for (let dx = -2; dx <= 2; dx++) {
for (let dy = -2; dy <= 2; dy++) {
let c = new Cell(cell.x + dx, cell.y + dy);
if (!gs.isOnMap(c)) {
continue;
}
if (Math.abs(dx) === 2 && Math.abs(dy) === 2) {
continue;
}
if (gs.tile(c).terrain() != TerrainTypes.Land) {
continue;
}
if (gs.tile(c).hasOwner()) {
continue;
}
result.push(c);
}
}
return result;
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.png' {
const content: string;
export default content;
}
+7
View File
@@ -0,0 +1,7 @@
import {ClientID} from "../core/Game";
import WebSocket from 'ws';
export class Client {
constructor(public readonly id: ClientID, public readonly ws: WebSocket) { }
}
+66
View File
@@ -0,0 +1,66 @@
import {GameID, LobbyID} from "../core/Game";
import {Client} from "./Client";
import {Lobby} from "./Lobby";
import {GameServer} from "./GameServer";
import {defaultSettings, Settings} from "../core/Settings";
import {generateUniqueID} from "../core/Util";
export class GameManager {
private lastNewLobby: number = 0
private _lobbies: Map<LobbyID, Lobby> = new Map()
private games: Map<GameID, GameServer> = new Map()
constructor(private settings: Settings) { }
public hasLobby(lobbyID: LobbyID): boolean {
return this._lobbies.has(lobbyID)
}
public addClientToLobby(client: Client, lobbyID: LobbyID) {
this._lobbies.get(lobbyID).addClient(client)
}
addLobby(lobby: Lobby) {
this._lobbies.set(lobby.id, lobby)
}
lobby(id: LobbyID): Lobby {
return this._lobbies.get(id)
}
lobbies(): Lobby[] {
return Array.from(this._lobbies.values())
}
addGame(game: GameServer) {
this.games.set(game.id, game)
}
startGame(lobby: Lobby) {
const gs = new GameServer(generateUniqueID(), lobby.clients, defaultSettings)
this.games.set(gs.id, gs)
gs.start()
}
tick() {
const now = Date.now()
const active = this.lobbies().filter(l => !l.isExpired(now))
const expired = this.lobbies().filter(l => l.isExpired(now))
this._lobbies = new Map(active.map(lobby => [lobby.id, lobby]));
expired.forEach(lobby => {
const game = new GameServer(generateUniqueID(), lobby.clients, this.settings)
this.games.set(game.id, game)
game.start()
})
if (now > this.lastNewLobby + this.settings.lobbyCreationRate()) {
this.lastNewLobby = now
this.addLobby(new Lobby(generateUniqueID(), this.settings.lobbyLifetime()))
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import {EventBus} from "../core/EventBus";
import {ClientID, GameID} from "../core/Game";
import {ClientMessage, ClientMessageSchema, Intent, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn} from "../core/Schemas";
import {Settings} from "../core/Settings";
import {Ticker, TickEvent} from "../core/Ticker";
import {Client} from "./Client";
export class GameServer {
private turns: Turn[] = []
private intents: Intent[] = []
constructor(
public readonly id: GameID,
private clients: Map<ClientID, Client>,
private settings: Settings,
) {
}
public start() {
this.clients.forEach(c => {
c.ws.on('message', (message: string) => {
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "intent") {
this.addIntent(clientMsg.intent)
}
})
})
const startGame = JSON.stringify(ServerStartGameMessageSchema.parse(
{
type: "start"
}
))
this.clients.forEach(c => {
c.ws.send(startGame)
})
setInterval(() => this.endTurn(), this.settings.turnIntervalMs());
}
private addIntent(intent: Intent) {
this.intents.push(intent)
}
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
intents: this.intents
}
this.turns.push(pastTurn)
this.intents = []
const msg = JSON.stringify(ServerTurnMessageSchema.parse(
{
type: "turn",
turn: pastTurn
}
))
this.clients.forEach(c => {
c.ws.send(msg)
})
}
private tick(event: TickEvent) {
}
}
+21
View File
@@ -0,0 +1,21 @@
import {ClientID} from "../core/Game";
import {Client} from "./Client";
export class Lobby {
public clients: Map<ClientID, Client> = new Map()
private startGameTs: number
constructor(public readonly id: string, durationMs: number) {
this.startGameTs = Date.now() + durationMs
}
public addClient(client: Client) {
this.clients.set(client.id, client)
}
public isExpired(now: number): boolean {
return now > this.startGameTs
}
}
+70
View File
@@ -0,0 +1,70 @@
import express, {json} from 'express';
import http from 'http';
import {WebSocketServer} from 'ws';
import path from 'path';
import {fileURLToPath} from 'url';
import {GameManager} from './GameManager';
import {Client} from './Client';
import {ClientMessage, ClientMessageSchema} from '../core/Schemas';
import {Lobby} from './Lobby';
import {defaultSettings} from '../core/Settings';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({server});
// Serve static files from the 'out' directory
app.use(express.static(path.join(__dirname, '../../out')));
app.use(express.json())
const gm = new GameManager(defaultSettings)
// New GET endpoint to list lobbies
app.get('/lobbies', (req, res) => {
const lobbyList = Array.from(gm.lobbies()).map(lobby => ({
id: lobby.id,
}));
res.json({
lobbies: lobbyList,
});
});
wss.on('connection', (ws) => {
ws.on('message', (message: string) => {
console.log(`got message ${message}`)
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
if (clientMsg.type == "join") {
if (gm.hasLobby(clientMsg.lobbyID)) {
gm.addClientToLobby(new Client(clientMsg.clientID, ws), clientMsg.lobbyID)
}
}
// TODO: send error message
})
});
function runGame() {
setInterval(() => tick(), 1000);
}
function tick() {
gm.tick()
}
const PORT = process.env.PORT || 3000;
console.log(`Server will try to run on http://localhost:${PORT}`);
server.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
runGame()