mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
66ecbabac2
The contents (Lit web components for in-game chat, build menu, leaderboard, attack displays, etc.) are HUD, not graphics — the actual graphics is in client/render/.
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { LitElement, html } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { assetUrl } from "../../../core/AssetUrls";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import { Gold } from "../../../core/game/Game";
|
|
import { GameView } from "../../../core/game/GameView";
|
|
import { UserSettings } from "../../../core/game/UserSettings";
|
|
import { ClientID } from "../../../core/Schemas";
|
|
import { Controller } from "../../Controller";
|
|
import { AttackRatioEvent } from "../../InputHandler";
|
|
import { UIState } from "../../UIState";
|
|
import { renderNumber, renderTroops } from "../../Utils";
|
|
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
|
|
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
|
const swordIcon = assetUrl("images/SwordIcon.svg");
|
|
|
|
@customElement("control-panel")
|
|
export class ControlPanel extends LitElement implements Controller {
|
|
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 = new UserSettings().attackRatio();
|
|
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;
|
|
}
|
|
|
|
setVisibile(visible: boolean) {
|
|
this._isVisible = visible;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private handleRatioSliderInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const value = Number(input.value);
|
|
this.attackRatio = value / 100;
|
|
this.onAttackRatioChange(this.attackRatio);
|
|
}
|
|
|
|
private handleRatioSliderPointerUp(e: Event) {
|
|
(e.target as HTMLInputElement).blur();
|
|
}
|
|
|
|
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-malibu-blue transition-[width] duration-200"
|
|
style="width: ${greenPercent}%;"
|
|
></div>`
|
|
: ""}
|
|
${orangePercent > 0
|
|
? html`<div
|
|
class="h-full bg-aquarius 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-malibu-blue transition-[width] duration-200"
|
|
style="width: ${greenPercent}%;"
|
|
></div>`
|
|
: ""}
|
|
${orangePercent > 0
|
|
? html`<div
|
|
class="h-full bg-aquarius transition-[width] duration-200"
|
|
style="width: ${orangePercent}%;"
|
|
></div>`
|
|
: ""}
|
|
</div>
|
|
<div
|
|
class="absolute inset-0 flex items-center text-lg font-bold leading-none pointer-events-none"
|
|
translate="no"
|
|
>
|
|
<span class="flex-1 flex justify-end h-full items-center pr-0.5">
|
|
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>${renderTroops(this._troops)}</span
|
|
>
|
|
</span>
|
|
<span
|
|
class="h-full flex items-center px-0.5 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>/</span
|
|
>
|
|
<span
|
|
class="flex-1 flex justify-start h-full items-center pl-0.5 gap-0.5"
|
|
>
|
|
<span
|
|
class="text-white tabular-nums w-[3.5rem] drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>${renderTroops(this._maxTroops)}</span
|
|
>
|
|
<img
|
|
src=${soldierIcon}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width="22"
|
|
height="22"
|
|
class="shrink-0 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ml-1.5"
|
|
/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderDesktop() {
|
|
return html`
|
|
<!-- Row 1: troop rate | troop bar | gold -->
|
|
<div class="flex gap-1.5 items-center mb-1">
|
|
<!-- Troop rate -->
|
|
<div
|
|
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-sm py-0.5 px-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-sm 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-sm py-0.5 px-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-1.5" translate="no">
|
|
<div
|
|
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md px-1 py-0.5 text-sm font-bold text-white cursor-pointer w-[8rem]"
|
|
>
|
|
<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)}
|
|
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
|
|
class="flex-1 h-1.5 accent-aquarius 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 w-8"
|
|
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)}
|
|
@pointerup=${(e: Event) => this.handleRatioSliderPointerUp(e)}
|
|
class="w-full h-1.5 accent-aquarius cursor-pointer"
|
|
/>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div
|
|
class="relative pointer-events-auto ${this._isVisible
|
|
? "relative w-full text-sm px-2 py-1"
|
|
: "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
|
|
}
|
|
}
|