Files
OpenFrontIO/src/client/graphics/layers/ControlPanel.ts
T
Evan 815f1de67b Update control panel UI (#3357)
Relates to #2260

## Description:

Inspired by https://github.com/openfrontio/OpenFrontIO/pull/3359

This PR centers the control panel and combines it with the units
display. The reasoning is that the control panel contains the most
critical info so it should be in the center of the screen. Combining it
with the units display reduces the number of UI components on screen.

Also made the attack ratio bar persistent on mobile

<img width="618" height="216" alt="Screenshot 2026-03-06 at 2 06 34 PM"
src="https://github.com/user-attachments/assets/34b041c1-d78b-46b5-a7ab-f2a44145a7e2"
/>


<img width="941" height="343" alt="Screenshot 2026-03-06 at 2 06 55 PM"
src="https://github.com/user-attachments/assets/1e3b026c-8eb2-407c-be38-0e71e1ae426c"
/>

<img width="562" height="228" alt="Screenshot 2026-03-06 at 4 11 20 PM"
src="https://github.com/user-attachments/assets/56eac49f-c8a4-4ac1-a60a-f1bcb2fad2d0"
/>

<img width="939" height="357" alt="Screenshot 2026-03-06 at 4 11 32 PM"
src="https://github.com/user-attachments/assets/eb5591d5-3cc2-4182-944b-3a4b0b76852a"
/>


## 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
- [x] 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

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

evan

Co-authored-by: hkio120 <111693579+hkio120@users.noreply.github.com>
2026-03-06 18:32:01 -08:00

375 lines
12 KiB
TypeScript

import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
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 { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
import soldierIcon from "/images/SoldierIcon.svg?url";
import swordIcon from "/images/SwordIcon.svg?url";
@customElement("control-panel")
export class ControlPanel extends LitElement implements Layer {
public game: GameView;
public clientID: ClientID;
public eventBus: EventBus;
public uiState: UIState;
@state()
private attackRatio: number = 0.2;
@state()
private _maxTroops: number;
@state()
private troopRate: number;
@state()
private _troops: number;
@state()
private _isVisible = false;
@state()
private _gold: Gold;
@state()
private _attackingTroops: number = 0;
private _troopRateIsIncreasing: boolean = true;
private _lastTroopIncreaseRate: number;
getTickIntervalMs() {
return 100;
}
init() {
this.attackRatio = Number(
localStorage.getItem("settings.attackRatio") ?? "0.2",
);
this.uiState.attackRatio = this.attackRatio;
this.eventBus.on(AttackRatioEvent, (event) => {
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
if (newAttackRatio < 0.01) {
newAttackRatio = 0.01;
}
if (newAttackRatio > 1) {
newAttackRatio = 1;
}
if (newAttackRatio === 0.11 && this.attackRatio === 0.01) {
// If we're changing the ratio from 1%, then set it to 10% instead of 11% to keep a consistency
newAttackRatio = 0.1;
}
this.attackRatio = newAttackRatio;
this.onAttackRatioChange(this.attackRatio);
});
}
tick() {
if (!this._isVisible && !this.game.inSpawnPhase()) {
this.setVisibile(true);
}
const player = this.game.myPlayer();
if (player === null || !player.isAlive()) {
this.setVisibile(false);
return;
}
this.updateTroopIncrease();
this._maxTroops = this.game.config().maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.requestUpdate();
}
private updateTroopIncrease() {
const player = this.game?.myPlayer();
if (player === null) return;
const troopIncreaseRate = this.game.config().troopIncreaseRate(player);
this._troopRateIsIncreasing =
troopIncreaseRate >= this._lastTroopIncreaseRate;
this._lastTroopIncreaseRate = troopIncreaseRate;
}
onAttackRatioChange(newRatio: number) {
this.uiState.attackRatio = newRatio;
}
renderLayer(context: CanvasRenderingContext2D) {
// Render any necessary canvas elements
}
shouldTransform(): boolean {
return false;
}
setVisibile(visible: boolean) {
this._isVisible = visible;
this.requestUpdate();
}
private handleRatioSliderInput(e: Event) {
const value = Number((e.target as HTMLInputElement).value);
this.attackRatio = value / 100;
this.onAttackRatioChange(this.attackRatio);
}
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
const base = Math.max(this._maxTroops, 1);
const greenPercentRaw = (this._troops / base) * 100;
const orangePercentRaw = (this._attackingTroops / base) * 100;
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
const orangePercent = Math.max(
0,
Math.min(100 - greenPercent, orangePercentRaw),
);
return { greenPercent, orangePercent };
}
private renderMobileTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
</div>
<div
class="absolute inset-0 flex items-center justify-center gap-0.5 pointer-events-none"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
class="brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
/>
<span
class="text-[10px] font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
</div>
`;
}
private renderDesktopTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-start px-1.5 text-xs font-bold leading-none pointer-events-none gap-0.5"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white/60 drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>/</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
</div>
</div>
`;
}
private renderDesktop() {
return html`
<!-- Row 1: troop rate | troop bar | gold -->
<div class="flex gap-1.5 items-center mb-1.5">
<!-- Troop rate -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-xs p-1 w-[5.5rem] ${this
._troopRateIsIncreasing
? "border-green-400"
: "border-orange-400"}"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="13"
height="13"
class="shrink-0"
style="filter: ${this._troopRateIsIncreasing
? "brightness(0) saturate(100%) invert(74%) sepia(44%) saturate(500%) hue-rotate(83deg) brightness(103%)"
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
/>
<span
class="text-xs font-bold tabular-nums ${this._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
<!-- Troop bar -->
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
<!-- Gold -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs p-1 w-[4.5rem]"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
<span class="tabular-nums">${renderNumber(this._gold)}</span>
</div>
</div>
<!-- Row 2: attack ratio | slider -->
<div class="flex items-center gap-2" translate="no">
<div
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-xs font-bold text-white cursor-pointer w-[7rem]"
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
style="filter: brightness(0) invert(1);"
/>
<span
>${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</span
>
</div>
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="flex-1 h-2 accent-blue-500 cursor-pointer"
/>
</div>
`;
}
private renderMobile() {
return html`
<div class="flex gap-2 items-center">
<!-- Gold -->
<div
class="flex items-center justify-center p-1 gap-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-1/5 shrink-0"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(this._gold)}</span>
</div>
<!-- Troop bar -->
<div class="w-[40%] shrink-0 flex items-center">
${this.renderMobileTroopBar()}
</div>
<!-- Sword + % label -->
<div class="flex flex-col items-center shrink-0 gap-0.5" translate="no">
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="10"
height="10"
style="filter: brightness(0) invert(1);"
/>
<span class="text-white text-xs font-bold tabular-nums"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
</div>
<!-- Attack ratio slider -->
<div class="flex-1" translate="no">
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="w-full h-1.5 accent-blue-500 cursor-pointer"
/>
</div>
</div>
`;
}
render() {
return html`
<div
class="relative pointer-events-auto ${this._isVisible
? "relative w-full text-sm px-2 py-1.5"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div class="lg:hidden">${this.renderMobile()}</div>
<div class="hidden lg:block">${this.renderDesktop()}</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
}