Merge branch 'v24' into evan-checkout-flow

This commit is contained in:
Drills Kibo
2025-07-28 17:22:47 +02:00
committed by GitHub
144 changed files with 5573 additions and 1394 deletions
+15 -11
View File
@@ -103,7 +103,7 @@ export function joinLobby(
if (message.type === "error") {
showErrorModal(
message.error,
"",
message.message,
lobbyConfig.gameID,
lobbyConfig.clientID,
true,
@@ -329,7 +329,7 @@ export class ClientGameRunner {
if (message.type === "error") {
showErrorModal(
message.error,
"",
message.message,
this.lobby.gameID,
this.lobby.clientID,
true,
@@ -385,7 +385,7 @@ export class ClientGameRunner {
!this.gameView.hasOwner(tile) &&
this.gameView.inSpawnPhase()
) {
this.eventBus.emit(new SendSpawnIntentEvent(cell));
this.eventBus.emit(new SendSpawnIntentEvent(tile));
return;
}
if (this.gameView.inSpawnPhase()) {
@@ -582,27 +582,31 @@ export class ClientGameRunner {
}
function showErrorModal(
errMsg: string,
stack: string,
error: string,
message: string | undefined,
gameID: GameID,
clientID: ClientID,
closable = false,
showDiscord = true,
heading = "error_modal.crashed",
) {
const errorText = `Error: ${errMsg}\nStack: ${stack}`;
if (document.querySelector("#error-modal")) {
return;
}
const modal = document.createElement("div");
modal.id = "error-modal";
const discord = showDiscord ? translateText("error_modal.paste_discord") : "";
const content = `${discord}\n${translateText(heading)}\n game id: ${gameID}, client id: ${clientID}\n${errorText}`;
const content = [
showDiscord ? translateText("error_modal.paste_discord") : null,
translateText(heading),
`game id: ${gameID}`,
`client id: ${clientID}`,
`Error: ${error}`,
message ? `Message: ${message}` : null,
]
.filter(Boolean)
.join("\n");
// Create elements
const pre = document.createElement("pre");
+25 -10
View File
@@ -13,20 +13,23 @@ import eo from "../../resources/lang/eo.json";
import es from "../../resources/lang/es.json";
import fi from "../../resources/lang/fi.json";
import fr from "../../resources/lang/fr.json";
import gl from "../../resources/lang/gl.json";
import he from "../../resources/lang/he.json";
import hi from "../../resources/lang/hi.json";
import it from "../../resources/lang/it.json";
import ja from "../../resources/lang/ja.json";
import ko from "../../resources/lang/ko.json";
import nl from "../../resources/lang/nl.json";
import pl from "../../resources/lang/pl.json";
import pt_br from "../../resources/lang/pt_br.json";
import pt_BR from "../../resources/lang/pt-BR.json";
import ru from "../../resources/lang/ru.json";
import sh from "../../resources/lang/sh.json";
import sv_se from "../../resources/lang/sv_se.json";
import sl from "../../resources/lang/sl.json";
import sv_SE from "../../resources/lang/sv-SE.json";
import tp from "../../resources/lang/tp.json";
import tr from "../../resources/lang/tr.json";
import uk from "../../resources/lang/uk.json";
import zh_cn from "../../resources/lang/zh_cn.json";
import zh_CN from "../../resources/lang/zh-CN.json";
@customElement("lang-selector")
export class LangSelector extends LitElement {
@@ -53,7 +56,7 @@ export class LangSelector extends LitElement {
ja,
nl,
pl,
pt_br,
"pt-BR": pt_BR,
ru,
sh,
tr,
@@ -63,8 +66,11 @@ export class LangSelector extends LitElement {
he,
da,
fi,
sv_se,
zh_cn,
"sv-SE": sv_SE,
"zh-CN": zh_CN,
ko,
gl,
sl,
};
createRenderRoot() {
@@ -89,15 +95,23 @@ export class LangSelector extends LitElement {
private getClosestSupportedLang(lang: string): string {
if (!lang) return "en";
if (lang in this.languageMap) return lang;
const base = lang.split("-")[0];
if (base in this.languageMap) return base;
const base = lang.slice(0, 2);
const candidates = Object.keys(this.languageMap).filter((key) =>
key.startsWith(base),
);
if (candidates.length > 0) {
candidates.sort((a, b) => b.length - a.length); // More specific first
return candidates[0];
}
return "en";
}
private async initializeLanguage() {
const browserLocale = navigator.language;
const savedLang = localStorage.getItem("lang");
const userLang = this.getClosestSupportedLang(savedLang || browserLocale);
const userLang = this.getClosestSupportedLang(savedLang ?? browserLocale);
this.defaultTranslations = this.loadLanguage("en");
this.translations = this.loadLanguage(userLang);
@@ -108,7 +122,7 @@ export class LangSelector extends LitElement {
}
private loadLanguage(lang: string): Record<string, string> {
const language = this.languageMap[lang] || {};
const language = this.languageMap[lang] ?? {};
const flat = flattenTranslations(language);
return flat;
}
@@ -204,6 +218,7 @@ export class LangSelector extends LitElement {
"user-setting",
"o-modal",
"o-button",
"territory-patterns-modal",
];
document.title = this.translateText("main.title") ?? document.title;
+1 -1
View File
@@ -162,7 +162,7 @@ export class LanguageModal extends LitElement {
<div class="c-modal__wrapper">
<header class="c-modal__header">
${translateText("select_lang.title")}
<div class="c-modal__close" @click=${this.close}>X</div>
<div class="c-modal__close" @click=${this.close}></div>
</header>
<section class="c-modal__content">
+3
View File
@@ -460,6 +460,9 @@ class Client {
"game-top-bar",
"help-modal",
"user-setting",
"territory-patterns-modal",
"language-modal",
"news-modal",
].forEach((tag) => {
const modal = document.querySelector(tag) as HTMLElement & {
close?: () => void;
+1 -1
View File
@@ -110,7 +110,7 @@ export class PublicLobby extends LitElement {
const teamCount =
lobby.gameConfig.gameMode === GameMode.Team
? lobby.gameConfig.playerTeams || 0
? (lobby.gameConfig.playerTeams ?? 0)
: null;
const mapImageSrc = this.mapImages.get(lobby.gameID);
+16 -18
View File
@@ -2,7 +2,6 @@ import { z } from "zod/v4";
import { EventBus, GameEvent } from "../core/EventBus";
import {
AllPlayers,
Cell,
GameType,
Gold,
PlayerID,
@@ -68,7 +67,7 @@ export class SendAllianceExtensionIntentEvent implements GameEvent {
}
export class SendSpawnIntentEvent implements GameEvent {
constructor(public readonly cell: Cell) {}
constructor(public readonly tile: TileRef) {}
}
export class SendAttackIntentEvent implements GameEvent {
@@ -90,7 +89,7 @@ export class SendBoatAttackIntentEvent implements GameEvent {
export class BuildUnitIntentEvent implements GameEvent {
constructor(
public readonly unit: UnitType,
public readonly cell: Cell,
public readonly tile: TileRef,
) {}
}
@@ -245,16 +244,14 @@ export class Transport {
}
private startPing() {
if (this.isLocal || this.pingInterval) return;
if (this.pingInterval === null) {
this.pingInterval = window.setInterval(() => {
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg({
type: "ping",
} satisfies ClientPingMessage);
}
}, 5 * 1000);
}
if (this.isLocal) return;
this.pingInterval ??= window.setInterval(() => {
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
this.sendMsg({
type: "ping",
} satisfies ClientPingMessage);
}
}, 5 * 1000);
}
private stopPing() {
@@ -344,7 +341,10 @@ export class Transport {
console.log(
`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`,
);
if (event.code !== 1000 && event.code !== 1002) {
if (event.code === 1002) {
// TODO: make this a modal
alert(`connection refused: ${event.reason}`);
} else if (event.code !== 1000) {
console.log(`recieved error code ${event.code}, reconnecting`);
this.reconnect();
}
@@ -437,8 +437,7 @@ export class Transport {
pattern: this.lobbyConfig.pattern,
name: this.lobbyConfig.playerName,
playerType: PlayerType.Human,
x: event.cell.x,
y: event.cell.y,
tile: event.tile,
});
}
@@ -539,8 +538,7 @@ export class Transport {
type: "build_unit",
clientID: this.lobbyConfig.clientID,
unit: event.unit,
x: event.cell.x,
y: event.cell.y,
tile: event.tile,
});
}
+1 -1
View File
@@ -172,7 +172,7 @@ export function getAltKey(): string {
export function getGamesPlayed(): number {
try {
return parseInt(localStorage.getItem("gamesPlayed") || "0", 10) || 0;
return parseInt(localStorage.getItem("gamesPlayed") ?? "0", 10) || 0;
} catch (error) {
console.warn("Failed to read games played from localStorage:", error);
return 0;
+2 -4
View File
@@ -17,9 +17,7 @@ export class NewsButton extends LitElement {
private checkForNewVersion() {
try {
const lastSeenVersion = localStorage.getItem(
"news-button-last-seen-version",
);
const lastSeenVersion = localStorage.getItem("version");
this.isActive = lastSeenVersion !== version;
} catch (error) {
// Fallback to NOT showing notification if localStorage fails
@@ -28,7 +26,7 @@ export class NewsButton extends LitElement {
}
private handleClick() {
localStorage.setItem("news-button-last-seen-version", version);
localStorage.setItem("version", version);
this.isActive = false;
const newsModal = document.querySelector("news-modal") as NewsModal;
@@ -79,7 +79,7 @@ export class OModal extends LitElement {
${`${this.translationKey}` === ""
? `${this.title}`
: `${translateText(this.translationKey)}`}
<div class="c-modal__close" @click=${this.close}>X</div>
<div class="c-modal__close" @click=${this.close}></div>
</header>
<section class="c-modal__content">
<slot></slot>
+4 -13
View File
@@ -15,7 +15,6 @@ import { EventsDisplay } from "./layers/EventsDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { GameTopBar } from "./layers/GameTopBar";
import { GutterAdModal } from "./layers/GutterAdModal";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { Layer } from "./layers/Layer";
@@ -75,8 +74,8 @@ export function createRenderer(
buildMenu.transformHandler = transformHandler;
const leaderboard = document.querySelector("leader-board") as Leaderboard;
if (!emojiTable || !(leaderboard instanceof Leaderboard)) {
console.error("EmojiTable element not found in the DOM");
if (!leaderboard || !(leaderboard instanceof Leaderboard)) {
console.error("LeaderBoard element not found in the DOM");
}
leaderboard.eventBus = eventBus;
leaderboard.game = game;
@@ -90,8 +89,8 @@ export function createRenderer(
gameLeftSidebar.game = game;
const teamStats = document.querySelector("team-stats") as TeamStats;
if (!emojiTable || !(teamStats instanceof TeamStats)) {
console.error("EmojiTable element not found in the DOM");
if (!teamStats || !(teamStats instanceof TeamStats)) {
console.error("TeamStats element not found in the DOM");
}
teamStats.eventBus = eventBus;
teamStats.game = game;
@@ -162,13 +161,6 @@ export function createRenderer(
settingsModal.userSettings = userSettings;
settingsModal.eventBus = eventBus;
const gameTopBar = document.querySelector("game-top-bar") as GameTopBar;
if (!(gameTopBar instanceof GameTopBar)) {
console.error("top bar not found");
}
gameTopBar.game = game;
gameTopBar.eventBus = eventBus;
const unitDisplay = document.querySelector("unit-display") as UnitDisplay;
if (!(unitDisplay instanceof UnitDisplay)) {
console.error("unit display not found");
@@ -255,7 +247,6 @@ export function createRenderer(
new SpawnTimer(game, transformHandler),
leaderboard,
gameLeftSidebar,
gameTopBar,
unitDisplay,
gameRightSidebar,
controlPanel,
+2 -2
View File
@@ -91,9 +91,9 @@ const getSpriteForUnit = (unit: UnitView): ImageBitmap | null => {
const unitType = unit.type();
if (unitType === UnitType.Train) {
const trainType = trainTypeToSpriteType(unit);
return spriteMap.get(trainType) || null;
return spriteMap.get(trainType) ?? null;
}
return spriteMap.get(unitType) || null;
return spriteMap.get(unitType) ?? null;
};
export const isSpriteReady = (unit: UnitView): boolean => {
+7 -8
View File
@@ -15,7 +15,6 @@ import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import {
BuildableUnit,
Cell,
Gold,
PlayerActions,
UnitType,
@@ -136,6 +135,11 @@ export class BuildMenu extends LitElement implements Layer {
if (!this.game.myPlayer()?.isAlive()) {
return;
}
if (!this._hidden) {
// Players sometimes hold control while building a unit,
// so if the menu is already open, ignore the event.
return;
}
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y,
@@ -379,7 +383,7 @@ export class BuildMenu extends LitElement implements Layer {
return "?";
}
return player.units(item.unitType).length.toString();
return player.totalUnitLevels(item.unitType).toString();
}
public sendBuildOrUpgrade(buildableUnit: BuildableUnit, tile: TileRef): void {
@@ -391,12 +395,7 @@ export class BuildMenu extends LitElement implements Layer {
),
);
} else if (buildableUnit.canBuild) {
this.eventBus.emit(
new BuildUnitIntentEvent(
buildableUnit.type,
new Cell(this.game.x(tile), this.game.y(tile)),
),
);
this.eventBus.emit(new BuildUnitIntentEvent(buildableUnit.type, tile));
}
this.hideMenu();
}
+78 -16
View File
@@ -2,16 +2,19 @@ import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderTroops } from "../../Utils";
import { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@customElement("control-panel")
export class ControlPanel extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public uiState: UIState;
@@ -27,13 +30,34 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _population: number;
@state()
private _maxPopulation: number;
@state()
private popRate: number;
@state()
private _troops: number;
@state()
private _workers: number;
@state()
private _isVisible = false;
@state()
private _manpower: number = 0;
@state()
private _gold: Gold;
@state()
private _goldPerSecond: Gold;
private _popRateIsIncreasing: boolean = true;
private _lastPopulationIncreaseRate: number;
private init_: boolean = false;
init() {
@@ -90,17 +114,31 @@ export class ControlPanel extends LitElement implements Layer {
return;
}
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._lastPopulationIncreaseRate = popIncreaseRate;
this.updatePopulationIncrease();
}
this._population = player.population();
this._maxPopulation = this.game.config().maxPopulation(player);
this._gold = player.gold();
this._troops = player.troops();
this._workers = player.workers();
this.popRate = this.game.config().populationIncreaseRate(player) * 10;
this._goldPerSecond = this.game.config().goldAdditionRate(player) * 10n;
this.currentTroopRatio = player.troops() / player.population();
this.requestUpdate();
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
onAttackRatioChange(newRatio: number) {
this.uiState.attackRatio = newRatio;
}
@@ -174,21 +212,45 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "text-sm lg:text-m md:w-[320px] bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
? "w-full sm:max-w-[320px] text-sm sm:text-base bg-gray-800/70 p-2 pr-3 sm:p-4 shadow-lg sm:rounded-lg backdrop-blur"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
<div class="relative mb-4 lg:mb-4">
<label class="flex justify-between text-white mb-1" translate="no">
<span>
${translateText("control_panel.troops")}:
${(this.currentTroopRatio * 100).toFixed(0)}%
</span>
<span>
${translateText("control_panel.workers")}:
${((1 - this.currentTroopRatio) * 100).toFixed(0)}%
</span>
</label>
<div class="block bg-black/30 text-white mb-4 p-2 rounded">
<div class="flex justify-between mb-1">
<span class="font-bold"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(this._population)} /
${renderTroops(this._maxPopulation)}
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>(+${renderTroops(this.popRate)})</span
></span
>
</div>
<div class="flex justify-between">
<span class="font-bold"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(this._gold)}
(+${renderNumber(this._goldPerSecond)})</span
>
</div>
</div>
<div class="relative mb-4 sm:mb-4">
<label class="block text-white mb-1" translate="no"
>${translateText("control_panel.troops")}:
<span translate="no">${renderTroops(this._troops)}</span> |
${translateText("control_panel.workers")}:
<span translate="no">${renderTroops(this._workers)}</span></label
>
<div class="relative h-8">
<!-- Background track -->
<div
@@ -215,7 +277,7 @@ export class ControlPanel extends LitElement implements Layer {
</div>
</div>
<div class="relative mb-0 lg:mb-4">
<div class="relative mb-0 sm:mb-4">
<label class="block text-white mb-1" translate="no"
>${translateText("control_panel.attack_ratio")}:
${(this.attackRatio * 100).toFixed(0)}%
+50 -101
View File
@@ -1,4 +1,4 @@
import { LitElement, css, html } from "lit";
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
@@ -11,91 +11,14 @@ import { TransformHandler } from "../TransformHandler";
@customElement("emoji-table")
export class EmojiTable extends LitElement {
@state() public isVisible = false;
public eventBus: EventBus;
public transformHandler: TransformHandler;
public game: GameView;
static styles = css`
:host {
display: block;
}
.emoji-table {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: #1e1e1e;
padding: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
max-width: 95vw;
max-height: 95vh;
overflow-y: auto;
}
.emoji-row {
display: flex;
justify-content: center;
width: 100%;
}
.emoji-button {
font-size: 60px;
width: 80px;
height: 80px;
border: 1px solid #333;
background-color: #2c2c2c;
color: white;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: center;
align-items: center;
margin: 8px;
}
.emoji-button:hover {
background-color: #3a3a3a;
transform: scale(1.1);
}
.emoji-button:active {
background-color: #4a4a4a;
transform: scale(0.95);
}
.hidden {
display: none !important;
}
@media (max-width: 600px) {
.emoji-button {
font-size: 32px;
/* Slightly smaller font size for mobile */
width: 60px;
/* Smaller width for mobile */
height: 60px;
/* Smaller height for mobile */
margin: 5px;
/* Smaller margin for mobile */
}
}
@media (max-width: 400px) {
.emoji-button {
font-size: 28px;
width: 50px;
height: 50px;
margin: 3px;
}
}
`;
@state()
private _hidden = true;
initEventBus() {
this.eventBus.on(ShowEmojiMenuEvent, (e) => {
this.isVisible = true;
const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
if (!this.game.isValidCoord(cell.x, cell.y)) {
return;
@@ -131,40 +54,66 @@ export class EmojiTable extends LitElement {
private onEmojiClicked: (emoji: string) => void = () => {};
render() {
if (!this.isVisible) {
return null;
}
return html`
<div class="emoji-table ${this._hidden ? "hidden" : ""}">
${emojiTable.map(
(row) => html`
<div class="emoji-row">
${row.map(
(emoji) => html`
<button
class="emoji-button"
@click=${() => this.onEmojiClicked(emoji)}
>
${emoji}
</button>
`,
)}
</div>
`,
)}
<div
class="bg-slate-800 max-w-[95vw] max-h-[95vh] pt-[15px] pb-[15px] fixed flex flex-col -translate-x-1/2 -translate-y-1/2
items-center rounded-[10px] z-[9999] top-[50%] left-[50%] justify-center"
@contextmenu=${(e) => e.preventDefault()}
@wheel=${(e) => e.stopPropagation()}
>
<!-- Close button -->
<button
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
bg-red-500 hover:bg-red-900 text-white rounded-full
text-sm font-bold transition-colors"
@click=${this.hideTable}
>
</button>
<div
class="flex flex-col overflow-y-auto"
style="scrollbar-gutter: stable both-edges;"
>
${emojiTable.map(
(row) => html`
<div class="w-full justify-center flex">
${row.map(
(emoji) => html`
<button
class="flex transition-transform duration-300 ease justify-center items-center cursor-pointer
border border-solid border-slate-500 rounded-[12px] bg-slate-700 hover:bg-slate-600 active:bg-slate-500
md:m-[8px] md:text-[60px] md:w-[80px] md:h-[80px] hover:scale-[1.1] active:scale-[0.95]
sm:w-[60px] sm:h-[60px] sm:text-[32px] sm:m-[5px] text-[28px] w-[50px] h-[50px] m-[3px]"
@click=${() => this.onEmojiClicked(emoji)}
>
${emoji}
</button>
`,
)}
</div>
`,
)}
</div>
</div>
`;
}
hideTable() {
this._hidden = true;
this.isVisible = false;
this.requestUpdate();
}
showTable(oneEmojiClicked: (emoji: string) => void) {
this.onEmojiClicked = oneEmojiClicked;
this._hidden = false;
this.isVisible = true;
this.requestUpdate();
}
get isVisible() {
return !this._hidden;
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}
+19 -5
View File
@@ -137,7 +137,7 @@ export class EventsDisplay extends LitElement implements Layer {
}
private toggleEventFilter(filterName: MessageCategory) {
const currentState = this.eventsFilters.get(filterName) || false;
const currentState = this.eventsFilters.get(filterName) ?? false;
this.eventsFilters.set(filterName, !currentState);
this.requestUpdate();
}
@@ -351,8 +351,15 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
let description: string = event.message;
if (event.params !== undefined) {
if (event.message.startsWith("events_display.")) {
description = translateText(event.message, event.params);
}
}
this.addEvent({
description: event.message,
description: description,
createdAt: this.game.ticks(),
highlight: true,
type: event.messageType,
@@ -375,7 +382,7 @@ export class EventsDisplay extends LitElement implements Layer {
if (event.target) {
try {
const targetPlayer = this.game.player(event.target);
const targetName = targetPlayer?.name() ?? event.target;
const targetName = targetPlayer?.displayName() ?? event.target;
translatedMessage = baseMessage.replace("[P1]", targetName);
} catch (e) {
console.warn(
@@ -386,9 +393,16 @@ export class EventsDisplay extends LitElement implements Layer {
}
}
let otherPlayerDiplayName: string = "";
if (event.recipient !== null) {
//'recipient' parameter contains sender ID or recipient ID
const player = this.game.player(event.recipient);
otherPlayerDiplayName = player ? player.displayName() : "";
}
this.addEvent({
description: translateText(event.isFrom ? "chat.from" : "chat.to", {
user: event.recipient,
user: otherPlayerDiplayName,
msg: translatedMessage,
}),
createdAt: this.game.ticks(),
@@ -893,7 +907,7 @@ export class EventsDisplay extends LitElement implements Layer {
: html`
<!-- Main Events Display -->
<div
class="relative w-full lg:bottom-2.5 lg:right-2.5 z-50 lg:w-96 backdrop-blur"
class="relative w-full sm:bottom-2.5 sm:right-2.5 z-50 sm:w-96 backdrop-blur"
>
<!-- Button Bar -->
<div
+13 -3
View File
@@ -22,6 +22,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
private playerColor: Colord = new Colord("#FFFFFF");
public game: GameView;
private _shownOnInit = false;
createRenderRoot() {
return this;
@@ -32,12 +33,15 @@ export class GameLeftSidebar extends LitElement implements Layer {
if (this.isTeamGame) {
this.isPlayerTeamLabelVisible = true;
}
// Make it visible by default on large screens
if (window.innerWidth >= 1024) {
// lg breakpoint
this._shownOnInit = true;
}
this.requestUpdate();
}
tick() {
if (!this.isPlayerTeamLabelVisible) return;
if (!this.playerTeam && this.game.myPlayer()?.team()) {
this.playerTeam = this.game.myPlayer()!.team();
if (this.playerTeam) {
@@ -49,6 +53,12 @@ export class GameLeftSidebar extends LitElement implements Layer {
}
}
if (this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = false;
this.isLeaderboardShow = true;
this.requestUpdate();
}
if (!this.game.inSpawnPhase()) {
this.isPlayerTeamLabelVisible = false;
this.requestUpdate();
@@ -130,7 +140,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
<div class="block lg:flex flex-wrap gap-2">
<leader-board .visible=${this.isLeaderboardShow}></leader-board>
<team-stats
class=${`flex-1 ${this.isTeamLeaderboardShow ? "sm:mt-4 lg:mt-12" : ""}`}
class="flex-1"
.visible=${this.isTeamLeaderboardShow && this.isTeamGame}
></team-stats>
</div>
@@ -76,7 +76,9 @@ export class GameRightSidebar extends LitElement implements Layer {
private toggleReplayPanel(): void {
this._isReplayVisible = !this._isReplayVisible;
this.eventBus.emit(new ShowReplayPanelEvent(this._isReplayVisible));
this.eventBus.emit(
new ShowReplayPanelEvent(this._isReplayVisible, this._isSinglePlayer),
);
}
private onPauseButtonClick() {
-168
View File
@@ -1,168 +0,0 @@
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import populationIcon from "../../../../resources/images/PopulationIconSolidWhite.svg";
import troopIcon from "../../../../resources/images/TroopIconWhite.svg";
import workerIcon from "../../../../resources/images/WorkerIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@customElement("game-top-bar")
export class GameTopBar extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private _troops = 0;
private _workers = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
private hasWinner = false;
createRenderRoot() {
return this;
}
init() {
this.requestUpdate();
}
tick() {
this.updatePopulationIncrease();
const player = this.game?.myPlayer();
if (!player) return;
this._troops = player.troops();
this._workers = player.workers();
const updates = this.game.updatesSinceLastTick();
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
this.requestUpdate();
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = this.game.config().populationIncreaseRate(player);
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
render() {
const myPlayer = this.game?.myPlayer();
if (!this.game || !myPlayer || this.game.inSpawnPhase()) {
return null;
}
const isAlt = this.game.config().isReplay();
if (isAlt) {
return html`
<div
class="absolute top-4 left-1/2 transform -translate-x-1/2 flex justify-center items-center p-2"
></div>
`;
}
const popRate = myPlayer
? this.game.config().populationIncreaseRate(myPlayer) * 10
: 0;
const maxPop = myPlayer ? this.game.config().maxPopulation(myPlayer) : 0;
const goldPerSecond = myPlayer
? this.game.config().goldAdditionRate(myPlayer) * 10n
: 0n;
return html`
<div
class="fixed top-4 left-1/2 transform -translate-x-1/2 flex justify-center items-center p-1 md:px-1.5 lg:px-4 z-[1100]"
>
<div class="flex justify-center items-center gap-1">
${myPlayer?.isAlive() && !this.game.inSpawnPhase()
? html`
<div class="overflow-x-auto hide-scrollbar">
<div
class="grid gap-1 grid-cols-[80px_100px_80px] w-max md:gap-2 md:grid-cols-[90px_120px_90px]"
>
<div
class="flex flex-wrap gap-1 flex-col bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex gap-2 items-center justify-between">
<img
src=${goldCoinIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
<span class="text-white"
>+${renderNumber(goldPerSecond)}</span
>
</div>
<div class="text-white">
${renderNumber(myPlayer.gold())}
</div>
</div>
<div
class="flex flex-wrap gap-1 flex-col bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex gap-2 items-center justify-between">
<img
src=${populationIcon}
alt="population"
width="20"
height="20"
style="vertical-align: middle;"
/>
<span
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
translate="no"
>
+${renderTroops(popRate)}
</span>
</div>
<div class="text-white">
${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}
</div>
</div>
<div
class="flex bg-gray-800/70 border border-slate-400 p-0.5 md:px-1 lg:px-2"
>
<div class="flex flex-col flex-grow gap-1 w-full ">
<div class="flex gap-1">
<img
src=${troopIcon}
alt="troops"
width="20"
height="20"
style="vertical-align: middle;"
/>
<span class="text-white"
>${renderTroops(this._troops)}</span
>
</div>
<div class="flex gap-1">
<img
src=${workerIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
<span class="text-white"
>${renderTroops(this._workers)}</span
>
</div>
</div>
</div>
</div>
</div>
`
: html`<div></div>`}
</div>
</div>
`;
}
}
+23 -38
View File
@@ -40,7 +40,6 @@ export class Leaderboard extends LitElement implements Layer {
players: Entry[] = [];
@property({ type: Boolean }) visible = false;
private _shownOnInit = false;
private showTopFive = true;
@state()
@@ -57,10 +56,6 @@ export class Leaderboard extends LitElement implements Layer {
tick() {
if (this.game === null) throw new Error("Not initialized");
if (!this._shownOnInit && !this.game.inSpawnPhase()) {
this._shownOnInit = true;
this.updateLeaderboard();
}
if (!this.visible) return;
if (this.game.ticks() % 10 === 0) {
this.updateLeaderboard();
@@ -174,37 +169,25 @@ export class Leaderboard extends LitElement implements Layer {
}
return html`
<div
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-sm md:max-h-[50vh] ${this
class="max-h-[35vh] overflow-y-auto text-white text-xs md:text-xs lg:text-sm md:max-h-[50vh] ${this
.visible
? ""
: "hidden"}"
@contextmenu=${(e: Event) => e.preventDefault()}
>
<button
class="mb-2 px-2 py-1 md:px-2.5 md:py-1.5 text-xs md:text-sm lg:text-base border border-white/20 hover:bg-white/10"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive
? translateText("leaderboard.show_all")
: translateText("leaderboard.show_top_5")}
</button>
<div
class="grid bg-gray-800/70 w-full text-xs md:text-sm lg:text-base"
style="grid-template-columns: 35px 100px 85px 65px 65px;"
class="grid bg-gray-800/70 w-full text-xs md:text-xs lg:text-sm"
style="grid-template-columns: 30px 100px 70px 55px 75px;"
>
<div class="contents font-bold bg-gray-700/50">
<div class="py-1.5 md:py-2.5 text-center border-b border-slate-500">
<div class="py-1 md:py-2 text-center border-b border-slate-500">
#
</div>
<div class="py-1.5 md:py-2.5 text-center border-b border-slate-500">
<div class="py-1 md:py-2 text-center border-b border-slate-500">
${translateText("leaderboard.player")}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
@click=${() => this.setSort("tiles")}
>
${translateText("leaderboard.owned")}
@@ -215,7 +198,7 @@ export class Leaderboard extends LitElement implements Layer {
: ""}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
@click=${() => this.setSort("gold")}
>
${translateText("leaderboard.gold")}
@@ -226,7 +209,7 @@ export class Leaderboard extends LitElement implements Layer {
: ""}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 cursor-pointer"
class="py-1 md:py-2 text-center border-b border-slate-500 cursor-pointer whitespace-nowrap"
@click=${() => this.setSort("troops")}
>
${translateText("leaderboard.troops")}
@@ -248,29 +231,21 @@ export class Leaderboard extends LitElement implements Layer {
: ""} cursor-pointer"
@click=${() => this.handleRowClickPlayer(player.player)}
>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
${player.position}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500 truncate"
class="py-1 md:py-2 text-center border-b border-slate-500 truncate"
>
${player.name}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
${player.score}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
${player.gold}
</div>
<div
class="py-1.5 md:py-2.5 text-center border-b border-slate-500"
>
<div class="py-1 md:py-2 text-center border-b border-slate-500">
${player.troops}
</div>
</div>
@@ -278,6 +253,16 @@ export class Leaderboard extends LitElement implements Layer {
)}
</div>
</div>
<button
class="mt-1 px-1.5 py-0.5 md:px-2 md:py-0.5 text-xs md:text-xs lg:text-sm border border-white/20 hover:bg-white/10 text-white mx-auto block"
@click=${() => {
this.showTopFive = !this.showTopFive;
this.updateLeaderboard();
}}
>
${this.showTopFive ? "+" : "-"}
</button>
`;
}
}
+28 -18
View File
@@ -1,7 +1,7 @@
import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { PlayerActions, TerraNullius } from "../../../core/game/Game";
import { PlayerActions } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
@@ -17,7 +17,7 @@ import {
centerButtonElement,
COLORS,
MenuElementParams,
rootMenuItems,
rootMenuElement,
} from "./RadialMenuElements";
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
@@ -31,7 +31,6 @@ export class MainRadialMenu extends LitElement implements Layer {
private chatIntegration: ChatIntegration;
private clickedTile: TileRef | null = null;
private selectedPlayer: PlayerView | TerraNullius | null = null;
constructor(
private eventBus: EventBus,
@@ -57,7 +56,12 @@ export class MainRadialMenu extends LitElement implements Layer {
`,
};
this.radialMenu = new RadialMenu(menuConfig);
this.radialMenu = new RadialMenu(
this.eventBus,
rootMenuElement,
centerButtonElement,
menuConfig,
);
this.playerActionHandler = new PlayerActionHandler(
this.eventBus,
@@ -65,8 +69,6 @@ export class MainRadialMenu extends LitElement implements Layer {
);
this.chatIntegration = new ChatIntegration(this.game, this.eventBus);
this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement);
}
init() {
@@ -83,12 +85,11 @@ export class MainRadialMenu extends LitElement implements Layer {
return;
}
this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y);
this.selectedPlayer = this.game.owner(this.clickedTile);
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.handlePlayerActions(
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
@@ -99,12 +100,12 @@ export class MainRadialMenu extends LitElement implements Layer {
});
}
private async handlePlayerActions(
private async updatePlayerActions(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef,
screenX: number,
screenY: number,
screenX: number | null = null,
screenY: number | null = null,
) {
this.buildMenu.playerActions = actions;
@@ -130,18 +131,27 @@ export class MainRadialMenu extends LitElement implements Layer {
eventBus: this.eventBus,
};
this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement);
this.radialMenu.setParams(params);
this.radialMenu.showRadialMenu(screenX, screenY);
if (screenX !== null && screenY !== null) {
this.radialMenu.showRadialMenu(screenX, screenY);
} else {
this.radialMenu.refresh();
}
}
async tick() {
if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return;
if (this.selectedPlayer === null) return;
const currentPlayer = this.game.owner(this.clickedTile);
if (currentPlayer.id() !== this.selectedPlayer.id()) {
this.closeMenu();
return;
if (this.game.ticks() % 5 === 0) {
this.game
.myPlayer()!
.actions(this.clickedTile)
.then((actions) => {
this.updatePlayerActions(
this.game.myPlayer()!,
actions,
this.clickedTile!,
);
});
}
}
@@ -1,5 +1,5 @@
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, PlayerID } from "../../../core/game/Game";
import { PlayerActions, PlayerID } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
@@ -61,8 +61,9 @@ export class PlayerActionHandler {
): Promise<TileRef | false> {
return await player.bestTransportShipSpawn(tile);
}
handleSpawn(spawnCell: Cell) {
this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
handleSpawn(tile: TileRef) {
this.eventBus.emit(new SendSpawnIntentEvent(tile));
}
handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
+49 -62
View File
@@ -13,10 +13,11 @@ import {
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { MouseMoveEvent } from "../../InputHandler";
import { ContextMenuEvent, MouseMoveEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
function euclideanDistWorld(
coord: { x: number; y: number },
@@ -69,6 +70,10 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
this.onMouseEvent(e),
);
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
this.maybeShow(e.x, e.y),
);
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
this._isActive = true;
}
@@ -165,6 +170,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}
}
private displayUnitCount(
player: PlayerView,
type: UnitType,
description: string,
) {
return !this.game.config().isUnitDisabled(type)
? html`<div class="text-sm opacity-80" translate="no">
${translateText(description)}: ${player.totalUnitLevels(type)}
</div>`
: "";
}
private renderPlayerInfo(player: PlayerView) {
const myPlayer = this.game.myPlayer();
const isFriendly = myPlayer?.isFriendly(player);
@@ -250,66 +267,36 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
${translateText("player_info_overlay.gold")}:
${renderNumber(player.gold())}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.ports")}:
${player.units(UnitType.Port).length}
${player
.units(UnitType.Port)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.Port)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.cities")}:
${player.units(UnitType.City).length}
${player
.units(UnitType.City)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.City)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.missile_launchers")}:
${player.units(UnitType.MissileSilo).length}
${player
.units(UnitType.MissileSilo)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.MissileSilo)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.sams")}:
${player.units(UnitType.SAMLauncher).length}
${player
.units(UnitType.SAMLauncher)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0) > 1
? html`(${translateText("player_info_overlay.levels")}:
${player
.units(UnitType.SAMLauncher)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0)})`
: ""}
</div>
<div class="text-sm opacity-80" translate="no">
${translateText("player_info_overlay.warships")}:
${player.units(UnitType.Warship).length}
</div>
${this.displayUnitCount(
player,
UnitType.Port,
"player_info_overlay.ports",
)}
${this.displayUnitCount(
player,
UnitType.City,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Factory,
"player_info_overlay.factories",
)}
${this.displayUnitCount(
player,
UnitType.MissileSilo,
"player_info_overlay.missile_launchers",
)}
${this.displayUnitCount(
player,
UnitType.SAMLauncher,
"player_info_overlay.sams",
)}
${this.displayUnitCount(
player,
UnitType.Warship,
"player_info_overlay.warships",
)}
${relationHtml}
</div>
`;
@@ -352,7 +339,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="hidden lg:flex fixed top-[245px] right-0 w-full z-50 flex-col max-w-[180px]"
class="block lg:flex fixed top-[150px] right-0 w-full z-50 flex-col max-w-[180px]"
@contextmenu=${(e) => e.preventDefault()}
>
<div
+1 -1
View File
@@ -233,7 +233,7 @@ export class PlayerPanel extends LitElement implements Layer {
return html`
<div
class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none overflow-auto"
class="fixed inset-0 flex items-center justify-center z-[1001] pointer-events-none overflow-auto"
@contextmenu=${(e) => e.preventDefault()}
@wheel=${(e) => e.stopPropagation()}
>
+143 -97
View File
@@ -1,5 +1,6 @@
import * as d3 from "d3";
import backIcon from "../../../../resources/images/BackIconWhite.svg";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { Layer } from "./Layer";
import {
CenterButtonElement,
@@ -7,6 +8,10 @@ import {
MenuElementParams,
} from "./RadialMenuElements";
export class CloseRadialMenuEvent implements GameEvent {
constructor() {}
}
export interface TooltipItem {
text: string;
className: string;
@@ -39,13 +44,11 @@ export class RadialMenu implements Layer {
private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.)
private menuStack: MenuElement[][] = []; // Stack to track menu navigation history
private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level)
private rootMenuItems: MenuElement[] = []; // Store the original root menu items
private readonly config: RequiredRadialMenuConfig;
private readonly backIconSize: number;
private centerButtonState: CenterButtonState = "default";
private centerButtonElement: CenterButtonElement | null = null;
private isTransitioning: boolean = false;
private lastHideTime: number = 0;
@@ -72,7 +75,12 @@ export class RadialMenu implements Layer {
private params: MenuElementParams | null = null;
constructor(config: RadialMenuConfig = {}) {
constructor(
private eventBus: EventBus,
private rootMenu: MenuElement,
private centerButtonElement: CenterButtonElement,
config: RadialMenuConfig = {},
) {
this.config = {
menuSize: config.menuSize ?? 190,
submenuScale: config.submenuScale ?? 1.5,
@@ -112,10 +120,12 @@ export class RadialMenu implements Layer {
.style("height", "100vh")
.on("click", () => {
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
})
.on("contextmenu", (e) => {
e.preventDefault();
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
});
// Calculate the total svg size needed for all potential nested menus
@@ -217,7 +227,7 @@ export class RadialMenu implements Layer {
}
private getInnerRadiusForLevel(level: number): number {
return level === 0 ? 50 : 50 + 25;
return level === 0 ? 40 : 50 + 25;
}
private getOuterRadiusForLevel(level: number): number {
@@ -237,10 +247,12 @@ export class RadialMenu implements Layer {
.append("g")
.attr("class", `menu-level-${level}`);
// Set initial animation styles
// Set initial animation styles only for submenus (level > 0)
if (level === 0) {
menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)");
// Main menu appears immediately without animation
menuGroup.style("opacity", 1).style("transform", "scale(1)");
} else {
// Submenus get the expansion animation
menuGroup.style("opacity", 0).style("transform", "scale(0.5)");
}
@@ -296,14 +308,14 @@ export class RadialMenu implements Layer {
const disabled = this.params === null || d.data.disabled(this.params);
const color = disabled
? this.config.disabledColor
: d.data.color || "#333333";
: (d.data.color ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
return color;
}
return d3.color(color)?.copy({ opacity: opacity })?.toString() || color;
return d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
})
.attr("stroke", "#ffffff")
.attr("stroke-width", "2")
@@ -339,7 +351,7 @@ export class RadialMenu implements Layer {
const color =
this.params === null || d.data.disabled(this.params)
? this.config.disabledColor
: d.data.color || "#333333";
: (d.data.color ?? "#333333");
path.attr("fill", color);
}
});
@@ -403,11 +415,11 @@ export class RadialMenu implements Layer {
path.attr("stroke-width", "2");
const color = disabled
? this.config.disabledColor
: d.data.color || "#333333";
: (d.data.color ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
path.attr(
"fill",
d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
);
};
@@ -435,6 +447,8 @@ export class RadialMenu implements Layer {
this.updateCenterButtonState("back");
} else {
d.data.action?.(this.params);
// Force transition state to false to ensure menu hides
this.isTransitioning = false;
this.hideRadialMenu();
}
};
@@ -477,6 +491,14 @@ export class RadialMenu implements Layer {
});
}
private isItemDisabled(item: MenuElement): boolean {
return (
this.params === null ||
this.params.game.inSpawnPhase() ||
item.disabled(this.params)
);
}
private renderIconsAndText(
arcs: d3.Selection<
SVGGElement,
@@ -494,10 +516,7 @@ export class RadialMenu implements Layer {
.each((d) => {
const contentId = d.data.id;
const content = d3.select(`g[data-id="${contentId}"]`);
const disabled =
this.params === null ||
this.params.game.inSpawnPhase() ||
d.data.disabled(this.params);
const disabled = this.isItemDisabled(d.data);
if (d.data.text) {
content
@@ -555,24 +574,43 @@ export class RadialMenu implements Layer {
}
private updateMenuGroupVisibility() {
// Hide all menus except the current and immediate previous one
this.updateMenuVisibility("forward");
}
private updateMenuVisibility(direction: "forward" | "backward" = "backward") {
this.menuGroups.forEach((menuGroup, level) => {
if (level === this.currentLevel) {
// Current level - always visible and interactive
menuGroup.style("display", "block");
} else if (level === this.currentLevel - 1) {
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(0.5)")
.style("transform", "scale(1)")
.style("opacity", 1);
// Enable pointer events for current level
menuGroup.selectAll("path").style("pointer-events", "auto");
} else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
// Previous level - visible but scaled down
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style(
"transform",
`scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`,
)
.style("opacity", 0.8);
menuGroup.selectAll("path").each(function () {
const pathElement = d3.select(this);
pathElement.style("pointer-events", "none");
});
} else {
// Disable pointer events for previous level when going forward
if (direction === "forward") {
menuGroup.selectAll("path").each(function () {
const pathElement = d3.select(this);
pathElement.style("pointer-events", "none");
});
}
} else if (level !== this.currentLevel + 1) {
// Hide all other levels
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.5)
@@ -610,7 +648,7 @@ export class RadialMenu implements Layer {
this.updateMenuLevels();
this.clearSelectedItemHoverState();
this.updateMenuVisibility();
this.updateMenuVisibility("backward");
this.animateMenuTransitions();
}
@@ -623,7 +661,7 @@ export class RadialMenu implements Layer {
this.selectedItemId = null;
}
this.currentMenuItems = previousItems || [];
this.currentMenuItems = previousItems ?? [];
if (this.currentLevel === 0) {
this.updateCenterButtonState("default");
@@ -637,54 +675,10 @@ export class RadialMenu implements Layer {
if (selectedPath) {
selectedPath.attr("filter", null);
selectedPath.attr("stroke-width", "2");
const item = this.findMenuItem(this.selectedItemId);
if (item) {
const disabled = this.params === null || item.disabled(this.params);
const color = disabled
? this.config.disabledColor
: item.color || "#333333";
const opacity = disabled ? 0.5 : 0.7;
selectedPath.attr(
"fill",
d3.color(color)?.copy({ opacity: opacity })?.toString() || color,
);
}
}
}
}
private updateMenuVisibility() {
this.menuGroups.forEach((menuGroup, level) => {
if (level === this.currentLevel) {
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(1)")
.style("opacity", 1);
menuGroup.selectAll("path").style("pointer-events", "auto");
} else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style(
"transform",
`scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`,
)
.style("opacity", 0.8);
} else if (level !== this.currentLevel + 1) {
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.5)
.style("opacity", 0)
.on("end", function () {
d3.select(this).style("display", "none");
});
}
});
// Use refresh() to update all item appearances consistently
this.refresh();
}
private animateMenuTransitions() {
@@ -765,10 +759,13 @@ export class RadialMenu implements Layer {
}
public hideRadialMenu() {
if (!this.isVisible || this.isTransitioning) {
if (!this.isVisible) {
return;
}
// Force transition state to false to ensure menu hides
this.isTransitioning = false;
this.menuElement.style("display", "none");
this.isVisible = false;
this.selectedItemId = null;
@@ -786,7 +783,7 @@ export class RadialMenu implements Layer {
private handleCenterButtonClick() {
if (this.centerButtonState === "default") {
if (this.params) {
if (this.params && this.isCenterButtonEnabled()) {
this.centerButtonElement?.action(this.params);
}
return;
@@ -810,6 +807,28 @@ export class RadialMenu implements Layer {
public updateCenterButtonState(state: CenterButtonState) {
this.centerButtonState = state;
if (state === "back") {
const backButtonSize = this.config.centerButtonSize * 0.8; // Make back button 20% smaller
this.menuElement
.select(".center-button-hitbox")
.transition()
.duration(0)
.attr("r", backButtonSize);
this.menuElement
.select(".center-button-visible")
.transition()
.duration(0)
.attr("r", backButtonSize);
const backIconImg = this.menuElement.select(".center-button-icon");
backIconImg
.attr("xlink:href", backIcon)
.attr("width", this.backIconSize)
.attr("height", this.backIconSize)
.attr("x", -this.backIconSize / 2)
.attr("y", -this.backIconSize / 2);
}
if (state === "default") {
// Restore original button size
this.menuElement
.select(".center-button-hitbox")
.transition()
@@ -821,15 +840,6 @@ export class RadialMenu implements Layer {
.duration(0)
.attr("r", this.config.centerButtonSize);
const backIconImg = this.menuElement.select(".center-button-icon");
backIconImg
.attr("xlink:href", backIcon)
.attr("width", this.backIconSize)
.attr("height", this.backIconSize)
.attr("x", -this.backIconSize / 2)
.attr("y", -this.backIconSize / 2);
}
if (state === "default") {
const iconImg = this.menuElement.select(".center-button-icon");
iconImg
.attr("xlink:href", this.originalCenterButtonIcon)
@@ -857,6 +867,11 @@ export class RadialMenu implements Layer {
}
private isCenterButtonEnabled(): boolean {
// Back button should always be enabled when in submenu levels
if (this.currentLevel > 0) {
return true;
}
if (this.params && this.centerButtonElement) {
return !this.centerButtonElement.disabled(this.params);
}
@@ -889,18 +904,6 @@ export class RadialMenu implements Layer {
return this.currentLevel;
}
public setRootMenuItems(
items: MenuElement[],
centerButton: CenterButtonElement,
) {
this.currentMenuItems = [...items];
this.rootMenuItems = [...items];
this.centerButtonElement = centerButton;
if (this.isVisible) {
this.refreshMenu();
}
}
public setParams(params: MenuElementParams) {
this.params = params;
}
@@ -913,7 +916,7 @@ export class RadialMenu implements Layer {
this.currentLevel = 0;
this.menuStack = [];
this.currentMenuItems = [...this.rootMenuItems];
this.currentMenuItems = this.rootMenu.subMenu!(this.params!);
this.navigationInProgress = false;
@@ -944,6 +947,49 @@ export class RadialMenu implements Layer {
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
}
public refresh() {
if (!this.isVisible || !this.params) return;
// Refresh the disabled state of all menu items
this.menuPaths.forEach((path, itemId) => {
const item = this.findMenuItem(itemId);
if (item) {
const disabled = this.isItemDisabled(item);
const color = disabled
? this.config.disabledColor
: (item.color ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
// Update path appearance
path.attr(
"fill",
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
);
path.style("opacity", disabled ? 0.5 : 1);
path.style("cursor", disabled ? "not-allowed" : "pointer");
// Update icon/text appearance using the same logic as renderIconsAndText
const icon = this.menuIcons.get(itemId);
if (icon) {
// Update text opacity
const textElement = icon.select("text");
if (!textElement.empty()) {
textElement.style("opacity", disabled ? 0.5 : 1);
}
// Update image opacity
const imageElement = icon.select("image");
if (!imageElement.empty()) {
imageElement.attr("opacity", disabled ? 0.5 : 1);
}
}
}
});
// Refresh center button state
this.updateCenterButtonState(this.centerButtonState);
}
renderLayer(context: CanvasRenderingContext2D) {
// No need to render anything on the canvas
}
@@ -1,10 +1,5 @@
import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
Cell,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
@@ -352,9 +347,9 @@ export const buildMenuElement: MenuElement = {
: undefined,
icon: item.icon,
tooltipItems: [
{ text: translateText(item.key || ""), className: "title" },
{ text: translateText(item.key ?? ""), className: "title" },
{
text: translateText(item.description || ""),
text: translateText(item.description ?? ""),
className: "description",
},
{
@@ -401,7 +396,7 @@ export const boatMenuElement: MenuElement = {
params.playerActionHandler.handleBoatAttack(
params.myPlayer,
params.selected?.id() || null,
params.selected?.id() ?? null,
params.tile,
spawn !== false ? spawn : null,
);
@@ -423,15 +418,11 @@ export const centerButtonElement: CenterButtonElement = {
}
return false;
}
return false;
return !params.playerActions.canAttack;
},
action: (params: MenuElementParams) => {
if (params.game.inSpawnPhase()) {
const cell = new Cell(
params.game.x(params.tile),
params.game.y(params.tile),
);
params.playerActionHandler.handleSpawn(cell);
params.playerActionHandler.handleSpawn(params.tile);
} else {
params.playerActionHandler.handleAttack(
params.myPlayer,
@@ -442,8 +433,17 @@ export const centerButtonElement: CenterButtonElement = {
},
};
export const rootMenuItems: MenuElement[] = [
infoMenuElement,
boatMenuElement,
buildMenuElement,
];
export const rootMenuElement: MenuElement = {
id: "root",
name: "root",
disabled: () => false,
icon: infoIcon,
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
let ally = allyRequestElement;
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
},
};
+5 -1
View File
@@ -11,7 +11,10 @@ import { translateText } from "../../Utils";
import { Layer } from "./Layer";
export class ShowReplayPanelEvent {
constructor(public visible: boolean = true) {}
constructor(
public visible: boolean = true,
public isSingleplayer: boolean = false,
) {}
}
@customElement("replay-panel")
@@ -36,6 +39,7 @@ export class ReplayPanel extends LitElement implements Layer {
if (this.eventBus) {
this.eventBus.on(ShowReplayPanelEvent, (event: ShowReplayPanelEvent) => {
this.visible = event.visible;
this.isSingleplayer = event.isSingleplayer;
});
}
}
+6 -3
View File
@@ -1,5 +1,6 @@
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";
@@ -55,8 +56,8 @@ export class SpawnAd extends LitElement implements Layer {
this.g.ticks() > 10 &&
this.gamesPlayed > 5
) {
console.log("showing bottom left ad");
this.show();
console.log("not showing spawn ad");
// this.show();
}
if (this.isVisible && !this.g.inSpawnPhase()) {
console.log("hiding bottom left ad");
@@ -123,7 +124,9 @@ export class SpawnAd extends LitElement implements Layer {
class="w-full h-full flex items-center justify-center"
>
${!this.adLoaded
? html`<span class="text-white text-sm">Loading ad...</span>`
? html`<span class="text-white text-sm"
>${translateText("spawn_ad.loading")}</span
>`
: ""}
</div>
</div>
+5 -11
View File
@@ -59,17 +59,11 @@ export class SpawnTimer implements Layer {
const barHeight = 10;
const barWidth = this.transformHandler.width();
let yOffset: number;
if (this.game.inSpawnPhase()) {
// At spawn time, draw at top
yOffset = 0;
} else if (this.game.config().gameConfig().gameMode === GameMode.Team) {
// After spawn, only in team mode, offset based on screen width
const screenW = window.innerWidth;
yOffset = screenW > 1024 ? 80 : 58;
} else {
// Not spawn and not team mode: no bar
if (
!this.game.inSpawnPhase() &&
this.game.config().gameConfig().gameMode !== GameMode.Team
) {
return;
}
@@ -80,7 +74,7 @@ export class SpawnTimer implements Layer {
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
context.fillRect(x, yOffset, segmentWidth, barHeight);
context.fillRect(x, 0, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
+1 -1
View File
@@ -52,7 +52,7 @@ export class TeamStats extends LitElement implements Layer {
for (const player of players) {
const team = player.team();
if (team === null) continue;
if (!grouped[team]) grouped[team] = [];
grouped[team] ??= [];
grouped[team].push(player);
}
+7 -1
View File
@@ -317,10 +317,16 @@ export class UILayer implements Layer {
if (constructionType === undefined) {
return 1;
}
const constDuration =
this.game.unitInfo(constructionType).constructionDuration;
if (constDuration === undefined) {
throw new Error("unit does not have constructionTime");
}
return (
(this.game.ticks() - unit.createdAt()) /
(this.game.unitInfo(constructionType).constructionDuration || 1)
(constDuration === 0 ? 1 : constDuration)
);
case UnitType.MissileSilo:
case UnitType.SAMLauncher:
return unit.missileReadinesss();
+13
View File
@@ -24,12 +24,21 @@ export class UnitDisplay extends LitElement implements Layer {
private _port = 0;
private _defensePost = 0;
private _samLauncher = 0;
private allDisabled = false;
createRenderRoot() {
return this;
}
init() {
const config = this.game.config();
this.allDisabled =
config.isUnitDisabled(UnitType.City) &&
config.isUnitDisabled(UnitType.Factory) &&
config.isUnitDisabled(UnitType.Port) &&
config.isUnitDisabled(UnitType.DefensePost) &&
config.isUnitDisabled(UnitType.MissileSilo) &&
config.isUnitDisabled(UnitType.SAMLauncher);
this.requestUpdate();
}
@@ -89,6 +98,10 @@ export class UnitDisplay extends LitElement implements Layer {
return null;
}
if (this.allDisabled) {
return null;
}
return html`
<div
class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] bg-gray-800/70 backdrop-blur-sm border border-slate-400 rounded-lg p-2 hidden lg:block"
+12 -2
View File
@@ -332,10 +332,20 @@
class="ml-2 mr-4"
/>
</a>
<a href="/privacy-policy.html" class="t-link" target="_blank">
<a
href="/privacy-policy.html"
data-i18n="main.privacy_policy"
class="t-link"
target="_blank"
>
Privacy Policy
</a>
<a href="/terms-of-service.html" class="t-link" target="_blank">
<a
href="/terms-of-service.html"
data-i18n="main.terms_of_service"
class="t-link"
target="_blank"
>
Terms of Service
</a>
<p style="text-align: center">
+2 -3
View File
@@ -104,9 +104,8 @@ export type IsLoggedInResponse =
| false;
let __isLoggedIn: IsLoggedInResponse | undefined = undefined;
export function isLoggedIn(): IsLoggedInResponse {
if (__isLoggedIn === undefined) {
__isLoggedIn = _isLoggedIn();
}
__isLoggedIn ??= _isLoggedIn();
return __isLoggedIn;
}
function _isLoggedIn(): IsLoggedInResponse {
+6 -1
View File
@@ -369,6 +369,11 @@ label.option-card:hover {
mask: url("../../resources/images/CityIconWhite.svg") no-repeat center / cover;
}
#helpModal .factory-icon {
mask: url("../../resources/images/FactoryIconWhite.svg") no-repeat center /
cover;
}
#helpModal .defense-post-icon {
mask: url("../../resources/images/ShieldIconWhite.svg") no-repeat center /
cover;
@@ -626,7 +631,7 @@ label.option-card:hover {
}
/* News Button Notification */
.news-button .active button {
news-button .active button {
position: relative;
border-color: #2563eb !important;
border-width: 2px !important;