mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:11:54 +00:00
First Commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "src/map/codec"]
|
||||||
|
path = src/map/codec
|
||||||
|
url = https://github.com/WarFrontIO/MapCodec
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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']
|
||||||
|
};
|
||||||
Generated
+12101
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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--;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.png' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user