Merge branch 'main' into patterned-territory

This commit is contained in:
Aotumuri
2025-05-29 06:22:10 +09:00
committed by GitHub
119 changed files with 4787 additions and 1589 deletions
+29 -27
View File
@@ -1,3 +1,4 @@
import { translateText } from "../client/Utils";
import { consolex, initRemoteSender } from "../core/Consolex";
import { EventBus } from "../core/EventBus";
import {
@@ -7,11 +8,12 @@ import {
GameStartInfo,
PlayerRecord,
ServerMessage,
Winner,
} from "../core/Schemas";
import { createGameRecord } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { Cell, Team, UnitType } from "../core/game/Game";
import { Cell, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import {
ErrorUpdate,
@@ -26,7 +28,7 @@ import { UserSettings } from "../core/game/UserSettings";
import { WorkerClient } from "../core/worker/WorkerClient";
import { InputHandler, MouseMoveEvent, MouseUpEvent } from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentIDFromCookie } from "./Main";
import { getPersistentID } from "./Main";
import {
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
@@ -187,25 +189,26 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private getWinner(update: WinUpdate): Winner {
if (update.winner[0] !== "player") return update.winner;
const clientId = this.gameView.playerBySmallID(update.winner[1]).clientID();
if (clientId === null) return;
return ["player", clientId];
}
private saveGame(update: WinUpdate) {
if (this.myPlayer === null) throw new Error("Not initialized");
if (this.myPlayer === null) {
return;
}
const players: PlayerRecord[] = [
{
playerID: this.myPlayer.id(),
persistentID: getPersistentIDFromCookie(),
persistentID: getPersistentID(),
username: this.lobby.playerName,
clientID: this.lobby.clientID,
stats: update.allPlayersStats[this.lobby.clientID],
},
];
let winner: ClientID | Team | null = null;
if (update.winnerType === "player") {
winner = this.gameView
.playerBySmallID(update.winner as number)
.clientID();
} else {
winner = update.winner as Team;
}
const winner = this.getWinner(update);
if (this.lobby.gameStartInfo === undefined) {
throw new Error("missing gameStartInfo");
@@ -219,7 +222,6 @@ export class ClientGameRunner {
startTime(),
Date.now(),
winner,
update.winnerType,
);
endGame(record);
}
@@ -307,7 +309,7 @@ export class ClientGameRunner {
this.lobby.gameStartInfo.gameID,
this.lobby.clientID,
true,
"You are desynced from other players. What you see might differ from other players.",
translateText("error_modal.desync_notice"),
);
}
if (message.type === "turn") {
@@ -496,7 +498,7 @@ function showErrorModal(
gameID: GameID,
clientID: ClientID,
closable = false,
heading = "Game crashed!",
heading = translateText("error_modal.crashed"),
) {
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
@@ -505,37 +507,37 @@ function showErrorModal(
}
const modal = document.createElement("div");
const content = `${heading}\n game id: ${gameID}, client id: ${clientID}\nPlease paste the following in your bug report in Discord:\n${errorText}`;
modal.id = "error-modal";
const content = `${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${translateText("error_modal.paste_discord")}\n${errorText}`;
// Create elements
const pre = document.createElement("pre");
pre.textContent = content;
const button = document.createElement("button");
button.textContent = "Copy to clipboard";
button.style.cssText =
"padding: 8px 16px; margin-top: 10px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;";
button.textContent = translateText("error_modal.copy_clipboard");
button.className = "copy-btn";
button.addEventListener("click", () => {
navigator.clipboard
.writeText(content)
.then(() => (button.textContent = "Copied!"))
.catch(() => (button.textContent = "Failed to copy"));
.then(() => (button.textContent = translateText("error_modal.copied")))
.catch(
() => (button.textContent = translateText("error_modal.failed_copy")),
);
});
const closeButton = document.createElement("button");
closeButton.textContent = "X";
closeButton.style.cssText =
"color: white;top: 0px;right: 0px;cursor: pointer;background: red;margin-right: 0px;position: fixed;width: 40px;";
closeButton.className = "close-btn";
closeButton.addEventListener("click", () => {
modal.style.display = "none";
});
// Add to modal
modal.style.cssText =
"position:fixed; padding:20px; background:white; border:1px solid black; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9999;";
modal.appendChild(pre);
modal.appendChild(button);
modal.id = "error-modal";
if (closable) {
modal.appendChild(closeButton);
}
+4
View File
@@ -5,11 +5,13 @@ import "./LanguageModal";
import ar from "../../resources/lang/ar.json";
import bg from "../../resources/lang/bg.json";
import bn from "../../resources/lang/bn.json";
import cs from "../../resources/lang/cs.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 he from "../../resources/lang/he.json";
import hi from "../../resources/lang/hi.json";
import it from "../../resources/lang/it.json";
import ja from "../../resources/lang/ja.json";
@@ -53,6 +55,8 @@ export class LangSelector extends LitElement {
tr,
tp,
uk,
cs,
he,
};
createRenderRoot() {
+2 -1
View File
@@ -1,5 +1,6 @@
import { consolex } from "../core/Consolex";
import { GameConfig, GameID, GameRecord } from "../core/Schemas";
import { replacer } from "../core/Util";
export interface LocalStatsData {
[key: GameID]: {
@@ -19,7 +20,7 @@ function getStats(): LocalStatsData {
function save(stats: LocalStatsData) {
// To execute asynchronously
setTimeout(
() => localStorage.setItem("game-records", JSON.stringify(stats)),
() => localStorage.setItem("game-records", JSON.stringify(stats, replacer)),
0,
);
}
+10 -9
View File
@@ -11,9 +11,9 @@ import {
ServerStartGameMessageSchema,
Turn,
} from "../core/Schemas";
import { createGameRecord, decompressGameRecord } from "../core/Util";
import { createGameRecord, decompressGameRecord, replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { getPersistentIDFromCookie } from "./Main";
import { getPersistentID } from "./Main";
export class LocalServer {
// All turns from the game record on replay.
@@ -176,8 +176,7 @@ export class LocalServer {
}
const players: PlayerRecord[] = [
{
playerID: this.lobbyConfig.clientID, // hack?
persistentID: getPersistentIDFromCookie(),
persistentID: getPersistentID(),
username: this.lobbyConfig.playerName,
clientID: this.lobbyConfig.clientID,
stats: this.allPlayersStats[this.lobbyConfig.clientID],
@@ -193,17 +192,19 @@ export class LocalServer {
this.turns,
this.startedAt,
Date.now(),
this.winner?.winner ?? null,
this.winner?.winnerType ?? null,
this.winner?.winner,
);
if (!saveFullGame) {
// Clear turns because beacon only supports up to 64kb
record.turns = [];
}
// For unload events, sendBeacon is the only reliable method
const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], {
type: "application/json",
});
const blob = new Blob(
[JSON.stringify(GameRecordSchema.parse(record), replacer)],
{
type: "application/json",
},
);
const workerPath = this.lobbyConfig.serverConfig.workerPath(
this.lobbyConfig.gameStartInfo.gameID,
);
+20 -10
View File
@@ -172,17 +172,16 @@ class Client {
TerritoryModal.open();
});
const claims = isLoggedIn();
if (claims === false) {
if (isLoggedIn() === false) {
// Not logged in
loginDiscordButton.disable = false;
loginDiscordButton.translationKey = "main.login_discord";
loginDiscordButton.addEventListener("click", discordLogin);
logoutDiscordButton.hidden = true;
} else {
// JWT appears to be valid, assume we are logged in
// JWT appears to be valid
loginDiscordButton.disable = true;
loginDiscordButton.translationKey = "main.logged_in";
loginDiscordButton.translationKey = "main.checking_login";
logoutDiscordButton.hidden = false;
logoutDiscordButton.addEventListener("click", () => {
// Log out
@@ -204,6 +203,8 @@ class Client {
return;
}
// TODO: Update the page for logged in user
loginDiscordButton.translationKey = "main.logged_in";
const { user, player } = userMeResponse;
});
}
@@ -303,7 +304,7 @@ class Client {
? ""
: this.flagInput.getCurrentFlag(),
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: localStorage.getItem("token") ?? getPersistentIDFromCookie(),
token: getPlayToken(),
clientID: lobby.clientID,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -382,12 +383,21 @@ function setFavicon(): void {
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentIDFromCookie(): string {
const claims = isLoggedIn();
if (claims !== false && claims.sub) {
return claims.sub;
}
function getPlayToken(): string {
const result = isLoggedIn();
if (result !== false) return result.token;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
export function getPersistentID(): string {
const result = isLoggedIn();
if (result !== false) return result.claims.sub;
return getPersistentIDFromCookie();
}
// WARNING: DO NOT EXPOSE THIS ID
function getPersistentIDFromCookie(): string {
const COOKIE_NAME = "player_persistent_id";
// Try to get existing cookie
+34 -31
View File
@@ -100,47 +100,50 @@ export class PublicLobby extends LitElement {
<button
@click=${() => this.lobbyClicked(lobby)}
?disabled=${this.isButtonDebounced}
class="w-full mx-auto p-4 md:p-6 ${this.isLobbyHighlighted
class="isolate grid h-40 grid-cols-[100%] grid-rows-[100%] place-content-stretch w-full overflow-hidden ${this
.isLobbyHighlighted
? "bg-gradient-to-r from-green-600 to-green-500"
: "bg-gradient-to-r from-blue-600 to-blue-500"} text-white font-medium rounded-xl transition-opacity duration-200 hover:opacity-90 ${this
.isButtonDebounced
? "opacity-70 cursor-not-allowed"
: ""}"
>
<div class="text-lg md:text-2xl font-semibold mb-2">
${translateText("public_lobby.join")}
</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
alt="${lobby.gameConfig.gameMap}"
class="w-1/3 md:w-1/5 md:h-[80px]"
style="border: 1px solid rgba(255, 255, 255, 0.5)"
/>
<div
class="w-full flex flex-col md:flex-row items-center justify-center md:justify-evenly"
>
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-4">
<!-- ${lobby.gameConfig.gameMap} -->
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}
</div>
<div class="text-md font-medium text-blue-100">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
alt="${lobby.gameConfig.gameMap}"
class="place-self-start col-span-full row-span-full h-full -z-10"
style="mask-image: linear-gradient(to left, transparent, #fff)"
/>
<div
class="flex flex-col justify-between h-full col-span-full row-span-full p-4 md:p-6 text-right z-0"
>
<div>
<div class="text-lg md:text-2xl font-semibold">
${translateText("public_lobby.join")}
</div>
<div class="text-md font-medium text-blue-100">
<span
class="text-sm ${this.isLobbyHighlighted
? "text-green-600"
: "text-blue-600"} bg-white rounded-sm px-1"
>
${lobby.gameConfig.gameMode === GameMode.Team
? translateText("public_lobby.teams", { num: teamCount ?? 0 })
: translateText("game_mode.ffa")}
</div>
: translateText("game_mode.ffa")}</span
>
<span
>${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}</span
>
</div>
<div class="flex flex-col items-center">
<div class="text-md font-medium text-blue-100 mb-4">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">
${timeDisplay}
</div>
</div>
<div>
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
</div>
<div class="text-md font-medium text-blue-100">${timeDisplay}</div>
</div>
</div>
</button>
-1
View File
@@ -435,7 +435,6 @@ export class SinglePlayerModal extends LitElement {
gameID: gameID,
players: [
{
playerID: generateID(),
clientID,
username: usernameInput.getCurrentUsername(),
flag:
+4 -6
View File
@@ -6,7 +6,6 @@ import {
GameType,
PlayerID,
PlayerType,
Team,
Tick,
UnitType,
} from "../core/game/Game";
@@ -14,7 +13,6 @@ import { PlayerView } from "../core/game/GameView";
import {
AllPlayersStats,
ClientHashMessage,
ClientID,
ClientIntentMessage,
ClientJoinMessage,
ClientLogMessage,
@@ -23,7 +21,9 @@ import {
Intent,
ServerMessage,
ServerMessageSchema,
Winner,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { LobbyConfig } from "./ClientGameRunner";
import { LocalServer } from "./LocalServer";
@@ -142,9 +142,8 @@ export class SendSetTargetTroopRatioEvent implements GameEvent {
export class SendWinnerEvent implements GameEvent {
constructor(
public readonly winner: ClientID | Team,
public readonly winner: Winner,
public readonly allPlayersStats: AllPlayersStats,
public readonly winnerType: "player" | "team",
) {}
}
export class SendHashEvent implements GameEvent {
@@ -539,9 +538,8 @@ export class Transport {
type: "winner",
winner: event.winner,
allPlayersStats: event.allPlayersStats,
winnerType: event.winnerType,
} satisfies ClientSendWinnerMessage;
this.sendMsg(JSON.stringify(msg));
this.sendMsg(JSON.stringify(msg, replacer));
} else {
console.log(
"WebSocket is not open. Current state:",
+1 -1
View File
@@ -47,7 +47,7 @@ export class UsernameInput extends LitElement {
/>
${this.validationError
? html`<div
class="mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
class="absolute w-full mt-2 px-3 py-1 text-lg border rounded bg-white text-red-600 border-red-600 dark:bg-gray-700 dark:text-red-300 dark:border-red-300"
>
${this.validationError}
</div>`
+1 -1
View File
@@ -6,6 +6,7 @@ import { getMapsImage } from "../utilities/Maps";
// Add map descriptions
export const MapDescription: Record<keyof typeof GameMapType, string> = {
World: "World",
WorldMapGiant: "Giant World Map",
Europe: "Europe",
EuropeClassic: "Europe Classic",
Mena: "MENA",
@@ -23,7 +24,6 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
Iceland: "Iceland",
Japan: "Japan",
BetweenTwoSeas: "Between Two Seas",
KnownWorld: "Known World",
FaroeIslands: "Faroe Islands",
DeglaciatedAntarctica: "Deglaciated Antarctica",
FalklandIslands: "Falkland Islands",
+11 -36
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import megaphone from "../../../resources/images/Megaphone.svg";
import { NewsModal } from "../NewsModal";
@@ -9,38 +9,6 @@ export class NewsButton extends LitElement {
@property({ type: Boolean })
hidden = false;
static styles = css`
.news-button {
opacity: 0.75;
transition: opacity 0.2s ease;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
}
.news-button:hover {
opacity: 1;
}
.news-button img {
width: 24px;
height: 24px;
display: block;
margin-left: 12px;
}
.hidden {
display: none !important;
}
`;
private handleClick() {
const newsModal = document.querySelector("news-modal") as NewsModal;
if (newsModal) {
@@ -50,9 +18,16 @@ export class NewsButton extends LitElement {
render() {
return html`
<div class="text-center mb-0.5 ${this.hidden ? "hidden" : ""}">
<button class="news-button" @click=${this.handleClick}>
<img src="${megaphone}" alt=${translateText("news.title")} />
<div class="flex relative ${this.hidden ? "parent-hidden" : ""}">
<button
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
@click=${this.handleClick}
>
<img
class="size-[48px]"
src="${megaphone}"
alt=${translateText("news.title")}
/>
</button>
</div>
`;
+2 -2
View File
@@ -24,7 +24,7 @@
"name": "Afghanistan"
},
{
"code": "african union",
"code": "African union",
"continent": "Africa",
"name": "African Union"
},
@@ -300,7 +300,7 @@
"name": "British Indian Ocean Territory"
},
{
"code": "Brittany",
"code": "brittany",
"continent": "Europe",
"name": "Brittany"
},
+8 -1
View File
@@ -9,7 +9,7 @@ export class AnimatedSprite {
private frameWidth: number,
private frameCount: number,
private frameDuration: number, // in milliseconds
private looping: boolean = true,
private looping: boolean = false,
private originX: number,
private originY: number,
) {
@@ -42,6 +42,13 @@ export class AnimatedSprite {
return this.active;
}
lifeTime(): number | undefined {
if (this.looping) {
return undefined;
}
return this.frameDuration * this.frameCount;
}
draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
const drawX = x - this.originX;
const drawY = y - this.originY;
+177 -42
View File
@@ -1,7 +1,17 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
import miniFire from "../../../resources/sprites/minifire.png";
import nuke from "../../../resources/sprites/nukeExplosion.png";
import SAMExplosion from "../../../resources/sprites/samExplosion.png";
import sinkingShip from "../../../resources/sprites/sinkingShip.png";
import miniSmoke from "../../../resources/sprites/smoke.png";
import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png";
import unitExplosion from "../../../resources/sprites/unitExplosion.png";
import { Theme } from "../../core/configuration/Config";
import { PlayerView } from "../../core/game/GameView";
import { AnimatedSprite } from "./AnimatedSprite";
import { FxType } from "./fx/Fx";
import { colorizeCanvas } from "./SpriteLoader";
type AnimatedSpriteConfig = {
url: string;
@@ -14,6 +24,69 @@ type AnimatedSpriteConfig = {
};
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.MiniFire]: {
url: miniFire,
frameWidth: 7,
frameCount: 6,
frameDuration: 100,
looping: true,
originX: 3,
originY: 11,
},
[FxType.MiniSmoke]: {
url: miniSmoke,
frameWidth: 11,
frameCount: 4,
frameDuration: 120,
looping: true,
originX: 2,
originY: 10,
},
[FxType.MiniBigSmoke]: {
url: miniBigSmoke,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniSmokeAndFire]: {
url: miniSmokeAndFire,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
originX: 9,
originY: 14,
},
[FxType.MiniExplosion]: {
url: miniExplosion,
frameWidth: 13,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 6,
originY: 6,
},
[FxType.UnitExplosion]: {
url: unitExplosion,
frameWidth: 19,
frameCount: 4,
frameDuration: 70,
looping: false,
originX: 9,
originY: 9,
},
[FxType.SinkingShip]: {
url: sinkingShip,
frameWidth: 16,
frameCount: 14,
frameDuration: 90,
looping: false,
originX: 7,
originY: 7,
},
[FxType.Nuke]: {
url: nuke,
frameWidth: 60,
@@ -34,53 +107,115 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
};
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
export class AnimatedSpriteLoader {
private animatedSpriteImageMap: Map<FxType, HTMLCanvasElement> = new Map();
// Do not color the same sprite twice
private coloredAnimatedSpriteCache: Map<string, HTMLCanvasElement> =
new Map();
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
public async loadAllAnimatedSpriteImages(): Promise<void> {
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
await Promise.all(
entries.map(async ([fxType, config]) => {
const typedFxType = fxType as FxType;
if (!config?.url) return;
await Promise.all(
entries.map(async ([fxType, config]) => {
const typedFxType = fxType as FxType;
if (!config?.url) return;
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = config.url;
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = config.url;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(e);
});
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = (e) => reject(e);
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext("2d")!.drawImage(img, 0, 0);
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext("2d")!.drawImage(img, 0, 0);
animatedSpriteImageMap.set(typedFxType, canvas);
} catch (err) {
console.error(`Failed to load sprite for ${typedFxType}:`, err);
}
}),
);
};
this.animatedSpriteImageMap.set(typedFxType, canvas);
} catch (err) {
console.error(`Failed to load sprite for ${typedFxType}:`, err);
}
}),
);
}
export const createAnimatedSpriteForUnit = (
fxType: FxType,
): AnimatedSprite | null => {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = animatedSpriteImageMap.get(fxType);
if (!config || !image) return null;
private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.animatedSpriteImageMap.get(fxType);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
};
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
private getColoredAnimatedSprite(
owner: PlayerView,
fxType: FxType,
theme: Theme,
): HTMLCanvasElement | null {
const baseImage = this.animatedSpriteImageMap.get(fxType);
const config = ANIMATED_SPRITE_CONFIG[fxType];
if (!baseImage || !config) return null;
const territoryColor = theme.territoryColor(owner);
const borderColor = theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const key = `${fxType}-${owner.id()}`;
let coloredCanvas: HTMLCanvasElement;
if (this.coloredAnimatedSpriteCache.has(key)) {
coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!;
} else {
coloredCanvas = colorizeCanvas(
baseImage,
territoryColor,
borderColor,
spawnHighlightColor,
);
this.coloredAnimatedSpriteCache.set(key, coloredCanvas);
}
return coloredCanvas;
}
private createColoredAnimatedSpriteForUnit(
fxType: FxType,
owner: PlayerView,
theme: Theme,
): AnimatedSprite | null {
const config = ANIMATED_SPRITE_CONFIG[fxType];
const image = this.getColoredAnimatedSprite(owner, fxType, theme);
if (!config || !image) return null;
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
config.originX,
config.originY,
);
}
public createAnimatedSprite(
fxType: FxType,
owner?: PlayerView,
theme?: Theme,
): AnimatedSprite | null {
if (owner && theme) {
return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme);
}
return this.createRegularAnimatedSprite(fxType);
}
}
+19 -1
View File
@@ -29,6 +29,7 @@ import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
import { UILayer } from "./layers/UILayer";
import { UnitInfoModal } from "./layers/UnitInfoModal";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
@@ -171,10 +172,26 @@ export function createRenderer(
}
playerTeamLabel.game = game;
const unitInfoModal = document.querySelector(
"unit-info-modal",
) as UnitInfoModal;
if (!(unitInfoModal instanceof UnitInfoModal)) {
console.error("unit info modal not found");
}
unitInfoModal.game = game;
const structureLayer = new StructureLayer(
game,
eventBus,
transformHandler,
unitInfoModal,
);
unitInfoModal.structureLayer = structureLayer;
// unitInfoModal.eventBus = eventBus;
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus),
new StructureLayer(game, eventBus),
structureLayer,
new UnitLayer(game, eventBus, clientID, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, clientID, transformHandler),
@@ -203,6 +220,7 @@ export function createRenderer(
topBar,
playerPanel,
playerTeamLabel,
unitInfoModal,
multiTabModal,
];
+56 -43
View File
@@ -71,7 +71,53 @@ export const isSpriteReady = (unitType: UnitType): boolean => {
const coloredSpriteCache: Map<string, HTMLCanvasElement> = new Map();
// puts the sprite in an canvas colors it and caches the colored canvas
/**
* Load a canvas and replace grayscale with border colors
*/
export const colorizeCanvas = (
source: CanvasImageSource & { width: number; height: number },
colorA: Colord,
colorB: Colord,
colorC: Colord,
): HTMLCanvasElement => {
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(source, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const colorARgb = colorA.toRgb();
const colorBRgb = colorB.toRgb();
const colorCRgb = colorC.toRgb();
for (let i = 0; i < data.length; i += 4) {
const r = data[i],
g = data[i + 1],
b = data[i + 2];
if (r === 180 && g === 180 && b === 180) {
data[i] = colorARgb.r;
data[i + 1] = colorARgb.g;
data[i + 2] = colorARgb.b;
} else if (r === 70 && g === 70 && b === 70) {
data[i] = colorBRgb.r;
data[i + 1] = colorBRgb.g;
data[i + 2] = colorBRgb.b;
} else if (r === 130 && g === 130 && b === 130) {
data[i] = colorCRgb.r;
data[i + 1] = colorCRgb.g;
data[i + 2] = colorCRgb.b;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas;
};
export const getColoredSprite = (
unit: UnitView,
theme: Theme,
@@ -82,8 +128,7 @@ export const getColoredSprite = (
const territoryColor = customTerritoryColor ?? theme.territoryColor(owner);
const borderColor = customBorderColor ?? theme.borderColor(owner);
const spawnHighlightColor = theme.spawnHighlightColor();
const colorKey = territoryColor.toRgbString() + borderColor.toRgbString();
const key = unit.type() + colorKey;
const key = `${unit.type()}-${owner.id()}`;
if (coloredSpriteCache.has(key)) {
return coloredSpriteCache.get(key)!;
@@ -94,45 +139,13 @@ export const getColoredSprite = (
throw new Error(`Failed to load sprite for ${unit.type()}`);
}
const territoryRgb = territoryColor.toRgb();
const borderRgb = borderColor.toRgb();
const spawnHighlightRgb = spawnHighlightColor.toRgb();
const coloredCanvas = colorizeCanvas(
sprite,
territoryColor,
borderColor,
spawnHighlightColor,
);
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;
coloredSpriteCache.set(key, coloredCanvas);
return coloredCanvas;
};
+41 -26
View File
@@ -2,12 +2,21 @@ import { EventBus } from "../../core/EventBus";
import { Cell } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
import { GoToPlayerEvent, GoToUnitEvent } from "./layers/Leaderboard";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./layers/Leaderboard";
export const GOTO_INTERVAL_MS = 16;
export const CAMERA_MAX_SPEED = 15;
export const CAMERA_SMOOTHING = 0.03;
export class TransformHandler {
public scale: number = 1.8;
private offsetX: number = -350;
private offsetY: number = -200;
private lastGoToCallTime: number | null = null;
private target: Cell | null;
private intervalID: NodeJS.Timeout | null = null;
@@ -21,6 +30,7 @@ export class TransformHandler {
this.eventBus.on(ZoomEvent, (e) => this.onZoom(e));
this.eventBus.on(DragEvent, (e) => this.onMove(e));
this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e));
this.eventBus.on(GoToPositionEvent, (e) => this.onGoToPosition(e));
this.eventBus.on(GoToUnitEvent, (e) => this.onGoToUnit(e));
this.eventBus.on(CenterCameraEvent, () => this.centerCamera());
}
@@ -146,7 +156,13 @@ export class TransformHandler {
event.player.nameLocation().x,
event.player.nameLocation().y,
);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
onGoToPosition(event: GoToPositionEvent) {
this.clearTarget();
this.target = new Cell(event.x, event.y);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
onGoToUnit(event: GoToUnitEvent) {
@@ -155,7 +171,7 @@ export class TransformHandler {
this.game.x(event.unit.lastTile()),
this.game.y(event.unit.lastTile()),
);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
centerCamera() {
@@ -163,43 +179,42 @@ export class TransformHandler {
const player = this.game.myPlayer();
if (!player || !player.nameLocation()) return;
this.target = new Cell(player.nameLocation().x, player.nameLocation().y);
this.intervalID = setInterval(() => this.goTo(), 1);
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
private goTo() {
const { screenX, screenY } = this.screenCenter();
const screenMapCenter = new Cell(screenX, screenY);
if (this.target === null) throw new Error("null target");
if (
this.game.manhattanDist(
this.game.ref(screenX, screenY),
this.game.ref(this.target.x, this.target.y),
) < 2
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
2
) {
this.clearTarget();
return;
}
const dX = Math.abs(screenMapCenter.x - this.target.x);
if (dX > 2) {
const offsetDx = Math.max(1, Math.floor(dX / 25));
if (screenMapCenter.x > this.target.x) {
this.offsetX -= offsetDx;
} else {
this.offsetX += offsetDx;
}
}
const dY = Math.abs(screenMapCenter.y - this.target.y);
if (dY > 2) {
const offsetDy = Math.max(1, Math.floor(dY / 25));
if (screenMapCenter.y > this.target.y) {
this.offsetY -= offsetDy;
} else {
this.offsetY += offsetDy;
}
let dt: number;
const now = window.performance.now();
if (this.lastGoToCallTime === null) {
dt = GOTO_INTERVAL_MS;
} else {
dt = now - this.lastGoToCallTime;
}
this.lastGoToCallTime = now;
const r = 1 - Math.pow(CAMERA_SMOOTHING, dt / 1000);
this.offsetX += Math.max(
Math.min((this.target.x - screenX) * r, CAMERA_MAX_SPEED),
-CAMERA_MAX_SPEED,
);
this.offsetY += Math.max(
Math.min((this.target.y - screenY) * r, CAMERA_MAX_SPEED),
-CAMERA_MAX_SPEED,
);
this.changed = true;
}
+7
View File
@@ -3,6 +3,13 @@ export interface Fx {
}
export enum FxType {
MiniFire = "MiniFire",
MiniSmoke = "MiniSmoke",
MiniBigSmoke = "MiniBigSmoke",
MiniSmokeAndFire = "MiniSmokeAndFire",
MiniExplosion = "MiniExplosion",
UnitExplosion = "UnitExplosion",
SinkingShip = "SinkingShip",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
}
+75 -27
View File
@@ -1,6 +1,7 @@
import { AnimatedSprite } from "../AnimatedSprite";
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { FadeFx, SpriteFx } from "./SpriteFx";
/**
* Shockwave effect: draw a growing 1px white circle
@@ -31,32 +32,79 @@ export class ShockwaveFx implements Fx {
}
/**
* Explosion effect: sprite animation of an explosion
* Spawn @p number of @p type animation within a perimeter
*/
export class NukeExplosionFx implements Fx {
private lifeTime: number = 0;
private nukeExplosionSprite: AnimatedSprite | null;
constructor(
private x: number,
private y: number,
private duration: number,
) {
this.nukeExplosionSprite = createAnimatedSpriteForUnit(FxType.Nuke);
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (this.nukeExplosionSprite) {
this.lifeTime += frameTime;
if (this.lifeTime >= this.duration) {
return false;
}
if (this.nukeExplosionSprite.isActive()) {
this.nukeExplosionSprite.update(frameTime);
this.nukeExplosionSprite.draw(ctx, this.x, this.y);
return true;
}
return false;
function addSpriteInCircle(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
num: number,
type: FxType,
result: Fx[],
game: GameView,
) {
const count = Math.max(0, Math.floor(num));
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
const distance = Math.random() * (radius / 2);
const spawnX = Math.floor(x + Math.cos(angle) * distance);
const spawnY = Math.floor(y + Math.sin(angle) * distance);
if (
game.isValidCoord(spawnX, spawnY) &&
game.isLand(game.ref(spawnX, spawnY))
) {
const sprite = new FadeFx(
new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000),
0.1,
0.8,
);
result.push(sprite as Fx);
}
return false;
}
}
/**
* Explosion effect:
* - explosion animation
* - shockwave
* - ruins and desolation fx
*/
export function nukeFxFactory(
animatedSpriteLoader: AnimatedSpriteLoader,
x: number,
y: number,
radius: number,
game: GameView,
): Fx[] {
const nukeFx: Fx[] = [];
// Explosion animation
nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke));
// Shockwave animation
nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5));
// Ruins and desolation sprites
const debrisPlan: Array<{
type: FxType;
radiusFactor: number;
density: number;
}> = [
{ type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 },
{ type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 },
{ type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 },
{ type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 },
];
for (const { type, radiusFactor, density } of debrisPlan) {
addSpriteInCircle(
animatedSpriteLoader,
x,
y,
radius * radiusFactor,
radius * density,
type,
nukeFx,
game,
);
}
return nukeFx;
}
-34
View File
@@ -1,34 +0,0 @@
import { AnimatedSprite } from "../AnimatedSprite";
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
/**
* Explosion effect: sprite animation of an explosion
*/
export class SAMExplosionFx implements Fx {
private lifeTime: number = 0;
private explosionSprite: AnimatedSprite | null;
constructor(
private x: number,
private y: number,
private duration: number,
) {
this.explosionSprite = createAnimatedSpriteForUnit(FxType.SAMExplosion);
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (this.explosionSprite) {
this.lifeTime += frameTime;
if (this.lifeTime >= this.duration) {
return false;
}
if (this.explosionSprite.isActive()) {
this.explosionSprite.update(frameTime);
this.explosionSprite.draw(ctx, this.x, this.y);
return true;
}
return false;
}
return false;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Theme } from "../../../core/configuration/Config";
import { consolex } from "../../../core/Consolex";
import { PlayerView } from "../../../core/game/GameView";
import { AnimatedSprite } from "../AnimatedSprite";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
function fadeInOut(
t: number,
fadeIn: number = 0.3,
fadeOut: number = 0.7,
): number {
if (t < fadeIn) {
const f = t / fadeIn; // Map to [0, 1]
return f * f;
} else if (t < fadeOut) {
return 1;
} else {
const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1]
return 1 - f * f;
}
}
/**
* Fade in/out another FX
*/
export class FadeFx implements Fx {
constructor(
private fxToFade: SpriteFx,
private fadeIn: number,
private fadeOut: number,
) {}
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean {
const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration();
ctx.save();
ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut);
const result = this.fxToFade.renderTick(duration, ctx);
ctx.restore();
return result;
}
}
/**
* Animated sprite. Can be colored if provided an owner/theme
*/
export class SpriteFx implements Fx {
protected animatedSprite: AnimatedSprite | null;
protected elapsedTime = 0;
protected duration = 1000;
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
protected x: number,
protected y: number,
fxType: FxType,
duration?: number,
private owner?: PlayerView,
private theme?: Theme,
) {
this.animatedSprite = animatedSpriteLoader.createAnimatedSprite(
fxType,
owner,
theme,
);
if (!this.animatedSprite) {
consolex.error("Could not load animated sprite", fxType);
} else {
this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000;
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
if (!this.animatedSprite) return false;
this.elapsedTime += frameTime;
if (this.elapsedTime >= this.duration) return false;
if (!this.animatedSprite.isActive()) return false;
const t = this.elapsedTime / this.duration;
this.animatedSprite.update(frameTime);
this.animatedSprite.draw(ctx, this.x, this.y);
return true;
}
getElapsedTime(): number {
return this.elapsedTime;
}
getDuration(): number {
return this.duration;
}
}
+33
View File
@@ -0,0 +1,33 @@
type TimedTask = {
delay: number;
action: () => void;
triggered: boolean;
};
/**
* Basic timeline to chain actions
*/
export class Timeline {
private tasks: TimedTask[] = [];
private timeElapsed = 0;
add(delay: number, action: () => void): Timeline {
this.tasks.push({ delay, action, triggered: false });
return this;
}
update(dt: number) {
this.timeElapsed += dt;
for (const task of this.tasks) {
if (!task.triggered && this.timeElapsed >= task.delay) {
task.action();
task.triggered = true;
}
}
}
isComplete() {
return this.tasks.every((t) => t.triggered);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { GameView } from "../../../core/game/GameView";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "./Fx";
import { SpriteFx } from "./SpriteFx";
import { Timeline } from "./Timeline";
/**
* Explosion Effect: a few timed explosions
*/
export class UnitExplosionFx implements Fx {
private timeline = new Timeline();
private explosions: Fx[] = [];
constructor(
animatedSpriteLoader: AnimatedSpriteLoader,
private x: number,
private y: number,
game: GameView,
) {
const config = [
{ dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion },
{ dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion },
{ dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion },
];
for (const { dx, dy, delay, type } of config) {
this.timeline.add(delay, () => {
if (game.isValidCoord(x + dx, y + dy)) {
this.explosions.push(
new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type),
);
}
});
}
}
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
this.timeline.update(frameTime);
let allDone = true;
for (const fx of this.explosions) {
if (fx.renderTick(frameTime, ctx)) {
allDone = false;
}
}
return !allDone || !this.timeline.isComplete();
}
}
+31 -6
View File
@@ -34,7 +34,11 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderTroops } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
import { translateText } from "../../Utils";
@@ -393,6 +397,10 @@ export class EventsDisplay extends LitElement implements Layer {
this.eventBus.emit(new GoToPlayerEvent(attacker));
}
emitGoToPositionEvent(x: number, y: number) {
this.eventBus.emit(new GoToPositionEvent(x, y));
}
emitGoToUnitEvent(unit: UnitView) {
this.eventBus.emit(new GoToUnitEvent(unit));
}
@@ -476,6 +484,26 @@ export class EventsDisplay extends LitElement implements Layer {
: event.description;
}
private async attackWarningOnClick(attack: AttackUpdate) {
const playerView = this.game.playerBySmallID(attack.attackerID);
if (playerView !== undefined) {
if (playerView instanceof PlayerView) {
const averagePosition = await playerView.attackAveragePosition(
attack.attackerID,
attack.id,
);
if (averagePosition === null) {
this.emitGoToPlayerEvent(attack.attackerID);
} else {
this.emitGoToPositionEvent(averagePosition.x, averagePosition.y);
}
}
} else {
this.emitGoToPlayerEvent(attack.attackerID);
}
}
private renderIncomingAttacks() {
return html`
${this.incomingAttacks.length > 0
@@ -487,10 +515,7 @@ export class EventsDisplay extends LitElement implements Layer {
<button
translate="no"
class="ml-2"
@click=${() => {
attack.attackerID &&
this.emitGoToPlayerEvent(attack.attackerID);
}}
@click=${() => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
@@ -520,7 +545,7 @@ export class EventsDisplay extends LitElement implements Layer {
<button
translate="no"
class="ml-2"
@click=${() => this.emitGoToPlayerEvent(attack.targetID)}
@click=${async () => this.attackWarningOnClick(attack)}
>
${renderTroops(attack.troops)}
${(
+84 -22
View File
@@ -1,10 +1,12 @@
import { Theme } from "../../../core/configuration/Config";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader";
import { Fx } from "../fx/Fx";
import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx";
import { SAMExplosionFx } from "../fx/SAMExplosionFx";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { Fx, FxType } from "../fx/Fx";
import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx";
import { SpriteFx } from "../fx/SpriteFx";
import { UnitExplosionFx } from "../fx/UnitExplosionFx";
import { Layer } from "./Layer";
export class FxLayer implements Layer {
@@ -13,10 +15,15 @@ export class FxLayer implements Layer {
private lastRefresh: number = 0;
private refreshRate: number = 10;
private theme: Theme;
private animatedSpriteLoader: AnimatedSpriteLoader =
new AnimatedSpriteLoader();
private allFx: Fx[] = [];
constructor(private game: GameView) {}
constructor(private game: GameView) {
this.theme = this.game.config().theme();
}
shouldTransform(): boolean {
return true;
@@ -36,47 +43,102 @@ export class FxLayer implements Layer {
switch (unit.type()) {
case UnitType.AtomBomb:
case UnitType.MIRVWarhead:
this.handleNukes(unit, 70);
this.onNukeEvent(unit, 70);
break;
case UnitType.HydrogenBomb:
this.handleNukes(unit, 250);
this.onNukeEvent(unit, 160);
break;
case UnitType.Warship:
this.onWarshipEvent(unit);
break;
case UnitType.Shell:
this.onShellEvent(unit);
break;
}
}
handleNukes(unit: UnitView, shockwaveRadius: number) {
onShellEvent(unit: UnitView) {
if (!unit.isActive()) {
if (unit.wasInterceptedBySAM()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, shockwaveRadius);
if (unit.reachedTarget()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.MiniExplosion,
);
this.allFx.push(shipExplosion);
}
}
}
handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
onWarshipEvent(unit: UnitView) {
if (!unit.isActive()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const shipExplosion = new UnitExplosionFx(
this.animatedSpriteLoader,
x,
y,
this.game,
);
this.allFx.push(shipExplosion);
const sinkingShip = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SinkingShip,
undefined,
unit.owner(),
this.theme,
);
this.allFx.push(sinkingShip);
}
}
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
this.handleSAMInterception(unit);
} else {
// Kaboom
this.handleNukeExplosion(unit, radius);
}
}
}
handleNukeExplosion(unit: UnitView, radius: number) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const nuke = new NukeExplosionFx(x, y, 1000);
this.allFx.push(nuke as Fx);
const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius);
this.allFx.push(shockwave as Fx);
const nukeFx = nukeFxFactory(
this.animatedSpriteLoader,
x,
y,
radius,
this.game,
);
this.allFx = this.allFx.concat(nukeFx);
}
handleSAMInterception(unit: UnitView) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const interception = new SAMExplosionFx(x, y, 1000);
this.allFx.push(interception as Fx);
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.SAMExplosion,
);
this.allFx.push(explosion);
const shockwave = new ShockwaveFx(x, y, 800, 40);
this.allFx.push(shockwave as Fx);
this.allFx.push(shockwave);
}
async init() {
this.redraw();
try {
await loadAllAnimatedSpriteImages();
this.animatedSpriteLoader.loadAllAnimatedSpriteImages();
console.log("FX sprites loaded successfully");
} catch (err) {
console.error("Failed to load FX sprites:", err);
@@ -22,6 +22,13 @@ export class GoToPlayerEvent implements GameEvent {
constructor(public player: PlayerView) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
+10
View File
@@ -308,6 +308,16 @@ export class PlayerPanel extends LitElement implements Layer {
</div>
</div>
<!-- Betrayals -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
${translateText("player_panel.betrayals")}
</div>
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
${other.data.betrayals ?? 0}
</div>
</div>
<!-- Embargo -->
<div class="flex flex-col gap-1">
<div class="text-white text-opacity-80 text-sm px-2">
+1 -1
View File
@@ -63,7 +63,7 @@ export class SpawnTimer implements Layer {
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 ratio = this.ratios[i] ?? 1 - filledRatio;
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
@@ -1,7 +1,10 @@
import { colord, Colord } from "colord";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { MouseUpEvent } from "../../InputHandler";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { UnitInfoModal } from "./UnitInfoModal";
import cityIcon from "../../../../resources/images/buildings/cityAlt1.png";
import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png";
@@ -22,6 +25,7 @@ import { GameView, UnitView } from "../../../core/game/GameView";
const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
const reloadingColor = colord({ r: 255, g: 0, b: 0 });
const selectedUnitColor = colord({ r: 0, g: 255, b: 255 });
type DistanceFunction = typeof euclDistFN;
@@ -44,6 +48,8 @@ export class StructureLayer implements Layer {
private context: CanvasRenderingContext2D;
private unitIcons: Map<string, ImageData> = new Map();
private theme: Theme;
private selectedStructureUnit: UnitView | null = null;
private previouslySelected: UnitView | null = null;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
@@ -82,7 +88,15 @@ export class StructureLayer implements Layer {
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private unitInfoModal: UnitInfoModal | null,
) {
if (!unitInfoModal) {
throw new Error(
"UnitInfoModal instance must be provided to StructureLayer.",
);
}
this.unitInfoModal = unitInfoModal;
this.theme = game.config().theme();
this.loadIconData();
this.loadIcon("reloadingSam", {
@@ -147,6 +161,7 @@ export class StructureLayer implements Layer {
init() {
this.redraw();
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
}
redraw() {
@@ -265,6 +280,10 @@ export class StructureLayer implements Layer {
borderColor = underConstructionColor;
}
if (this.selectedStructureUnit === unit) {
borderColor = selectedUnitColor;
}
this.drawBorder(unit, borderColor, config, drawFunction);
const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2);
@@ -316,4 +335,80 @@ export class StructureLayer implements Layer {
clearCell(cell: Cell) {
this.context.clearRect(cell.x, cell.y, 1, 1);
}
private findStructureUnitAtCell(
cell: { x: number; y: number },
maxDistance: number = 10,
): UnitView | null {
const targetRef = this.game.ref(cell.x, cell.y);
const allUnitTypes = Object.values(UnitType);
const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes);
for (const { unit } of nearby) {
if (unit.isActive() && this.isUnitTypeSupported(unit.type())) {
return unit;
}
}
return null;
}
private onMouseUp(event: MouseUpEvent) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
}
const clickedUnit = this.findStructureUnitAtCell(cell);
this.previouslySelected = this.selectedStructureUnit;
if (clickedUnit) {
const wasSelected = this.previouslySelected === clickedUnit;
if (wasSelected) {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
} else {
this.selectedStructureUnit = clickedUnit;
if (
this.previouslySelected &&
this.previouslySelected !== clickedUnit
) {
this.handleUnitRendering(this.previouslySelected);
}
this.handleUnitRendering(clickedUnit);
const screenPos = this.transformHandler.worldToScreenCoordinates(cell);
const unitTile = clickedUnit.tile();
this.unitInfoModal?.onOpenStructureModal({
unit: clickedUnit,
x: screenPos.x,
y: screenPos.y,
tileX: this.game.x(unitTile),
tileY: this.game.y(unitTile),
});
}
} else {
this.selectedStructureUnit = null;
if (this.previouslySelected) {
this.handleUnitRendering(this.previouslySelected);
}
this.unitInfoModal?.onCloseStructureModal();
}
}
public unSelectStructureUnit() {
if (this.selectedStructureUnit) {
this.previouslySelected = this.selectedStructureUnit;
this.selectedStructureUnit = null;
this.handleUnitRendering(this.previouslySelected);
}
}
}
+164
View File
@@ -0,0 +1,164 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { UnitType } from "../../../core/game/Game";
import { GameView, UnitView } from "../../../core/game/GameView";
import { Layer } from "./Layer";
import { StructureLayer } from "./StructureLayer";
@customElement("unit-info-modal")
export class UnitInfoModal extends LitElement implements Layer {
@property({ type: Boolean }) open = false;
@property({ type: Number }) x = 0;
@property({ type: Number }) y = 0;
@property({ type: Object }) unit: UnitView | null = null;
public game: GameView;
public structureLayer: StructureLayer | null = null;
constructor() {
super();
}
init() {}
tick() {
if (this.unit) {
this.requestUpdate();
}
}
public onOpenStructureModal = ({
unit,
x,
y,
tileX,
tileY,
}: {
unit: UnitView;
x: number;
y: number;
tileX: number;
tileY: number;
}) => {
if (!this.game) return;
this.x = x;
this.y = y;
const targetRef = this.game.ref(tileX, tileY);
const allUnitTypes = Object.values(UnitType);
const matchingUnits = this.game
.nearbyUnits(targetRef, 10, allUnitTypes)
.filter(({ unit }) => unit.isActive());
if (matchingUnits.length > 0) {
matchingUnits.sort((a, b) => a.distSquared - b.distSquared);
this.unit = matchingUnits[0].unit;
} else {
this.unit = null;
}
this.open = this.unit !== null;
};
public onCloseStructureModal = () => {
this.open = false;
this.unit = null;
};
connectedCallback() {
super.connectedCallback();
}
disconnectedCallback() {
super.disconnectedCallback();
}
static styles = css`
:host {
position: fixed;
pointer-events: none;
z-index: 1000;
}
.modal {
pointer-events: auto;
background: rgba(30, 30, 30, 0.95);
color: #f8f8f8;
border: 1px solid #555;
padding: 12px 18px;
border-radius: 8px;
min-width: 220px;
max-width: 300px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
font-family: "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.6;
backdrop-filter: blur(6px);
position: relative;
}
.modal strong {
color: #e0e0e0;
}
.close-button {
background: #d00;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 6px 12px;
}
.close-button:hover {
background: #a00;
}
`;
render() {
if (!this.unit) return null;
const cooldown = this.unit.ticksLeftInCooldown() ?? 0;
const secondsLeft = Math.ceil(cooldown / 10);
return html`
<div
class="modal"
style="display: ${this.open ? "block" : "none"}; left: ${this
.x}px; top: ${this.y}px; position: absolute;"
>
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
Structure Info
</div>
<div style="margin-bottom: 4px;">
<strong>Type:</strong> ${this.unit.type?.() ?? "Unknown"}
</div>
${secondsLeft > 0
? html`<div style="margin-bottom: 4px;">
<strong>Cooldown:</strong> ${secondsLeft}s
</div>`
: ""}
<div style="margin-top: 14px; display: flex; justify-content: center;">
<button
@click=${() => {
this.onCloseStructureModal();
if (this.structureLayer) {
this.structureLayer.unSelectStructureUnit();
}
}}
class="close-button"
title="Close"
style="width: 100px; height: 32px;"
>
CLOSE
</button>
</div>
</div>
`;
}
}
+13 -14
View File
@@ -2,9 +2,8 @@ import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Team } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { GameView } from "../../../core/game/GameView";
import { SendWinnerEvent } from "../../Transport";
import { Layer } from "./Layer";
@@ -185,32 +184,32 @@ export class WinModal extends LitElement implements Layer {
const updates = this.game.updatesSinceLastTick();
const winUpdates = updates !== null ? updates[GameUpdateType.Win] : [];
winUpdates.forEach((wu) => {
if (wu.winnerType === "team") {
this.eventBus.emit(
new SendWinnerEvent(wu.winner as Team, wu.allPlayersStats, "team"),
);
if (wu.winner === this.game.myPlayer()?.team()) {
if (wu.winner[0] === "team") {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
} else {
this._title = translateText("win_modal.other_team", {
team: wu.winner,
team: wu.winner[1],
});
}
this.show();
} else {
const winner = this.game.playerBySmallID(
wu.winner as number,
) as PlayerView;
const winner = this.game.playerBySmallID(wu.winner[1]);
if (!winner.isPlayer()) return;
const winnerClient = winner.clientID();
if (winnerClient !== null) {
this.eventBus.emit(
new SendWinnerEvent(winnerClient, wu.allPlayersStats, "player"),
new SendWinnerEvent(["player", winnerClient], wu.allPlayersStats),
);
}
if (winner === this.game.myPlayer()) {
if (
winnerClient !== null &&
winnerClient === this.game.myPlayer()?.clientID()
) {
this._title = translateText("win_modal.you_won");
} else {
this._title = translateText("win_modal.you_won", {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
});
}
+13 -43
View File
@@ -135,25 +135,6 @@
gtag("config", "G-WQGQQ8RDN4");
</script>
<!-- AdinPlay Ads -->
<script>
var aiptag = aiptag || {};
aiptag.cmd = aiptag.cmd || [];
aiptag.cmd.display = aiptag.cmd.display || [];
aiptag.cmd.player = aiptag.cmd.player || [];
//CMP tool settings
aiptag.cmp = {
show: true,
button: true,
buttonText: "Privacy settings",
buttonPosition: "top-right", //bottom-left, bottom-right, top-left, top-right
};
</script>
<script
async
src="//api.adinplay.com/libs/aiptag/pub/OFI/openfront.io/tag.min.js"
></script>
</head>
<body
@@ -222,26 +203,6 @@
</header>
<div class="bg-image"></div>
<!-- Left gutter ad placement - full height, no empty space -->
<div class="left-gutter-ad ad">
<div id="openfront-io_300x600">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600");
});
</script>
</div>
</div>
<div class="right-gutter-ad ad">
<div id="openfront-io_300x600_2">
<script type="text/javascript">
aiptag.cmd.display.push(function () {
aipDisplayTag.display("openfront-io_300x600_2");
});
</script>
</div>
</div>
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
<div class="container pt-12">
@@ -269,12 +230,12 @@
title="Pick a pattern!"
></button>
</territory-patterns-modal>
<username-input class="w-full"></username-input>
<news-button class="mt-3"></news-button>
<username-input class="relative w-full"></username-input>
<news-button class="w-[20%] md:w-[15%]"></news-button>
</div>
<div></div>
<div>
<public-lobby class="w-full"></public-lobby>
<public-lobby class="block"></public-lobby>
</div>
<div class="container__row container__row--equal">
<o-button
@@ -396,10 +357,17 @@
<div class="l-footer__col t-text-white">
<a
href="https://github.com/openfrontio/OpenFrontIO"
class="t-link"
class="t-link inline-flex items-center space-x-2"
target="_blank"
>
©2025 OpenFront™
<img
src="../../resources/icons/github-mark-white.svg"
alt="GitHub"
width="20"
height="20"
class="ml-2 mr-4"
/>
</a>
<a href="/privacy-policy.html" class="t-link" target="_blank">
Privacy Policy
@@ -427,7 +395,9 @@
<chat-modal></chat-modal>
<user-setting></user-setting>
<multi-tab-modal></multi-tab-modal>
<unit-info-modal></unit-info-modal>
<news-modal></news-modal>
<left-in-game-ad></left-in-game-ad>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+10 -4
View File
@@ -65,14 +65,17 @@ export async function logOut(allSessions: boolean = false) {
return true;
}
let __isLoggedIn: TokenPayload | false | undefined = undefined;
export function isLoggedIn(): TokenPayload | false {
export type IsLoggedInResponse =
| { token: string; claims: TokenPayload }
| false;
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
export function isLoggedIn(): IsLoggedInResponse {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
return __isLoggedIn;
}
export function _isLoggedIn(): TokenPayload | false {
function _isLoggedIn(): IsLoggedInResponse {
try {
const token = getToken();
if (!token) {
@@ -144,7 +147,8 @@ export function _isLoggedIn(): TokenPayload | false {
return false;
}
return result.data;
const claims = result.data;
return { token, claims };
} catch (e) {
console.log(e);
return false;
@@ -177,6 +181,7 @@ export async function postRefresh(): Promise<boolean> {
localStorage.setItem("token", result.data.token);
return true;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
@@ -205,6 +210,7 @@ export async function getUserMe(): Promise<UserMeResponse | false> {
}
return result.data;
} catch (e) {
__isLoggedIn = false;
return false;
}
}
+50
View File
@@ -453,6 +453,56 @@ label.option-card:hover {
max-width: 100vw;
width: 100%;
}
#error-modal {
max-width: 575px;
}
}
@media screen and (max-width: 480px) {
#error-modal {
max-width: 350px;
}
}
#error-modal {
position: fixed;
padding: 20px;
background: white;
border: 1px solid black;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
width: 87%;
box-sizing: border-box;
}
#error-modal pre {
overflow-x: auto;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
#error-modal button.copy-btn {
padding: 8px 16px;
margin-top: 10px;
background: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#error-modal button.close-btn {
color: white;
top: 0px;
right: 0px;
cursor: pointer;
background: red;
margin-right: 0px;
position: fixed;
width: 40px;
}
.start-game-button-container {
+3 -3
View File
@@ -14,13 +14,13 @@ import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticTh
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
import iceland from "../../../resources/maps/IcelandThumb.webp";
import japan from "../../../resources/maps/JapanThumb.webp";
import knownworld from "../../../resources/maps/KnownWorldThumb.webp";
import mars from "../../../resources/maps/MarsThumb.webp";
import mena from "../../../resources/maps/MenaThumb.webp";
import northAmerica from "../../../resources/maps/NorthAmericaThumb.webp";
import oceania from "../../../resources/maps/OceaniaThumb.webp";
import pangaea from "../../../resources/maps/PangaeaThumb.webp";
import southAmerica from "../../../resources/maps/SouthAmericaThumb.webp";
import worldmapgiant from "../../../resources/maps/WorldMapGiantThumb.webp";
import world from "../../../resources/maps/WorldMapThumb.webp";
import { GameMapType } from "../../core/game/Game";
@@ -29,6 +29,8 @@ export function getMapsImage(map: GameMapType): string {
switch (map) {
case GameMapType.World:
return world;
case GameMapType.WorldMapGiant:
return worldmapgiant;
case GameMapType.Oceania:
return oceania;
case GameMapType.Europe:
@@ -63,8 +65,6 @@ export function getMapsImage(map: GameMapType): string {
return japan;
case GameMapType.BetweenTwoSeas:
return betweenTwoSeas;
case GameMapType.KnownWorld:
return knownworld;
case GameMapType.FaroeIslands:
return faroeislands;
case GameMapType.DeglaciatedAntarctica:
+22 -1
View File
@@ -49,7 +49,7 @@ export async function createGameRunner(
: fixProfaneUsername(sanitize(p.username)),
PlayerType.Human,
p.clientID,
p.playerID,
random.nextID(),
),
);
@@ -227,6 +227,27 @@ export class GameRunner {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public attackAveragePosition(
playerID: number,
attackID: string,
): Cell | null {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
const condition = (a) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
+19 -29
View File
@@ -136,33 +136,18 @@ const SafeString = z
)
.max(1000);
const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/;
// Copied from zod, modified to remove their erroneous `typ` header requirement
function isValidJWT(jwt: string, alg?: string): boolean {
if (!jwtRegex.test(jwt)) return false;
try {
const [header] = jwt.split(".");
// Convert base64url to base64
const base64 = header
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(header.length + ((4 - (header.length % 4)) % 4), "=");
const decoded = JSON.parse(atob(base64));
if (typeof decoded !== "object" || decoded === null) return false;
if (!decoded.alg) return false;
if (alg && decoded.alg !== alg) return false;
return true;
} catch {
return false;
}
}
const PersistentIdSchema = z.string().uuid();
const JwtTokenSchema = z.string().jwt();
const TokenSchema = z
.string()
.refine((v) => PersistentIdSchema.safeParse(v).success || isValidJWT(v), {
message: "Token must be a valid UUID or JWT",
});
.refine(
(v) =>
PersistentIdSchema.safeParse(v).success ||
JwtTokenSchema.safeParse(v).success,
{
message: "Token must be a valid UUID or JWT",
},
);
const EmojiSchema = z
.number()
@@ -354,7 +339,6 @@ export const ServerPrestartMessageSchema = ServerBaseMessageSchema.extend({
});
export const PlayerSchema = z.object({
playerID: ID,
clientID: ID,
username: SafeString,
flag: SafeString.optional(),
@@ -393,11 +377,18 @@ export const ServerMessageSchema = z.union([
// Client
export const WinnerSchema = z
.union([
z.tuple([z.literal("player"), ID]),
z.tuple([z.literal("team"), SafeString]),
])
.optional();
export type Winner = z.infer<typeof WinnerSchema>;
export const ClientSendWinnerSchema = z.object({
type: z.literal("winner"),
winner: z.union([ID, TeamSchema]).nullable(),
winner: WinnerSchema,
allPlayersStats: AllPlayersStatsSchema,
winnerType: z.enum(["player", "team"]),
});
export const ClientHashSchema = z.object({
@@ -454,8 +445,7 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({
end: z.number(),
duration: z.number().nonnegative(),
num_turns: z.number(),
winner: z.union([ID, SafeString]).nullable().optional(),
winnerType: z.enum(["player", "team"]).nullable().optional(),
winner: WinnerSchema,
});
export type GameEndInfo = z.infer<typeof GameEndInfoSchema>;
+8 -2
View File
@@ -84,13 +84,19 @@ export const OTHER_INDEX_DESTROY = 1; // Structures and warships destroyed
export const OTHER_INDEX_CAPTURE = 2; // Structures captured
export const OTHER_INDEX_LOST = 3; // Structures/warships destroyed/captured by others
const AtLeastOneNumberSchema = z.number().array().min(1);
const BigIntStringSchema = z.preprocess((val) => {
if (typeof val === "string" && /^\d+$/.test(val)) return BigInt(val);
if (typeof val === "bigint") return val;
return val;
}, z.bigint());
const AtLeastOneNumberSchema = BigIntStringSchema.array().min(1);
export type AtLeastOneNumber = z.infer<typeof AtLeastOneNumberSchema>;
export const PlayerStatsSchema = z
.object({
attacks: AtLeastOneNumberSchema.optional(),
betrayals: z.number().positive().optional(),
betrayals: BigIntStringSchema.optional(),
boats: z.record(BoatUnitSchema, AtLeastOneNumberSchema).optional(),
bombs: z.record(BombUnitSchema, AtLeastOneNumberSchema).optional(),
gold: AtLeastOneNumberSchema.optional(),
+10 -5
View File
@@ -1,15 +1,15 @@
import DOMPurify from "dompurify";
import { customAlphabet } from "nanoid";
import twemoji from "twemoji";
import { Cell, Team, Unit } from "./game/Game";
import { Cell, Unit } from "./game/Game";
import { GameMap, TileRef } from "./game/GameMap";
import {
ClientID,
GameConfig,
GameID,
GameRecord,
PlayerRecord,
Turn,
Winner,
} from "./Schemas";
import {
@@ -191,8 +191,7 @@ export function createGameRecord(
allTurns: Turn[],
start: number,
end: number,
winner: ClientID | Team | null,
winnerType: "player" | "team" | null,
winner: Winner,
): GameRecord {
const duration = Math.floor((end - start) / 1000);
const version = "v0.0.2";
@@ -211,7 +210,6 @@ export function createGameRecord(
duration,
num_turns,
winner,
winnerType,
},
version,
gitCommit,
@@ -310,3 +308,10 @@ export const emojiTable: string[][] = [
];
// 2d to 1d array
export const flattenedEmojiTable: string[] = emojiTable.flat();
/**
* JSON.stringify replacer function that converts bigint values to strings.
*/
export function replacer(_key: string, value: any): any {
return typeof value === "bigint" ? value.toString() : value;
}
+13 -9
View File
@@ -23,8 +23,8 @@ import { UserSettings } from "../game/UserSettings";
import { GameConfig, GameID } from "../Schemas";
import { assertNever, simpleHash, within } from "../Util";
import { Config, GameEnv, NukeMagnitude, ServerConfig, Theme } from "./Config";
import { pastelTheme } from "./PastelTheme";
import { pastelThemeDark } from "./PastelThemeDark";
import { PastelTheme } from "./PastelTheme";
import { PastelThemeDark } from "./PastelThemeDark";
const JwksSchema = z.object({
keys: z
@@ -61,14 +61,14 @@ const numPlayersConfig = {
[GameMapType.BlackSea]: [40, 50, 30],
[GameMapType.Pangaea]: [40, 20, 30],
[GameMapType.World]: [150, 80, 50],
[GameMapType.KnownWorld]: [50, 40, 30],
[GameMapType.WorldMapGiant]: [150, 100, 60],
[GameMapType.Halkidiki]: [50, 40, 30],
} as const satisfies Record<GameMapType, [number, number, number]>;
const TERRAIN_EFFECTS = {
[TerrainType.Plains]: { mag: 0.85, speed: 0.8 },
[TerrainType.Highland]: { mag: 1, speed: 1 },
[TerrainType.Mountain]: { mag: 1.2, speed: 1.3 },
[TerrainType.Plains]: { mag: 0.9, speed: 0.8 },
[TerrainType.Highland]: { mag: 1.1, speed: 1 },
[TerrainType.Mountain]: { mag: 1.3, speed: 1.25 },
} as const;
export abstract class DefaultServerConfig implements ServerConfig {
@@ -165,6 +165,8 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
constructor(
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
@@ -274,7 +276,7 @@ export class DefaultConfig implements Config {
return 10000 + 150 * Math.pow(dist, 1.1);
}
tradeShipSpawnRate(numberOfPorts: number): number {
return Math.round(10 * Math.pow(numberOfPorts, 0.6));
return Math.round(10 * Math.pow(numberOfPorts, 0.5));
}
unitInfo(type: UnitType): UnitInfo {
@@ -460,7 +462,9 @@ export class DefaultConfig implements Config {
return this.bots();
}
theme(): Theme {
return this.userSettings()?.darkMode() ? pastelThemeDark : pastelTheme;
return this.userSettings()?.darkMode()
? this.pastelThemeDark
: this.pastelTheme;
}
attackLogic(
@@ -643,7 +647,7 @@ export class DefaultConfig implements Config {
// smaller countries recieve a boost to pop growth to speed up early game
const baseAdditionRate = 10;
const basePopGrowthRate = 1300 / max + 1 / 140;
const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers();
const reproductionPop = 0.85 * player.troops() + 1.15 * player.workers();
let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop;
const totalPop = player.totalPopulation();
const ratio = 1 - totalPop / max;
+15 -8
View File
@@ -19,7 +19,10 @@ import {
} from "./Colors";
import { Theme } from "./Config";
export const pastelTheme = new (class implements Theme {
type ColorCache = Map<string, Colord>;
export class PastelTheme implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private background = colord({ r: 60, g: 60, b: 60 });
@@ -69,19 +72,17 @@ export const pastelTheme = new (class implements Theme {
if (team !== null) {
return this.teamColor(team);
}
if (player.info().playerType === PlayerType.Human) {
if (player.type() === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
}
if (player.info().playerType === PlayerType.Bot) {
if (player.type() === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
}
textColor(player: PlayerView): string {
return player.info().playerType === PlayerType.Human
? "#000000"
: "#4D4D4D";
return player.type() === PlayerType.Human ? "#000000" : "#4D4D4D";
}
specialBuildingColor(player: PlayerView): Colord {
@@ -94,12 +95,18 @@ export const pastelTheme = new (class implements Theme {
}
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
}
const tc = this.territoryColor(player).rgba;
return colord({
const color = colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
this.borderColorCache.set(player.id(), color);
return color;
}
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
@@ -177,4 +184,4 @@ export const pastelTheme = new (class implements Theme {
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
})();
}
+15 -8
View File
@@ -19,7 +19,10 @@ import {
} from "./Colors";
import { Theme } from "./Config";
export const pastelThemeDark = new (class implements Theme {
type ColorCache = Map<string, Colord>;
export class PastelThemeDark implements Theme {
private borderColorCache: ColorCache = new Map<string, Colord>();
private rand = new PseudoRandom(123);
private background = colord({ r: 0, g: 0, b: 0 });
@@ -69,19 +72,17 @@ export const pastelThemeDark = new (class implements Theme {
if (team !== null) {
return this.teamColor(team);
}
if (player.info().playerType === PlayerType.Human) {
if (player.type() === PlayerType.Human) {
return humanColors[simpleHash(player.id()) % humanColors.length];
}
if (player.info().playerType === PlayerType.Bot) {
if (player.type() === PlayerType.Bot) {
return botColors[simpleHash(player.id()) % botColors.length];
}
return territoryColors[simpleHash(player.id()) % territoryColors.length];
}
textColor(player: PlayerView): string {
return player.info().playerType === PlayerType.Human
? "#ffffff"
: "#e6e6e6";
return player.type() === PlayerType.Human ? "#ffffff" : "#e6e6e6";
}
specialBuildingColor(player: PlayerView): Colord {
@@ -94,12 +95,18 @@ export const pastelThemeDark = new (class implements Theme {
}
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
}
const tc = this.territoryColor(player).rgba;
return colord({
const color = colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
this.borderColorCache.set(player.id(), color);
return color;
}
defendedBorderColors(player: PlayerView): { light: Colord; dark: Colord } {
@@ -179,4 +186,4 @@ export const pastelThemeDark = new (class implements Theme {
spawnHighlightColor(): Colord {
return this._spawnHighlightColor;
}
})();
}
+35 -20
View File
@@ -27,8 +27,6 @@ export class AttackExecution implements Execution {
private mg: Game;
private border = new Set<TileRef>();
private attack: Attack | null = null;
constructor(
@@ -87,15 +85,23 @@ export class AttackExecution implements Execution {
return;
}
if (
this.target.isPlayer() &&
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
if (this.target.isPlayer()) {
if (
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
console.warn("cannot attack player during immunity phase");
this.active = false;
return;
) {
console.warn("cannot attack player during immunity phase");
this.active = false;
return;
}
if (this._owner.isOnSameTeam(this.target)) {
console.warn(
`${this._owner.displayName()} cannot attack ${this.target.displayName()} because they are on the same team`,
);
this.active = false;
return;
}
}
if (this.startTroops === null) {
@@ -111,8 +117,15 @@ export class AttackExecution implements Execution {
this.target,
this.startTroops,
this.sourceTile,
new Set<TileRef>(),
);
if (this.sourceTile !== null) {
this.addNeighbors(this.sourceTile);
} else {
this.refreshToConquer();
}
// Record stats
this.mg.stats().attack(this._owner, this.target, this.startTroops);
@@ -144,12 +157,6 @@ export class AttackExecution implements Execution {
}
}
if (this.sourceTile !== null) {
this.addNeighbors(this.sourceTile);
} else {
this.refreshToConquer();
}
if (this.target.isPlayer()) {
if (this._owner.isAlliedWith(this.target)) {
// No updates should happen in init.
@@ -160,8 +167,12 @@ export class AttackExecution implements Execution {
}
private refreshToConquer() {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
this.toConquer.clear();
this.border.clear();
this.attack.clearBorder();
for (const tile of this._owner.borderTiles()) {
this.addNeighbors(tile);
}
@@ -235,7 +246,7 @@ export class AttackExecution implements Execution {
troopCount,
this._owner,
this.target,
this.border.size + this.random.nextInt(0, 5),
this.attack.borderSize() + this.random.nextInt(0, 5),
);
while (numTilesPerTick > 0) {
@@ -252,7 +263,7 @@ export class AttackExecution implements Execution {
}
const [tileToConquer] = this.toConquer.dequeue();
this.border.delete(tileToConquer);
this.attack.removeBorderTile(tileToConquer);
let onBorder = false;
for (const n of this.mg.neighbors(tileToConquer)) {
@@ -286,6 +297,10 @@ export class AttackExecution implements Execution {
}
private addNeighbors(tile: TileRef) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
const tickNow = this.mg.ticks(); // cache tick
for (const neighbor of this.mg.neighbors(tile)) {
@@ -295,7 +310,7 @@ export class AttackExecution implements Execution {
) {
continue;
}
this.border.add(neighbor);
this.attack.addBorderTile(neighbor);
let numOwnedByMe = 0;
for (const n of this.mg.neighbors(neighbor)) {
if (this.mg.owner(n) === this._owner) {
+3 -1
View File
@@ -104,7 +104,9 @@ export class ConstructionExecution implements Execution {
this.mg.addExecution(new MirvExecution(player.id(), this.tile));
break;
case UnitType.Warship:
this.mg.addExecution(new WarshipExecution(player.id(), this.tile));
this.mg.addExecution(
new WarshipExecution({ owner: player, patrolTile: this.tile }),
);
break;
case UnitType.Port:
this.mg.addExecution(new PortExecution(player.id(), this.tile));
+1 -1
View File
@@ -41,7 +41,7 @@ export class EmojiExecution implements Execution {
}
tick(ticks: number): void {
const emojiString = flattenedEmojiTable.at(this.emoji);
const emojiString = flattenedEmojiTable[this.emoji];
if (emojiString === undefined) {
consolex.warn(
`cannot send emoji ${this.emoji} from ${this.requestor} to ${this.recipient}`,
+1 -1
View File
@@ -63,7 +63,7 @@ export class Executor {
case "cancel_boat":
return new BoatRetreatExecution(playerID, intent.unitID);
case "move_warship":
return new MoveWarshipExecution(intent.unitId, intent.tile);
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
case "spawn":
return new SpawnExecution(
player.info(),
+2 -1
View File
@@ -53,7 +53,8 @@ export class MissileSiloExecution implements Execution {
}
}
if (this.silo.ticksLeftInCooldown() === 0) {
const cooldown = this.silo.ticksLeftInCooldown();
if (typeof cooldown === "number" && cooldown >= 0) {
this.silo.touch();
}
}
+18 -20
View File
@@ -1,35 +1,33 @@
import { Execution, Game } from "../game/Game";
const cancelDelay = 2;
import { Execution, Game, Player, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
export class MoveWarshipExecution implements Execution {
private active = true;
private mg: Game | null = null;
constructor(
public readonly unitId: number,
public readonly position: number,
private readonly owner: Player,
private readonly unitId: number,
private readonly position: TileRef,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
}
tick(ticks: number): void {
if (this.mg === null) {
throw new Error("Not initialized");
}
const warship = this.mg.units().find((u) => u.id() === this.unitId);
const warship = this.owner
.units(UnitType.Warship)
.find((u) => u.id() === this.unitId);
if (!warship) {
console.log("MoveWarshipExecution: warship is already dead");
console.warn("MoveWarshipExecution: warship not found");
return;
}
warship.setTargetTile(this.position);
this.active = false;
if (!warship.isActive()) {
console.warn("MoveWarshipExecution: warship is not active");
return;
}
warship.setPatrolTile(this.position);
warship.setTargetTile(undefined);
}
tick(ticks: number): void {}
isActive(): boolean {
return this.active;
return false;
}
activeDuringSpawnPhase(): boolean {
+2 -1
View File
@@ -119,7 +119,7 @@ export class NukeExecution implements Execution {
this.type !== UnitType.MIRVWarhead,
);
this.nuke = this.player.buildUnit(this.type, spawn, {
detonationDst: this.dst,
targetTile: this.dst,
});
if (this.mg.hasOwner(this.dst)) {
const target = this.mg.owner(this.dst);
@@ -239,6 +239,7 @@ export class NukeExecution implements Execution {
}
}
this.active = false;
this.nuke.setReachedTarget();
this.nuke.delete(false);
// Record stats
+2 -7
View File
@@ -46,17 +46,12 @@ export class PlayerExecution implements Execution {
throw new Error("Not initialized");
}
this.player.decayRelations();
const hasPort = this.player.units(UnitType.Port).length > 0;
this.player.units().forEach((u) => {
if (hasPort && u.type() === UnitType.Warship) {
u.modifyHealth(1);
}
if (this.mg === null) return;
const tileOwner = this.mg.owner(u.tile());
const tileOwner = this.mg!.owner(u.tile());
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner !== this.player) {
this.mg.player(tileOwner.id()).captureUnit(u);
this.mg!.player(tileOwner.id()).captureUnit(u);
}
} else {
u.delete();
+1 -3
View File
@@ -8,7 +8,6 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFinder } from "../pathfinding/PathFinding";
import { PseudoRandom } from "../PseudoRandom";
import { TradeShipExecution } from "./TradeShipExecution";
@@ -79,9 +78,8 @@ export class PortExecution implements Execution {
}
const port = this.random.randElement(ports);
const pf = PathFinder.Mini(this.mg, 2500);
this.mg.addExecution(
new TradeShipExecution(this.player().id(), this.port, port, pf),
new TradeShipExecution(this.player().id(), this.port, port),
);
}
+2 -3
View File
@@ -149,8 +149,8 @@ export class SAMLauncherExecution implements Execution {
target = this.getSingleTarget();
}
if (this.sam.ticksLeftInCooldown() === 0) {
// Touch SAM to update sprite to show not in cooldown.
const cooldown = this.sam.ticksLeftInCooldown();
if (typeof cooldown === "number" && cooldown >= 0) {
this.sam.touch();
}
@@ -181,7 +181,6 @@ export class SAMLauncherExecution implements Execution {
);
// Delete warheads
mirvWarheadTargets.forEach((u) => {
u.setInterceptedBySam();
u.delete();
});
} else if (target !== null) {
@@ -66,7 +66,6 @@ export class SAMMissileExecution implements Execution {
this._owner.id(),
);
this.active = false;
this.target.setInterceptedBySam();
this.target.delete(true, this._owner);
this.SAMMissile.delete(false);
+1
View File
@@ -52,6 +52,7 @@ export class ShellExecution implements Execution {
if (result === true) {
this.active = false;
this.target.modifyHealth(-this.effectOnTarget(), this._owner);
this.shell.setReachedTarget();
this.shell.delete(false);
return;
} else {
+16 -47
View File
@@ -16,30 +16,26 @@ import { distSortUnit } from "../Util";
export class TradeShipExecution implements Execution {
private active = true;
private mg: Game | null = null;
private origOwner: Player | null = null;
private tradeShip: Unit | null = null;
private index = 0;
private mg: Game;
private origOwner: Player;
private tradeShip: Unit | undefined;
private wasCaptured = false;
private tilesTraveled = 0;
private pathFinder: PathFinder;
constructor(
private _owner: PlayerID,
private srcPort: Unit,
private _dstPort: Unit,
private pathFinder: PathFinder,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
this.origOwner = mg.player(this._owner);
this.pathFinder = PathFinder.Mini(mg, 2500);
}
tick(ticks: number): void {
if (this.mg === null || this.origOwner === null) {
throw new Error("Not initialized");
}
if (this.tradeShip === null) {
if (this.tradeShip === undefined) {
const spawn = this.origOwner.canBuild(
UnitType.TradeShip,
this.srcPort.tile(),
@@ -50,12 +46,9 @@ export class TradeShipExecution implements Execution {
return;
}
this.tradeShip = this.origOwner.buildUnit(UnitType.TradeShip, spawn, {
dstPort: this._dstPort,
targetUnit: this._dstPort,
lastSetSafeFromPirates: ticks,
});
// Record stats
this.mg.stats().boatSendTrade(this.origOwner, this._dstPort.owner());
}
if (!this.tradeShip.isActive()) {
@@ -101,19 +94,6 @@ export class TradeShipExecution implements Execution {
}
}
const cachedNextTile = this._dstPort.cacheGet(this.tradeShip.tile());
if (cachedNextTile !== undefined) {
if (
this.mg.isWater(cachedNextTile) &&
this.mg.isShoreline(cachedNextTile)
) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(cachedNextTile);
this.tilesTraveled++;
return;
}
const result = this.pathFinder.nextTile(
this.tradeShip.tile(),
this._dstPort.tile(),
@@ -125,16 +105,14 @@ export class TradeShipExecution implements Execution {
break;
case PathFindResultType.Pending:
// Fire unit event to rerender.
this.tradeShip.touch();
this.tradeShip.move(this.tradeShip.tile());
break;
case PathFindResultType.NextTile:
this._dstPort.cachePut(this.tradeShip.tile(), result.tile);
// Update safeFromPirates status
if (this.mg.isWater(result.tile) && this.mg.isShoreline(result.tile)) {
this.tradeShip.setSafeFromPirates();
}
this.tradeShip.move(result.tile);
this.tilesTraveled++;
break;
case PathFindResultType.PathNotFound:
consolex.warn("captured trade ship cannot find route");
@@ -147,25 +125,21 @@ export class TradeShipExecution implements Execution {
}
private complete() {
if (this.mg === null || this.origOwner === null) {
throw new Error("Not initialized");
}
if (this.tradeShip === null) return;
this.active = false;
this.tradeShip.delete(false);
const gold = this.mg.config().tradeShipGold(this.tilesTraveled);
this.tradeShip!.delete(false);
const gold = this.mg
.config()
.tradeShipGold(
this.mg.manhattanDist(this.srcPort.tile(), this._dstPort.tile()),
);
if (this.wasCaptured) {
const player = this.tradeShip.owner();
player.addGold(gold);
this.tradeShip!.owner().addGold(gold);
this.mg.displayMessage(
`Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`,
MessageType.SUCCESS,
this.tradeShip.owner().id(),
this.tradeShip!.owner().id(),
);
// Record stats
this.mg.stats().boatCapturedTrade(player, this.origOwner, gold);
} else {
this.srcPort.owner().addGold(gold);
this._dstPort.owner().addGold(gold);
@@ -179,11 +153,6 @@ export class TradeShipExecution implements Execution {
MessageType.SUCCESS,
this.srcPort.owner().id(),
);
// Record stats
this.mg
.stats()
.boatArriveTrade(this.srcPort.owner(), this._dstPort.owner(), gold);
}
return;
}
+2 -1
View File
@@ -215,7 +215,8 @@ export class TransportShipExecution implements Execution {
break;
case PathFindResultType.PathNotFound:
// TODO: add to poisoned port list
consolex.warn(`path not found tot dst`);
consolex.warn(`path not found to dst`);
this.attacker.addTroops(this.troops);
this.boat.delete(false);
this.active = false;
return;
+107 -178
View File
@@ -2,9 +2,10 @@ import { consolex } from "../Consolex";
import {
Execution,
Game,
Player,
PlayerID,
isUnit,
OwnerComp,
Unit,
UnitParams,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
@@ -15,178 +16,91 @@ import { ShellExecution } from "./ShellExecution";
export class WarshipExecution implements Execution {
private random: PseudoRandom;
private _owner: Player;
private active = true;
private warship: Unit | null = null;
private warship: Unit;
private mg: Game;
private target: Unit | undefined = undefined;
private pathfinder: PathFinder | null = null;
private patrolTile: TileRef | undefined;
private pathfinder: PathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
constructor(
private playerID: PlayerID,
private patrolCenterTile: TileRef,
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
) {}
init(mg: Game, ticks: number): void {
this.mg = mg;
if (!mg.hasPlayer(this.playerID)) {
console.log(`WarshipExecution: player ${this.playerID} not found`);
this.active = false;
return;
}
this.pathfinder = PathFinder.Mini(mg, 5000);
this._owner = mg.player(this.playerID);
this.patrolTile = this.patrolCenterTile;
this.random = new PseudoRandom(mg.ticks());
}
// Only for warships with "moveTarget" set
goToMoveTarget(target: TileRef) {
if (this.warship === null || this.pathfinder === null) {
throw new Error("Warship not initialized");
}
// Patrol unless we are hunting down a tradeship
const result = this.pathfinder.nextTile(this.warship.tile(), target);
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setTargetTile(undefined);
this.warship.touch();
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
break;
}
}
private shoot() {
if (
this.mg === null ||
this.warship === null ||
this.target === undefined
) {
throw new Error("Warship not initialized");
}
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
this.lastShellAttack = this.mg.ticks();
this.mg.addExecution(
new ShellExecution(
this.warship.tile(),
this.warship.owner(),
this.warship,
this.target,
),
if (isUnit(this.input)) {
this.warship = this.input;
} else {
const spawn = this.input.owner.canBuild(
UnitType.Warship,
this.input.patrolTile,
);
if (!this.target.hasHealth()) {
// Don't send multiple shells to target that can be oneshotted
this.alreadySentShell.add(this.target);
this.target = undefined;
if (spawn === false) {
console.warn(
`Failed to spawn warship for ${this.input.owner.name()} at ${this.input.patrolTile}`,
);
return;
}
}
}
private patrol() {
if (this.warship === null || this.pathfinder === null) {
throw new Error("Warship not initialized");
}
if (this.patrolTile === undefined) {
this.patrolTile = this.randomTile();
if (this.patrolTile === undefined) {
return;
}
}
this.warship.setTargetUnit(this.target);
if (
this.target === undefined ||
this.target.type() !== UnitType.TradeShip
) {
// Patrol unless we are hunting down a tradeship
const result = this.pathfinder.nextTile(
this.warship.tile(),
this.patrolTile,
this.warship = this.input.owner.buildUnit(
UnitType.Warship,
spawn,
this.input,
);
switch (result.type) {
case PathFindResultType.Completed:
this.patrolTile = undefined;
this.warship.touch();
break;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to patrol tile`);
this.patrolTile = undefined;
break;
}
}
}
tick(ticks: number): void {
if (this.pathfinder === null) throw new Error("Warship not initialized");
if (this.warship === null) {
if (this.patrolTile === undefined) {
console.log(
`WarshipExecution: no patrol tile for ${this._owner.name()}`,
);
this.active = false;
return;
}
const spawn = this._owner.canBuild(UnitType.Warship, this.patrolTile);
if (spawn === false) {
this.active = false;
return;
}
this.warship = this._owner.buildUnit(UnitType.Warship, spawn, {});
if (this.warship.health() <= 0) {
this.warship.delete();
return;
}
if (!this.warship.isActive()) {
this.active = false;
const hasPort = this.warship.owner().units(UnitType.Port).length > 0;
if (hasPort) {
this.warship.modifyHealth(1);
}
this.warship.setTargetUnit(this.findTargetUnit());
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
return;
}
if (this.target !== undefined && !this.target.isActive()) {
this.target = undefined;
this.patrol();
if (this.warship.targetUnit() !== undefined) {
this.shootTarget();
return;
}
const hasPort = this._owner.units(UnitType.Port).length > 0;
const warship = this.warship;
if (warship === undefined) throw new Error("Warship not initialized");
}
private findTargetUnit(): Unit | undefined {
const hasPort = this.warship.owner().units(UnitType.Port).length > 0;
const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
const ships = this.mg
.nearbyUnits(
this.warship.tile(),
this.warship.patrolTile()!,
this.mg.config().warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
)
.filter(
({ unit }) =>
unit.owner() !== warship.owner() &&
unit !== warship &&
!unit.owner().isFriendly(warship.owner()) &&
unit.owner() !== this.warship.owner() &&
unit !== this.warship &&
!unit.owner().isFriendly(this.warship.owner()) &&
!this.alreadySentShell.has(unit) &&
(unit.type() !== UnitType.TradeShip ||
(hasPort &&
this.warship !== null &&
this.mg.euclideanDistSquared(this.warship.tile(), unit.tile()) <=
patrolRangeSquared &&
unit.targetUnit()?.owner() !== this.warship.owner() &&
!unit.targetUnit()?.owner().isFriendly(this.warship.owner()) &&
unit.isSafeFromPirates() !== true)),
);
this.target = ships.sort((a, b) => {
return ships.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
@@ -217,60 +131,48 @@ export class WarshipExecution implements Execution {
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit;
}
const moveTarget = this.warship.targetTile();
if (moveTarget) {
this.goToMoveTarget(moveTarget);
// If we have a "move target" then we cannot target trade ships as it
// requires moving.
if (this.target && this.target.type() === UnitType.TradeShip) {
this.target = undefined;
private shootTarget() {
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
this.lastShellAttack = this.mg.ticks();
this.mg.addExecution(
new ShellExecution(
this.warship.tile(),
this.warship.owner(),
this.warship,
this.warship.targetUnit()!,
),
);
if (!this.warship.targetUnit()!.hasHealth()) {
// Don't send multiple shells to target that can be oneshotted
this.alreadySentShell.add(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
return;
}
} else if (!this.target || this.target.type() !== UnitType.TradeShip) {
this.patrol();
}
if (
this.target === undefined ||
!this.target.isActive() ||
this.target.owner() === this._owner ||
this.target.isSafeFromPirates() === true
) {
// In case another warship captured or destroyed target, or the target escaped into safe waters
this.target = undefined;
return;
}
this.warship.setTargetUnit(this.target);
// If we have a move target we do not want to go after trading ships
if (!this.target) {
return;
}
if (this.target.type() !== UnitType.TradeShip) {
this.shoot();
return;
}
}
private huntDownTradeShip() {
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.nextTile(
this.warship.tile(),
this.target.tile(),
this.warship.targetUnit()!.tile(),
5,
);
switch (result.type) {
case PathFindResultType.Completed:
this._owner.captureUnit(this.target);
this.target = undefined;
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.move(this.warship.tile());
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
consolex.log(`path not found to target`);
@@ -279,8 +181,38 @@ export class WarshipExecution implements Execution {
}
}
private patrol() {
if (this.warship.targetTile() === undefined) {
this.warship.setTargetTile(this.randomTile());
if (this.warship.targetTile() === undefined) {
return;
}
}
const result = this.pathfinder.nextTile(
this.warship.tile(),
this.warship.targetTile()!,
);
switch (result.type) {
case PathFindResultType.Completed:
this.warship.setTargetTile(undefined);
this.warship.move(result.tile);
break;
case PathFindResultType.NextTile:
this.warship.move(result.tile);
break;
case PathFindResultType.Pending:
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
consolex.warn(`path not found to target tile`);
this.warship.setTargetTile(undefined);
break;
}
}
isActive(): boolean {
return this.active;
return this.warship?.isActive();
}
activeDuringSpawnPhase(): boolean {
@@ -288,19 +220,16 @@ export class WarshipExecution implements Execution {
}
randomTile(allowShoreline: boolean = false): TileRef | undefined {
if (this.mg === null) {
throw new Error("Warship not initialized");
}
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
const maxAttemptBeforeExpand: number = 500;
let attempts: number = 0;
let expandCount: number = 0;
while (expandCount < 3) {
const x =
this.mg.x(this.patrolCenterTile) +
this.mg.x(this.warship.patrolTile()!) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
const y =
this.mg.y(this.patrolCenterTile) +
this.mg.y(this.warship.patrolTile()!) +
this.random.nextInt(-warshipPatrolRange / 2, warshipPatrolRange / 2);
if (!this.mg.isValidCoord(x, y)) {
continue;
@@ -322,7 +251,7 @@ export class WarshipExecution implements Execution {
return tile;
}
console.warn(
`Failed to find random tile for warship for ${this._owner.name()}`,
`Failed to find random tile for warship for ${this.warship.owner().name()}`,
);
if (!allowShoreline) {
// If we failed to find a tile on the ocean, try again but allow shoreline
+53 -1
View File
@@ -1,9 +1,11 @@
import { Attack, Player, TerraNullius } from "./Game";
import { Attack, Cell, Player, TerraNullius } from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
import { PlayerImpl } from "./PlayerImpl";
export class AttackImpl implements Attack {
private _isActive = true;
private _borderSize = 0;
public _retreating = false;
public _retreated = false;
@@ -13,6 +15,8 @@ export class AttackImpl implements Attack {
private _attacker: Player,
private _troops: number,
private _sourceTile: TileRef | null,
private _border: Set<number>,
private _mg: GameImpl,
) {}
sourceTile(): TileRef | null {
@@ -69,4 +73,52 @@ export class AttackImpl implements Attack {
retreated(): boolean {
return this._retreated;
}
borderSize(): number {
return this._borderSize;
}
clearBorder(): void {
this._borderSize = 0;
this._border.clear();
}
addBorderTile(tile: TileRef): void {
if (!this._border.has(tile)) {
this._borderSize += 1;
this._border.add(tile);
}
}
removeBorderTile(tile: TileRef): void {
if (this._border.has(tile)) {
this._borderSize -= 1;
this._border.delete(tile);
}
}
averagePosition(): Cell | null {
if (this._borderSize === 0) {
if (this.sourceTile() === null) {
// No border tiles and no source tile—return a default position or throw an error
return null;
}
// No border tiles yet—use the source tile's location
const tile: number = this.sourceTile()!;
return new Cell(this._mg.map().x(tile), this._mg.map().y(tile));
}
let averageX = 0;
let averageY = 0;
for (const t of this._border) {
averageX += this._mg.map().x(t);
averageY += this._mg.map().y(t);
}
averageX = averageX / this._borderSize;
averageY = averageY / this._borderSize;
return new Cell(averageX, averageY);
}
}
+38 -14
View File
@@ -54,6 +54,7 @@ export const ColoredTeams: Record<string, Team> = {
export enum GameMapType {
World = "World",
WorldMapGiant = "Giant World Map",
Europe = "Europe",
EuropeClassic = "Europe Classic",
Mena = "Mena",
@@ -71,7 +72,6 @@ export enum GameMapType {
Iceland = "Iceland",
Japan = "Japan",
BetweenTwoSeas = "Between Two Seas",
KnownWorld = "Known World",
FaroeIslands = "Faroe Islands",
DeglaciatedAntarctica = "Deglaciated Antarctica",
FalklandIslands = "Falkland Islands",
@@ -82,6 +82,7 @@ export enum GameMapType {
export const mapCategories: Record<string, GameMapType[]> = {
continental: [
GameMapType.World,
GameMapType.WorldMapGiant,
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Europe,
@@ -107,7 +108,6 @@ export const mapCategories: Record<string, GameMapType[]> = {
fantasy: [
GameMapType.Pangaea,
GameMapType.Mars,
GameMapType.KnownWorld,
GameMapType.DeglaciatedAntarctica,
],
};
@@ -150,13 +150,19 @@ export enum UnitType {
Construction = "Construction",
}
export interface OwnerComp {
owner: Player;
}
export interface UnitParamsMap {
[UnitType.TransportShip]: {
troops?: number;
destination?: TileRef;
};
[UnitType.Warship]: {};
[UnitType.Warship]: {
patrolTile: TileRef;
};
[UnitType.Shell]: {};
@@ -164,12 +170,16 @@ export interface UnitParamsMap {
[UnitType.Port]: {};
[UnitType.AtomBomb]: {};
[UnitType.AtomBomb]: {
targetTile?: number;
};
[UnitType.HydrogenBomb]: {};
[UnitType.HydrogenBomb]: {
targetTile?: number;
};
[UnitType.TradeShip]: {
dstPort: Unit;
targetUnit: Unit;
lastSetSafeFromPirates?: number;
};
@@ -185,7 +195,9 @@ export interface UnitParamsMap {
[UnitType.MIRV]: {};
[UnitType.MIRVWarhead]: {};
[UnitType.MIRVWarhead]: {
targetTile?: number;
};
[UnitType.Construction]: {};
}
@@ -276,6 +288,11 @@ export interface Attack {
delete(): void;
// The tile the attack originated from, mostly used for boat attacks.
sourceTile(): TileRef | null;
addBorderTile(tile: TileRef): void;
removeBorderTile(tile: TileRef): void;
clearBorder(): void;
borderSize(): number;
averagePosition(): Cell | null;
}
export interface AllianceRequest {
@@ -316,14 +333,18 @@ export class PlayerInfo {
if (!name.startsWith("[") || !name.includes("]")) {
this.clan = null;
} else {
const clanMatch = name.match(/^\[([A-Z]{2,5})\]/);
const clanMatch = name.match(/^\[([a-zA-Z]{2,5})\]/);
this.clan = clanMatch ? clanMatch[1] : null;
}
}
}
export function isUnit(unit: Unit | UnitParams<UnitType>): unit is Unit {
return "isUnit" in unit && typeof unit.isUnit === "function" && unit.isUnit();
}
export interface Unit {
hash(): number;
isUnit(): this is Unit;
// Common properties.
id(): number;
@@ -337,6 +358,7 @@ export interface Unit {
isActive(): boolean;
setOwner(owner: Player): void;
touch(): void;
hash(): number;
toUpdate(): UnitUpdate;
// Targeting
@@ -346,8 +368,8 @@ export interface Unit {
targetUnit(): Unit | undefined;
setTargetedBySAM(targeted: boolean): void;
targetedBySAM(): boolean;
setInterceptedBySam(): void;
interceptedBySam(): boolean;
setReachedTarget(): void;
reachedTarget(): boolean;
// Health
hasHealth(): boolean;
@@ -375,9 +397,9 @@ export interface Unit {
constructionType(): UnitType | null;
setConstructionType(type: UnitType): void;
// Ports
cachePut(from: TileRef, to: TileRef): void;
cacheGet(from: TileRef): TileRef | undefined;
// Warships
setPatrolTile(tile: TileRef): void;
patrolTile(): TileRef | undefined;
}
export interface TerraNullius {
@@ -500,10 +522,12 @@ export interface Player {
// Attacking.
canAttack(tile: TileRef): boolean;
createAttack(
target: Player | TerraNullius,
troops: number,
sourceTile: TileRef | null,
border: Set<number>,
): Attack;
outgoingAttacks(): Attack[];
incomingAttacks(): Attack[];
+6 -4
View File
@@ -286,8 +286,8 @@ export class GameImpl implements Game {
this.updates = createGameUpdatesMap();
this.execs.forEach((e) => {
if (
e.isActive() &&
(!this.inSpawnPhase() || e.activeDuringSpawnPhase())
(!this.inSpawnPhase() || e.activeDuringSpawnPhase()) &&
e.isActive()
) {
e.tick(this._ticks);
}
@@ -594,8 +594,10 @@ export class GameImpl implements Game {
setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void {
this.addUpdate({
type: GameUpdateType.Win,
winner: typeof winner === "string" ? winner : winner.smallID(),
winnerType: typeof winner === "string" ? "team" : "player",
winner:
typeof winner === "string"
? ["team", winner]
: ["player", winner.smallID()],
allPlayersStats,
});
}
+3 -3
View File
@@ -73,7 +73,7 @@ export interface UnitUpdate {
pos: TileRef;
lastPos: TileRef;
isActive: boolean;
wasIntercepted: boolean;
reachedTarget: boolean;
retreating: boolean;
targetUnitId?: number; // Only for trade ships
targetTile?: TileRef; // Only for nukes
@@ -119,6 +119,7 @@ export interface PlayerUpdate {
incomingAttacks: AttackUpdate[];
outgoingAllianceRequests: PlayerID[];
hasSpawned: boolean;
betrayals?: bigint;
}
export interface AllianceRequestUpdate {
@@ -178,8 +179,7 @@ export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
// Player id or team name.
winner: number | Team;
winnerType: "player" | "team";
winner: ["player", number] | ["team", Team];
}
export interface HashUpdate {
+11 -4
View File
@@ -93,8 +93,8 @@ export class UnitView {
isActive(): boolean {
return this.data.isActive;
}
wasInterceptedBySAM(): boolean {
return this.data.wasIntercepted;
reachedTarget(): boolean {
return this.data.reachedTarget;
}
hasHealth(): boolean {
return this.data.health !== undefined;
@@ -158,6 +158,13 @@ export class PlayerView {
return this.data.incomingAttacks;
}
async attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return this.game.worker.attackAveragePosition(playerID, attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
@@ -180,12 +187,12 @@ export class PlayerView {
}
name(): string {
return userSettings.anonymousNames() && this.anonymousName !== null
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
}
displayName(): string {
return userSettings.anonymousNames() && this.anonymousName !== null
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
}
+23 -20
View File
@@ -121,6 +121,7 @@ export class PlayerImpl implements Player {
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
ar.recipient().id(),
);
const stats = this.mg.stats().getPlayerStats(this);
return {
type: GameUpdateType.Player,
@@ -146,28 +147,27 @@ export class PlayerImpl implements Player {
isTraitor: this.isTraitor(),
targets: this.targets().map((p) => p.smallID()),
outgoingEmojis: this.outgoingEmojis(),
outgoingAttacks: this._outgoingAttacks.map(
(a) =>
({
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
}) as AttackUpdate,
),
incomingAttacks: this._incomingAttacks.map(
(a) =>
({
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
}) as AttackUpdate,
),
outgoingAttacks: this._outgoingAttacks.map((a) => {
return {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
} as AttackUpdate;
}),
incomingAttacks: this._incomingAttacks.map((a) => {
return {
attackerID: a.attacker().smallID(),
targetID: a.target().smallID(),
troops: a.troops(),
id: a.id(),
retreating: a.retreating(),
} as AttackUpdate;
}),
outgoingAllianceRequests: outgoingAllianceRequests,
hasSpawned: this.hasSpawned(),
betrayals: stats?.betrayals,
};
}
@@ -959,6 +959,7 @@ export class PlayerImpl implements Player {
target: Player | TerraNullius,
troops: number,
sourceTile: TileRef | null,
border: Set<number>,
): Attack {
const attack = new AttackImpl(
this._pseudo_random.nextID(),
@@ -966,6 +967,8 @@ export class PlayerImpl implements Player {
this,
troops,
sourceTile,
border,
this.mg,
);
this._outgoingAttacks.push(attack);
if (target.isPlayer()) {
+21 -9
View File
@@ -7,13 +7,17 @@ export interface Stats {
stats(): AllPlayersStats;
// Player attacks target
attack(player: Player, target: Player | TerraNullius, troops: number): void;
attack(
player: Player,
target: Player | TerraNullius,
troops: number | bigint,
): void;
// Player cancels attack on target
attackCancel(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: number | bigint,
): void;
// Player betrays another player
@@ -23,10 +27,14 @@ export interface Stats {
boatSendTrade(player: Player, target: Player): void;
// Player's trade ship arrives at target, both players earn gold
boatArriveTrade(player: Player, target: Player, gold: number): void;
boatArriveTrade(player: Player, target: Player, gold: number | bigint): void;
// Player's trade ship, captured from target, arrives. Player earns gold.
boatCapturedTrade(player: Player, target: Player, gold: number): void;
boatCapturedTrade(
player: Player,
target: Player,
gold: number | bigint,
): void;
// Player destroys target's trade ship
boatDestroyTrade(player: Player, target: Player): void;
@@ -35,18 +43,22 @@ export interface Stats {
boatSendTroops(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: number | bigint,
): void;
// Player's transport ship arrives at target with troops
boatArriveTroops(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: number | bigint,
): void;
// Player destroys target's transport ship with troops
boatDestroyTroops(player: Player, target: Player, troops: number): void;
boatDestroyTroops(
player: Player,
target: Player,
troops: number | bigint,
): void;
// Player launches bomb at target
bombLaunch(
@@ -62,10 +74,10 @@ export interface Stats {
bombIntercept(player: Player, attacker: Player, type: NukeType): void;
// Player earns gold from conquering tiles or trade ships from captured
goldWar(player: Player, captured: Player, gold: number): void;
goldWar(player: Player, captured: Player, gold: number | bigint): void;
// Player earns gold from workers
goldWork(player: Player, gold: number): void;
goldWork(player: Player, gold: number | bigint): void;
// Player builds a unit of type
unitBuild(player: Player, type: OtherUnitType): void;
+49 -35
View File
@@ -28,6 +28,16 @@ import {
import { Player, TerraNullius } from "./Game";
import { Stats } from "./Stats";
type BigIntLike = bigint | number;
function _bigint(value: BigIntLike): bigint {
switch (typeof value) {
case "bigint":
return value;
case "number":
return BigInt(Math.floor(value));
}
}
export class StatsImpl implements Stats {
private readonly data: AllPlayersStats = {};
@@ -52,21 +62,21 @@ export class StatsImpl implements Stats {
return data;
}
private _addAttack(player: Player, index: number, value: number) {
private _addAttack(player: Player, index: number, value: BigIntLike) {
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.attacks === undefined) p.attacks = [0];
while (p.attacks.length < index) p.attacks.push(0);
p.attacks[index] += value;
if (p.attacks === undefined) p.attacks = [0n];
while (p.attacks.length <= index) p.attacks.push(0n);
p.attacks[index] += _bigint(value);
}
private _addBetrayal(player: Player, value: number) {
private _addBetrayal(player: Player, value: BigIntLike) {
const data = this._makePlayerStats(player);
if (data === undefined) return;
if (data.betrayals === undefined) {
data.betrayals = value;
data.betrayals = _bigint(value);
} else {
data.betrayals += value;
data.betrayals += _bigint(value);
}
}
@@ -74,55 +84,59 @@ export class StatsImpl implements Stats {
player: Player,
type: BoatUnit,
index: number,
value: number,
value: BigIntLike,
) {
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.boats === undefined) p.boats = { [type]: [0] };
if (p.boats[type] === undefined) p.boats[type] = [0];
while (p.boats[type].length < index) p.boats[type].push(0);
p.boats[type][index] += value;
if (p.boats === undefined) p.boats = { [type]: [0n] };
if (p.boats[type] === undefined) p.boats[type] = [0n];
while (p.boats[type].length <= index) p.boats[type].push(0n);
p.boats[type][index] += _bigint(value);
}
private _addBomb(
player: Player,
nukeType: NukeType,
index: number,
value: number,
value: BigIntLike,
): void {
const type = unitTypeToBombUnit[nukeType];
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.bombs === undefined) p.bombs = { [type]: [0] };
if (p.bombs[type] === undefined) p.bombs[type] = [0];
while (p.bombs[type].length < index) p.bombs[type].push(0);
p.bombs[type][index] += value;
if (p.bombs === undefined) p.bombs = { [type]: [0n] };
if (p.bombs[type] === undefined) p.bombs[type] = [0n];
while (p.bombs[type].length <= index) p.bombs[type].push(0n);
p.bombs[type][index] += _bigint(value);
}
private _addGold(player: Player, index: number, value: number) {
private _addGold(player: Player, index: number, value: BigIntLike) {
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.gold === undefined) p.gold = [0];
while (p.gold.length < index) p.gold.push(0);
p.gold[index] += value;
if (p.gold === undefined) p.gold = [0n];
while (p.gold.length <= index) p.gold.push(0n);
p.gold[index] += _bigint(value);
}
private _addOtherUnit(
player: Player,
otherUnitType: OtherUnitType,
index: number,
value: number,
value: BigIntLike,
) {
const type = unitTypeToOtherUnit[otherUnitType];
const p = this._makePlayerStats(player);
if (p === undefined) return;
if (p.units === undefined) p.units = { [type]: [0] };
if (p.units[type] === undefined) p.units[type] = [0];
while (p.units[type].length < index) p.units[type].push(0);
p.units[type][index] += value;
if (p.units === undefined) p.units = { [type]: [0n] };
if (p.units[type] === undefined) p.units[type] = [0n];
while (p.units[type].length <= index) p.units[type].push(0n);
p.units[type][index] += _bigint(value);
}
attack(player: Player, target: Player | TerraNullius, troops: number): void {
attack(
player: Player,
target: Player | TerraNullius,
troops: BigIntLike,
): void {
this._addAttack(player, ATTACK_INDEX_SENT, troops);
if (target.isPlayer()) {
this._addAttack(target, ATTACK_INDEX_RECV, troops);
@@ -132,7 +146,7 @@ export class StatsImpl implements Stats {
attackCancel(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: BigIntLike,
): void {
this._addAttack(player, ATTACK_INDEX_CANCEL, troops);
this._addAttack(player, ATTACK_INDEX_SENT, -troops);
@@ -149,13 +163,13 @@ export class StatsImpl implements Stats {
this._addBoat(player, "trade", BOAT_INDEX_SENT, 1);
}
boatArriveTrade(player: Player, target: Player, gold: number): void {
boatArriveTrade(player: Player, target: Player, gold: BigIntLike): void {
this._addBoat(player, "trade", BOAT_INDEX_ARRIVE, 1);
this._addGold(player, GOLD_INDEX_TRADE, gold);
this._addGold(target, GOLD_INDEX_TRADE, gold);
}
boatCapturedTrade(player: Player, target: Player, gold: number): void {
boatCapturedTrade(player: Player, target: Player, gold: BigIntLike): void {
this._addBoat(player, "trade", BOAT_INDEX_CAPTURE, 1);
this._addGold(player, GOLD_INDEX_STEAL, gold);
}
@@ -167,7 +181,7 @@ export class StatsImpl implements Stats {
boatSendTroops(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: BigIntLike,
): void {
this._addBoat(player, "trans", BOAT_INDEX_SENT, 1);
}
@@ -175,12 +189,12 @@ export class StatsImpl implements Stats {
boatArriveTroops(
player: Player,
target: Player | TerraNullius,
troops: number,
troops: BigIntLike,
): void {
this._addBoat(player, "trans", BOAT_INDEX_ARRIVE, 1);
}
boatDestroyTroops(player: Player, target: Player, troops: number): void {
boatDestroyTroops(player: Player, target: Player, troops: BigIntLike): void {
this._addBoat(player, "trans", BOAT_INDEX_DESTROY, 1);
}
@@ -204,11 +218,11 @@ export class StatsImpl implements Stats {
this._addBomb(player, type, BOMB_INDEX_INTERCEPT, 1);
}
goldWork(player: Player, gold: number): void {
goldWork(player: Player, gold: BigIntLike): void {
this._addGold(player, GOLD_INDEX_WORK, gold);
}
goldWar(player: Player, captured: Player, gold: number): void {
goldWar(player: Player, captured: Player, gold: BigIntLike): void {
this._addGold(player, GOLD_INDEX_WAR, gold);
}
+1 -1
View File
@@ -24,6 +24,7 @@ interface NationMapModule {
// Mapping from GameMap enum values to file names
const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.World]: "WorldMap",
[GameMapType.WorldMapGiant]: "WorldMapGiant",
[GameMapType.Europe]: "Europe",
[GameMapType.Mena]: "Mena",
[GameMapType.NorthAmerica]: "NorthAmerica",
@@ -40,7 +41,6 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
[GameMapType.Iceland]: "Iceland",
[GameMapType.Japan]: "Japan",
[GameMapType.BetweenTwoSeas]: "BetweenTwoSeas",
[GameMapType.KnownWorld]: "KnownWorld",
[GameMapType.FaroeIslands]: "FaroeIslands",
[GameMapType.DeglaciatedAntarctica]: "DeglaciatedAntarctica",
[GameMapType.EuropeClassic]: "EuropeClassic",
+26 -16
View File
@@ -21,14 +21,13 @@ export class UnitImpl implements Unit {
private _lastTile: TileRef;
private _retreating: boolean = false;
private _targetedBySAM = false;
private _interceptedBySAM = false;
private _reachedTarget = false;
private _lastSetSafeFromPirates: number; // Only for trade ships
private _constructionType: UnitType | undefined;
private _lastOwner: PlayerImpl | null = null;
private _troops: number;
private _cooldownStartTick: Tick | null = null;
private _pathCache: Map<TileRef, TileRef> = new Map();
private _patrolTile: TileRef | undefined;
constructor(
private _type: UnitType,
private mg: GameImpl,
@@ -39,12 +38,17 @@ export class UnitImpl implements Unit {
) {
this._lastTile = _tile;
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
this._targetTile =
"targetTile" in params ? (params.targetTile ?? undefined) : undefined;
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
this._lastSetSafeFromPirates =
"lastSetSafeFromPirates" in params
? (params.lastSetSafeFromPirates ?? 0)
: 0;
this._patrolTile =
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
switch (this._type) {
case UnitType.Warship:
@@ -56,6 +60,19 @@ export class UnitImpl implements Unit {
this.mg.stats().unitBuild(_owner, this._type);
}
}
setPatrolTile(tile: TileRef): void {
this._patrolTile = tile;
}
patrolTile(): TileRef | undefined {
return this._patrolTile;
}
isUnit(): this is Unit {
return true;
}
touch(): void {
this.mg.addUpdate(this.toUpdate());
}
@@ -66,13 +83,6 @@ export class UnitImpl implements Unit {
return this._targetTile;
}
cachePut(from: TileRef, to: TileRef): void {
this._pathCache.set(from, to);
}
cacheGet(from: TileRef): TileRef | undefined {
return this._pathCache.get(from);
}
id() {
return this._id;
}
@@ -86,7 +96,7 @@ export class UnitImpl implements Unit {
ownerID: this._owner.smallID(),
lastOwnerID: this._lastOwner?.smallID(),
isActive: this._active,
wasIntercepted: this._interceptedBySAM,
reachedTarget: this._reachedTarget,
retreating: this._retreating,
pos: this._tile,
lastPos: this._lastTile,
@@ -306,12 +316,12 @@ export class UnitImpl implements Unit {
return this._targetedBySAM;
}
setInterceptedBySam(): void {
this._interceptedBySAM = true;
setReachedTarget(): void {
this._reachedTarget = true;
}
interceptedBySam(): boolean {
return this._interceptedBySAM;
reachedTarget(): boolean {
return this._reachedTarget;
}
setSafeFromPirates(): void {
+51 -6
View File
@@ -9,7 +9,7 @@ export class MiniAStar implements AStar {
constructor(
private gameMap: GameMap,
private miniMap: GameMap,
src: TileRef | TileRef[],
private src: TileRef | TileRef[],
private dst: TileRef,
iterations: number,
maxTries: number,
@@ -41,16 +41,52 @@ export class MiniAStar implements AStar {
}
reconstructPath(): TileRef[] {
const upscaled = upscalePath(
this.aStar
.reconstructPath()
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
let cellSrc: Cell | undefined;
if (!Array.isArray(this.src)) {
cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src));
}
const cellDst = new Cell(
this.gameMap.x(this.dst),
this.gameMap.y(this.dst),
);
const upscaled = fixExtremes(
upscalePath(
this.aStar
.reconstructPath()
.map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))),
),
cellDst,
cellSrc,
);
upscaled.push(new Cell(this.gameMap.x(this.dst), this.gameMap.y(this.dst)));
return upscaled.map((c) => this.gameMap.ref(c.x, c.y));
}
}
function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
if (cellSrc !== undefined) {
const srcIndex = findCell(upscaled, cellSrc);
if (srcIndex === -1) {
// didnt find the start tile in the path
upscaled.unshift(cellSrc);
} else if (srcIndex !== 0) {
// found start tile but not at the start
// remove all tiles before the start tile
upscaled = upscaled.slice(srcIndex);
}
}
const dstIndex = findCell(upscaled, cellDst);
if (dstIndex === -1) {
// didnt find the dst tile in the path
upscaled.push(cellDst);
} else if (dstIndex !== upscaled.length - 1) {
// found dst tile but not at the end
// remove all tiles after the dst tile
upscaled = upscaled.slice(0, dstIndex + 1);
}
return upscaled;
}
function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
// Scale up each point
const scaledPath = path.map(
@@ -92,3 +128,12 @@ function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
return smoothPath;
}
function findCell(upscaled: Cell[], cellDst: Cell): number {
for (let i = 0; i < upscaled.length; i++) {
if (upscaled[i].x === cellDst.x && upscaled[i].y === cellDst.y) {
return i;
}
}
return -1;
}
+22
View File
@@ -1,6 +1,7 @@
import { createGameRunner, GameRunner } from "../GameRunner";
import { GameUpdateViewData } from "../game/GameUpdates";
import {
AttackAveragePositionResultMessage,
InitializedMessage,
MainThreadMessage,
PlayerActionsResultMessage,
@@ -121,6 +122,27 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
throw error;
}
break;
case "attack_average_position":
if (!gameRunner) {
throw new Error("Game runner not initialized");
}
try {
const averagePosition = (await gameRunner).attackAveragePosition(
message.playerID,
message.attackID,
);
sendMessage({
type: "attack_average_position_result",
id: message.id,
x: averagePosition ? averagePosition.x : null,
y: averagePosition ? averagePosition.y : null,
} as AttackAveragePositionResultMessage);
} catch (error) {
console.error("Failed to get attack average position:", error);
throw error;
}
break;
case "transport_ship_spawn":
if (!gameRunner) {
throw new Error("Game runner not initialized");
+36
View File
@@ -1,4 +1,5 @@
import {
Cell,
PlayerActions,
PlayerBorderTiles,
PlayerID,
@@ -189,6 +190,41 @@ export class WorkerClient {
});
}
attackAveragePosition(
playerID: number,
attackID: string,
): Promise<Cell | null> {
return new Promise((resolve, reject) => {
if (!this.isInitialized) {
reject(new Error("Worker not initialized"));
return;
}
const messageId = generateID();
this.messageHandlers.set(messageId, (message) => {
if (
message.type === "attack_average_position_result" &&
message.x !== undefined &&
message.y !== undefined
) {
if (message.x === null || message.y === null) {
resolve(null);
} else {
resolve(new Cell(message.x, message.y));
}
}
});
this.worker.postMessage({
type: "attack_average_position",
id: messageId,
playerID: playerID,
attackID: attackID,
});
});
}
transportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
+16
View File
@@ -20,6 +20,8 @@ export type WorkerMessageType =
| "player_profile_result"
| "player_border_tiles"
| "player_border_tiles_result"
| "attack_average_position"
| "attack_average_position_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
@@ -87,6 +89,18 @@ export interface PlayerBorderTilesResultMessage extends BaseWorkerMessage {
result: PlayerBorderTiles;
}
export interface AttackAveragePositionMessage extends BaseWorkerMessage {
type: "attack_average_position";
playerID: number;
attackID: string;
}
export interface AttackAveragePositionResultMessage extends BaseWorkerMessage {
type: "attack_average_position_result";
x: number | null;
y: number | null;
}
export interface TransportShipSpawnMessage extends BaseWorkerMessage {
type: "transport_ship_spawn";
playerID: PlayerID;
@@ -106,6 +120,7 @@ export type MainThreadMessage =
| PlayerActionsMessage
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| TransportShipSpawnMessage;
// Message send from worker
@@ -115,4 +130,5 @@ export type WorkerMessage =
| PlayerActionsResultMessage
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| TransportShipSpawnResultMessage;
+1
View File
@@ -7,6 +7,7 @@ const maps = [
"Africa",
"Asia",
"WorldMap",
"WorldMapGiant",
"BlackSea",
"Europe",
"EuropeClassic",
+6 -6
View File
@@ -1,6 +1,7 @@
import { S3 } from "@aws-sdk/client-s3";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
import { replacer } from "../core/Util";
import { logger } from "./Logger";
const config = getServerConfigFromServer();
@@ -60,7 +61,7 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
await r2.putObject({
Bucket: bucket,
Key: `${analyticsFolder}/${analyticsKey}`,
Body: JSON.stringify(analyticsData),
Body: JSON.stringify(analyticsData, replacer),
ContentType: "application/json",
});
@@ -78,19 +79,18 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) {
async function archiveFullGameToR2(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
const recordCopy = structuredClone(gameRecord);
// Players may see this so make sure to clear PII
recordCopy.players.forEach((p) => {
p.ip = "REDACTED";
recordCopy.info.players.forEach((p) => {
p.persistentID = "REDACTED";
});
try {
await r2.putObject({
Bucket: bucket,
Key: `${gameFolder}/${recordCopy.id}`,
Body: JSON.stringify(recordCopy),
Key: `${gameFolder}/${recordCopy.info.gameID}`,
Body: JSON.stringify(recordCopy, replacer),
ContentType: "application/json",
});
} catch (error) {
+1 -4
View File
@@ -1,16 +1,13 @@
import WebSocket from "ws";
import { TokenPayload } from "../core/ApiSchemas";
import { PlayerID, Tick } from "../core/game/Game";
import { Tick } from "../core/game/Game";
import { ClientID } from "../core/Schemas";
import { generateID } from "../core/Util";
export class Client {
public lastPing: number;
public hashes: Map<Tick, number> = new Map();
public readonly playerID: PlayerID = generateID();
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
+49 -32
View File
@@ -56,6 +56,7 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
private outOfSyncClients: Set<ClientID> = new Set();
constructor(
public readonly id: string,
@@ -200,7 +201,15 @@ export class GameServer {
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
}
if (clientMsg.type === "winner") {
if (
this.outOfSyncClients.has(client.clientID) ||
this.kickedClients.has(client.clientID) ||
this.winner !== null
) {
return;
}
this.winner = clientMsg;
this.archiveGame();
}
} catch (error) {
this.log.info(
@@ -291,7 +300,6 @@ export class GameServer {
gameID: this.id,
config: this.gameConfig,
players: this.activeClients.map((c) => ({
playerID: c.playerID,
username: c.username,
clientID: c.clientID,
pattern: c.pattern,
@@ -383,40 +391,16 @@ export class GameServer {
}
this.log.info(`ending game with ${this.turns.length} turns`);
try {
if (this.allClients.size > 0) {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => {
const stats = this.winner?.allPlayersStats[client.clientID];
if (stats === undefined) {
this.log.warn(
`Unable to find stats for clientID ${client.clientID}`,
);
}
return {
playerID: client.playerID,
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
stats,
} satisfies PlayerRecord;
});
archive(
createGameRecord(
this.id,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
Date.now(),
this.winner?.winner ?? null,
this.winner?.winnerType ?? null,
),
);
} else {
if (this.allClients.size === 0) {
this.log.info("no clients joined, not archiving game", {
gameID: this.id,
});
} else if (this.winner !== null) {
this.log.info("game already archived", {
gameID: this.id,
});
} else {
this.archiveGame();
}
} catch (error) {
let errorDetails;
@@ -550,6 +534,38 @@ export class GameServer {
}
}
private archiveGame() {
this.log.info("archiving game", {
gameID: this.id,
winner: this.winner?.winner,
});
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => {
const stats = this.winner?.allPlayersStats[client.clientID];
if (stats === undefined) {
this.log.warn(`Unable to find stats for clientID ${client.clientID}`);
}
return {
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
stats,
} satisfies PlayerRecord;
});
archive(
createGameRecord(
this.id,
this.gameStartInfo.config,
playerRecords,
this.turns,
this._startTime ?? 0,
Date.now(),
this.winner?.winner,
),
);
}
private handleSynchronization() {
if (this.activeClients.length <= 1) {
return;
@@ -587,6 +603,7 @@ export class GameServer {
const desyncMsg = JSON.stringify(serverDesync.data);
for (const c of outOfSyncClients) {
this.outOfSyncClients.add(c.clientID);
if (this.sentDesyncMessageClients.has(c.clientID)) {
continue;
}