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}
-
-
+
    - ${allies.length > 0 - ? allies.map((p) => { - const color = p.territoryColor().toHex(); - return html` -
    + ${translateText("common.none")} + ` + : alliesSorted.map( + (p) => + html`
  • - - - - - ${p.name()} - -
  • - `; - }) - : html` -
    - ${translateText("common.none")} -
    - `} -
+ ${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" > + +