Better In Game UI (#1243)

## Description:
Top Bar Refactor – UI & UX Improvement Proposal

This update overhauls the top game bar to improve clarity,
responsiveness, and overall user experience across desktop and mobile.
It consolidates player resources (e.g., building counts), integrates
game controls (pause, settings, time), and enhances visual contrast.

Key changes:

Redesigned top bar with player data and game options.

Team color indicator bar (team games only).

Countdown bar during "Choose Starting Position" phase.

Removed redundant info (e.g., troop/worker counts shown elsewhere).

Inspired by strategy games like Travian Legends, this refactor offers a
cleaner and more intuitive layout, especially for smaller screens.

⚠️ Note: This is a large change and likely contains visual or functional
bugs I can’t currently spot due to fatigue. Thorough testing is required
before approval.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Diessel
![Snímek obrazovky 2025-06-21 v 8 13 46](https://github.c
![Snímek obrazovky 2025-06-21 v 8 13
35](https://github.com/user-attachments/assets/e166ee1b-6173-48f5-8e2d-598d796a7e2d)
om/user-attachments/assets/3a0edbef-e621-4fc4-b6b7-c1ed
![Snímek obrazovky 2025-06-21 v 8 13
20](https://github.com/user-attachments/assets/1214ab61-323c-4317-8722-eaa4b932a60c)
8f9a8219)
![Snímek obrazovky 2025-06-21 v 8 10
04](https://github.com/user-attachments/assets/374fe15a-bfad-4469-9950-3ec631b6e2d3)

Closes #1165

---------

Co-authored-by: Scott Anderson <scottanderson@users.noreply.github.com>
Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
DiesselOne
2025-07-01 04:49:42 +02:00
committed by GitHub
parent 91adf1fa8b
commit 4e48eba910
55 changed files with 795 additions and 715 deletions
+1 -1
View File
@@ -366,7 +366,7 @@ class Client {
"host-lobby-modal",
"join-private-lobby-modal",
"game-starting-modal",
"top-bar",
"game-top-bar",
"help-modal",
"user-setting",
].forEach((tag) => {
+18 -15
View File
@@ -14,6 +14,8 @@ import { EmojiTable } from "./layers/EmojiTable";
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";
@@ -21,7 +23,6 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
@@ -33,7 +34,6 @@ import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
import { TerrainLayer } from "./layers/TerrainLayer";
import { TerritoryLayer } from "./layers/TerritoryLayer";
import { TopBar } from "./layers/TopBar";
import { UILayer } from "./layers/UILayer";
import { UnitInfoModal } from "./layers/UnitInfoModal";
import { UnitLayer } from "./layers/UnitLayer";
@@ -135,25 +135,28 @@ export function createRenderer(
winModal.eventBus = eventBus;
winModal.game = game;
const optionsMenu = document.querySelector("options-menu") as OptionsMenu;
if (!(optionsMenu instanceof OptionsMenu)) {
console.error("options menu not found");
}
optionsMenu.eventBus = eventBus;
optionsMenu.game = game;
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("ReplayPanel element not found in the DOM");
console.error("replay panel not found");
}
replayPanel.eventBus = eventBus;
replayPanel.game = game;
const topBar = document.querySelector("top-bar") as TopBar;
if (!(topBar instanceof TopBar)) {
const gameRightSidebar = document.querySelector(
"game-right-sidebar",
) as GameRightSidebar;
if (!(gameRightSidebar instanceof GameRightSidebar)) {
console.error("Game Right bar not found");
}
gameRightSidebar.game = game;
gameRightSidebar.eventBus = eventBus;
const gameTopBar = document.querySelector("game-top-bar") as GameTopBar;
if (!(gameTopBar instanceof GameTopBar)) {
console.error("top bar not found");
}
topBar.game = game;
gameTopBar.game = game;
gameTopBar.eventBus = eventBus;
const playerPanel = document.querySelector("player-panel") as PlayerPanel;
if (!(playerPanel instanceof PlayerPanel)) {
@@ -248,13 +251,13 @@ export function createRenderer(
new SpawnTimer(game, transformHandler),
leaderboard,
gameLeftSidebar,
gameTopBar,
gameRightSidebar,
controlPanel,
playerInfo,
winModal,
optionsMenu,
replayPanel,
teamStats,
topBar,
playerPanel,
headsUpMessage,
unitInfoModal,
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("leaderboard-regular-icon")
export class LeaderboardRegularIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M4 19h4v-8H4zm6 0h4V5h-4zm6 0h4v-6h-4zM2 21V9h6V3h8v8h6v10z"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("leaderboard-solid-icon")
export class LeaderboardSolidIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M2 21V9h5.5v12zm7.25 0V3h5.5v18zm7.25 0V11H22v10z"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("team-regular-icon")
export class TeamRegularIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M9 13.75c-2.34 0-7 1.17-7 3.5V19h14v-1.75c0-2.33-4.66-3.5-7-3.5M4.34 17c.84-.58 2.87-1.25 4.66-1.25s3.82.67 4.66 1.25zM9 12c1.93 0 3.5-1.57 3.5-3.5S10.93 5 9 5S5.5 6.57 5.5 8.5S7.07 12 9 12m0-5c.83 0 1.5.67 1.5 1.5S9.83 10 9 10s-1.5-.67-1.5-1.5S8.17 7 9 7m7.04 6.81c1.16.84 1.96 1.96 1.96 3.44V19h4v-1.75c0-2.02-3.5-3.17-5.96-3.44M15 12c1.93 0 3.5-1.57 3.5-3.5S16.93 5 15 5c-.54 0-1.04.13-1.5.35c.63.89 1 1.98 1 3.15s-.37 2.26-1 3.15c.46.22.96.35 1.5.35"
/>
</svg>
`;
}
}
@@ -1,35 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("team-solid-icon")
export class TeamSolidIcon extends LitElement {
@property({ type: String }) size = "24"; // Accepts "24", "32", etc.
@property({ type: String }) color = "currentColor";
static styles = css`
:host {
display: inline-block;
vertical-align: middle;
}
svg {
display: block;
}
`;
render() {
return html`
<svg
xmlns="http://www.w3.org/2000/svg"
width="${this.size}"
height="${this.size}"
viewBox="0 0 24 24"
fill="${this.color}"
>
<path
fill="currentColor"
d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3s1.34 3 3 3m-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5S5 6.34 5 8s1.34 3 3 3m0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5m8 0c-.29 0-.62.02-.97.05c1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5"
/>
</svg>
`;
}
}
+12 -77
View File
@@ -1,13 +1,11 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { Gold, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { AttackRatioEvent } from "../../InputHandler";
import { SendSetTargetTroopRatioEvent } from "../../Transport";
import { renderNumber, renderTroops } from "../../Utils";
import { renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@@ -29,37 +27,13 @@ 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;
@state()
private _factories: number;
private _lastPopulationIncreaseRate: number;
private _popRateIsIncreasing: boolean = true;
private init_: boolean = false;
init() {
@@ -118,22 +92,13 @@ export class ControlPanel extends LitElement implements Layer {
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
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();
this._factories = player.units(UnitType.Factory).length;
}
onAttackRatioChange(newRatio: number) {
@@ -209,51 +174,21 @@ export class ControlPanel extends LitElement implements Layer {
</style>
<div
class="${this._isVisible
? "w-[320px] text-sm lg:text-m bg-gray-800/70 p-2 pr-3 lg:p-4 shadow-lg lg:rounded-lg backdrop-blur"
? "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"
: "hidden"}"
@contextmenu=${(e) => e.preventDefault()}
>
<div class="hidden lg: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)}
${renderNumber(this._factories)}
<img
src="${factoryIcon}"
style="display: inline"
width="15"
/>)</span
>
</div>
</div>
<div class="relative mb-4 lg: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
>
<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="relative h-8">
<!-- Background track -->
<div
+26 -12
View File
@@ -1,13 +1,13 @@
import { Colord } from "colord";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import leaderboardRegularIcon from "../../../../resources/images/LeaderboardIconRegularWhite.svg";
import leaderboardSolidIcon from "../../../../resources/images/LeaderboardIconSolidWhite.svg";
import teamRegularIcon from "../../../../resources/images/TeamIconRegularWhite.svg";
import teamSolidIcon from "../../../../resources/images/TeamIconSolidWhite.svg";
import { GameMode } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { translateText } from "../../Utils";
import "../icons/LeaderboardRegularIcon";
import "../icons/LeaderboardSolidIcon";
import "../icons/TeamRegularIcon";
import "../icons/TeamSolidIcon";
import { Layer } from "./Layer";
@customElement("game-left-sidebar")
@@ -77,7 +77,7 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
<aside
class=${`fixed top-[50px] lg:top-[10px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
class=${`fixed top-[90px] left-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tr-lg rounded-br-lg transition-transform duration-300 ease-out transform ${
this.isVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
@@ -94,11 +94,20 @@ export class GameLeftSidebar extends LitElement implements Layer {
</div>
`
: null}
<div class="flex items-center gap-2 space-x-2 text-white mb-2">
<div
class=${`flex items-center gap-2 space-x-2 text-white ${
this.isLeaderboardShow || this.isTeamLeaderboardShow ? "mb-2" : ""
}`}
>
<div class="w-6 h-6 cursor-pointer" @click=${this.toggleLeaderboard}>
${this.isLeaderboardShow
? html` <leaderboard-solid-icon></leaderboard-solid-icon>`
: html` <leaderboard-regular-icon></leaderboard-regular-icon>`}
<img
src=${this.isLeaderboardShow
? leaderboardSolidIcon
: leaderboardRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
${this.isTeamGame
? html`
@@ -106,9 +115,14 @@ export class GameLeftSidebar extends LitElement implements Layer {
class="w-6 h-6 cursor-pointer"
@click=${this.toggleTeamLeaderboard}
>
${this.isTeamLeaderboardShow
? html` <team-solid-icon></team-solid-icon>`
: html` <team-regular-icon></team-regular-icon>`}
<img
src=${this.isTeamLeaderboardShow
? teamSolidIcon
: teamRegularIcon}
alt="treeIcon"
width="20"
height="20"
/>
</div>
`
: null}
@@ -0,0 +1,139 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
import pauseIcon from "../../../../resources/images/PauseIconWhite.svg";
import playIcon from "../../../../resources/images/PlayIconWhite.svg";
import replayRegularIcon from "../../../../resources/images/ReplayRegularIconWhite.svg";
import replaySolidIcon from "../../../../resources/images/ReplaySolidIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { PauseGameEvent } from "../../Transport";
import { Layer } from "./Layer";
@customElement("game-right-sidebar")
export class GameRightSidebar extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
@state()
private _isSinglePlayer: boolean = false;
@state()
private _isReplayVisible: boolean = false;
@state()
private _isVisible: boolean = true;
@state()
private isPaused: boolean = false;
@state()
private isExistButtonVisible: boolean = true;
createRenderRoot() {
return this;
}
init() {
this._isSinglePlayer =
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this._isVisible = true;
this.game.inSpawnPhase();
this.requestUpdate();
}
tick() {
if (!this.game.inSpawnPhase()) {
this.isExistButtonVisible = false;
}
}
private toggleReplayPanel(): void {
this._isReplayVisible = !this._isReplayVisible;
}
private onPauseButtonClick() {
this.isPaused = !this.isPaused;
this.eventBus.emit(new PauseGameEvent(this.isPaused));
}
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm("Are you sure you want to exit the game?");
if (!isConfirmed) return;
}
// redirect to the home page
window.location.href = "/";
}
render() {
return html`
<aside
class=${`fixed top-[90px] right-0 z-[1000] flex flex-col max-h-[calc(100vh-80px)] overflow-y-auto p-2 bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-tl-lg rounded-bl-lg transition-transform duration-300 ease-out transform ${
this._isVisible ? "translate-x-0" : "translate-x-full"
}`}
@contextmenu=${(e: Event) => e.preventDefault()}
>
<div
class=${`flex justify-end items-center gap-2 text-white ${
this._isReplayVisible ? "mb-2" : ""
}`}
>
${this._isSinglePlayer || this.game?.config()?.isReplay()
? html`
<div
class="w-6 h-6 cursor-pointer"
@click=${this.toggleReplayPanel}
>
<img
src=${this._isReplayVisible
? replaySolidIcon
: replayRegularIcon}
alt="replay"
width="20"
height="20"
style="vertical-align: middle;"
/>
</div>
<div
class="w-6 h-6 cursor-pointer"
@click=${this.onPauseButtonClick}
>
<img
src=${this.isPaused ? playIcon : pauseIcon}
alt="play/pause"
width="20"
height="20"
style="vertical-align: middle;"
/>
</div>
${this.isExistButtonVisible
? html`
<div
class="w-6 h-6 cursor-pointer"
@click=${this.onExitButtonClick}
>
<img
src=${exitIcon}
alt="exit"
width="20"
height="20"
/>
</div>
`
: null}
`
: null}
</div>
<div class="block lg:flex flex-wrap gap-2">
<replay-panel
.isSingleplayer="${this._isSinglePlayer}"
.visible="${this._isReplayVisible}"
></replay-panel>
</div>
</aside>
`;
}
}
+471
View File
@@ -0,0 +1,471 @@
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
import explosionIcon from "../../../../resources/images/ExplosionIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import mouseIcon from "../../../../resources/images/MouseIconWhite.svg";
import ninjaIcon from "../../../../resources/images/NinjaIconWhite.svg";
import populationIcon from "../../../../resources/images/PopulationIconSolidWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherUnitWhite.png";
import settingsIcon from "../../../../resources/images/SettingIconWhite.svg";
import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
import treeIcon from "../../../../resources/images/TreeIconWhite.svg";
import troopIcon from "../../../../resources/images/TroopIconWhite.svg";
import workerIcon from "../../../../resources/images/WorkerIconWhite.svg";
import { translateText } from "../../../client/Utils";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
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 _userSettings: UserSettings = new UserSettings();
private _population = 0;
private _troops = 0;
private _cities = 0;
private _factories = 0;
private _workers = 0;
private _missileSilo = 0;
private _port = 0;
private _defensePost = 0;
private _samLauncher = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
private hasWinner = false;
@state()
private showSettingsMenu = false;
@state()
private alternateView: boolean = false;
@state()
private timer: number = 0;
@query(".settings-container")
private settingsContainer!: HTMLElement;
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();
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
this._port = player.totalUnitLevels(UnitType.Port);
this._defensePost = player.totalUnitLevels(UnitType.DefensePost);
this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher);
this._factories = player.totalUnitLevels(UnitType.Factory);
const updates = this.game.updatesSinceLastTick();
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
this.timer++;
}
this.requestUpdate();
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("click", this.handleOutsideClick, true);
}
disconnectedCallback() {
window.removeEventListener("click", this.handleOutsideClick, true);
super.disconnectedCallback();
}
private handleOutsideClick = (event: MouseEvent) => {
if (
this.showSettingsMenu &&
this.settingsContainer &&
!this.settingsContainer.contains(event.target as Node)
) {
this.showSettingsMenu = false;
}
};
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm("Are you sure you want to exit the game?");
if (!isConfirmed) return;
}
// redirect to the home page
window.location.href = "/";
}
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onToggleEmojisButtonClick() {
this._userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this._userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this._userSettings.toggleDarkMode();
this.requestUpdate();
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleRandomNameModeButtonClick() {
this._userSettings.toggleRandomName();
}
private onToggleLeftClickOpensMenu() {
this._userSettings.toggleLeftClickOpenMenu();
}
private toggleSettingsMenu() {
this.showSettingsMenu = !this.showSettingsMenu;
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
private secondsToHms = (d: number): string => {
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
let time = d === 0 ? "-" : `${s}s`;
if (m > 0) time = `${m}m` + time;
if (h > 0) time = `${h}h` + time;
return time;
};
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="fixed top-0 left-auto right-0 z-[1100] bg-slate-800/40 backdrop-blur-sm p-2 flex justify-center items-center"
>
<div
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center text-white px-1"
>
${this.secondsToHms(this.timer)}
</div>
</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-0 min-h-[50px] lg:min-h-[80px] z-[1100] flex flex-wrap bg-slate-800/40 backdrop-blur-sm shadow-xs text-white text-xs md:text-sm lg:text-base left-0 right-0 grid-cols-4 p-1 md:px-1.5 lg:px-4"
>
<div
class="flex flex-1 basis-full justify-between items-center gap-1 w-full"
>
${myPlayer?.isAlive() && !this.game.inSpawnPhase()
? html`
<div class="overflow-x-auto hide-scrollbar flex-1 max-w-[85vw]">
<div
class="grid gap-1 grid-cols-[80px_100px_80px_minmax(80px,auto)] w-max md:gap-2 md:grid-cols-[90px_120px_90px_minmax(100px,auto)]"
>
<div
class="flex flex-wrap gap-1 flex-col bg-slate-800/20 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;"
/>
+${renderNumber(goldPerSecond)}
</div>
<div>${renderNumber(myPlayer.gold())}</div>
</div>
<div
class="flex flex-wrap gap-1 flex-col bg-slate-800/20 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>
${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}
</div>
</div>
<div
class="flex bg-slate-800/20 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;"
/>
${renderTroops(this._troops)}
</div>
<div class="flex gap-1">
<img
src=${workerIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderTroops(this._workers)}
</div>
</div>
</div>
<div
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 bg-slate-800/20 border border-slate-400 p-0.5 md:px-1 lg:px-2 md:gap-2"
>
<div class="flex items-center gap-2">
<img
src=${cityIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._cities)}
</div>
<div class="flex items-center gap-2">
<img
src=${factoryIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._factories)}
</div>
<div class="flex items-center gap-2">
<img
src=${portIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._port)}
</div>
<div class="flex items-center gap-2">
<img
src=${defensePostIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._defensePost)}
</div>
<div class="flex items-center gap-2">
<img
src=${missileSiloIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._missileSilo)}
</div>
<div class="flex items-center gap-2">
<img
src=${samLauncherIcon}
alt="gold"
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(this._samLauncher)}
</div>
</div>
</div>
</div>
`
: html`<div></div>`}
<div class="flex gap-1 items-center">
<div
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center px-1"
>
${this.secondsToHms(this.timer)}
</div>
<div class="relative settings-container">
<img
class="cursor-pointer bg-slate-800/20 border border-slate-400 p-0.5"
src=${settingsIcon}
alt="settings"
width="28"
height="28"
style="vertical-align: middle;"
@click=${this.toggleSettingsMenu}
/>
${this.showSettingsMenu
? html`
<div
class="absolute right-0 mt-1.5 bg-slate-700 border border-slate-500 rounded shadow-lg z-[1100] w-max min-w-[10rem] whitespace-nowrap"
>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onTerrainButtonClick}"
>
<img
src=${treeIcon}
alt="treeIcon"
width="20"
height="20"
/>
Toggle Terrain ${this.alternateView ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleEmojisButtonClick}"
>
<img
src=${emojiIcon}
alt="emojiIcon"
width="20"
height="20"
/>
${translateText("user_setting.emojis_label")}
${this._userSettings.emojis() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleDarkModeButtonClick}"
>
<img
src=${darkModeIcon}
alt="darkModeIcon"
width="20"
height="20"
/>
${translateText("user_setting.dark_mode_label")}
${this._userSettings.darkMode() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleSpecialEffectsButtonClick}"
>
<img
src=${explosionIcon}
alt="onExitButtonClick"
width="20"
height="20"
/>
${translateText("user_setting.special_effects_label")}
${this._userSettings.fxLayer() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleRandomNameModeButtonClick}"
>
<img
src=${ninjaIcon}
alt="ninjaIcon"
width="20"
height="20"
/>
${translateText("user_setting.anonymous_names_label")}
${this._userSettings.anonymousNames() ? "On" : "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onToggleLeftClickOpensMenu}"
>
<img
src=${mouseIcon}
alt="mouseIcon"
width="20"
height="20"
/>
Left click
${this._userSettings.leftClickOpensMenu()
? "On"
: "Off"}
</button>
<button
class="flex gap-1 items-center w-full text-left px-2 py-1 hover:bg-slate-600 text-white text-sm"
@click="${this.onExitButtonClick}"
>
<img
src=${exitIcon}
alt="exitIcon"
width="20"
height="20"
/>
Exit game
</button>
</div>
`
: null}
</div>
</div>
</div>
</div>
`;
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ export class HeadsUpMessage extends LitElement implements Layer {
return html`
<div
class="flex items-center
w-full justify-evenly h-8 lg:h-10 top-0 lg:top-4 left-0 lg:left-4
class="flex items-center relative
w-full justify-evenly h-8 lg:h-10 md:top-[70px] left-0 lg:left-4
bg-opacity-60 bg-gray-900 rounded-md lg:rounded-lg
backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2"
@contextmenu=${(e) => e.preventDefault()}
@@ -352,11 +352,11 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div
class="flex w-full z-50 flex-col"
class="hidden lg:flex fixed top-[245px] right-0 w-full z-50 flex-col max-w-[180px]"
@contextmenu=${(e) => e.preventDefault()}
>
<div
class="bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg shadow-lg backdrop-blur-sm transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
class="bg-slate-800/40 backdrop-blur-sm shadow-xs rounded-lg shadow-lg transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
>
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
+35 -77
View File
@@ -1,7 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ReplaySpeedChangeEvent } from "../../InputHandler";
import {
@@ -16,27 +15,26 @@ export class ReplayPanel extends LitElement implements Layer {
public game: GameView | undefined;
public eventBus: EventBus | undefined;
@property({ type: Boolean })
visible: boolean = false;
@state()
private _replaySpeedMultiplier: number = defaultReplaySpeedMultiplier;
private _isSinglePlayer: boolean = false;
@state()
private _isVisible = false;
@property({ type: Boolean })
isSingleplayer = false;
init() {
this._isSinglePlayer =
this.game?.config().gameConfig().gameType === GameType.Singleplayer;
if (this._isSinglePlayer) {
this.setVisible(true);
}
createRenderRoot() {
return this; // Enable Tailwind CSS
}
tick() {
if (!this._isVisible && this.game?.config().isReplay()) {
this.setVisible(true);
}
init() {}
this.requestUpdate();
tick() {
if (!this.visible) return;
if (this.game!.ticks() % 10 === 0) {
this.requestUpdate();
}
}
onReplaySpeedChange(value: ReplaySpeedMultiplier) {
@@ -44,85 +42,45 @@ export class ReplayPanel extends LitElement implements Layer {
this.eventBus?.emit(new ReplaySpeedChangeEvent(value));
}
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
shouldTransform(): boolean {
renderLayer(_ctx: CanvasRenderingContext2D) {}
shouldTransform() {
return false;
}
setVisible(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
render() {
if (!this._isVisible) {
return html``;
}
if (!this.visible) return html``;
return html`
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
@contextmenu=${(e) => e.preventDefault()}
@contextmenu=${(e: Event) => e.preventDefault()}
>
<label class="block mb-1 text-white" translate="no">
${this._isSinglePlayer
${this.isSingleplayer
? translateText("replay_panel.game_speed")
: translateText("replay_panel.replay_speed")}
</label>
<div class="grid grid-cols-2 gap-1">
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.slow
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.slow);
}}
>
×0.5
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.normal
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.normal);
}}
>
×1
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fast
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fast);
}}
>
×2
</button>
<button
class="text-white font-bold py-0 rounded border transition ${this
._replaySpeedMultiplier === ReplaySpeedMultiplier.fastest
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => {
this.onReplaySpeedChange(ReplaySpeedMultiplier.fastest);
}}
>
max
</button>
${this.renderSpeedButton(ReplaySpeedMultiplier.slow, "×0.5")}
${this.renderSpeedButton(ReplaySpeedMultiplier.normal, "×1")}
${this.renderSpeedButton(ReplaySpeedMultiplier.fast, "×2")}
${this.renderSpeedButton(ReplaySpeedMultiplier.fastest, "max")}
</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
private renderSpeedButton(value: ReplaySpeedMultiplier, label: string) {
const isActive = this._replaySpeedMultiplier === value;
return html`
<button
class="text-white font-bold py-0 rounded border transition ${isActive
? "bg-blue-500 border-gray-400"
: "border-gray-500"}"
@click=${() => this.onReplaySpeedChange(value)}
>
${label}
</button>
`;
}
}
+23 -12
View File
@@ -16,8 +16,11 @@ export class SpawnTimer implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
this.ratios[0] =
this.game.ticks() / this.game.config().numSpawnPhaseTurns();
// During spawn phase, only one segment filling full width
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
];
this.colors = ["rgba(0, 128, 255, 0.7)"];
return;
}
@@ -33,18 +36,17 @@ export class SpawnTimer implements Layer {
const team = player.team();
if (team === null) throw new Error("Team is null");
const tiles = teamTiles.get(team) ?? 0;
const sum = tiles + player.numTilesOwned();
teamTiles.set(team, sum);
teamTiles.set(team, tiles + player.numTilesOwned());
}
const theme = this.game.config().theme();
const total = sumIterator(teamTiles.values());
if (total === 0) return;
for (const [team, count] of teamTiles) {
const ratio = count / total;
const color = theme.teamColor(team).toRgbString();
this.ratios.push(ratio);
this.colors.push(color);
this.colors.push(theme.teamColor(team).toRgbString());
}
}
@@ -53,12 +55,23 @@ export class SpawnTimer implements Layer {
}
renderLayer(context: CanvasRenderingContext2D) {
if (this.ratios === null) return;
if (this.ratios.length === 0) return;
if (this.colors.length === 0) return;
if (this.ratios.length === 0 || this.colors.length === 0) return;
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
return;
}
let x = 0;
let filledRatio = 0;
@@ -67,7 +80,7 @@ export class SpawnTimer implements Layer {
const segmentWidth = barWidth * ratio;
context.fillStyle = this.colors[i];
context.fillRect(x, 0, segmentWidth, barHeight);
context.fillRect(x, yOffset, segmentWidth, barHeight);
x += segmentWidth;
filledRatio += ratio;
@@ -76,8 +89,6 @@ export class SpawnTimer implements Layer {
}
function sumIterator(values: MapIterator<number>) {
// To use reduce, we'd need to allocate an array:
// return Array.from(values).reduce((sum, v) => sum + v, 0);
let total = 0;
for (const value of values) {
total += value;
-93
View File
@@ -1,93 +0,0 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { GameView } from "../../../core/game/GameView";
import { renderNumber, renderTroops } from "../../Utils";
import { Layer } from "./Layer";
@customElement("top-bar")
export class TopBar extends LitElement implements Layer {
public game: GameView;
private isVisible = false;
private _population = 0;
private _lastPopulationIncreaseRate = 0;
private _popRateIsIncreasing = false;
createRenderRoot() {
return this;
}
init() {
this.isVisible = true;
this.requestUpdate();
}
tick() {
this.updatePopulationIncrease();
this.requestUpdate();
}
private updatePopulationIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const popIncreaseRate = player.population() - this._population;
if (this.game.ticks() % 5 === 0) {
this._popRateIsIncreasing =
popIncreaseRate >= this._lastPopulationIncreaseRate;
this._lastPopulationIncreaseRate = popIncreaseRate;
}
}
render() {
if (!this.isVisible) {
return html``;
}
const myPlayer = this.game?.myPlayer();
if (!myPlayer?.isAlive() || this.game?.inSpawnPhase()) {
return html``;
}
const popRate = this.game.config().populationIncreaseRate(myPlayer) * 10;
const maxPop = this.game.config().maxPopulation(myPlayer);
const goldPerSecond = this.game.config().goldAdditionRate(myPlayer) * 10n;
return html`
<div
class="fixed top-0 z-50 bg-slate-800/40 backdrop-blur-sm shadow-xs text-white text-sm p-1 rounded-ee-sm lg:rounded grid grid-cols-1 sm:grid-cols-2 w-1/2 sm:w-2/3 md:w-1/2 lg:hidden"
>
<!-- Pop section (takes 2 columns on desktop) -->
<div
class="sm:col-span-1 flex items-center space-x-1 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0"
>${translateText("control_panel.pop")}:</span
>
<span translate="no"
>${renderTroops(myPlayer.population())} /
${renderTroops(maxPop)}</span
>
<span
translate="no"
class="${this._popRateIsIncreasing
? "text-green-500"
: "text-yellow-500"}"
>(+${renderTroops(popRate)})</span
>
</div>
<!-- Gold section (takes 1 column on desktop) -->
<div
class="flex items-center space-x-2 overflow-x-auto whitespace-nowrap"
>
<span class="font-bold shrink-0"
>${translateText("control_panel.gold")}:</span
>
<span translate="no"
>${renderNumber(myPlayer.gold())}
(+${renderNumber(goldPerSecond)})</span
>
</div>
</div>
`;
}
}
+4 -7
View File
@@ -265,7 +265,6 @@
<div id="radialMenu" class="radial-menu"></div>
<div class="flex gap-2 fixed right-[10px] top-[10px] z-50 flex-col">
<options-menu></options-menu>
<replay-panel></replay-panel>
<player-info-overlay></player-info-overlay>
</div>
<div
@@ -279,16 +278,13 @@
style="position: fixed; pointer-events: none"
>
<div
class="w-full sm:w-2/3 sm:fixed sm:right-0 sm:bottom-0 sm:flex flex-col items-end"
class="w-full md:w-2/3 md:fixed sm:right-0 md:bottom-0 md:flex flex-col items-end"
style="pointer-events: none"
>
<chat-display></chat-display>
<events-display></events-display>
</div>
<div
class="w-[320px] flex flex-col items-center"
style="pointer-events: auto"
>
<div style="pointer-events: auto">
<control-panel></control-panel>
</div>
</div>
@@ -363,7 +359,8 @@
<build-menu></build-menu>
<win-modal></win-modal>
<game-starting-modal></game-starting-modal>
<top-bar></top-bar>
<game-top-bar></game-top-bar>
<game-right-sidebar></game-right-sidebar>
<player-panel></player-panel>
<help-modal></help-modal>
+8
View File
@@ -37,6 +37,14 @@
background: rgba(255, 255, 255, 0.3);
}
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.start-game-button {
width: 100%;
max-width: 300px;
+7
View File
@@ -264,10 +264,17 @@ export class PlayerView {
targetTroopRatio(): number {
return this.data.targetTroopRatio;
}
troops(): number {
return this.data.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}