mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
mobile control panel (#3096)
Relates to #2260 ## Description: Redo the control panel to be more mobile friendly and take up less space ![Uploading Screenshot 2026-02-02 at 8.09.13 PM.png…]() <img width="584" height="236" alt="Screenshot 2026-02-02 at 8 09 34 PM" src="https://github.com/user-attachments/assets/d48906d5-3653-499c-9b08-b661d5e7d4a4" /> Describe 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: evan
This commit is contained in:
+5
-5
@@ -239,17 +239,17 @@
|
||||
<div id="app"></div>
|
||||
|
||||
<div
|
||||
class="left-0 bottom-0 sm:left-4 sm:bottom-4 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px] fixed pointer-events-none"
|
||||
class="fixed left-0 bottom-0 min-[1200px]:left-4 min-[1200px]:bottom-4 w-full flex flex-col sm:flex-row sm:items-end z-50 pointer-events-none"
|
||||
>
|
||||
<div class="order-2 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto">
|
||||
<control-panel></control-panel>
|
||||
</div>
|
||||
<div
|
||||
class="w-full md:w-2/3 md:fixed sm:right-0 md:bottom-0 md:flex flex-col items-end pointer-events-none"
|
||||
class="order-1 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 flex flex-col sm:items-end pointer-events-none"
|
||||
>
|
||||
<chat-display></chat-display>
|
||||
<events-display></events-display>
|
||||
</div>
|
||||
<div>
|
||||
<control-panel></control-panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game modals and overlays -->
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Gold } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
@@ -9,6 +8,9 @@ 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 {
|
||||
@@ -35,6 +37,12 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private _gold: Gold;
|
||||
|
||||
@state()
|
||||
private _attackingTroops: number = 0;
|
||||
|
||||
@state()
|
||||
private _touchDragging = false;
|
||||
|
||||
private _troopRateIsIncreasing: boolean = true;
|
||||
|
||||
private _lastTroopIncreaseRate: number;
|
||||
@@ -49,12 +57,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
);
|
||||
this.uiState.attackRatio = this.attackRatio;
|
||||
this.eventBus.on(AttackRatioEvent, (event) => {
|
||||
let newAttackRatio =
|
||||
(parseInt(
|
||||
(document.getElementById("attack-ratio") as HTMLInputElement).value,
|
||||
) +
|
||||
event.attackRatio) /
|
||||
100;
|
||||
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
|
||||
|
||||
if (newAttackRatio < 0.01) {
|
||||
newAttackRatio = 0.01;
|
||||
@@ -90,6 +93,10 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
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();
|
||||
}
|
||||
@@ -120,119 +127,255 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _outsideTouchHandler: ((ev: Event) => void) | null = null;
|
||||
|
||||
private handleAttackTouchStart(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this._touchDragging) {
|
||||
this.closeAttackBar();
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchDragging = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this._outsideTouchHandler = () => {
|
||||
this.closeAttackBar();
|
||||
};
|
||||
document.addEventListener("touchstart", this._outsideTouchHandler);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private closeAttackBar() {
|
||||
this._touchDragging = false;
|
||||
if (this._outsideTouchHandler) {
|
||||
document.removeEventListener("touchstart", this._outsideTouchHandler);
|
||||
this._outsideTouchHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBarTouch(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.setRatioFromTouch(e.touches[0]);
|
||||
|
||||
const onMove = (ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
this.setRatioFromTouch(ev.touches[0]);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", onMove, { passive: false });
|
||||
document.addEventListener("touchend", onEnd);
|
||||
}
|
||||
|
||||
private setRatioFromTouch(touch: Touch) {
|
||||
const barEl = this.querySelector(".attack-drag-bar");
|
||||
if (!barEl) return;
|
||||
|
||||
const rect = barEl.getBoundingClientRect();
|
||||
const ratio = (rect.bottom - touch.clientY) / (rect.bottom - rect.top);
|
||||
this.attackRatio =
|
||||
Math.round(Math.max(1, Math.min(100, ratio * 100))) / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private handleRatioSliderInput(e: Event) {
|
||||
const value = Number((e.target as HTMLInputElement).value);
|
||||
this.attackRatio = value / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private renderTroopBar() {
|
||||
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 html`
|
||||
<div
|
||||
class="w-full h-6 lg:h-8 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 lg:px-2 text-xs lg:text-sm 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="lg:w-4 lg:h-4 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
/>
|
||||
<span
|
||||
class="text-[10px] lg:text-xs 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>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.targetTroopRatio::-webkit-slider-thumb {
|
||||
border-color: rgb(59 130 246);
|
||||
}
|
||||
.targetTroopRatio::-moz-range-thumb {
|
||||
border-color: rgb(59 130 246);
|
||||
}
|
||||
.attackRatio::-webkit-slider-thumb {
|
||||
border-color: rgb(239 68 68);
|
||||
}
|
||||
.attackRatio::-moz-range-thumb {
|
||||
border-color: rgb(239 68 68);
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="pointer-events-auto ${this._isVisible
|
||||
? "w-full sm:max-w-[320px] text-sm sm:text-base bg-gray-800/70 p-2 pr-3 sm:p-4 shadow-lg sm:rounded-lg backdrop-blur-sm"
|
||||
? "relative z-[60] w-full max-lg:landscape:fixed max-lg:landscape:bottom-0 max-lg:landscape:left-0 max-lg:landscape:w-1/2 max-lg:landscape:z-50 lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg lg:rounded-xl backdrop-blur-sm"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div class="block bg-black/30 text-white mb-4 p-2 rounded-sm">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.troops")}:</span
|
||||
>
|
||||
<span translate="no"
|
||||
>${renderTroops(this._troops)} / ${renderTroops(this._maxTroops)}
|
||||
<span
|
||||
class="${this._troopRateIsIncreasing
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"}"
|
||||
<div class="flex gap-2 lg:gap-3 items-center">
|
||||
<!-- Gold: 1/4 -->
|
||||
<div
|
||||
class="flex items-center justify-center p-1 lg:p-1.5 lg:gap-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs lg:text-sm w-1/5 lg:w-auto shrink-0"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${goldCoinIcon}
|
||||
width="13"
|
||||
height="13"
|
||||
class="lg:w-4 lg:h-4"
|
||||
/>
|
||||
<span class="px-0.5">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
<!-- Troop bar: 2/4 -->
|
||||
<div class="w-3/5 lg:flex-1">${this.renderTroopBar()}</div>
|
||||
<!-- Attack ratio: 1/4 -->
|
||||
<div
|
||||
class="relative w-1/5 shrink-0 flex items-center justify-center gap-1 cursor-pointer lg:hidden"
|
||||
@touchstart=${(e: TouchEvent) => this.handleAttackTouchStart(e)}
|
||||
>
|
||||
<div class="flex flex-col items-center w-10 shrink-0">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-white text-xs font-bold tabular-nums"
|
||||
translate="no"
|
||||
>(+${renderTroops(this.troopRate)})</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.gold")}:</span
|
||||
>
|
||||
<span translate="no">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mb-0 sm:mb-4">
|
||||
<label class="block text-white mb-1">
|
||||
${translateText("control_panel.attack_ratio")} :
|
||||
<span
|
||||
class="inline-flex items-center gap-1 [unicode-bidi:isolate]"
|
||||
dir="ltr"
|
||||
translate="no"
|
||||
>
|
||||
<span>${(this.attackRatio * 100).toFixed(0)}%</span>
|
||||
<span>
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="10"
|
||||
height="10"
|
||||
class="brightness-0 invert sepia saturate-[10000%] hue-rotate-[0deg]"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>
|
||||
${(this.attackRatio * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div class="text-[10px] text-red-400 tabular-nums" translate="no">
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative h-8">
|
||||
<!-- Background track -->
|
||||
<div
|
||||
class="absolute left-0 right-0 top-3 h-2 bg-white/20 rounded-sm"
|
||||
></div>
|
||||
<!-- Fill track -->
|
||||
<div
|
||||
class="absolute left-0 top-3 h-2 bg-red-500/60 rounded-sm transition-all duration-300 w-(--width)"
|
||||
style="--width: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
<!-- Range input - exactly overlaying the visual elements -->
|
||||
<input
|
||||
id="attack-ratio"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${(this.attackRatio * 100).toString()}
|
||||
@input=${(e: Event) => {
|
||||
this.attackRatio =
|
||||
parseInt((e.target as HTMLInputElement).value) / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}}
|
||||
class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer attackRatio"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Small red vertical bar indicator -->
|
||||
<div class="relative shrink-0">
|
||||
<div
|
||||
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full transition-all duration-200"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
${this._touchDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-lg p-2 w-12"
|
||||
style="height: 50vh;"
|
||||
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
|
||||
>
|
||||
<span
|
||||
class="text-red-400 text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
<div
|
||||
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attack ratio bar (desktop, always visible) -->
|
||||
<div class="hidden lg:block mt-2">
|
||||
<div
|
||||
class="flex items-center justify-between text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white flex items-center gap-1"
|
||||
><img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>Attack Ratio</span
|
||||
>
|
||||
<span class="text-white tabular-nums"
|
||||
>${(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="w-full h-2 accent-red-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1013,7 +1013,9 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<!-- Events Toggle (when hidden) -->
|
||||
${this._hidden
|
||||
? html`
|
||||
<div class="relative w-fit lg:bottom-4 lg:right-4 z-50">
|
||||
<div
|
||||
class="relative w-fit min-[1200px]:bottom-4 min-[1200px]:right-4 z-50"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
Events
|
||||
@@ -1033,7 +1035,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur-sm"
|
||||
class="relative w-full min-[1200px]:bottom-4 min-[1200px]:right-4 z-50 min-[1200px]:w-96 backdrop-blur-sm"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
|
||||
|
||||
Reference in New Issue
Block a user