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
+26
View File
@@ -0,0 +1,26 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup npm
run: npm install
- name: Prebuild
run: npm run prebuild
- name: Build
run: npm run build-prod
- uses: actions/upload-artifact@v4
with:
path: out/index.html
retention-days: 1
+3
View File
@@ -0,0 +1,3 @@
build/
node_modules/
out/
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/map/codec"]
path = src/map/codec
url = https://github.com/WarFrontIO/MapCodec
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 WarFront.io Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
# OpenFront.io
OpenFront is an online rts.
This is a fork/rewrite of WarFront.io. Credit to https://github.com/WarFrontIO.
## Building
To build the project, you will need to have Node.js and npm installed. You can download them from [here](https://nodejs.org/).
Before building the project, you will need to install the dependencies. You can do this by running the following command in the project directory:
```bash
git submodule update --init --recursive
npm install
```
To run dev build:
```bash
npm run dev
```
+8
View File
@@ -0,0 +1,8 @@
* fix conquer expansion
* improve front page
* make boats larger
* perf improvements on graphics (only draw images to canvas on ticks)
* better troop addition logic
* maybe cache neigbors?
* have boats not get close to shore
* better algorithm for name render placement
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
transform: {'^.+\\.ts?$': 'ts-jest'},
testEnvironment: 'node',
testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
};
+12101
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "warfront-client",
"scripts": {
"prebuild": "tsc --project scripts",
"build-dev": "webpack --config webpack.config.js --mode development",
"build-prod": "webpack --config webpack.config.js --mode production",
"start:client": "webpack serve --open --node-env development",
"start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts",
"dev": "concurrently \"npm run start:client\" \"npm run start:server\"",
"build": "tsc",
"test": "jest"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/preset-typescript": "^7.24.7",
"@types/chai": "^4.3.17",
"@types/jest": "^29.5.12",
"@types/mocha": "^10.0.7",
"@types/sinon": "^17.0.3",
"@types/ws": "^8.5.11",
"babel-jest": "^29.7.0",
"chai": "^5.1.1",
"concurrently": "^8.2.2",
"html-inline-script-webpack-plugin": "^3.2.1",
"html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
"mocha": "^10.7.0",
"mrmime": "^2.0.0",
"sinon": "^18.0.0",
"sinon-chai": "^4.0.0",
"ts-jest": "^29.2.4",
"ts-loader": "^9.5.1",
"ts-mocha": "^10.0.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/jimp": "^0.2.28",
"colord": "^2.9.3",
"express": "^4.19.2",
"jimp": "^0.22.12",
"node-addon-api": "^8.1.0",
"node-gyp": "^10.2.0",
"priority-queue-typescript": "^1.0.1",
"typia": "^6.5.2",
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"type": "module"
}
+125
View File
@@ -0,0 +1,125 @@
/*
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
body {
line-height: 1.31;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}
/* Generic margins */
.m-1_2 {
margin: 0.5em;
}
.m-1 {
margin: 1em;
}
.mr-1_2 {
margin-right: 0.5em;
}
.mr-1 {
margin-right: 1em;
}
/* Generic padding */
.p-1_2 {
padding: 0.5em;
}
.p-1 {
padding: 1em;
}
.pt-1 {
padding-top: 1em;
}
/* Generic widths */
.w-100 {
width: 100%;
}
/* Typography helpers */
.text-center {
text-align: center;
}
.text-lg {
font-size: 1.25em;
}
.text-xl {
font-size: 1.4em;
}
.text-xxl {
font-size: 2em;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+3
View File
@@ -0,0 +1,3 @@
{
"tiles": ["#000000", "#AAAAAA"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+521
View File
@@ -0,0 +1,521 @@
@import "../base.css";
/* Fonts */
@font-face {
font-family: 'Vanchrome';
src: url(/resources/themes/vanchrome.woff) format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url(/resources/themes/overpass-regular.woff) format("woff");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url(/resources/themes/overpass-bold.woff) format("woff");
font-weight: 700;
font-style: normal;
}
/* Global variables */
:root {
--color-background: #ddecf5;
--color-light: #FFFFFF;
--color-primary-dark: #13415c;
--color-primary: #457493;
--color-primary-medium: #77aac9;
--color-primary-light: #9dc4de;
--color-secondary-dark: #9c8559;
--color-secondary: #c5b47a;
--color-secondary-medium: #e5d59e;
--color-secondary-light: #f3f3c6;
--color-danger-dark: #7b1e2b;
--color-danger: #c83446;
--color-danger-medium: #ed6780;
--color-danger-light: #ee92a4;
--color-success-dark: #4f7348;
--color-success: #82ad77;
--color-success-medium: #a3ce94;
--color-success-light: #c0dcaf;
--color-copy: #555555;
}
/* General formatting */
body {
font-family: 'Overpass', Arial, Helvetica, sans-serif;
background-color: var(--color-background);
font-size: 100%;
background-image: url(/resources/themes/pastel-mm-bg.jpg);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
@media screen and (max-width: 1023px) {
body {
font-size: 90% !important;
}
}
@media screen and (max-width: 767px) {
body {
font-size: 80% !important;
}
}
h1,
h2 {
font-family: 'Vanchrome';
font-weight: 500;
text-transform: uppercase;
}
h2 {
font-size: 2.2em;
color: var(--color-primary-dark);
margin-bottom: 0.375em;
}
a {
text-decoration: none;
color: var(--color-primary);
}
a:hover {
text-decoration: none;
color: var(--color-primary-light);
}
a:active {
text-decoration: none;
color: var(--color-primary-dark);
}
a > svg {
fill: #EEEEEE !important;
display: inline-block !important;
}
a > svg:hover,
a > svg:active {
fill: #FFFFFF !important;
transform: scale(1.05);
}
p {
margin-bottom: 0.8em;
}
/* Logo and background */
.theme-logo {
background-image: url(/resources/themes/wf-logo-pastel.png);
background-position: center;
background-size: contain;
background-repeat: no-repeat;
margin-bottom: 1em;
}
.background-blur {
backdrop-filter: blur(5px);
}
.background-vignette {
box-shadow: 0 0 15em rgba(0, 0, 0, 0.6) inset;
}
@media screen and (max-width: 1023px) {
.background-vignette {
box-shadow: 0 0 8em rgba(0, 0, 0, 0.6) inset !important;
}
}
@media screen and (max-width: 767px) {
.background-vignette {
box-shadow: 0 0 4em rgba(0, 0, 0, 0.6) inset !important;
}
}
/* Layout Windows */
.wf-window {
position: absolute;
height: 100vh;
width: 100%;
display: flex;
padding: 1em;
}
.wf-window-centered {
justify-items: center;
justify-content: center;
align-items: center;
align-content: center;
flex-flow: column;
}
@media screen and (max-height: 511px) {
.wf-window-centered {
flex-flow: row !important;
}
}
.wf-window-spaced {
justify-items: center;
justify-content: space-between;
align-items: center;
align-content: space-between;
flex-flow: column;
pointer-events: none;
}
/* General elements */
.wf-footer {
position: absolute;
height: 2em; width: 100%;
top: calc(100% - 2em);
background-color: rgba(0,0,0,0.2);
color: #CCCCCC;
padding: 0.25em;
text-align: center;
font-size: 0.8em;
}
.wf-footer a {
color: #CCCCCC;
}
.wf-footer a:hover {
color: #EEEEEE;
}
.wf-footer a:active {
color: #AAAAAA;
}
/* Ingame HUD */
.wf-hud-menubar {
height: 4em;
padding: 0.5em;
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
align-content: flex-start;
justify-items: flex-end;
justify-content: flex-end;
pointer-events: all;
}
/* Layout elements */
.wf-grid {
display: grid;
gap: 0.5em;
}
.wf-grid-1col {
grid-template-columns: 1fr;
}
.wf-grid-2col {
grid-template-columns: 1fr 1fr;
}
@media screen and (max-width: 767px) {
.wf-grid-2col {
grid-template-columns: 1fr !important;
}
}
.wf-grid-gap-lg {
gap: 1em;
}
.wf-grid-gap-xl {
gap: 2em;
}
/* Buttons */
.wf-btn {
display: inline-block;
text-align: center;
font-family: 'Overpass';
font-size: 1em;
font-weight: 600;
padding: 0.5em 0.75em 0.5em 0.75em;
color: white;
background-color: #666666;
border: 2px solid #888888;
outline: 1px solid #444444;
outline-offset: -1px;
font-optical-sizing: auto;
text-decoration: none;
cursor: default;
}
.wf-btn:hover {
background-color: #888888;
border: 2px solid #AAAAAA;
outline: 1px solid #666666;
transform: scale(1.01);
color: white;
}
.wf-btn:active {
background-color: #444444;
border: 2px solid #888888;
outline: 1px solid #444444;
transform: scale(1.01);
color: white;
}
.wf-btn-block {
display: block;
width: 100%;
}
.wf-btn-circle {
padding-left: 0;
padding-right: 0;
border-radius: 100%;
width: 2.625em;
height: 2.625em;
padding-top: 0.45em !important;
}
.wf-btn-circle > svg {
width: 1.5em;
height: 1.5em;
fill: var(--color-light);
display: block;
margin: 0 auto;
}
.wf-btn-featured {
font-family: 'Vanchrome', sans-serif;
padding: 0.15em 0.5em;
font-size: 2em;
text-transform: uppercase;
font-weight: 500;
}
/* Button colors */
/* Button colors */
.wf-btn-primary {
background-color: var(--color-primary);
border-color: var(--color-primary-medium);
outline-color: var(--color-primary-dark);
}
.wf-btn-primary:hover {
background-color: var(--color-primary-medium);
border-color: var(--color-primary-light);
outline-color: var(--color-primary);
}
.wf-btn-primary:active {
background-color: var(--color-primary-dark);
border-color: var(--color-primary-medium);
outline-color: var(--color-primary);
}
.wf-btn-primary:disabled {
background-color: var(--color-primary-dark);
border-color: var(--color-primary-medium);
outline-color: var(--color-primary);
color: #999999;
}
.wf-btn-secondary {
background-color: var(--color-secondary);
border-color: var(--color-secondary-medium);
outline-color: var(--color-secondary-dark);
}
.wf-btn-secondary:hover {
background-color: var(--color-secondary-medium);
border-color: var(--color-secondary-light);
outline-color: var(--color-secondary);
}
.wf-btn-secondary:active {
background-color: var(--color-secondary-dark);
border-color: var(--color-secondary-medium);
outline-color: var(--color-secondary);
}
.wf-btn-danger {
background-color: var(--color-danger);
border: 2px solid var(--color-danger-medium);
outline: 1px solid var(--color-danger-dark);
}
.wf-btn-danger:hover {
background-color: var(--color-danger-medium);
border-color: var(--color-danger-light);
outline-color: var(--color-danger);
}
.wf-btn-danger:active {
background-color: var(--color-danger-dark);
border-color: var(--color-danger-medium);
outline-color: var(--color-danger);
}
.wf-btn-success {
background-color: var(--color-success);
border: 2px solid var(--color-success-medium);
outline: 1px solid var(--color-success-dark);
}
.wf-btn-success:hover {
background-color: var(--color-success-medium);
border-color: var(--color-success-light);
outline-color: var(--color-success);
}
.wf-btn-success:active {
background-color: var(--color-success-dark);
border-color: var(--color-success-medium);
outline-color: var(--color-success);
}
/* Panels and Modals */
.wf-panel {
background-color: #eeeeeebb;
flex-grow: 0;
}
.wf-panel-header {
font-family: 'Vanchrome';
font-size: 2.2em;
font-weight: 500;
text-transform: uppercase;
line-height: 0;
margin: 0;
padding: 0.75em 0.375em !important;
background-color: var(--color-danger);
color: white;
border-bottom: 3px solid var(--color-danger-dark);
}
a.wf-panel-header-button {
text-decoration: none;
color: white;
float: right;
margin-right: 0.375em;
padding-top: 0.375em;
}
a.wf-panel-header-button:hover {
color: var(--color-primary-light);
}
a.wf-panel-header-button:active {
color: var(--color-primary-dark);
}
.wf-modal-header {
font-size: 1em;
color: var(--color-primary);
margin: -0.5em -0.5em 0.5em -0.5em;
padding: 0.5em 0.5em 0.5em 0.5em;
border-bottom: 3px solid var(--color-primary-light);
}
a.wf-modal-header-button {
text-decoration: none;
color: var(--color-primary);
float: right;
}
a.wf-modal-header-button:hover {
color: var(--color-primary-light);
}
a.wf-modal-header-button:active {
color: var(--color-primary-dark);
}
.wf-panel-body {
color: var(--color-copy);
padding: 0.5em;
border: 3px solid var(--color-primary-light);
outline: 1px solid var(--color-primary);
outline-offset: -1px;
box-shadow: 0px 3px 2px 0px rgba(0, 0, 0, 0.5);
max-height: 90vh;
overflow: auto;
width: 100%;
}
.wf-panel-header + .wf-panel-body {
padding: 1em;
}
.wf-panel-header+.wf-panel-body {
border-top: none;
}
/* Labels */
.wf-lbl {
display: block;
background-color: #CCC;
border-left: 2px solid #999;
padding: 0.375em;
}
.wf-lbl-danger {
background-color: var(--color-danger-light);
color: var(--color-danger-dark);
border-left: 2px solid var(--color-danger-dark);
}
/* Form elements */
input.wf-form-control {
display: block;
background-color: var(color-form-element-bg);
border: none;
border-bottom: 2px solid var(--color-primary);
padding: 0.375em;
line-height: 1em;
}
.wf-form-control-error {
border-bottom: 2px solid var(--color-danger) !important;
}
input.wf-form-control:focus {
border: none;
outline: none;
border-bottom: 2px solid var(--color-secondary);
}
+36
View File
@@ -0,0 +1,36 @@
{
"territory": [
"saturation * 0.5",
"lightness = 0.75"
],
"border": [
"saturation * 0.5",
"lightness = 0.65"
],
"tiles": [
"lightness = 0.8"
],
"tileOverwrite": {
"grass": "rgb(244, 243, 198)",
"water": "rgb(160, 203, 231)"
},
"shaders": [
{
"type": "territory-outline",
"color": "rgba(152, 185, 223, 1)",
"thickness": 1
},
{
"type": "territory-outline-smooth",
"color": "rgba(255, 255, 255, 0.1)",
"thickness": 15
},
{
"type": "territory-inline",
"color": "rgba(201, 187, 139, 1)",
"thickness": 1
}
],
"background": "#7ba6c2",
"font": "Overpass"
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+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()
+170
View File
@@ -0,0 +1,170 @@
import {GameImpl, PlayerImpl} from '../src/core/GameImpl';
import {EventBus} from '../src/core/EventBus';
import {Game, Cell, MutablePlayer, PlayerInfo, TerrainMap, TerrainTypes, Tile} from '../src/core/Game';
describe('borderTilesWith', () => {
let game: GameImpl;
let player1: PlayerImpl;
let player2: PlayerImpl;
let terrainMap: TerrainMap;
beforeEach(() => {
// Create a 5x5 terrain map
terrainMap = {
terrain: jest.fn().mockReturnValue(TerrainTypes.Land),
width: jest.fn().mockReturnValue(5),
height: jest.fn().mockReturnValue(5)
};
const eventBus = new EventBus();
game = new GameImpl(terrainMap, eventBus);
player1 = game.addPlayer(new PlayerInfo('Player 1', false)) as PlayerImpl;
player2 = game.addPlayer(new PlayerInfo('Player 2', false)) as PlayerImpl;
});
test('should return an empty set when players have no bordering tiles', () => {
const borderTiles = player1.borderTilesWith(player2);
expect(borderTiles.size).toBe(0);
});
test('should return correct border tiles when players are adjacent', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player2, new Cell(1, 0));
const borderTilesP1 = player1.borderTilesWith(player2);
const borderTilesP2 = player2.borderTilesWith(player1);
expect(borderTilesP1.size).toBe(1);
expect(borderTilesP2.size).toBe(1);
const p1BorderTile = Array.from(borderTilesP1)[0];
const p2BorderTile = Array.from(borderTilesP2)[0];
expect(p1BorderTile.cell()).toEqual(new Cell(0, 0));
expect(p2BorderTile.cell()).toEqual(new Cell(1, 0));
});
test('should update border tiles when a new tile is conquered', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player2, new Cell(2, 0));
expect(player1.borderTilesWith(player2).size).toBe(0);
game.conquer(player2, new Cell(1, 0));
const borderTiles = player1.borderTilesWith(player2);
expect(borderTiles.size).toBe(1);
expect(Array.from(borderTiles)[0].cell()).toEqual(new Cell(0, 0));
});
test('should handle multiple border tiles correctly', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player1, new Cell(0, 1));
game.conquer(player2, new Cell(1, 0));
game.conquer(player2, new Cell(1, 1));
const borderTiles = player1.borderTilesWith(player2);
expect(borderTiles.size).toBe(2);
const borderCells = Array.from(borderTiles).map(tile => tile.cell());
expect(borderCells).toEqual(expect.arrayContaining([new Cell(0, 0), new Cell(0, 1)]));
});
test('should update border tiles when a tile changes ownership', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player1, new Cell(1, 0));
game.conquer(player2, new Cell(2, 0));
expect(player1.borderTilesWith(player2).size).toBe(1);
game.conquer(player2, new Cell(1, 0));
const borderTilesP1 = player1.borderTilesWith(player2);
const borderTilesP2 = player2.borderTilesWith(player1);
expect(borderTilesP1.size).toBe(1);
expect(borderTilesP2.size).toBe(1);
expect(Array.from(borderTilesP1)[0].cell()).toEqual(new Cell(0, 0));
expect(Array.from(borderTilesP2).map(t => t.cell())).toEqual(
expect.arrayContaining([new Cell(1, 0)])
);
});
test('should handle border tiles with TerraNullius', () => {
game.conquer(player1, new Cell(0, 0));
const borderWithTerraNullius = player1.borderTilesWith(game.terraNullius());
expect(borderWithTerraNullius.size).toBe(1);
const borderCells = Array.from(borderWithTerraNullius).map(tile => tile.cell());
expect(borderCells).toEqual(expect.arrayContaining([new Cell(0, 0)]));
});
test('should not include diagonal tiles as borders', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player2, new Cell(1, 1));
expect(player1.borderTilesWith(player2).size).toBe(0);
expect(player2.borderTilesWith(player1).size).toBe(0);
});
// test('should handle complex border scenarios', () => {
// // Create a more complex border scenario
// // 0 1 2 3 4
// // 0 1 1 2 2 2
// // 1 1 1 2 2 2
// // 2 1 1 1 2 2
// // 3 1 1 1 1 2
// // 4 1 1 1 1 1
// for (let y = 0; y < 5; y++) {
// for (let x = 0; x < 5; x++) {
// if (x + y < 6) {
// game.conquer(player1, new Cell(x, y));
// } else {
// game.conquer(player2, new Cell(x, y));
// }
// }
// }
// const borderTilesP1 = player1.borderTilesWith(player2);
// const borderTilesP2 = player2.borderTilesWith(player1);
// expect(borderTilesP1.size).toBe(5);
// expect(borderTilesP2.size).toBe(5);
// const expectedBorderP1 = [
// new Cell(2, 0),
// new Cell(2, 1),
// new Cell(3, 2),
// new Cell(3, 3),
// new Cell(4, 3)
// ];
// const expectedBorderP2 = [
// new Cell(2, 2),
// new Cell(3, 1),
// new Cell(3, 2),
// new Cell(4, 1),
// new Cell(4, 2)
// ];
// const actualBorderP1 = Array.from(borderTilesP1).map(t => t.cell());
// const actualBorderP2 = Array.from(borderTilesP2).map(t => t.cell());
// expect(actualBorderP1).toEqual(expect.arrayContaining(expectedBorderP1));
// expect(actualBorderP2).toEqual(expect.arrayContaining(expectedBorderP2));
// });
test('should handle border updates when a player loses all tiles', () => {
game.conquer(player1, new Cell(0, 0));
game.conquer(player2, new Cell(1, 0));
expect(player1.borderTilesWith(player2).size).toBe(1);
game.conquer(player1, new Cell(1, 0)); // Player 1 takes Player 2's only tile
expect(player1.borderTilesWith(player2).size).toBe(0);
expect(player2.borderTilesWith(player1).size).toBe(0);
});
});
+194
View File
@@ -0,0 +1,194 @@
// import {Game, Player, Tile, Cell, TerraNullius, PlayerInfo} from '../src/core/GameApi';
// import {placeName, calculateBoundingBox, createGrid, findLargestInscribedRectangle, largestRectangleInHistogram, calculateFontSize} from '../src/client/NameBoxCalculator';
// class MockPlayer implements Player {
// constructor(private playerTiles: [number, number][]) { }
// info(): PlayerInfo {
// return new PlayerInfo("TestPlayer", false);
// }
// id(): PlayerID {
// return 1;
// }
// troops(): number {
// return 0;
// }
// ownsTile(cell: Cell): boolean {
// return this.playerTiles.some(([x, y]) => x === cell.x && y === cell.y);
// }
// isAlive(): boolean {
// return true;
// }
// gameState(): Game {
// return {} as Game; // This should be properly implemented
// }
// executions(): ExecutionView[] {
// return [];
// }
// borderTilesWith(other: Player | TerraNullius): ReadonlySet<Tile> {
// return new Set();
// }
// isPlayer(): this is Player {
// return true;
// }
// neighbors(): (Player | TerraNullius)[] {
// return [];
// }
// }
// class MockGame implements Game {
// private tiles: Tile[][] = [];
// private mockPlayer: Player;
// constructor(width: number, height: number, playerTiles: [number, number][]) {
// this.tiles = Array(height).fill(null).map(() => Array(width).fill(null));
// this.mockPlayer = new MockPlayer(playerTiles);
// for (let y = 0; y < height; y++) {
// for (let x = 0; x < width; x++) {
// this.tiles[y][x] = {
// owner: () => playerTiles.some(([px, py]) => px === x && py === y) ? this.mockPlayer : this.terraNullius(),
// hasOwner: () => playerTiles.some(([px, py]) => px === x && py === y),
// isBorder: () => false,
// isInterior: () => false,
// cell: () => new Cell(x, y),
// terrain: () => ({expansionCost: 1, expansionTime: 1}),
// game: () => this,
// neighbors: () => []
// };
// }
// }
// }
// player(id: PlayerID): Player {return this.mockPlayer;}
// tile(cell: Cell): Tile {return this.tiles[cell.y][cell.x];}
// isOnMap(cell: Cell): boolean {return cell.x >= 0 && cell.x < this.width() && cell.y >= 0 && cell.y < this.height();}
// neighbors(cell: Cell): Cell[] {return [];}
// width(): number {return this.tiles[0].length;}
// height(): number {return this.tiles.length;}
// forEachTile(fn: (tile: Tile) => void): void {this.tiles.flat().forEach(fn);}
// executions(): ExecutionView[] {return [];}
// terraNullius(): TerraNullius {return {ownsTile: () => false, isPlayer: () => false};}
// tick() { }
// addExecution(...exec: Execution[]) { }
// }
// // Mock implementations
// class MockGame implements Game {
// private tiles: Tile[][] = [];
// private mockPlayer: Player;
// constructor(width: number, height: number, playerTiles: [number, number][]) {
// this.tiles = Array(height).fill(null).map(() => Array(width).fill(null));
// this.mockPlayer = {
// info: () => new PlayerInfo("TestPlayer", false),
// id: () => 1,
// troops: () => 0,
// ownsTile: (cell: Cell) => playerTiles.some(([x, y]) => x === cell.x && y === cell.y),
// isAlive: () => true,
// gameState: () => this,
// executions: () => [],
// borderTilesWith: () => new Set(),
// isPlayer: function (this: Player): this is Player {return true},
// neighbors: () => []
// };
// for (let y = 0; y < height; y++) {
// for (let x = 0; x < width; x++) {
// this.tiles[y][x] = {
// owner: () => playerTiles.some(([px, py]) => px === x && py === y) ? this.mockPlayer : this.terraNullius(),
// hasOwner: () => playerTiles.some(([px, py]) => px === x && py === y),
// isBorder: () => false,
// isInterior: () => false,
// cell: () => new Cell(x, y),
// terrain: () => ({expansionCost: 1, expansionTime: 1}),
// game: () => this,
// neighbors: () => []
// };
// }
// }
// }
// player(id: number): Player {return this.mockPlayer;}
// tile(cell: Cell): Tile {return this.tiles[cell.y][cell.x];}
// isOnMap(cell: Cell): boolean {return cell.x >= 0 && cell.x < this.width() && cell.y >= 0 && cell.y < this.height();}
// neighbors(cell: Cell): Cell[] {return [];}
// width(): number {return this.tiles[0].length;}
// height(): number {return this.tiles.length;}
// forEachTile(fn: (tile: Tile) => void): void {this.tiles.flat().forEach(fn);}
// executions(): any[] {return [];}
// terraNullius(): TerraNullius {return {ownsTile: () => false, isPlayer: () => false};}
// tick() { }
// addExecution(...exec: any[]) { }
// }
// describe('Territory Name Placement', () => {
// test('placeName should return a position and font size', () => {
// const game = new MockGame(5, 5, [[1, 1], [2, 1], [3, 1], [2, 2], [2, 3]]);
// const player = game.player(1);
// const result = placeName(game, player);
// expect(result).toHaveProperty('position');
// expect(result).toHaveProperty('fontSize');
// expect(result.position).toHaveProperty('x');
// expect(result.position).toHaveProperty('y');
// expect(typeof result.fontSize).toBe('number');
// });
// test('calculateBoundingBox should return correct bounding box', () => {
// const game = new MockGame(5, 5, [[1, 1], [3, 3]]);
// const player = game.player(1);
// const boundingBox = calculateBoundingBox(game, player);
// expect(boundingBox).toEqual({min: {x: 1, y: 1}, max: {x: 3, y: 3}});
// });
// test('createGrid should create correct boolean grid', () => {
// const game = new MockGame(3, 3, [[0, 0], [1, 1], [2, 2]]);
// const player = game.player(1);
// const boundingBox = {min: {x: 0, y: 0}, max: {x: 2, y: 2}};
// const grid = createGrid(game, player, boundingBox);
// expect(grid).toEqual([
// [true, false, false],
// [false, true, false],
// [false, false, true]
// ]);
// });
// test('findLargestInscribedRectangle should find correct rectangle', () => {
// const grid = [
// [true, true, true],
// [true, true, false],
// [true, true, false]
// ];
// const result = findLargestInscribedRectangle(grid);
// expect(result).toEqual({x: 0, y: 0, width: 2, height: 3});
// });
// test('largestRectangleInHistogram should find correct rectangle', () => {
// const heights = [2, 1, 5, 6, 2, 3];
// const result = largestRectangleInHistogram(heights);
// expect(result).toEqual({x: 2, y: 0, width: 2, height: 5});
// });
// test('calculateFontSize should return correct font size', () => {
// const rectangle = {x: 0, y: 0, width: 100, height: 50};
// const name = "TestPlayer";
// const fontSize = calculateFontSize(rectangle, name);
// expect(fontSize).toBe(25); // 50 / 2 = 25 (height constrained)
// });
// });
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"rootDir": "src",
"moduleResolution": "node",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": [
"src/**/*",
"resources/**/*",
"test/core/GameImpl.test.ts"
],
"exclude": [
"node_modules"
]
}
+59
View File
@@ -0,0 +1,59 @@
import path from 'path';
import {fileURLToPath} from 'url';
import HtmlWebpackPlugin from 'html-webpack-plugin';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/client/Client.ts',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'out'),
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.(png|jpe?g|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[hash][ext][query]'
}
}
],
},
resolve: {
extensions: ['.ts', '.js'],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/client/index.html',
filename: 'index.html'
}),
],
devServer: {
static: {
directory: path.join(__dirname, 'out'),
},
compress: true,
port: 9000,
proxy: [
{
context: ['/socket'],
target: 'ws://localhost:3000',
ws: true,
},
{
context: ['/lobbies', '/join_game', '/join_lobby'], // Add any other API endpoints here
target: 'http://localhost:3000',
secure: false,
changeOrigin: true,
}
],
},
};