diff --git a/index.html b/index.html index 5db9deb743..27264b3469 100644 --- a/index.html +++ b/index.html @@ -267,39 +267,42 @@
-
- +
- +
@@ -311,6 +314,7 @@
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()}
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` - +
+ + +
+ + +
`; } } diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 8b1fc31698..489fa94ef9 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -7,6 +7,7 @@ import { crazyGamesSDK } from "../../CrazyGamesSDK"; import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; +import "./DraggablePanel"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -184,11 +185,16 @@ export class GameRightSidebar extends LitElement implements Layer { return html`