Merge branch 'main' into keys-wrongly-displayed

This commit is contained in:
VariableVince
2026-05-01 00:51:13 +02:00
committed by GitHub
474 changed files with 6433 additions and 2081 deletions
+15 -14
View File
@@ -110,7 +110,7 @@ export class AccountModal extends BaseModal {
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.personal_player_id")}</span
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
@@ -241,12 +241,12 @@ export class AccountModal extends BaseModal {
private renderLogoutButton(): TemplateResult {
return html`
<button
@click="${this.handleLogout}"
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-red-600/80 hover:bg-red-600 border border-red-500/50 rounded-lg transition-all shadow-lg hover:shadow-red-900/40"
>
${translateText("account_modal.log_out")}
</button>
<o-button
variant="danger"
size="md"
translationKey="account_modal.log_out"
@click=${this.handleLogout}
></o-button>
`;
}
@@ -318,19 +318,20 @@ export class AccountModal extends BaseModal {
name="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all font-medium hover:bg-white/10"
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
placeholder="${translateText(
"account_modal.email_placeholder",
)}"
required
/>
</div>
<button
@click="${this.handleSubmit}"
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-500 hover:to-blue-600 rounded-xl transition-all shadow-lg hover:shadow-blue-900/40 border border-white/5"
>
${translateText("account_modal.get_magic_link")}
</button>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
</div>
</div>
+13 -2
View File
@@ -53,7 +53,7 @@ import {
} from "./Transport";
import { createCanvas } from "./Utils";
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
import { GoToPlayerEvent } from "./graphics/layers/Leaderboard";
import { GoToPlayerEvent } from "./graphics/TransformHandler";
import { SoundManager } from "./sound/SoundManager";
export interface LobbyConfig {
@@ -441,6 +441,8 @@ export class ClientGameRunner {
console.log("Connected to game server!");
this.transport.rejoinGame(this.turnsSeen);
};
let hasGoneToPlayer = false;
const onmessage = (message: ServerMessage) => {
this.lastMessageTime = Date.now();
if (message.type === "start") {
@@ -472,7 +474,7 @@ export class ClientGameRunner {
return;
}
this.eventBus.emit(new GoToPlayerEvent(myPlayer));
this.eventBus.emit(new GoToPlayerEvent(myPlayer, 10));
};
goToPlayer();
@@ -519,6 +521,15 @@ export class ClientGameRunner {
);
}
if (message.type === "turn") {
if (
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
this.gameView.myPlayer()
) {
hasGoneToPlayer = true;
this.eventBus.emit(new GoToPlayerEvent(this.gameView.myPlayer()!, 8));
}
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
+1 -1
View File
@@ -73,7 +73,7 @@ export class FlagInput extends LitElement {
return html`
<button
id="flag-input"
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+6 -5
View File
@@ -149,15 +149,16 @@ export class FlagInputModal extends BaseModal {
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
></o-button>
</div>
<div
+17 -13
View File
@@ -10,6 +10,7 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
import { JoinLobbyModal } from "./JoinLobbyModal";
@@ -27,7 +28,7 @@ import {
translateText,
} from "./Utils";
const CARD_BG = "bg-sky-950";
const CARD_BG = "bg-surface";
@customElement("game-mode-selector")
export class GameModeSelector extends LitElement {
@@ -119,7 +120,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
)}
</div>
<!-- Create/ranked/join: mobile only, below solo -->
@@ -127,21 +128,24 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
</div>
<!-- iOS Add to Home Screen banner -->
<ios-add-to-home-screen-banner></ios-add-to-home-screen-banner>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
@@ -188,7 +192,7 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.solo"),
this.openSinglePlayerModal,
"bg-[#0073b7] hover:bg-sky-500 active:bg-sky-700",
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
)}
</div>
<!-- Bottom row: create + ranked + join (desktop only) -->
@@ -196,19 +200,19 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
${!crazyGamesSDK.isOnCrazyGames()
? this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)
: html`<div class="invisible"></div>`}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-slate-600 hover:bg-slate-500 active:bg-slate-700",
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105",
)}
</div>
</div>
@@ -249,7 +253,7 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${onClick}
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-colors text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
>
${title}
</button>
@@ -295,7 +299,7 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${() => this.validateAndJoin(lobby)}
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] bg-sky-950"
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] bg-surface hover:shadow-[var(--shadow-lobby-card-hover)]"
>
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
<div
@@ -321,7 +325,7 @@ export class GameModeSelector extends LitElement {
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-[#0073b7] text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-malibu-blue text-white shadow-[var(--shadow-malibu-blue-pill)]"
>${label}</span
>`,
)}
@@ -331,7 +335,7 @@ export class GameModeSelector extends LitElement {
<span
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
? "uppercase"
: "normal-case"} bg-[#0073b7] text-white px-2 py-1 rounded"
: "normal-case"} bg-malibu-blue text-white px-2 py-1 rounded"
>${timeDisplay}</span
>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@ export class GameStartingModal extends LitElement {
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
target="_blank"
rel="noopener noreferrer"
class="block mb-4 text-lg font-medium tracking-wider uppercase text-sky-400 no-underline transition-colors duration-200 hover:text-sky-300"
class="block mb-4 text-lg font-medium tracking-wider uppercase text-malibu-blue no-underline transition-colors duration-200 hover:text-aquarius"
>${translateText("game_starting_modal.credits")}</a
>
<p class="text-base text-white/40 mb-4">
+1 -1
View File
@@ -128,7 +128,7 @@ export class HelpModal extends BaseModal {
</p>
<button
id="troubleshooting-button"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
data-page="page-troubleshooting"
@click="${this.openTroubleshooting}"
data-i18n="main.go_to_troubleshooting"
+26 -24
View File
@@ -22,7 +22,7 @@ import { generateID } from "../core/Util";
import { getPlayToken } from "./Auth";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import { CopyButton } from "./components/CopyButton";
import "./components/GameConfigSettings";
import "./components/LobbyPlayerView";
import "./components/ToggleInputCard";
@@ -404,15 +404,16 @@ export class HostLobbyModal extends BaseModal {
<!-- Player List / footer -->
<div class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0">
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
@click=${this.startGame}
?disabled=${this.clients.length < 2}
>
${this.clients.length === 1
<o-button
variant="primary"
width="block"
size="lg"
.title=${this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")}
</button>
?disable=${this.clients.length < 2}
@click=${this.startGame}
></o-button>
</div>
</div>
`;
@@ -439,6 +440,14 @@ export class HostLobbyModal extends BaseModal {
// Note: clientID will be assigned by server when we join the lobby
// lobbyCreatorClientID stays empty until then
// Copy immediately so the host can share the link without waiting for the
// server. If lobby creation fails, clear the clipboard to avoid a dead link.
void this.constructUrl().then(async (url) => {
this.updateHistory(url);
await this.updateComplete;
void (this.querySelector("copy-button") as CopyButton)?.handleCopy();
});
// Pass auth token for creator identification (server extracts persistentID from it)
createLobby(this.lobbyId)
.then(async (lobby) => {
@@ -447,8 +456,6 @@ export class HostLobbyModal extends BaseModal {
throw new Error(`Invalid lobby ID format: ${this.lobbyId}`);
}
crazyGamesSDK.showInviteButton(this.lobbyId);
const url = await this.constructUrl();
this.updateHistory(url);
})
.then(() => {
this.dispatchEvent(
@@ -461,6 +468,10 @@ export class HostLobbyModal extends BaseModal {
composed: true,
}),
);
})
.catch(() => {
// Clear clipboard so the host doesn't accidentally share a dead link
void navigator.clipboard.writeText("").catch(() => {});
});
if (this.modalEl) {
this.modalEl.onClose = () => {
@@ -1000,21 +1011,12 @@ export class HostLobbyModal extends BaseModal {
// If the modal closes as part of starting the game, do not leave the lobby
this.leaveLobbyOnClose = false;
const config = await getRuntimeClientServerConfig();
const response = await fetch(
`${window.location.origin}/${config.workerPath(this.lobbyId)}/api/start_game/${this.lobbyId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
this.dispatchEvent(
new CustomEvent("start-game", {
bubbles: true,
composed: true,
}),
);
if (!response.ok) {
this.leaveLobbyOnClose = true;
}
return response;
}
private kickPlayer(clientID: string) {
+11 -11
View File
@@ -160,7 +160,7 @@ export class JoinLobbyModal extends BaseModal {
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-malibu-blue hover:bg-aquarius disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
>
${translateText("private_lobby.joined_waiting")}
@@ -240,13 +240,12 @@ export class JoinLobbyModal extends BaseModal {
@keyup=${this.handleChange}
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
/>
<button
@click=${this.pasteFromClipboard}
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
title=${translateText("common.paste")}
>
<svg
class="text-white/60 group-hover:text-white transition-colors"
<o-button
variant="ghost"
size="md"
iconPosition="only"
.title=${translateText("common.paste")}
.icon=${html`<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
@@ -258,12 +257,13 @@ export class JoinLobbyModal extends BaseModal {
<path
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
></path>
</svg>
</button>
</svg>`}
@click=${this.pasteFromClipboard}
></o-button>
</div>
<o-button
title=${translateText("private_lobby.join_lobby")}
block
width="block"
submit
></o-button>
</div>
+1 -1
View File
@@ -58,7 +58,7 @@ export class LanguageModal extends BaseModal {
buttonClasses +=
" animate-pulse font-bold text-white border-2 border-dashed border-cyan-400 shadow-[0_0_15px_rgba(34,211,238,0.2)] bg-gradient-to-r from-red-600 via-yellow-600 via-green-600 via-blue-600 to-purple-600";
} else if (isActive) {
buttonClasses += " bg-blue-500/20 border-blue-500/50";
buttonClasses += " bg-malibu-blue/20 border-malibu-blue/50";
} else {
buttonClasses +=
" bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+26
View File
@@ -1,5 +1,6 @@
import version from "resources/version.txt?raw";
import { UserMeResponse } from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { EventBus } from "../core/EventBus";
import {
GAME_ID_REGEX,
@@ -51,6 +52,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
SendKickPlayerIntentEvent,
SendStartGameEvent,
SendUpdateGameConfigIntentEvent,
} from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
@@ -216,8 +218,17 @@ declare global {
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"start-game": CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<undefined>;
userMeResponse: CustomEvent<UserMeResponse | false>;
"leave-lobby": CustomEvent;
"update-game-config": CustomEvent;
}
// Fixes the globalThis.addEventListener errors
interface WindowEventMap {
"event:user-settings-changed:settings.darkMode": CustomEvent<string>;
}
}
@@ -267,6 +278,13 @@ class Client {
await customElements.whenDefined("mobile-nav-bar");
await customElements.whenDefined("desktop-nav-bar");
const openFrontFont = new FontFace(
"OpenFront",
`url(${assetUrl("fonts/OpenFront.ttf")})`,
);
document.fonts.add(openFrontFont);
openFrontFont.load().catch(() => {});
const versionElements = document.querySelectorAll(
"#game-version, .game-version-display",
);
@@ -276,6 +294,7 @@ class Client {
const trimmed = version.trim();
const displayVersion = trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
versionElements.forEach((el) => {
(el as HTMLElement).style.fontFamily = '"OpenFront", Inter, sans-serif';
el.textContent = displayVersion;
});
}
@@ -314,6 +333,7 @@ class Client {
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
document.addEventListener("start-game", this.handleStartGame.bind(this));
document.addEventListener(
"update-game-config",
this.handleUpdateGameConfig.bind(this),
@@ -935,6 +955,12 @@ class Client {
}
}
private handleStartGame() {
if (this.eventBus) {
this.eventBus.emit(new SendStartGameEvent());
}
}
private handleUpdateGameConfig(event: CustomEvent) {
const { config } = event.detail;
+1 -1
View File
@@ -123,7 +123,7 @@ export class MatchmakingModal extends BaseModal {
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
}
};
this.socket.onerror = (event: ErrorEvent) => {
this.socket.onerror = (event: Event) => {
console.error("WebSocket error occurred:", event);
};
this.socket.onclose = () => {
+2 -2
View File
@@ -111,7 +111,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] rounded-lg overflow-hidden"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 bg-surface rounded-lg overflow-hidden"
disabled
>
<span
@@ -131,7 +131,7 @@ export class PatternInput extends LitElement {
return html`
<button
id="pattern-input"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
title=${buttonTitle}
@click=${this.onInputClick}
>
+6 -5
View File
@@ -348,12 +348,13 @@ export class SinglePlayerModal extends BaseModal {
${translateText("single_modal.options_changed_no_achievements")}
</div>`
: null}
<button
<o-button
variant="primary"
width="block"
size="lg"
translationKey="single_modal.start"
@click=${this.startGame}
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-[#0073b7] hover:bg-sky-500 rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0"
>
${translateText("single_modal.start")}
</button>
></o-button>
</div>
</div>
`;
+3 -3
View File
@@ -52,7 +52,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "packs")}
>
@@ -61,7 +61,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "patterns")}
>
@@ -70,7 +70,7 @@ export class StoreModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "flags"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "flags")}
>
+6 -5
View File
@@ -149,15 +149,16 @@ export class TerritoryPatternsModal extends BaseModal {
</div>
</div>
<div class="flex justify-center py-3 shrink-0">
<button
class="no-crazygames px-4 py-2 text-sm font-bold uppercase tracking-wider rounded-lg bg-blue-600 hover:bg-blue-700 text-white cursor-pointer transition-colors"
<o-button
class="no-crazygames"
variant="primary"
size="sm"
translationKey="main.store"
@click=${() => {
this.close();
window.showPage?.("page-item-store");
}}
>
${translateText("main.store")}
</button>
></o-button>
</div>
<div
class="flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
+8
View File
@@ -173,6 +173,8 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent {
constructor(public readonly config: Partial<GameConfig>) {}
}
export class SendStartGameEvent implements GameEvent {}
export class Transport {
private socket: WebSocket | null = null;
@@ -262,6 +264,8 @@ export class Transport {
this.eventBus.on(SendUpdateGameConfigIntentEvent, (e) =>
this.onSendUpdateGameConfigIntent(e),
);
this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame());
}
private startPing() {
@@ -644,6 +648,10 @@ export class Transport {
});
}
private onSendStartGame() {
this.sendIntent({ type: "start_game" });
}
private sendIntent(intent: Intent) {
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
const msg = {
+5 -5
View File
@@ -46,12 +46,12 @@ export class TroubleshootingModal extends BaseModal {
>
/ ${translateText("troubleshooting.title")}
</span>
<button
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
<o-button
variant="primary"
size="sm"
translationKey="common.copy"
@click=${this.copyDiagnostics}
>
${translateText("common.copy")}
</button>
></o-button>
</div>`,
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
+2 -2
View File
@@ -351,7 +351,7 @@ export class UserSettingModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "basic"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "basic")}
>
@@ -360,7 +360,7 @@ export class UserSettingModal extends BaseModal {
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "keybinds"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "keybinds")}
>
+1 -1
View File
@@ -80,7 +80,7 @@ export class CopyButton extends LitElement {
return await this.buildCopyUrl();
}
private async handleCopy() {
async handleCopy() {
const text = await this.resolveCopyText();
if (!text) {
alert("Error copying game id");
+8 -8
View File
@@ -53,11 +53,11 @@ export class DesktopNavBar extends LitElement {
class="hidden lg:flex w-full bg-zinc-900/90 backdrop-blur-md items-center justify-center gap-8 py-4 shrink-0 z-50 relative"
>
<div class="flex flex-col items-center justify-center">
<div class="h-8 text-[#0073b7]">
<div class="h-8">
<img
class="block h-full aspect-[1364/259]"
src=${assetUrl("images/OpenFrontLogo.svg")}
alt="OpenFront"
class="h-full w-auto"
/>
</div>
<div
@@ -68,7 +68,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-play"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-play"
data-i18n="main.play"
></button>
@@ -77,7 +77,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-news"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-news"
data-i18n="main.news"
@click=${this._notifications.onNewsClick}
@@ -97,7 +97,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-item-store"
? "active"
: ""} text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
: ""} text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-item-store"
data-i18n="main.store"
@click=${this._notifications.onStoreClick}
@@ -114,18 +114,18 @@ export class DesktopNavBar extends LitElement {
: ""}
</div>
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-[#0073b7] font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-[#0073b7] "
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
data-page="page-help"
data-i18n="main.help"
@click=${this._notifications.onHelpClick}
+3 -3
View File
@@ -86,12 +86,12 @@ export class FluentSlider extends LitElement {
.max=${this.max}
.step=${this.step}
.valueAsNumber=${this.value}
style="background: linear-gradient(to right, #3b82f6 0%, #3b82f6 ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
style="background: linear-gradient(to right, var(--color-malibu-blue) 0%, var(--color-malibu-blue) ${percentage}%, rgba(255, 255, 255, 0.15) ${percentage}%, rgba(255, 255, 255, 0.15) 100%); background-size: 100% 6px; background-repeat: no-repeat; background-position: center; border-radius: 9999px;"
class="w-full h-6 p-0 m-0 bg-transparent appearance-none cursor-pointer focus:outline-none
[&::-webkit-slider-runnable-track]:w-full [&::-webkit-slider-runnable-track]:h-[6px] [&::-webkit-slider-runnable-track]:cursor-pointer [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:transition-colors
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-malibu-blue [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]
[&::-moz-range-track]:w-full [&::-moz-range-track]:h-[6px] [&::-moz-range-track]:cursor-pointer [&::-moz-range-track]:bg-transparent [&::-moz-range-track]:rounded-full [&::-moz-range-track]:transition-colors
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-malibu-blue [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]"
@input=${this.handleSliderInput}
@change=${this.handleSliderChange}
/>
+3 -3
View File
@@ -24,7 +24,7 @@ import "./FluentSlider";
import "./map/MapPicker";
const ACTIVE_CARD =
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
@@ -317,8 +317,8 @@ export class GameConfigSettings extends LitElement {
<div class=${this.sectionGapClass}>
${renderSection(
MAP_ICON,
"text-blue-400",
"bg-blue-500/20",
"text-aquarius",
"bg-malibu-blue/20",
"map.map",
html`<map-picker
.selectedMap=${settings.map.selected}
@@ -0,0 +1,195 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Platform } from "../Platform";
import { translateText } from "../Utils";
const DISMISSED_KEY = "ios_a2hs_banner_dismissed";
const LATER_KEY = "ios_a2hs_banner_later";
@customElement("ios-add-to-home-screen-banner")
export class IOSAddToHomeScreenBanner extends LitElement {
@state() private dismissed = false;
@state() private later = false;
@state() private showGuide = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
try {
this.dismissed = localStorage.getItem(DISMISSED_KEY) === "true";
} catch {
this.dismissed = false;
}
try {
this.later = sessionStorage.getItem(LATER_KEY) === "true";
} catch {
this.later = false;
}
}
private never() {
try {
localStorage.setItem(DISMISSED_KEY, "true");
} catch {
// localStorage unavailable — dismiss for session only
}
this.dismissed = true;
}
private later_() {
try {
sessionStorage.setItem(LATER_KEY, "true");
} catch {
// ignore — this.later still set in memory
}
this.later = true;
}
private openGuide() {
this.showGuide = true;
}
private closeGuide() {
this.showGuide = false;
}
private renderGuideModal() {
if (!this.showGuide) return nothing;
return html`
<div
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9999] flex items-end sm:items-center justify-center p-4"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.closeGuide();
}}
>
<div class="relative w-full max-w-sm">
<div
class="bg-slate-800 border border-slate-600 rounded-2xl w-full p-5 pb-6 flex flex-col gap-4"
role="dialog"
aria-modal="true"
aria-labelledby="ios-banner-modal-title"
>
<div class="flex items-center justify-between">
<h2
id="ios-banner-modal-title"
class="text-white font-bold text-lg"
>
${translateText("ios_banner.modal_title")}
</h2>
<button
class="text-slate-400 hover:text-white text-2xl leading-none"
@click=${this.closeGuide}
aria-label=${translateText("common.close")}
>
×
</button>
</div>
<p class="text-slate-300 text-sm">
${translateText("ios_banner.modal_desc")}
</p>
<ol class="flex flex-col gap-3 text-sm text-slate-200">
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>1</span
>
<span>${translateText("ios_banner.step_share")}</span>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>2</span
>
<span
>${translateText("ios_banner.step_scroll_and_tap")}
<strong class="text-white"
>${translateText(
"ios_banner.step_add_to_home_label",
)}</strong
></span
>
</li>
<li class="flex items-start gap-3">
<span
class="shrink-0 w-6 h-6 rounded-full bg-malibu-blue flex items-center justify-center text-white font-bold text-xs"
>3</span
>
<span>${translateText("ios_banner.step_open")}</span>
</li>
</ol>
<button
class="w-full py-2.5 rounded-lg bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 text-white font-semibold transition-colors"
@click=${this.closeGuide}
>
${translateText("ios_banner.got_it")}
</button>
</div>
</div>
</div>
`;
}
render() {
if (!Platform.isIOS) return nothing;
if (this.dismissed || this.later) return nothing;
if (
(navigator as any).standalone === true ||
window.matchMedia("(display-mode: standalone)").matches
) {
return nothing;
}
return html`
${this.renderGuideModal()}
<div
class="flex flex-col gap-3 w-full px-3 py-3 rounded-xl bg-slate-800/90 border border-slate-600 text-sm text-slate-200"
>
<div class="flex gap-3 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="shrink-0 w-8 h-8 text-malibu-blue"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
<span>${translateText("ios_banner.text")}</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="w-full py-1.5 rounded-lg bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 text-white font-semibold text-sm transition-colors"
@click=${this.openGuide}
>
${translateText("ios_banner.how")}
</button>
<button
class="w-full py-1.5 rounded-lg bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-300 text-sm transition-colors"
@click=${this.later_}
>
${translateText("ios_banner.later")}
</button>
<button
class="w-full py-1.5 rounded-lg text-slate-500 hover:text-slate-400 text-xs transition-colors"
@click=${this.never}
>
${translateText("ios_banner.never")}
</button>
</div>
</div>
`;
}
}
+2 -2
View File
@@ -126,7 +126,7 @@ export class LobbyTeamView extends LitElement {
return html`<div
class="px-2 py-1 rounded-sm mb-1 text-xs text-white border
${this.isCurrentPlayer(client)
? "bg-[#0073b7]/20 border-sky-500/40"
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
@@ -242,7 +242,7 @@ export class LobbyTeamView extends LitElement {
return html` <div
class="px-2 py-1 rounded-sm text-xs flex items-center justify-between border
${this.isCurrentPlayer(p)
? "bg-[#0073b7]/20 border-sky-500/40"
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
+1 -1
View File
@@ -73,7 +73,7 @@ export class MobileNavBar extends LitElement {
>
<!-- Logo + Menu -->
<div
class="flex flex-col text-[#0073b7] mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
class="flex flex-col text-malibu-blue mb-[clamp(1rem,2vh,2rem)] ml-[clamp(0.2rem,0.4vw,0.4vh)]"
>
<div class="flex flex-col items-center gap-2">
<img
+1 -1
View File
@@ -115,7 +115,7 @@ export class NewsBox extends LitElement {
return html`
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
class="px-2 py-2 bg-surface border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
>
<div class="flex items-center gap-3">
<span
+3 -3
View File
@@ -19,7 +19,7 @@ export class PlayPage extends LitElement {
<!-- Mobile: Fixed top bar -->
<div
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-b border-white/10"
class="lg:hidden fixed left-0 right-0 top-0 z-40 pt-[env(safe-area-inset-top)] bg-surface border-b border-white/10"
>
<div
class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center h-14 px-2 gap-2"
@@ -50,7 +50,7 @@ export class PlayPage extends LitElement {
</button>
<div
class="col-start-2 flex items-center justify-center text-[#0073b7] min-w-0"
class="col-start-2 flex items-center justify-center text-malibu-blue min-w-0"
>
<img
src=${assetUrl("images/OpenFrontLogo.svg")}
@@ -79,7 +79,7 @@ export class PlayPage extends LitElement {
<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
class="px-2 py-2 bg-surface border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<username-input
+1 -1
View File
@@ -128,7 +128,7 @@ export class RankedModal extends BaseModal {
return html`
<button
@click=${onClick}
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)] border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-surface border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
>
<div class="flex flex-col items-center gap-1 text-center">
<h3
+2 -2
View File
@@ -3,11 +3,11 @@ import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
const ACTIVE_CARD =
"bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
const INPUT_CLASS =
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1";
const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+76 -28
View File
@@ -1,51 +1,99 @@
import { LitElement, html } from "lit";
import { LitElement, TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { translateText } from "../../Utils";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
type ButtonWidth = "auto" | "block" | "blockDesktop" | "fill";
type IconPosition = "left" | "right" | "only";
@customElement("o-button")
export class OButton extends LitElement {
@property({ type: String }) title = "";
@property({ type: String }) translationKey = "";
@property({ type: Boolean }) secondary = false;
@property({ type: Boolean }) block = false;
@property({ type: Boolean }) blockDesktop = false;
@property() title = "";
@property() translationKey = "";
@property() variant: ButtonVariant = "primary";
@property() size: ButtonSize = "md";
@property() width: ButtonWidth = "auto";
@property() iconPosition: IconPosition = "left";
@property({ attribute: false }) icon?: TemplateResult;
@property({ type: Boolean }) disable = false;
@property({ type: Boolean }) fill = false;
@property({ type: Boolean }) submit = false;
private static readonly BASE_CLASS =
"bg-[#0073b7] hover:bg-sky-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
createRenderRoot() {
return this;
}
private getButtonClasses(): Record<string, boolean> {
return {
[OButton.BASE_CLASS]: true,
"w-full block": this.block,
"h-full w-full flex items-center justify-center": this.fill,
"lg:w-auto lg:inline-block":
!this.block && !this.blockDesktop && !this.fill,
"lg:w-1/2 lg:mx-auto lg:block": this.blockDesktop,
"bg-gray-700 text-gray-100 hover:bg-gray-600": this.secondary,
"disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:bg-gray-600":
this.disable,
};
private readonly BASE =
"font-bold uppercase tracking-wider rounded-xl border border-transparent " +
"transition-all duration-300 transform hover:-translate-y-px " +
"outline-none text-center whitespace-normal break-words leading-tight overflow-hidden relative " +
"disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:opacity-70";
private variantClasses(): string {
switch (this.variant) {
case "primary":
return "bg-malibu-blue hover:bg-aquarius text-white disabled:bg-gray-600 disabled:text-gray-300";
case "secondary":
return "bg-gray-700 hover:bg-gray-600 text-white disabled:bg-gray-800 disabled:text-gray-400";
case "danger":
return "bg-red-600 hover:bg-red-500 text-white disabled:bg-red-900 disabled:text-gray-300";
case "ghost":
return "bg-transparent hover:bg-white/10 text-malibu-blue disabled:text-gray-500 disabled:hover:bg-transparent";
}
}
private sizeClasses(): string {
if (this.iconPosition === "only") {
switch (this.size) {
case "sm":
return "w-8 h-8 text-sm";
case "md":
return "w-10 h-10 text-base";
case "lg":
return "w-12 h-12 text-lg";
}
}
switch (this.size) {
case "sm":
return "py-1.5 px-3 text-sm";
case "md":
return "py-3 px-4 text-base lg:text-lg";
case "lg":
return "py-4 px-6 text-lg lg:text-xl";
}
}
private widthClasses(): string {
switch (this.width) {
case "auto":
return "inline-flex items-center justify-center gap-2";
case "block":
return "flex w-full items-center justify-center gap-2";
case "blockDesktop":
return "flex w-full items-center justify-center gap-2 lg:w-1/2 lg:mx-auto";
case "fill":
return "flex w-full h-full items-center justify-center gap-2";
}
}
render() {
const label =
this.translationKey === ""
? this.title
: translateText(this.translationKey);
const iconOnly = this.iconPosition === "only";
const classes = `${this.BASE} ${this.variantClasses()} ${this.sizeClasses()} ${this.widthClasses()}`;
return html`
<button
class=${classMap(this.getButtonClasses())}
class=${classes}
?disabled=${this.disable}
type=${this.submit ? "submit" : "button"}
aria-label=${iconOnly ? label : nothing}
>
<span class="block min-w-0">
${this.translationKey === ""
? this.title
: translateText(this.translationKey)}
</span>
${this.icon && this.iconPosition !== "right" ? this.icon : nothing}
${iconOnly ? nothing : html`<span class="min-w-0">${label}</span>`}
${this.icon && this.iconPosition === "right" ? this.icon : nothing}
</button>
`;
}
@@ -240,7 +240,7 @@ export class PlayerRow extends LitElement {
private renderTag(tag: string) {
return html`
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
class="px-2.5 py-1 rounded bg-malibu-blue/10 border border-malibu-blue/20 text-aquarius font-bold text-xs tracking-wide group-hover:bg-malibu-blue/20 transition-colors"
>
${tag}
</div>
@@ -64,7 +64,7 @@ export class RankingControls extends LitElement {
return html`
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest hover:text-white hover:bg-white/5 border ${active
? "bg-blue-500/20 text-blue-400 border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-aquarius border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
: "text-white/40 border-transparent"}"
@click=${() => this.onSort(type)}
>
@@ -70,11 +70,11 @@ export class SettingSlider extends LitElement {
<input
type="range"
class="flex-1 w-auto appearance-none h-2 bg-transparent rounded outline-none
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,#3b82f6_0%,#3b82f6_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]
[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded [&::-webkit-slider-runnable-track]:bg-[image:linear-gradient(to_right,var(--color-malibu-blue)_0%,var(--color-malibu-blue)_var(--fill),rgba(255,255,255,0.1)_var(--fill),rgba(255,255,255,0.1)_100%)]
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-[18px] [&::-webkit-slider-thumb]:w-[18px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-malibu-blue [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:-mt-[6px] [&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-webkit-slider-thumb]:transition-all active:[&::-webkit-slider-thumb]:scale-110 active:[&::-webkit-slider-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]
[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded [&::-moz-range-track]:bg-white/10
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-blue-500
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-500 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[0_0_0_4px_rgba(59,130,246,0.2)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[0_0_0_6px_rgba(59,130,246,0.3)]"
[&::-moz-range-progress]:h-2 [&::-moz-range-progress]:rounded [&::-moz-range-progress]:bg-malibu-blue
[&::-moz-range-thumb]:h-[18px] [&::-moz-range-thumb]:w-[18px] [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-malibu-blue [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-sm)] [&::-moz-range-thumb]:transition-all active:[&::-moz-range-thumb]:scale-110 active:[&::-moz-range-thumb]:shadow-[var(--shadow-malibu-blue-ring-lg)]"
min=${this.min}
max=${this.max}
.value=${String(this.value)}
@@ -4,6 +4,7 @@ import { PlayerGame } from "../../../../core/ApiSchemas";
import { GameMode } from "../../../../core/game/Game";
import { GameInfoModal } from "../../../GameInfoModal";
import { translateText } from "../../../Utils";
import "../../CopyButton";
@customElement("game-list")
export class GameList extends LitElement {
@@ -46,7 +47,7 @@ export class GameList extends LitElement {
>
<div class="flex items-center gap-4">
<button
class="p-2 bg-blue-500/20 rounded-lg text-blue-400"
class="p-2 bg-malibu-blue/20 rounded-lg text-aquarius"
@click=${() => this.onViewGame?.(game.gameId)}
>
<svg
@@ -115,7 +116,10 @@ export class GameList extends LitElement {
>
${translateText("game_list.game_id")}
</div>
<div class="text-white font-mono">${game.gameId}</div>
<copy-button
.copyText="${game.gameId}"
compact
></copy-button>
</div>
<div>
<div
+1 -1
View File
@@ -86,7 +86,7 @@ export class MapDisplay extends LitElement {
@keydown="${this.handleKeydown}"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.selected
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
? "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue-strong)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
>
${this.isLoading
+3 -3
View File
@@ -125,7 +125,7 @@ export class MapPicker extends LitElement {
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "text-white/60 hover:text-white"
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
: "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"}"
@click=${() => (this.showAllMaps = false)}
>
${translateText("map.featured")}
@@ -136,7 +136,7 @@ export class MapPicker extends LitElement {
aria-selected=${this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
? "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"
: "text-white/60 hover:text-white"}"
@click=${() => (this.showAllMaps = true)}
>
@@ -160,7 +160,7 @@ export class MapPicker extends LitElement {
type="button"
class="w-full h-full p-3 flex flex-col items-center justify-between rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 gap-3 group ${this
.useRandomMap
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
? "bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue-strong)]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"}"
@click=${this.handleSelectRandomMap}
>
+50 -11
View File
@@ -1,12 +1,25 @@
import { EventBus } from "../../core/EventBus";
import { EventBus, GameEvent } from "../../core/EventBus";
import { Cell } from "../../core/game/Game";
import { GameView } from "../../core/game/GameView";
import { GameView, PlayerView, UnitView } from "../../core/game/GameView";
import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./layers/Leaderboard";
export class GoToPlayerEvent implements GameEvent {
constructor(
public player: PlayerView,
public zoom?: number,
) {}
}
export class GoToPositionEvent implements GameEvent {
constructor(
public x: number,
public y: number,
) {}
}
export class GoToUnitEvent implements GameEvent {
constructor(public unit: UnitView) {}
}
export const GOTO_INTERVAL_MS = 16;
export const CAMERA_MAX_SPEED = 15;
@@ -20,6 +33,7 @@ export class TransformHandler {
private lastGoToCallTime: number | null = null;
private target: Cell | null;
private targetScale: number | null = null;
private intervalID: NodeJS.Timeout | null = null;
private changed = false;
@@ -183,6 +197,7 @@ export class TransformHandler {
return;
}
this.target = new Cell(nameLocation.x, nameLocation.y);
this.targetScale = event.zoom ?? null;
this.intervalID = setInterval(() => this.goTo(), GOTO_INTERVAL_MS);
}
@@ -214,10 +229,12 @@ export class TransformHandler {
if (this.target === null) throw new Error("null target");
if (
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) <
2
) {
const positionClose =
Math.abs(this.target.x - screenX) + Math.abs(this.target.y - screenY) < 2;
const scaleClose =
this.targetScale === null ||
Math.abs(this.scale - this.targetScale) < 0.01;
if (positionClose && scaleClose) {
this.clearTarget();
return;
}
@@ -242,6 +259,27 @@ export class TransformHandler {
-CAMERA_MAX_SPEED,
);
if (this.targetScale !== null) {
const oldScale = this.scale;
const zoomSmoothing = 0.7;
const zoomR = 1 - Math.pow(zoomSmoothing, dt / 1000);
const diff = this.targetScale - this.scale;
const smoothStep = diff * zoomR;
const minStep =
Math.sign(diff) * Math.min(Math.abs(diff), (6.0 * dt) / 1000);
this.scale +=
Math.abs(smoothStep) >= Math.abs(minStep) ? smoothStep : minStep;
// Keep screen center pinned as scale changes: (canvasSize - mapSize) / (2 * scale)
// shifts the apparent center when canvas != map dimensions (always on mobile).
const { width: canvasWidth, height: canvasHeight } = this.boundingRect();
this.offsetX +=
(canvasWidth - this.game.width()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
this.offsetY +=
(canvasHeight - this.game.height()) *
(1 / (2 * oldScale) - 1 / (2 * this.scale));
}
this.changed = true;
}
@@ -321,6 +359,7 @@ export class TransformHandler {
this.intervalID = null;
}
this.target = null;
this.targetScale = null;
}
override(x: number = 0, y: number = 0, s: number = 1) {
@@ -7,21 +7,60 @@ import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
export function troopAttackColor(
attackerTroops: number,
defenderTroops: number,
): string {
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
const OUTGOING_COLOR = "var(--color-aquarius)";
const INCOMING_COLOR = "var(--color-red-400)";
// At/above this zoom, the label stays at its full screen size. Below it the
// label shrinks linearly with zoom-out, floored so it never disappears.
const LABEL_FULL_SIZE_ZOOM = 1.5;
const LABEL_MIN_SCREEN_SCALE = 0.5;
const OUTGOING_ICON_FILTER =
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
const INCOMING_ICON_FILTER =
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
// Vertical strength bar to the left of the icon: grows in height as the
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
const BAR_FULL_HEIGHT_RATIO = 2;
const BAR_MAX_HEIGHT_PX = 13;
// Element scale factor that, combined with the container's `scale(zoom)`,
// yields the desired on-screen label size: constant screen size when zoomed
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
export function computeLabelScale(zoom: number): number {
const netScale = Math.max(
LABEL_MIN_SCREEN_SCALE,
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
);
return netScale / zoom;
}
export function troopDefenceColor(
// Fraction (01) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
// of the opposing troops.
export function computeBarStrength(
attackerTroops: number,
myTroops: number,
): string {
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
opposingTroops: number,
): number {
if (opposingTroops <= 0) return 1;
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
}
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
// ordering tick-to-tick. If swapping brings each new position closer to where
// its label already is, swap `next` in place. (clusteredPositions caps at 2.)
export function alignClusterOrder(next: Cell[], prev: (Cell | null)[]): void {
const [a, b] = prev;
if (next.length !== 2 || !a || !b) return;
const dist = (p: Cell, q: Cell) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y);
const direct = dist(next[0], a) + dist(next[1], b);
const swapped = dist(next[1], a) + dist(next[0], b);
if (swapped < direct) [next[0], next[1]] = [next[1], next[0]];
}
// An attack can have multiple disconnected front-line segments, so elements
@@ -31,7 +70,7 @@ interface AttackLabel {
positions: (Cell | null)[];
isIncoming: boolean;
attackerTroops: number;
defenderTroops: number;
barStrength: number;
}
export class AttackingTroopsOverlay implements Layer {
@@ -42,6 +81,9 @@ export class AttackingTroopsOverlay implements Layer {
private inFlightRequest = false;
private isVisible = true;
private onAlternateView: (e: AlternateViewEvent) => void;
// Last transform string written per element; lets renderLayer skip identical
// re-assignments every frame (~60fps × N labels).
private lastTransform = new WeakMap<HTMLDivElement, string>();
constructor(
private readonly game: GameView,
@@ -84,6 +126,10 @@ export class AttackingTroopsOverlay implements Layer {
return 200;
}
private labelScale(): number {
return computeLabelScale(this.transformHandler.scale);
}
tick() {
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
if (this.labels.size > 0) this.clearAllLabels();
@@ -98,7 +144,7 @@ export class AttackingTroopsOverlay implements Layer {
const activeIDs = new Set<string>();
// Outgoing attacks — green if winning, amber if losing.
// Outgoing: cyan bar widens as our attack outnumbers the defender.
for (const attack of myPlayer.outgoingAttacks()) {
activeIDs.add(attack.id);
if (!attack.targetID) {
@@ -110,10 +156,11 @@ export class AttackingTroopsOverlay implements Layer {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
const barStrength = computeBarStrength(attack.troops, defender.troops());
this.ensureLabel(attack.id, attack.troops, false, barStrength);
}
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
// Incoming: red bar widens as the attacker outnumbers the player.
for (const attack of myPlayer.incomingAttacks()) {
activeIDs.add(attack.id);
const attacker = this.game.playerBySmallID(attack.attackerID);
@@ -121,7 +168,8 @@ export class AttackingTroopsOverlay implements Layer {
this.removeLabel(attack.id);
continue;
}
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
this.ensureLabel(attack.id, attack.troops, true, barStrength);
}
for (const [id] of this.labels) {
@@ -153,8 +201,8 @@ export class AttackingTroopsOverlay implements Layer {
private ensureLabel(
attackID: string,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
) {
let label = this.labels.get(attackID);
if (!label) {
@@ -163,15 +211,15 @@ export class AttackingTroopsOverlay implements Layer {
positions: [],
isIncoming,
attackerTroops,
defenderTroops,
barStrength,
};
this.labels.set(attackID, label);
} else {
label.attackerTroops = attackerTroops;
label.defenderTroops = defenderTroops;
label.barStrength = barStrength;
}
for (const el of label.elements) {
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.updateLabelContent(el, attackerTroops, barStrength);
}
}
@@ -185,6 +233,8 @@ export class AttackingTroopsOverlay implements Layer {
);
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
// Hoist the per-frame label scale once; zoom is constant within a frame.
const scale = this.labelScale();
for (const label of this.labels.values()) {
for (let i = 0; i < label.elements.length; i++) {
const el = label.elements[i];
@@ -196,9 +246,14 @@ export class AttackingTroopsOverlay implements Layer {
}
el.style.display = "inline-flex";
// Centre the label on its world position and counter-scale so text
// stays the same screen size regardless of zoom level.
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
// Centre the label on its world position; counter-scale keeps the
// label at constant screen size while zoomed in, then it shrinks
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
if (this.lastTransform.get(el) !== transform) {
el.style.transform = transform;
this.lastTransform.set(el, transform);
}
}
}
}
@@ -209,8 +264,8 @@ export class AttackingTroopsOverlay implements Layer {
lbl.elements.push(
this.createLabelElement(
lbl.attackerTroops,
lbl.defenderTroops,
lbl.isIncoming,
lbl.barStrength,
),
);
lbl.positions.push(null);
@@ -222,16 +277,20 @@ export class AttackingTroopsOverlay implements Layer {
lbl.positions.pop();
}
// Snap large jumps instantly; let the CSS transition handle small advances.
alignClusterOrder(positions, lbl.positions);
// Snap teleport-sized jumps instantly; let the CSS transition handle the rest.
for (let i = 0; i < positions.length; i++) {
const old = lbl.positions[i];
const next = positions[i];
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
const el = lbl.elements[i];
el.style.transition = "none";
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
el.style.transform = transform;
this.lastTransform.set(el, transform);
requestAnimationFrame(() => {
el.style.transition = "transform 0.2s ease-out";
el.style.transition = "transform 0.25s linear";
});
}
lbl.positions[i] = next;
@@ -245,33 +304,53 @@ export class AttackingTroopsOverlay implements Layer {
el.style.alignItems = "center";
el.style.gap = "3px";
el.style.whiteSpace = "nowrap";
el.style.fontSize = "11px";
el.style.fontSize = "14px";
el.style.fontWeight = "bold";
el.style.padding = "1px 4px";
el.style.padding = "2px 5px";
el.style.borderRadius = "3px";
el.style.backgroundColor = "rgba(0,0,0,0.55)";
el.style.backgroundColor = "rgba(0,0,0,0.85)";
el.style.pointerEvents = "none";
el.style.lineHeight = "1.3";
el.style.transition = "transform 0.2s ease-out";
el.style.transition = "transform 0.25s linear";
el.style.width = "max-content";
const bar = document.createElement("div");
bar.style.width = "2px";
bar.style.borderRadius = "1px";
bar.style.alignSelf = "flex-end";
bar.style.transition = "height 0.25s linear";
el.appendChild(bar);
const icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
icon.style.width = "13px";
icon.style.height = "13px";
el.appendChild(icon);
const span = document.createElement("span");
span.style.minWidth = "25px";
el.appendChild(span);
return el;
}
private createLabelElement(
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
el.style.fontFamily = this.game.config().theme().font();
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
const bar = el.children[0] as HTMLDivElement;
const icon = el.children[1] as HTMLImageElement;
const span = el.children[2] as HTMLSpanElement;
icon.src = soldierIcon;
icon.style.filter = isIncoming
? INCOMING_ICON_FILTER
: OUTGOING_ICON_FILTER;
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
span.textContent = renderTroops(attackerTroops);
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
this.container.appendChild(el);
return el;
}
@@ -279,20 +358,12 @@ export class AttackingTroopsOverlay implements Layer {
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
barStrength: number,
) {
const icon = el.children[0] as HTMLImageElement;
const span = el.children[1] as HTMLSpanElement;
if (isIncoming) {
icon.src = shieldIcon;
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
} else {
icon.src = swordIcon;
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
span.textContent = renderTroops(attackerTroops);
}
const bar = el.children[0] as HTMLDivElement;
const span = el.children[2] as HTMLSpanElement;
span.textContent = renderTroops(attackerTroops);
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
}
private removeLabel(attackID: string) {
+16 -16
View File
@@ -16,13 +16,13 @@ import {
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { getColoredSprite } from "../SpriteLoader";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import {
GoToPlayerEvent,
GoToPositionEvent,
GoToUnitEvent,
} from "./Leaderboard";
} from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
@@ -283,7 +283,7 @@ export class AttacksDisplay extends LitElement implements Layer {
> `,
onClick: async () => this.attackWarningOnClick(attack),
className:
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!attack.retreating
@@ -293,7 +293,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: attack.retreating,
})
: html`<span class="ml-auto truncate text-blue-400"
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -319,7 +319,7 @@ export class AttacksDisplay extends LitElement implements Layer {
><span class="ml-1">${renderTroops(landAttack.troops)}</span>
${translateText("help_modal.ui_wilderness")}`,
className:
"text-left text-sky-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!landAttack.retreating
@@ -329,7 +329,7 @@ export class AttacksDisplay extends LitElement implements Layer {
className: "ml-auto text-left shrink-0",
disabled: landAttack.retreating,
})
: html`<span class="ml-auto truncate text-blue-400"
: html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`}
</div>
@@ -374,19 +374,19 @@ export class AttacksDisplay extends LitElement implements Layer {
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
"text-left text-blue-400 inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
"text-left text-aquarius inline-flex items-center gap-0.5 lg:gap-1 min-w-0",
translate: false,
})}
${!boat.retreating()
? this.renderButton({
content: "❌",
${boat.transportShipState().isRetreating
? html`<span class="ml-auto truncate text-aquarius"
>(${translateText("events_display.retreating")}...)</span
>`
: this.renderButton({
content: "\u274C",
onClick: () => this.emitBoatCancelIntent(boat.id()),
className: "ml-auto text-left shrink-0",
disabled: boat.retreating(),
})
: html`<span class="ml-auto truncate text-blue-400"
>(${translateText("events_display.retreating")}...)</span
>`}
disabled: boat.transportShipState().isRetreating,
})}
</div>
`,
);
+1 -1
View File
@@ -4,7 +4,7 @@ import { customElement, query } from "lit/decorators.js";
import { PlayerType } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import quickChatData from "resources/QuickChat.json" with { type: "json" };
import quickChatData from "resources/QuickChat.json";
import { EventBus } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { SendQuickChatEvent } from "../../Transport";
+6 -6
View File
@@ -158,13 +158,13 @@ export class ControlPanel extends LitElement implements Layer {
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-sky-700 transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -213,13 +213,13 @@ export class ControlPanel extends LitElement implements Layer {
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-sky-700 transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-aquarius transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
@@ -326,7 +326,7 @@ export class ControlPanel extends LitElement implements Layer {
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="flex-1 h-1.5 accent-blue-500 cursor-pointer"
class="flex-1 h-1.5 accent-aquarius cursor-pointer"
/>
</div>
`;
@@ -373,7 +373,7 @@ export class ControlPanel extends LitElement implements Layer {
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
class="w-full h-1.5 accent-blue-500 cursor-pointer"
class="w-full h-1.5 accent-aquarius cursor-pointer"
/>
</div>
</div>
+1 -1
View File
@@ -34,7 +34,7 @@ import { Layer } from "./Layer";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { onlyImages } from "../../../core/Util";
import { renderNumber } from "../../Utils";
import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { GoToPlayerEvent, GoToUnitEvent } from "../TransformHandler";
import { PlaySoundEffectEvent } from "../../sound/Sounds";
import { getMessageTypeClasses, translateText } from "../../Utils";
+30 -39
View File
@@ -26,7 +26,6 @@ export class FxLayer implements Layer {
private allFx: Fx[] = [];
private hasBufferedFrame = false;
private constructionState: Map<number, boolean> = new Map();
constructor(
private game: GameView,
@@ -114,6 +113,26 @@ export class FxLayer implements Layer {
this.eventBus.emit(new PlaySoundEffectEvent("build-warship"));
}
break;
case UnitType.City:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
}
break;
case UnitType.Port:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
}
break;
case UnitType.DefensePost:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
}
break;
case UnitType.SAMLauncher:
if (unit.owner() === this.game.myPlayer()) {
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
}
break;
}
}
@@ -207,44 +226,16 @@ export class FxLayer implements Layer {
}
onStructureEvent(unit: UnitView) {
if (!unit.isActive()) {
if (this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
this.constructionState.delete(unit.id());
} else {
const wasUnderConstruction = this.constructionState.get(unit.id());
this.constructionState.set(unit.id(), unit.isUnderConstruction());
if (wasUnderConstruction && !unit.isUnderConstruction()) {
if (unit.owner() === this.game.myPlayer()) {
this.onStructureBuilt(unit);
}
}
}
}
onStructureBuilt(unit: UnitView) {
switch (unit.type()) {
case UnitType.City:
this.eventBus.emit(new PlaySoundEffectEvent("build-city"));
break;
case UnitType.Port:
this.eventBus.emit(new PlaySoundEffectEvent("build-port"));
break;
case UnitType.DefensePost:
this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post"));
break;
case UnitType.SAMLauncher:
this.eventBus.emit(new PlaySoundEffectEvent("sam-built"));
break;
if (!unit.isActive() && this.fxEnabled()) {
const x = this.game.x(unit.lastTile());
const y = this.game.y(unit.lastTile());
const explosion = new SpriteFx(
this.animatedSpriteLoader,
x,
y,
FxType.BuildingExplosion,
);
this.allFx.push(explosion);
}
}
@@ -18,6 +18,8 @@ const FastForwardIconSolid = assetUrl("images/FastForwardIconSolidWhite.svg");
const pauseIcon = assetUrl("images/PauseIconWhite.svg");
const playIcon = assetUrl("images/PlayIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg");
const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg");
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
@@ -36,6 +38,9 @@ export class GameRightSidebar extends LitElement implements Layer {
@state()
private isPaused: boolean = false;
@state()
private isFullscreen: boolean = false;
@state()
private timer: number = 0;
@@ -80,6 +85,21 @@ export class GameRightSidebar extends LitElement implements Layer {
this.requestUpdate();
}
private onFullscreenChange = () => {
this.isFullscreen = !!document.fullscreenElement;
};
connectedCallback() {
super.connectedCallback();
document.addEventListener("fullscreenchange", this.onFullscreenChange);
this.onFullscreenChange();
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener("fullscreenchange", this.onFullscreenChange);
}
getTickIntervalMs() {
return 250;
}
@@ -177,6 +197,18 @@ export class GameRightSidebar extends LitElement implements Layer {
);
}
private onFullscreenButtonClick() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.warn("Failed to enter fullscreen:", err);
});
} else {
document.exitFullscreen().catch((err) => {
console.warn("Failed to exit fullscreen:", err);
});
}
}
render() {
if (this.game === undefined) return html``;
@@ -204,6 +236,22 @@ export class GameRightSidebar extends LitElement implements Layer {
<img src=${settingsIcon} alt="settings" width="20" height="20" />
</div>
${document.fullscreenEnabled
? html`<div
class="cursor-pointer"
@click=${this.onFullscreenButtonClick}
>
<img
src=${this.isFullscreen ? exitFullscreenIcon : fullscreenIcon}
alt=${this.isFullscreen
? translateText("fullscreen.exit")
: translateText("fullscreen.enter")}
width="20"
height="20"
/>
</div>`
: ""}
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
<img src=${exitIcon} alt="exit" width="20" height="20" />
</div>
+3 -17
View File
@@ -2,9 +2,10 @@ import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { renderTroops, translateText } from "../../../client/Utils";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { formatPercentage, renderNumber } from "../../Utils";
import { GoToPlayerEvent } from "../TransformHandler";
import { Layer } from "./Layer";
interface Entry {
@@ -18,21 +19,6 @@ interface Entry {
player: PlayerView;
}
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) {}
}
@customElement("leader-board")
export class Leaderboard extends LitElement implements Layer {
public game: GameView | null = null;
@@ -510,7 +510,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (!this.isUserSettingsListenerAttached) {
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
this.onUserSettingsChanged as EventListener,
);
this.isUserSettingsListenerAttached = true;
}
@@ -522,7 +522,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
if (this.isUserSettingsListenerAttached) {
globalThis.removeEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`,
this.onUserSettingsChanged,
this.onUserSettingsChanged as EventListener,
);
this.isUserSettingsListenerAttached = false;
}
+22 -36
View File
@@ -34,7 +34,9 @@ import { TransformHandler } from "../TransformHandler";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
import "./RelationSmiley";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
const soldierIconAquarius = assetUrl("images/SoldierIconAquarius.svg");
const allianceIcon = assetUrl("images/AllianceIcon.svg");
const warshipIcon = assetUrl("images/BattleshipIconWhite.svg");
const cityIcon = assetUrl("images/CityIconWhite.svg");
@@ -182,37 +184,21 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.requestUpdate();
}
private getPlayerNameColor(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
isFriendly: boolean,
): string {
private getPlayerNameColor(isFriendly: boolean): string {
if (isFriendly) return "text-green-500";
if (
myPlayer &&
myPlayer !== player &&
player.type() === PlayerType.Nation
) {
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
return this.getRelationClass(relation);
}
return "text-white";
}
private getRelationClass(relation: Relation): string {
switch (relation) {
case Relation.Hostile:
return "text-red-500";
case Relation.Distrustful:
return "text-red-300";
case Relation.Neutral:
return "text-white";
case Relation.Friendly:
return "text-green-500";
default:
return "text-white";
}
private getRelationSmiley(
player: PlayerView,
myPlayer: PlayerView | null | undefined,
): TemplateResult | string {
if (!myPlayer || myPlayer === player || player.type() !== PlayerType.Nation)
return "";
const relation =
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
if (relation === Relation.Neutral) return "";
return html`<relation-smiley .relation=${relation}></relation-smiley>`;
}
private getRelationName(relation: Relation): string {
@@ -337,17 +323,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div
class="flex flex-1 flex-col items-center justify-center text-xs font-bold ${attackingTroops >
0
? "text-sky-400"
? "text-aquarius"
: "text-white/40"} drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
translate="no"
>
<span class="flex items-center gap-px leading-none text-xs"
><img
src=${soldierIcon}
class="w-2.5 h-2.5"
style="${attackingTroops > 0
? "filter: brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%); opacity:1"
: "filter: brightness(0) invert(1); opacity:0.4"}"
class="w-2.5 h-2.5 inline-block ${attackingTroops > 0
? ""
: "brightness-0 invert opacity-40"}"
src=${attackingTroops > 0 ? soldierIconAquarius : soldierIcon}
alt=""
aria-hidden="true"
/></span
>
<span class="tabular-nums leading-none text-sm mt-0.5"
@@ -363,8 +350,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
<div class="flex flex-col justify-between self-stretch">
<div
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
player,
myPlayer,
isFriendly ?? false,
)}"
>
@@ -375,6 +360,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
/>`
: html``}
<span>${player.displayName()}</span>
${this.getRelationSmiley(player, myPlayer)}
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -445,7 +431,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-[#0073b7] transition-[width] duration-200"
class="h-full bg-malibu-blue transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
+27 -6
View File
@@ -155,12 +155,30 @@ export class RailroadLayer implements Layer {
if (context === null) throw new Error("2d context not supported");
this.context = context;
// Firefox's GPU limit is 8192, only known browser issue
const maxTextureSize = 8192;
const scaleX = maxTextureSize / this.game.width();
const scaleY = maxTextureSize / this.game.height();
const targetScale = Math.min(2, scaleX, scaleY);
this.canvas.width = Math.max(
1,
Math.floor(this.game.width() * targetScale),
);
this.canvas.height = Math.max(
1,
Math.floor(this.game.height() * targetScale),
);
// Enable smooth scaling
this.context.imageSmoothingEnabled = true;
this.context.imageSmoothingQuality = "high";
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
// Scale context so existing *2 rendering math continues to work automatically
this.context.scale(
this.canvas.width / (this.game.width() * 2),
this.canvas.height / (this.game.height() * 2),
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, rail] of this.existingRailroads) {
@@ -215,10 +233,13 @@ export class RailroadLayer implements Layer {
return;
}
const srcX = visLeft * 2;
const srcY = visTop * 2;
const srcW = visWidth * 2;
const srcH = visHeight * 2;
const actualScaleX = this.canvas.width / this.game.width();
const actualScaleY = this.canvas.height / this.game.height();
const srcX = visLeft * actualScaleX;
const srcY = visTop * actualScaleY;
const srcW = visWidth * actualScaleX;
const srcH = visHeight * actualScaleY;
const dstX = -this.game.width() / 2 + visLeft;
const dstY = -this.game.height() / 2 + visTop;
@@ -106,7 +106,6 @@ export function computeDirection(
if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT;
if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT;
}
console.warn(`Invalid rail segment: ${dx1}:${dy1}, ${dx2}:${dy2}`);
return RailType.VERTICAL;
}
@@ -0,0 +1,79 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Relation } from "../../../core/game/Game";
type FaceData = {
color: string;
eyeCy: number;
mouth: string;
brows?: string[];
};
const RELATION_FACES: Partial<Record<Relation, FaceData>> = {
[Relation.Hostile]: {
color: "#ef4444",
eyeCy: 7.5,
mouth: "M5 12 Q8 9 11 12",
brows: ["M4 5.5 L6.5 7", "M12 5.5 L9.5 7"],
},
[Relation.Distrustful]: {
color: "#f97316",
eyeCy: 6.8,
mouth: "M5.5 11 Q8 9.2 10.5 11",
},
[Relation.Friendly]: {
color: "#22c55e",
eyeCy: 6.5,
mouth: "M5 10 Q8 13 11 10",
},
};
@customElement("relation-smiley")
export class RelationSmiley extends LitElement {
@property({ type: Number })
relation: Relation = Relation.Neutral;
createRenderRoot() {
return this;
}
render() {
const face = RELATION_FACES[this.relation];
if (!face) return html``;
const { color, eyeCy, mouth, brows } = face;
return html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
style="flex-shrink:0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.5"
stroke="${color}"
stroke-width="1.4"
fill="none"
/>
${brows?.map(
(d) =>
html`<path
d="${d}"
stroke="${color}"
stroke-width="1.4"
stroke-linecap="round"
/>`,
)}
<circle cx="5.8" cy="${eyeCy}" r="0.9" fill="${color}" />
<circle cx="10.2" cy="${eyeCy}" r="0.9" fill="${color}" />
<path
d="${mouth}"
stroke="${color}"
stroke-width="1.4"
fill="none"
stroke-linecap="round"
/>
</svg>`;
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ export class ReplayPanel extends LitElement implements Layer {
private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
const backgroundColor =
this._replaySpeedMultiplier === value ? "bg-blue-400" : "";
this._replaySpeedMultiplier === value ? "bg-malibu-blue" : "";
return html`
<button
+5 -2
View File
@@ -18,7 +18,10 @@ export class SpawnTimer extends LitElement implements Layer {
private ratios = [0];
private _barVisible = false;
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
private colors = [
"rgb(from var(--color-malibu-blue) r g b / 0.7)",
"rgba(0, 0, 0, 0.5)",
];
private isVisible = false;
@@ -43,7 +46,7 @@ export class SpawnTimer extends LitElement implements Layer {
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
this.colors = ["rgb(from var(--color-malibu-blue) r g b / 0.7)"];
} else {
this.ratios = [];
this.colors = [];
@@ -56,6 +56,8 @@ export class SpriteFactory {
private transformHandler: TransformHandler;
private renderSprites: boolean;
private readonly textureCache: Map<string, PIXI.Texture> = new Map();
private colorCanvas: HTMLCanvasElement | null = null;
private colorCtx: CanvasRenderingContext2D | null = null;
private readonly structuresInfos: Map<
UnitType,
@@ -81,6 +83,21 @@ export class SpriteFactory {
this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType));
}
public clearCache() {
for (const texture of this.textureCache.values()) {
if (texture && texture !== PIXI.Texture.EMPTY) {
try {
texture.destroy(true);
} catch (e) {
console.error("Error clearing texture cache:", e);
}
}
}
this.textureCache.clear();
this.colorCanvas = null;
this.colorCtx = null;
}
private loadIcon(
unitInfo: {
iconPath: string;
@@ -89,6 +106,10 @@ export class SpriteFactory {
unitType: UnitType,
) {
const image = new Image();
// crossOrigin must be set before src so the fetch is CORS-checked.
// Without this, an icon served from CDN_BASE taints structureCanvas
// and PIXI.Texture.from rejects the upload to WebGL.
image.crossOrigin = "anonymous";
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
@@ -104,6 +125,10 @@ export class SpriteFactory {
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (key.includes(`-${unitType}`)) {
const tex = this.textureCache.get(key);
if (tex && tex !== PIXI.Texture.EMPTY) {
tex.destroy(true);
}
this.textureCache.delete(key);
}
}
@@ -281,7 +306,7 @@ export class SpriteFactory {
structureType: UnitType,
isConstruction: boolean,
isMarkedForDeletion: boolean,
shape: string,
shape: keyof typeof ICON_SIZE,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
@@ -451,7 +476,7 @@ export class SpriteFactory {
context.restore();
}
return PIXI.Texture.from(structureCanvas);
return PIXI.Texture.from(structureCanvas, true);
}
public createRange(
@@ -507,14 +532,18 @@ export class SpriteFactory {
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
if (!this.colorCanvas || !this.colorCtx) {
this.colorCanvas = document.createElement("canvas");
this.colorCtx = this.colorCanvas.getContext("2d")!;
}
const { colorCanvas, colorCtx: ctx } = this;
if (colorCanvas.width !== image.width) colorCanvas.width = image.width;
if (colorCanvas.height !== image.height) colorCanvas.height = image.height;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
return colorCanvas;
}
}
+141 -56
View File
@@ -101,7 +101,11 @@ export class StructureIconsLayer implements Layer {
private visibilityStateDirty = true;
private pendingConfirm: MouseUpEvent | null = null;
private hasHiddenStructure = false;
private rebuildPending = false;
potentialUpgrade: StructureRenderInfo | undefined;
private filterRedArray: OutlineFilter[] = [];
private filterGreenArray: OutlineFilter[] = [];
private filterWhiteArray: OutlineFilter[] = [];
constructor(
private game: GameView,
@@ -119,16 +123,37 @@ export class StructureIconsLayer implements Layer {
}
async setupRenderer() {
if (this.renderer) {
this.renderer.destroy(true);
this.rootStage.removeChildren();
}
try {
await PIXI.Assets.load(bitmapFont);
} catch (error) {
console.error("Failed to load bitmap font:", error);
}
const renderer = new PIXI.WebGLRenderer();
this.pixicanvas = document.createElement("canvas");
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
// This will prefer WebGL, eventually WebGPU, and fallback to Canvas
// Restrict using 'preferences: ["WebGPU", "WebGL"]' or
// 'preferences: "WebGPU"' later if needed
const renderer = await PIXI.autoDetectRenderer({
canvas: this.pixicanvas,
resolution: 1,
width: this.pixicanvas.width,
height: this.pixicanvas.height,
antialias: false,
clearBeforeRender: true,
backgroundAlpha: 0,
backgroundColor: 0x00000000,
});
console.info(`Using ${renderer.name} for structure icons layer`);
this.iconsStage = new PIXI.Container();
this.iconsStage.position.set(0, 0);
this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
@@ -154,25 +179,87 @@ export class StructureIconsLayer implements Layer {
this.rootStage.position.set(0, 0);
this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
await renderer.init({
canvas: this.pixicanvas,
resolution: 1,
width: this.pixicanvas.width,
height: this.pixicanvas.height,
antialias: false,
clearBeforeRender: true,
backgroundAlpha: 0,
backgroundColor: 0x00000000,
});
this.filterRedArray = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.filterGreenArray = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.filterWhiteArray = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
this.renderer = renderer;
if (this.renderer.name === "webgpu") {
// Listen to device loss as PixiJS doesn't handle WebGPU context loss itself
const gpuRenderer = this.renderer as PIXI.WebGPURenderer;
gpuRenderer.gpu.device.lost.then(() => {
this.redraw();
});
}
if (this.renderer.name === "webgl") {
this.renderer.runners.contextChange.add({
// Listen to contextChange as PixiJS handles WebGL context loss and restores itself.
// Don't listen to "webglcontextrestored" event directly as it can fire before PixiJS is ready.
contextChange: () => {
requestAnimationFrame(() => {
this.redraw();
});
},
});
}
this.rendererInitialized = true;
}
private rebuildAllIcons() {
this.clearGhostStructure();
this.factory.clearCache();
const allUnitIds = Array.from(this.seenUnitIds);
this.seenUnitIds.clear();
for (const unitId of allUnitIds) {
const render = this.rendersByUnitId.get(unitId);
if (render) {
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
}
const unitView = this.game.unit(unitId);
if (unitView && unitView.isActive()) {
this.handleActiveUnit(unitView);
} else {
this.rendersByUnitId.delete(unitId);
}
}
}
shouldTransform(): boolean {
return false;
}
async redraw() {
if (this.rebuildPending) {
return;
}
if (this.rendererOrGLContextLost()) {
return;
}
this.rebuildPending = true;
try {
if (this.renderer?.name === "webgpu") {
this.rendererInitialized = false;
await this.setupRenderer();
}
this.resizeCanvas();
this.rebuildAllIcons();
} finally {
this.rebuildPending = false;
}
}
async init() {
this.eventBus.on(ToggleStructuresEvent, (e) =>
this.toggleStructures(e.structureTypes),
@@ -188,15 +275,27 @@ export class StructureIconsLayer implements Layer {
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
this.redraw();
this.resizeCanvas();
}
private rendererOrGLContextLost(): boolean {
if (!this.renderer || !this.rendererInitialized) return true;
if (this.renderer.name === "webgl") {
// For WebGL, check isLost to prevent ungraceful handling by PixiJS:
// its GL > logPrettyShaderError throws, when getShaderSource returns null
// Needs to be fixed in PixiJS, in meantime prevent it from here
return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true;
}
return false;
}
resizeCanvas() {
if (this.renderer) {
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer.resize(innerWidth, innerHeight, 1);
if (this.rendererOrGLContextLost()) {
return;
}
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer?.resize(innerWidth, innerHeight, 1);
}
tick() {
@@ -220,12 +319,8 @@ export class StructureIconsLayer implements Layer {
this.game.config().userSettings()?.structureSprites() ?? true;
}
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
if (!this.renderer || !this.rendererInitialized) {
if (this.rendererOrGLContextLost()) {
return;
}
@@ -254,8 +349,10 @@ export class StructureIconsLayer implements Layer {
scale > DOTS_ZOOM_THRESHOLD &&
(scale <= ZOOM_THRESHOLD || !this.renderSprites);
this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites;
this.renderer.render(this.rootStage);
mainContext.drawImage(this.renderer.canvas, 0, 0);
if (this.renderer) {
this.renderer?.render(this.rootStage);
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
}
renderGhost() {
@@ -333,9 +430,7 @@ export class StructureIconsLayer implements Layer {
canUpgrade: false,
});
this.updateGhostPrice(0, showPrice);
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.ghostUnit.container.filters = this.filterRedArray;
this.pendingConfirm = null;
return;
}
@@ -356,20 +451,14 @@ export class StructureIconsLayer implements Layer {
this.potentialUpgrade = undefined;
}
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.potentialUpgrade.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.potentialUpgrade.iconContainer.filters = this.filterGreenArray;
this.potentialUpgrade.dotContainer.filters = this.filterGreenArray;
}
// No overlapping when a structure is upgradable
this.uiState.overlappingRailroads = [];
this.uiState.ghostRailPaths = [];
} else if (unit.canBuild === false) {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.ghostUnit.container.filters = this.filterRedArray;
this.uiState.overlappingRailroads = [];
this.uiState.ghostRailPaths = [];
} else {
@@ -536,8 +625,8 @@ export class StructureIconsLayer implements Layer {
private clearGhostStructure() {
this.pendingConfirm = null;
if (this.ghostUnit) {
this.ghostUnit.container.destroy();
this.ghostUnit.range?.destroy();
this.ghostUnit.container.destroy({ children: true });
this.ghostUnit.range?.destroy({ children: true });
this.ghostUnit = null;
}
if (this.potentialUpgrade) {
@@ -585,7 +674,7 @@ export class StructureIconsLayer implements Layer {
return;
}
this.ghostUnit.range?.destroy();
this.ghostUnit.range?.destroy({ children: true });
this.ghostUnit.range = null;
this.ghostUnit.rangeLevel = level;
this.ghostUnit.targetingAlly = targetingAlly;
@@ -676,12 +765,8 @@ export class StructureIconsLayer implements Layer {
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
if (structureInfos.visible && this.hasHiddenStructure) {
render.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
render.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
render.iconContainer.filters = this.filterWhiteArray;
render.dotContainer.filters = this.filterWhiteArray;
} else {
render.iconContainer.filters = [];
render.dotContainer.filters = [];
@@ -691,8 +776,8 @@ export class StructureIconsLayer implements Layer {
private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) {
if (unit.markedForDeletion() !== false) {
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -705,8 +790,8 @@ export class StructureIconsLayer implements Layer {
) {
if (render.underConstruction && !unit.isUnderConstruction()) {
render.underConstruction = false;
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -716,8 +801,8 @@ export class StructureIconsLayer implements Layer {
private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) {
if (render.owner !== unit.owner().id()) {
render.owner = unit.owner().id();
render.iconContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
@@ -727,9 +812,9 @@ export class StructureIconsLayer implements Layer {
private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) {
if (render.level !== unit.level()) {
render.level = unit.level();
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
render.iconContainer = this.createIconSprite(unit);
render.levelContainer = this.createLevelSprite(unit);
render.dotContainer = this.createDotSprite(unit);
@@ -834,9 +919,9 @@ export class StructureIconsLayer implements Layer {
}
private deleteStructure(render: StructureRenderInfo) {
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
render.iconContainer?.destroy({ children: true });
render.levelContainer?.destroy({ children: true });
render.dotContainer?.destroy({ children: true });
const unitId = render.unit.id();
this.rendersByUnitId.delete(unitId);
this.seenUnitIds.delete(unitId);
@@ -87,6 +87,10 @@ export class StructureLayer implements Layer {
private loadIcon(unitType: string, config: UnitRenderConfig) {
const image = new Image();
// crossOrigin must be set before src so the fetch is CORS-checked.
// Without this, an icon served from CDN_BASE taints any canvas/texture
// it's drawn into, and WebGL refuses to upload it via texImage2D.
image.crossOrigin = "anonymous";
image.src = config.icon;
image.onload = () => {
this.unitIcons.set(unitType, image);
@@ -167,10 +167,6 @@ export class TerritoryLayer implements Layer {
}
private spawnHighlight() {
if (this.game.ticks() % 5 === 0) {
return;
}
this.highlightContext.clearRect(
0,
0,
+36 -4
View File
@@ -456,11 +456,43 @@ export class UnitLayer implements Layer {
}
private handleWarShipEvent(unit: UnitView) {
if (unit.targetUnitId()) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
} else {
this.drawSprite(unit);
if (unit.warshipState().state !== "patrolling" && unit.isActive()) {
if (unit.warshipState().isInCombat) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
} else {
this.drawSprite(unit);
}
this.drawRetreatCross(unit);
return;
}
if (unit.warshipState().isInCombat) {
this.drawSprite(unit, colord("rgb(200,0,0)"));
return;
}
this.drawSprite(unit);
}
private drawRetreatCross(unit: UnitView) {
// Blink: 500ms on, 500ms off
if (Math.floor(Date.now() / 500) % 2 === 0) return;
const x = this.game.x(unit.tile());
const y = this.game.y(unit.tile());
const ctx = this.context;
ctx.save();
const cx = x + 0.5;
const cy = y + 0.5;
ctx.lineCap = "square";
ctx.strokeStyle = "rgb(36,36,36)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx, cy - 1.5);
ctx.lineTo(cx, cy + 1.5);
ctx.moveTo(cx - 1.5, cy);
ctx.lineTo(cx + 1.5, cy);
ctx.stroke();
ctx.restore();
}
private handleShellEvent(unit: UnitView) {
+19 -16
View File
@@ -73,30 +73,33 @@ export class WinModal extends LitElement implements Layer {
? "flex justify-between gap-2.5"
: "hidden"}"
>
<button
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.exit"
@click=${this._handleExit}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.exit")}
</button>
></o-button>
${this.isRankedGame
? html`
<button
<o-button
variant="primary"
width="block"
class="flex-1"
translationKey="win_modal.requeue"
@click=${this._handleRequeue}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-purple-600 text-white border-0 rounded-sm transition-all duration-200 hover:bg-purple-500 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.requeue")}
</button>
></o-button>
`
: null}
<button
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded-sm transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${this.game?.myPlayer()?.isAlive()
<o-button
variant="primary"
width="block"
class="flex-1"
.title=${this.game?.myPlayer()?.isAlive()
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
@click=${this.hide}
></o-button>
</div>
</div>
`;
+2 -1
View File
@@ -122,7 +122,8 @@ export class NavalTarget extends Target {
if (
!this.ended &&
(!this.unit.isActive() ||
(this.unit.type() === UnitType.TransportShip && this.unit.retreating()))
(this.unit.type() === UnitType.TransportShip &&
this.unit.transportShipState().isRetreating))
) {
this.ended = true;
}
+31 -4
View File
@@ -5,7 +5,34 @@
@theme {
--default-ring-width: 3px;
--default-ring-color: var(text-[#0073b7]);
--default-ring-color: var(--color-malibu-blue);
--font-display: "OpenFront", Inter, Arial, sans-serif;
/* Openfront.io brand palette — see Openfront_Brand_Guidelines.pdf §4 */
--color-malibu-blue: #0084d1;
--color-aquarius: #3fa9f5;
--color-dawn-blue: #cccccc;
--color-bright-white: #ffffff;
/* Openfront Masters sub-brand */
--color-cyber-yellow: #ffd700;
--color-limestone: #999999;
/* Background & decorative — not for standalone UI use */
--color-deep-navy: #0a1628;
--color-hex-cyan: #00c8ff;
--color-frame-orange: #f97316;
/* App-specific surface colors */
--color-surface: #0a1628;
--shadow-malibu-blue: 0 0 15px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-soft: 0 0 12px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-strong: 0 0 15px rgba(0, 132, 209, 0.3);
--shadow-malibu-blue-pill: 0 0 6px rgba(0, 132, 209, 0.35);
--shadow-malibu-blue-ring-sm: 0 0 0 4px rgba(0, 132, 209, 0.2);
--shadow-malibu-blue-ring-lg: 0 0 0 6px rgba(0, 132, 209, 0.3);
--shadow-lobby-card-hover: 0 0 0 2px #0084d1, 0 0 20px rgba(0, 132, 209, 0.5);
}
@layer base {
@@ -113,7 +140,7 @@ body {
padding: 15px 20px;
font-size: 16px;
cursor: pointer;
background-color: #0073b7;
background-color: var(--color-malibu-blue);
color: white;
border: none;
border-radius: 8px;
@@ -123,7 +150,7 @@ body {
}
.start-game-button:not(:disabled):hover {
background-color: #0073b7;
background-color: var(--color-aquarius);
}
.start-game-button:disabled {
@@ -580,7 +607,7 @@ label.option-card:hover {
/* News Button Notification */
news-button .active button {
position: relative;
border-color: #0073b7 !important;
border-color: var(--color-malibu-blue) !important;
border-width: 2px !important;
box-shadow:
0 0 0 1px rgba(37, 99, 235, 0.5),
-88
View File
@@ -1,88 +0,0 @@
.c-button {
background: var(--primaryColor);
color: #fff;
cursor: pointer;
outline: none;
display: inline-block;
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
text-align: center;
padding: 0.8rem 1rem;
border-radius: 0.75rem;
transition: var(--transition);
@media (min-width: 1024px) {
font-size: 18px;
}
}
.c-button:hover,
.c-button:active,
.c-button:focus {
background: var(--primaryColorHover);
transition: var(--transition);
transform: translateY(-1px);
}
.c-button:disabled {
background: var(--primaryColorDisabled);
opacity: 0.7;
cursor: not-allowed;
transition: var(--transition);
}
.c-button--secondary {
background: var(--secondaryColor);
color: var(--fontColor);
}
.c-button--secondary:hover,
.c-button--secondary:active,
.c-button--secondary:focus {
background: var(--secondaryColorHover);
}
.c-button--block {
display: block;
width: 100%;
}
.c-button--blockDesktop {
display: block;
width: 100%;
@media (min-width: 1024px) {
width: auto;
margin: 0 auto;
}
}
.dark .c-button {
background: var(--primaryColorDark);
color: var(--fontColorLight);
}
.dark .c-button:hover,
.dark .c-button:active,
.dark .c-button:focus {
background: var(--primaryColorHoverDark);
}
.dark .c-button:disabled {
background: var(--primaryColorDisabledDark);
opacity: 0.7;
}
.dark .c-button--secondary {
background: var(--secondaryColorDark);
color: var(--fontColorDark);
}
.dark .c-button--secondary:hover,
.dark .c-button--secondary:active,
.dark .c-button--secondary:focus {
background: var(--secondaryColorHoverDark);
}
+7 -14
View File
@@ -7,33 +7,26 @@
--fontColor: #202020;
--fontColorLight: #fff;
/* Palette: Deep French Blue / Muted Cyan / Black / Forest Teal */
--frenchBlue: #1f3a70; /* Deeper French Blue */
--cyanBlue: #0f6ca3; /* Muted Cyan secondary */
--tealAccent: #1f6c5a; /* Darker Teal accent */
--primaryColor: var(--frenchBlue);
--primaryColorHover: var(--tealAccent);
--primaryColorHover: var(--color-malibu-blue);
--primaryColorDisabled: linear-gradient(
to right,
rgb(74, 74, 74),
rgb(61, 61, 61)
);
--secondaryColor: var(--cyanBlue);
--secondaryColorHover: var(--cyanBlue);
--secondaryColor: var(--color-malibu-blue);
--secondaryColorHover: var(--color-aquarius);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--primaryColorDark: var(--frenchBlue);
--primaryColorHoverDark: var(--tealAccent);
--primaryColorHoverDark: var(--color-malibu-blue);
--primaryColorDisabledDark: #4b5563;
--secondaryColorDark: var(--tealAccent);
--secondaryColorHoverDark: var(--frenchBlue);
--secondaryColorDark: var(--color-malibu-blue);
--secondaryColorHoverDark: var(--color-aquarius);
--fontColorDark: #f3f4f6;
/* Achievements */
--medal-easy: #cd7f32;
--medal-medium: #c0c0c0;
--medal-hard: #ffd700;
--medal-hard: var(--color-cyber-yellow);
--medal-impossible: #d32f2f;
--medal-custom: #2196f3;
}
+1 -1
View File
@@ -30,6 +30,6 @@
}
.l-header__highlightText {
color: #0073b7;
color: var(--color-malibu-blue);
font-weight: 700;
}
+32 -11
View File
@@ -51,6 +51,7 @@ function isAbsoluteUrl(path: string): boolean {
export function buildAssetUrl(
path: string,
assetManifest: AssetManifest = {},
baseUrl: string = "",
): string {
if (isAbsoluteUrl(path)) {
return path;
@@ -60,15 +61,7 @@ export function buildAssetUrl(
const directUrl = assetManifest[normalizedPath];
if (directUrl) {
return directUrl;
}
const directoryPrefix = `${normalizedPath}/`;
const hasNestedAssets = Object.keys(assetManifest).some((manifestPath) =>
manifestPath.startsWith(directoryPrefix),
);
if (hasNestedAssets) {
return `/_assets/${encodeAssetPath(normalizedPath)}`;
return baseUrl ? `${baseUrl.replace(/\/+$/, "")}${directUrl}` : directUrl;
}
return `/${encodeAssetPath(normalizedPath)}`;
@@ -76,9 +69,11 @@ export function buildAssetUrl(
declare global {
var __ASSET_MANIFEST__: AssetManifest | undefined;
var __CDN_BASE__: string | undefined;
interface Window {
ASSET_MANIFEST?: AssetManifest;
CDN_BASE?: string;
}
}
@@ -89,6 +84,32 @@ export function getAssetManifest(): AssetManifest {
return globalThis.__ASSET_MANIFEST__ ?? {};
}
export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest());
// Web workers have no `window`, so they read `__CDN_BASE__` off globalThis,
// which Worker.worker.ts sets from the init message before any asset fetches.
// Without this fallback, asset fetches inside workers (e.g. map binaries)
// would silently bypass the CDN.
export function getCdnBase(): string {
if (typeof window !== "undefined" && window.CDN_BASE !== undefined) {
return window.CDN_BASE;
}
return globalThis.__CDN_BASE__ ?? "";
}
export function assetUrl(path: string): string {
return buildAssetUrl(path, getAssetManifest(), getCdnBase());
}
// Rewrites Vite's emitted /assets/... references in the built index.html to
// use the cdnBaseRaw EJS placeholder, so RenderHtml.ts can prefix them with
// CDN_BASE at request time. Scoped to src=/href= attribute values so inline
// scripts containing the literal "/assets/..." can't be mangled. Does NOT
// match /_assets/ (underscore) — source-asset manifest URLs are prefixed via
// buildAssetUrl, not this rewrite. Falls back to "" when cdnBaseRaw is missing
// so a future renderer that forgets to provide it still produces working
// same-origin URLs.
export function rewriteAssetsForCdn(html: string): string {
return html.replace(
/(\s(?:src|href)=)(["'])\/assets\//g,
`$1$2<%- locals.cdnBaseRaw || "" %>/assets/`,
);
}
+8 -1
View File
@@ -50,7 +50,8 @@ export type Intent =
| DeleteUnitIntent
| KickPlayerIntent
| TogglePauseIntent
| UpdateGameConfigIntent;
| UpdateGameConfigIntent
| StartGameIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -84,6 +85,7 @@ export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
export type UpdateGameConfigIntent = z.infer<
typeof UpdateGameConfigIntentSchema
>;
export type StartGameIntent = z.infer<typeof StartGameIntentSchema>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -453,6 +455,10 @@ export const UpdateGameConfigIntentSchema = z.object({
config: GameConfigSchema.partial(),
});
export const StartGameIntentSchema = z.object({
type: z.literal("start_game"),
});
const IntentSchema = z.discriminatedUnion("type", [
AttackIntentSchema,
CancelAttackIntentSchema,
@@ -478,6 +484,7 @@ const IntentSchema = z.discriminatedUnion("type", [
KickPlayerIntentSchema,
TogglePauseIntentSchema,
UpdateGameConfigIntentSchema,
StartGameIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
+6
View File
@@ -153,6 +153,12 @@ export interface Config {
warshipPatrolRange(): number;
warshipShellAttackRate(): number;
warshipTargettingRange(): number;
warshipDockingRange(): number;
warshipPortHealingBonusPerLevel(): number;
warshipRetreatHealthThreshold(): number;
warshipPassiveHealing(): number;
warshipPassiveHealingRange(): number;
warshipPortSwitchThreshold(): number;
defensePostShellAttackRate(): number;
defensePostTargettingRange(): number;
// 0-1
+24
View File
@@ -969,6 +969,30 @@ export class DefaultConfig implements Config {
return 20;
}
warshipDockingRange(): number {
return 5;
}
warshipPortHealingBonusPerLevel(): number {
return 5;
}
warshipRetreatHealthThreshold(): number {
return 750;
}
warshipPassiveHealing(): number {
return 1;
}
warshipPassiveHealingRange(): number {
return 150;
}
warshipPortSwitchThreshold(): number {
return 0.75;
}
defensePostShellAttackRate(): number {
return 100;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class BoatRetreatExecution implements Execution {
return;
}
unit.orderBoatRetreat();
unit.updateTransportShipState({ isRetreating: true });
this.active = false;
}
+2 -2
View File
@@ -20,8 +20,8 @@ export class DeleteUnitExecution implements Execution {
}
this.mg = mg;
const unit = this.player.units().find((u) => u.id() === this.unitId);
if (!unit) {
const unit = this.mg.unit(this.unitId);
if (!unit || unit.owner() !== this.player) {
console.warn(
`SECURITY: unit ${this.unitId} not found or not owned by player ${this.player.displayName()}`,
);
@@ -45,6 +45,10 @@ export class DonateTroopsExecution implements Execution {
const maxDonation =
mg.config().maxTroops(this.recipient) - this.recipient.troops();
this.troops = Math.min(this.troops, maxDonation);
if (this.troops <= 0) {
this.active = false;
}
}
tick(ticks: number): void {
+3 -1
View File
@@ -28,7 +28,9 @@ export class MoveWarshipExecution implements Execution {
console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
continue;
}
warship.setPatrolTile(this.position);
warship.updateWarshipState({
patrolTile: this.position,
});
warship.setTargetTile(undefined);
}
}
+28 -5
View File
@@ -2,11 +2,14 @@ import {
Difficulty,
Execution,
Game,
GameMode,
Nation,
Player,
PlayerID,
PlayerType,
Relation,
TerrainType,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
@@ -71,7 +74,7 @@ export class NationExecution implements Execution {
const { difficulty } = this.mg.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return this.random.nextInt(65, 80); // Slower reactions
return this.random.nextInt(65, 100); // Slower reactions
case Difficulty.Medium:
return this.random.nextInt(55, 70);
case Difficulty.Hard:
@@ -89,7 +92,9 @@ export class NationExecution implements Execution {
this.behaviorsInitialized &&
this.player !== null &&
this.player.isAlive() &&
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
this.mg.config().gameConfig().difficulty !== Difficulty.Easy &&
this.player.unitsConstructed(UnitType.Port) &&
!this.mg.config().isUnitDisabled(UnitType.Warship)
) {
this.warshipBehavior.trackShipsAndRetaliate();
}
@@ -293,8 +298,26 @@ export class NationExecution implements Execution {
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
const difficulty = this.mg.config().gameConfig().difficulty;
const isHigherDifficulty =
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
const teamGame = this.mg.config().gameConfig().gameMode === GameMode.Team;
others.forEach((other: Player) => {
// In team games on higher difficulties, refuse to trade with anyone
// not on this nation's team (mirrors the "stop trading with all" button).
if (
teamGame &&
isHigherDifficulty &&
other.type() !== PlayerType.Bot &&
!player.isOnSameTeam(other)
) {
if (!player.hasEmbargoAgainst(other)) {
player.addEmbargo(other, false);
}
return;
}
/* When player is hostile starts embargo. Do not stop until neutral again */
if (
player.relation(other) <= Relation.Hostile &&
@@ -305,14 +328,14 @@ export class NationExecution implements Execution {
} else if (
player.relation(other) >= Relation.Neutral &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Hard &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
difficulty !== Difficulty.Hard &&
difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
} else if (
player.relation(other) >= Relation.Friendly &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
}
-1
View File
@@ -229,7 +229,6 @@ export class NukeExecution implements Execution {
// make the nuke unactive if it was intercepted
if (!this.nuke.isActive()) {
console.log(`Nuke destroyed before reaching target`);
this.active = false;
return;
}
@@ -304,9 +304,6 @@ export class SAMLauncherExecution implements Execution {
let target: Target | null = null;
if (mirvWarheadTargets.length === 0) {
target = this.targetingSystem.getSingleTarget(ticks);
if (target !== null) {
console.log("Target acquired");
}
}
// target is already filtered to exclude nukes targeted by other SAMs
+3 -3
View File
@@ -198,14 +198,14 @@ export class TransportShipExecution implements Execution {
// Checked every tick (not just on graph rebuild) because graph rebuilds
// are throttled and the tile may already be water before the version bumps.
if (this.dst !== null && this.mg.isWater(this.dst)) {
if (!this.boat.retreating()) {
this.boat.orderBoatRetreat();
if (!this.boat.transportShipState().isRetreating) {
this.boat.updateTransportShipState({ isRetreating: true });
}
// Reset cached retreat destination so it's recomputed from current position
this.retreatDst = null;
}
if (this.boat.retreating()) {
if (this.boat.transportShipState().isRetreating) {
// Resolve retreat destination once, based on current boat location when retreat begins.
this.retreatDst ??= this.attacker.bestTransportShipSpawn(
this.boat.tile(),
+12 -11
View File
@@ -60,7 +60,7 @@ export class TribeExecution implements Execution {
}
this.acceptAllAllianceRequests();
this.deleteAllStructures();
this.deleteNextStructure();
this.maybeAttack();
}
@@ -83,11 +83,13 @@ export class TribeExecution implements Execution {
}
}
private deleteAllStructures() {
private deleteNextStructure() {
if (!this.tribe.canDeleteUnit()) return;
for (const unit of this.tribe.units()) {
if (Structures.has(unit.type()) && this.tribe.canDeleteUnit()) {
this.mg.addExecution(new DeleteUnitExecution(this.tribe, unit.id()));
}
if (!Structures.has(unit.type())) continue;
if (unit.isMarkedForDeletion()) continue;
this.mg.addExecution(new DeleteUnitExecution(this.tribe, unit.id()));
return;
}
}
@@ -106,17 +108,16 @@ export class TribeExecution implements Execution {
this.tribe.breakAlliance(alliance);
}
this.attackBehavior.sendAttack(toAttack);
return;
if (this.attackBehavior.sendAttack(toAttack)) return;
}
}
if (this.neighborsTerraNullius) {
if (this.tribe.neighbors().some((n) => !n.isPlayer())) {
this.attackBehavior.sendAttack(this.mg.terraNullius());
return;
if (this.tribe.nearby().some((n) => !n.isPlayer())) {
if (this.attackBehavior.sendAttack(this.mg.terraNullius())) return;
} else {
this.neighborsTerraNullius = false;
}
this.neighborsTerraNullius = false;
}
this.attackBehavior.attackRandomTarget();
@@ -10,9 +10,11 @@ export class UpgradeStructureExecution implements Execution {
) {}
init(mg: Game, ticks: number): void {
this.structure = this.player
.units()
.find((unit) => unit.id() === this.unitId);
this.structure = mg.unit(this.unitId);
if (this.structure && this.structure.owner() !== this.player) {
console.warn(`structure not owned by player`);
this.structure = undefined;
}
if (this.structure === undefined) {
console.warn(`structure is undefined`);
+525 -47
View File
@@ -11,6 +11,7 @@ import { TileRef } from "../game/GameMap";
import { WaterPathFinder } from "../pathfinding/PathFinder";
import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { findMinimumBy } from "../Util";
import { ShellExecution } from "./ShellExecution";
export class WarshipExecution implements Execution {
@@ -20,6 +21,10 @@ export class WarshipExecution implements Execution {
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
private lastManualMoveTickRetreatDisabled = 0;
private lastObservedPatrolTile: TileRef | undefined;
private activeHealingRemainder = 0;
private lastEmittedCombat = false;
constructor(
private input: (UnitParams<UnitType.Warship> & OwnerComp) | Unit,
@@ -48,6 +53,7 @@ export class WarshipExecution implements Execution {
this.input,
);
}
this.lastObservedPatrolTile = this.warship.warshipState().patrolTile;
}
tick(ticks: number): void {
@@ -55,69 +61,223 @@ export class WarshipExecution implements Execution {
this.warship.delete();
return;
}
const isInCombat = this.warship.warshipState().isInCombat ?? false;
if (this.lastEmittedCombat && !isInCombat) {
this.warship.touch();
}
this.lastEmittedCombat = isInCombat;
const healthBeforeHealing = this.warship.health();
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
if (hasPort) {
this.warship.modifyHealth(1);
this.healWarship();
this.handleManualPatrolOverride();
if (this.warship.warshipState().state === "docked") {
if (this.currentRetreatPort() === undefined) {
this.cancelRepairRetreat();
}
if (this.isFullyHealed()) {
this.cancelRepairRetreat();
}
if (this.warship.warshipState().state === "docked") {
return;
}
}
if (this.handleRepairRetreat()) {
return;
}
// Priority 1: Check if need to heal before doing anything else
if (this.shouldStartRepairRetreat(healthBeforeHealing)) {
this.startRepairRetreat();
if (this.handleRepairRetreat()) {
return;
}
}
this.warship.setTargetUnit(this.findTargetUnit());
// Priority 1: Shoot transport ship if in range
if (this.warship.targetUnit()?.type() === UnitType.TransportShip) {
this.shootTarget();
this.patrol();
return;
}
// Priority 2: Fight enemy warship if in range
if (this.warship.targetUnit()?.type() === UnitType.Warship) {
this.shootTarget();
this.patrol();
return;
}
// Priority 3: Hunt trade ship only if not healing and no enemy warship
if (this.warship.targetUnit()?.type() === UnitType.TradeShip) {
this.huntDownTradeShip();
return;
}
this.patrol();
}
if (this.warship.targetUnit() !== undefined) {
this.shootTarget();
return;
private healWarship(): void {
const owner = this.warship.owner();
const passiveHealing = this.mg.config().warshipPassiveHealing();
const passiveHealingRange = this.mg.config().warshipPassiveHealingRange();
const passiveHealingRangeSquared =
passiveHealingRange * passiveHealingRange;
const warshipTile = this.warship.tile();
let isNearPort = false;
for (const port of owner.units(UnitType.Port)) {
const distSquared = this.mg.euclideanDistSquared(
warshipTile,
port.tile(),
);
if (distSquared <= passiveHealingRangeSquared) {
isNearPort = true;
break;
}
}
if (isNearPort) {
this.warship.modifyHealth(passiveHealing);
}
if (this.warship.warshipState().state === "docked") {
this.applyActiveDockedHealing();
}
}
private isFullyHealed(): boolean {
const maxHealth = this.mg.config().unitInfo(UnitType.Warship).maxHealth;
if (typeof maxHealth !== "number") {
return true;
}
return this.warship.health() >= maxHealth;
}
private shouldStartRepairRetreat(
healthBeforeHealing = this.warship.health(),
): boolean {
if (this.warship.warshipState().state !== "patrolling") {
return false;
}
const manualMoveRetreatDisabledDuration = 50;
if (
this.mg.ticks() - this.lastManualMoveTickRetreatDisabled <
manualMoveRetreatDisabledDuration
) {
return false;
}
if (
healthBeforeHealing >= this.mg.config().warshipRetreatHealthThreshold()
) {
return false;
}
const ports = this.warship.owner().units(UnitType.Port);
return ports.length > 0;
}
private findNearestPort(): TileRef | undefined {
const ports = this.warship.owner().units(UnitType.Port);
if (ports.length === 0) {
return undefined;
}
const warshipTile = this.warship.tile();
const warshipComponent = this.mg.getWaterComponent(warshipTile);
if (warshipComponent === null) {
throw new Error(`Warship at tile ${warshipTile} has no water component`);
}
const nearest = findMinimumBy(
ports,
(port) => this.mg.euclideanDistSquared(warshipTile, port.tile()),
(port) => {
const portComponent = this.mg.getWaterComponent(port.tile());
if (portComponent === null) {
throw new Error(`Port at tile ${port.tile()} has no water component`);
}
return portComponent === warshipComponent;
},
);
return nearest?.tile();
}
private findRetreatAggroTarget(): Unit | undefined {
return this.findBestTarget([UnitType.TransportShip, UnitType.Warship]);
}
private findTargetUnit(): Unit | undefined {
return this.findBestTarget(
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
true,
);
}
/**
* Shared target selection: searches nearby units of given types,
* filters common exclusions (self, friendly, docked, already-shelled),
* picks best by type priority (lower index = higher priority) then distance.
*
* When `includeTradeShips` is true, applies trade-ship-specific filters
* (safe from pirates, patrol range, water component, allied destination).
*/
private findBestTarget(
types: UnitType[],
includeTradeShips = false,
): Unit | undefined {
const mg = this.mg;
const config = mg.config();
const owner = this.warship.owner();
const hasPort = owner.unitCount(UnitType.Port) > 0;
const patrolTile = this.warship.patrolTile()!;
const patrolRangeSquared = config.warshipPatrolRange() ** 2;
// Lazy: only computed if a TradeShip candidate forces the component check.
// `undefined` = not yet computed; `null` = computed, no component found.
let warshipComponent: number | null | undefined = undefined;
const ships = mg.nearbyUnits(
this.warship.tile()!,
this.warship.tile(),
config.warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
types,
);
// Trade-ship-specific state, lazily computed.
let hasPort: boolean | undefined;
let patrolTile: number | undefined;
let patrolRangeSquared: number | undefined;
let warshipComponent: number | null | undefined = undefined;
let bestUnit: Unit | undefined = undefined;
let bestTypePriority = 0;
let bestDistSquared = 0;
for (const { unit, distSquared } of ships) {
if (
unit.owner() === owner ||
unit === this.warship ||
unit.owner() === owner ||
!owner.canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit)
this.alreadySentShell.has(unit) ||
(unit.type() === UnitType.Warship &&
unit.warshipState().state === "docked")
) {
continue;
}
const type = unit.type();
if (type === UnitType.TradeShip) {
if (includeTradeShips && type === UnitType.TradeShip) {
if (hasPort === undefined) {
hasPort = owner.unitCount(UnitType.Port) > 0;
patrolTile = this.warship.warshipState().patrolTile;
patrolRangeSquared = config.warshipPatrolRange() ** 2;
}
if (
!hasPort ||
patrolTile === undefined ||
unit.isSafeFromPirates() ||
unit.targetUnit()?.owner() === owner || // trade ship is coming to my port
unit.targetUnit()?.owner().isFriendly(owner) // trade ship is coming to my ally
unit.targetUnit()?.owner() === owner ||
unit.targetUnit()?.owner().isFriendly(owner)
) {
continue;
}
if (warshipComponent === undefined) {
warshipComponent = mg.getWaterComponent(this.warship.tile());
}
@@ -127,12 +287,9 @@ export class WarshipExecution implements Execution {
) {
continue;
}
if (
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared!
) {
// Prevent warship from chasing trade ship that is too far away from
// the patrol tile to prevent warships from wandering around the map.
continue;
}
}
@@ -140,18 +297,8 @@ export class WarshipExecution implements Execution {
const typePriority =
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
if (bestUnit === undefined) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
continue;
}
// Match existing `sort()` semantics:
// - Lower priority is better (TransportShip < Warship < TradeShip).
// - For same type, smaller distance is better.
// - For exact ties, keep the first encountered (stable sort behavior).
if (
bestUnit === undefined ||
typePriority < bestTypePriority ||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
) {
@@ -164,7 +311,303 @@ export class WarshipExecution implements Execution {
return bestUnit;
}
private startRepairRetreat(): void {
const portTile = this.findNearestPort();
if (portTile === undefined) {
return;
}
this.warship.updateWarshipState({
retreatPort: portTile,
state: "retreating",
});
this.activeHealingRemainder = 0;
this.warship.setTargetUnit(undefined);
}
private cancelRepairRetreat(clearTargetTile = true): void {
this.activeHealingRemainder = 0;
this.warship.updateWarshipState({
state: "patrolling",
retreatPort: undefined,
});
if (clearTargetTile) {
this.warship.setTargetTile(undefined);
}
}
private handleManualPatrolOverride(): void {
const patrolTile = this.warship.warshipState().patrolTile;
if (
this.lastObservedPatrolTile !== undefined &&
patrolTile !== this.lastObservedPatrolTile
) {
this.lastManualMoveTickRetreatDisabled = this.mg.ticks();
if (this.warship.warshipState().state !== "patrolling") {
this.cancelRepairRetreat(false);
}
}
this.lastObservedPatrolTile = patrolTile;
}
private handleRepairRetreat(): boolean {
if (this.warship.warshipState().state === "patrolling") {
return false;
}
const retreatAggroTarget = this.findRetreatAggroTarget();
if (retreatAggroTarget) {
this.warship.setTargetUnit(retreatAggroTarget);
this.shootTarget();
// Fall through — continue retreating toward port even while firing back.
}
if (!this.refreshRetreatPortTile()) {
this.cancelRepairRetreat();
return false;
}
// Only clear the target when there's no active aggro target this tick.
if (!retreatAggroTarget) {
this.warship.setTargetUnit(undefined);
}
const retreatPortTile = this.warship.warshipState().retreatPort;
if (retreatPortTile === undefined) {
return false;
}
const dockingRadius = this.mg.config().warshipDockingRange();
const dockingRadiusSq = dockingRadius * dockingRadius;
const distToPort = this.mg.euclideanDistSquared(
this.warship.tile(),
retreatPortTile,
);
if (distToPort <= dockingRadiusSq) {
// Check if the port has capacity available (excluding this warship from capacity check)
const port = this.warship
.owner()
.units(UnitType.Port)
.find((p) => p.tile() === retreatPortTile);
if (port && !this.isPortFullOfHealing(port, this.warship)) {
// Port has capacity - dock here
this.warship.setTargetTile(undefined);
this.warship.updateWarshipState({
state: "docked",
});
return true;
} else {
// Port is full - wait near port, but leave if already fully healed
if (this.isFullyHealed()) {
this.cancelRepairRetreat();
return false;
}
return true;
}
}
this.warship.setTargetTile(retreatPortTile);
const result = this.pathfinder.next(this.warship.tile(), retreatPortTile);
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.move(result.node);
if (result.node === retreatPortTile) {
this.warship.setTargetTile(undefined);
}
break;
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND: {
const newPort = this.findNearestAvailablePortTile();
this.warship.updateWarshipState({
retreatPort: newPort,
});
if (newPort === undefined) {
this.cancelRepairRetreat();
}
break;
}
}
return true;
}
private refreshRetreatPortTile(): boolean {
const ports = this.warship.owner().units(UnitType.Port);
if (ports.length === 0) {
return false;
}
const currentRetreatPort = this.warship.warshipState().retreatPort;
// Check if current retreat port still exists
const currentPortExists =
currentRetreatPort !== undefined &&
ports.some((port) => port.tile() === currentRetreatPort);
if (!currentPortExists) {
const newPort = this.findNearestAvailablePortTile();
this.warship.updateWarshipState({
retreatPort: newPort,
});
return newPort !== undefined;
}
// Check if current port is now full of healing (not counting arrived warships)
const currentPort = ports.find((p) => p.tile() === currentRetreatPort);
if (currentPort && this.isPortFullOfHealing(currentPort)) {
// Current port is at healing capacity, look for alternatives
const alternativePort = this.findNearestAvailablePort();
if (alternativePort) {
this.warship.updateWarshipState({
retreatPort: alternativePort,
});
}
return this.warship.warshipState().retreatPort !== undefined;
}
// Check if a significantly closer port is available
const closerPort = this.findBetterPortTile();
if (closerPort && closerPort !== currentRetreatPort) {
this.warship.updateWarshipState({
retreatPort: closerPort,
});
return true;
}
return true;
}
private isPortFullOfHealing(port: Unit, excludeShip?: Unit): boolean {
const maxShipsHealing = port.level();
return this.dockedShipsAtPort(port, excludeShip).length >= maxShipsHealing;
}
private dockedShipsAtPort(port: Unit, excludeShip?: Unit): Unit[] {
const dockingRadius = this.mg.config().warshipDockingRange();
const owner = this.warship.owner();
return this.mg
.nearbyUnits(port.tile(), dockingRadius, [UnitType.Warship])
.filter(({ unit: ship }) => {
if (excludeShip && ship === excludeShip) return false;
if (ship.owner() !== owner) return false;
if (ship.warshipState().state === "patrolling") return false;
if (ship.targetTile() !== undefined) return false;
return true;
})
.map(({ unit }) => unit);
}
private applyActiveDockedHealing(): void {
const dockedPort = this.currentRetreatPort();
if (!dockedPort) {
return;
}
const dockedShips = this.dockedShipsAtPort(dockedPort);
if (!dockedShips.some((ship) => ship === this.warship)) {
return;
}
const healingPool =
dockedPort.level() * this.mg.config().warshipPortHealingBonusPerLevel();
if (healingPool <= 0 || dockedShips.length === 0) {
return;
}
// Preserve fractional split healing over time with a per-ship remainder.
const activeHealing = healingPool / dockedShips.length;
this.activeHealingRemainder += activeHealing;
const integerHealing = Math.floor(this.activeHealingRemainder);
if (integerHealing <= 0) {
return;
}
this.activeHealingRemainder -= integerHealing;
this.warship.modifyHealth(integerHealing);
}
private currentRetreatPort(): Unit | undefined {
const retreatPort = this.warship.warshipState().retreatPort;
if (retreatPort === undefined) {
return undefined;
}
return this.warship
.owner()
.units(UnitType.Port)
.find((port) => port.tile() === retreatPort);
}
private nearestAvailablePortTile(
excludeShip?: Unit,
): { tile: TileRef; distSquared: number } | undefined {
const ports = this.warship.owner().units(UnitType.Port);
const warshipTile = this.warship.tile();
const warshipComponent = this.mg.getWaterComponent(warshipTile);
if (warshipComponent === null) {
throw new Error(`Warship at tile ${warshipTile} has no water component`);
}
let bestTile: TileRef | undefined = undefined;
let bestDistance = Infinity;
for (const port of ports) {
if (this.isPortFullOfHealing(port, excludeShip)) {
continue;
}
const portTile = port.tile();
if (!this.mg.hasWaterComponent(portTile, warshipComponent)) {
continue;
}
const distance = this.mg.euclideanDistSquared(warshipTile, portTile);
if (distance < bestDistance) {
bestDistance = distance;
bestTile = portTile;
}
}
return bestTile !== undefined
? { tile: bestTile, distSquared: bestDistance }
: undefined;
}
private findNearestAvailablePort(): TileRef | undefined {
return this.nearestAvailablePortTile()?.tile;
}
private findBetterPortTile(): TileRef | undefined {
const result = this.nearestAvailablePortTile();
if (!result) return undefined;
let currentDistance = Infinity;
const currentRetreatPort = this.warship.warshipState().retreatPort;
if (currentRetreatPort !== undefined) {
currentDistance = this.mg.euclideanDistSquared(
this.warship.tile(),
currentRetreatPort,
);
}
if (
result.distSquared <
currentDistance * this.mg.config().warshipPortSwitchThreshold()
) {
return result.tile;
}
return undefined;
}
private findNearestAvailablePortTile(): TileRef | undefined {
return this.nearestAvailablePortTile(this.warship)?.tile;
}
private shootTarget() {
this.warship.updateWarshipState({ isInCombat: true });
const shellAttackRate = this.mg.config().warshipShellAttackRate();
if (this.mg.ticks() - this.lastShellAttack > shellAttackRate) {
if (this.warship.targetUnit()?.type() !== UnitType.TransportShip) {
@@ -189,30 +632,61 @@ export class WarshipExecution implements Execution {
}
private huntDownTradeShip() {
this.warship.updateWarshipState({ isInCombat: true });
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetUnit()!.tile(),
5,
);
const target = this.warship.targetUnit()!;
const targetTile = target.tile();
const dist = this.mg.manhattanDist(this.warship.tile(), targetTile);
if (dist <= 5) {
this.warship.owner().captureUnit(target);
this.warship.setTargetUnit(undefined);
this.warship.touch();
return;
}
// When close, the minimap (2x scale) produces diagonal upscaled paths that
// make it hard to converge. Use direct greedy movement instead.
if (dist <= 20) {
const nextTile = this.bestNeighborToward(targetTile);
if (nextTile !== undefined) {
this.warship.move(nextTile);
continue;
}
}
const result = this.pathfinder.next(this.warship.tile(), targetTile, 5);
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.owner().captureUnit(target);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
this.warship.touch();
return;
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathStatus.NOT_FOUND: {
case PathStatus.NOT_FOUND:
console.log(`path not found to target`);
break;
}
}
}
}
private bestNeighborToward(targetTile: TileRef): TileRef | undefined {
const warshipTile = this.warship.tile();
let best: TileRef | undefined;
let bestDist = this.mg.manhattanDist(warshipTile, targetTile);
this.mg.forEachNeighbor(warshipTile, (neighbor) => {
if (!this.mg.isWater(neighbor)) return;
const d = this.mg.manhattanDist(neighbor, targetTile);
if (d < bestDist) {
bestDist = d;
best = neighbor;
}
});
return best;
}
private patrol() {
if (this.warship.targetTile() === undefined) {
this.warship.setTargetTile(this.randomTile());
@@ -245,6 +719,10 @@ export class WarshipExecution implements Execution {
return this.warship?.isActive();
}
isDocked(): boolean {
return (this.warship?.warshipState().state ?? "patrolling") === "docked";
}
activeDuringSpawnPhase(): boolean {
return false;
}
@@ -258,7 +736,7 @@ export class WarshipExecution implements Execution {
// Get warship's water component for connectivity check
const warshipComponent = this.mg.getWaterComponent(this.warship.tile());
const patrolTile = this.warship.patrolTile();
const patrolTile = this.warship.warshipState().patrolTile;
if (patrolTile === undefined) {
return undefined;
}
@@ -38,7 +38,7 @@ export class BreakAllianceExecution implements Execution {
this.recipient.updateRelation(this.requestor, -100);
const neighbors = this.requestor
.neighbors()
.nearby()
.filter(
(n): n is Player => n.isPlayer() && !n.isOnSameTeam(this.recipient!),
);
@@ -27,6 +27,8 @@ export class NationAllianceBehavior {
) {}
handleAllianceRequests() {
if (this.game.config().disableAlliances()) return;
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
@@ -44,6 +46,8 @@ export class NationAllianceBehavior {
}
handleAllianceExtensionRequests() {
if (this.game.config().disableAlliances()) return;
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
// Skip if no expiration yet/ ally didn't request extension yet / nation already agreed to extend
@@ -59,6 +63,8 @@ export class NationAllianceBehavior {
}
maybeSendAllianceRequests(borderingEnemies: Player[]) {
if (this.game.config().disableAlliances()) return;
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
(p.type() === PlayerType.Bot &&
@@ -276,7 +282,7 @@ export class NationAllianceBehavior {
case Difficulty.Impossible: {
// On hard and impossible we try to not ally with all our neighbors (If we have 2+ neighbors)
const borderingPlayers = this.player
.neighbors()
.nearby()
.filter(
(n): n is Player => n.isPlayer() && n.type() !== PlayerType.Bot,
);
@@ -384,9 +390,14 @@ export class NationAllianceBehavior {
}
// Betray very weak players (similar check as above but for the easier difficulties)
// This doesn't check for maxTroops and isn't really smart. It opens the nations up for attacks, but that's intended.
// This doesn't check for maxTroops and isn't really smart. It makes nations vulnerable, but that's intended.
// On easy, don't betray humans
if (
(difficulty === Difficulty.Easy || difficulty === Difficulty.Medium) &&
!(
difficulty === Difficulty.Easy &&
otherPlayer.type() === PlayerType.Human
) &&
this.player.troops() >= otherPlayer.troops() * 10
) {
this.betray(otherPlayer);
@@ -210,7 +210,7 @@ export class NationEmojiBehavior {
if (!this.random.chance(250)) return;
const nearbyHumans = this.player
.neighbors()
.nearby()
.filter(
(p): p is Player => p.isPlayer() && p.type() === PlayerType.Human,
);
@@ -88,11 +88,11 @@ export class NationMIRVBehavior {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 1.5; // Needs larger gap to trigger
return 2; // Needs larger gap to trigger
case Difficulty.Medium:
return 1.3;
return 1.5;
case Difficulty.Hard:
return 1.2;
return 1.25;
case Difficulty.Impossible:
return 1.15; // Reacts to smaller gaps
default:
@@ -104,7 +104,7 @@ export class NationMIRVBehavior {
const { difficulty } = this.game.config().gameConfig();
switch (difficulty) {
case Difficulty.Easy:
return 15; // Needs more cities to trigger
return 20; // Needs more cities to trigger
case Difficulty.Medium:
case Difficulty.Hard:
return 10;
@@ -117,6 +117,9 @@ export class NationMIRVBehavior {
considerMIRV(): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.MIRV)) {
return false;
}
if (this.player.units(UnitType.MissileSilo).length === 0) {
return false;
}
+127 -20
View File
@@ -6,6 +6,7 @@ import {
Player,
PlayerType,
Relation,
Structures,
Tick,
Unit,
UnitType,
@@ -21,6 +22,18 @@ import { AiAttackBehavior } from "../utils/AiAttackBehavior";
import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
import { randTerritoryTileArray } from "./NationUtils";
/** Cap on silo levels reachable via maybeDestroyEnemySam's upgrade fallback. */
const MAX_NATION_SILO_UPGRADE_LEVEL = 5;
/**
* Level-weighted structure density (sum of structure levels per tile owned)
* above which the richest impossible nation will pre-emptively nuke a player.
*/
const HIGH_DENSITY_NUKE_THRESHOLD = 1 / 75;
/** Minimum sum of structure levels a player needs to qualify as a high-density nuke target. */
const MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE = 5;
export class NationNukeBehavior {
private readonly recentlySentNukes: [
Tick,
@@ -43,14 +56,23 @@ export class NationNukeBehavior {
) {}
maybeSendNuke() {
const silos = this.player.units(UnitType.MissileSilo);
const config = this.game.config();
if (
silos.length === 0 ||
config.isUnitDisabled(UnitType.MissileSilo) ||
(config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb))
) {
return;
}
const nukeTarget = this.findBestNukeTarget();
if (nukeTarget === null) {
return;
}
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length === 0 ||
nukeTarget.type() === PlayerType.Bot || // Don't nuke tribes (as opposed to nations and humans)
this.player.isOnSameTeam(nukeTarget) ||
this.attackBehavior.shouldAttack(nukeTarget) === false
@@ -77,14 +99,7 @@ export class NationNukeBehavior {
}
const range = this.game.config().nukeMagnitudes(nukeType).outer;
const structures = nukeTarget.units(
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
UnitType.SAMLauncher,
UnitType.Factory,
);
const structures = nukeTarget.units(...Structures.types);
const structureTiles = structures.map((u) => u.tile());
const difficulty = this.game.config().gameConfig().difficulty;
// Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
@@ -167,6 +182,20 @@ export class NationNukeBehavior {
return incomingAttackPlayer;
}
// On Impossible, the richest nation hunts very high structure density targets
// Restricting to the richest nation prevents every impossible nation
// from piling onto the same compact player.
if (
diff === Difficulty.Impossible &&
this.isRichestNation() &&
this.random.chance(2)
) {
const denseTarget = this.findHighDensityTarget();
if (denseTarget !== null) {
return denseTarget;
}
}
// On impossible difficulty, prioritize nuking the crown if they have more than 50% of the map
const { difficulty, gameMode } = this.game.config().gameConfig();
if (difficulty === Difficulty.Impossible && gameMode === GameMode.FFA) {
@@ -230,6 +259,39 @@ export class NationNukeBehavior {
return null;
}
private isRichestNation(): boolean {
const myGold = this.player.gold();
for (const other of this.game.players()) {
if (other === this.player) continue;
if (other.type() !== PlayerType.Nation) continue;
if (other.gold() > myGold) return false;
}
return true;
}
private findHighDensityTarget(): Player | null {
let bestTarget: Player | null = null;
let bestDensity = HIGH_DENSITY_NUKE_THRESHOLD;
for (const other of this.game.players()) {
if (other === this.player) continue;
if (other.type() === PlayerType.Bot) continue;
if (this.player.isFriendly(other)) continue;
const tilesOwned = other.numTilesOwned();
if (tilesOwned === 0) continue;
const structures = other.units(...Structures.types);
let levelSum = 0;
for (const s of structures) levelSum += s.level();
// Skip players with too few structures regardless of density
if (levelSum < MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE) continue;
const density = levelSum / tilesOwned;
if (density > bestDensity) {
bestDensity = density;
bestTarget = other;
}
}
return bestTarget;
}
private findFFACrownTarget(): Player | null {
const { difficulty, gameMode } = this.game.config().gameConfig();
if (gameMode !== GameMode.FFA) {
@@ -377,12 +439,19 @@ export class NationNukeBehavior {
return this.cost(type);
}
// Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that)
// or if we already have enough gold to buy both a MIRV and a hydro
// Save up a limited amount in team games, synced with NationStructureBehavior
// Saving up for a MIRV is not relevant
if (
this.game.config().gameConfig().gameMode === GameMode.Team &&
this.player.gold() > this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
// Return the actual cost if we already have enough gold to buy both a MIRV and a hydro
if (
this.game.config().gameConfig().gameMode === GameMode.Team ||
this.player.gold() >
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
@@ -735,6 +804,13 @@ export class NationNukeBehavior {
// Try each enemy SAM as a target, easiest (lowest level) first
const sortedSams = enemySams.slice().sort((a, b) => a.level() - b.level());
let needsMoreSilos = false;
// Track the first failed attempt so we can upgrade a silo that would
// actually have helped that plan (rather than an unrelated silo).
let failedTarget: {
targetTile: TileRef;
coveringSamIds: Set<number>;
totalBombs: number;
} | null = null;
for (const targetSam of sortedSams) {
const targetTile = targetSam.tile();
@@ -832,6 +908,7 @@ export class NationNukeBehavior {
}
if (unblockedBombs.length < totalBombs) {
failedTarget ??= { targetTile, coveringSamIds, totalBombs };
needsMoreSilos = true;
continue;
}
@@ -860,6 +937,7 @@ export class NationNukeBehavior {
}
if (bestWindowCount < totalBombs) {
failedTarget ??= { targetTile, coveringSamIds, totalBombs };
needsMoreSilos = true;
continue;
}
@@ -921,8 +999,8 @@ export class NationNukeBehavior {
// Couldn't destroy any SAM — upgrade silos only if capacity was the bottleneck.
// If we only lack gold, don't waste it upgrading silos — just wait and save.
if (needsMoreSilos) {
this.maybeUpgradeBestProtectedSilo();
if (needsMoreSilos && failedTarget !== null) {
this.maybeUpgradeHelpfulSilo(failedTarget);
}
}
@@ -951,18 +1029,47 @@ export class NationNukeBehavior {
}
/**
* Upgrade the missile silo that is best protected by our own SAMs.
* Called when we need more silo capacity to overwhelm enemy SAMs.
* Upgrade a missile silo that would actually have helped the failed
* overwhelm attempt: trajectory to the failed target is not blocked by
* non-covering enemy SAMs, and the silo is below the upgrade cap. Among
* those, picks the one best protected by our own SAMs.
*/
private maybeUpgradeBestProtectedSilo(): void {
private maybeUpgradeHelpfulSilo(failedTarget: {
targetTile: TileRef;
coveringSamIds: Set<number>;
totalBombs: number;
}): void {
const silos = this.player.units(UnitType.MissileSilo);
if (silos.length === 0) return;
// First pass: find silos with an unblocked trajectory to the failed
// target. Only these contribute slots to the overwhelm plan.
const unblockedSilos: Unit[] = [];
for (const silo of silos) {
if (
!this.isTrajectoryInterceptableBySam(
silo.tile(),
failedTarget.targetTile,
failedTarget.coveringSamIds,
)
) {
unblockedSilos.push(silo);
}
}
if (unblockedSilos.length === 0) return;
// Bail out if the target is unreachable even at max silo level —
// crazy amounts of covering SAMs, upgrading is wasted gold.
const maxAchievableSlots =
unblockedSilos.length * MAX_NATION_SILO_UPGRADE_LEVEL;
if (maxAchievableSlots < failedTarget.totalBombs) return;
const ourSams = this.player.units(UnitType.SAMLauncher);
let bestSilo: Unit | null = null;
let bestProtection = -1;
for (const silo of silos) {
for (const silo of unblockedSilos) {
if (silo.level() >= MAX_NATION_SILO_UPGRADE_LEVEL) continue;
if (!this.player.canUpgradeUnit(silo)) continue;
let protection = 0;
@@ -1,6 +1,7 @@
import {
Difficulty,
Game,
GameMode,
Gold,
Player,
PlayerType,
@@ -57,7 +58,7 @@ function getStructureRatios(
},
[UnitType.SAMLauncher]: {
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
perceivedCostIncreasePerOwned: 0.5,
perceivedCostIncreasePerOwned: 0.3,
},
[UnitType.MissileSilo]: {
ratioPerCity: 0.2,
@@ -75,6 +76,9 @@ const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
/** Maximum number of missile silos a nation will build */
const MAX_MISSILE_SILOS = 3;
/** Ratio per city used for the first missile silo so nations start nuking earlier */
const FIRST_MISSILE_SILO_RATIO = 0.4;
/** If we have more than this many structures per tiles, prefer upgrading over building */
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
@@ -84,6 +88,34 @@ const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
/** Estimated number of tiles per city equivalent, used when cities are disabled */
const TILES_PER_CITY_EQUIVALENT = 2000;
/**
* When map-wide nation density (nations per land tile) is above this threshold,
* a nation's very first structure is a port (or factory if no water access)
*/
const HIGH_NATION_DENSITY_THRESHOLD = 1 / 7500;
/**
* Starting-gold threshold above which nations enter the
* "high-gold" early game: they build a SAM first and wait between structure
* placements. Without this, high-starting-gold games let a nation
* drop many structures within a short timespan, which ballooned its maxTroops
* before troop count caught up (delaying its attacks) and clustered the
* new structures inside a single nuke blast radius.
*/
const HIGH_STARTING_GOLD_THRESHOLD = 3_000_000n;
/** Tick gap a high-starting-gold nation must wait before placing its Nth structure */
const HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS: readonly number[] = [
0, // before #1 (SAM) — no pause
0, // before #2 — no pause
250, // before #3 — 25s
150, // before #4 — 15s
100, // before #5 — 10s
];
/** Length in ticks of each on/off phase after the team-mode save-up target is first reached */
const TEAM_POST_SAVE_UP_PHASE_TICKS = 150; // 15s
export class NationStructureBehavior {
private reachableStationsCache: Array<{
tile: TileRef;
@@ -91,6 +123,10 @@ export class NationStructureBehavior {
weight: number;
}> | null = null;
private _sharedWaterComponents: Set<number> | null = null;
private lastStructureTick: number | null = null;
private placementsCount = 0;
private _hasHighStartingGold: boolean | null = null;
private _postSaveUpStartTick: number | null = null;
constructor(
private random: PseudoRandom,
@@ -99,6 +135,54 @@ export class NationStructureBehavior {
) {}
handleStructures(): boolean {
if (this.isOnStructureCooldown()) {
return false;
}
if (this.isInPostSaveUpBlockedPhase()) {
return false;
}
const built = this.doHandleStructures();
if (built) {
this.lastStructureTick = this.game.ticks();
this.placementsCount++;
}
return built;
}
private isOnStructureCooldown(): boolean {
// Only high-starting-gold nations pause
if (this.lastStructureTick === null || !this.hasHighStartingGold()) {
return false;
}
const requiredGap =
HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS[this.placementsCount] ?? 0;
if (requiredGap === 0) {
return false;
}
return this.game.ticks() - this.lastStructureTick < requiredGap;
}
// Spreads placements after the save-up target is first reached:
// 15s ON / 15s OFF, alternating, to allow NationNukeBehavior to spend the gold.
private isInPostSaveUpBlockedPhase(): boolean {
if (this.game.config().isUnitDisabled(UnitType.MissileSilo)) {
return false;
}
const saveUpTarget = this.getSaveUpTarget();
if (this._postSaveUpStartTick === null) {
if (this.player.gold() < saveUpTarget) {
return false;
}
this._postSaveUpStartTick = this.game.ticks();
}
const elapsed = this.game.ticks() - this._postSaveUpStartTick;
return (
elapsed % (TEAM_POST_SAVE_UP_PHASE_TICKS * 2) >=
TEAM_POST_SAVE_UP_PHASE_TICKS
);
}
private doHandleStructures(): boolean {
this.reachableStationsCache = null;
const config = this.game.config();
const citiesDisabled = config.isUnitDisabled(UnitType.City);
@@ -111,6 +195,44 @@ export class NationStructureBehavior {
this._sharedWaterComponents = this.game.sharedWaterComponents(this.player);
const hasCoastalTiles = this._sharedWaterComponents !== null;
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
// High-starting-gold Hard/Impossible nations build a SAM first so their
// next structures get SAM coverage and aren't clustered under the same nuke target.
const { difficulty } = config.gameConfig();
if (
this.placementsCount === 0 &&
(difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible) &&
!config.isUnitDisabled(UnitType.AtomBomb) &&
missileSilosEnabled &&
!config.isUnitDisabled(UnitType.SAMLauncher) &&
this.hasHighStartingGold() &&
this.maybeSpawnStructure(UnitType.SAMLauncher)
) {
return true;
}
// On crowded maps the first structure is a port (or factory if landlocked)
// instead of a city, so nations can get income earlier.
// Mainly intended for private 200+ nation HvN games.
if (
!citiesDisabled &&
this.player.unitsOwned(UnitType.City) === 0 &&
this.isHighNationDensity()
) {
const preferredFirst =
hasCoastalTiles && !config.isUnitDisabled(UnitType.Port)
? UnitType.Port
: UnitType.Factory;
if (
!config.isUnitDisabled(preferredFirst) &&
this.maybeSpawnStructure(preferredFirst)
) {
return true;
}
}
// Build order for non-city structures (priority order)
const buildOrder: UnitType[] = [
UnitType.DefensePost,
@@ -124,7 +246,6 @@ export class NationStructureBehavior {
!config.isUnitDisabled(UnitType.AtomBomb) ||
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
!config.isUnitDisabled(UnitType.MIRV);
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
for (const structureType of buildOrder) {
// Skip disabled structure types
@@ -167,6 +288,21 @@ export class NationStructureBehavior {
return false;
}
private hasHighStartingGold(): boolean {
this._hasHighStartingGold ??=
this.game.config().startingGold(this.player.info()) >=
HIGH_STARTING_GOLD_THRESHOLD;
return this._hasHighStartingGold;
}
private isHighNationDensity(): boolean {
const landTiles = this.game.numLandTiles();
if (landTiles <= 0) return false;
return (
this.game.nations().length / landTiles > HIGH_NATION_DENSITY_THRESHOLD
);
}
/**
* Determines if we should build more of this structure type based on
* the current city count and the configured ratio.
@@ -202,6 +338,11 @@ export class NationStructureBehavior {
return false;
}
// First missile silo uses a higher ratio so nations can start nuking earlier
if (type === UnitType.MissileSilo && owned === 0) {
ratio = FIRST_MISSILE_SILO_RATIO;
}
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
if (type === UnitType.DefensePost) {
const tilesOwned = this.player.numTilesOwned();
@@ -297,9 +438,15 @@ export class NationStructureBehavior {
private getSaveUpTarget(): Gold {
const config = this.game.config();
// No need to save up if missile silos are disabled
// Just save up for SAMs if missile silos are disabled
if (config.isUnitDisabled(UnitType.MissileSilo)) {
return 0n;
return this.cost(UnitType.SAMLauncher);
}
// Save up a limited amount in team games, synced with NationNukeBehavior
// Saving up for a MIRV is not relevant
if (this.game.config().gameConfig().gameMode === GameMode.Team) {
return this.cost(UnitType.HydrogenBomb);
}
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
@@ -318,8 +465,8 @@ export class NationStructureBehavior {
// Save up for 20 atom bombs
return this.cost(UnitType.AtomBomb) * 20n;
}
// No nukes enabled, no need to save up
return 0n;
// No nukes enabled, just save up for SAMs
return this.cost(UnitType.SAMLauncher);
}
/**
@@ -561,16 +708,15 @@ export class NationStructureBehavior {
private portValue(): (tile: TileRef) => number {
const game = this.game;
const otherUnits = this.player.units(UnitType.Port);
const { structureSpacing } = this.spacingConstants();
return (tile) => {
let w = 0;
// Prefer to be away from other structures of the same type
// Prefer to be as far as possible from other ports
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
otherTiles.delete(tile);
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
w += Math.min(closestOtherDist, structureSpacing);
w += closestOtherDist;
return w;
};
@@ -732,7 +878,7 @@ export class NationStructureBehavior {
}
// Neighbor structures — all non-embargoed non-bot neighbors.
for (const neighbor of player.neighbors()) {
for (const neighbor of player.nearby()) {
if (!neighbor.isPlayer()) continue;
if (neighbor.type() === PlayerType.Bot) continue;
if (!player.canTrade(neighbor)) continue;
@@ -877,7 +1023,7 @@ export class NationStructureBehavior {
// Check if we have any non-friendly non-bot neighbors with more troops
const hasHostileNeighbor =
player
.neighbors()
.nearby()
.filter(
(n): n is Player =>
n.isPlayer() &&
@@ -21,6 +21,10 @@ export class NationWarshipBehavior {
private trackedTransportShips: Set<Unit> = new Set();
// Track our trade ships we currently own
private trackedTradeShips: Set<Unit> = new Set();
// Track incoming transport ships
private trackedIncomingTransportShips: Set<Unit> = new Set();
// Track incoming transport ships we have dealt with
private dealtWithTransportShip: Set<Unit> = new Set();
constructor(
private random: PseudoRandom,
@@ -31,6 +35,9 @@ export class NationWarshipBehavior {
maybeSpawnWarship(): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.Warship)) {
return false;
}
if (!this.random.chance(50)) {
return false;
}
@@ -42,7 +49,7 @@ export class NationWarshipBehavior {
this.player.gold() > this.cost(UnitType.Warship)
) {
const port = this.random.randElement(ports);
const targetTile = this.warshipSpawnTile(port.tile());
const targetTile = this.warshipSpawnTile(port.tile(), 250);
if (targetTile === null) {
return false;
}
@@ -58,8 +65,7 @@ export class NationWarshipBehavior {
return false;
}
private warshipSpawnTile(portTile: TileRef): TileRef | null {
const radius = 250;
private warshipSpawnTile(portTile: TileRef, radius: number): TileRef | null {
for (let attempts = 0; attempts < 50; attempts++) {
const randX = this.random.nextInt(
this.game.x(portTile) - radius,
@@ -85,10 +91,14 @@ export class NationWarshipBehavior {
trackShipsAndRetaliate(): void {
this.trackTransportShipsAndRetaliate();
this.trackTradeShipsAndRetaliate();
this.trackIncomingTransportsAndRetaliate();
}
// Send out a warship if our transport ship got captured
private trackTransportShipsAndRetaliate(): void {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return;
}
// Add any currently owned transport ships to our tracking set
this.player
.units(UnitType.TransportShip)
@@ -131,6 +141,82 @@ export class NationWarshipBehavior {
}
}
private trackIncomingTransportsAndRetaliate(): void {
// Add any transports which are targeting us to our tracking map
this.game
.units(UnitType.TransportShip)
.filter((p) => {
const target = p.targetTile();
return (
target &&
p.isActive() &&
!p.transportShipState().isRetreating &&
this.game.ownerID(target) === this.player?.smallID() &&
p.owner().smallID() !== this.player?.smallID()
);
})
.forEach((p) => this.trackedIncomingTransportShips.add(p));
for (const transport of Array.from(this.trackedIncomingTransportShips)) {
const target = transport.targetTile();
if (
!transport.isActive() ||
target === undefined ||
transport.transportShipState().isRetreating
) {
this.trackedIncomingTransportShips.delete(transport);
this.dealtWithTransportShip.delete(transport);
continue;
}
// Transport has already been dealt with
if (this.dealtWithTransportShip.has(transport)) {
continue;
}
const distanceToTarget = this.game.manhattanDist(
transport.tile(),
target,
);
// Too close to deal with
if (distanceToTarget < 20) {
this.dealtWithTransportShip.add(transport);
continue;
}
// Possible dock snipe counter? Too niche?
if (!transport.owner().isAlliedWith(this.player)) {
if (
this.game.hasUnitNearby(
target,
90,
UnitType.Warship,
this.player.id(),
true,
) ||
this.player.units(UnitType.Warship).filter((p) => {
const patrolTile = p.warshipState().patrolTile;
return (
patrolTile !== undefined &&
this.game.manhattanDist(target, patrolTile) < 90
);
}).length > 0
) {
this.dealtWithTransportShip.add(transport);
continue;
}
const oceanTiles = this.warshipSpawnTile(target, 30);
if (oceanTiles === null) continue;
this.maybeRetaliateWithWarship(
oceanTiles,
transport.owner(),
"transport",
);
this.dealtWithTransportShip.add(transport);
break;
}
}
}
private maybeRetaliateWithWarship(
tile: TileRef,
enemy: Player,
@@ -143,6 +229,7 @@ export class NationWarshipBehavior {
// Don't send too many warships
if (this.player.units(UnitType.Warship).length >= 10) {
this.maybeMoveWarship(tile);
return;
}
@@ -155,6 +242,7 @@ export class NationWarshipBehavior {
) {
const canBuild = this.player.canBuild(UnitType.Warship, tile);
if (canBuild === false) {
this.maybeMoveWarship(tile);
return;
}
this.game.addExecution(
@@ -165,6 +253,32 @@ export class NationWarshipBehavior {
}
}
private maybeMoveWarship(tile: TileRef): void {
// Make sure we are targeting water
if (this.game.isWater(tile)) {
const warship = this.player
.units(UnitType.Warship)
.filter((p) => {
const patrolTile = p.warshipState().patrolTile;
return (
patrolTile !== undefined &&
// Dont send ships which are already traveling
this.game.manhattanDist(p.tile(), patrolTile) < 130
);
})
.sort((a, b) => {
// Sort by distance (closest first)
const distA = this.game.manhattanDist(a.tile(), tile);
const distB = this.game.manhattanDist(b.tile(), tile);
return distA - distB;
})[0];
if (warship) {
warship.updateWarshipState({ patrolTile: tile });
}
}
}
// Prevent warship infestations: if current player is one of the 3 richest and an enemy has too many warships, send a counter-warship.
// What is a warship infestation? A player tries to dominate the entire ocean to block all trade and transport boats.
counterWarshipInfestation(): void {
@@ -185,6 +299,10 @@ export class NationWarshipBehavior {
}
private shouldCounterWarshipInfestation(): boolean {
if (this.game.config().isUnitDisabled(UnitType.Warship)) {
return false;
}
// Only the smart nations can do this
const { difficulty } = this.game.config().gameConfig();
if (
@@ -325,6 +443,7 @@ export class NationWarshipBehavior {
target.warship.tile(),
);
if (canBuild === false) {
this.maybeMoveWarship(target.warship.tile());
return;
}
+181 -70
View File
@@ -59,13 +59,18 @@ export class AiAttackBehavior {
this.game.isLand(t) &&
this.game.ownerID(t) !== this.player?.smallID(),
);
const borderingPlayers = [
...new Set(
border
.map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
.filter((o): o is Player => o.isPlayer()),
),
].sort((a, b) => a.troops() - b.troops());
const playerNeighbors = this.player.nearby();
const borderingPlayerSet = new Set<Player>(
border
.map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
.filter((o): o is Player => o.isPlayer()),
);
for (const n of playerNeighbors) {
if (n.isPlayer()) borderingPlayerSet.add(n);
}
const borderingPlayers = [...borderingPlayerSet].sort(
(a, b) => a.troops() - b.troops(),
);
const borderingFriends = borderingPlayers.filter(
(o) => this.player?.isFriendly(o) === true,
);
@@ -73,13 +78,12 @@ export class AiAttackBehavior {
(o) => this.player?.isFriendly(o) === false,
);
// Attack TerraNullius but not nuked territory
const hasNonNukedTerraNullius = border.some(
(t) => !this.game.hasOwner(t) && !this.game.hasFallout(t),
);
// Attack TerraNullius but not nuked territory (direct border or across a river)
const hasNonNukedTerraNullius =
border.some((t) => !this.game.hasOwner(t) && !this.game.hasFallout(t)) ||
playerNeighbors.some((n) => !n.isPlayer());
if (hasNonNukedTerraNullius) {
this.sendAttack(this.game.terraNullius());
return;
if (this.sendAttack(this.game.terraNullius())) return;
}
if (borderingEnemies.length === 0) {
@@ -101,6 +105,10 @@ export class AiAttackBehavior {
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return;
}
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
@@ -166,8 +174,12 @@ export class AiAttackBehavior {
if (owner.isPlayer() && borderingEnemies.includes(owner)) {
continue;
}
// Don't spam boats into players which are stronger than us
if (owner.isPlayer() && owner.troops() > this.player.troops()) {
// Don't spam boats into players which are stronger than us (FFA only)
if (
this.isFFA() &&
owner.isPlayer() &&
owner.troops() > this.player.troops()
) {
continue;
}
@@ -235,8 +247,7 @@ export class AiAttackBehavior {
const retaliate = (): boolean => {
const attacker = this.findIncomingAttackPlayer();
if (attacker) {
this.sendAttack(attacker, true);
return true;
return this.sendAttack(attacker, true);
}
return false;
};
@@ -248,8 +259,7 @@ export class AiAttackBehavior {
const traitor = (): boolean => {
const traitor = this.findTraitor(borderingEnemies);
if (traitor) {
this.sendAttack(traitor);
return true;
return this.sendAttack(traitor);
}
return false;
};
@@ -258,11 +268,11 @@ export class AiAttackBehavior {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest afk enemy
const afk = borderingEnemies.find(
(enemy) =>
enemy.isDisconnected() && enemy.troops() < this.player.troops() * 3,
enemy.isDisconnected() &&
(!this.isFFA() || enemy.troops() < this.player.troops() * 3),
);
if (afk) {
this.sendAttack(afk);
return true;
return this.sendAttack(afk);
}
return false;
};
@@ -272,8 +282,7 @@ export class AiAttackBehavior {
const nuked = (): boolean => {
if (this.isBorderingNukedTerritory()) {
this.sendAttack(this.game.terraNullius());
return true;
return this.sendAttack(this.game.terraNullius());
}
return false;
};
@@ -281,8 +290,7 @@ export class AiAttackBehavior {
const victim = (): boolean => {
const victim = this.findVictim(borderingEnemies);
if (victim) {
this.sendAttack(victim);
return true;
return this.sendAttack(victim);
}
return false;
};
@@ -292,9 +300,8 @@ export class AiAttackBehavior {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
if (other.troops() > this.player.troops() * 3) continue;
this.sendAttack(other);
return true;
if (this.isFFA() && other.troops() > this.player.troops() * 3) continue;
return this.sendAttack(other);
}
return false;
};
@@ -302,8 +309,7 @@ export class AiAttackBehavior {
const veryWeak = (): boolean => {
const veryWeak = this.findVeryWeakEnemy(borderingEnemies);
if (veryWeak) {
this.sendAttack(veryWeak);
return true;
return this.sendAttack(veryWeak);
}
return false;
};
@@ -312,10 +318,9 @@ export class AiAttackBehavior {
if (borderingEnemies.length > 0) {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
const weakest = borderingEnemies[0];
// Don't attack if they have more troops than us
if (weakest.troops() < this.player.troops()) {
this.sendAttack(weakest);
return true;
// In FFA, don't attack if they have more troops than us
if (!this.isFFA() || weakest.troops() < this.player.troops()) {
return this.sendAttack(weakest);
}
}
return false;
@@ -325,8 +330,7 @@ export class AiAttackBehavior {
if (borderingEnemies.length === 0) {
const enemy = this.findNearestIslandEnemy();
if (enemy) {
this.sendAttack(enemy);
return true;
return this.sendAttack(enemy);
}
}
return false;
@@ -356,7 +360,7 @@ export class AiAttackBehavior {
private hasNeighboringBotWithStructures(): boolean {
return this.player
.neighbors()
.nearby()
.some(
(n) =>
n.isPlayer() &&
@@ -379,8 +383,10 @@ export class AiAttackBehavior {
}
findIncomingAttackPlayer(): Player | null {
let incomingAttacks = this.player
.incomingAttacks()
.filter((attack) => !this.player.isFriendly(attack.attacker()));
// Ignore bot attacks if we are not a bot.
let incomingAttacks = this.player.incomingAttacks();
if (this.player.type() !== PlayerType.Bot) {
incomingAttacks = incomingAttacks.filter(
(attack) => attack.attacker().type() !== PlayerType.Bot,
@@ -404,7 +410,7 @@ export class AiAttackBehavior {
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
private attackBots(): boolean {
const bots = this.player
.neighbors()
.nearby()
.filter(
(n): n is Player =>
n.isPlayer() &&
@@ -461,6 +467,8 @@ export class AiAttackBehavior {
private assistAllies(): boolean {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
if (this.game.config().disableAlliances()) return false;
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
@@ -476,9 +484,8 @@ export class AiAttackBehavior {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
// All checks passed, assist them
if (!this.sendAttack(target)) continue;
this.player.updateRelation(ally, -20);
this.sendAttack(target);
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return true;
}
@@ -488,11 +495,14 @@ export class AiAttackBehavior {
// Find a traitor who isn't significantly stronger than us
private findTraitor(borderingEnemies: Player[]): Player | null {
if (this.game.config().disableAlliances()) return null;
// borderingEnemies is already sorted by troops (ascending), so first match is weakest traitor
return (
borderingEnemies.find(
(enemy) =>
enemy.isTraitor() && enemy.troops() < this.player.troops() * 1.2,
enemy.isTraitor() &&
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2),
) ?? null
);
}
@@ -503,6 +513,8 @@ export class AiAttackBehavior {
): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
if (this.game.config().disableAlliances()) return false;
if (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (
@@ -511,8 +523,7 @@ export class AiAttackBehavior {
borderingFriends.length + borderingEnemies.length,
)
) {
this.sendAttack(friend, true);
return true;
return this.sendAttack(friend, true);
}
}
}
@@ -520,6 +531,10 @@ export class AiAttackBehavior {
}
private isBorderingNukedTerritory(): boolean {
if (this.game.config().isUnitDisabled(UnitType.MissileSilo)) {
return false;
}
for (const tile of this.player.borderTiles()) {
for (const neighbor of this.game.neighbors(tile)) {
if (
@@ -539,7 +554,9 @@ export class AiAttackBehavior {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest victim
return (
borderingEnemies.find((enemy) => {
if (enemy.troops() > this.player.troops() * 1.2) return false;
if (this.isFFA() && enemy.troops() > this.player.troops() * 1.2) {
return false;
}
const totalIncomingTroops = enemy
.incomingAttacks()
@@ -557,7 +574,7 @@ export class AiAttackBehavior {
const enemyMaxTroops = this.game.config().maxTroops(enemy);
return (
enemy.troops() < enemyMaxTroops * 0.15 &&
enemy.troops() < this.player.troops() * 1.2
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2)
);
});
@@ -566,6 +583,10 @@ export class AiAttackBehavior {
}
private findNearestIslandEnemy(): Player | null {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return null;
}
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
@@ -583,8 +604,8 @@ export class AiAttackBehavior {
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
if (this.player.isFriendly(p)) return false;
// Don't spam boats into players with more troops
return p.troops() < this.player.troops();
// In FFA, don't spam boats into players with more troops
return !this.isFFA() || p.troops() < this.player.troops();
});
if (filteredPlayers.length === 0) return null;
@@ -640,6 +661,13 @@ export class AiAttackBehavior {
return reachablePlayers[0];
}
// In team games, nations should be willing to attack/boat into stronger
// enemies - they can rely on teammates to donate. In FFA, going after
// someone significantly stronger is usually a losing proposition.
private isFFA(): boolean {
return this.game.config().gameConfig().gameMode === GameMode.FFA;
}
private getPlayerCenter(player: Player) {
if (player.largestClusterBoundingBox) {
return boundingBoxCenter(player.largestClusterBoundingBox);
@@ -654,21 +682,19 @@ export class AiAttackBehavior {
// Retaliate against incoming attacks
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
this.sendAttack(incomingAttackPlayer, true);
return;
if (this.sendAttack(incomingAttackPlayer, true)) return;
}
// Select a traitor as an enemy
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (this.random.chance(3)) {
this.sendAttack(toAttack);
return;
if (this.sendAttack(toAttack)) return;
}
}
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
const neighbors = this.player.nearby();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
@@ -680,14 +706,15 @@ export class AiAttackBehavior {
continue;
}
}
this.sendAttack(neighbor);
return;
if (this.sendAttack(neighbor)) return;
}
}
getNeighborTraitorToAttack(): Player | null {
if (this.game.config().disableAlliances()) return null;
const traitors = this.player
.neighbors()
.nearby()
.filter(
(n): n is Player =>
n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(),
@@ -705,16 +732,94 @@ export class AiAttackBehavior {
);
}
sendAttack(target: Player | TerraNullius, force = false) {
if (!force && !this.shouldAttack(target)) return;
sendAttack(target: Player | TerraNullius, force = false): boolean {
if (!force && !this.shouldAttack(target)) return false;
if (this.player.sharesBorderWith(target)) {
this.sendLandAttack(target);
} else if (target.isPlayer()) {
this.sendBoatAttack(target);
if (target.isPlayer()) {
if (this.player.sharesBorderWith(target)) {
return this.sendLandAttack(target);
} else {
return this.sendBoatAttack(target);
}
} else {
// sharesBorderWith(TerraNullius) counts water tiles as TN (ownerID 0 = TN smallID),
// so use a land-only adjacency check to decide land vs boat attack.
if (this.hasLandBorderWithTerraNullius()) {
return this.sendLandAttack(target);
} else {
return this.sendBoatAttackToNearbyTerraNullius();
}
}
}
private hasLandBorderWithTerraNullius(): boolean {
for (const border of this.player.borderTiles()) {
for (const neighbor of this.game.neighbors(border)) {
if (
this.game.isLand(neighbor) &&
!this.game.hasOwner(neighbor) &&
!this.game.hasFallout(neighbor)
) {
return true;
}
}
}
return false;
}
// Scans shore border tiles (every 10th) for unowned land within 5 water tiles
// in each cardinal direction, then sends a transport ship to the first match.
private sendBoatAttackToNearbyTerraNullius(): boolean {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) return false;
if (
this.player.unitCount(UnitType.TransportShip) >=
this.game.config().boatMaxNumber()
)
return false;
const directions: [number, number][] = [
[0, -1],
[0, 1],
[-1, 0],
[1, 0],
];
const shores = Array.from(this.player.borderTiles()).filter((t) =>
this.game.isShore(t),
);
for (let i = 0; i < shores.length; i += 10) {
const border = shores[i];
const bx = this.game.x(border);
const by = this.game.y(border);
for (const [dx, dy] of directions) {
const x1 = bx + dx;
const y1 = by + dy;
if (!this.game.isValidCoord(x1, y1)) continue;
if (!this.game.isWater(this.game.ref(x1, y1))) continue;
const nx = bx + dx * 5;
const ny = by + dy * 5;
if (!this.game.isValidCoord(nx, ny)) continue;
const tile = this.game.ref(nx, ny);
if (!this.game.isLand(tile)) continue;
if (this.game.hasOwner(tile)) continue;
if (this.game.hasFallout(tile)) continue;
if (!canBuildTransportShip(this.game, this.player, tile)) continue;
const troops = this.player.troops() / 5;
if (troops < 1) return false;
this.game.addExecution(
new TransportShipExecution(this.player, tile, troops),
);
return true;
}
}
return false;
}
shouldAttack(other: Player | TerraNullius): boolean {
if (
// Always attack Terra Nullius, non-humans and traitors
@@ -730,7 +835,7 @@ export class AiAttackBehavior {
// Prevent attacking of humans on lower difficulties
const { difficulty } = this.game.config().gameConfig();
if (difficulty === Difficulty.Easy && this.random.chance(2)) {
if (difficulty === Difficulty.Easy && this.random.nextInt(0, 4) !== 0) {
return false;
}
if (difficulty === Difficulty.Medium && this.random.chance(4)) {
@@ -739,7 +844,7 @@ export class AiAttackBehavior {
return true;
}
private sendLandAttack(target: Player | TerraNullius) {
private sendLandAttack(target: Player | TerraNullius): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const botWithStructures =
target.isPlayer() &&
@@ -766,7 +871,7 @@ export class AiAttackBehavior {
}
if (troops < 1) {
return;
return false;
}
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
@@ -781,20 +886,25 @@ export class AiAttackBehavior {
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
return true;
}
private sendBoatAttack(target: Player) {
private sendBoatAttack(target: Player): boolean {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return false;
}
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
);
if (closest === null) {
return;
return false;
}
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
return;
return false;
}
let troops;
@@ -805,7 +915,7 @@ export class AiAttackBehavior {
}
if (troops < 1) {
return;
return false;
}
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
@@ -816,6 +926,7 @@ export class AiAttackBehavior {
this.game.addExecution(
new TransportShipExecution(this.player, closest.y, troops),
);
return true;
}
private calculateBotAttackTroops(target: Player, maxTroops: number): number {
+29 -9
View File
@@ -26,6 +26,19 @@ export type PlayerID = string;
export type Tick = number;
export type Gold = bigint;
export type WarshipState = {
state: "patrolling" | "retreating" | "docked";
patrolTile?: TileRef;
retreatPort?: TileRef;
isInCombat?: boolean;
lastCombatTick: number;
};
export type TransportShipState = {
isRetreating: boolean;
troops: number;
};
export const AllPlayers = "AllPlayers" as const;
// export type GameUpdates = Record<GameUpdateType, GameUpdate[]>;
@@ -144,14 +157,18 @@ export enum GameMapType {
SanFrancisco = "San Francisco",
Aegean = "Aegean",
MilkyWay = "MilkyWay",
Mediterranean = "Mediterranean",
MareNostrum = "Mare Nostrum",
Dyslexdria = "Dyslexdria",
GreatLakes = "Great Lakes",
StraitOfMalacca = "Strait Of Malacca",
Luna = "Luna",
Conakry = "Conakry",
Caucasus = "Caucasus",
LosAngeles = "Los Angeles",
BeringSea = "Bering Sea",
Antarctica = "Antarctica",
ArchipelagoSea = "ArchipelagoSea",
BajaCalifornia = "Baja California",
}
export type GameMapName = keyof typeof GameMapType;
@@ -167,6 +184,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Oceania,
GameMapType.Antarctica,
],
regional: [
GameMapType.BritanniaClassic,
@@ -203,12 +221,15 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Arctic,
GameMapType.SanFrancisco,
GameMapType.Aegean,
GameMapType.Mediterranean,
GameMapType.MareNostrum,
GameMapType.GreatLakes,
GameMapType.StraitOfMalacca,
GameMapType.Conakry,
GameMapType.Caucasus,
GameMapType.LosAngeles,
GameMapType.BeringSea,
GameMapType.ArchipelagoSea,
GameMapType.BajaCalifornia,
],
fantasy: [
GameMapType.Pangaea,
@@ -609,8 +630,10 @@ export interface Unit {
// Health
hasHealth(): boolean;
retreating(): boolean;
orderBoatRetreat(): void;
warshipState(): WarshipState;
updateWarshipState(update: Partial<WarshipState>): void;
transportShipState(): TransportShipState;
updateTransportShipState(update: Partial<TransportShipState>): void;
health(): number;
modifyHealth(delta: number, attacker?: Player): void;
@@ -638,10 +661,6 @@ export interface Unit {
level(): number;
increaseLevel(): void;
decreaseLevel(destroyer?: Player): void;
// Warships
setPatrolTile(tile: TileRef): void;
patrolTile(): TileRef | undefined;
}
export interface TerraNullius {
@@ -730,7 +749,7 @@ export interface Player {
captureUnit(unit: Unit): void;
// Relations & Diplomacy
neighbors(): (Player | TerraNullius)[];
nearby(): (Player | TerraNullius)[];
sharesBorderWith(other: Player | TerraNullius): boolean;
relation(other: Player): Relation;
allRelationsSorted(): { player: Player; relation: Relation }[];
@@ -861,6 +880,7 @@ export interface Game extends GameMap {
setPaused(paused: boolean): void;
// Units
unit(id: number): Unit | undefined;
units(...types: UnitType[]): Unit[];
unitCount(type: UnitType): number;
unitInfo(type: UnitType): UnitInfo;
+9 -1
View File
@@ -40,6 +40,7 @@ import {
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { GameUpdate, GameUpdateType } from "./GameUpdates";
import { UnitView } from "./GameView";
import { MotionPlanRecord, packMotionPlans } from "./MotionPlans";
import { PlayerImpl } from "./PlayerImpl";
import { RailNetwork } from "./RailNetwork";
@@ -97,6 +98,7 @@ export class GameImpl implements Game {
private motionPlanRecords: MotionPlanRecord[] = [];
private planDrivenUnitIds = new Set<number>();
private unitGrid: UnitGrid;
private _unitMap = new Map<number, Unit>();
private playerTeams: Team[] = [];
private botTeam: Team = ColoredTeams.Bot;
@@ -287,6 +289,10 @@ export class GameImpl implements Game {
this._waterManager.queueTile(tile);
}
unit(id: number): Unit | undefined {
return this._unitMap.get(id);
}
units(...types: UnitType[]): Unit[] {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
}
@@ -955,9 +961,11 @@ export class GameImpl implements Game {
addUnit(u: Unit) {
this.unitGrid.addUnit(u);
this._unitMap.set(u.id(), u);
}
removeUnit(u: Unit) {
this.unitGrid.removeUnit(u);
this._unitMap.delete(u.id());
this.planDrivenUnitIds.delete(u.id());
if (u.hasTrainStation()) {
this._railNetwork.removeStation(u);
@@ -995,7 +1003,7 @@ export class GameImpl implements Game {
tile,
searchRange,
types,
predicate,
predicate as (unit: Unit | UnitView) => boolean,
playerId,
includeUnderConstruction,
);
+4 -1
View File
@@ -10,7 +10,9 @@ import {
Team,
Tick,
TrainType,
TransportShipState,
UnitType,
WarshipState,
} from "./Game";
import { TileRef } from "./GameMap";
@@ -137,7 +139,8 @@ export interface UnitUpdate {
lastPos: TileRef;
isActive: boolean;
reachedTarget: boolean;
retreating: boolean;
warshipState?: WarshipState;
transportShipState?: TransportShipState;
targetable: boolean;
markedForDeletion: number | false;
targetUnitId?: number; // Only for trade ships
+25 -5
View File
@@ -24,8 +24,11 @@ import {
TerraNullius,
Tick,
TrainType,
TransportShipState,
Unit,
UnitInfo,
UnitType,
WarshipState,
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import {
@@ -115,11 +118,28 @@ export class UnitView {
troops(): number {
return this.data.troops;
}
retreating(): boolean {
if (this.type() !== UnitType.TransportShip) {
throw Error("Must be a transport ship");
warshipState(): WarshipState {
if (this.data.warshipState === undefined) {
throw new Error("warshipState called on non-warship unit");
}
return this.data.retreating;
return this.data.warshipState;
}
updateWarshipState(_update: Partial<WarshipState>): void {
throw new Error("updateWarshipState is not supported on UnitView");
}
isInCombat(): boolean {
return this.data.warshipState?.isInCombat ?? false;
}
touch(): void {
throw new Error("touch is not supported on UnitView");
}
transportShipState(): TransportShipState {
return this.data.transportShipState ?? { isRetreating: false, troops: 0 };
}
updateTransportShipState(
_update: Pick<TransportShipState, "isRetreating">,
): void {
throw new Error("updateTransportShipState is not supported on UnitView");
}
tile(): TileRef {
return this.data.pos;
@@ -1132,7 +1152,7 @@ export class GameView implements GameMap {
tile,
searchRange,
types,
predicate,
predicate as (unit: Unit | UnitView) => boolean,
playerId,
includeUnderConstruction,
);
+51 -1
View File
@@ -319,6 +319,7 @@ export class PlayerImpl implements Player {
}
return false;
}
numTilesOwned(): number {
return this._tiles.size;
}
@@ -331,7 +332,7 @@ export class PlayerImpl implements Player {
return this._borderTiles;
}
neighbors(): (Player | TerraNullius)[] {
nearby(): (Player | TerraNullius)[] {
const ns: Set<Player | TerraNullius> = new Set();
for (const border of this.borderTiles()) {
for (const neighbor of this.mg.map().neighbors(border)) {
@@ -345,9 +346,58 @@ export class PlayerImpl implements Player {
}
}
}
for (const n of this.shoreReachableNeighbors()) {
ns.add(n);
}
return Array.from(ns);
}
// Samples every 10th border tile for shore tiles, checks the tile 5 steps
// away in each cardinal direction that immediately enters water, to detect
// players separated by a small river (up to 4 water tiles wide)
private shoreReachableNeighbors(): Set<Player | TerraNullius> {
const ns: Set<Player | TerraNullius> = new Set();
const map = this.mg.map();
const shores = Array.from(this.borderTiles()).filter((t) => map.isShore(t));
const directions: [number, number][] = [
[0, -1],
[0, 1],
[-1, 0],
[1, 0],
];
for (let i = 0; i < shores.length; i += 10) {
const border = shores[i];
const bx = map.x(border);
const by = map.y(border);
for (const [dx, dy] of directions) {
// Only follow directions that immediately enter water; land-adjacent
// directions are already covered by the direct neighbors() loop.
const x1 = bx + dx;
const y1 = by + dy;
if (!map.isValidCoord(x1, y1) || !map.isWater(map.ref(x1, y1)))
continue;
const nx = bx + dx * 5;
const ny = by + dy * 5;
if (!map.isValidCoord(nx, ny)) continue;
const tile = map.ref(nx, ny);
if (!map.isLand(tile)) continue;
if (!map.hasOwner(tile) && map.hasFallout(tile)) continue;
const owner = map.ownerID(tile);
if (owner !== this.smallID()) {
ns.add(
this.mg.playerBySmallID(owner) satisfies Player | TerraNullius,
);
}
}
}
return ns;
}
isPlayer(): this is Player {
return true as const;
}
+2 -1
View File
@@ -113,7 +113,8 @@ export class RailNetworkImpl implements RailNetwork {
for (const cluster of this.dirtyClusters) {
const allOriginalStations = new Set(cluster.stations);
while (allOriginalStations.size > 0) {
const nextStation = allOriginalStations.values().next().value;
const nextStation = allOriginalStations.values().next()
.value as TrainStation;
const allConnectedStations = this.computeCluster(nextStation);
// Filter stations that are connected to the current cluster
for (const connectedStation of allConnectedStations) {
+1 -1
View File
@@ -106,5 +106,5 @@ export interface Stats {
trainSelfTrade(player: Player, gold: number | bigint): void;
// Another player's train arrives at own station
trainExternalTrade(player: Player, goldPlayer: number | bigint);
trainExternalTrade(player: Player, goldPlayer: number | bigint): void;
}
+106 -21
View File
@@ -6,9 +6,11 @@ import {
Tick,
TrainType,
TrajectoryTile,
TransportShipState,
Unit,
UnitInfo,
UnitType,
WarshipState,
} from "./Game";
import { GameImpl } from "./GameImpl";
import { TileRef } from "./GameMap";
@@ -21,7 +23,8 @@ export class UnitImpl implements Unit {
private _targetUnit: Unit | undefined;
private _health: bigint;
private _lastTile: TileRef;
private _retreating: boolean = false;
private _transportShipState: TransportShipState | undefined = undefined;
private _warshipState: WarshipState | undefined = undefined;
private _targetedBySAM = false;
private _reachedTarget = false;
private _wasDestroyedByEnemy: boolean = false;
@@ -33,7 +36,6 @@ export class UnitImpl implements Unit {
// Number of missiles in cooldown, if empty all missiles are ready.
private _missileTimerQueue: number[] = [];
private _hasTrainStation: boolean = false;
private _patrolTile: TileRef | undefined;
private _level: number = 1;
private _targetable: boolean = true;
private _loaded: boolean | undefined;
@@ -61,8 +63,16 @@ export class UnitImpl implements Unit {
"lastSetSafeFromPirates" in params
? (params.lastSetSafeFromPirates ?? 0)
: 0;
this._patrolTile =
"patrolTile" in params ? (params.patrolTile ?? undefined) : undefined;
if (this._type === UnitType.TransportShip) {
this._transportShipState = { isRetreating: false, troops: 0 };
}
if ("patrolTile" in params) {
this._warshipState = {
state: "patrolling",
patrolTile: params.patrolTile,
lastCombatTick: -100,
};
}
this._targetUnit =
"targetUnit" in params ? (params.targetUnit ?? undefined) : undefined;
this._loaded =
@@ -92,14 +102,6 @@ export class UnitImpl implements Unit {
return this._targetable;
}
setPatrolTile(tile: TileRef): void {
this._patrolTile = tile;
}
patrolTile(): TileRef | undefined {
return this._patrolTile;
}
isUnit(): this is Unit {
return true;
}
@@ -128,7 +130,14 @@ export class UnitImpl implements Unit {
lastOwnerID: this._lastOwner?.smallID(),
isActive: this._active,
reachedTarget: this._reachedTarget,
retreating: this._retreating,
warshipState:
this._warshipState !== undefined
? { ...this.warshipState() }
: undefined,
transportShipState:
this._transportShipState !== undefined
? this.transportShipState()
: undefined,
pos: this._tile,
markedForDeletion: this._deletionAt ?? false,
targetable: this._targetable,
@@ -221,11 +230,26 @@ export class UnitImpl implements Unit {
}
modifyHealth(delta: number, attacker?: Player): void {
this._health = withinInt(
const previousHealth = this._health;
const nextHealth = withinInt(
this._health + toInt(delta),
0n,
toInt(this.info().maxHealth ?? 1),
);
if (nextHealth === previousHealth) {
return;
}
if (
attacker !== undefined &&
delta < 0 &&
this._warshipState !== undefined
) {
this._warshipState.lastCombatTick = this.mg.ticks();
}
this._health = nextHealth;
this.mg.addUpdate(this.toUpdate());
if (this._health === 0n) {
this.delete(true, attacker);
}
@@ -327,16 +351,77 @@ export class UnitImpl implements Unit {
return this._destroyer;
}
retreating(): boolean {
return this._retreating;
warshipState(): WarshipState {
if (this._warshipState === undefined) {
throw new Error("warshipState called on non-warship unit");
}
this._warshipState.isInCombat = this.isInCombat();
return this._warshipState;
}
orderBoatRetreat() {
if (this.type() !== UnitType.TransportShip) {
throw new Error(`Cannot retreat ${this.type()}`);
updateWarshipState(update: Partial<WarshipState>): void {
if (this._warshipState === undefined) {
throw new Error("updateWarshipState called on non-warship unit");
}
if (!this._retreating) {
this._retreating = true;
if (update.isInCombat) {
this.markInCombat();
}
const merged = { ...this._warshipState, ...update };
if (
merged.state === this._warshipState.state &&
merged.patrolTile === this._warshipState.patrolTile &&
merged.retreatPort === this._warshipState.retreatPort
)
return;
this._warshipState = {
state: merged.state,
patrolTile: merged.patrolTile,
retreatPort: merged.retreatPort,
lastCombatTick: this._warshipState.lastCombatTick,
};
this.mg.addUpdate(this.toUpdate());
}
isInCombat(): boolean {
return this.mg.ticks() - this._warshipState!.lastCombatTick <= 3;
}
private markInCombat(): void {
const wasInCombat = this.isInCombat();
this._warshipState!.lastCombatTick = this.mg.ticks();
if (!wasInCombat) {
this.mg.addUpdate(this.toUpdate());
}
}
transportShipState(): TransportShipState {
if (this._transportShipState === undefined) {
throw new Error("transportShipState called on non-transport-ship unit");
}
return {
isRetreating: this._transportShipState.isRetreating,
troops: this._troops,
};
}
updateTransportShipState(update: Partial<TransportShipState>): void {
if (this._transportShipState === undefined) {
throw new Error(
"updateTransportShipState called on non-transport-ship unit",
);
}
let changed = false;
if (
update.isRetreating !== undefined &&
this._transportShipState.isRetreating !== update.isRetreating
) {
this._transportShipState = {
...this._transportShipState,
isRetreating: update.isRetreating,
};
changed = true;
}
if (changed) {
this.mg.addUpdate(this.toUpdate());
}
}
+1 -1
View File
@@ -435,7 +435,7 @@ export class UserSettings {
}
soundEffectsVolume(): number {
return this.getFloat("settings.soundEffectsVolume", 1);
return this.getFloat("settings.soundEffectsVolume", 0);
}
setSoundEffectsVolume(volume: number): void {
+9 -1
View File
@@ -9,6 +9,11 @@ type Span = {
const stack: Span[] = [];
declare global {
var __DEBUG_SPAN_ENABLED__: boolean | undefined;
var __DEBUG_SPANS__: Span[];
}
function isEnabled(): boolean {
return globalThis.__DEBUG_SPAN_ENABLED__ === true;
}
@@ -82,7 +87,10 @@ export const DebugSpan = {
);
};
const properties = {
const properties: {
timings: Record<string, number | undefined>;
data: Record<string, any>;
} = {
timings: { total: span.duration },
data: extractData(span),
};
+3
View File
@@ -134,6 +134,9 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
switch (message.type) {
case "init":
try {
// Set before createGameRunner so map fetches via mapLoader pick up the
// CDN base. Workers have no `window`, so AssetUrls falls back to this.
globalThis.__CDN_BASE__ = message.cdnBase;
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
+59 -5
View File
@@ -1,3 +1,4 @@
import { getCdnBase } from "../AssetUrls";
import {
BuildableUnit,
Cell,
@@ -12,6 +13,45 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { ClientID, GameStartInfo, Turn } from "../Schemas";
import { generateID } from "../Util";
import { WorkerMessage } from "./WorkerMessages";
// ?worker&url returns the worker bundle's URL as a string. We load it via a
// same-origin Blob trampoline because browsers refuse cross-origin
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
// the page so the constructor accepts it, and dynamic `import()` inside the
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
import workerUrl from "./Worker.worker.ts?worker&url";
function createGameWorker(): Worker {
const cdnBase = getCdnBase().replace(/\/+$/, "");
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
// worker directly. The Blob trampoline below is only needed for cross-origin
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
// regular ES modules so the trampoline's dynamic `import()` would hang.
if (!cdnBase) {
return new Worker(workerUrl, { type: "module" });
}
const fullUrl = `${cdnBase}${workerUrl}`;
// Buffer-and-replay: the worker's port enables when the trampoline script
// starts, so any messages posted before the imported module attaches its
// `message` handler would dispatch to no listener and be dropped. Capture
// them here, then re-dispatch after the import resolves.
const trampoline = `
const buffered = [];
const buffer = (e) => buffered.push(e);
self.addEventListener("message", buffer);
import(${JSON.stringify(fullUrl)}).then(() => {
self.removeEventListener("message", buffer);
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
`;
const blobUrl = URL.createObjectURL(
new Blob([trampoline], { type: "application/javascript" }),
);
const worker = new Worker(blobUrl, { type: "module" });
URL.revokeObjectURL(blobUrl);
return worker;
}
export class WorkerClient {
private worker: Worker;
@@ -25,9 +65,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID | undefined,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url), {
type: "module",
});
this.worker = createGameWorker();
this.messageHandlers = new Map();
// Set up global message handler
@@ -74,8 +112,21 @@ export class WorkerClient {
return new Promise((resolve, reject) => {
const messageId = generateID();
const onTrampolineError = (event: MessageEvent) => {
if (event.data?.type !== "trampoline_error") return;
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(
new Error(
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
),
);
};
this.worker.addEventListener("message", onTrampolineError);
this.messageHandlers.set(messageId, (message) => {
if (message.type === "initialized") {
this.worker.removeEventListener("message", onTrampolineError);
this.isInitialized = true;
resolve();
}
@@ -86,15 +137,18 @@ export class WorkerClient {
id: messageId,
gameStartInfo: this.gameStartInfo,
clientID: this.clientID,
cdnBase: getCdnBase(),
});
// Add timeout for initialization
// Backstop for the worker hanging after a successful import (the
// trampoline_error path handles the cross-origin / CORS load failure).
setTimeout(() => {
if (!this.isInitialized) {
this.worker.removeEventListener("message", onTrampolineError);
this.messageHandlers.delete(messageId);
reject(new Error("Worker initialization timeout"));
}
}, 20000); // 20 second timeout
}, 20000);
});
}
+14 -2
View File
@@ -28,7 +28,8 @@ export type WorkerMessageType =
| "attack_clustered_positions"
| "attack_clustered_positions_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
| "transport_ship_spawn_result"
| "trampoline_error";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -41,6 +42,7 @@ export interface InitMessage extends BaseWorkerMessage {
type: "init";
gameStartInfo: GameStartInfo;
clientID: ClientID | undefined;
cdnBase: string;
}
export interface TurnMessage extends BaseWorkerMessage {
@@ -137,6 +139,15 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
result: TileRef | false;
}
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
// dynamic import of the real worker module fails. The real worker module
// never loaded, so no other message will ever arrive — initialize() must
// reject on this rather than wait out its timeout.
export interface TrampolineErrorMessage extends BaseWorkerMessage {
type: "trampoline_error";
message: string;
}
// Union types for type safety
export type MainThreadMessage =
| InitMessage
@@ -159,4 +170,5 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackClusteredPositionsResultMessage
| TransportShipSpawnResultMessage;
| TransportShipSpawnResultMessage
| TrampolineErrorMessage;

Some files were not shown because too many files have changed in this diff Show More