mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 12:14:16 +00:00
Merge branch 'openfrontio:main' into custom-flag
This commit is contained in:
@@ -12,6 +12,7 @@ import { createGameRecord } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { getConfig } from "../core/configuration/ConfigLoader";
|
||||
import { Team, UnitType } from "../core/game/Game";
|
||||
import { TileRef } from "../core/game/GameMap";
|
||||
import {
|
||||
ErrorUpdate,
|
||||
GameUpdateType,
|
||||
@@ -28,6 +29,7 @@ import { endGame, startGame, startTime } from "./LocalPersistantStats";
|
||||
import { getPersistentIDFromCookie } from "./Main";
|
||||
import {
|
||||
SendAttackIntentEvent,
|
||||
SendBoatAttackIntentEvent,
|
||||
SendHashEvent,
|
||||
SendSpawnIntentEvent,
|
||||
Transport,
|
||||
@@ -215,6 +217,7 @@ export class ClientGameRunner {
|
||||
|
||||
public start() {
|
||||
consolex.log("starting client game");
|
||||
|
||||
this.isActive = true;
|
||||
this.lastMessageTime = Date.now();
|
||||
setTimeout(() => {
|
||||
@@ -358,6 +361,18 @@ export class ClientGameRunner {
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
actions.canBoat !== false &&
|
||||
this.shouldBoat(tile, actions.canBoat) &&
|
||||
this.gameView.isLand(tile)
|
||||
) {
|
||||
this.eventBus.emit(
|
||||
new SendBoatAttackIntentEvent(
|
||||
this.gameView.owner(tile).id(),
|
||||
cell,
|
||||
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const owner = this.gameView.owner(tile);
|
||||
@@ -369,6 +384,18 @@ export class ClientGameRunner {
|
||||
});
|
||||
}
|
||||
|
||||
private shouldBoat(tile: TileRef, src: TileRef) {
|
||||
// TODO: Global enable flag
|
||||
// TODO: Global limit autoboat to nearby shore flag
|
||||
// if (!enableAutoBoat) return false;
|
||||
// if (!limitAutoBoatNear) return true;
|
||||
const distanceSquared = this.gameView.euclideanDistSquared(tile, src);
|
||||
const limit = 100;
|
||||
const limitSquared = limit * limit;
|
||||
if (distanceSquared > limitSquared) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private onMouseMove(event: MouseMoveEvent) {
|
||||
this.lastMousePosition = { x: event.x, y: event.y };
|
||||
this.checkTileUnderCursor();
|
||||
|
||||
+51
-10
@@ -35,43 +35,84 @@ export class HelpModal extends LitElement {
|
||||
</thead>
|
||||
<tbody class="text-left">
|
||||
<tr>
|
||||
<td>Space</td>
|
||||
<td><span class="key">Space</span></td>
|
||||
<td>${translateText("help_modal.action_alt_view")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shift + left click</td>
|
||||
<td>
|
||||
<div class="scroll-combo-horizontal">
|
||||
<span class="key">Shift</span>
|
||||
<span class="plus">+</span>
|
||||
<div class="mouse-shell alt-left-click">
|
||||
<div class="mouse-left-corner"></div>
|
||||
<div class="mouse-wheel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_attack_altclick")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ctrl + left click</td>
|
||||
<td>
|
||||
<div class="scroll-combo-horizontal">
|
||||
<span class="key">Ctrl</span>
|
||||
<span class="plus">+</span>
|
||||
<div class="mouse-shell alt-left-click">
|
||||
<div class="mouse-left-corner"></div>
|
||||
<div class="mouse-wheel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_build")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Alt + left click</td>
|
||||
<td>
|
||||
<div class="scroll-combo-horizontal">
|
||||
<span class="key">Alt</span>
|
||||
<span class="plus">+</span>
|
||||
<div class="mouse-shell alt-left-click">
|
||||
<div class="mouse-left-corner"></div>
|
||||
<div class="mouse-wheel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_emote")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>C</td>
|
||||
<td><span class="key">C</span></td>
|
||||
<td>${translateText("help_modal.action_center")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Q / E</td>
|
||||
<td><span class="key">Q</span> / <span class="key">E</span></td>
|
||||
<td>${translateText("help_modal.action_zoom")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>W / A / S / D</td>
|
||||
<td><span class="key">W</span> <span class="key">A</span> <span class="key">S</span> <span class="key">D</span></td>
|
||||
<td>${translateText("help_modal.action_move_camera")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1 / 2</td>
|
||||
<td><span class="key">1</span> / <span class="key">2</span></td>
|
||||
<td>${translateText("help_modal.action_ratio_change")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Shift + scroll down / scroll up</td>
|
||||
<td>
|
||||
<div class="scroll-combo-horizontal">
|
||||
<span class="key">Shift</span>
|
||||
<span class="plus">+</span>
|
||||
<div class="mouse-with-arrows">
|
||||
<div class="mouse-shell">
|
||||
<div class="mouse-wheel" id="highlighted-wheel"></div>
|
||||
</div>
|
||||
<div class="mouse-arrows-side">
|
||||
<div class="arrow">↑</div>
|
||||
<div class="arrow">↓</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_ratio_change")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ALT + R</td>
|
||||
<td><span class="key">ALT</span> + <span class="key">R</span></td>
|
||||
<td>${translateText("help_modal.action_reset_gfx")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -4,7 +4,12 @@ import randomMap from "../../resources/images/RandomMap.webp";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import { Difficulty, GameMapType, GameMode } from "../core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Modal";
|
||||
@@ -23,6 +28,7 @@ export class HostLobbyModal extends LitElement {
|
||||
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
|
||||
@state() private disableNPCs = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number = 2;
|
||||
@state() private disableNukes: boolean = false;
|
||||
@state() private bots: number = 400;
|
||||
@state() private infiniteGold: boolean = false;
|
||||
@@ -73,23 +79,40 @@ export class HostLobbyModal extends LitElement {
|
||||
<!-- Map Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">${translateText("map.map")}</div>
|
||||
<div class="option-cards">
|
||||
${Object.entries(GameMapType)
|
||||
.filter(([key]) => isNaN(Number(key)))
|
||||
.map(
|
||||
([key, value]) => html`
|
||||
<div @click=${() => this.handleMapSelection(value)}>
|
||||
<map-display
|
||||
.mapKey=${key}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === value}
|
||||
.translation=${translateText(
|
||||
`map.${key.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
<div class="option-cards flex-col">
|
||||
<!-- Use the imported mapCategories -->
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full mb-4">
|
||||
<h3
|
||||
class="text-lg font-semibold mb-2 text-center text-gray-300"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h3>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-4">
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.keys(GameMapType).find(
|
||||
(key) => GameMapType[key] === mapValue,
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div
|
||||
class="option-card random-map ${
|
||||
this.useRandomMap ? "selected" : ""
|
||||
@@ -103,7 +126,9 @@ export class HostLobbyModal extends LitElement {
|
||||
style="width:100%; aspect-ratio: 4/2; object-fit:cover; border-radius:8px;"
|
||||
/>
|
||||
</div>
|
||||
<div class="option-card-title">${translateText("map.random")}</div>
|
||||
<div class="option-card-title">
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +184,33 @@ export class HostLobbyModal extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
this.gameMode === GameMode.FFA
|
||||
? ""
|
||||
: html`
|
||||
<!-- Team Count Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.handleTeamCountSelection(o)}
|
||||
>
|
||||
<div class="option-card-title">${o}</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<!-- Game Options -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
@@ -413,6 +465,11 @@ export class HostLobbyModal extends LitElement {
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async handleTeamCountSelection(value: number) {
|
||||
this.teamCount = value;
|
||||
this.putGameConfig();
|
||||
}
|
||||
|
||||
private async putGameConfig() {
|
||||
const config = await getServerConfigFromClient();
|
||||
const response = await fetch(
|
||||
@@ -432,6 +489,7 @@ export class HostLobbyModal extends LitElement {
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
gameMode: this.gameMode,
|
||||
numPlayerTeams: this.teamCount,
|
||||
} as GameConfig),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,14 +3,21 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import "./LanguageModal";
|
||||
|
||||
import bg from "../../resources/lang/bg.json";
|
||||
import bn from "../../resources/lang/bn.json";
|
||||
import de from "../../resources/lang/de.json";
|
||||
import en from "../../resources/lang/en.json";
|
||||
import eo from "../../resources/lang/eo.json";
|
||||
import es from "../../resources/lang/es.json";
|
||||
import fr from "../../resources/lang/fr.json";
|
||||
import hi from "../../resources/lang/hi.json";
|
||||
import it from "../../resources/lang/it.json";
|
||||
import ja from "../../resources/lang/ja.json";
|
||||
import nl from "../../resources/lang/nl.json";
|
||||
import pl from "../../resources/lang/pl.json";
|
||||
import pt_br from "../../resources/lang/pt_br.json";
|
||||
import ru from "../../resources/lang/ru.json";
|
||||
import sh from "../../resources/lang/sh.json";
|
||||
import tr from "../../resources/lang/tr.json";
|
||||
import uk from "../../resources/lang/uk.json";
|
||||
|
||||
@customElement("lang-selector")
|
||||
@@ -26,14 +33,21 @@ export class LangSelector extends LitElement {
|
||||
|
||||
private languageMap: Record<string, any> = {
|
||||
bg,
|
||||
bn,
|
||||
de,
|
||||
en,
|
||||
es,
|
||||
eo,
|
||||
fr,
|
||||
it,
|
||||
hi,
|
||||
ja,
|
||||
nl,
|
||||
pl,
|
||||
pt_br,
|
||||
ru,
|
||||
sh,
|
||||
tr,
|
||||
uk,
|
||||
};
|
||||
|
||||
@@ -246,7 +260,7 @@ export class LangSelector extends LitElement {
|
||||
: {
|
||||
native: "English",
|
||||
en: "English",
|
||||
svg: "xx",
|
||||
svg: "uk_us_flag",
|
||||
});
|
||||
|
||||
return html`
|
||||
|
||||
+107
-63
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
|
||||
@customElement("language-modal")
|
||||
export class LanguageModal extends LitElement {
|
||||
@@ -8,35 +9,55 @@ export class LanguageModal extends LitElement {
|
||||
@property({ type: String }) currentLang = "en";
|
||||
|
||||
static styles = css`
|
||||
.modal {
|
||||
.c-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||
padding: 1.5rem;
|
||||
width: 24rem;
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.language-list {
|
||||
.c-modal__wrapper {
|
||||
background: #23232382;
|
||||
border-radius: 8px;
|
||||
min-width: 340px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c-modal__header {
|
||||
position: relative;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
font-size: 18px;
|
||||
background: #000000a1;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
padding: 1rem 2.4rem 1rem 1.4rem;
|
||||
}
|
||||
|
||||
.c-modal__close {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.c-modal__content {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
padding: 1.4rem;
|
||||
max-height: 60dvh;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-bottom: 1rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.lang-button {
|
||||
@@ -44,19 +65,23 @@ export class LanguageModal extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #aaa;
|
||||
background-color: #505050;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.lang-button:hover {
|
||||
background-color: #ebf8ff;
|
||||
background-color: #969696;
|
||||
}
|
||||
|
||||
.lang-button.active {
|
||||
background-color: #bee3f8;
|
||||
background-color: #aaaaaa;
|
||||
border-color: #bbb;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.flag-icon {
|
||||
@@ -65,30 +90,44 @@ export class LanguageModal extends LitElement {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background-color: #3182ce;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
@keyframes rainbow {
|
||||
0% {
|
||||
background-color: #990033;
|
||||
}
|
||||
20% {
|
||||
background-color: #996600;
|
||||
}
|
||||
40% {
|
||||
background-color: #336600;
|
||||
}
|
||||
60% {
|
||||
background-color: #008080;
|
||||
}
|
||||
80% {
|
||||
background-color: #1c3f99;
|
||||
}
|
||||
100% {
|
||||
background-color: #5e0099;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: #2b6cb0;
|
||||
.lang-button.debug {
|
||||
animation: rainbow 10s infinite;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
border: 2px dashed aqua;
|
||||
box-shadow: 0 0 4px aqua;
|
||||
}
|
||||
`;
|
||||
|
||||
private selectLanguage(lang: string) {
|
||||
private close = () => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("language-selected", {
|
||||
detail: { lang },
|
||||
new CustomEvent("close-modal", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("visible")) {
|
||||
@@ -105,18 +144,36 @@ export class LanguageModal extends LitElement {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="modal ${this.visible ? "" : "hidden"}">
|
||||
<div class="modal-content">
|
||||
<h2 class="text-xl font-semibold mb-4">Select Language</h2>
|
||||
private selectLanguage = (lang: string) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("language-selected", {
|
||||
detail: { lang },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
<div class="language-list">
|
||||
render() {
|
||||
if (!this.visible) return null;
|
||||
|
||||
return html`
|
||||
<aside class="c-modal">
|
||||
<div class="c-modal__wrapper">
|
||||
<header class="c-modal__header">
|
||||
${translateText("select_lang.title")}
|
||||
<div class="c-modal__close" @click=${this.close}>X</div>
|
||||
</header>
|
||||
|
||||
<section class="c-modal__content">
|
||||
${this.languageList.map((lang) => {
|
||||
const isActive = this.currentLang === lang.code;
|
||||
return html`
|
||||
<button
|
||||
class="lang-button ${isActive ? "active" : ""}"
|
||||
class="lang-button ${isActive ? "active" : ""} ${lang.code ===
|
||||
"debug"
|
||||
? "debug"
|
||||
: ""}"
|
||||
@click=${() => this.selectLanguage(lang.code)}
|
||||
>
|
||||
<img
|
||||
@@ -128,22 +185,9 @@ export class LanguageModal extends LitElement {
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="close-button"
|
||||
@click=${() =>
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("close-modal", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { LanguageModal } from "./LanguageModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import { UserSettingModal } from "./UserSettingModal";
|
||||
import "./UsernameInput";
|
||||
import { UsernameInput } from "./UsernameInput";
|
||||
import { generateCryptoRandomUUID } from "./Utils";
|
||||
@@ -118,6 +119,14 @@ class Client {
|
||||
hlpModal.open();
|
||||
});
|
||||
|
||||
const settingsModal = document.querySelector(
|
||||
"user-setting",
|
||||
) as UserSettingModal;
|
||||
settingsModal instanceof UserSettingModal;
|
||||
document.getElementById("settings-button").addEventListener("click", () => {
|
||||
settingsModal.open();
|
||||
});
|
||||
|
||||
const hostModal = document.querySelector(
|
||||
"host-lobby-modal",
|
||||
) as HostPrivateLobbyModal;
|
||||
@@ -200,6 +209,33 @@ class Client {
|
||||
gameRecord: lobby.gameRecord,
|
||||
},
|
||||
() => {
|
||||
console.log("Closing modals");
|
||||
document.getElementById("settings-button").classList.add("hidden");
|
||||
[
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"game-starting-modal",
|
||||
"top-bar",
|
||||
"help-modal",
|
||||
"user-setting",
|
||||
].forEach((tag) => {
|
||||
const modal = document.querySelector(tag) as HTMLElement & {
|
||||
close?: () => void;
|
||||
isModalOpen?: boolean;
|
||||
};
|
||||
if (modal?.close) {
|
||||
modal.close();
|
||||
} else if ("isModalOpen" in modal) {
|
||||
modal.isModalOpen = false;
|
||||
}
|
||||
});
|
||||
this.publicLobby.stop();
|
||||
document.querySelectorAll(".ad").forEach((ad) => {
|
||||
(ad as HTMLElement).style.display = "none";
|
||||
});
|
||||
|
||||
// show when the game loads
|
||||
const startingModal = document.querySelector(
|
||||
"game-starting-modal",
|
||||
) as GameStartingModal;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export class MultiTabDetector {
|
||||
private focusChanges: number[] = [];
|
||||
private readonly maxFocusChanges: number = 10;
|
||||
private readonly timeWindow: number = 60_000;
|
||||
private readonly punishmentDelays: number[] = [
|
||||
2_000, 3_000, 5_000, 10_000, 30_000, 60_000,
|
||||
];
|
||||
private lastFocusChangeTime: number = 0;
|
||||
private isPunished: boolean = false;
|
||||
private isMonitoring: boolean = false;
|
||||
private startPenaltyCallback?: (duration: number) => void;
|
||||
|
||||
private numPunishmentsGiven = 0;
|
||||
|
||||
/**
|
||||
* Start monitoring for multi-tabbing behavior
|
||||
*
|
||||
* @param startPenalty Callback function when punishment starts
|
||||
*/
|
||||
public startMonitoring(startPenalty: (duration: number) => void): void {
|
||||
if (this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = true;
|
||||
this.startPenaltyCallback = startPenalty;
|
||||
|
||||
// Event listeners for window focus/blur
|
||||
window.addEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.addEventListener("focus", this.handleFocusChange.bind(this));
|
||||
|
||||
// Also track visibility changes for tab switching
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
public stopMonitoring(): void {
|
||||
if (!this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = false;
|
||||
|
||||
// Remove event listeners
|
||||
window.removeEventListener("blur", this.handleFocusChange.bind(this));
|
||||
window.removeEventListener("focus", this.handleFocusChange.bind(this));
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.handleVisibilityChange.bind(this),
|
||||
);
|
||||
|
||||
// Clear data
|
||||
this.focusChanges = [];
|
||||
this.isPunished = false;
|
||||
}
|
||||
|
||||
private handleFocusChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Check for multi-tabbing when focus is gained
|
||||
if (document.hasFocus() && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
private handleVisibilityChange(): void {
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Record and check regardless of current focus state
|
||||
this.recordFocusChange(currentTime);
|
||||
|
||||
// Only check when tab becomes visible
|
||||
if (document.visibilityState === "visible" && !this.isPunished) {
|
||||
this.checkForMultiTabbing(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
private recordFocusChange(timestamp: number): void {
|
||||
if (Math.abs(this.lastFocusChangeTime - timestamp) < 100) {
|
||||
// Don't count multiple triggers at same time
|
||||
return;
|
||||
}
|
||||
this.focusChanges.push(timestamp);
|
||||
console.log(`pushing focus change at ${timestamp}`);
|
||||
this.lastFocusChangeTime = timestamp;
|
||||
|
||||
// Keep only recent changes
|
||||
if (this.focusChanges.length > this.maxFocusChanges) {
|
||||
this.focusChanges.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private checkForMultiTabbing(currentTime: number): void {
|
||||
// Only if we have enough data points
|
||||
if (this.focusChanges.length >= this.maxFocusChanges) {
|
||||
const oldestChange = this.focusChanges[0];
|
||||
const timeSpan = currentTime - oldestChange;
|
||||
|
||||
// If changes happened within detection window
|
||||
if (timeSpan <= this.timeWindow) {
|
||||
this.applyPunishment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyPunishment(): void {
|
||||
// Prevent multiple punishments
|
||||
if (this.isPunished) return;
|
||||
this.isPunished = true;
|
||||
|
||||
let punishmentDelay = 0;
|
||||
if (this.numPunishmentsGiven >= this.punishmentDelays.length) {
|
||||
punishmentDelay = this.punishmentDelays[this.punishmentDelays.length - 1];
|
||||
} else {
|
||||
punishmentDelay = this.punishmentDelays[this.numPunishmentsGiven];
|
||||
}
|
||||
|
||||
this.numPunishmentsGiven++;
|
||||
|
||||
// Call the start penalty callback
|
||||
if (this.startPenaltyCallback) {
|
||||
this.startPenaltyCallback(punishmentDelay);
|
||||
}
|
||||
|
||||
// Remove penalty after delay
|
||||
setTimeout(() => {
|
||||
this.isPunished = false;
|
||||
}, punishmentDelay);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@ import { customElement, query, state } from "lit/decorators.js";
|
||||
import randomMap from "../../resources/images/RandomMap.webp";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { consolex } from "../core/Consolex";
|
||||
import { Difficulty, GameMapType, GameMode, GameType } from "../core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Button";
|
||||
import "./components/baseComponents/Modal";
|
||||
@@ -30,6 +36,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: number = 2;
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@@ -38,27 +45,40 @@ export class SinglePlayerModal extends LitElement {
|
||||
<!-- Map Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">${translateText("map.map")}</div>
|
||||
<div class="option-cards">
|
||||
${Object.entries(GameMapType)
|
||||
.filter(([key]) => isNaN(Number(key)))
|
||||
.map(
|
||||
([key, value]) => html`
|
||||
<div
|
||||
@click=${function () {
|
||||
this.handleMapSelection(value);
|
||||
}}
|
||||
<div class="option-cards flex-col">
|
||||
<!-- Use the imported mapCategories -->
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full mb-4">
|
||||
<h3
|
||||
class="text-lg font-semibold mb-2 text-center text-gray-300"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${key}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === value}
|
||||
.translation=${translateText(
|
||||
`map.${key.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h3>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-4">
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.keys(GameMapType).find(
|
||||
(key) => GameMapType[key] === mapValue,
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<div
|
||||
class="option-card random-map ${this.useRandomMap
|
||||
? "selected"
|
||||
@@ -136,6 +156,31 @@ export class SinglePlayerModal extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.gameMode === GameMode.FFA
|
||||
? ""
|
||||
: html`
|
||||
<!-- Team Count Selection -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
${translateText("host_modal.team_count")}
|
||||
</div>
|
||||
<div class="option-cards">
|
||||
${[2, 3, 4, 5, 6, 7].map(
|
||||
(o) => html`
|
||||
<div
|
||||
class="option-card ${this.teamCount === o
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click=${() => this.handleTeamCountSelection(o)}
|
||||
>
|
||||
<div class="option-card-title">${o}</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- Game Options -->
|
||||
<div class="options-section">
|
||||
<div class="option-title">
|
||||
@@ -310,6 +355,10 @@ export class SinglePlayerModal extends LitElement {
|
||||
this.gameMode = value;
|
||||
}
|
||||
|
||||
private handleTeamCountSelection(value: number) {
|
||||
this.teamCount = value;
|
||||
}
|
||||
|
||||
private getRandomMap(): GameMapType {
|
||||
const maps = Object.values(GameMapType);
|
||||
const randIdx = Math.floor(Math.random() * maps.length);
|
||||
@@ -361,6 +410,7 @@ export class SinglePlayerModal extends LitElement {
|
||||
gameMap: this.selectedMap,
|
||||
gameType: GameType.Singleplayer,
|
||||
gameMode: this.gameMode,
|
||||
numPlayerTeams: this.teamCount,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
disableNukes: this.disableNukes,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import "./components/baseComponents/setting/SettingNumber";
|
||||
import "./components/baseComponents/setting/SettingSlider";
|
||||
import "./components/baseComponents/setting/SettingToggle";
|
||||
|
||||
@customElement("user-setting")
|
||||
export class UserSettingModal extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@state() private darkMode: boolean = this.userSettings.darkMode();
|
||||
|
||||
@state() private keySequence: string[] = [];
|
||||
@state() private showEasterEggSettings = false;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isModalOpen: boolean;
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.modalEl?.isModalOpen || this.showEasterEggSettings) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const nextSequence = [...this.keySequence, key].slice(-4);
|
||||
this.keySequence = nextSequence;
|
||||
|
||||
if (nextSequence.join("") === "evan") {
|
||||
this.triggerEasterEgg();
|
||||
this.keySequence = [];
|
||||
}
|
||||
};
|
||||
|
||||
private triggerEasterEgg() {
|
||||
console.log("🪺 Setting~ unlocked by EVAN combo!");
|
||||
this.showEasterEggSettings = true;
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "easter-egg-popup";
|
||||
popup.textContent = "🎉 You found a secret setting!";
|
||||
document.body.appendChild(popup);
|
||||
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
toggleDarkMode(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
|
||||
if (typeof enabled !== "boolean") {
|
||||
console.warn("Unexpected toggle event payload", e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.userSettings.set("settings.darkMode", enabled);
|
||||
|
||||
if (enabled) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
console.log("🌙 Dark Mode:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleEmojis(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.emojis", enabled);
|
||||
|
||||
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleLeftClickOpensMenu(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.leftClickOpensMenu", enabled);
|
||||
console.log("🖱️ Left Click Opens Menu:", enabled ? "ON" : "OFF");
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private sliderAttackRatio(e: CustomEvent<{ value: number }>) {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value === "number") {
|
||||
const ratio = value / 100;
|
||||
localStorage.setItem("settings.attackRatio", ratio.toString());
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}
|
||||
|
||||
private sliderTroopRatio(e: CustomEvent<{ value: number }>) {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value === "number") {
|
||||
const ratio = value / 100;
|
||||
localStorage.setItem("settings.troopRatio", ratio.toString());
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title="User Settings">
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content user-setting-modal">
|
||||
<div class="settings-list">
|
||||
<setting-toggle
|
||||
label="🌙 Dark Mode"
|
||||
description="Toggle the site’s appearance between light and dark themes"
|
||||
id="dark-mode-toggle"
|
||||
.checked=${this.userSettings.darkMode()}
|
||||
@change=${(e: CustomEvent<{ checked: boolean }>) =>
|
||||
this.toggleDarkMode(e)}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-toggle
|
||||
label="😊 Emojis"
|
||||
description="Toggle whether emojis are shown in game"
|
||||
id="emoji-toggle"
|
||||
.checked=${this.userSettings.emojis()}
|
||||
@change=${this.toggleEmojis}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-toggle
|
||||
label="🖱️ Left Click to Open Menu"
|
||||
description="When ON, left-click opens menu and sword button attacks. When OFF, right-click attacks directly."
|
||||
id="left-click-toggle"
|
||||
.checked=${this.userSettings.leftClickOpensMenu()}
|
||||
@change=${this.toggleLeftClickOpensMenu}
|
||||
></setting-toggle>
|
||||
|
||||
<setting-slider
|
||||
label="⚔️ Attack Ratio"
|
||||
description="What percentage of your troops to send in an attack (1–100%)"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(
|
||||
localStorage.getItem("settings.attackRatio") ?? "0.2",
|
||||
) * 100}
|
||||
@change=${this.sliderAttackRatio}
|
||||
></setting-slider>
|
||||
|
||||
<setting-slider
|
||||
label="🪖🛠️ Troops and Workers Ratio"
|
||||
description="Adjust the balance between troops (for combat) and workers (for gold production) (1–100%)"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${Number(
|
||||
localStorage.getItem("settings.troopRatio") ?? "0.95",
|
||||
) * 100}
|
||||
@change=${this.sliderTroopRatio}
|
||||
></setting-slider>
|
||||
|
||||
${this.showEasterEggSettings
|
||||
? html`
|
||||
<setting-slider
|
||||
label="Writing Speed Multiplier"
|
||||
description="Adjust how fast you pretend to code (x1–x100)"
|
||||
min="0"
|
||||
max="100"
|
||||
value="40"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-slider>
|
||||
|
||||
<setting-number
|
||||
label="Bug Count"
|
||||
description="How many bugs you're okay with (0–1000, emotionally)"
|
||||
value="100"
|
||||
min="0"
|
||||
max="1000"
|
||||
easter="true"
|
||||
@change=${(e: CustomEvent) => {
|
||||
const value = e.detail?.value;
|
||||
if (typeof value !== "undefined") {
|
||||
console.log("Changed:", value);
|
||||
} else {
|
||||
console.warn("Slider event missing detail.value", e);
|
||||
}
|
||||
}}
|
||||
></setting-number>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Japan: "Japan",
|
||||
BetweenTwoSeas: "Between Two Seas",
|
||||
KnownWorld: "Known World",
|
||||
FaroeIslands: "Faroe Islands",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-number")
|
||||
export class SettingNumber extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Number }) value = 0;
|
||||
@property({ type: Number }) min = 0;
|
||||
@property({ type: Number }) max = 100;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const newValue = Number(input.value);
|
||||
this.value = newValue;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value: newValue },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label" for="setting-number-input"
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
id="setting-number-input"
|
||||
class="setting-input number"
|
||||
.value=${String(this.value ?? 0)}
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-slider")
|
||||
export class SettingSlider extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property({ type: Number }) value = 0;
|
||||
@property({ type: Number }) min = 0;
|
||||
@property({ type: Number }) max = 100;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.value = Number(input.value);
|
||||
this.updateSliderStyle(input);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleSliderChange(e: Event) {
|
||||
const detail = (e as CustomEvent)?.detail;
|
||||
if (!detail || typeof detail.value === "undefined") {
|
||||
console.warn("Invalid slider change event", e);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = detail.value;
|
||||
console.log("Slider changed to", value);
|
||||
}
|
||||
|
||||
private updateSliderStyle(slider: HTMLInputElement) {
|
||||
const percent = ((this.value - this.min) / (this.max - this.min)) * 100;
|
||||
slider.style.background = `linear-gradient(to right, #2196f3 ${percent}%, #444 ${percent}%)`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
const slider = this.renderRoot.querySelector(
|
||||
"input[type=range]",
|
||||
) as HTMLInputElement;
|
||||
if (slider) this.updateSliderStyle(slider);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
|
||||
<div class="setting-label-group">
|
||||
<label class="setting-label" for="setting-slider-input"
|
||||
>${this.label}</label
|
||||
>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id="setting-slider-input"
|
||||
class="setting-input slider full-width"
|
||||
min=${this.min}
|
||||
max=${this.max}
|
||||
.value=${String(this.value)}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
<div class="slider-value">${this.value}%</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("setting-toggle")
|
||||
export class SettingToggle extends LitElement {
|
||||
@property() label = "Setting";
|
||||
@property() description = "";
|
||||
@property() id = "";
|
||||
@property({ type: Boolean, reflect: true }) checked = false;
|
||||
@property({ type: Boolean }) easter = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.checked = input.checked;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { checked: this.checked },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="setting-item vertical${this.easter ? " easter-egg" : ""}">
|
||||
<div class="toggle-row">
|
||||
<label class="setting-label" for=${this.id}>${this.label}</label>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id=${this.id}
|
||||
?checked=${this.checked}
|
||||
@change=${this.handleChange}
|
||||
/>
|
||||
<span class="slider-round"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-description">${this.description}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { Layer } from "./layers/Layer";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MultiTabModal } from "./layers/MultiTabModal";
|
||||
import { NameLayer } from "./layers/NameLayer";
|
||||
import { OptionsMenu } from "./layers/OptionsMenu";
|
||||
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
|
||||
@@ -125,6 +126,14 @@ export function createRenderer(
|
||||
playerPanel.eventBus = eventBus;
|
||||
playerPanel.emojiTable = emojiTable;
|
||||
|
||||
const multiTabModal = document.querySelector(
|
||||
"multi-tab-modal",
|
||||
) as MultiTabModal;
|
||||
if (!(multiTabModal instanceof MultiTabModal)) {
|
||||
console.error("multi-tab modal not found");
|
||||
}
|
||||
multiTabModal.game = game;
|
||||
|
||||
const layers: Layer[] = [
|
||||
new TerrainLayer(game, transformHandler),
|
||||
new TerritoryLayer(game, eventBus),
|
||||
@@ -153,6 +162,7 @@ export function createRenderer(
|
||||
optionsMenu,
|
||||
topBar,
|
||||
playerPanel,
|
||||
multiTabModal,
|
||||
];
|
||||
|
||||
return new GameRenderer(
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Colord } from "colord";
|
||||
import atomBombSprite from "../../../resources/sprites/atombomb.png";
|
||||
import hydrogenBombSprite from "../../../resources/sprites/hydrogenbomb.png";
|
||||
import mirvSprite from "../../../resources/sprites/mirv2.png";
|
||||
import samMissileSprite from "../../../resources/sprites/samMissile.png";
|
||||
import tradeShipSprite from "../../../resources/sprites/tradeship.png";
|
||||
import transportShipSprite from "../../../resources/sprites/transportship.png";
|
||||
import warshipSprite from "../../../resources/sprites/warship.png";
|
||||
import { Theme } from "../../core/configuration/Config";
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
import { UnitView } from "../../core/game/GameView";
|
||||
|
||||
const SPRITE_CONFIG: Partial<Record<UnitType, string>> = {
|
||||
[UnitType.TransportShip]: transportShipSprite,
|
||||
[UnitType.Warship]: warshipSprite,
|
||||
[UnitType.SAMMissile]: samMissileSprite,
|
||||
[UnitType.AtomBomb]: atomBombSprite,
|
||||
[UnitType.HydrogenBomb]: hydrogenBombSprite,
|
||||
[UnitType.TradeShip]: tradeShipSprite,
|
||||
[UnitType.MIRV]: mirvSprite,
|
||||
};
|
||||
|
||||
const spriteMap: Map<UnitType, ImageBitmap> = new Map();
|
||||
|
||||
// preload all images
|
||||
export const loadAllSprites = async (): Promise<void> => {
|
||||
const entries = Object.entries(SPRITE_CONFIG);
|
||||
const totalSprites = entries.length;
|
||||
let loadedCount = 0;
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async ([unitType, url]) => {
|
||||
const typedUnitType = unitType as UnitType;
|
||||
|
||||
if (!url || url === "") {
|
||||
console.warn(`No sprite URL for ${typedUnitType}, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = url;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (err) => reject(err);
|
||||
});
|
||||
|
||||
const bitmap = await createImageBitmap(img);
|
||||
spriteMap.set(typedUnitType, bitmap);
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === totalSprites) {
|
||||
console.log("All sprites loaded.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load sprite for ${typedUnitType}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getSpriteForUnit = (unitType: UnitType): ImageBitmap | null => {
|
||||
return spriteMap.get(unitType) ?? null;
|
||||
};
|
||||
|
||||
export const isSpriteReady = (unitType: UnitType): boolean => {
|
||||
return spriteMap.has(unitType);
|
||||
};
|
||||
|
||||
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
|
||||
|
||||
// puts the sprite in an canvas colors it and caches the colored canvas
|
||||
export const getColoredSprite = (
|
||||
unit: UnitView,
|
||||
theme: Theme,
|
||||
customTerritoryColor?: Colord,
|
||||
customBorderColor?: Colord,
|
||||
): HTMLCanvasElement => {
|
||||
const owner = unit.owner();
|
||||
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
|
||||
const borderColor = customBorderColor ?? theme.borderColor(owner);
|
||||
const spawnHighlightColor = theme.spawnHighlightColor();
|
||||
const colorKey = customTerritoryColor
|
||||
? customTerritoryColor.toRgbString()
|
||||
: "";
|
||||
const key = owner.id() + unit.type() + colorKey;
|
||||
|
||||
if (coloredSpriteCache.has(key)) {
|
||||
return coloredSpriteCache.get(key)!;
|
||||
}
|
||||
|
||||
const sprite = getSpriteForUnit(unit.type());
|
||||
|
||||
const territoryRgb = territoryColor.toRgb();
|
||||
const borderRgb = borderColor.toRgb();
|
||||
const spawnHighlightRgb = spawnHighlightColor.toRgb();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = sprite.width;
|
||||
canvas.height = sprite.height;
|
||||
|
||||
ctx.drawImage(sprite, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
if (r === 180 && g === 180 && b === 180) {
|
||||
data[i] = territoryRgb.r;
|
||||
data[i + 1] = territoryRgb.g;
|
||||
data[i + 2] = territoryRgb.b;
|
||||
}
|
||||
|
||||
if (r === 70 && g === 70 && b === 70) {
|
||||
data[i] = borderRgb.r;
|
||||
data[i + 1] = borderRgb.g;
|
||||
data[i + 2] = borderRgb.b;
|
||||
}
|
||||
|
||||
if (r === 130 && g === 130 && b === 130) {
|
||||
data[i] = spawnHighlightRgb.r;
|
||||
data[i + 1] = spawnHighlightRgb.g;
|
||||
data[i + 2] = spawnHighlightRgb.b;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0.5, 0.5);
|
||||
coloredSpriteCache.set(key, canvas);
|
||||
return canvas;
|
||||
};
|
||||
@@ -97,6 +97,10 @@ export class TransformHandler {
|
||||
}
|
||||
|
||||
screenBoundingRect(): [Cell, Cell] {
|
||||
const canvasRect = this.boundingRect();
|
||||
const canvasWidth = canvasRect.width;
|
||||
const canvasHeight = canvasRect.height;
|
||||
|
||||
const LeftX = -this.game.width() / 2 / this.scale + this.offsetX;
|
||||
const TopY = -this.game.height() / 2 / this.scale + this.offsetY;
|
||||
|
||||
@@ -104,12 +108,12 @@ export class TransformHandler {
|
||||
const gameTopY = TopY + this.game.height() / 2;
|
||||
|
||||
const rightX =
|
||||
(screen.width - this.game.width() / 2) / this.scale + this.offsetX;
|
||||
const rightY =
|
||||
(screen.height - this.game.height() / 2) / this.scale + this.offsetY;
|
||||
(canvasWidth - this.game.width() / 2) / this.scale + this.offsetX;
|
||||
const bottomY =
|
||||
(canvasHeight - this.game.height() / 2) / this.scale + this.offsetY;
|
||||
|
||||
const gameRightX = rightX + this.game.width() / 2;
|
||||
const gameBottomY = rightY + this.game.height() / 2;
|
||||
const gameBottomY = bottomY + this.game.height() / 2;
|
||||
|
||||
return [
|
||||
new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)),
|
||||
|
||||
@@ -56,8 +56,16 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
|
||||
private _popRateIsIncreasing: boolean = true;
|
||||
|
||||
private init_: boolean = false;
|
||||
|
||||
init() {
|
||||
this.attackRatio = 0.2;
|
||||
this.attackRatio = Number(
|
||||
localStorage.getItem("settings.attackRatio") ?? "0.2",
|
||||
);
|
||||
this.targetTroopRatio = Number(
|
||||
localStorage.getItem("settings.troopRatio") ?? "0.95",
|
||||
);
|
||||
this.init_ = true;
|
||||
this.uiState.attackRatio = this.attackRatio;
|
||||
this.currentTroopRatio = this.targetTroopRatio;
|
||||
this.eventBus.on(AttackRatioEvent, (event) => {
|
||||
@@ -87,6 +95,13 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.init_) {
|
||||
this.eventBus.emit(
|
||||
new SendSetTargetTroopRatioEvent(this.targetTroopRatio),
|
||||
);
|
||||
this.init_ = false;
|
||||
}
|
||||
|
||||
if (!this._isVisible && !this.game.inSpawnPhase()) {
|
||||
this.setVisibile(true);
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
});
|
||||
} else if (betrayed === myPlayer) {
|
||||
this.addEvent({
|
||||
description: `${traitor.name()}, broke their alliance with you`,
|
||||
description: `${traitor.name()} broke their alliance with you`,
|
||||
type: MessageType.ERROR,
|
||||
highlight: true,
|
||||
createdAt: this.game.ticks(),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { MultiTabDetector } from "../../MultiTabDetector";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("multi-tab-modal")
|
||||
export class MultiTabModal extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
private detector: MultiTabDetector;
|
||||
|
||||
@property({ type: Number }) duration: number = 5000;
|
||||
@state() private countdown: number = 5;
|
||||
@state() private isVisible: boolean = false;
|
||||
|
||||
private intervalId?: number;
|
||||
|
||||
// Disable shadow DOM to allow Tailwind classes to work
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (
|
||||
this.game.inSpawnPhase() ||
|
||||
this.game.config().gameConfig().gameType == GameType.Singleplayer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!this.detector) {
|
||||
this.detector = new MultiTabDetector();
|
||||
this.detector.startMonitoring((duration: number) => {
|
||||
this.show(duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show the modal with penalty information
|
||||
public show(duration: number): void {
|
||||
if (!this.game.myPlayer()?.isAlive()) {
|
||||
return;
|
||||
}
|
||||
this.duration = duration;
|
||||
this.countdown = Math.ceil(duration / 1000);
|
||||
this.isVisible = true;
|
||||
|
||||
// Start countdown timer
|
||||
this.intervalId = window.setInterval(() => {
|
||||
this.countdown--;
|
||||
|
||||
if (this.countdown <= 0) {
|
||||
this.hide();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// Hide the modal
|
||||
public hide(): void {
|
||||
this.isVisible = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = undefined;
|
||||
}
|
||||
|
||||
// Dispatch event when modal is closed
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("penalty-complete", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-auto bg-red-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-6 bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full m-4 transition-all transform"
|
||||
>
|
||||
<h2 class="text-2xl font-bold mb-4 text-red-600 dark:text-red-400">
|
||||
${translateText("multi_tab.warning")}
|
||||
</h2>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.detected")}
|
||||
</p>
|
||||
|
||||
<p class="mb-4 text-gray-800 dark:text-gray-200">
|
||||
${translateText("multi_tab.please_wait")}
|
||||
<span class="font-bold text-xl">${this.countdown}</span>
|
||||
${translateText("multi_tab.seconds")}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 mb-4"
|
||||
>
|
||||
<div
|
||||
class="bg-red-600 dark:bg-red-500 h-2.5 rounded-full transition-all duration-1000 ease-linear"
|
||||
style="width: ${(this.countdown / (this.duration / 1000)) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
${translateText("multi_tab.explanation")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -145,8 +145,14 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
this.eventBus.on(MouseUpEvent, (e: MouseEvent) => this.hide());
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.requestUpdate();
|
||||
async tick() {
|
||||
if (this.isVisible && this.tile) {
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer !== null && myPlayer.isAlive()) {
|
||||
this.actions = await myPlayer.actions(this.tile);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTotalNukesSent(otherId: PlayerID): number {
|
||||
|
||||
@@ -8,7 +8,7 @@ import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
|
||||
import { consolex } from "../../../core/Consolex";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell, PlayerActions } from "../../../core/game/Game";
|
||||
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
@@ -44,6 +44,7 @@ export class RadialMenu implements Layer {
|
||||
private clickedCell: Cell | null = null;
|
||||
private lastClosed: number = 0;
|
||||
|
||||
private originalTileOwner: PlayerView | TerraNullius;
|
||||
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
|
||||
private isVisible: boolean = false;
|
||||
private readonly menuItems = new Map([
|
||||
@@ -138,6 +139,7 @@ export class RadialMenu implements Layer {
|
||||
.style("touch-action", "none")
|
||||
.on("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
this.hideRadialMenu();
|
||||
});
|
||||
|
||||
const svg = this.menuElement
|
||||
@@ -266,8 +268,26 @@ export class RadialMenu implements Layer {
|
||||
.style("pointer-events", "none");
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Update logic if needed
|
||||
async tick() {
|
||||
// Only update when menu is visible
|
||||
if (!this.isVisible || this.clickedCell === null) return;
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer === null || !myPlayer.isAlive()) return;
|
||||
const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
|
||||
if (this.originalTileOwner.isPlayer()) {
|
||||
if (this.g.owner(tile) != this.originalTileOwner) {
|
||||
this.closeMenu();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (this.g.owner(tile).isPlayer() || this.g.owner(tile) == myPlayer) {
|
||||
this.closeMenu();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const actions = await myPlayer.actions(tile);
|
||||
this.disableAllButtons();
|
||||
this.handlePlayerActions(myPlayer, actions, tile);
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
@@ -290,12 +310,7 @@ export class RadialMenu implements Layer {
|
||||
} else {
|
||||
this.showRadialMenu(event.x, event.y);
|
||||
}
|
||||
this.enableCenterButton(false);
|
||||
for (const item of this.menuItems.values()) {
|
||||
item.disabled = true;
|
||||
this.updateMenuItemState(item);
|
||||
}
|
||||
|
||||
this.disableAllButtons();
|
||||
this.clickedCell = this.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
@@ -304,7 +319,7 @@ export class RadialMenu implements Layer {
|
||||
return;
|
||||
}
|
||||
const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y);
|
||||
|
||||
this.originalTileOwner = this.g.owner(tile);
|
||||
if (this.g.inSpawnPhase()) {
|
||||
if (this.g.isLand(tile) && !this.g.hasOwner(tile)) {
|
||||
this.enableCenterButton(true);
|
||||
@@ -312,10 +327,8 @@ export class RadialMenu implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
const myPlayer = this.g
|
||||
.playerViews()
|
||||
.find((p) => p.clientID() == this.clientID);
|
||||
if (!myPlayer) {
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer === null) {
|
||||
consolex.warn("my player not found");
|
||||
return;
|
||||
}
|
||||
@@ -429,6 +442,14 @@ export class RadialMenu implements Layer {
|
||||
this.hideRadialMenu();
|
||||
}
|
||||
|
||||
private disableAllButtons() {
|
||||
this.enableCenterButton(false);
|
||||
for (const item of this.menuItems.values()) {
|
||||
item.disabled = true;
|
||||
this.updateMenuItemState(item);
|
||||
}
|
||||
}
|
||||
|
||||
private activateMenuElement(
|
||||
slot: Slot,
|
||||
color: string,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { blue, red } from "../../../core/configuration/Colors";
|
||||
import { GameMode, Team } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class SpawnTimer implements Layer {
|
||||
private ratio = 0;
|
||||
private leftColor = "rgba(0, 128, 255, 0.7)";
|
||||
private rightColor = "rgba(0, 0, 0, 0.5)";
|
||||
private ratios = [0];
|
||||
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -18,27 +16,35 @@ export class SpawnTimer implements Layer {
|
||||
|
||||
tick() {
|
||||
if (this.game.inSpawnPhase()) {
|
||||
this.ratio = this.game.ticks() / this.game.config().numSpawnPhaseTurns();
|
||||
this.ratios[0] =
|
||||
this.game.ticks() / this.game.config().numSpawnPhaseTurns();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ratios = [];
|
||||
this.colors = [];
|
||||
|
||||
if (this.game.config().gameConfig().gameMode != GameMode.Team) {
|
||||
this.ratio = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const numBlueTiles = this.game
|
||||
.players()
|
||||
.filter((p) => p.team() == Team.Blue)
|
||||
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
|
||||
const teamTiles: Map<Team, number> = new Map();
|
||||
for (const player of this.game.players()) {
|
||||
const team = player.team();
|
||||
const tiles = teamTiles.get(team) ?? 0;
|
||||
const sum = tiles + player.numTilesOwned();
|
||||
teamTiles.set(team, sum);
|
||||
}
|
||||
|
||||
const numRedTiles = this.game
|
||||
.players()
|
||||
.filter((p) => p.team() == Team.Red)
|
||||
.reduce((acc, p) => acc + p.numTilesOwned(), 0);
|
||||
|
||||
this.ratio = numBlueTiles / (numBlueTiles + numRedTiles);
|
||||
this.leftColor = blue.toRgbString();
|
||||
this.rightColor = red.toRgbString();
|
||||
const theme = this.game.config().theme();
|
||||
const total = sumIterator(teamTiles.values());
|
||||
if (total === 0) return;
|
||||
for (const [team, count] of teamTiles) {
|
||||
const ratio = count / total;
|
||||
const color = theme.teamColor(team).toRgbString();
|
||||
this.ratios.push(ratio);
|
||||
this.colors.push(color);
|
||||
}
|
||||
}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
@@ -46,18 +52,34 @@ export class SpawnTimer implements Layer {
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (this.ratio == 0) {
|
||||
return;
|
||||
}
|
||||
if (this.ratios === null) return;
|
||||
if (this.ratios.length === 0) return;
|
||||
if (this.colors.length === 0) return;
|
||||
|
||||
const barHeight = 10;
|
||||
const barBackgroundWidth = this.transformHandler.width();
|
||||
const barWidth = this.transformHandler.width();
|
||||
|
||||
// Draw bar background
|
||||
context.fillStyle = this.rightColor;
|
||||
context.fillRect(0, 0, barBackgroundWidth, barHeight);
|
||||
let x = 0;
|
||||
let filledRatio = 0;
|
||||
for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
|
||||
const ratio = this.ratios[i];
|
||||
const segmentWidth = barWidth * ratio;
|
||||
|
||||
context.fillStyle = this.leftColor;
|
||||
context.fillRect(0, 0, barBackgroundWidth * this.ratio, barHeight);
|
||||
context.fillStyle = this.colors[i];
|
||||
context.fillRect(x, 0, segmentWidth, barHeight);
|
||||
|
||||
x += segmentWidth;
|
||||
filledRatio += ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sumIterator(values: MapIterator<number>) {
|
||||
// To use reduce, we'd need to allocate an array:
|
||||
// return Array.from(values).reduce((sum, v) => sum + v, 0);
|
||||
let total = 0;
|
||||
for (const value of values) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { MoveWarshipIntentEvent } from "../../Transport";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
import { getColoredSprite, loadAllSprites } from "../SpriteLoader";
|
||||
|
||||
enum Relationship {
|
||||
Self,
|
||||
Ally,
|
||||
@@ -77,6 +79,8 @@ export class UnitLayer implements Layer {
|
||||
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e));
|
||||
this.redraw();
|
||||
|
||||
loadAllSprites();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +203,18 @@ export class UnitLayer implements Layer {
|
||||
?.[GameUpdateType.Unit]?.forEach((unit) => {
|
||||
this.onUnitEvent(this.game.unit(unit.id));
|
||||
});
|
||||
this.boatToTrail.forEach((trail, unit) => {
|
||||
for (const t of trail) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
this.relationship(unit),
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
150,
|
||||
this.transportShipTrailContext,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private relationship(unit: UnitView): Relationship {
|
||||
@@ -258,56 +274,13 @@ export class UnitLayer implements Layer {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
|
||||
if (!unit.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let outerColor = this.theme.territoryColor(unit.owner());
|
||||
if (unit.warshipTargetId()) {
|
||||
const targetOwner = this.game
|
||||
.units()
|
||||
.find((u) => u.id() == unit.warshipTargetId())
|
||||
?.owner();
|
||||
if (targetOwner == this.myPlayer) {
|
||||
outerColor = colord({ r: 200, b: 0, g: 0 });
|
||||
if (unit.isActive()) {
|
||||
if (unit.warshipTargetId()) {
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
// Paint outer territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 5, false),
|
||||
)) {
|
||||
this.paintCell(this.game.x(t), this.game.y(t), rel, outerColor, 255);
|
||||
}
|
||||
|
||||
// Paint border
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 4),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint inner territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 1, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleShellEvent(unit: UnitView) {
|
||||
@@ -355,32 +328,13 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), range, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.spawnHighlightColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
this.paintCell(
|
||||
this.game.x(unit.tile()),
|
||||
this.game.y(unit.tile()),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNuke(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
let range = 0;
|
||||
|
||||
switch (unit.type()) {
|
||||
case UnitType.AtomBomb:
|
||||
range = 4;
|
||||
@@ -393,7 +347,6 @@ export class UnitLayer implements Layer {
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
euclDistFN(unit.lastTile(), range, false),
|
||||
@@ -402,30 +355,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), range, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.spawnHighlightColor(),
|
||||
255,
|
||||
);
|
||||
}
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
euclDistFN(unit.tile(), 2, false),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,8 +377,6 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleTradeShipEvent(unit: UnitView) {
|
||||
const rel = this.relationship(unit);
|
||||
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
@@ -458,33 +386,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
if (unit.isActive()) {
|
||||
// Paint territory
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 2),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
// Paint border
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 1),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +402,7 @@ export class UnitLayer implements Layer {
|
||||
// Clear previous area
|
||||
for (const t of this.game.bfs(
|
||||
unit.lastTile(),
|
||||
manhattanDistFN(unit.lastTile(), 2),
|
||||
manhattanDistFN(unit.lastTile(), 4),
|
||||
)) {
|
||||
this.clearCell(this.game.x(t), this.game.y(t));
|
||||
}
|
||||
@@ -518,31 +420,7 @@ export class UnitLayer implements Layer {
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 2),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.borderColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of this.game.bfs(
|
||||
unit.tile(),
|
||||
manhattanDistFN(unit.tile(), 1),
|
||||
)) {
|
||||
this.paintCell(
|
||||
this.game.x(t),
|
||||
this.game.y(t),
|
||||
rel,
|
||||
this.theme.territoryColor(unit.owner()),
|
||||
255,
|
||||
);
|
||||
}
|
||||
this.drawSprite(unit);
|
||||
} else {
|
||||
for (const t of trail) {
|
||||
this.clearCell(
|
||||
@@ -606,4 +484,41 @@ export class UnitLayer implements Layer {
|
||||
) {
|
||||
context.clearRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
drawSprite(unit: UnitView, customTerritoryColor?: Colord) {
|
||||
const x = this.game.x(unit.tile());
|
||||
const y = this.game.y(unit.tile());
|
||||
|
||||
let alternateViewColor = null;
|
||||
|
||||
if (this.alternateView) {
|
||||
const rel = this.relationship(unit);
|
||||
switch (rel) {
|
||||
case Relationship.Self:
|
||||
alternateViewColor = this.theme.selfColor();
|
||||
break;
|
||||
case Relationship.Ally:
|
||||
alternateViewColor = this.theme.allyColor();
|
||||
break;
|
||||
case Relationship.Enemy:
|
||||
alternateViewColor = this.theme.enemyColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sprite = getColoredSprite(
|
||||
unit,
|
||||
this.theme,
|
||||
alternateViewColor ?? customTerritoryColor,
|
||||
alternateViewColor,
|
||||
);
|
||||
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
Math.round(x - sprite.width / 2),
|
||||
Math.round(y - sprite.height / 2),
|
||||
sprite.width,
|
||||
sprite.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
return html`
|
||||
<div class="win-modal ${this.isVisible ? "visible" : ""}">
|
||||
<h2>${this._title || ""}</h2>
|
||||
${this.supportHTML()}
|
||||
${this.innerHtml()}
|
||||
<div class="button-container">
|
||||
<button @click=${this._handleExit}>Exit Game</button>
|
||||
<button @click=${this.hide}>Keep Playing</button>
|
||||
@@ -158,35 +158,9 @@ export class WinModal extends LitElement implements Layer {
|
||||
`;
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
// Initialize ads if modal is visible and showing ads
|
||||
if (changedProperties.has("isVisible") && this.isVisible && !this.won) {
|
||||
try {
|
||||
setTimeout(() => {
|
||||
(adsbygoogle = window.adsbygoogle || []).push({});
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.error("Error initializing ad:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportHTML() {
|
||||
innerHtml() {
|
||||
return html`
|
||||
<div style="text-align: center; margin: 15px 0;">
|
||||
<p>
|
||||
Like the game? Help make this my full-time project!
|
||||
<a
|
||||
href="https://discord.gg/k22YrnAzGp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="color: #0096ff; text-decoration: underline; display: block; margin-top: 5px;"
|
||||
>
|
||||
Support the game!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 15px 0; line-height: 1.5;"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+46
-36
@@ -48,8 +48,10 @@
|
||||
.left-gutter-ad {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 200px; /* Changed from top: 50% */
|
||||
transform: none; /* Removed translateY(-50%) since we don't need to center anymore */
|
||||
top: 200px;
|
||||
/* Changed from top: 50% */
|
||||
transform: none;
|
||||
/* Removed translateY(-50%) since we don't need to center anymore */
|
||||
z-index: 40;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
@@ -68,8 +70,10 @@
|
||||
.right-gutter-ad {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 200px; /* Changed from top: 50% */
|
||||
transform: none; /* Removed translateY(-50%) since we don't need to center anymore */
|
||||
top: 200px;
|
||||
/* Changed from top: 50% */
|
||||
transform: none;
|
||||
/* Removed translateY(-50%) since we don't need to center anymore */
|
||||
z-index: 40;
|
||||
width: 300px;
|
||||
height: 600px;
|
||||
@@ -122,7 +126,22 @@
|
||||
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7035513310742290"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-WQGQQ8RDN4"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-WQGQQ8RDN4");
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="h-full select-none font-sans min-h-screen bg-opacity-0 bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-col"
|
||||
>
|
||||
@@ -184,47 +203,19 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="l-header__highlightText">v21.0</div>
|
||||
<div class="l-header__highlightText">v21.2</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
<!-- Left gutter ad placement - full height, no empty space -->
|
||||
<div class="left-gutter-ad ad">
|
||||
<google-ad
|
||||
adSlot="5220834834"
|
||||
adFormat="vertical"
|
||||
fullWidthResponsive="false"
|
||||
></google-ad>
|
||||
</div>
|
||||
<div class="right-gutter-ad ad">
|
||||
<google-ad
|
||||
adSlot="1814331462"
|
||||
adFormat="vertical"
|
||||
fullWidthResponsive="false"
|
||||
></google-ad>
|
||||
</div>
|
||||
|
||||
<!-- Main container with responsive padding -->
|
||||
<main class="flex justify-center items-center flex-grow">
|
||||
<div class="container">
|
||||
<main class="flex justify-center flex-grow">
|
||||
<div class="container pt-12">
|
||||
<div class="container__row">
|
||||
<flag-input class="w-[20%] md:w-[15%]"></flag-input>
|
||||
<username-input class="w-full"></username-input>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.gg/openfront"
|
||||
class="w-full bg-[#5865F2] hover:bg-[#4752C4] text-white p-3 sm:p-4 lg:p-5 font-medium text-lg sm:text-xl lg:text-2xl rounded-lg border-none cursor-pointer transition-colors duration-300 flex justify-center items-center gap-5"
|
||||
>
|
||||
<img
|
||||
style="height: 50px; width: 50px"
|
||||
alt="Discord"
|
||||
src="../../resources/icons/discord.svg"
|
||||
/>
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
</a>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<public-lobby class="w-full"></public-lobby>
|
||||
</div>
|
||||
@@ -265,6 +256,20 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- User Setting -->
|
||||
<button
|
||||
id="settings-button"
|
||||
title="Settings"
|
||||
class="fixed bottom-4 right-4 z-50 rounded-full p-2 shadow-lg transition-colors duration-300 flex items-center justify-center"
|
||||
style="width: 80px; height: 80px; background-color: #0075ff"
|
||||
>
|
||||
<img
|
||||
src="../../resources/images/SettingIconWhite.svg"
|
||||
alt="Settings"
|
||||
style="width: 72px; height: 72px"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Game components -->
|
||||
<div id="customMenu" class="mt-4 sm:mt-6 lg:mt-8">
|
||||
<ul></ul>
|
||||
@@ -313,6 +318,9 @@
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
<a target="_blank" href="https://discord.gg/openfront" class="t-link">
|
||||
<span data-i18n="main.join_discord"> Join the Discord! </span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="l-footer__col t-text-white">
|
||||
© 2025
|
||||
@@ -339,6 +347,8 @@
|
||||
<player-panel></player-panel>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<div
|
||||
id="language-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
@import url("./styles/layout/container.css");
|
||||
@import url("./styles/components/button.css");
|
||||
@import url("./styles/components/modal.css");
|
||||
@import url("./styles/components/setting.css");
|
||||
@import url("./styles/components/controls.css");
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
@@ -215,6 +217,8 @@ label.option-card:hover {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.scroll-combo-horizontal {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: sans-serif;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: inline-block;
|
||||
padding: 4px 14px;
|
||||
border-radius: 6px;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 0 #444;
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.mouse-shell {
|
||||
width: 28px;
|
||||
height: 45px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 50px;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mouse-left-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
background-color: #ff4d4d;
|
||||
border-top-left-radius: 50px;
|
||||
}
|
||||
|
||||
.mouse-right-corner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
background-color: #ff4d4d;
|
||||
border-top-right-radius: 50px;
|
||||
}
|
||||
|
||||
.mouse-wheel {
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#highlighted-wheel {
|
||||
background-color: #ff4d4d;
|
||||
}
|
||||
|
||||
.mouse-with-arrows {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mouse-arrows-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
.settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 10px;
|
||||
padding: 12px 20px;
|
||||
width: 360px !important;
|
||||
max-width: 360px !important;
|
||||
min-width: 360px !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
transition: background 0.3s ease;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@keyframes rainbow-background {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item.easter-egg {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
#990033,
|
||||
#996600,
|
||||
#336600,
|
||||
#008080,
|
||||
#1c3f99,
|
||||
#5e0099,
|
||||
#990033
|
||||
);
|
||||
background-size: 1400% 1400%;
|
||||
animation: rainbow-background 10s ease infinite;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.easter-egg-popup {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
border-radius: 12px;
|
||||
animation: fadePop 5s ease-out forwards;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes fadePop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.setting-item.easter-egg:hover {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
#990033,
|
||||
#996600,
|
||||
#336600,
|
||||
#008080,
|
||||
#1c3f99,
|
||||
#5e0099,
|
||||
#990033
|
||||
);
|
||||
background-size: 1400% 1400%;
|
||||
animation: rainbow-background 10s ease infinite;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: #f0f0f0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-item.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-input.slider.full-width {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.setting-input.slider {
|
||||
-webkit-appearance: none;
|
||||
width: 180px;
|
||||
height: 10px;
|
||||
background: linear-gradient(to right, #2196f3 50%, #444 50%);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.setting-input.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #2196f3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #2196f3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-input.slider::-moz-range-track {
|
||||
background: linear-gradient(to right, #2196f3 50%, #444 50%);
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.setting-input.slider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.setting-input.number {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch.switch-right {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.slider-round {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #d9534f;
|
||||
transition: 0.4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider-round::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider-round {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.switch input:checked + .slider-round::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.setting-label-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -20,5 +20,5 @@
|
||||
|
||||
.l-footer__col {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import betweenTwoSeas from "../../../resources/maps/BetweenTwoSeasThumb.webp";
|
||||
import blackSea from "../../../resources/maps/BlackSeaThumb.webp";
|
||||
import britannia from "../../../resources/maps/BritanniaThumb.webp";
|
||||
import europe from "../../../resources/maps/EuropeThumb.webp";
|
||||
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
|
||||
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
import japan from "../../../resources/maps/JapanThumb.webp";
|
||||
@@ -57,6 +58,8 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return betweenTwoSeas;
|
||||
case GameMapType.KnownWorld:
|
||||
return knownworld;
|
||||
case GameMapType.FaroeIslands:
|
||||
return faroeislands;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Executor } from "./execution/ExecutionManager";
|
||||
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
||||
import {
|
||||
AllPlayers,
|
||||
BuildableUnit,
|
||||
Game,
|
||||
GameUpdates,
|
||||
NameViewData,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
PlayerInfo,
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "./game/Game";
|
||||
import { createGame } from "./game/GameImpl";
|
||||
import {
|
||||
@@ -161,13 +159,7 @@ export class GameRunner {
|
||||
const actions = {
|
||||
canBoat: player.canBoat(tile),
|
||||
canAttack: player.canAttack(tile),
|
||||
buildableUnits: Object.values(UnitType).map((u) => {
|
||||
return {
|
||||
type: u,
|
||||
canBuild: player.canBuild(u, tile) != false,
|
||||
cost: this.game.config().unitInfo(u).cost(player),
|
||||
} as BuildableUnit;
|
||||
}),
|
||||
buildableUnits: player.buildableUnits(tile),
|
||||
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
||||
} as PlayerActions;
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ const GameConfigSchema = z.object({
|
||||
infiniteTroops: z.boolean(),
|
||||
instantBuild: z.boolean(),
|
||||
maxPlayers: z.number().optional(),
|
||||
numPlayerTeams: z.number().optional(),
|
||||
});
|
||||
|
||||
const SafeString = z
|
||||
|
||||
@@ -2,6 +2,11 @@ import { colord, Colord } from "colord";
|
||||
|
||||
export const red: Colord = colord({ r: 235, g: 53, b: 53 }); // Bright Red
|
||||
export const blue: Colord = colord({ r: 41, g: 98, b: 255 }); // Royal Blue
|
||||
export const teal = colord({ h: 172, s: 66, l: 50 });
|
||||
export const purple = colord({ h: 271, s: 81, l: 56 });
|
||||
export const yellow = colord({ h: 45, s: 93, l: 47 });
|
||||
export const orange = colord({ h: 25, s: 95, l: 53 });
|
||||
export const green = colord({ h: 128, s: 49, l: 50 });
|
||||
export const botColor: Colord = colord({ r: 210, g: 206, b: 200 }); // Muted Beige Gray
|
||||
|
||||
export const territoryColors: Colord[] = [
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Gold,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
Team,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
UnitInfo,
|
||||
@@ -51,6 +52,7 @@ export interface NukeMagnitude {
|
||||
|
||||
export interface Config {
|
||||
samHittingChance(): number;
|
||||
samWarheadHittingChance(): number;
|
||||
spawnImmunityDuration(): Tick;
|
||||
serverConfig(): ServerConfig;
|
||||
gameConfig(): GameConfig;
|
||||
@@ -65,6 +67,7 @@ export interface Config {
|
||||
instantBuild(): boolean;
|
||||
numSpawnPhaseTurns(): number;
|
||||
userSettings(): UserSettings;
|
||||
numPlayerTeams(): number;
|
||||
|
||||
startManpower(playerInfo: PlayerInfo): number;
|
||||
populationIncreaseRate(player: Player | PlayerView): number;
|
||||
@@ -95,7 +98,7 @@ export interface Config {
|
||||
maxPopulation(player: Player | PlayerView): number;
|
||||
cityPopulationIncrease(): number;
|
||||
boatAttackAmount(attacker: Player, defender: Player | TerraNullius): number;
|
||||
warshipShellLifetime(): number;
|
||||
shellLifetime(): number;
|
||||
boatMaxNumber(): number;
|
||||
allianceDuration(): Tick;
|
||||
allianceRequestCooldown(): Tick;
|
||||
@@ -108,20 +111,29 @@ export interface Config {
|
||||
unitInfo(type: UnitType): UnitInfo;
|
||||
tradeShipGold(dist: number): Gold;
|
||||
tradeShipSpawnRate(numberOfPorts: number): number;
|
||||
safeFromPiratesCooldownMax(): number;
|
||||
defensePostRange(): number;
|
||||
SAMCooldown(): number;
|
||||
SiloCooldown(): number;
|
||||
defensePostDefenseBonus(): number;
|
||||
falloutDefenseModifier(percentOfFallout: number): number;
|
||||
difficultyModifier(difficulty: Difficulty): number;
|
||||
warshipPatrolRange(): number;
|
||||
warshipShellAttackRate(): number;
|
||||
warshipTargettingRange(): number;
|
||||
defensePostShellAttackRate(): number;
|
||||
defensePostTargettingRange(): number;
|
||||
// 0-1
|
||||
traitorDefenseDebuff(): number;
|
||||
traitorDuration(): number;
|
||||
nukeMagnitudes(unitType: UnitType): NukeMagnitude;
|
||||
defaultNukeSpeed(): number;
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number;
|
||||
structureMinDist(): number;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
teamColor(team: Team): Colord;
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
specialBuildingColor(playerInfo: PlayerView): Colord;
|
||||
borderColor(playerInfo: PlayerView): Colord;
|
||||
|
||||
@@ -34,7 +34,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
return process.env.GIT_COMMIT;
|
||||
}
|
||||
r2Endpoint(): string {
|
||||
return process.env.R2_ENDPOINT;
|
||||
return `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
||||
}
|
||||
r2AccessKey(): string {
|
||||
return process.env.R2_ACCESS_KEY;
|
||||
@@ -69,7 +69,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
GameMapType.Europe,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 150 : 70;
|
||||
return Math.random() < 0.2 ? 100 : 50;
|
||||
}
|
||||
// Maps with ~2.5 - ~3.5 mil pixels
|
||||
if (
|
||||
@@ -80,7 +80,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
GameMapType.Asia,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 100 : 50;
|
||||
return Math.random() < 0.3 ? 50 : 25;
|
||||
}
|
||||
// Maps with ~2 mil pixels
|
||||
if (
|
||||
@@ -89,9 +89,10 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
GameMapType.Mars,
|
||||
GameMapType.Oceania,
|
||||
GameMapType.Japan, // Japan at this level because its 2/3 water
|
||||
GameMapType.FaroeIslands,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 70 : 40;
|
||||
return Math.random() < 0.3 ? 50 : 25;
|
||||
}
|
||||
// Maps smaller than ~2 mil pixels
|
||||
if (
|
||||
@@ -101,14 +102,14 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
GameMapType.Pangaea,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.2 ? 60 : 35;
|
||||
return Math.random() < 0.5 ? 30 : 15;
|
||||
}
|
||||
// world belongs with the ~2 mils, but these amounts never made sense so I assume the insanity is intended.
|
||||
if (map == GameMapType.World) {
|
||||
return Math.random() < 0.2 ? 150 : 60;
|
||||
return Math.random() < 0.2 ? 150 : 50;
|
||||
}
|
||||
// default return for non specified map
|
||||
return Math.random() < 0.2 ? 85 : 45;
|
||||
return Math.random() < 0.2 ? 50 : 20;
|
||||
}
|
||||
workerIndex(gameID: GameID): number {
|
||||
return simpleHash(gameID) % this.numWorkers();
|
||||
@@ -135,8 +136,15 @@ export class DefaultConfig implements Config {
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
samWarheadHittingChance(): number {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
traitorDefenseDebuff(): number {
|
||||
return 0.8;
|
||||
return 0.5;
|
||||
}
|
||||
traitorDuration(): number {
|
||||
return 30 * 10; // 30 seconds
|
||||
}
|
||||
spawnImmunityDuration(): Tick {
|
||||
return 5 * 10;
|
||||
@@ -189,6 +197,9 @@ export class DefaultConfig implements Config {
|
||||
defensePostDefenseBonus(): number {
|
||||
return 5;
|
||||
}
|
||||
numPlayerTeams(): number {
|
||||
return this._gameConfig.numPlayerTeams ?? 0;
|
||||
}
|
||||
spawnNPCs(): boolean {
|
||||
return !this._gameConfig.disableNPCs;
|
||||
}
|
||||
@@ -322,7 +333,7 @@ export class DefaultConfig implements Config {
|
||||
p.type() == PlayerType.Human && this.infiniteGold()
|
||||
? 0
|
||||
: Math.min(
|
||||
1_500_000 * 3,
|
||||
3_000_000,
|
||||
(p.unitsIncludingConstruction(UnitType.SAMLauncher).length +
|
||||
1) *
|
||||
1_500_000,
|
||||
@@ -336,7 +347,7 @@ export class DefaultConfig implements Config {
|
||||
p.type() == PlayerType.Human && this.infiniteGold()
|
||||
? 0
|
||||
: Math.min(
|
||||
2_000_000,
|
||||
1_000_000,
|
||||
Math.pow(
|
||||
2,
|
||||
p.unitsIncludingConstruction(UnitType.City).length,
|
||||
@@ -385,7 +396,7 @@ export class DefaultConfig implements Config {
|
||||
return 80;
|
||||
}
|
||||
boatMaxNumber(): number {
|
||||
return 3;
|
||||
return 9;
|
||||
}
|
||||
numSpawnPhaseTurns(): number {
|
||||
return this._gameConfig.gameType == GameType.Singleplayer ? 100 : 300;
|
||||
@@ -473,18 +484,25 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
if (defender.isPlayer()) {
|
||||
const ratio = within(
|
||||
Math.pow(defender.troops() / attackTroops, 0.4),
|
||||
0.1,
|
||||
10,
|
||||
);
|
||||
const speedRatio = within(
|
||||
defender.troops() / (5 * attackTroops),
|
||||
0.1,
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
attackerTroopLoss:
|
||||
within(defender.troops() / attackTroops, 0.6, 2) *
|
||||
ratio *
|
||||
mag *
|
||||
0.8 *
|
||||
largeLossModifier *
|
||||
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
|
||||
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
|
||||
tilesPerTickUsed:
|
||||
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
|
||||
speed *
|
||||
largeSpeedMalus,
|
||||
defenderTroopLoss: defender.population() / defender.numTilesOwned(),
|
||||
tilesPerTickUsed: Math.floor(speedRatio * speed * largeSpeedMalus),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -620,7 +638,8 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
goldAdditionRate(player: Player): number {
|
||||
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
|
||||
const ratio = Math.pow(player.workers() / player.population(), 1.3);
|
||||
return Math.floor(Math.sqrt(player.workers()) * ratio * 5);
|
||||
}
|
||||
|
||||
troopAdjustmentRate(player: Player): number {
|
||||
@@ -657,4 +676,36 @@ export class DefaultConfig implements Config {
|
||||
nukeDeathFactor(humans: number, tilesOwned: number): number {
|
||||
return (5 * humans) / Math.max(1, tilesOwned);
|
||||
}
|
||||
|
||||
structureMinDist(): number {
|
||||
return 18;
|
||||
}
|
||||
|
||||
shellLifetime(): number {
|
||||
return 50;
|
||||
}
|
||||
|
||||
warshipPatrolRange(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
warshipTargettingRange(): number {
|
||||
return 130;
|
||||
}
|
||||
|
||||
warshipShellAttackRate(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
defensePostShellAttackRate(): number {
|
||||
return 100;
|
||||
}
|
||||
|
||||
safeFromPiratesCooldownMax(): number {
|
||||
return 20;
|
||||
}
|
||||
|
||||
defensePostTargettingRange(): number {
|
||||
return 75;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ export class DevServerConfig extends DefaultServerConfig {
|
||||
return Math.random() < 0.5 ? 2 : 3;
|
||||
}
|
||||
|
||||
samWarheadHittingChance(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
samHittingChance(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
discordRedirectURI(): string {
|
||||
return "http://localhost:3000/auth/callback";
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
blue,
|
||||
botColor,
|
||||
botColors,
|
||||
green,
|
||||
humanColors,
|
||||
orange,
|
||||
purple,
|
||||
red,
|
||||
teal,
|
||||
territoryColors,
|
||||
yellow,
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
@@ -36,15 +41,31 @@ export const pastelTheme = new (class implements Theme {
|
||||
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
switch (team) {
|
||||
case Team.Blue:
|
||||
return blue;
|
||||
case Team.Red:
|
||||
return red;
|
||||
case Team.Teal:
|
||||
return teal;
|
||||
case Team.Purple:
|
||||
return purple;
|
||||
case Team.Yellow:
|
||||
return yellow;
|
||||
case Team.Orange:
|
||||
return orange;
|
||||
case Team.Green:
|
||||
return green;
|
||||
case Team.Bot:
|
||||
return botColor;
|
||||
}
|
||||
throw new Error(`Missing color for ${team}`);
|
||||
}
|
||||
|
||||
territoryColor(player: PlayerView): Colord {
|
||||
if (player.team() == Team.Bot) {
|
||||
return botColor;
|
||||
}
|
||||
if (player.team() == Team.Red) {
|
||||
return red;
|
||||
}
|
||||
if (player.team() == Team.Blue) {
|
||||
return blue;
|
||||
if (player.team() !== null) {
|
||||
return this.teamColor(player.team());
|
||||
}
|
||||
if (player.info().playerType == PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
blue,
|
||||
botColor,
|
||||
botColors,
|
||||
green,
|
||||
humanColors,
|
||||
orange,
|
||||
purple,
|
||||
red,
|
||||
teal,
|
||||
territoryColors,
|
||||
yellow,
|
||||
} from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
@@ -36,15 +41,31 @@ export const pastelThemeDark = new (class implements Theme {
|
||||
|
||||
private _spawnHighlightColor = colord({ r: 255, g: 213, b: 79 });
|
||||
|
||||
teamColor(team: Team): Colord {
|
||||
switch (team) {
|
||||
case Team.Blue:
|
||||
return blue;
|
||||
case Team.Red:
|
||||
return red;
|
||||
case Team.Teal:
|
||||
return teal;
|
||||
case Team.Purple:
|
||||
return purple;
|
||||
case Team.Yellow:
|
||||
return yellow;
|
||||
case Team.Orange:
|
||||
return orange;
|
||||
case Team.Green:
|
||||
return green;
|
||||
case Team.Bot:
|
||||
return botColor;
|
||||
}
|
||||
throw new Error(`Missing color for ${team}`);
|
||||
}
|
||||
|
||||
territoryColor(player: PlayerView): Colord {
|
||||
if (player.team() == Team.Bot) {
|
||||
return botColor;
|
||||
}
|
||||
if (player.team() == Team.Red) {
|
||||
return red;
|
||||
}
|
||||
if (player.team() == Team.Blue) {
|
||||
return blue;
|
||||
if (player.team() !== null) {
|
||||
return this.teamColor(player.team());
|
||||
}
|
||||
if (player.info().playerType == PlayerType.Human) {
|
||||
return humanColors[simpleHash(player.id()) % humanColors.length];
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
Player,
|
||||
PlayerType,
|
||||
TerraNullius,
|
||||
} from "../game/Game";
|
||||
import { Execution, Game, Player } from "../game/Game";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { simpleHash } from "../Util";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { BotBehavior } from "./utils/BotBehavior";
|
||||
|
||||
export class BotExecution implements Execution {
|
||||
private active = true;
|
||||
@@ -16,18 +10,20 @@ export class BotExecution implements Execution {
|
||||
private mg: Game;
|
||||
private neighborsTerraNullius = true;
|
||||
|
||||
private behavior: BotBehavior | null = null;
|
||||
|
||||
constructor(private bot: Player) {
|
||||
this.random = new PseudoRandom(simpleHash(bot.id()));
|
||||
this.attackRate = this.random.nextInt(10, 50);
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
init(mg: Game) {
|
||||
this.mg = mg;
|
||||
this.bot.setTargetTroopRatio(0.7);
|
||||
// this.neighborsTerra = this.bot.neighbors().filter(n => n == this.gs.terraNullius()).length > 0
|
||||
}
|
||||
|
||||
tick(ticks: number) {
|
||||
@@ -40,14 +36,15 @@ export class BotExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bot.incomingAllianceRequests().forEach((ar) => {
|
||||
if (ar.requestor().isTraitor()) {
|
||||
ar.reject();
|
||||
} else {
|
||||
ar.accept();
|
||||
}
|
||||
});
|
||||
if (this.behavior === null) {
|
||||
this.behavior = new BotBehavior(this.random, this.mg, this.bot, 1 / 20);
|
||||
}
|
||||
|
||||
this.behavior.handleAllianceRequests();
|
||||
this.maybeAttack();
|
||||
}
|
||||
|
||||
private maybeAttack() {
|
||||
const traitors = this.bot
|
||||
.neighbors()
|
||||
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
|
||||
@@ -55,56 +52,22 @@ export class BotExecution implements Execution {
|
||||
const toAttack = this.random.randElement(traitors);
|
||||
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
|
||||
if (this.random.chance(odds)) {
|
||||
this.sendAttack(toAttack);
|
||||
this.behavior.sendAttack(toAttack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.neighborsTerraNullius) {
|
||||
for (const b of this.bot.borderTiles()) {
|
||||
for (const n of this.mg.neighbors(b)) {
|
||||
if (!this.mg.hasOwner(n) && this.mg.isLand(n)) {
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
this.neighborsTerraNullius = false;
|
||||
}
|
||||
|
||||
const border = Array.from(this.bot.borderTiles())
|
||||
.flatMap((t) => this.mg.neighbors(t))
|
||||
.filter((t) => this.mg.hasOwner(t) && this.mg.owner(t) != this.bot);
|
||||
|
||||
if (border.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toAttack = border[this.random.nextInt(0, border.length)];
|
||||
const owner = this.mg.owner(toAttack);
|
||||
|
||||
if (owner.isPlayer()) {
|
||||
if (this.bot.isFriendly(owner)) {
|
||||
return;
|
||||
}
|
||||
if (owner.type() == PlayerType.FakeHuman) {
|
||||
if (!this.random.chance(2)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.sendAttack(owner);
|
||||
}
|
||||
|
||||
sendAttack(toAttack: Player | TerraNullius) {
|
||||
if (toAttack.isPlayer() && this.bot.isOnSameTeam(toAttack)) return;
|
||||
this.mg.addExecution(
|
||||
new AttackExecution(
|
||||
this.bot.troops() / 20,
|
||||
this.bot.id(),
|
||||
toAttack.isPlayer() ? toAttack.id() : null,
|
||||
),
|
||||
);
|
||||
const enemy = this.behavior.selectRandomEnemy();
|
||||
if (!enemy) return;
|
||||
this.behavior.sendAttack(enemy);
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { ShellExecution } from "./ShellExecution";
|
||||
|
||||
export class DefensePostExecution implements Execution {
|
||||
private player: Player;
|
||||
@@ -15,6 +16,11 @@ export class DefensePostExecution implements Execution {
|
||||
private post: Unit;
|
||||
private active: boolean = true;
|
||||
|
||||
private target: Unit = null;
|
||||
private lastShellAttack = 0;
|
||||
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
private ownerId: PlayerID,
|
||||
private tile: TileRef,
|
||||
@@ -30,6 +36,27 @@ export class DefensePostExecution implements Execution {
|
||||
this.player = mg.player(this.ownerId);
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
const shellAttackRate = this.mg.config().defensePostShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
this.post.tile(),
|
||||
this.post.owner(),
|
||||
this.post,
|
||||
this.target,
|
||||
),
|
||||
);
|
||||
if (!this.target.hasHealth()) {
|
||||
// Don't send multiple shells to target that can be oneshotted
|
||||
this.alreadySentShell.add(this.target);
|
||||
this.target = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.post == null) {
|
||||
const spawnTile = this.player.canBuild(UnitType.DefensePost, this.tile);
|
||||
@@ -48,6 +75,52 @@ export class DefensePostExecution implements Execution {
|
||||
if (this.player != this.post.owner()) {
|
||||
this.player = this.post.owner();
|
||||
}
|
||||
|
||||
if (this.target != null && !this.target.isActive()) {
|
||||
this.target = null;
|
||||
}
|
||||
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
this.post.tile(),
|
||||
this.mg.config().defensePostTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship],
|
||||
)
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== this.post.owner() &&
|
||||
!unit.owner().isFriendly(this.post.owner()) &&
|
||||
!this.alreadySentShell.has(unit),
|
||||
);
|
||||
|
||||
this.target =
|
||||
ships.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize TransportShip
|
||||
if (
|
||||
unitA.type() === UnitType.TransportShip &&
|
||||
unitB.type() !== UnitType.TransportShip
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.TransportShip &&
|
||||
unitB.type() === UnitType.TransportShip
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null;
|
||||
|
||||
if (this.target == null || !this.target.isActive()) {
|
||||
this.target = null;
|
||||
return;
|
||||
} else {
|
||||
this.shoot();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import {
|
||||
AllianceRequest,
|
||||
Cell,
|
||||
Difficulty,
|
||||
Execution,
|
||||
@@ -11,35 +10,33 @@ import {
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerrainType,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
Unit,
|
||||
UnitType,
|
||||
} from "../game/Game";
|
||||
import { manhattanDistFN, TileRef } from "../game/GameMap";
|
||||
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { GameID } from "../Schemas";
|
||||
import { calculateBoundingBox, simpleHash } from "../Util";
|
||||
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { EmojiExecution } from "./EmojiExecution";
|
||||
import { NukeExecution } from "./NukeExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { closestTwoTiles } from "./Util";
|
||||
import { BotBehavior } from "./utils/BotBehavior";
|
||||
|
||||
export class FakeHumanExecution implements Execution {
|
||||
private firstMove = true;
|
||||
|
||||
private active = true;
|
||||
private random: PseudoRandom;
|
||||
private behavior: BotBehavior | null = null;
|
||||
private mg: Game;
|
||||
private player: Player = null;
|
||||
|
||||
private enemy: Player | null = null;
|
||||
|
||||
private lastEnemyUpdateTick: number = 0;
|
||||
private lastEmojiSent = new Map<Player, Tick>();
|
||||
private lastNukeSent: [Tick, TileRef][] = [];
|
||||
private embargoMalusApplied = new Set<PlayerID>();
|
||||
|
||||
constructor(
|
||||
@@ -51,7 +48,7 @@ export class FakeHumanExecution implements Execution {
|
||||
);
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number) {
|
||||
init(mg: Game) {
|
||||
this.mg = mg;
|
||||
if (this.random.chance(10)) {
|
||||
// this.isTraitor = true
|
||||
@@ -116,16 +113,23 @@ export class FakeHumanExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.firstMove) {
|
||||
this.firstMove = false;
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.player.isAlive()) {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.behavior === null) {
|
||||
// Player is unavailable during init()
|
||||
this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5);
|
||||
}
|
||||
|
||||
if (this.firstMove) {
|
||||
this.firstMove = false;
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
if (ticks % this.random.nextInt(40, 80) != 0) {
|
||||
return;
|
||||
}
|
||||
@@ -138,7 +142,7 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
this.updateRelationsFromEmbargos();
|
||||
this.handleAllianceRequests();
|
||||
this.behavior.handleAllianceRequests();
|
||||
this.handleEnemies();
|
||||
this.handleUnits();
|
||||
this.handleEmbargoesToHostileNations();
|
||||
@@ -164,7 +168,7 @@ export class FakeHumanExecution implements Execution {
|
||||
this.mg.playerBySmallID(this.mg.ownerID(t)),
|
||||
);
|
||||
if (enemiesWithTN.filter((o) => !o.isPlayer()).length > 0) {
|
||||
this.sendAttack(this.mg.terraNullius());
|
||||
this.behavior.sendAttack(this.mg.terraNullius());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ export class FakeHumanExecution implements Execution {
|
||||
? enemies[0]
|
||||
: this.random.randElement(enemies);
|
||||
if (this.shouldAttack(toAttack)) {
|
||||
this.sendAttack(toAttack);
|
||||
this.behavior.sendAttack(toAttack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,97 +227,137 @@ export class FakeHumanExecution implements Execution {
|
||||
}
|
||||
|
||||
handleEnemies() {
|
||||
if (this.mg.ticks() - this.lastEnemyUpdateTick > 100) {
|
||||
this.enemy = null;
|
||||
}
|
||||
|
||||
const target =
|
||||
this.player
|
||||
.allies()
|
||||
.filter((ally) => this.player.relation(ally) == Relation.Friendly)
|
||||
.filter((ally) => ally.targets().length > 0)
|
||||
.map((ally) => ({ ally: ally, t: ally.targets()[0] }))[0] ?? null;
|
||||
|
||||
if (
|
||||
target != null &&
|
||||
target.t != this.player &&
|
||||
!this.player.isAlliedWith(target.t)
|
||||
) {
|
||||
this.player.updateRelation(target.ally, -20);
|
||||
this.enemy = target.t;
|
||||
this.lastEnemyUpdateTick = this.mg.ticks();
|
||||
if (target.ally.type() == PlayerType.Human) {
|
||||
this.mg.addExecution(
|
||||
new EmojiExecution(this.player.id(), target.ally.id(), "👍"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enemy == null) {
|
||||
const mostHated = this.player.allRelationsSorted()[0] ?? null;
|
||||
if (mostHated != null && mostHated.relation == Relation.Hostile) {
|
||||
this.enemy = mostHated.player;
|
||||
this.lastEnemyUpdateTick = this.mg.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.enemy) {
|
||||
if (this.player.isFriendly(this.enemy)) {
|
||||
this.enemy = null;
|
||||
return;
|
||||
}
|
||||
this.maybeSendEmoji();
|
||||
this.maybeSendNuke(this.enemy);
|
||||
if (this.player.sharesBorderWith(this.enemy)) {
|
||||
this.sendAttack(this.enemy);
|
||||
} else {
|
||||
this.maybeSendBoatAttack(this.enemy);
|
||||
}
|
||||
return;
|
||||
this.behavior.assistAllies();
|
||||
const enemy = this.behavior.selectEnemy();
|
||||
if (!enemy) return;
|
||||
this.maybeSendEmoji(enemy);
|
||||
this.maybeSendNuke(enemy);
|
||||
if (this.player.sharesBorderWith(enemy)) {
|
||||
this.behavior.sendAttack(enemy);
|
||||
} else {
|
||||
this.maybeSendBoatAttack(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeSendEmoji() {
|
||||
if (this.enemy.type() != PlayerType.Human) return;
|
||||
const lastSent = this.lastEmojiSent.get(this.enemy) ?? -300;
|
||||
private maybeSendEmoji(enemy: Player) {
|
||||
if (enemy.type() != PlayerType.Human) return;
|
||||
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
|
||||
if (this.mg.ticks() - lastSent <= 300) return;
|
||||
this.lastEmojiSent.set(this.enemy, this.mg.ticks());
|
||||
this.lastEmojiSent.set(enemy, this.mg.ticks());
|
||||
this.mg.addExecution(
|
||||
new EmojiExecution(
|
||||
this.player.id(),
|
||||
this.enemy.id(),
|
||||
enemy.id(),
|
||||
this.random.randElement(["🤡", "😡"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private maybeSendNuke(other: Player) {
|
||||
const silos = this.player.units(UnitType.MissileSilo);
|
||||
if (
|
||||
this.player.units(UnitType.MissileSilo).length == 0 ||
|
||||
silos.length == 0 ||
|
||||
this.player.gold() <
|
||||
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
|
||||
other.type() == PlayerType.Bot ||
|
||||
this.player.isOnSameTeam(other)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
outer: for (let i = 0; i < 10; i++) {
|
||||
const tile = this.randTerritoryTile(other);
|
||||
if (tile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const structures = other.units(
|
||||
UnitType.City,
|
||||
UnitType.DefensePost,
|
||||
UnitType.MissileSilo,
|
||||
UnitType.Port,
|
||||
UnitType.SAMLauncher,
|
||||
);
|
||||
const structureTiles = structures.map((u) => u.tile());
|
||||
const randomTiles: TileRef[] = new Array(10);
|
||||
for (let i = 0; i < randomTiles.length; i++) {
|
||||
randomTiles[i] = this.randTerritoryTile(other);
|
||||
}
|
||||
const allTiles = randomTiles.concat(structureTiles);
|
||||
|
||||
let bestTile = null;
|
||||
let bestValue = 0;
|
||||
this.removeOldNukeEvents();
|
||||
outer: for (const tile of new Set(allTiles)) {
|
||||
if (tile == null) continue;
|
||||
for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) {
|
||||
// Make sure we nuke at least 15 tiles in border
|
||||
if (this.mg.owner(t) != other) {
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
if (this.player.canBuild(UnitType.AtomBomb, tile)) {
|
||||
this.mg.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, this.player.id(), tile),
|
||||
);
|
||||
return;
|
||||
if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue;
|
||||
const value = this.nukeTileScore(tile, silos, structures);
|
||||
if (value > bestTile) {
|
||||
bestTile = tile;
|
||||
bestValue = value;
|
||||
}
|
||||
}
|
||||
if (bestTile != null) {
|
||||
this.sendNuke(bestTile);
|
||||
}
|
||||
}
|
||||
|
||||
private removeOldNukeEvents() {
|
||||
const maxAge = 500;
|
||||
const tick = this.mg.ticks();
|
||||
while (
|
||||
this.lastNukeSent.length > 0 &&
|
||||
this.lastNukeSent[0][0] + maxAge < tick
|
||||
) {
|
||||
this.lastNukeSent.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private sendNuke(tile: TileRef) {
|
||||
const tick = this.mg.ticks();
|
||||
this.lastNukeSent.push([tick, tile]);
|
||||
this.mg.addExecution(
|
||||
new NukeExecution(UnitType.AtomBomb, this.player.id(), tile),
|
||||
);
|
||||
}
|
||||
|
||||
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
|
||||
// Potential damage in a 25-tile radius
|
||||
const dist = euclDistFN(tile, 25, false);
|
||||
let tileValue = targets
|
||||
.filter((unit) => dist(this.mg, unit.tile()))
|
||||
.map((unit) => {
|
||||
switch (unit.type()) {
|
||||
case UnitType.City:
|
||||
return 25_000;
|
||||
case UnitType.DefensePost:
|
||||
return 5_000;
|
||||
case UnitType.MissileSilo:
|
||||
return 50_000;
|
||||
case UnitType.Port:
|
||||
return 10_000;
|
||||
case UnitType.SAMLauncher:
|
||||
return 5_000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
// Prefer tiles that are closer to a silo
|
||||
const siloTiles = silos.map((u) => u.tile());
|
||||
const { x: closestSilo } = closestTwoTiles(this.mg, siloTiles, [tile]);
|
||||
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
|
||||
const distanceToClosestSilo = Math.sqrt(distanceSquared);
|
||||
tileValue -= distanceToClosestSilo * 30;
|
||||
|
||||
// Don't target near recent targets
|
||||
tileValue -= this.lastNukeSent
|
||||
.filter(([_tick, tile]) => dist(this.mg, tile))
|
||||
.map((_) => 1_000_000)
|
||||
.reduce((prev, cur) => prev + cur, 0);
|
||||
|
||||
return tileValue;
|
||||
}
|
||||
|
||||
private maybeSendBoatAttack(other: Player) {
|
||||
@@ -473,36 +517,6 @@ export class FakeHumanExecution implements Execution {
|
||||
return this.mg.unitInfo(type).cost(this.player);
|
||||
}
|
||||
|
||||
handleAllianceRequests() {
|
||||
for (const req of this.player.incomingAllianceRequests()) {
|
||||
if (req.requestor().isTraitor()) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
if (this.player.relation(req.requestor()) < Relation.Neutral) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
const requestorIsMuchLarger =
|
||||
req.requestor().numTilesOwned() > this.player.numTilesOwned() * 3;
|
||||
if (!requestorIsMuchLarger && req.requestor().alliances().length >= 3) {
|
||||
this.replyToAllianceRequest(req, false);
|
||||
continue;
|
||||
}
|
||||
this.replyToAllianceRequest(req, true);
|
||||
}
|
||||
}
|
||||
|
||||
private replyToAllianceRequest(req: AllianceRequest, accept: boolean): void {
|
||||
this.mg.addExecution(
|
||||
new AllianceRequestReplyExecution(
|
||||
req.requestor().id(),
|
||||
this.player.id(),
|
||||
accept,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sendBoatRandomly() {
|
||||
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
||||
this.mg.isOceanShore(t),
|
||||
@@ -554,17 +568,6 @@ export class FakeHumanExecution implements Execution {
|
||||
return null;
|
||||
}
|
||||
|
||||
sendAttack(toAttack: Player | TerraNullius) {
|
||||
if (toAttack.isPlayer() && this.player.isOnSameTeam(toAttack)) return;
|
||||
this.mg.addExecution(
|
||||
new AttackExecution(
|
||||
this.player.troops() / 5,
|
||||
this.player.id(),
|
||||
toAttack.isPlayer() ? toAttack.id() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null {
|
||||
const x = this.mg.x(tile);
|
||||
const y = this.mg.y(tile);
|
||||
|
||||
@@ -170,7 +170,6 @@ export class MirvExecution implements Execution {
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
continue;
|
||||
}
|
||||
console.log(`got coord ${x}, ${y}`);
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (!this.mg.isLand(tile)) {
|
||||
continue;
|
||||
|
||||
@@ -33,14 +33,15 @@ export class MissileSiloExecution implements Execution {
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.silo == null) {
|
||||
if (!this.player.canBuild(UnitType.MissileSilo, this.tile)) {
|
||||
const spawn = this.player.canBuild(UnitType.MissileSilo, this.tile);
|
||||
if (spawn === false) {
|
||||
consolex.warn(
|
||||
`player ${this.player} cannot build missile silo at ${this.tile}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, this.tile, {
|
||||
this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, spawn, {
|
||||
cooldownDuration: this.mg.config().SiloCooldown(),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,19 +15,28 @@ import { SAMMissileExecution } from "./SAMMissileExecution";
|
||||
export class SAMLauncherExecution implements Execution {
|
||||
private player: Player;
|
||||
private mg: Game;
|
||||
private sam: Unit;
|
||||
private active: boolean = true;
|
||||
|
||||
private target: Unit = null;
|
||||
private warheadTargets: Unit[] = [];
|
||||
|
||||
private searchRangeRadius = 75;
|
||||
private searchRangeRadius = 80;
|
||||
// As MIRV go very fast we have to detect them very early but we only
|
||||
// shoot the one targeting very close (MIRVWarheadProtectionRadius)
|
||||
private MIRVWarheadSearchRadius = 400;
|
||||
private MIRVWarheadProtectionRadius = 50;
|
||||
|
||||
private pseudoRandom: PseudoRandom;
|
||||
|
||||
constructor(
|
||||
private ownerId: PlayerID,
|
||||
private tile: TileRef,
|
||||
) {}
|
||||
private sam: Unit | null = null,
|
||||
) {
|
||||
if (sam != null) {
|
||||
this.tile = sam.tile();
|
||||
}
|
||||
}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
@@ -39,6 +48,52 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.player = mg.player(this.ownerId);
|
||||
}
|
||||
|
||||
private getSingleTarget(): Unit | null {
|
||||
const nukes = this.mg
|
||||
.nearbyUnits(this.sam.tile(), this.searchRangeRadius, [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
])
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
|
||||
);
|
||||
|
||||
return (
|
||||
nukes.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize Hydrogen Bombs
|
||||
if (
|
||||
unitA.type() === UnitType.HydrogenBomb &&
|
||||
unitB.type() !== UnitType.HydrogenBomb
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.HydrogenBomb &&
|
||||
unitB.type() === UnitType.HydrogenBomb
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private isHit(type: UnitType, random: number): boolean {
|
||||
if (type == UnitType.AtomBomb) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type == UnitType.MIRVWarhead) {
|
||||
return random < this.mg.config().samWarheadHittingChance();
|
||||
}
|
||||
|
||||
return random < this.mg.config().samHittingChance();
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (this.sam == null) {
|
||||
const spawnTile = this.player.canBuild(UnitType.SAMLauncher, this.tile);
|
||||
@@ -64,36 +119,26 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.pseudoRandom = new PseudoRandom(this.sam.id());
|
||||
}
|
||||
|
||||
const nukes = this.mg
|
||||
.nearbyUnits(this.sam.tile(), this.searchRangeRadius, [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.HydrogenBomb,
|
||||
])
|
||||
this.warheadTargets = this.mg
|
||||
.nearbyUnits(
|
||||
this.sam.tile(),
|
||||
this.MIRVWarheadSearchRadius,
|
||||
UnitType.MIRVWarhead,
|
||||
)
|
||||
.map(({ unit }) => unit)
|
||||
.filter(
|
||||
({ unit }) =>
|
||||
(unit) =>
|
||||
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
|
||||
)
|
||||
.filter(
|
||||
(unit) =>
|
||||
this.mg.manhattanDist(unit.detonationDst(), this.sam.tile()) <
|
||||
this.MIRVWarheadProtectionRadius,
|
||||
);
|
||||
|
||||
this.target =
|
||||
nukes.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize Hydrogen Bombs
|
||||
if (
|
||||
unitA.type() === UnitType.HydrogenBomb &&
|
||||
unitB.type() !== UnitType.HydrogenBomb
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.HydrogenBomb &&
|
||||
unitB.type() === UnitType.HydrogenBomb
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null;
|
||||
if (this.warheadTargets.length == 0) {
|
||||
this.target = this.getSingleTarget();
|
||||
}
|
||||
|
||||
if (
|
||||
this.sam.isCooldown() &&
|
||||
@@ -102,26 +147,46 @@ export class SAMLauncherExecution implements Execution {
|
||||
this.sam.setCooldown(false);
|
||||
}
|
||||
|
||||
if (this.target && !this.sam.isCooldown() && !this.target.targetedBySAM()) {
|
||||
const isSingleTarget = this.target && !this.target.targetedBySAM();
|
||||
if (
|
||||
(isSingleTarget || this.warheadTargets.length > 0) &&
|
||||
!this.sam.isCooldown()
|
||||
) {
|
||||
this.sam.setCooldown(true);
|
||||
const type =
|
||||
this.warheadTargets.length > 0
|
||||
? UnitType.MIRVWarhead
|
||||
: this.target.type();
|
||||
const random = this.pseudoRandom.next();
|
||||
const hit = random < this.mg.config().samHittingChance();
|
||||
const hit = this.isHit(type, random);
|
||||
if (!hit) {
|
||||
this.mg.displayMessage(
|
||||
`Missile failed to intercept ${this.target.type()}`,
|
||||
`Missile failed to intercept ${type}`,
|
||||
MessageType.ERROR,
|
||||
this.sam.owner().id(),
|
||||
);
|
||||
} else {
|
||||
this.target.setTargetedBySAM(true);
|
||||
this.mg.addExecution(
|
||||
new SAMMissileExecution(
|
||||
this.sam.tile(),
|
||||
this.sam.owner(),
|
||||
this.sam,
|
||||
this.target,
|
||||
),
|
||||
);
|
||||
if (this.warheadTargets.length > 0) {
|
||||
// Message
|
||||
this.mg.displayMessage(
|
||||
`${this.warheadTargets.length} MIRV warheads intercepted`,
|
||||
MessageType.SUCCESS,
|
||||
this.sam.owner().id(),
|
||||
);
|
||||
// Delete warheads
|
||||
this.warheadTargets.forEach((u) => u.delete());
|
||||
} else {
|
||||
this.target.setTargetedBySAM(true);
|
||||
this.mg.addExecution(
|
||||
new SAMMissileExecution(
|
||||
this.sam.tile(),
|
||||
this.sam.owner(),
|
||||
this.sam,
|
||||
this.target,
|
||||
),
|
||||
);
|
||||
this.warheadTargets = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,7 @@ export class ShellExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.destroyAtTick == -1 && !this.ownerUnit.isActive()) {
|
||||
this.destroyAtTick =
|
||||
this.mg.ticks() + this.mg.config().warshipShellLifetime();
|
||||
this.destroyAtTick = this.mg.ticks() + this.mg.config().shellLifetime();
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
@@ -55,7 +54,7 @@ export class ShellExecution implements Execution {
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.active = false;
|
||||
this.target.modifyHealth(-this.shell.info().damage);
|
||||
this.target.modifyHealth(-this.effectOnTarget());
|
||||
this.shell.delete(false);
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
@@ -72,6 +71,11 @@ export class ShellExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
private effectOnTarget(): number {
|
||||
const baseDamage: number = this.mg.config().unitInfo(UnitType.Shell).damage;
|
||||
return baseDamage;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, 0, spawn, {
|
||||
dstPort: this._dstPort,
|
||||
lastSetSafeFromPirates: ticks,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,11 +57,11 @@ export class TradeShipExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.origOwner != this.tradeShip.owner()) {
|
||||
// Store as vairable in case ship is recaptured by previous owner
|
||||
// Store as variable in case ship is recaptured by previous owner
|
||||
this.wasCaptured = true;
|
||||
}
|
||||
|
||||
// If a player captures an other player's port while trading we should delete
|
||||
// If a player captures another player's port while trading we should delete
|
||||
// the ship.
|
||||
if (this._dstPort.owner().id() == this.srcPort.owner().id()) {
|
||||
this.tradeShip.delete(false);
|
||||
@@ -107,6 +108,10 @@ export class TradeShipExecution implements Execution {
|
||||
this.tradeShip.move(this.tradeShip.tile());
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
// Update safeFromPirates status
|
||||
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
|
||||
this.tradeShip.setSafeFromPirates();
|
||||
}
|
||||
this.tradeShip.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
|
||||
@@ -26,6 +26,7 @@ export class TransportShipExecution implements Execution {
|
||||
private mg: Game;
|
||||
private attacker: Player;
|
||||
private target: Player | TerraNullius;
|
||||
private embarkDelay = 10;
|
||||
|
||||
// TODO make private
|
||||
public path: TileRef[];
|
||||
@@ -136,6 +137,10 @@ export class TransportShipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.embarkDelay > 0) {
|
||||
this.embarkDelay--;
|
||||
return;
|
||||
}
|
||||
if (ticks - this.lastMove < this.ticksPerMove) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,12 +26,7 @@ export class WarshipExecution implements Execution {
|
||||
|
||||
private patrolTile: TileRef;
|
||||
|
||||
// TODO: put in config
|
||||
private searchRange = 100;
|
||||
|
||||
private shellAttackRate = 5;
|
||||
private lastShellAttack = 0;
|
||||
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
|
||||
constructor(
|
||||
@@ -72,7 +67,8 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
if (this.mg.ticks() - this.lastShellAttack > this.shellAttackRate) {
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
|
||||
this.lastShellAttack = this.mg.ticks();
|
||||
this.mg.addExecution(
|
||||
new ShellExecution(
|
||||
@@ -137,7 +133,7 @@ export class WarshipExecution implements Execution {
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
this.warship.tile(),
|
||||
130, // Search range
|
||||
this.mg.config().warshipTargettingRange(),
|
||||
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
|
||||
)
|
||||
.filter(
|
||||
@@ -146,9 +142,11 @@ export class WarshipExecution implements Execution {
|
||||
unit !== this.warship &&
|
||||
!unit.owner().isFriendly(this.warship.owner()) &&
|
||||
!this.alreadySentShell.has(unit) &&
|
||||
(unit.type() !== UnitType.TradeShip || hasPort) &&
|
||||
(unit.type() !== UnitType.TradeShip ||
|
||||
unit.dstPort()?.owner() !== this._owner),
|
||||
(hasPort &&
|
||||
unit.dstPort()?.owner() !== this.warship.owner() &&
|
||||
!unit.dstPort()?.owner().isFriendly(this.warship.owner()) &&
|
||||
unit.isSafeFromPirates() !== true)),
|
||||
);
|
||||
|
||||
this.target =
|
||||
@@ -198,9 +196,10 @@ export class WarshipExecution implements Execution {
|
||||
if (
|
||||
this.target == null ||
|
||||
!this.target.isActive() ||
|
||||
this.target.owner() == this._owner
|
||||
this.target.owner() == this._owner ||
|
||||
this.target.isSafeFromPirates() == true
|
||||
) {
|
||||
// In case another destroyer captured or destroyed target
|
||||
// In case another warship captured or destroyed target, or the target escaped into safe waters
|
||||
this.target = null;
|
||||
return;
|
||||
}
|
||||
@@ -250,18 +249,29 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
randomTile(): TileRef {
|
||||
while (true) {
|
||||
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
|
||||
const maxAttemptBeforeExpand: number = warshipPatrolRange * 2;
|
||||
let attemptCount: number = 0;
|
||||
let expandCount: number = 0;
|
||||
while (expandCount < 3) {
|
||||
const x =
|
||||
this.mg.x(this.patrolCenterTile) +
|
||||
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
const y =
|
||||
this.mg.y(this.patrolCenterTile) +
|
||||
this.random.nextInt(-this.searchRange / 2, this.searchRange / 2);
|
||||
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
|
||||
if (!this.mg.isValidCoord(x, y)) {
|
||||
continue;
|
||||
}
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (!this.mg.isOcean(tile)) {
|
||||
if (!this.mg.isOcean(tile) || this.mg.isShoreline(tile)) {
|
||||
attemptCount++;
|
||||
if (attemptCount === maxAttemptBeforeExpand) {
|
||||
expandCount++;
|
||||
attemptCount = 0;
|
||||
warshipPatrolRange =
|
||||
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
AllianceRequest,
|
||||
Game,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
TerraNullius,
|
||||
Tick,
|
||||
} from "../../game/Game";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { AttackExecution } from "../AttackExecution";
|
||||
import { EmojiExecution } from "../EmojiExecution";
|
||||
|
||||
export class BotBehavior {
|
||||
private enemy: Player | null = null;
|
||||
private enemyUpdated: Tick;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
private player: Player,
|
||||
private attackRatio: number,
|
||||
) {}
|
||||
|
||||
handleAllianceRequests() {
|
||||
for (const req of this.player.incomingAllianceRequests()) {
|
||||
if (shouldAcceptAllianceRequest(this.player, req)) {
|
||||
req.accept();
|
||||
} else {
|
||||
req.reject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emoji(player: Player, emoji: string) {
|
||||
if (player.type() !== PlayerType.Human) return;
|
||||
this.game.addExecution(
|
||||
new EmojiExecution(this.player.id(), player.id(), emoji),
|
||||
);
|
||||
}
|
||||
|
||||
assistAllies() {
|
||||
outer: for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) {
|
||||
// this.emoji(ally, "🤦");
|
||||
continue;
|
||||
}
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) {
|
||||
// this.emoji(ally, "💀");
|
||||
continue;
|
||||
}
|
||||
if (this.player.isAlliedWith(target)) {
|
||||
// this.emoji(ally, "👎");
|
||||
continue;
|
||||
}
|
||||
// All checks passed, assist them
|
||||
this.player.updateRelation(ally, -20);
|
||||
this.enemy = target;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
this.emoji(ally, "👍");
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectEnemy(): Player | null {
|
||||
// Forget old enemies
|
||||
if (this.game.ticks() - this.enemyUpdated > 100) {
|
||||
this.enemy = null;
|
||||
}
|
||||
|
||||
// Prefer neighboring bots
|
||||
if (this.enemy === null) {
|
||||
const bots = this.player
|
||||
.neighbors()
|
||||
.filter((n) => n.isPlayer() && n.type() === PlayerType.Bot) as Player[];
|
||||
if (bots.length > 0) {
|
||||
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
||||
this.enemy = bots.sort((a, b) => density(a) - density(b))[0];
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
// Select the most hated player
|
||||
if (this.enemy === null) {
|
||||
const mostHated = this.player.allRelationsSorted()[0] ?? null;
|
||||
if (mostHated != null && mostHated.relation === Relation.Hostile) {
|
||||
this.enemy = mostHated.player;
|
||||
this.enemyUpdated = this.game.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check, don't attack our allies or teammates
|
||||
if (this.enemy && this.player.isFriendly(this.enemy)) {
|
||||
this.enemy = null;
|
||||
}
|
||||
return this.enemy;
|
||||
}
|
||||
|
||||
selectRandomEnemy(): Player | TerraNullius | null {
|
||||
const neighbors = this.player.neighbors();
|
||||
for (const neighbor of this.random.shuffleArray(neighbors)) {
|
||||
if (neighbor.isPlayer()) {
|
||||
if (this.player.isFriendly(neighbor)) continue;
|
||||
if (neighbor.type() == PlayerType.FakeHuman) {
|
||||
if (this.random.chance(2)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return neighbor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
sendAttack(target: Player | TerraNullius) {
|
||||
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
|
||||
const troops = this.player.troops() * this.attackRatio;
|
||||
if (troops < 1) return;
|
||||
this.game.addExecution(
|
||||
new AttackExecution(
|
||||
troops,
|
||||
this.player.id(),
|
||||
target.isPlayer() ? target.id() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
|
||||
const notTraitor = !request.requestor().isTraitor();
|
||||
const noMalice = player.relation(request.requestor()) >= Relation.Neutral;
|
||||
const requestorIsMuchLarger =
|
||||
request.requestor().numTilesOwned() > player.numTilesOwned() * 3;
|
||||
const notTooManyAlliances =
|
||||
requestorIsMuchLarger || request.requestor().alliances().length < 3;
|
||||
return notTraitor && noMalice && notTooManyAlliances;
|
||||
}
|
||||
+36
-2
@@ -40,6 +40,11 @@ export enum Difficulty {
|
||||
export enum Team {
|
||||
Red = "Red",
|
||||
Blue = "Blue",
|
||||
Teal = "Teal",
|
||||
Purple = "Purple",
|
||||
Yellow = "Yellow",
|
||||
Orange = "Orange",
|
||||
Green = "Green",
|
||||
Bot = "Bot",
|
||||
}
|
||||
|
||||
@@ -62,8 +67,32 @@ export enum GameMapType {
|
||||
Japan = "Japan",
|
||||
BetweenTwoSeas = "Between Two Seas",
|
||||
KnownWorld = "Known World",
|
||||
FaroeIslands = "FaroeIslands",
|
||||
}
|
||||
|
||||
export const mapCategories: Record<string, GameMapType[]> = {
|
||||
continental: [
|
||||
GameMapType.World,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.Europe,
|
||||
GameMapType.Asia,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Oceania,
|
||||
],
|
||||
regional: [
|
||||
GameMapType.BlackSea,
|
||||
GameMapType.Britannia,
|
||||
GameMapType.GatewayToTheAtlantic,
|
||||
GameMapType.BetweenTwoSeas,
|
||||
GameMapType.Iceland,
|
||||
GameMapType.Japan,
|
||||
GameMapType.Mena,
|
||||
GameMapType.Australia,
|
||||
],
|
||||
fantasy: [GameMapType.Pangaea, GameMapType.Mars, GameMapType.KnownWorld],
|
||||
};
|
||||
|
||||
export enum GameType {
|
||||
Singleplayer = "Singleplayer",
|
||||
Public = "Public",
|
||||
@@ -234,6 +263,7 @@ export class PlayerInfo {
|
||||
// Some units have info specific to them
|
||||
export interface UnitSpecificInfos {
|
||||
dstPort?: Unit; // Only for trade ships
|
||||
lastSetSafeFromPirates?: number; // Only for trade ships
|
||||
detonationDst?: TileRef; // Only for nukes
|
||||
warshipTarget?: Unit;
|
||||
cooldownDuration?: number;
|
||||
@@ -267,6 +297,8 @@ export interface Unit {
|
||||
isCooldown(): boolean;
|
||||
setDstPort(dstPort: Unit): void;
|
||||
dstPort(): Unit; // Only for trade ships
|
||||
setSafeFromPirates(): void; // Only for trade ships
|
||||
isSafeFromPirates(): boolean; // Only for trade ships
|
||||
detonationDst(): TileRef; // Only for nukes
|
||||
|
||||
setMoveTarget(cell: TileRef): void;
|
||||
@@ -309,6 +341,7 @@ export interface Player {
|
||||
// State & Properties
|
||||
isAlive(): boolean;
|
||||
isTraitor(): boolean;
|
||||
markTraitor(): void;
|
||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||
lastTileChange(): Tick;
|
||||
|
||||
@@ -340,6 +373,7 @@ export interface Player {
|
||||
// Units
|
||||
units(...types: UnitType[]): Unit[];
|
||||
unitsIncludingConstruction(type: UnitType): Unit[];
|
||||
buildableUnits(tile: TileRef): BuildableUnit[];
|
||||
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
|
||||
buildUnit(
|
||||
type: UnitType,
|
||||
@@ -409,7 +443,7 @@ export interface Player {
|
||||
// Misc
|
||||
toUpdate(): PlayerUpdate;
|
||||
playerProfile(): PlayerProfile;
|
||||
canBoat(tile: TileRef): boolean;
|
||||
canBoat(tile: TileRef): TileRef | false;
|
||||
tradingPorts(port: Unit): Unit[];
|
||||
}
|
||||
|
||||
@@ -467,7 +501,7 @@ export interface Game extends GameMap {
|
||||
}
|
||||
|
||||
export interface PlayerActions {
|
||||
canBoat: boolean;
|
||||
canBoat: TileRef | false;
|
||||
canAttack: boolean;
|
||||
buildableUnits: BuildableUnit[];
|
||||
canSendEmojiAllPlayers: boolean;
|
||||
|
||||
@@ -99,6 +99,17 @@ export class GameImpl implements Game {
|
||||
),
|
||||
);
|
||||
this.unitGrid = new UnitGrid(this._map);
|
||||
|
||||
if (_config.gameConfig().gameMode === GameMode.Team) {
|
||||
const numPlayerTeams = _config.numPlayerTeams();
|
||||
if (numPlayerTeams < 2) throw new Error("Too few teams!");
|
||||
if (numPlayerTeams >= 3) this.playerTeams.push(Team.Teal);
|
||||
if (numPlayerTeams >= 4) this.playerTeams.push(Team.Purple);
|
||||
if (numPlayerTeams >= 5) this.playerTeams.push(Team.Yellow);
|
||||
if (numPlayerTeams >= 6) this.playerTeams.push(Team.Orange);
|
||||
if (numPlayerTeams >= 7) this.playerTeams.push(Team.Green);
|
||||
if (numPlayerTeams >= 8) throw new Error("Too many teams!");
|
||||
}
|
||||
}
|
||||
|
||||
private addHumans() {
|
||||
@@ -106,7 +117,7 @@ export class GameImpl implements Game {
|
||||
this._humans.forEach((p) => this.addPlayer(p));
|
||||
return;
|
||||
}
|
||||
const playerToTeam = assignTeams(this._humans);
|
||||
const playerToTeam = assignTeams(this._humans, this.playerTeams);
|
||||
for (const [playerInfo, team] of playerToTeam.entries()) {
|
||||
if (team == "kicked") {
|
||||
console.warn(`Player ${playerInfo.name} was kicked from team`);
|
||||
@@ -507,7 +518,7 @@ export class GameImpl implements Game {
|
||||
);
|
||||
}
|
||||
if (!other.isTraitor()) {
|
||||
(breaker as PlayerImpl).isTraitor_ = true;
|
||||
breaker.markTraitor();
|
||||
}
|
||||
|
||||
const breakerSet = new Set(breaker.alliances());
|
||||
|
||||
@@ -178,7 +178,7 @@ export class PlayerView {
|
||||
return this.data.id;
|
||||
}
|
||||
team(): Team | null {
|
||||
return this.data.team;
|
||||
return this.data.team ?? null;
|
||||
}
|
||||
type(): PlayerType {
|
||||
return this.data.playerType;
|
||||
|
||||
+97
-18
@@ -20,8 +20,10 @@ import {
|
||||
AllianceRequest,
|
||||
AllPlayers,
|
||||
Attack,
|
||||
BuildableUnit,
|
||||
Cell,
|
||||
EmojiMessage,
|
||||
GameMode,
|
||||
Gold,
|
||||
MessageType,
|
||||
MutableAlliance,
|
||||
@@ -67,7 +69,7 @@ export class PlayerImpl implements Player {
|
||||
// 0 to 100
|
||||
private _targetTroopRatio: bigint;
|
||||
|
||||
isTraitor_ = false;
|
||||
markedTraitorTick = -1;
|
||||
|
||||
private embargoes: Set<PlayerID> = new Set();
|
||||
|
||||
@@ -243,7 +245,7 @@ export class PlayerImpl implements Player {
|
||||
const ns: Set<Player | TerraNullius> = new Set();
|
||||
for (const border of this.borderTiles()) {
|
||||
for (const neighbor of this.mg.map().neighbors(border)) {
|
||||
if (this.mg.map().isLake(neighbor)) {
|
||||
if (this.mg.map().isLand(neighbor)) {
|
||||
const owner = this.mg.map().ownerID(neighbor);
|
||||
if (owner != this.smallID()) {
|
||||
ns.add(
|
||||
@@ -372,7 +374,14 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
isTraitor(): boolean {
|
||||
return this.isTraitor_;
|
||||
return (
|
||||
this.markedTraitorTick >= 0 &&
|
||||
this.mg.ticks() - this.markedTraitorTick <
|
||||
this.mg.config().traitorDuration()
|
||||
);
|
||||
}
|
||||
markTraitor(): void {
|
||||
this.markedTraitorTick = this.mg.ticks();
|
||||
}
|
||||
|
||||
createAllianceRequest(recipient: Player): AllianceRequest {
|
||||
@@ -516,6 +525,13 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
canDonate(recipient: Player): boolean {
|
||||
if (
|
||||
recipient.type() == PlayerType.Human &&
|
||||
this.mg.config().gameConfig().gameMode == GameMode.FFA
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isFriendly(recipient)) {
|
||||
return false;
|
||||
}
|
||||
@@ -541,7 +557,7 @@ export class PlayerImpl implements Player {
|
||||
this.id(),
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
`Recieved ${renderTroops(troops)} troops from ${this.name()}`,
|
||||
`Received ${renderTroops(troops)} troops from ${this.name()}`,
|
||||
MessageType.SUCCESS,
|
||||
recipient.id(),
|
||||
);
|
||||
@@ -555,7 +571,7 @@ export class PlayerImpl implements Player {
|
||||
this.id(),
|
||||
);
|
||||
this.mg.displayMessage(
|
||||
`Recieved ${renderNumber(gold)} gold from ${this.name()}`,
|
||||
`Received ${renderNumber(gold)} gold from ${this.name()}`,
|
||||
MessageType.SUCCESS,
|
||||
recipient.id(),
|
||||
);
|
||||
@@ -714,7 +730,22 @@ export class PlayerImpl implements Player {
|
||||
return b;
|
||||
}
|
||||
|
||||
canBuild(unitType: UnitType, targetTile: TileRef): TileRef | false {
|
||||
public buildableUnits(tile: TileRef): BuildableUnit[] {
|
||||
const validTiles = this.validStructureSpawnTiles(tile);
|
||||
return Object.values(UnitType).map((u) => {
|
||||
return {
|
||||
type: u,
|
||||
canBuild: this.canBuild(u, tile, validTiles) != false,
|
||||
cost: this.mg.config().unitInfo(u).cost(this),
|
||||
} as BuildableUnit;
|
||||
});
|
||||
}
|
||||
|
||||
canBuild(
|
||||
unitType: UnitType,
|
||||
targetTile: TileRef,
|
||||
validTiles: TileRef[] | null = null,
|
||||
): TileRef | false {
|
||||
// prevent the building of nukes and nuke related buildings
|
||||
if (this.mg.config().disableNukes()) {
|
||||
if (
|
||||
@@ -746,7 +777,7 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.MIRVWarhead:
|
||||
return targetTile;
|
||||
case UnitType.Port:
|
||||
return this.portSpawn(targetTile);
|
||||
return this.portSpawn(targetTile, validTiles);
|
||||
case UnitType.Warship:
|
||||
return this.warshipSpawn(targetTile);
|
||||
case UnitType.Shell:
|
||||
@@ -761,7 +792,7 @@ export class PlayerImpl implements Player {
|
||||
case UnitType.SAMLauncher:
|
||||
case UnitType.City:
|
||||
case UnitType.Construction:
|
||||
return this.landBasedStructureSpawn(targetTile);
|
||||
return this.landBasedStructureSpawn(targetTile, validTiles);
|
||||
default:
|
||||
assertNever(unitType);
|
||||
}
|
||||
@@ -787,7 +818,7 @@ export class PlayerImpl implements Player {
|
||||
return spawns[0].tile();
|
||||
}
|
||||
|
||||
portSpawn(tile: TileRef): TileRef | false {
|
||||
portSpawn(tile: TileRef, validTiles: TileRef[]): TileRef | false {
|
||||
const spawns = Array.from(
|
||||
this.mg.bfs(
|
||||
tile,
|
||||
@@ -799,10 +830,15 @@ export class PlayerImpl implements Player {
|
||||
(a, b) =>
|
||||
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
|
||||
);
|
||||
if (spawns.length == 0) {
|
||||
return false;
|
||||
const validTileSet = new Set(
|
||||
validTiles ?? this.validStructureSpawnTiles(tile),
|
||||
);
|
||||
for (const t of spawns) {
|
||||
if (validTileSet.has(t)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return spawns[0];
|
||||
return false;
|
||||
}
|
||||
|
||||
warshipSpawn(tile: TileRef): TileRef | false {
|
||||
@@ -820,11 +856,54 @@ export class PlayerImpl implements Player {
|
||||
return spawns[0].tile();
|
||||
}
|
||||
|
||||
landBasedStructureSpawn(tile: TileRef): TileRef | false {
|
||||
if (this.mg.owner(tile) != this) {
|
||||
landBasedStructureSpawn(
|
||||
tile: TileRef,
|
||||
validTiles: TileRef[] | null = null,
|
||||
): TileRef | false {
|
||||
const tiles = validTiles ?? this.validStructureSpawnTiles(tile);
|
||||
if (tiles.length == 0) {
|
||||
return false;
|
||||
}
|
||||
return tile;
|
||||
return tiles[0];
|
||||
}
|
||||
|
||||
private validStructureSpawnTiles(tile: TileRef): TileRef[] {
|
||||
if (this.mg.owner(tile) != this) {
|
||||
return [];
|
||||
}
|
||||
const searchRadius = 15;
|
||||
const searchRadiusSquared = searchRadius ** 2;
|
||||
const types = Object.values(UnitType).filter((unitTypeValue) => {
|
||||
return this.mg.config().unitInfo(unitTypeValue).territoryBound;
|
||||
});
|
||||
|
||||
const nearbyUnits = this.mg
|
||||
.nearbyUnits(tile, searchRadius * 2, types)
|
||||
.map((u) => u.unit);
|
||||
const nearbyTiles = this.mg.bfs(tile, (gm, t) => {
|
||||
return (
|
||||
this.mg.euclideanDistSquared(tile, t) < searchRadiusSquared &&
|
||||
gm.ownerID(t) == this.smallID()
|
||||
);
|
||||
});
|
||||
const validSet: Set<TileRef> = new Set(nearbyTiles);
|
||||
|
||||
const minDistSquared = this.mg.config().structureMinDist() ** 2;
|
||||
for (const t of nearbyTiles) {
|
||||
for (const unit of nearbyUnits) {
|
||||
if (this.mg.euclideanDistSquared(unit.tile(), t) < minDistSquared) {
|
||||
validSet.delete(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const valid = Array.from(validSet);
|
||||
valid.sort(
|
||||
(a, b) =>
|
||||
this.mg.euclideanDistSquared(a, tile) -
|
||||
this.mg.euclideanDistSquared(b, tile),
|
||||
);
|
||||
return valid;
|
||||
}
|
||||
|
||||
transportShipSpawn(targetTile: TileRef): TileRef | false {
|
||||
@@ -878,7 +957,7 @@ export class PlayerImpl implements Player {
|
||||
return rel;
|
||||
}
|
||||
|
||||
public canBoat(tile: TileRef): boolean {
|
||||
public canBoat(tile: TileRef): TileRef | false {
|
||||
if (
|
||||
this.units(UnitType.TransportShip).length >=
|
||||
this.mg.config().boatMaxNumber()
|
||||
@@ -921,7 +1000,7 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
|
||||
return this.canBuild(UnitType.TransportShip, dst) != false;
|
||||
return this.canBuild(UnitType.TransportShip, dst);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -944,7 +1023,7 @@ export class PlayerImpl implements Player {
|
||||
|
||||
for (const t of sorted) {
|
||||
if (this.mg.owner(t) == this) {
|
||||
return this.canBuild(UnitType.TransportShip, dst) != false;
|
||||
return this.canBuild(UnitType.TransportShip, dst);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { PlayerInfo, Team } from "./Game";
|
||||
|
||||
export function assignTeams(
|
||||
players: PlayerInfo[],
|
||||
teams: Team[],
|
||||
): Map<PlayerInfo, Team | "kicked"> {
|
||||
const result = new Map<PlayerInfo, Team | "kicked">();
|
||||
let redTeamCount = 0;
|
||||
let blueTeamCount = 0;
|
||||
const teamPlayerCount = new Map<Team, number>();
|
||||
|
||||
// Group players by clan
|
||||
const clanGroups = new Map<string, PlayerInfo[]>();
|
||||
@@ -23,7 +23,7 @@ export function assignTeams(
|
||||
}
|
||||
}
|
||||
|
||||
const maxTeamSize = Math.ceil(players.length / 2);
|
||||
const maxTeamSize = Math.ceil(players.length / teams.length);
|
||||
|
||||
// Sort clans by size (largest first)
|
||||
const sortedClans = Array.from(clanGroups.entries()).sort(
|
||||
@@ -33,38 +33,38 @@ export function assignTeams(
|
||||
// First, assign clan players
|
||||
for (const [_, clanPlayers] of sortedClans) {
|
||||
// Try to keep the clan together on the team with fewer players
|
||||
if (redTeamCount <= blueTeamCount) {
|
||||
// Assign to red team
|
||||
for (const player of clanPlayers) {
|
||||
if (redTeamCount < maxTeamSize) {
|
||||
redTeamCount++;
|
||||
result.set(player, Team.Red);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Assign to blue team
|
||||
for (const player of clanPlayers) {
|
||||
if (blueTeamCount < maxTeamSize) {
|
||||
blueTeamCount++;
|
||||
result.set(player, Team.Blue);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
let team: Team | null = null;
|
||||
let teamSize = 0;
|
||||
for (const t of teams) {
|
||||
const p = teamPlayerCount.get(t) ?? 0;
|
||||
if (team !== null && teamSize <= p) continue;
|
||||
teamSize = p;
|
||||
team = t;
|
||||
}
|
||||
|
||||
for (const player of clanPlayers) {
|
||||
if (teamSize < maxTeamSize) {
|
||||
teamSize++;
|
||||
result.set(player, team);
|
||||
} else {
|
||||
result.set(player, "kicked");
|
||||
}
|
||||
}
|
||||
teamPlayerCount.set(team, teamSize);
|
||||
}
|
||||
|
||||
// Then, assign non-clan players to balance teams
|
||||
for (const player of noClanPlayers) {
|
||||
if (redTeamCount <= blueTeamCount) {
|
||||
redTeamCount++;
|
||||
result.set(player, Team.Red);
|
||||
} else {
|
||||
blueTeamCount++;
|
||||
result.set(player, Team.Blue);
|
||||
let team: Team | null = null;
|
||||
let teamSize = 0;
|
||||
for (const t of teams) {
|
||||
const p = teamPlayerCount.get(t) ?? 0;
|
||||
if (team !== null && teamSize <= p) continue;
|
||||
teamSize = p;
|
||||
team = t;
|
||||
}
|
||||
teamPlayerCount.set(team, teamSize + 1);
|
||||
result.set(player, team);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -41,6 +41,7 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.Japan]: "Japan",
|
||||
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
|
||||
[GameMapType.KnownWorld]: "KnownWorld",
|
||||
[GameMapType.FaroeIslands]: "FaroeIslands",
|
||||
};
|
||||
|
||||
class GameMapLoader {
|
||||
|
||||
@@ -17,11 +17,11 @@ export class UnitImpl implements Unit {
|
||||
private _active = true;
|
||||
private _health: bigint;
|
||||
private _lastTile: TileRef = null;
|
||||
// Currently only warship use it
|
||||
private _target: Unit = null;
|
||||
private _moveTarget: TileRef = null;
|
||||
private _targetedBySAM = false;
|
||||
|
||||
private _safeFromPiratesCooldown: number; // Only for trade ships
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType = undefined;
|
||||
|
||||
private _cooldownTick: Tick | null = null;
|
||||
@@ -45,6 +45,10 @@ export class UnitImpl implements Unit {
|
||||
this._detonationDst = unitsSpecificInfos.detonationDst;
|
||||
this._warshipTarget = unitsSpecificInfos.warshipTarget;
|
||||
this._cooldownDuration = unitsSpecificInfos.cooldownDuration;
|
||||
this._lastSetSafeFromPirates = unitsSpecificInfos.lastSetSafeFromPirates;
|
||||
this._safeFromPiratesCooldown = this.mg
|
||||
.config()
|
||||
.safeFromPiratesCooldownMax();
|
||||
}
|
||||
|
||||
id() {
|
||||
@@ -141,7 +145,7 @@ export class UnitImpl implements Unit {
|
||||
this._active = false;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
this.mg.removeUnit(this);
|
||||
if (displayMessage) {
|
||||
if (displayMessage && this.type() != UnitType.MIRVWarhead) {
|
||||
this.mg.displayMessage(
|
||||
`Your ${this.type()} was destroyed`,
|
||||
MessageType.ERROR,
|
||||
@@ -233,4 +237,15 @@ export class UnitImpl implements Unit {
|
||||
targetedBySAM(): boolean {
|
||||
return this._targetedBySAM;
|
||||
}
|
||||
|
||||
setSafeFromPirates(): void {
|
||||
this._lastSetSafeFromPirates = this.mg.ticks();
|
||||
}
|
||||
|
||||
isSafeFromPirates(): boolean {
|
||||
return (
|
||||
this.mg.ticks() - this._lastSetSafeFromPirates <
|
||||
this._safeFromPiratesCooldown
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const maps = [
|
||||
"BetweenTwoSeas",
|
||||
"Japan",
|
||||
"KnownWorld",
|
||||
"FaroeIslands",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
+59
-36
@@ -24,7 +24,6 @@ import { GameType } from "../core/game/Game";
|
||||
import { archive } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { gatekeeper } from "./Gatekeeper";
|
||||
import { slog } from "./StructuredLog";
|
||||
export enum GamePhase {
|
||||
Lobby = "LOBBY",
|
||||
Active = "ACTIVE",
|
||||
@@ -99,19 +98,11 @@ export class GameServer {
|
||||
}
|
||||
|
||||
public addClient(client: Client, lastTurn: number) {
|
||||
this.log.info(`adding client ${client.clientID}`);
|
||||
slog({
|
||||
logKey: "client_joined_game",
|
||||
msg: `client ${client.clientID} (re)joining game ${this.id}`,
|
||||
data: {
|
||||
clientID: client.clientID,
|
||||
clientIP: client.ip,
|
||||
gameID: this.id,
|
||||
isRejoin: lastTurn > 0,
|
||||
},
|
||||
this.log.info("client (re)joining game", {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
gameID: this.id,
|
||||
clientIP: client.ip,
|
||||
isRejoin: lastTurn > 0,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -120,9 +111,10 @@ export class GameServer {
|
||||
(c) => c.ip == client.ip && c.clientID != client.clientID,
|
||||
).length >= 3
|
||||
) {
|
||||
this.log.info(
|
||||
`cannot add client ${client.clientID}, already have 3 ips (${client.ip})`,
|
||||
);
|
||||
this.log.warn("cannot add client, already have 3 ips", {
|
||||
clientID: client.clientID,
|
||||
clientIP: client.ip,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,6 +148,10 @@ export class GameServer {
|
||||
if (client.persistentID != clientMsg.persistentID) {
|
||||
this.log.warn(
|
||||
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${client.persistentID}`,
|
||||
{
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: clientMsg.persistentID,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -168,9 +164,10 @@ export class GameServer {
|
||||
if (clientMsg.gameID == this.id) {
|
||||
this.addIntent(clientMsg.intent);
|
||||
} else {
|
||||
this.log.warn(
|
||||
`${this.id}: client ${clientMsg.clientID} sent to wrong game`,
|
||||
);
|
||||
this.log.warn("client sent to wrong game", {
|
||||
clientID: clientMsg.clientID,
|
||||
persistentID: clientMsg.persistentID,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (clientMsg.type == "ping") {
|
||||
@@ -187,12 +184,18 @@ export class GameServer {
|
||||
} catch (error) {
|
||||
this.log.info(
|
||||
`error handline websocket request in game server: ${error}`,
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
client.ws.on("close", () => {
|
||||
this.log.info(`${this.id}: client ${client.clientID} disconnected`);
|
||||
this.log.info("client disconnected", {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
});
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID != client.clientID,
|
||||
);
|
||||
@@ -245,7 +248,10 @@ export class GameServer {
|
||||
|
||||
const msg = JSON.stringify(prestartMsg.data);
|
||||
this.activeClients.forEach((c) => {
|
||||
this.log.info(`${this.id}: sending prestart message to ${c.clientID}`);
|
||||
this.log.info("sending prestart message", {
|
||||
clientID: c.clientID,
|
||||
persistentID: c.persistentID,
|
||||
});
|
||||
c.ws.send(msg);
|
||||
});
|
||||
}
|
||||
@@ -276,7 +282,10 @@ export class GameServer {
|
||||
this.config.turnIntervalMs(),
|
||||
);
|
||||
this.activeClients.forEach((c) => {
|
||||
this.log.info(`${this.id}: sending start message to ${c.clientID}`);
|
||||
this.log.info("sending start message", {
|
||||
clientID: c.clientID,
|
||||
persistentID: c.persistentID,
|
||||
});
|
||||
this.sendStartGameMsg(c.ws, 0);
|
||||
});
|
||||
}
|
||||
@@ -327,10 +336,8 @@ export class GameServer {
|
||||
);
|
||||
} catch (error) {
|
||||
this.log.info(
|
||||
`error sending message for game ${this.id}, error ${error}`.substring(
|
||||
0,
|
||||
250,
|
||||
),
|
||||
`error sending message for game: ${error.substring(0, 250)}`,
|
||||
{},
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -349,9 +356,11 @@ export class GameServer {
|
||||
client.ws.close(1000, "game has ended");
|
||||
}
|
||||
});
|
||||
this.log.info(
|
||||
`${this.id}: ending game ${this.id} with ${this.turns.length} turns`,
|
||||
);
|
||||
if (!this._hasPrestarted || !this._hasStarted) {
|
||||
this.log.info(`game not started, not archiving game`);
|
||||
return;
|
||||
}
|
||||
this.log.info(`ending game with ${this.turns.length} turns`);
|
||||
try {
|
||||
if (this.allClients.size > 0) {
|
||||
const playerRecords: PlayerRecord[] = Array.from(
|
||||
@@ -376,7 +385,9 @@ export class GameServer {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.log.info(`${this.id}: no clients joined, not archiving game`);
|
||||
this.log.info("no clients joined, not archiving game", {
|
||||
gameID: this.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
let errorDetails;
|
||||
@@ -408,9 +419,10 @@ export class GameServer {
|
||||
const alive = [];
|
||||
for (const client of this.activeClients) {
|
||||
if (now - client.lastPing > 60_000) {
|
||||
this.log.info(
|
||||
`${this.id}: no pings from ${client.clientID}, terminating connection`,
|
||||
);
|
||||
this.log.info("no pings received, terminating connection", {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
});
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.close(1000, "no heartbeats received, closing connection");
|
||||
}
|
||||
@@ -420,7 +432,9 @@ export class GameServer {
|
||||
}
|
||||
this.activeClients = alive;
|
||||
if (now > this.createdAt + this.maxGameDuration) {
|
||||
this.log.warn(`${this.id}: game past max duration ${this.id}`);
|
||||
this.log.warn("game past max duration", {
|
||||
gameID: this.id,
|
||||
});
|
||||
return GamePhase.Finished;
|
||||
}
|
||||
|
||||
@@ -430,7 +444,9 @@ export class GameServer {
|
||||
if (this.gameConfig.gameType != GameType.Public) {
|
||||
if (this._hasStarted) {
|
||||
if (noActive && noRecentPings) {
|
||||
this.log.info(`${this.id}: private game: ${this.id} complete`);
|
||||
this.log.info("private game complete", {
|
||||
gameID: this.id,
|
||||
});
|
||||
return GamePhase.Finished;
|
||||
} else {
|
||||
return GamePhase.Active;
|
||||
@@ -508,7 +524,10 @@ export class GameServer {
|
||||
totalActiveClients: this.activeClients.length,
|
||||
});
|
||||
if (!serverDesync.success) {
|
||||
this.log.warn(`failed to create desync message ${serverDesync.error}`);
|
||||
this.log.warn("failed to create desync message", {
|
||||
gameID: this.id,
|
||||
error: serverDesync.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -518,7 +537,11 @@ export class GameServer {
|
||||
continue;
|
||||
}
|
||||
this.sentDesyncMessageClients.add(c.clientID);
|
||||
this.log.info(`game: ${this.id}: sending desync to client ${c.clientID}`);
|
||||
this.log.info("sending desync to client", {
|
||||
gameID: this.id,
|
||||
clientID: c.clientID,
|
||||
persistentID: c.persistentID,
|
||||
});
|
||||
c.ws.send(desyncMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameMapType, GameMode } from "../core/game/Game";
|
||||
import { PseudoRandom } from "../core/PseudoRandom";
|
||||
|
||||
enum PlaylistType {
|
||||
@@ -9,6 +9,9 @@ enum PlaylistType {
|
||||
const random = new PseudoRandom(123);
|
||||
|
||||
export class MapPlaylist {
|
||||
private gameModeRotation = [GameMode.FFA, GameMode.FFA, GameMode.Team];
|
||||
private currentGameModeIndex = 0;
|
||||
|
||||
private mapsPlaylistBig: GameMapType[] = [];
|
||||
private mapsPlaylistSmall: GameMapType[] = [];
|
||||
private currentPlaylistCounter = 0;
|
||||
@@ -20,6 +23,13 @@ export class MapPlaylist {
|
||||
return mapsPlaylist.shift()!;
|
||||
}
|
||||
|
||||
public getNextGameMode(): GameMode {
|
||||
const nextGameMode = this.gameModeRotation[this.currentGameModeIndex];
|
||||
this.currentGameModeIndex =
|
||||
(this.currentGameModeIndex + 1) % this.gameModeRotation.length;
|
||||
return nextGameMode;
|
||||
}
|
||||
|
||||
private getNextMapsPlayList(playlistType: PlaylistType): GameMapType[] {
|
||||
switch (playlistType) {
|
||||
case PlaylistType.BigMaps:
|
||||
@@ -71,26 +81,27 @@ export class MapPlaylist {
|
||||
// Big Maps are those larger than ~2.5 mil pixels
|
||||
case PlaylistType.BigMaps:
|
||||
return {
|
||||
Europe: 3,
|
||||
NorthAmerica: 2,
|
||||
Europe: 2,
|
||||
NorthAmerica: 1,
|
||||
Africa: 2,
|
||||
Britannia: 1,
|
||||
GatewayToTheAtlantic: 2,
|
||||
Australia: 1,
|
||||
Iceland: 1,
|
||||
SouthAmerica: 3,
|
||||
Australia: 2,
|
||||
Iceland: 2,
|
||||
SouthAmerica: 1,
|
||||
KnownWorld: 2,
|
||||
};
|
||||
case PlaylistType.SmallMaps:
|
||||
return {
|
||||
World: 1,
|
||||
World: 4,
|
||||
Mena: 2,
|
||||
Pangaea: 1,
|
||||
Asia: 1,
|
||||
Mars: 1,
|
||||
BetweenTwoSeas: 3,
|
||||
Japan: 3,
|
||||
BetweenTwoSeas: 2,
|
||||
Japan: 2,
|
||||
BlackSea: 1,
|
||||
FaroeIslands: 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+19
-5
@@ -24,7 +24,7 @@ const server = http.createServer(app);
|
||||
const metricsApp = express();
|
||||
const metricsServer = http.createServer(metricsApp);
|
||||
|
||||
const log = logger.child({ component: "Master" });
|
||||
const log = logger.child({ comp: "m" });
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -222,13 +222,26 @@ async function fetchLobbies(): Promise<number> {
|
||||
return publicLobbyIDs.size;
|
||||
}
|
||||
|
||||
let lastGameMode: GameMode = GameMode.FFA;
|
||||
|
||||
// Function to schedule a new public game
|
||||
async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
const gameID = generateID();
|
||||
const map = playlist.getNextMap();
|
||||
publicLobbyIDs.add(gameID);
|
||||
|
||||
if (lastGameMode == GameMode.FFA) {
|
||||
lastGameMode = GameMode.Team;
|
||||
} else {
|
||||
lastGameMode = GameMode.FFA;
|
||||
}
|
||||
|
||||
const gameMode = playlist.getNextGameMode();
|
||||
const numPlayerTeams =
|
||||
gameMode === GameMode.Team ? 2 + Math.floor(Math.random() * 5) : undefined;
|
||||
|
||||
// Create the default public game config (from your GameManager)
|
||||
const defaultGameConfig = {
|
||||
const defaultGameConfig: GameConfig = {
|
||||
gameMap: map,
|
||||
maxPlayers: config.lobbyMaxPlayers(map),
|
||||
gameType: GameType.Public,
|
||||
@@ -236,11 +249,12 @@ async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
disableNPCs: false,
|
||||
disableNPCs: gameMode == GameMode.Team,
|
||||
disableNukes: false,
|
||||
gameMode: Math.random() < 0.5 ? GameMode.FFA : GameMode.Team,
|
||||
gameMode,
|
||||
numPlayerTeams,
|
||||
bots: 400,
|
||||
} as GameConfig;
|
||||
};
|
||||
|
||||
const workerPath = config.workerPath(gameID);
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ClientID, GameID, LogSeverity } from "../core/Schemas";
|
||||
|
||||
export interface slogMsg {
|
||||
logKey: string;
|
||||
msg: string;
|
||||
data?: {
|
||||
stack?: unknown;
|
||||
clientID?: unknown;
|
||||
clientIP?: unknown;
|
||||
gameID?: unknown;
|
||||
isRejoin?: unknown;
|
||||
};
|
||||
severity?: LogSeverity;
|
||||
gameID?: GameID;
|
||||
clientID?: ClientID;
|
||||
persistentID?: string;
|
||||
stack?: string; // Added stack property
|
||||
}
|
||||
|
||||
export function slog(msg: slogMsg): void {
|
||||
msg.severity = msg.severity ?? LogSeverity.Info;
|
||||
|
||||
// Format stack trace if available
|
||||
if (msg.stack) {
|
||||
// Keep the stack trace in the log data
|
||||
if (!msg.data) {
|
||||
msg.data = { stack: msg.stack };
|
||||
} else if (typeof msg.data === "object") {
|
||||
msg.data.stack = msg.stack;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.GAME_ENV == "dev") {
|
||||
// Avoid blowing up the log during development.
|
||||
if (msg.logKey == "client_console_log") {
|
||||
return;
|
||||
}
|
||||
if (msg.severity != LogSeverity.Debug) {
|
||||
console.log(msg.msg);
|
||||
// Print stack trace in development for errors
|
||||
if (msg.severity === LogSeverity.Error && msg.stack) {
|
||||
console.error(msg.stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
console.log(JSON.stringify(msg));
|
||||
} catch (error) {
|
||||
console.error("Failed to stringify log message:", error);
|
||||
// Fallback to basic logging
|
||||
console.log(`${msg.severity}: ${msg.msg}`);
|
||||
if (msg.severity === LogSeverity.Error && msg.stack) {
|
||||
console.error(msg.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-33
@@ -7,25 +7,22 @@ import { WebSocket, WebSocketServer } from "ws";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
|
||||
import { GameConfig, GameRecord } from "../core/Schemas";
|
||||
import { archive, readGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { logger } from "./Logger";
|
||||
import { slog } from "./StructuredLog";
|
||||
import { metrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
let log = logger.child({ component: "Worker" });
|
||||
const workerId = parseInt(process.env.WORKER_ID || "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
|
||||
// Worker setup
|
||||
export function startWorker() {
|
||||
// Get worker ID from environment variable
|
||||
const workerId = parseInt(process.env.WORKER_ID || "0");
|
||||
log = log.child({ workerId: workerId });
|
||||
log.info(`Worker ${workerId} starting...`);
|
||||
log.info(`Worker starting...`);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -342,7 +339,7 @@ export function startWorker() {
|
||||
// The load balancer will handle routing to this server based on path
|
||||
const PORT = config.workerPortByIndex(workerId);
|
||||
server.listen(PORT, () => {
|
||||
log.info(`Worker ${workerId} running on http://localhost:${PORT}`);
|
||||
log.info(`running on http://localhost:${PORT}`);
|
||||
log.info(`Handling requests with path prefix /w${workerId}/`);
|
||||
// Signal to the master process that this worker is ready
|
||||
if (process.send) {
|
||||
@@ -350,44 +347,22 @@ export function startWorker() {
|
||||
type: "WORKER_READY",
|
||||
workerId: workerId,
|
||||
});
|
||||
log.info(`Worker ${workerId} signaled ready state to master`);
|
||||
log.info(`signaled ready state to master`);
|
||||
}
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
log.error(`Error in ${req.method} ${req.path}:`, err);
|
||||
slog({
|
||||
logKey: "server_error",
|
||||
msg: `Unhandled exception in ${req.method} ${req.path}: ${err.message}`,
|
||||
severity: LogSeverity.Error,
|
||||
stack: err.stack,
|
||||
});
|
||||
res.status(500).json({ error: "An unexpected error occurred" });
|
||||
});
|
||||
|
||||
// Process-level error handlers
|
||||
process.on("uncaughtException", (err) => {
|
||||
log.error(`Worker ${workerId} uncaught exception:`, err);
|
||||
slog({
|
||||
logKey: "uncaught_exception",
|
||||
msg: `Worker ${workerId} uncaught exception: ${err.message}`,
|
||||
severity: LogSeverity.Error,
|
||||
stack: err.stack,
|
||||
});
|
||||
log.error(`uncaught exception:`, err);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
log.error(
|
||||
`Worker ${workerId} unhandled rejection at:`,
|
||||
promise,
|
||||
"reason:",
|
||||
reason,
|
||||
);
|
||||
slog({
|
||||
logKey: "unhandled_rejection",
|
||||
msg: `Worker ${workerId} unhandled promise rejection: ${reason}`,
|
||||
severity: LogSeverity.Error,
|
||||
});
|
||||
log.error(`unhandled rejection at:`, promise, "reason:", reason);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user