mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 02:17:45 +00:00
815f1de67b
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>
375 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|