mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 15:14:16 +00:00
1d1b076672
## Description: Resolves #3285. As discussed on Discord. However, in at least one instance "Tribes" feels a bit off: in Humans vs Nations, team "Tribes" feels as human too while they are just bots. This PR changes Bots to Tribes outwardly by - Changing default EN translation. - Changing (untranslated) alt text in PlayerPanel. - To change "Team Bot" into "Team Tribes" too in PlayerInfoOverlay and TeamStats (team leaderboard in-game), translate team names in there from now on too. - This way we also fix a bug where team names were not translated yet in there. To add to that fix, also translate team names in LobbyPlayerView in the same way. For this we re-use the existing getTranslatedPlayerTeamLabel function from GameLeftSideBar by moving it to Utils. - No translation key was present yet for Humans and Nations teams, so added those to now be used in PlayerInfoOverlay, LobbyPlayerView and TeamStats for completeness. - No internal code changes so nothing breaks. **BEFORE (showing old team name Bot and also that team names weren't translated yet in TeamStats)**   **AFTER** (translations in Dutch only shown as proof here, did not include nl.json in the PR)      ## 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: tryout33
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import { LitElement, TemplateResult, html } from "lit";
|
|
import { ref } from "lit-html/directives/ref.js";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { renderPlayerFlag } from "../../../core/CustomFlag";
|
|
import { EventBus } from "../../../core/EventBus";
|
|
import {
|
|
PlayerProfile,
|
|
PlayerType,
|
|
Relation,
|
|
Unit,
|
|
UnitType,
|
|
} from "../../../core/game/Game";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { AllianceView } from "../../../core/game/GameUpdates";
|
|
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
|
|
import {
|
|
ContextMenuEvent,
|
|
MouseMoveEvent,
|
|
TouchEvent,
|
|
} from "../../InputHandler";
|
|
import {
|
|
getTranslatedPlayerTeamLabel,
|
|
renderDuration,
|
|
renderNumber,
|
|
renderTroops,
|
|
translateText,
|
|
} from "../../Utils";
|
|
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
|
import { TransformHandler } from "../TransformHandler";
|
|
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
|
|
import { Layer } from "./Layer";
|
|
import { CloseRadialMenuEvent } from "./RadialMenu";
|
|
import { SpawnBarVisibleEvent } from "./SpawnTimer";
|
|
import allianceIcon from "/images/AllianceIcon.svg?url";
|
|
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 missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
|
|
import portIcon from "/images/PortIcon.svg?url";
|
|
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
|
|
import soldierIcon from "/images/SoldierIcon.svg?url";
|
|
|
|
function euclideanDistWorld(
|
|
coord: { x: number; y: number },
|
|
tileRef: TileRef,
|
|
game: GameView,
|
|
): number {
|
|
const x = game.x(tileRef);
|
|
const y = game.y(tileRef);
|
|
const dx = coord.x - x;
|
|
const dy = coord.y - y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) {
|
|
return (a: Unit | UnitView, b: Unit | UnitView) => {
|
|
const distA = euclideanDistWorld(coord, a.tile(), game);
|
|
const distB = euclideanDistWorld(coord, b.tile(), game);
|
|
return distA - distB;
|
|
};
|
|
}
|
|
|
|
@customElement("player-info-overlay")
|
|
export class PlayerInfoOverlay extends LitElement implements Layer {
|
|
@property({ type: Object })
|
|
public game!: GameView;
|
|
|
|
@property({ type: Object })
|
|
public eventBus!: EventBus;
|
|
|
|
@property({ type: Object })
|
|
public transform!: TransformHandler;
|
|
|
|
@state()
|
|
private player: PlayerView | null = null;
|
|
|
|
@state()
|
|
private playerProfile: PlayerProfile | null = null;
|
|
|
|
@state()
|
|
private unit: UnitView | null = null;
|
|
|
|
@state()
|
|
private _isInfoVisible: boolean = false;
|
|
|
|
@state()
|
|
private spawnBarVisible = false;
|
|
@state()
|
|
private immunityBarVisible = false;
|
|
|
|
private _isActive = false;
|
|
|
|
private get barOffset(): number {
|
|
return (this.spawnBarVisible ? 7 : 0) + (this.immunityBarVisible ? 7 : 0);
|
|
}
|
|
|
|
private lastMouseUpdate = 0;
|
|
|
|
init() {
|
|
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
|
|
this.onMouseEvent(e),
|
|
);
|
|
this.eventBus.on(ContextMenuEvent, (e: ContextMenuEvent) =>
|
|
this.maybeShow(e.x, e.y),
|
|
);
|
|
this.eventBus.on(TouchEvent, (e: TouchEvent) => this.maybeShow(e.x, e.y));
|
|
this.eventBus.on(CloseRadialMenuEvent, () => this.hide());
|
|
this.eventBus.on(SpawnBarVisibleEvent, (e) => {
|
|
this.spawnBarVisible = e.visible;
|
|
});
|
|
this.eventBus.on(ImmunityBarVisibleEvent, (e) => {
|
|
this.immunityBarVisible = e.visible;
|
|
});
|
|
this._isActive = true;
|
|
}
|
|
|
|
private onMouseEvent(event: MouseMoveEvent) {
|
|
const now = Date.now();
|
|
if (now - this.lastMouseUpdate < 100) {
|
|
return;
|
|
}
|
|
this.lastMouseUpdate = now;
|
|
this.maybeShow(event.x, event.y);
|
|
}
|
|
|
|
public hide() {
|
|
this.setVisible(false);
|
|
this.unit = null;
|
|
this.player = null;
|
|
}
|
|
|
|
public maybeShow(x: number, y: number) {
|
|
this.hide();
|
|
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
|
|
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
|
|
return;
|
|
}
|
|
|
|
const tile = this.game.ref(worldCoord.x, worldCoord.y);
|
|
if (!tile) return;
|
|
|
|
const owner = this.game.owner(tile);
|
|
|
|
if (owner && owner.isPlayer()) {
|
|
this.player = owner as PlayerView;
|
|
this.player.profile().then((p) => {
|
|
this.playerProfile = p;
|
|
});
|
|
this.setVisible(true);
|
|
} else if (!this.game.isLand(tile)) {
|
|
const units = this.game
|
|
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
|
|
.filter((u) => euclideanDistWorld(worldCoord, u.tile(), this.game) < 50)
|
|
.sort(distSortUnitWorld(worldCoord, this.game));
|
|
|
|
if (units.length > 0) {
|
|
this.unit = units[0];
|
|
this.setVisible(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
tick() {
|
|
this.requestUpdate();
|
|
}
|
|
|
|
renderLayer(context: CanvasRenderingContext2D) {
|
|
// Implementation for Layer interface
|
|
}
|
|
|
|
shouldTransform(): boolean {
|
|
return false;
|
|
}
|
|
|
|
setVisible(visible: boolean) {
|
|
this._isInfoVisible = visible;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
private getPlayerNameColor(
|
|
player: PlayerView,
|
|
myPlayer: PlayerView | null | undefined,
|
|
isFriendly: boolean,
|
|
): string {
|
|
if (isFriendly) return "text-green-500";
|
|
if (
|
|
myPlayer &&
|
|
myPlayer !== player &&
|
|
player.type() === PlayerType.Nation
|
|
) {
|
|
const relation =
|
|
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
|
|
return this.getRelationClass(relation);
|
|
}
|
|
return "text-white";
|
|
}
|
|
|
|
private getRelationClass(relation: Relation): string {
|
|
switch (relation) {
|
|
case Relation.Hostile:
|
|
return "text-red-500";
|
|
case Relation.Distrustful:
|
|
return "text-red-300";
|
|
case Relation.Neutral:
|
|
return "text-white";
|
|
case Relation.Friendly:
|
|
return "text-green-500";
|
|
default:
|
|
return "text-white";
|
|
}
|
|
}
|
|
|
|
private getRelationName(relation: Relation): string {
|
|
switch (relation) {
|
|
case Relation.Hostile:
|
|
return translateText("relation.hostile");
|
|
case Relation.Distrustful:
|
|
return translateText("relation.distrustful");
|
|
case Relation.Neutral:
|
|
return translateText("relation.neutral");
|
|
case Relation.Friendly:
|
|
return translateText("relation.friendly");
|
|
default:
|
|
return translateText("relation.default");
|
|
}
|
|
}
|
|
|
|
private displayUnitCount(player: PlayerView, type: UnitType, icon: string) {
|
|
return !this.game.config().isUnitDisabled(type)
|
|
? html`<div
|
|
class="flex items-center justify-center gap-0.5 lg:gap-1 p-0.5 lg:p-1 border rounded-md border-gray-500 text-[10px] lg:text-xs w-9 lg:w-12 h-6 lg:h-7"
|
|
translate="no"
|
|
>
|
|
<img
|
|
src=${icon}
|
|
class="w-3 h-3 lg:w-4 lg:h-4 object-contain shrink-0"
|
|
/>
|
|
<span>${player.totalUnitLevels(type)}</span>
|
|
</div>`
|
|
: "";
|
|
}
|
|
|
|
private allianceExpirationText(alliance: AllianceView) {
|
|
const { expiresAt } = alliance;
|
|
const remainingTicks = expiresAt - this.game.ticks();
|
|
let remainingSeconds = 0;
|
|
if (remainingTicks > 0) {
|
|
remainingSeconds = Math.max(0, Math.floor(remainingTicks / 10)); // 10 ticks per second
|
|
}
|
|
return renderDuration(remainingSeconds);
|
|
}
|
|
|
|
private renderPlayerNameIcons(player: PlayerView) {
|
|
const firstPlace = getFirstPlacePlayer(this.game);
|
|
const icons = getPlayerIcons({
|
|
game: this.game,
|
|
player,
|
|
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
|
|
includeAllianceIcon: false,
|
|
firstPlace,
|
|
});
|
|
|
|
if (icons.length === 0) {
|
|
return html``;
|
|
}
|
|
|
|
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
|
|
${icons.map((icon) =>
|
|
icon.kind === "emoji" && icon.text
|
|
? html`<span class="text-sm shrink-0" translate="no"
|
|
>${icon.text}</span
|
|
>`
|
|
: icon.kind === "image" && icon.src
|
|
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
|
|
: html``,
|
|
)}
|
|
</span>`;
|
|
}
|
|
|
|
private renderPlayerInfo(player: PlayerView) {
|
|
const myPlayer = this.game.myPlayer();
|
|
const isFriendly = myPlayer?.isFriendly(player);
|
|
const isAllied = myPlayer?.isAlliedWith(player);
|
|
let allianceHtml: TemplateResult | null = null;
|
|
const maxTroops = this.game.config().maxTroops(player);
|
|
const attackingTroops = player
|
|
.outgoingAttacks()
|
|
.map((a) => a.troops)
|
|
.reduce((a, b) => a + b, 0);
|
|
const totalTroops = player.troops();
|
|
|
|
if (isAllied) {
|
|
const alliance = myPlayer
|
|
?.alliances()
|
|
.find((alliance) => alliance.other === player.id());
|
|
if (alliance !== undefined) {
|
|
allianceHtml = html` <div
|
|
class="flex items-center ml-auto mr-0 gap-1 text-sm font-bold leading-tight"
|
|
>
|
|
<img src=${allianceIcon} width="20" height="20" />
|
|
${this.allianceExpirationText(alliance)}
|
|
</div>`;
|
|
}
|
|
}
|
|
let playerType = "";
|
|
switch (player.type()) {
|
|
case PlayerType.Bot:
|
|
playerType = translateText("player_type.bot");
|
|
break;
|
|
case PlayerType.Nation:
|
|
playerType = translateText("player_type.nation");
|
|
break;
|
|
case PlayerType.Human:
|
|
playerType = translateText("player_type.player");
|
|
break;
|
|
}
|
|
const playerTeam = getTranslatedPlayerTeamLabel(player.team());
|
|
|
|
return html`
|
|
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
|
|
<!-- Left: Gold & Troop bar -->
|
|
<div class="flex flex-col gap-1 shrink-0 w-28">
|
|
<div
|
|
class="flex items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-28 lg:gap-1"
|
|
translate="no"
|
|
>
|
|
<img src=${goldCoinIcon} width="13" height="13" />
|
|
<span class="px-0.5">${renderNumber(player.gold())}</span>
|
|
</div>
|
|
<div class="w-28" translate="no">
|
|
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
|
|
</div>
|
|
</div>
|
|
<!-- Right: Player identity + Units below -->
|
|
<div class="flex flex-col justify-between self-stretch">
|
|
<div
|
|
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${this.getPlayerNameColor(
|
|
player,
|
|
myPlayer,
|
|
isFriendly ?? false,
|
|
)}"
|
|
>
|
|
${player.cosmetics.flag
|
|
? player.cosmetics.flag!.startsWith("!")
|
|
? html`<div
|
|
class="h-6 aspect-3/4 player-flag"
|
|
${ref((el) => {
|
|
if (el instanceof HTMLElement) {
|
|
requestAnimationFrame(() => {
|
|
renderPlayerFlag(player.cosmetics.flag!, el);
|
|
});
|
|
}
|
|
})}
|
|
></div>`
|
|
: html`<img
|
|
class="h-6 aspect-3/4"
|
|
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
|
|
/>`
|
|
: html``}
|
|
<span>${player.name()}</span>
|
|
${playerTeam !== "" && player.type() !== PlayerType.Bot
|
|
? html`<div class="flex flex-col leading-tight">
|
|
<span class="text-gray-400 text-xs font-normal"
|
|
>${playerType}</span
|
|
>
|
|
<span class="text-xs font-normal text-gray-400"
|
|
>[<span
|
|
style="color: ${this.game
|
|
.config()
|
|
.theme()
|
|
.teamColor(player.team()!)
|
|
.toHex()}"
|
|
>${playerTeam}</span
|
|
>]</span
|
|
>
|
|
</div>`
|
|
: html`<span class="text-gray-400 text-xs font-normal"
|
|
>${playerType}</span
|
|
>`}
|
|
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
|
|
</div>
|
|
<div class="flex gap-0.5 lg:gap-1 items-center mt-1">
|
|
${this.displayUnitCount(player, UnitType.City, cityIcon)}
|
|
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
|
|
${this.displayUnitCount(player, UnitType.Port, portIcon)}
|
|
${this.displayUnitCount(
|
|
player,
|
|
UnitType.MissileSilo,
|
|
missileSiloIcon,
|
|
)}
|
|
${this.displayUnitCount(
|
|
player,
|
|
UnitType.SAMLauncher,
|
|
samLauncherIcon,
|
|
)}
|
|
${this.displayUnitCount(player, UnitType.Warship, warshipIcon)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderTroopBar(
|
|
totalTroops: number,
|
|
attackingTroops: number,
|
|
maxTroops: number,
|
|
) {
|
|
const base = Math.max(maxTroops, 1);
|
|
const greenPercentRaw = (totalTroops / base) * 100;
|
|
const orangePercentRaw = (attackingTroops / base) * 100;
|
|
|
|
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
|
|
const orangePercent = Math.max(
|
|
0,
|
|
Math.min(100 - greenPercent, orangePercentRaw),
|
|
);
|
|
|
|
return html`
|
|
<div
|
|
class="w-full mt-1 lg:mt-2 h-5 lg: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(totalTroops)}</span
|
|
>
|
|
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
|
>${renderTroops(maxTroops)}</span
|
|
>
|
|
</div>
|
|
<img
|
|
src=${soldierIcon}
|
|
alt=""
|
|
aria-hidden="true"
|
|
width="14"
|
|
height="14"
|
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] pointer-events-none"
|
|
/>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderUnitInfo(unit: UnitView) {
|
|
const isAlly =
|
|
(unit.owner() === this.game.myPlayer() ||
|
|
this.game.myPlayer()?.isFriendly(unit.owner())) ??
|
|
false;
|
|
|
|
return html`
|
|
<div class="p-2">
|
|
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
|
|
${unit.owner().name()}
|
|
</div>
|
|
<div class="mt-1">
|
|
<div class="text-sm opacity-80">${unit.type()}</div>
|
|
${unit.hasHealth()
|
|
? html` <div class="text-sm">Health: ${unit.health()}</div> `
|
|
: ""}
|
|
${unit.type() === UnitType.TransportShip
|
|
? html`
|
|
<div class="text-sm">
|
|
Troops: ${renderTroops(unit.troops())}
|
|
</div>
|
|
`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
if (!this._isActive) {
|
|
return html``;
|
|
}
|
|
|
|
const containerClasses = this._isInfoVisible
|
|
? "opacity-100 visible"
|
|
: "opacity-0 invisible pointer-events-none";
|
|
|
|
return html`
|
|
<div
|
|
class="fixed top-0 min-[1200px]:top-4 left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
|
style="margin-top: ${this.barOffset}px;"
|
|
@click=${() => this.hide()}
|
|
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
|
>
|
|
<div
|
|
class="bg-gray-800/70 backdrop-blur-xs shadow-xs min-[1200px]:rounded-lg sm:rounded-b-lg shadow-lg text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
|
|
>
|
|
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
|
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
createRenderRoot() {
|
|
return this; // Disable shadow DOM to allow Tailwind styles
|
|
}
|
|
}
|