diff --git a/index.html b/index.html
index 5db9deb743..27264b3469 100644
--- a/index.html
+++ b/index.html
@@ -267,39 +267,42 @@
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 1f1f0674ad..32c4a98d42 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -1013,5 +1013,10 @@
"description": "(ALPHA)",
"login_required": "Login to play ranked!",
"must_login": "You must be logged in to play ranked matchmaking."
+ },
+ "draggable_panel": {
+ "unlock_to_move": "Unlock to move",
+ "lock_position": "Lock position",
+ "reset_position": "Reset position"
}
}
diff --git a/src/client/graphics/DraggableController.ts b/src/client/graphics/DraggableController.ts
new file mode 100644
index 0000000000..b84d02e845
--- /dev/null
+++ b/src/client/graphics/DraggableController.ts
@@ -0,0 +1,350 @@
+import { DraggableManager } from "./DraggableManager";
+
+/**
+ * Reusable drag controller that can make any HTMLElement draggable.
+ * Persists position and lock state to localStorage.
+ * Registers with DraggableManager for collision detection.
+ */
+export class DraggableController {
+ private _locked = false;
+ private _offsetX = 0;
+ private _offsetY = 0;
+ private _dragging = false;
+ private _pointerId: number | null = null;
+ private _startMouseX = 0;
+ private _startMouseY = 0;
+ private _startOffsetX = 0;
+ private _startOffsetY = 0;
+ private _committed = false;
+ private _naturalRect: DOMRect | null = null;
+ private _obstacleRects: DOMRect[] = [];
+ private _isContentsDisplay = false;
+ private _savedZIndex = "";
+ private static readonly DRAG_THRESHOLD = 4;
+
+ private readonly storageKey: string;
+ private readonly el: HTMLElement;
+ private _onMoved: (() => void) | null = null;
+ private _onResize: (() => void) | null = null;
+
+ private onPointerDown = (e: PointerEvent) => this.handlePointerDown(e);
+ private onPointerMove = (e: PointerEvent) => this.handlePointerMove(e);
+ private onPointerUp = () => this.handlePointerUp();
+
+ constructor(el: HTMLElement, storageKey: string) {
+ this.el = el;
+ this.storageKey = `draggable.${storageKey}`;
+ this.load();
+ }
+
+ set onMoved(cb: (() => void) | null) {
+ this._onMoved = cb;
+ }
+
+ set onResize(cb: (() => void) | null) {
+ this._onResize = cb;
+ }
+
+ /** Called by DraggableManager when the element resizes. */
+ notifyResize(): void {
+ this._onResize?.();
+ }
+
+ /** True when the panel's center is in the right half of the viewport. */
+ isOnRightSide(): boolean {
+ const rect = this.el.getBoundingClientRect();
+ return (rect.left + rect.right) / 2 > window.innerWidth / 2;
+ }
+
+ get locked(): boolean {
+ return this._locked;
+ }
+
+ set locked(v: boolean) {
+ this._locked = v;
+ this.save();
+ }
+
+ getElement(): HTMLElement {
+ return this.el;
+ }
+
+ /** Apply the current offset as a CSS transform on the element. */
+ applyTransform(): void {
+ // transform has no effect on display:contents elements
+ if (this._isContentsDisplay) return;
+
+ if (this._offsetX === 0 && this._offsetY === 0) {
+ this.el.style.transform = "";
+ } else {
+ this.el.style.transform = `translate(${this._offsetX}px, ${this._offsetY}px)`;
+ }
+ }
+
+ /** Start listening for drag events. */
+ attach(): void {
+ this._isContentsDisplay = getComputedStyle(this.el).display === "contents";
+ this.el.addEventListener("pointerdown", this.onPointerDown);
+ this.applyTransform();
+ DraggableManager.instance.register(this);
+ // Defer clamp until the element has its final layout
+ requestAnimationFrame(() => this.clampToViewport());
+ }
+
+ /** Stop listening for drag events and clear the inline transform. */
+ detach(): void {
+ DraggableManager.instance.unregister(this);
+ this.el.removeEventListener("pointerdown", this.onPointerDown);
+ this.el.removeEventListener("pointermove", this.onPointerMove);
+ this.el.removeEventListener("pointerup", this.onPointerUp);
+ this.el.removeEventListener("lostpointercapture", this.onPointerUp);
+ this.el.style.transform = "";
+ }
+
+ resetPosition(): void {
+ this._offsetX = 0;
+ this._offsetY = 0;
+ this.applyTransform();
+ this.save();
+ }
+
+ /** Public entry point for the manager to re-clamp after window resize. */
+ clampAndApply(): void {
+ this.clampToViewport();
+ }
+
+ /** Push this panel away from any overlapping panels, then clamp to viewport. */
+ resolveOverlaps(): void {
+ if (this._dragging) return;
+ const rect = this.el.getBoundingClientRect();
+ if (rect.width === 0 && rect.height === 0) return;
+
+ const nr = new DOMRect(
+ rect.x - this._offsetX,
+ rect.y - this._offsetY,
+ rect.width,
+ rect.height,
+ );
+ this._obstacleRects = DraggableManager.instance.snapshotObstacles(this);
+ const prevX = this._offsetX;
+ const prevY = this._offsetY;
+
+ this.resolveCollisions(nr);
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ this._offsetX = Math.max(-nr.left, Math.min(vw - nr.right, this._offsetX));
+ this._offsetY = Math.max(-nr.top, Math.min(vh - nr.bottom, this._offsetY));
+
+ // If overlaps remain (obstacle at viewport edge), revert
+ if (this.hasAnyOverlap(nr)) {
+ this._offsetX = prevX;
+ this._offsetY = prevY;
+ }
+
+ if (this._offsetX !== prevX || this._offsetY !== prevY) {
+ this.applyTransform();
+ this.save();
+ }
+ }
+
+ private handlePointerDown(e: PointerEvent): void {
+ if (this._locked || e.button !== 0) return;
+ const target = e.target as HTMLElement;
+ if (target.closest("button, input, select, textarea, a, [data-no-drag]")) {
+ return;
+ }
+
+ e.preventDefault();
+ this.el.setPointerCapture(e.pointerId);
+ this._pointerId = e.pointerId;
+ this._dragging = true;
+ this._committed = false;
+ this._startMouseX = e.clientX;
+ this._startMouseY = e.clientY;
+ this._startOffsetX = this._offsetX;
+ this._startOffsetY = this._offsetY;
+
+ this.el.addEventListener("pointermove", this.onPointerMove);
+ this.el.addEventListener("pointerup", this.onPointerUp);
+ this.el.addEventListener("lostpointercapture", this.onPointerUp);
+ }
+
+ private handlePointerMove(e: PointerEvent): void {
+ if (!this._dragging) return;
+
+ // Require minimum movement before committing to a drag
+ if (!this._committed) {
+ const dx = e.clientX - this._startMouseX;
+ const dy = e.clientY - this._startMouseY;
+ if (
+ dx * dx + dy * dy <
+ DraggableController.DRAG_THRESHOLD * DraggableController.DRAG_THRESHOLD
+ ) {
+ return;
+ }
+ this._committed = true;
+ // Cache the element's natural position (without transform offset)
+ const rect = this.el.getBoundingClientRect();
+ this._naturalRect = new DOMRect(
+ rect.x - this._offsetX,
+ rect.y - this._offsetY,
+ rect.width,
+ rect.height,
+ );
+ // Snapshot obstacle rects and boost z-index for the drag
+ this._obstacleRects = DraggableManager.instance.snapshotObstacles(this);
+ this._savedZIndex = this.el.style.zIndex;
+ this.el.style.zIndex = "10000";
+ }
+
+ if (!this._naturalRect) return;
+ const nr = this._naturalRect;
+ const prevX = this._offsetX;
+ const prevY = this._offsetY;
+
+ this._offsetX = this._startOffsetX + (e.clientX - this._startMouseX);
+ this._offsetY = this._startOffsetY + (e.clientY - this._startMouseY);
+
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ // Viewport clamp → collision resolution → re-clamp
+ this._offsetX = Math.max(-nr.left, Math.min(vw - nr.right, this._offsetX));
+ this._offsetY = Math.max(-nr.top, Math.min(vh - nr.bottom, this._offsetY));
+ this.resolveCollisions(nr);
+ this._offsetX = Math.max(-nr.left, Math.min(vw - nr.right, this._offsetX));
+ this._offsetY = Math.max(-nr.top, Math.min(vh - nr.bottom, this._offsetY));
+
+ // If overlaps remain (obstacle at viewport edge), revert
+ if (this.hasAnyOverlap(nr)) {
+ this._offsetX = prevX;
+ this._offsetY = prevY;
+ }
+
+ this.applyTransform();
+ }
+
+ /**
+ * AABB collision: for each obstacle, if the candidate rect overlaps,
+ * push out on the axis with the smallest penetration (slide on the other).
+ */
+ private resolveCollisions(nr: DOMRect): void {
+ for (const obs of this._obstacleRects) {
+ const cl = nr.left + this._offsetX;
+ const ct = nr.top + this._offsetY;
+ const cr = nr.right + this._offsetX;
+ const cb = nr.bottom + this._offsetY;
+
+ if (
+ cr <= obs.left ||
+ cl >= obs.right ||
+ cb <= obs.top ||
+ ct >= obs.bottom
+ ) {
+ continue;
+ }
+
+ const overlapLeft = cr - obs.left;
+ const overlapRight = obs.right - cl;
+ const overlapTop = cb - obs.top;
+ const overlapBottom = obs.bottom - ct;
+
+ if (
+ Math.min(overlapLeft, overlapRight) <
+ Math.min(overlapTop, overlapBottom)
+ ) {
+ this._offsetX +=
+ overlapLeft < overlapRight ? -overlapLeft : overlapRight;
+ } else {
+ this._offsetY +=
+ overlapTop < overlapBottom ? -overlapTop : overlapBottom;
+ }
+ }
+ }
+
+ private hasAnyOverlap(nr: DOMRect): boolean {
+ const cl = nr.left + this._offsetX;
+ const ct = nr.top + this._offsetY;
+ const cr = nr.right + this._offsetX;
+ const cb = nr.bottom + this._offsetY;
+ for (const obs of this._obstacleRects) {
+ if (cr > obs.left && cl < obs.right && cb > obs.top && ct < obs.bottom) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private handlePointerUp(): void {
+ if (!this._dragging) return;
+ const didMove = this._committed;
+ this._dragging = false;
+ this._committed = false;
+ if (didMove) {
+ this.el.style.zIndex = this._savedZIndex;
+ }
+ if (this._pointerId !== null) {
+ try {
+ this.el.releasePointerCapture(this._pointerId);
+ } catch {
+ // already released
+ }
+ this._pointerId = null;
+ }
+ this.el.removeEventListener("pointermove", this.onPointerMove);
+ this.el.removeEventListener("pointerup", this.onPointerUp);
+ this.el.removeEventListener("lostpointercapture", this.onPointerUp);
+ if (didMove) {
+ this.save();
+ this._onMoved?.();
+ }
+ }
+
+ /** Clamp restored offsets so the element stays fully within the viewport. */
+ private clampToViewport(): void {
+ if (this._offsetX === 0 && this._offsetY === 0) return;
+ const rect = this.el.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const prevX = this._offsetX;
+ const prevY = this._offsetY;
+
+ if (rect.left < 0) this._offsetX -= rect.left;
+ else if (rect.right > vw) this._offsetX -= rect.right - vw;
+
+ if (rect.top < 0) this._offsetY -= rect.top;
+ else if (rect.bottom > vh) this._offsetY -= rect.bottom - vh;
+
+ if (this._offsetX !== prevX || this._offsetY !== prevY) {
+ this.applyTransform();
+ this.save();
+ }
+ }
+
+ private save(): void {
+ try {
+ const data = {
+ locked: this._locked,
+ x: this._offsetX,
+ y: this._offsetY,
+ };
+ localStorage.setItem(this.storageKey, JSON.stringify(data));
+ } catch {
+ // Quota exceeded or storage unavailable — position won't persist
+ }
+ }
+
+ private load(): void {
+ try {
+ const raw = localStorage.getItem(this.storageKey);
+ if (raw) {
+ const data = JSON.parse(raw);
+ this._locked = data.locked ?? false;
+ this._offsetX = data.x ?? 0;
+ this._offsetY = data.y ?? 0;
+ }
+ } catch {
+ localStorage.removeItem(this.storageKey);
+ }
+ }
+}
diff --git a/src/client/graphics/DraggableManager.ts b/src/client/graphics/DraggableManager.ts
new file mode 100644
index 0000000000..240d282dd6
--- /dev/null
+++ b/src/client/graphics/DraggableManager.ts
@@ -0,0 +1,53 @@
+import { DraggableController } from "./DraggableController";
+
+const GAP = 4;
+
+export class DraggableManager {
+ private static _instance: DraggableManager | null = null;
+ private controllers = new Set
();
+ private resizeObserver = new ResizeObserver(() => this.onPanelResize());
+ private _resizeFrame = 0;
+
+ static get instance(): DraggableManager {
+ this._instance ??= new DraggableManager();
+ return this._instance;
+ }
+
+ register(ctrl: DraggableController): void {
+ this.controllers.add(ctrl);
+ this.resizeObserver.observe(ctrl.getElement());
+ }
+
+ unregister(ctrl: DraggableController): void {
+ this.controllers.delete(ctrl);
+ this.resizeObserver.unobserve(ctrl.getElement());
+ }
+
+ private onPanelResize(): void {
+ cancelAnimationFrame(this._resizeFrame);
+ this._resizeFrame = requestAnimationFrame(() => {
+ for (const ctrl of this.controllers) {
+ ctrl.resolveOverlaps();
+ ctrl.notifyResize();
+ }
+ });
+ }
+
+ snapshotObstacles(exclude: DraggableController): DOMRect[] {
+ const rects: DOMRect[] = [];
+ const g = GAP / 2;
+ for (const ctrl of this.controllers) {
+ if (ctrl === exclude) continue;
+ const r = ctrl.getElement().getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ rects.push(new DOMRect(r.x - g, r.y - g, r.width + GAP, r.height + GAP));
+ }
+ return rects;
+ }
+
+ reclampAll(): void {
+ for (const ctrl of this.controllers) {
+ ctrl.clampAndApply();
+ }
+ }
+}
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 1937b4a3e6..d8649b2be9 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
+import { DraggableManager } from "./DraggableManager";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
@@ -359,7 +360,10 @@ export class GameRenderer {
document.body.appendChild(this.canvas);
}
- window.addEventListener("resize", () => this.resizeCanvas());
+ window.addEventListener("resize", () => {
+ this.resizeCanvas();
+ DraggableManager.instance.reclampAll();
+ });
this.resizeCanvas();
//show whole map on startup
diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts
index 719272804f..372e506e53 100644
--- a/src/client/graphics/layers/ControlPanel.ts
+++ b/src/client/graphics/layers/ControlPanel.ts
@@ -7,6 +7,7 @@ import { ClientID } from "../../../core/Schemas";
import { AttackRatioEvent } from "../../InputHandler";
import { renderNumber, renderTroops } from "../../Utils";
import { UIState } from "../UIState";
+import "./DraggablePanel";
import { Layer } from "./Layer";
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
import soldierIcon from "/images/SoldierIcon.svg?url";
@@ -383,11 +384,15 @@ export class ControlPanel extends LitElement implements Layer {
render() {
return html`
e.preventDefault()}
>
+
${this.renderMobile()}
${this.renderDesktop()}
diff --git a/src/client/graphics/layers/DraggablePanel.ts b/src/client/graphics/layers/DraggablePanel.ts
new file mode 100644
index 0000000000..5346ed309b
--- /dev/null
+++ b/src/client/graphics/layers/DraggablePanel.ts
@@ -0,0 +1,165 @@
+import { html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { translateText } from "../../Utils";
+import { DraggableController } from "../DraggableController";
+
+/**
+ * Non-wrapper element: place inside a panel and it renders a lock/reset
+ * toolbar on the outside edge. Drag behaviour is applied to the nearest
+ * ancestor with a matching `data-draggable` attribute.
+ */
+@customElement("draggable-panel")
+export class DraggablePanel extends LitElement {
+ @property({ type: String }) key = "panel";
+ @property({ type: Boolean }) visible = true;
+
+ @state() private _locked = false;
+
+ private ctrl: DraggableController | null = null;
+ private _observer: MutationObserver | null = null;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ if (document.body.classList.contains("in-game")) {
+ this.initController();
+ } else {
+ this._observer = new MutationObserver(() => {
+ if (document.body.classList.contains("in-game")) {
+ this.initController();
+ this._observer?.disconnect();
+ this._observer = null;
+ }
+ });
+ this._observer.observe(document.body, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ }
+ }
+
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.ctrl?.detach();
+ this._observer?.disconnect();
+ }
+
+ private initController(): void {
+ if (this.ctrl) return;
+ const target = this.closest(`[data-draggable="${this.key}"]`);
+ if (!target) {
+ console.error(
+ `draggable-panel: no ancestor [data-draggable="${this.key}"] found`,
+ );
+ return;
+ }
+
+ this.ctrl = new DraggableController(target as HTMLElement, this.key);
+ this._locked = this.ctrl.locked;
+ this.ctrl.onMoved = () => this.requestUpdate();
+ this.ctrl.onResize = () => this.requestUpdate();
+ this.ctrl.attach();
+ this.requestUpdate();
+ }
+
+ private toggleLock(): void {
+ if (!this.ctrl) return;
+ this._locked = !this._locked;
+ this.ctrl.locked = this._locked;
+ this.requestUpdate();
+ }
+
+ private resetPosition(): void {
+ this.ctrl?.resetPosition();
+ this.requestUpdate();
+ }
+
+ render() {
+ if (!this.ctrl || !this.visible) return nothing;
+ if (this.ctrl.getElement().getBoundingClientRect().height < 10)
+ return nothing;
+ const right = !this.ctrl.isOnRightSide();
+ return html`
+
+ ${this._locked
+ ? nothing
+ : html``}
+
+
+ `;
+ }
+
+ private static lockSvg = html``;
+
+ private static unlockSvg = html``;
+
+ private static resetSvg = html``;
+}
diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts
index 14e546ce9a..02322e91f0 100644
--- a/src/client/graphics/layers/EventsDisplay.ts
+++ b/src/client/graphics/layers/EventsDisplay.ts
@@ -37,6 +37,7 @@ import { GoToPlayerEvent, GoToUnitEvent } from "./Leaderboard";
import { getMessageTypeClasses, translateText } from "../../Utils";
import { UIState } from "../UIState";
+import "./DraggablePanel";
import allianceIcon from "/images/AllianceIconWhite.svg?url";
import chatIcon from "/images/ChatIconWhite.svg?url";
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
@@ -790,6 +791,10 @@ export class EventsDisplay extends LitElement implements Layer {
});
return html`
+
${styles}
${this._hidden
diff --git a/src/client/graphics/layers/GameLeftSidebar.ts b/src/client/graphics/layers/GameLeftSidebar.ts
index 9092670e88..dbea305e03 100644
--- a/src/client/graphics/layers/GameLeftSidebar.ts
+++ b/src/client/graphics/layers/GameLeftSidebar.ts
@@ -6,6 +6,7 @@ import { GameMode, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { Platform } from "../../Platform";
import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils";
+import "./DraggablePanel";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { SpawnBarVisibleEvent } from "./SpawnTimer";
@@ -101,95 +102,104 @@ export class GameLeftSidebar extends LitElement implements Layer {
render() {
return html`
-
+ data-draggable="ad-promo"
+ >
+