mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 17:16:38 +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>
310 lines
9.8 KiB
TypeScript
310 lines
9.8 KiB
TypeScript
import { html, LitElement } from "lit";
|
|
import { customElement } from "lit/decorators.js";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
BuildableUnit,
|
|
BuildMenus,
|
|
Gold,
|
|
PlayerBuildableUnitType,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import { GameView } from "../../../core/game/GameView";
|
|
import {
|
|
GhostStructureChangedEvent,
|
|
ToggleStructureEvent,
|
|
} from "../../InputHandler";
|
|
import { renderNumber, translateText } from "../../Utils";
|
|
import { UIState } from "../UIState";
|
|
import { Layer } from "./Layer";
|
|
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
|
|
import cityIcon from "/images/CityIconWhite.svg?url";
|
|
import factoryIcon from "/images/FactoryIconWhite.svg?url";
|
|
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
|
|
import mirvIcon from "/images/MIRVIcon.svg?url";
|
|
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
|
|
import hydrogenBombIcon from "/images/MushroomCloudIconWhite.svg?url";
|
|
import atomBombIcon from "/images/NukeIconWhite.svg?url";
|
|
import portIcon from "/images/PortIcon.svg?url";
|
|
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
|
|
import defensePostIcon from "/images/ShieldIconWhite.svg?url";
|
|
|
|
@customElement("unit-display")
|
|
export class UnitDisplay extends LitElement implements Layer {
|
|
public game: GameView;
|
|
public eventBus: EventBus;
|
|
public uiState: UIState;
|
|
private playerBuildables: BuildableUnit[] | null = null;
|
|
private keybinds: Record<string, { value: string; key: string }> = {};
|
|
private _cities = 0;
|
|
private _warships = 0;
|
|
private _factories = 0;
|
|
private _missileSilo = 0;
|
|
private _port = 0;
|
|
private _defensePost = 0;
|
|
private _samLauncher = 0;
|
|
private allDisabled = false;
|
|
private _hoveredUnit: PlayerBuildableUnitType | null = null;
|
|
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
init() {
|
|
const config = this.game.config();
|
|
|
|
const savedKeybinds = localStorage.getItem("settings.keybinds");
|
|
if (savedKeybinds) {
|
|
try {
|
|
this.keybinds = JSON.parse(savedKeybinds);
|
|
} catch (e) {
|
|
console.warn("Invalid keybinds JSON:", e);
|
|
}
|
|
}
|
|
|
|
this.allDisabled = BuildMenus.types.every((u) => config.isUnitDisabled(u));
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private cost(item: UnitType): Gold {
|
|
for (const bu of this.playerBuildables ?? []) {
|
|
if (bu.type === item) {
|
|
return bu.cost;
|
|
}
|
|
}
|
|
return 0n;
|
|
}
|
|
|
|
private canBuild(item: UnitType): boolean {
|
|
if (this.game?.config().isUnitDisabled(item)) return false;
|
|
const player = this.game?.myPlayer();
|
|
switch (item) {
|
|
case UnitType.AtomBomb:
|
|
case UnitType.HydrogenBomb:
|
|
case UnitType.MIRV:
|
|
return (
|
|
this.cost(item) <= (player?.gold() ?? 0n) &&
|
|
(player?.units(UnitType.MissileSilo).length ?? 0) > 0
|
|
);
|
|
case UnitType.Warship:
|
|
return (
|
|
this.cost(item) <= (player?.gold() ?? 0n) &&
|
|
(player?.units(UnitType.Port).length ?? 0) > 0
|
|
);
|
|
default:
|
|
return this.cost(item) <= (player?.gold() ?? 0n);
|
|
}
|
|
}
|
|
|
|
tick() {
|
|
const player = this.game?.myPlayer();
|
|
if (!player) return;
|
|
player.buildables(undefined, BuildMenus.types).then((buildables) => {
|
|
this.playerBuildables = buildables;
|
|
});
|
|
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);
|
|
this._warships = player.totalUnitLevels(UnitType.Warship);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
render() {
|
|
const myPlayer = this.game?.myPlayer();
|
|
if (
|
|
!this.game ||
|
|
!myPlayer ||
|
|
this.game.inSpawnPhase() ||
|
|
!myPlayer.isAlive()
|
|
) {
|
|
return null;
|
|
}
|
|
if (this.allDisabled) {
|
|
return null;
|
|
}
|
|
|
|
return html`
|
|
<div class="border-t border-white/10 p-0.5 w-full">
|
|
<div
|
|
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-0.5 w-fit mx-auto"
|
|
>
|
|
${this.renderUnitItem(
|
|
cityIcon,
|
|
this._cities,
|
|
UnitType.City,
|
|
"city",
|
|
this.keybinds["buildCity"]?.key ?? "1",
|
|
)}
|
|
${this.renderUnitItem(
|
|
factoryIcon,
|
|
this._factories,
|
|
UnitType.Factory,
|
|
"factory",
|
|
this.keybinds["buildFactory"]?.key ?? "2",
|
|
)}
|
|
${this.renderUnitItem(
|
|
portIcon,
|
|
this._port,
|
|
UnitType.Port,
|
|
"port",
|
|
this.keybinds["buildPort"]?.key ?? "3",
|
|
)}
|
|
${this.renderUnitItem(
|
|
defensePostIcon,
|
|
this._defensePost,
|
|
UnitType.DefensePost,
|
|
"defense_post",
|
|
this.keybinds["buildDefensePost"]?.key ?? "4",
|
|
)}
|
|
${this.renderUnitItem(
|
|
missileSiloIcon,
|
|
this._missileSilo,
|
|
UnitType.MissileSilo,
|
|
"missile_silo",
|
|
this.keybinds["buildMissileSilo"]?.key ?? "5",
|
|
)}
|
|
${this.renderUnitItem(
|
|
samLauncherIcon,
|
|
this._samLauncher,
|
|
UnitType.SAMLauncher,
|
|
"sam_launcher",
|
|
this.keybinds["buildSamLauncher"]?.key ?? "6",
|
|
)}
|
|
${this.renderUnitItem(
|
|
warshipIcon,
|
|
this._warships,
|
|
UnitType.Warship,
|
|
"warship",
|
|
this.keybinds["buildWarship"]?.key ?? "7",
|
|
)}
|
|
${this.renderUnitItem(
|
|
atomBombIcon,
|
|
null,
|
|
UnitType.AtomBomb,
|
|
"atom_bomb",
|
|
this.keybinds["buildAtomBomb"]?.key ?? "8",
|
|
)}
|
|
${this.renderUnitItem(
|
|
hydrogenBombIcon,
|
|
null,
|
|
UnitType.HydrogenBomb,
|
|
"hydrogen_bomb",
|
|
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
|
|
)}
|
|
${this.renderUnitItem(
|
|
mirvIcon,
|
|
null,
|
|
UnitType.MIRV,
|
|
"mirv",
|
|
this.keybinds["buildMIRV"]?.key ?? "0",
|
|
)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderUnitItem(
|
|
icon: string,
|
|
number: number | null,
|
|
unitType: PlayerBuildableUnitType,
|
|
structureKey: string,
|
|
hotkey: string,
|
|
) {
|
|
if (this.game.config().isUnitDisabled(unitType)) {
|
|
return html``;
|
|
}
|
|
const selected = this.uiState.ghostStructure === unitType;
|
|
const hovered = this._hoveredUnit === unitType;
|
|
const displayHotkey = hotkey
|
|
.replace("Digit", "")
|
|
.replace("Key", "")
|
|
.toUpperCase();
|
|
|
|
return html`
|
|
<div
|
|
class="flex flex-col items-center relative"
|
|
@mouseenter=${() => {
|
|
this._hoveredUnit = unitType;
|
|
this.requestUpdate();
|
|
}}
|
|
@mouseleave=${() => {
|
|
this._hoveredUnit = null;
|
|
this.requestUpdate();
|
|
}}
|
|
>
|
|
${hovered
|
|
? html`
|
|
<div
|
|
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
|
|
>
|
|
<div class="font-bold text-sm mb-1">
|
|
${translateText(
|
|
"unit_type." + structureKey,
|
|
)}${` [${displayHotkey}]`}
|
|
</div>
|
|
<div class="p-2">
|
|
${translateText("build_menu.desc." + structureKey)}
|
|
</div>
|
|
<div class="flex items-center justify-center gap-1">
|
|
<img src=${goldCoinIcon} width="13" height="13" />
|
|
<span class="text-yellow-300"
|
|
>${renderNumber(this.cost(unitType))}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
`
|
|
: null}
|
|
<div
|
|
class="${this.canBuild(unitType)
|
|
? ""
|
|
: "opacity-40"} border border-slate-500 rounded-sm px-0.5 pb-0.5 flex items-center gap-0.5 cursor-pointer
|
|
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
|
|
rounded-sm text-white ${selected ? "bg-slate-400/20" : ""}"
|
|
@click=${() => {
|
|
if (selected) {
|
|
this.uiState.ghostStructure = null;
|
|
this.eventBus?.emit(new GhostStructureChangedEvent(null));
|
|
} else if (this.canBuild(unitType)) {
|
|
this.uiState.ghostStructure = unitType;
|
|
this.eventBus?.emit(new GhostStructureChangedEvent(unitType));
|
|
}
|
|
this.requestUpdate();
|
|
}}
|
|
@mouseenter=${() => {
|
|
switch (unitType) {
|
|
case UnitType.AtomBomb:
|
|
case UnitType.HydrogenBomb:
|
|
this.eventBus?.emit(
|
|
new ToggleStructureEvent([
|
|
UnitType.MissileSilo,
|
|
UnitType.SAMLauncher,
|
|
]),
|
|
);
|
|
break;
|
|
case UnitType.Warship:
|
|
this.eventBus?.emit(new ToggleStructureEvent([UnitType.Port]));
|
|
break;
|
|
default:
|
|
this.eventBus?.emit(new ToggleStructureEvent([unitType]));
|
|
}
|
|
}}
|
|
@mouseleave=${() =>
|
|
this.eventBus?.emit(new ToggleStructureEvent(null))}
|
|
>
|
|
${html`<div class="ml-0.5 text-[10px] relative -top-1 text-gray-400">
|
|
${displayHotkey}
|
|
</div>`}
|
|
<div class="flex items-center gap-0.5 pt-0.5">
|
|
<img src=${icon} alt=${structureKey} class="align-middle size-5" />
|
|
${number !== null
|
|
? html`<span class="text-xs">${renderNumber(number)}</span>`
|
|
: null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|