Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,21 @@
}, 3000);
</script>

<script>
(function () {
const MINI_HOVER_STORAGE_KEY = "settings.miniHoverOverlay";
const MINI_HOVER_DISABLED = "disabled";

const savedMiniHover = localStorage.getItem(MINI_HOVER_STORAGE_KEY);
const initialMiniHoverEnabled =
savedMiniHover !== MINI_HOVER_DISABLED && savedMiniHover !== "false";
document.body.classList.toggle(
"mini-hover-overlay-disabled",
!initialMiniHoverEnabled,
);
})();
</script>

<script>
// Fallback sidebar toggle so hamburger works even if module bundle fails to load
window.__toggleSidebar = function (e) {
Expand Down
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@
"territory_patterns_desc": "Choose whether to display territory skin designs in game",
"performance_overlay_label": "Performance Overlay",
"performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.",
"mini_hover_overlay_label": "Mini Hover Overlay",
"mini_hover_overlay_desc": "Show or hide the compact hover panel when pointing at other players.",
"easter_writing_speed_label": "Writing Speed Multiplier",
"easter_writing_speed_desc": "Adjust how fast you pretend to code (x1–x100)",
"easter_bug_count_label": "Bug Count",
Expand Down
1 change: 1 addition & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function createRenderer(
playerInfo.eventBus = eventBus;
playerInfo.transform = transformHandler;
playerInfo.game = game;
playerInfo.uiState = uiState;

const winModal = document.querySelector("win-modal") as WinModal;
if (!(winModal instanceof WinModal)) {
Expand Down
145 changes: 145 additions & 0 deletions src/client/graphics/layers/PlayerInfoOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../../Utils";
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { ImmunityBarVisibleEvent } from "./ImmunityTimer";
import { Layer } from "./Layer";
import { CloseRadialMenuEvent } from "./RadialMenu";
Expand All @@ -39,6 +40,7 @@ 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";
import swordIcon from "/images/SwordIcon.svg?url";

function euclideanDistWorld(
coord: { x: number; y: number },
Expand Down Expand Up @@ -71,6 +73,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@property({ type: Object })
public transform!: TransformHandler;

@property({ type: Object })
public uiState!: UIState;

@state()
private player: PlayerView | null = null;

Expand All @@ -88,6 +93,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
@state()
private immunityBarVisible = false;

@state()
private hoverScreenX = 0;

@state()
private hoverScreenY = 0;

private _isActive = false;

private get barOffset(): number {
Expand Down Expand Up @@ -115,6 +126,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}

private onMouseEvent(event: MouseMoveEvent) {
this.hoverScreenX = event.x;
this.hoverScreenY = event.y;

const now = Date.now();
if (now - this.lastMouseUpdate < 100) {
return;
Expand All @@ -130,6 +144,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
}

public maybeShow(x: number, y: number) {
this.hoverScreenX = x;
this.hoverScreenY = y;

this.hide();
const worldCoord = this.transform.screenToWorldCoordinates(x, y);
if (!this.game.isValidCoord(worldCoord.x, worldCoord.y)) {
Expand Down Expand Up @@ -483,11 +500,136 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
`;
}

private isDesktopViewport(): boolean {
return typeof window !== "undefined" && window.innerWidth >= 1024;
}

private canShowAttackPreview(player: PlayerView): boolean {
const myPlayer = this.game.myPlayer();
if (!myPlayer || !myPlayer.isAlive()) return false;
if (myPlayer.id() === player.id()) return false;
if (myPlayer.isFriendly(player)) return false;
return true;
}

private predictedOutgoingAttackTroops(): number {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return 0;
const ratio = this.uiState?.attackRatio ?? 0;
return Math.max(0, Math.floor(myPlayer.troops() * ratio));
}
Comment on lines +515 to +520
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize attackRatio before calculating preview troops.

uiState.attackRatio is seeded as 20, so this currently predicts myPlayer.troops() * 20 instead of 20% of the army. That makes the new blue-sword preview wildly wrong.

Proposed fix
   private predictedOutgoingAttackTroops(): number {
     const myPlayer = this.game.myPlayer();
     if (!myPlayer) return 0;
-    const ratio = this.uiState?.attackRatio ?? 0;
+    const ratio = (this.uiState?.attackRatio ?? 0) / 100;
     return Math.max(0, Math.floor(myPlayer.troops() * ratio));
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private predictedOutgoingAttackTroops(): number {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return 0;
const ratio = this.uiState?.attackRatio ?? 0;
return Math.max(0, Math.floor(myPlayer.troops() * ratio));
}
private predictedOutgoingAttackTroops(): number {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return 0;
const ratio = (this.uiState?.attackRatio ?? 0) / 100;
return Math.max(0, Math.floor(myPlayer.troops() * ratio));
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/PlayerInfoOverlay.ts` around lines 515 - 520, The
preview multiplies troops by uiState.attackRatio as if it's fractional but
attackRatio is seeded as 20 (percent); in predictedOutgoingAttackTroops()
normalize attackRatio before use (e.g., divide by 100 or convert values >1 to a
fraction), clamp to 0..1, then compute Math.floor(myPlayer.troops() *
normalizedRatio) to return the correct percent-based preview; update references
to this.uiState?.attackRatio and ensure the final Math.max/Math.floor logic
remains.


private myCurrentTroopPercent(): number {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return 0;

const myMaxTroops = this.game.config().maxTroops(myPlayer);
if (myMaxTroops <= 0) return 0;

const ratio = (myPlayer.troops() / myMaxTroops) * 100;
return Math.max(0, Math.min(100, ratio));
}

private isMiniHoverOverlayEnabled(): boolean {
if (typeof document === "undefined") return true;
return !document.body.classList.contains("mini-hover-overlay-disabled");
}

private compactOverlayStyle(): string {
if (typeof window === "undefined") {
return `left: ${this.hoverScreenX}px; top: ${this.hoverScreenY}px;`;
}

const margin = 8;
const overlayWidth = Math.min(230, Math.max(210, window.innerWidth - 16));
const x = Math.min(
Math.max(margin, this.hoverScreenX + 16),
Math.max(margin, window.innerWidth - overlayWidth - margin),
);
const y = Math.min(
Math.max(margin, this.hoverScreenY - 102),
Math.max(margin, window.innerHeight - 130),
);

return `left: ${Math.round(x)}px; top: ${Math.round(y)}px;`;
}

private renderCompactHoverPlayerInfo(player: PlayerView) {
const myPlayer = this.game.myPlayer();
if (myPlayer && myPlayer.id() === player.id()) {
return html``;
}

const totalTroops = player.troops();
const maxTroops = this.game.config().maxTroops(player);
const attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
const attackPreviewEnabled = this.canShowAttackPreview(player);
const previewTroops = this.predictedOutgoingAttackTroops();
const myTroopPercent = this.myCurrentTroopPercent();

return html`
<div
class="fixed z-[70] pointer-events-none flex flex-col gap-1.5 w-[230px] max-w-[calc(100vw-1rem)]"
style="${this.compactOverlayStyle()}"
>
<div
class="inline-flex items-center gap-2 self-start rounded-md border border-white/20 bg-black/82 backdrop-blur-xs px-2 py-1 text-xs text-white shadow-[0_2px_8px_rgba(0,0,0,0.55)]"
translate="no"
>
<span class="font-semibold">${renderTroops(totalTroops)}</span>
<span class="text-white/60">/ ${renderTroops(maxTroops)}</span>
${attackingTroops > 0
? html`<span
class="inline-flex items-center gap-1 rounded-sm bg-red-900/35 border border-red-500/40 px-1.5 py-0.5 text-red-400"
>
<img
src=${swordIcon}
class="h-3.5 w-3.5"
style="filter: brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"
/>
<span>${renderTroops(attackingTroops)}</span>
</span>`
: ""}
</div>

${attackPreviewEnabled && previewTroops > 0
? html`<div
class="self-start inline-flex items-center gap-1 rounded-md border border-blue-300/70 bg-black/88 backdrop-blur-xs px-2 py-1 text-blue-400 text-xs font-bold shadow-[0_2px_8px_rgba(0,0,0,0.55)]"
translate="no"
>
<img
src=${swordIcon}
class="h-3.5 w-3.5"
style="filter: brightness(0) saturate(100%) invert(72%) sepia(56%) saturate(3204%) hue-rotate(176deg) brightness(99%) contrast(101%)"
/>
<span>${renderTroops(previewTroops)}</span>
<span class="text-blue-200/90"
>(${myTroopPercent.toFixed(0)}%)</span
>
</div>`
: ""}
</div>
`;
}

render() {
if (!this._isActive) {
return html``;
}

const myPlayer = this.game.myPlayer();
const showCompactHoverOverlay =
this._isInfoVisible &&
this.player !== null &&
this.unit === null &&
this.isDesktopViewport() &&
this.isMiniHoverOverlayEnabled() &&
myPlayer !== null &&
this.player.id() !== myPlayer.id();

const containerClasses = this._isInfoVisible
? "opacity-100 visible"
: "opacity-0 invisible pointer-events-none";
Expand All @@ -506,6 +648,9 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
</div>
</div>
${showCompactHoverOverlay
? this.renderCompactHoverPlayerInfo(this.player!)
: ""}
`;
}

Expand Down
41 changes: 41 additions & 0 deletions src/client/graphics/layers/SettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url";
import ninjaIcon from "/images/NinjaIconWhite.svg?url";
import settingsIcon from "/images/SettingIconWhite.svg?url";
import sirenIcon from "/images/SirenIconWhite.svg?url";
import swordIcon from "/images/SwordIcon.svg?url";
import treeIcon from "/images/TreeIconWhite.svg?url";
import musicIcon from "/images/music.svg?url";

Expand Down Expand Up @@ -54,6 +55,7 @@ export class SettingsModal extends LitElement implements Layer {
this.userSettings.backgroundMusicVolume(),
);
SoundManager.setSoundEffectsVolume(this.userSettings.soundEffectsVolume());
this.applyMiniHoverOverlayClass();
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
Expand Down Expand Up @@ -168,6 +170,19 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}

private applyMiniHoverOverlayClass() {
document.body.classList.toggle(
"mini-hover-overlay-disabled",
!this.userSettings.miniHoverOverlay(),
);
}

private onToggleMiniHoverOverlayButtonClick() {
this.userSettings.toggleMiniHoverOverlay();
this.applyMiniHoverOverlayClass();
this.requestUpdate();
}

private onExitButtonClick() {
// redirect to the home page
window.location.href = "/";
Expand Down Expand Up @@ -498,6 +513,32 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</button>

<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleMiniHoverOverlayButtonClick}"
>
<img
src=${swordIcon}
alt="miniHoverOverlay"
width="20"
height="20"
style="filter: brightness(0) saturate(100%) invert(72%) sepia(56%) saturate(3204%) hue-rotate(176deg) brightness(99%) contrast(101%)"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.mini_hover_overlay_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.mini_hover_overlay_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.miniHoverOverlay()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
Comment on lines +516 to +540
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't show a no-op toggle on touch devices.

The mini-hover overlay is desktop-only in this PR, but this row renders for every client. On mobile/tablet this becomes a saved setting with no visible effect. Please hide it there, or render it disabled with a short desktop-only hint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/SettingsModal.ts` around lines 516 - 540, The
mini-hover overlay toggle (button using onToggleMiniHoverOverlayButtonClick and
reading userSettings.miniHoverOverlay()) is desktop-only but is rendered on
touch devices; modify SettingsModal to detect hover-capable devices and either
hide the entire button or render it disabled with a short hint on non-hover
devices. Implement a small helper (e.g. isHoverCapable or isTouchDevice) in the
SettingsModal component that uses navigator / matchMedia (hover: none) to
determine capability, and wrap the existing button render in that check (or set
disabled/aria-disabled plus a desktop-only hint text) so mobile/tablet users
won't see an active no-op toggle. Ensure you reference
onToggleMiniHoverOverlayButtonClick and userSettings.miniHoverOverlay() when
updating the rendering logic.


<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-red-600/20 rounded-sm text-red-400 transition-colors"
Expand Down
11 changes: 11 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export class UserSettings {
return this.get("settings.performanceOverlay", false);
}

miniHoverOverlay() {
const raw = localStorage.getItem("settings.miniHoverOverlay");
if (raw === "enabled") return true;
if (raw === "disabled") return false;
return this.get("settings.miniHoverOverlay", true);
}

alertFrame() {
return this.get("settings.alertFrame", true);
}
Expand Down Expand Up @@ -116,6 +123,10 @@ export class UserSettings {
this.set("settings.performanceOverlay", !this.performanceOverlay());
}

toggleMiniHoverOverlay() {
this.set("settings.miniHoverOverlay", !this.miniHoverOverlay());
}

toggleAlertFrame() {
this.set("settings.alertFrame", !this.alertFrame());
}
Expand Down
Loading