diff --git a/resources/lang/en.json b/resources/lang/en.json
index e3a47ef60..30ed71d37 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -157,6 +157,7 @@
"account_modal": {
"title": "Account",
"logged_in_as": "Logged in as {email}",
+ "fetching_account": "Fetching account information...",
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
},
diff --git a/resources/lang/sl.json b/resources/lang/sl.json
index c6c4cb02e..b786336ae 100644
--- a/resources/lang/sl.json
+++ b/resources/lang/sl.json
@@ -130,9 +130,8 @@
"disable_nations": "Deaktiviraj nacije",
"instant_build": "Instantna gradnja",
"infinite_gold": "Neomejeno zlata",
- "donate_gold": "Doniraj zlato",
"infinite_troops": "Neomejene enote",
- "donate_troops": "Doniraj enote",
+ "compact_map": "Mini mapa",
"disable_nukes": "Deaktiviraj bombe",
"enables_title": "Aktiviraj nastavitve",
"start": "Začni igro"
@@ -177,8 +176,10 @@
"halkidiki": "Halkidiki",
"straitofgibraltar": "Gibraltarska ožina",
"italia": "Italija",
+ "japan": "Japonska",
"yenisei": "Jenisej",
- "pluto": "Pluto"
+ "pluto": "Pluto",
+ "montreal": "Montreal"
},
"map_categories": {
"continental": "Celinska",
@@ -196,8 +197,9 @@
"join_lobby": "Pridruži se sobi",
"checking": "Preverjanje sobe...",
"not_found": "Sobe ni mogoče najti. Prosimo preverite ID in poskusite znova.",
- "error": "Prišlo je do napake. Prosimo, poskusite znova.",
- "joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre..."
+ "error": "Prišlo je do napake. Poskusite znova ali se obrnite na podporo.",
+ "joined_waiting": "Uspešna pridružitev! Čakanje na začetek igre...",
+ "version_mismatch": "Ta soba je bila ustvarjena z drugo verzijo igre. Napaka pridružitve."
},
"public_lobby": {
"join": "Pridruži se naslednji igri",
@@ -227,6 +229,7 @@
"donate_gold": "Doniraj zlato",
"infinite_troops": "Neomejene enote",
"donate_troops": "Doniraj enote",
+ "compact_map": "Mini mapa",
"enables_title": "Aktiviraj nastavitve",
"player": "Igralec",
"players": "Igralci",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Izberite, ali želite v igri prikazati vzorce ozemlja",
"performance_overlay_label": "Prekrivni sloj zmogljivosti",
"performance_overlay_desc": "Preklopi prekrivni sloj zmogljivosti. Ko je omogočen, bo prikazan prekrivni sloj zmogljivosti. Med igro pritisnite shift-D za preklop.",
+ "performance_overlay_enabled": "Prekrivanje zmogljivosti je omogočeno",
+ "performance_overlay_disabled": "Prekrivanje zmogljivosti je onemogočeno",
"easter_writing_speed_label": "Množitelj hitrosti pisanja",
"easter_writing_speed_desc": "Prilagodite hitrost pretvarjanja, da programirate (x1–x100)",
"easter_bug_count_label": "Število hroščev",
@@ -494,7 +499,8 @@
"nation": "Narod",
"player": "Igralec",
"team": "Ekipa",
- "d_troops": "Obrambne enote",
+ "alliance_timeout": "Zavezništvo poteče čez",
+ "troops": "Enote",
"a_troops": "Napadalne enote",
"gold": "Zlato",
"ports": "Luka",
@@ -585,7 +591,7 @@
"choose_spawn": "Izberite začetno lokacijo"
},
"territory_patterns": {
- "title": "Izberite vzorec ozemlja",
+ "title": "Izberi poslikavo območja",
"purchase": "Nakup",
"blocked": {
"login": "Za dostop do tega vzorca morate biti prijavljeni.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Pobriši enoto",
"delete_unit_description": "Pritisni za odstranitev najbližjih enot"
+ },
+ "discord_user_header": {
+ "avatar_alt": "Avatar"
+ },
+ "player_stats_table": {
+ "building_stats": "Statistika zgradbe",
+ "ship_arrivals": "Prihajajoče ladje",
+ "nuke_stats": "Statistika bomb",
+ "player_metrics": "Metrika igralca",
+ "building": "Zgradba",
+ "ship_type": "Vrsta ladje",
+ "weapon": "Orožje",
+ "built": "Zgrajeno",
+ "destroyed": "Uničeno",
+ "captured": "Ujeto",
+ "lost": "Zgubljeno",
+ "hits": "Zadetki",
+ "launched": "Izstreljeno",
+ "landed": "Pristanek",
+ "sent": "Poslano",
+ "arrived": "Prišlo",
+ "attack": "Napad",
+ "received": "Prejeto",
+ "cancelled": "Prekinjeno",
+ "count": "Število",
+ "gold": "Zlato",
+ "workers": "Delavci",
+ "war": "Vojna",
+ "trade": "Trgovanje",
+ "steal": "Kraja",
+ "unit": {
+ "city": "Mesto",
+ "port": "Luka",
+ "defp": "Obrambni stolp",
+ "saml": "SAM Izstreljevalec",
+ "silo": "Izstreljevalec raket",
+ "wshp": "Bojna ladja",
+ "fact": "Tovarna",
+ "trade": "Trgovska ladja",
+ "trans": "Transportna ladja",
+ "abomb": "Atomska bomba",
+ "hbomb": "Vodikova bomba",
+ "mirv": "MIRV",
+ "mirvw": "MIRV bojna glava"
+ }
+ },
+ "game_list": {
+ "recent_games": "Nedavne igre",
+ "game_id": "ID sobe",
+ "mode": "Način",
+ "mode_ffa": "Prosto za vse",
+ "mode_team": "Ekipe",
+ "view": "Poglej",
+ "details": "Detajli",
+ "started": "Začeto",
+ "map": "Mapa",
+ "difficulty": "Težavnost",
+ "type": "Tip"
}
}
diff --git a/resources/lang/zh-CN.json b/resources/lang/zh-CN.json
index 2e748efa2..4b038a59c 100644
--- a/resources/lang/zh-CN.json
+++ b/resources/lang/zh-CN.json
@@ -130,9 +130,8 @@
"disable_nations": "禁用国家",
"instant_build": "立即建造",
"infinite_gold": "无限黄金",
- "donate_gold": "捐赠金币",
"infinite_troops": "无限军队",
- "donate_troops": "捐赠军队",
+ "compact_map": "小地图",
"disable_nukes": "禁用核弹",
"enables_title": "启用设置",
"start": "开始游戏"
@@ -177,8 +176,10 @@
"halkidiki": "哈尔基季基",
"straitofgibraltar": "直布罗陀海峡",
"italia": "意大利",
+ "japan": "日本",
"yenisei": "叶尼塞河",
- "pluto": "冥王星"
+ "pluto": "冥王星",
+ "montreal": "蒙特利尔"
},
"map_categories": {
"continental": "大陆",
@@ -196,8 +197,9 @@
"join_lobby": "加入房间",
"checking": "正在确认房间...",
"not_found": "找不到房间。请检查 ID 然后重试。",
- "error": "发生了错误,请再试一次。",
- "joined_waiting": "加入成功!正在等待游戏开始..."
+ "error": "发生错误。请再试一次或联系支持人员。",
+ "joined_waiting": "加入成功!正在等待游戏开始...",
+ "version_mismatch": "这场游戏基于另一个版本,无法加入。"
},
"public_lobby": {
"join": "加入下一场游戏",
@@ -227,6 +229,7 @@
"donate_gold": "捐赠金币",
"infinite_troops": "无限军队",
"donate_troops": "捐赠军队",
+ "compact_map": "小地图",
"enables_title": "启用设置",
"player": "玩家",
"players": "玩家",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "选择是否在游戏中显示领土皮肤",
"performance_overlay_label": "性能叠层",
"performance_overlay_desc": "切换性能叠层。启用后将显示性能叠层。在游戏过程中按下 Shift+D 可进行切换。",
+ "performance_overlay_enabled": "已启用性能叠层",
+ "performance_overlay_disabled": "已禁用性能叠层",
"easter_writing_speed_label": "写入速度乘数",
"easter_writing_speed_desc": "调节你“假装写代码”的速度 (x1–x100)",
"easter_bug_count_label": "Bug 计数",
@@ -494,7 +499,8 @@
"nation": "国家",
"player": "玩家",
"team": "队伍",
- "d_troops": "防守军队",
+ "alliance_timeout": "结盟剩余时长",
+ "troops": "军队",
"a_troops": "进攻军队",
"gold": "黄金",
"ports": "港口",
@@ -541,7 +547,7 @@
},
"relation": {
"hostile": "敌对",
- "distrustful": "不可信",
+ "distrustful": "可疑",
"neutral": "中立",
"friendly": "友好",
"default": "默认"
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "删除单位",
"delete_unit_description": "点击删除最近的单位"
+ },
+ "discord_user_header": {
+ "avatar_alt": "头像"
+ },
+ "player_stats_table": {
+ "building_stats": "建筑统计",
+ "ship_arrivals": "船只抵达",
+ "nuke_stats": "核弹统计",
+ "player_metrics": "玩家指标",
+ "building": "建筑",
+ "ship_type": "船只类型",
+ "weapon": "武器",
+ "built": "已建造",
+ "destroyed": "已摧毁",
+ "captured": "已捕获",
+ "lost": "损失",
+ "hits": "击中",
+ "launched": "已发射",
+ "landed": "已登陆",
+ "sent": "已发送",
+ "arrived": "已抵达",
+ "attack": "攻击",
+ "received": "已收到",
+ "cancelled": "已取消",
+ "count": "总计",
+ "gold": "黄金",
+ "workers": "工人",
+ "war": "战争",
+ "trade": "交易",
+ "steal": "偷窃",
+ "unit": {
+ "city": "城市",
+ "port": "港口",
+ "defp": "防守据点",
+ "saml": "防空塔",
+ "silo": "导弹发射井",
+ "wshp": "军舰",
+ "fact": "工厂",
+ "trade": "贸易船",
+ "trans": "运输船",
+ "abomb": "原子弹",
+ "hbomb": "氢弹",
+ "mirv": "MIRV",
+ "mirvw": "MIRV 弹头"
+ }
+ },
+ "game_list": {
+ "recent_games": "近期对局",
+ "game_id": "游戏 ID",
+ "mode": "模式",
+ "mode_ffa": "混战",
+ "mode_team": "团队",
+ "view": "视图",
+ "details": "详细信息",
+ "started": "已开始",
+ "map": "地图",
+ "difficulty": "难度",
+ "type": "类型"
}
}
diff --git a/resources/sprites/buildingExplosion.png b/resources/sprites/buildingExplosion.png
new file mode 100644
index 000000000..de827d4e0
Binary files /dev/null and b/resources/sprites/buildingExplosion.png differ
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index 93bf0ff96..01762b942 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -1,9 +1,23 @@
import { html, LitElement, TemplateResult } from "lit";
import { customElement, query, state } from "lit/decorators.js";
-import { UserMeResponse } from "../core/ApiSchemas";
+import {
+ PlayerGame,
+ PlayerStatsTree,
+ UserMeResponse,
+} from "../core/ApiSchemas";
+import "./components/baseComponents/stats/DiscordUserHeader";
+import "./components/baseComponents/stats/GameList";
+import "./components/baseComponents/stats/PlayerStatsTable";
+import "./components/baseComponents/stats/PlayerStatsTree";
import "./components/Difficulties";
import "./components/PatternButton";
-import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
+import {
+ discordLogin,
+ fetchPlayerById,
+ getApiBase,
+ getUserMe,
+ logOut,
+} from "./jwt";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
@@ -14,12 +28,33 @@ export class AccountModal extends LitElement {
};
@state() private email: string = "";
+ @state() private isLoadingUser: boolean = false;
private loggedInEmail: string | null = null;
private loggedInDiscord: string | null = null;
+ private userMeResponse: UserMeResponse | null = null;
+ private playerId: string | null = null;
+ private statsTree: PlayerStatsTree | null = null;
+ private recentGames: PlayerGame[] = [];
constructor() {
super();
+
+ document.addEventListener("userMeResponse", (event: Event) => {
+ const customEvent = event as CustomEvent;
+ if (customEvent.detail) {
+ this.userMeResponse = customEvent.detail as UserMeResponse;
+ this.playerId = this.userMeResponse?.player?.publicId;
+ if (this.playerId === undefined) {
+ this.statsTree = null;
+ this.recentGames = [];
+ }
+ } else {
+ this.statsTree = null;
+ this.recentGames = [];
+ this.requestUpdate();
+ }
+ });
}
createRenderRoot() {
@@ -38,6 +73,16 @@ export class AccountModal extends LitElement {
}
private renderInner() {
+ if (this.isLoadingUser) {
+ return html`
+
+
${translateText("account_modal.fetching_account")}
+
+
+ `;
+ }
if (this.loggedInDiscord) {
return this.renderLoggedInDiscord();
} else if (this.loggedInEmail) {
@@ -47,15 +92,39 @@ export class AccountModal extends LitElement {
}
}
+ private viewGame(gameId: string): void {
+ this.close();
+ const path = location.pathname;
+ const { search } = location;
+ const hash = `#join=${encodeURIComponent(gameId)}`;
+ const newUrl = `${path}${search}${hash}`;
+
+ history.pushState({ join: gameId }, "", newUrl);
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ }
+
private renderLoggedInDiscord() {
return html`
-
-
+
+
Logged in with Discord as ${this.loggedInDiscord}
+ ${this.logoutButton()}
+
+
+
+
+
+
this.viewGame(id)}
+ >
- ${this.logoutButton()}
`;
}
@@ -208,13 +277,30 @@ export class AccountModal extends LitElement {
discordLogin();
}
- public async open() {
- const userMe = await getUserMe();
- if (userMe) {
- this.loggedInEmail = userMe.user.email ?? null;
- this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
- }
+ public open() {
this.modalEl?.open();
+ this.isLoadingUser = true;
+
+ void getUserMe()
+ .then((userMe) => {
+ if (userMe) {
+ this.loggedInEmail = userMe.user.email ?? null;
+ this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
+ if (this.playerId) {
+ this.loadFromApi(this.playerId);
+ }
+ } else {
+ this.loggedInEmail = null;
+ this.loggedInDiscord = null;
+ }
+ this.isLoadingUser = false;
+ this.requestUpdate();
+ })
+ .catch((err) => {
+ console.warn("Failed to fetch user info in AccountModal.open():", err);
+ this.isLoadingUser = false;
+ this.requestUpdate();
+ });
this.requestUpdate();
}
@@ -228,6 +314,24 @@ export class AccountModal extends LitElement {
// Refresh the page after logout to update the UI state
window.location.reload();
}
+
+ private async loadFromApi(playerId: string): Promise
{
+ try {
+ const data = await fetchPlayerById(playerId);
+ if (!data) {
+ this.requestUpdate();
+ return;
+ }
+
+ this.recentGames = data.games;
+ this.statsTree = data.stats;
+
+ this.requestUpdate();
+ } catch (err) {
+ console.warn("Failed to load player data:", err);
+ this.requestUpdate();
+ }
+ }
}
@customElement("account-button")
diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts
index c0a501d8b..8eeb1f74d 100644
--- a/src/client/Cosmetics.ts
+++ b/src/client/Cosmetics.ts
@@ -74,10 +74,11 @@ export async function fetchCosmetics(): Promise {
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
- userMeResponse: UserMeResponse | null,
+ userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
- const flares = userMeResponse?.player.flares ?? [];
+ const flares =
+ userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes("pattern:*")) {
return "owned";
}
diff --git a/src/client/GutterAds.ts b/src/client/GutterAds.ts
index b47f2939b..caa33623a 100644
--- a/src/client/GutterAds.ts
+++ b/src/client/GutterAds.ts
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
+import { UserMeResponse } from "../core/ApiSchemas";
import { isInIframe } from "./Utils";
const LEFT_FUSE = "gutter-ad-container-left";
@@ -17,6 +18,31 @@ export class GutterAds extends LitElement {
return this;
}
+ private readonly boundUserMeHandler = (event: Event) =>
+ this.onUserMe((event as CustomEvent).detail);
+
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener(
+ "userMeResponse",
+ this.boundUserMeHandler as EventListener,
+ );
+ }
+
+ private onUserMe(userMeResponse: UserMeResponse | false): void {
+ const flares =
+ userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
+ const hasFlare = flares.some((flare) => flare.startsWith("pattern:"));
+ if (hasFlare) {
+ console.log("No ads because you have patterns");
+ window.enableAds = false;
+ } else {
+ console.log("No flares, showing ads");
+ this.show();
+ window.enableAds = true;
+ }
+ }
+
private isScreenLargeEnough(): boolean {
return window.innerWidth >= MIN_SCREEN_WIDTH;
}
@@ -52,6 +78,10 @@ export class GutterAds extends LitElement {
this.isVisible = false;
console.log("hiding GutterAds");
this.destroyAds();
+ document.removeEventListener(
+ "userMeResponse",
+ this.boundUserMeHandler as EventListener,
+ );
this.requestUpdate();
}
@@ -95,7 +125,7 @@ export class GutterAds extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
- this.destroyAds();
+ this.hide();
}
render() {
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 17df5a4c2..c7cf0f270 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -2,7 +2,6 @@ import version from "../../resources/version.txt";
import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
-import { ServerConfig } from "../core/configuration/Config";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
@@ -38,17 +37,17 @@ import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
- translateText,
} from "./Utils";
import "./components/NewsButton";
import { NewsButton } from "./components/NewsButton";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
-import { discordLogin, getUserMe, isLoggedIn } from "./jwt";
+import { getUserMe, isLoggedIn } from "./jwt";
import "./styles.css";
declare global {
interface Window {
+ enableAds: boolean;
PageOS: {
session: {
newPageView: () => void;
@@ -59,6 +58,7 @@ declare global {
destroyZone: (id: string) => void;
pageInit: (options?: any) => void;
que: Array<() => void>;
+ destroySticky: () => void;
};
ramp: {
que: Array<() => void>;
@@ -278,98 +278,12 @@ class Client {
}),
);
- const config = await getServerConfigFromClient();
- if (!hasAllowedFlare(userMeResponse, config)) {
- if (userMeResponse === false) {
- // Login is required
- document.body.innerHTML = `
-
-
-
${translateText("auth.login_required")}
-
${translateText("auth.redirecting")}
-
-
-
-
-
- `;
- setTimeout(discordLogin, 5000);
- } else {
- // Unauthorized
- document.body.innerHTML = `
-
-
-
${translateText("auth.not_authorized")}
-
${translateText("auth.contact_admin")}
-
-
-
- `;
- }
- return;
- } else if (userMeResponse === false) {
- // Not logged in
- this.patternsModal.onUserMe(null);
- } else {
+ if (userMeResponse !== false) {
// Authorized
console.log(
`Your player ID is ${userMeResponse.player.publicId}\n` +
"Sharing this ID will allow others to view your game history and stats.",
);
- this.patternsModal.onUserMe(userMeResponse);
- const flares = (userMeResponse.player.flares ?? []).filter((flare) =>
- flare.startsWith("pattern:"),
- );
- if (flares.length > 0) {
- console.log("Hiding gutter ads because you have patterns");
- this.gutterAds.hide();
- }
}
};
@@ -642,12 +556,6 @@ class Client {
this.publicLobby.stop();
incrementGamesPlayed();
- try {
- window.PageOS.session.newPageView();
- } catch (e) {
- console.error("Error calling newPageView", e);
- }
-
document.querySelectorAll(".ad").forEach((ad) => {
(ad as HTMLElement).style.display = "none";
});
@@ -689,7 +597,6 @@ class Client {
window.fusetag.pageInit({
blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"],
});
- this.gutterAds.show();
});
return true;
} else {
@@ -749,15 +656,3 @@ function getPersistentIDFromCookie(): string {
return newID;
}
-
-function hasAllowedFlare(
- userMeResponse: UserMeResponse | false,
- config: ServerConfig,
-) {
- const allowed = config.allowedFlares();
- if (allowed === undefined) return true;
- if (userMeResponse === false) return false;
- const flares = userMeResponse.player.flares;
- if (flares === undefined) return false;
- return allowed.length === 0 || allowed.some((f) => flares.includes(f));
-}
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts
index baf75e7be..f13faf670 100644
--- a/src/client/TerritoryPatternsModal.ts
+++ b/src/client/TerritoryPatternsModal.ts
@@ -37,14 +37,24 @@ export class TerritoryPatternsModal extends LitElement {
private affiliateCode: string | null = null;
- private userMeResponse: UserMeResponse | null = null;
+ private userMeResponse: UserMeResponse | false = false;
constructor() {
super();
}
- async onUserMe(userMeResponse: UserMeResponse | null) {
- if (userMeResponse === null) {
+ connectedCallback() {
+ super.connectedCallback();
+ document.addEventListener(
+ "userMeResponse",
+ (event: CustomEvent) => {
+ this.onUserMe(event.detail);
+ },
+ );
+ }
+
+ async onUserMe(userMeResponse: UserMeResponse | false) {
+ if (userMeResponse === false) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
@@ -136,7 +146,11 @@ export class TerritoryPatternsModal extends LitElement {
}
private renderColorSwatchGrid(): TemplateResult {
- const hexCodes = (this.userMeResponse?.player.flares ?? [])
+ const hexCodes = (
+ this.userMeResponse === false
+ ? []
+ : (this.userMeResponse.player.flares ?? [])
+ )
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
return html`
diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts
index 1b7146b13..9c35347e1 100644
--- a/src/client/components/PatternButton.ts
+++ b/src/client/components/PatternButton.ts
@@ -137,7 +137,6 @@ export function renderPatternPreview(
width: number,
height: number,
): TemplateResult {
- console.log("renderPatternPreview", pattern);
if (pattern === null) {
return renderBlankPreview(width, height);
}
diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts
index a7f1f2f28..03e49e0cc 100644
--- a/src/client/graphics/AnimatedSpriteLoader.ts
+++ b/src/client/graphics/AnimatedSpriteLoader.ts
@@ -1,4 +1,5 @@
import miniBigSmoke from "../../../resources/sprites/bigsmoke.png";
+import buildingExplosion from "../../../resources/sprites/buildingExplosion.png";
import conquestSword from "../../../resources/sprites/conquestSword.png";
import dust from "../../../resources/sprites/dust.png";
import miniExplosion from "../../../resources/sprites/miniExplosion.png";
@@ -89,6 +90,15 @@ const ANIMATED_SPRITE_CONFIG: Partial> = {
originX: 9,
originY: 9,
},
+ [FxType.BuildingExplosion]: {
+ url: buildingExplosion,
+ frameWidth: 17,
+ frameCount: 10,
+ frameDuration: 70,
+ looping: false,
+ originX: 8,
+ originY: 8,
+ },
[FxType.SinkingShip]: {
url: sinkingShip,
frameWidth: 16,
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 4491dede9..8b8080dcb 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -5,6 +5,7 @@ import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
+import { AdTimer } from "./layers/AdTimer";
import { AlertFrame } from "./layers/AlertFrame";
import { BuildMenu } from "./layers/BuildMenu";
import { ChatDisplay } from "./layers/ChatDisplay";
@@ -27,7 +28,6 @@ import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SettingsModal } from "./layers/SettingsModal";
-import { SpawnAd } from "./layers/SpawnAd";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
import { StructureLayer } from "./layers/StructureLayer";
@@ -209,18 +209,19 @@ export function createRenderer(
fpsDisplay.eventBus = eventBus;
fpsDisplay.userSettings = userSettings;
- const spawnAd = document.querySelector("spawn-ad") as SpawnAd;
- if (!(spawnAd instanceof SpawnAd)) {
- console.error("spawn ad not found");
- }
- spawnAd.g = game;
-
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
}
alertFrame.game = game;
+ const spawnTimer = document.querySelector("spawn-timer") as SpawnTimer;
+ if (!(spawnTimer instanceof SpawnTimer)) {
+ console.error("spawn timer not found");
+ }
+ spawnTimer.game = game;
+ spawnTimer.transformHandler = transformHandler;
+
// When updating these layers please be mindful of the order.
// Try to group layers by the return value of shouldTransform.
// Not grouping the layers may cause excessive calls to context.save() and context.restore().
@@ -246,7 +247,7 @@ export function createRenderer(
uiState,
playerPanel,
),
- new SpawnTimer(game, transformHandler),
+ spawnTimer,
leaderboard,
gameLeftSidebar,
unitDisplay,
@@ -260,7 +261,7 @@ export function createRenderer(
playerPanel,
headsUpMessage,
multiTabModal,
- spawnAd,
+ new AdTimer(game),
alertFrame,
fpsDisplay,
];
diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts
index 2aeb3ccf6..d4b206614 100644
--- a/src/client/graphics/fx/Fx.ts
+++ b/src/client/graphics/fx/Fx.ts
@@ -9,6 +9,7 @@ export enum FxType {
MiniSmokeAndFire = "MiniSmokeAndFire",
MiniExplosion = "MiniExplosion",
UnitExplosion = "UnitExplosion",
+ BuildingExplosion = "BuildingExplosion",
SinkingShip = "SinkingShip",
Nuke = "Nuke",
SAMExplosion = "SAMExplosion",
diff --git a/src/client/graphics/layers/AdTimer.ts b/src/client/graphics/layers/AdTimer.ts
new file mode 100644
index 000000000..4184e6a43
--- /dev/null
+++ b/src/client/graphics/layers/AdTimer.ts
@@ -0,0 +1,28 @@
+import { GameView } from "../../../core/game/GameView";
+import { Layer } from "./Layer";
+
+const AD_SHOW_TICKS = 60 * 10; // 1 minute
+
+export class AdTimer implements Layer {
+ private isHidden: boolean = false;
+
+ constructor(private g: GameView) {}
+
+ init() {}
+
+ public async tick() {
+ if (this.isHidden) {
+ return;
+ }
+
+ const gameTicks = this.g.ticks() - this.g.config().numSpawnPhaseTurns();
+ if (gameTicks > AD_SHOW_TICKS) {
+ console.log("destroying sticky ads");
+ window.fusetag?.que?.push(() => {
+ window.fusetag?.destroySticky?.();
+ });
+ this.isHidden = true;
+ return;
+ }
+ }
+}
diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts
index 027b8dd6c..e5ddf3831 100644
--- a/src/client/graphics/layers/FxLayer.ts
+++ b/src/client/graphics/layers/FxLayer.ts
@@ -151,6 +151,14 @@ export class FxLayer implements Layer {
case UnitType.Train:
this.onTrainEvent(unit);
break;
+ case UnitType.DefensePost:
+ case UnitType.City:
+ case UnitType.Port:
+ case UnitType.MissileSilo:
+ case UnitType.SAMLauncher:
+ case UnitType.Factory:
+ this.onStructureEvent(unit);
+ break;
}
}
@@ -246,6 +254,20 @@ export class FxLayer implements Layer {
}
}
+ onStructureEvent(unit: UnitView) {
+ if (!unit.isActive()) {
+ 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);
+ }
+ }
+
onNukeEvent(unit: UnitView, radius: number) {
if (!unit.isActive()) {
if (!unit.reachedTarget()) {
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts
index 3af01a0ec..5ff2640a9 100644
--- a/src/client/graphics/layers/PlayerPanel.ts
+++ b/src/client/graphics/layers/PlayerPanel.ts
@@ -193,6 +193,7 @@ export class PlayerPanel extends LitElement implements Layer {
private closeSend = () => {
this.sendTarget = null;
+ this.sendMode = "none";
};
private confirmSend = (
@@ -418,10 +419,15 @@ export class PlayerPanel extends LitElement implements Layer {
}}
/>`
: ""}
-
- ${other.name()}
-
+
+
+ ${other.name()}
+
+
${chip
? html`
💰
-
+
${renderNumber(other.gold() || 0)}
- ${translateText("player_panel.gold")}
+ ${translateText("player_panel.gold")}
🛡️
-
+
${renderTroops(other.troops() || 0)}
- ${translateText("player_panel.troops")}
+ ${translateText("player_panel.troops")}
@@ -476,32 +482,34 @@ export class PlayerPanel extends LitElement implements Layer {
private renderStats(other: PlayerView, my: PlayerView) {
return html`
-
+
⚠️
${translateText("player_panel.betrayals")}
-
+
${other.data.betrayals ?? 0}
-
+
⚓
${translateText("player_panel.trading")}
-
+
${other.hasEmbargoAgainst(my)
- ? html`${translateText("player_panel.stopped")}`
- : html`${translateText("player_panel.active")}`}
@@ -512,60 +520,57 @@ export class PlayerPanel extends LitElement implements Layer {
private renderAlliances(other: PlayerView) {
const allies = other.allies();
+ const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" });
+ const alliesSorted = [...allies].sort((a, b) =>
+ nameCollator.compare(a.name(), b.name()),
+ );
+
return html`
-
-
+
-
+
${translateText("player_panel.alliances")}
${allies.length}
-
-
+
${p.name()}
+ `,
+ )}
+
`;
@@ -580,7 +585,7 @@ export class PlayerPanel extends LitElement implements Layer {
${this.allianceExpiryText}
+
${actionButton({
onClick: (e: MouseEvent) => this.handleChat(e, my, other),
@@ -657,6 +662,7 @@ export class PlayerPanel extends LitElement implements Layer {
})
: ""}
+
${other !== my
@@ -754,80 +760,85 @@ export class PlayerPanel extends LitElement implements Layer {
e.preventDefault()}
@wheel=${(e: MouseEvent) => e.stopPropagation()}
@click=${() => this.hide()}
>
e.stopPropagation()}
>
-
-
-
-
+
+
-
-
${this.renderIdentityRow(other, my)}
+
+
- ${this.sendTarget
- ? html`
-
- `
- : ""}
+
+
+
${this.renderIdentityRow(other, my)}
-
+ ${this.sendTarget
+ ? html`
+
+ `
+ : ""}
-
- ${this.renderResources(other)}
+
-
+
+ ${this.renderResources(other)}
-
- ${this.renderStats(other, my)}
+
-
+
+ ${this.renderStats(other, my)}
-
- ${this.renderAlliances(other)}
+
-
- ${this.renderAllianceExpiry()}
+
+ ${this.renderAlliances(other)}
-
+
+ ${this.renderAllianceExpiry()}
-
- ${this.renderActions(my, other)}
+
+
+
+ ${this.renderActions(my, other)}
+
diff --git a/src/client/graphics/layers/SendResourceModal.ts b/src/client/graphics/layers/SendResourceModal.ts
index c3de5319a..5e7b3a1ed 100644
--- a/src/client/graphics/layers/SendResourceModal.ts
+++ b/src/client/graphics/layers/SendResourceModal.ts
@@ -244,19 +244,22 @@ export class SendResourceModal extends LitElement {
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
-
+
${this.heading ?? this.i18n.title(name)}
+
`;
@@ -264,7 +267,6 @@ export class SendResourceModal extends LitElement {
private renderAvailable() {
const total = this.getTotalNumber();
- const cap = this.getCapacityLeft();
return html`
@@ -277,21 +279,6 @@ export class SendResourceModal extends LitElement {
${this.i18n.availableChip()}
${this.format(total)}
-
- ${cap !== null
- ? html`
-
-
- ${this.i18n.cap()}
- ${this.format(cap)}
-
- `
- : html``}
`;
@@ -554,9 +541,11 @@ export class SendResourceModal extends LitElement {
const allowed = this.limitAmount(this.sendAmount);
return html`
-
+
this.closeModal()}
>
diff --git a/src/client/graphics/layers/SpawnAd.ts b/src/client/graphics/layers/SpawnAd.ts
deleted file mode 100644
index f8f39294f..000000000
--- a/src/client/graphics/layers/SpawnAd.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { LitElement, css, html } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import { translateText } from "../../../client/Utils";
-import { GameView } from "../../../core/game/GameView";
-import { getGamesPlayed } from "../../Utils";
-import { Layer } from "./Layer";
-
-const AD_TYPE = "bottom_rail";
-const AD_CONTAINER_ID = "bottom-rail-ad-container";
-
-@customElement("spawn-ad")
-export class SpawnAd extends LitElement implements Layer {
- public g: GameView;
-
- @state()
- private isVisible: boolean = false;
-
- @state()
- private adLoaded: boolean = false;
-
- private gamesPlayed: number = 0;
-
- // Override createRenderRoot to disable shadow DOM
- createRenderRoot() {
- return this;
- }
-
- static styles = css``;
-
- constructor() {
- super();
- }
-
- init() {
- this.gamesPlayed = getGamesPlayed();
- }
-
- public show(): void {
- this.isVisible = true;
- this.loadAd();
- this.requestUpdate();
- }
-
- public hide(): void {
- // Destroy the ad when hiding
- this.destroyAd();
- this.isVisible = false;
- this.adLoaded = false;
- this.requestUpdate();
- }
-
- public async tick() {
- if (
- !this.isVisible &&
- this.g.inSpawnPhase() &&
- this.g.ticks() > 10 &&
- this.gamesPlayed > 5
- ) {
- console.log("not showing spawn ad");
- // this.show();
- }
- if (this.isVisible && !this.g.inSpawnPhase()) {
- console.log("hiding bottom left ad");
- this.hide();
- }
- }
-
- private loadAd(): void {
- if (!window.ramp) {
- console.warn("Playwire RAMP not available");
- return;
- }
- if (this.adLoaded) {
- console.log("Ad already loaded, skipping");
- return;
- }
- try {
- window.ramp.que.push(() => {
- window.ramp.spaAddAds([
- {
- type: AD_TYPE,
- selectorId: AD_CONTAINER_ID,
- },
- ]);
- this.adLoaded = true;
- console.log("Playwire ad loaded:", AD_TYPE);
- });
- } catch (error) {
- console.error("Failed to load Playwire ad:", error);
- }
- }
-
- private destroyAd(): void {
- if (!window.ramp || !this.adLoaded) {
- return;
- }
- try {
- window.ramp.que.push(() => {
- window.ramp.destroyUnits("all");
- console.log("Playwire spawn ad destroyed");
- });
- } catch (error) {
- console.error("Failed to destroy Playwire ad:", error);
- }
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- // Clean up ad when component is removed
- this.destroyAd();
- }
-
- render() {
- if (!this.isVisible) {
- return html``;
- }
-
- return html`
-
-
- ${!this.adLoaded
- ? html`${translateText("spawn_ad.loading")}`
- : ""}
-
-
- `;
- }
-}
diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts
index da96c89a1..393cf96d4 100644
--- a/src/client/graphics/layers/SpawnTimer.ts
+++ b/src/client/graphics/layers/SpawnTimer.ts
@@ -1,18 +1,34 @@
+import { LitElement, html } from "lit";
+import { customElement } from "lit/decorators.js";
import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
-export class SpawnTimer implements Layer {
+@customElement("spawn-timer")
+export class SpawnTimer extends LitElement implements Layer {
+ public game: GameView;
+ public transformHandler: TransformHandler;
+
private ratios = [0];
private colors = ["rgba(0, 128, 255, 0.7)", "rgba(0, 0, 0, 0.5)"];
- constructor(
- private game: GameView,
- private transformHandler: TransformHandler,
- ) {}
+ private isVisible = false;
- init() {}
+ createRenderRoot() {
+ this.style.position = "fixed";
+ this.style.top = "0";
+ this.style.left = "0";
+ this.style.width = "100%";
+ this.style.height = "7px";
+ this.style.zIndex = "1000";
+ this.style.pointerEvents = "none";
+ return this;
+ }
+
+ init() {
+ this.isVisible = true;
+ }
tick() {
if (this.game.inSpawnPhase()) {
@@ -21,6 +37,7 @@ export class SpawnTimer implements Layer {
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
+ this.requestUpdate();
return;
}
@@ -28,6 +45,7 @@ export class SpawnTimer implements Layer {
this.colors = [];
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
+ this.requestUpdate();
return;
}
@@ -41,44 +59,52 @@ export class SpawnTimer implements Layer {
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
- if (total === 0) return;
+ if (total === 0) {
+ this.requestUpdate();
+ return;
+ }
for (const [team, count] of teamTiles) {
const ratio = count / total;
this.ratios.push(ratio);
this.colors.push(theme.teamColor(team).toRgbString());
}
+ this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
- renderLayer(context: CanvasRenderingContext2D) {
- if (this.ratios.length === 0 || this.colors.length === 0) return;
+ render() {
+ if (!this.isVisible) {
+ return html``;
+ }
- const barHeight = 10;
- const barWidth = this.transformHandler.width();
+ if (this.ratios.length === 0 || this.colors.length === 0) {
+ return html``;
+ }
if (
!this.game.inSpawnPhase() &&
this.game.config().gameConfig().gameMode !== GameMode.Team
) {
- return;
+ return html``;
}
- let x = 0;
- let filledRatio = 0;
- for (let i = 0; i < this.ratios.length && i < this.colors.length; i++) {
- const ratio = this.ratios[i] ?? 1 - filledRatio;
- const segmentWidth = barWidth * ratio;
-
- context.fillStyle = this.colors[i];
- context.fillRect(x, 0, segmentWidth, barHeight);
-
- x += segmentWidth;
- filledRatio += ratio;
- }
+ return html`
+
+ ${this.ratios.map((ratio, i) => {
+ const color = this.colors[i] || "rgba(0, 0, 0, 0.5)";
+ return html`
+
+ `;
+ })}
+
+ `;
}
}
diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts
index ba8e9dbe7..a8503c229 100644
--- a/src/client/graphics/layers/TeamStats.ts
+++ b/src/client/graphics/layers/TeamStats.ts
@@ -221,5 +221,6 @@ export class TeamStats extends LitElement implements Layer {
function formatPercentage(value: number): string {
const perc = value * 100;
if (Number.isNaN(perc)) return "0%";
+ if (perc === 100) return "100%";
return perc.toPrecision(2) + "%";
}
diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts
index 809eb5465..3e042afe1 100644
--- a/src/client/graphics/layers/WinModal.ts
+++ b/src/client/graphics/layers/WinModal.ts
@@ -132,12 +132,7 @@ export class WinModal extends LitElement implements Layer {
for (const pattern of Object.values(patterns?.patterns ?? {})) {
for (const colorPalette of pattern.colorPalettes ?? []) {
if (
- patternRelationship(
- pattern,
- colorPalette,
- me !== false ? me : null,
- null,
- ) === "purchasable"
+ patternRelationship(pattern, colorPalette, me, null) === "purchasable"
) {
const palette = patterns?.colorPalettes?.[colorPalette.name];
if (palette) {
diff --git a/src/client/index.html b/src/client/index.html
index 7a765995c..a588a0d02 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -97,6 +97,13 @@
src="https://cdn.fuseplatform.net/publift/tags/2/4121/fuse.js"
>
+
+