mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 02:56:06 +00:00
Merge branch 'main' into keys-wrongly-displayed
This commit is contained in:
+15
-14
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 (0–1) 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,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>
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>`
|
||||
: ""}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
}
|
||||
|
||||
.l-header__highlightText {
|
||||
color: #0073b7;
|
||||
color: var(--color-malibu-blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
+32
-11
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class BoatRetreatExecution implements Execution {
|
||||
return;
|
||||
}
|
||||
|
||||
unit.orderBoatRetreat();
|
||||
unit.updateTransportShipState({ isRetreating: true });
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user