diff --git a/.gitignore b/.gitignore index f97753a021..c65b3f251a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ src/assets/ *.log *debug*.txt eslint_out.txt + +# Kiro IDE workspace files +.kiro/ diff --git a/index.html b/index.html index 2bb0093668..091f0db5e8 100644 --- a/index.html +++ b/index.html @@ -329,6 +329,8 @@ + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 3ee0af4814..f03b94c4c4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -958,6 +958,22 @@ "delete_unit_title": "Delete Unit", "delete_unit_description": "Click to delete the nearest unit" }, + "quick_chat": { + "configure_presets": "Customize Presets", + "preset_label": "Preset", + "add_preset": "+ Add", + "done": "Done", + "reset_defaults": "Reset defaults", + "confirm_reset": "⚠ Confirm reset", + "emoji_panel": "😀 Emoji Panel", + "trade_toggle": "🤝 Start / Stop Trade", + "editing_hint": "Editing preset {n} — pick a category and phrase, or choose an action", + "select_hint": "Click a preset to edit it", + "actions": "Actions", + "select_target": "Select a player", + "player_info": "Player Info", + "needs_target": "[needs target]" + }, "discord_user_header": { "avatar_alt": "Avatar" }, diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index aefbac8ed3..59efa59d33 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -50,6 +50,7 @@ import { import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/layers/Leaderboard"; +import { TargetSelectionMode } from "./graphics/layers/TargetSelectionMode"; import SoundManager from "./sound/SoundManager"; export interface LobbyConfig { @@ -544,6 +545,10 @@ export class ClientGameRunner { if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; } + // Don't process attacks while the player is picking a target for a quick-chat action + if (TargetSelectionMode.getInstance().active) { + return; + } const cell = this.renderer.transformHandler.screenToWorldCoordinates( event.x, event.y, diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index e42d622411..f7f9ac48b0 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -54,6 +54,8 @@ export class ContextMenuEvent implements GameEvent { constructor( public readonly x: number, public readonly y: number, + /** true when triggered by a genuine right-click / long-press */ + public readonly isRightClick: boolean = false, ) {} } @@ -596,7 +598,9 @@ export class InputHandler { this.setGhostStructure(null); return; } - this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); + this.eventBus.emit( + new ContextMenuEvent(event.clientX, event.clientY, true), + ); } private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 1937b4a3e6..1e9b612229 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -32,6 +32,7 @@ import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer" import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; +import { QuickChatConfigModal } from "./layers/QuickChatConfigModal"; import { RailroadLayer } from "./layers/RailroadLayer"; import { ReplayPanel } from "./layers/ReplayPanel"; import { SAMRadiusLayer } from "./layers/SAMRadiusLayer"; @@ -39,6 +40,7 @@ import { SettingsModal } from "./layers/SettingsModal"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureIconsLayer } from "./layers/StructureIconsLayer"; import { StructureLayer } from "./layers/StructureLayer"; +import { TargetSelectionLayer } from "./layers/TargetSelectionLayer"; import { TeamStats } from "./layers/TeamStats"; import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; @@ -227,6 +229,25 @@ export function createRenderer( } headsUpMessage.game = game; + const targetSelectionLayer = document.querySelector( + "target-selection-layer", + ) as TargetSelectionLayer; + if (!(targetSelectionLayer instanceof TargetSelectionLayer)) { + console.error("target-selection-layer not found"); + } else { + targetSelectionLayer.eventBus = eventBus; + targetSelectionLayer.game = game; + targetSelectionLayer.transformHandler = transformHandler; + } + + // QuickChatConfigModal needs no runtime wiring — it reads from QuickChatPresetService directly. + const quickChatConfigModal = document.querySelector( + "quick-chat-config-modal", + ) as QuickChatConfigModal; + if (!(quickChatConfigModal instanceof QuickChatConfigModal)) { + console.error("quick-chat-config-modal not found"); + } + const structureLayer = new StructureLayer(game, eventBus, transformHandler); const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); @@ -317,6 +338,9 @@ export function createRenderer( inGamePromo, alertFrame, performanceOverlay, + ...(targetSelectionLayer instanceof TargetSelectionLayer + ? [targetSelectionLayer] + : []), ]; return new GameRenderer( diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index b6adba9291..365359425c 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -5,6 +5,13 @@ import { EventBus } from "../../../core/EventBus"; import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + CloseViewEvent, + ContextMenuEvent, + MouseUpEvent, + TouchEvent, +} from "../../InputHandler"; +import { SendQuickChatEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; import { BuildMenu } from "./BuildMenu"; @@ -20,11 +27,11 @@ import { MenuElementParams, rootMenuElement, } from "./RadialMenuElements"; +import { TargetSelectionMode } from "./TargetSelectionMode"; + const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg"); const swordIcon = assetUrl("images/SwordIconWhite.svg"); -import { ContextMenuEvent } from "../../InputHandler"; - @customElement("main-radial-menu") export class MainRadialMenu extends LitElement implements Layer { private radialMenu: RadialMenu; @@ -79,7 +86,59 @@ export class MainRadialMenu extends LitElement implements Layer { init() { this.radialMenu.init(); + + // Handle left-click and touch: if target-selection mode is active, resolve + // the clicked tile as the chat target instead of performing a normal action. + const handleSelectClick = (x: number, y: number) => { + const mode = TargetSelectionMode.getInstance(); + if (!mode.active) return false; + + const worldCoords = this.transformHandler.screenToWorldCoordinates(x, y); + if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) return true; + + const tile = this.game.ref(worldCoords.x, worldCoords.y); + const owner = this.game.owner(tile); + + if (owner.isPlayer()) { + this.eventBus.emit( + new SendQuickChatEvent( + mode.pendingRecipient!, + mode.pendingKey!, + (owner as PlayerView).id(), + ), + ); + mode.exit(); + } + // If tile has no owner, stay in mode and wait for another click. + return true; + }; + + this.eventBus.on(MouseUpEvent, (event) => { + if (handleSelectClick(event.x, event.y)) return; + }); + + this.eventBus.on(TouchEvent, (event) => { + if (handleSelectClick(event.x, event.y)) return; + }); + + // Escape cancels target-selection mode. + this.eventBus.on(CloseViewEvent, () => { + TargetSelectionMode.getInstance().exit(); + }); + this.eventBus.on(ContextMenuEvent, (event) => { + // While in target-selection mode: + // - left-click (isRightClick=false) → attempt to resolve the target + // - right-click (isRightClick=true) → cancel the mode + if (TargetSelectionMode.getInstance().active) { + if (event.isRightClick) { + TargetSelectionMode.getInstance().exit(); + } else { + handleSelectClick(event.x, event.y); + } + return; + } + const worldCoords = this.transformHandler.screenToWorldCoordinates( event.x, event.y, diff --git a/src/client/graphics/layers/QuickChatConfigModal.ts b/src/client/graphics/layers/QuickChatConfigModal.ts new file mode 100644 index 0000000000..87d74c6cd4 --- /dev/null +++ b/src/client/graphics/layers/QuickChatConfigModal.ts @@ -0,0 +1,321 @@ +import { LitElement, html } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { translateText } from "../../Utils"; +import { quickChatPhrases } from "./ChatModal"; +import { + DEFAULT_PRESETS, + PresetSlot, + QuickChatPresetService, +} from "./QuickChatPresetService"; + +const MAX_SLOTS = 5; + +@customElement("quick-chat-config-modal") +export class QuickChatConfigModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + @state() private slots: PresetSlot[] = []; + @state() private editingIndex: number | null = null; + @state() private selectedCategory: string | null = null; + @state() private confirmReset = false; + + createRenderRoot() { + return this; + } + + open() { + this.slots = QuickChatPresetService.getInstance().load(); + this.editingIndex = null; + this.selectedCategory = null; + this.confirmReset = false; + this.requestUpdate(); + this.modalEl?.open(); + } + + close() { + this.editingIndex = null; + this.selectedCategory = null; + this.confirmReset = false; + this.modalEl?.close(); + } + + private persist() { + try { + QuickChatPresetService.getInstance().save(this.slots); + } catch (e) { + console.error("[QuickChatConfigModal] Auto-save failed:", e); + } + } + + private reset() { + if (!this.confirmReset) { + this.confirmReset = true; + this.requestUpdate(); + setTimeout(() => { + this.confirmReset = false; + this.requestUpdate(); + }, 3000); + return; + } + this.slots = [...DEFAULT_PRESETS]; + this.editingIndex = null; + this.selectedCategory = null; + this.confirmReset = false; + this.persist(); + this.requestUpdate(); + } + + private addSlot() { + if (this.slots.length >= MAX_SLOTS) return; + this.slots = [ + ...this.slots, + { type: "quickchat", category: "help", key: "troops" }, + ]; + this.editingIndex = this.slots.length - 1; + this.selectedCategory = null; + this.persist(); + this.requestUpdate(); + } + + private removeSlot(index: number) { + if (this.slots.length <= 1) return; + this.slots = this.slots.filter((_, i) => i !== index); + if (this.editingIndex !== null) { + if (this.editingIndex === index) { + this.editingIndex = null; + this.selectedCategory = null; + } else if (this.editingIndex > index) { + this.editingIndex--; + } + } + this.persist(); + this.requestUpdate(); + } + + private selectSlot(index: number) { + if (this.editingIndex === index) { + this.editingIndex = null; + this.selectedCategory = null; + } else { + this.editingIndex = index; + const slot = this.slots[index]; + this.selectedCategory = + slot.type === "quickchat" ? (slot.category ?? null) : null; + } + this.requestUpdate(); + } + + private selectCategory(cat: string) { + this.selectedCategory = cat; + this.requestUpdate(); + } + + private assignQcPhrase(category: string, key: string) { + if (this.editingIndex === null) return; + this.slots = this.slots.map((s, i) => + i === this.editingIndex ? { type: "quickchat", category, key } : s, + ); + this.persist(); + const next = this.editingIndex + 1; + if (next < this.slots.length) { + this.editingIndex = next; + const nextSlot = this.slots[next]; + this.selectedCategory = + nextSlot.type === "quickchat" ? (nextSlot.category ?? null) : null; + } else { + this.editingIndex = null; + this.selectedCategory = null; + } + this.requestUpdate(); + } + + private assignSpecial(type: "emoji" | "trade") { + if (this.editingIndex === null) return; + this.slots = this.slots.map((s, i) => + i === this.editingIndex ? { type } : s, + ); + this.persist(); + this.editingIndex = null; + this.selectedCategory = null; + this.requestUpdate(); + } + + private slotLabel(slot: PresetSlot): string { + if (slot.type === "quickchat" && slot.category && slot.key) + return translateText(`chat.${slot.category}.${slot.key}`); + if (slot.type === "emoji") return translateText("quick_chat.emoji_panel"); + if (slot.type === "trade") return translateText("quick_chat.trade_toggle"); + return "?"; + } + + render() { + const categories = Object.keys(quickChatPhrases); + const editing = + this.editingIndex !== null ? this.slots[this.editingIndex] : null; + + return html` + +
+ +
+
+ ${translateText("quick_chat.preset_label")} +
+ + ${this.slots.map( + (slot, i) => html` +
+ + ${this.slots.length > 1 + ? html`` + : null} +
+ `, + )} + ${this.slots.length < MAX_SLOTS + ? html`` + : null} +
+ + + ${editing !== null + ? html` +
+
+ ${translateText("chat.category")} +
+ + ${categories.map( + (cat) => html` + + `, + )} + +
+ ${translateText("quick_chat.actions")} +
+ + + + +
+ ` + : null} + + + ${editing !== null && this.selectedCategory + ? html` +
+
+ ${translateText("chat.phrase")} +
+
+ ${(quickChatPhrases[this.selectedCategory] ?? []).map( + (phrase) => { + const label = translateText( + `chat.${this.selectedCategory}.${phrase.key}`, + ); + const isActive = + editing.type === "quickchat" && + editing.category === this.selectedCategory && + editing.key === phrase.key; + return html` + + `; + }, + )} +
+
+ ` + : null} +
+ +
+ ${editing !== null + ? translateText("quick_chat.editing_hint", { + n: (this.editingIndex ?? 0) + 1, + }) + : translateText("quick_chat.select_hint")} +
+ +
+ + +
+
+ `; + } +} diff --git a/src/client/graphics/layers/QuickChatPresetService.ts b/src/client/graphics/layers/QuickChatPresetService.ts new file mode 100644 index 0000000000..b4bd67d960 --- /dev/null +++ b/src/client/graphics/layers/QuickChatPresetService.ts @@ -0,0 +1,71 @@ +import { quickChatPhrases } from "./ChatModal"; + +export type PresetSlotType = "quickchat" | "emoji" | "trade"; + +export interface PresetSlot { + type: PresetSlotType; + // quickchat only + category?: string; + key?: string; +} + +export const DEFAULT_PRESETS: PresetSlot[] = [ + { type: "quickchat", category: "help", key: "troops" }, + { type: "emoji" }, + { type: "quickchat", category: "attack", key: "attack" }, +]; + +const STORAGE_KEY = "quickchat.presets.v4"; +const MIN_SLOTS = 1; +const MAX_SLOTS = 5; + +/** Singleton service that persists and retrieves quick-chat preset configuration from localStorage. */ +export class QuickChatPresetService { + private static instance: QuickChatPresetService; + + /** Returns the singleton instance, creating it on first call. */ + static getInstance(): QuickChatPresetService { + if (!QuickChatPresetService.instance) { + QuickChatPresetService.instance = new QuickChatPresetService(); + } + return QuickChatPresetService.instance; + } + + /** Returns saved presets from localStorage, falling back to DEFAULT_PRESETS if missing or invalid. */ + load(): PresetSlot[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return [...DEFAULT_PRESETS]; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length === 0) + return [...DEFAULT_PRESETS]; + const valid = (parsed.slice(0, MAX_SLOTS) as PresetSlot[]).filter((s) => + this.isValidSlot(s), + ); + return valid.length > 0 ? valid : [...DEFAULT_PRESETS]; + } catch { + return [...DEFAULT_PRESETS]; + } + } + + /** Persists the given preset slots to localStorage. Throws if count is out of range. */ + save(slots: PresetSlot[]): void { + if (slots.length < MIN_SLOTS || slots.length > MAX_SLOTS) { + throw new Error( + `Preset count must be between ${MIN_SLOTS} and ${MAX_SLOTS}`, + ); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(slots)); + } + + /** Returns true if the slot has a valid type and all required fields for that type. */ + isValidSlot(slot: PresetSlot): boolean { + if (!slot?.type) return false; + if (slot.type === "quickchat") { + if (!slot.category || !slot.key) return false; + return !!quickChatPhrases[slot.category]?.some((p) => p.key === slot.key); + } + // emoji and trade are always valid + return slot.type === "emoji" || slot.type === "trade"; + } +} diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 9f8b6c6549..9a02c8c573 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -616,6 +616,10 @@ export class RadialMenu implements Layer { ); } } else if (d.data.text) { + const resolvedText = + typeof d.data.text === "function" + ? d.data.text(this.params!) + : d.data.text; content .append("text") .attr("text-anchor", "middle") @@ -626,7 +630,7 @@ export class RadialMenu implements Layer { .attr("font-size", d.data.fontSize ?? "12px") .attr("font-family", "Arial, sans-serif") .style("opacity", disabled ? 0.5 : 1) - .text(d.data.text); + .text(resolvedText); } else { const imgSel = content .append("image") diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 1364bb41d0..dcfc5f04af 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -15,12 +15,16 @@ import { renderNumber, translateText } from "../../Utils"; import { UIState } from "../UIState"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; +import { quickChatPhrases } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; import { PlayerActionHandler } from "./PlayerActionHandler"; import { PlayerPanel } from "./PlayerPanel"; +import { PresetSlot, QuickChatPresetService } from "./QuickChatPresetService"; import { TooltipItem } from "./RadialMenu"; +import { TargetSelectionMode } from "./TargetSelectionMode"; import { EventBus } from "../../../core/EventBus"; +import { SendQuickChatEvent } from "../../Transport"; const allianceIcon = assetUrl("images/AllianceIconWhite.svg"); const boatIcon = assetUrl("images/BoatIconWhite.svg"); const buildIcon = assetUrl("images/BuildIconWhite.svg"); @@ -34,6 +38,8 @@ const targetIcon = assetUrl("images/TargetIconWhite.svg"); const traitorIcon = assetUrl("images/TraitorIconWhite.svg"); const xIcon = assetUrl("images/XIcon.svg"); +const settingsIcon = assetUrl("images/SettingIconWhite.svg"); + export interface MenuElementParams { myPlayer: PlayerView; selected: PlayerView | null; @@ -56,7 +62,7 @@ export interface MenuElement { displayed?: boolean | ((params: MenuElementParams) => boolean); color?: string | ((params: MenuElementParams) => string); icon?: string; - text?: string; + text?: string | ((params: MenuElementParams) => string); fontSize?: string; tooltipItems?: TooltipItem[]; tooltipKeys?: TooltipKey[]; @@ -367,6 +373,124 @@ const infoEmojiElement: MenuElement = { }, }; +/** Shortens a phrase label to fit inside a radial menu arc segment. */ +function shortenPresetText(text: string, maxLength = 15): string { + return text.length <= maxLength + ? text + : text.substring(0, maxLength - 3) + "..."; +} + +/** Builds a single MenuElement from a PresetSlot at runtime. */ +function buildPresetItem(slot: PresetSlot, recipient: PlayerView): MenuElement { + // --- Quick Chat --- + if (slot.type === "quickchat" && slot.category && slot.key) { + const phrase = quickChatPhrases[slot.category]?.find( + (p) => p.key === slot.key, + ); + const fullKey = `${slot.category}.${slot.key}`; + const label = translateText(`chat.${slot.category}.${slot.key}`); + const color = + COLORS.chat[slot.category as keyof typeof COLORS.chat] ?? + COLORS.chat.default; + return { + id: `preset-qc-${slot.category}-${slot.key}`, + name: label, + disabled: () => false, + text: shortenPresetText(label), + fontSize: "10px", + color, + tooltipItems: [{ text: label, className: "description" }], + action: (p: MenuElementParams) => { + if (phrase?.requiresPlayer) { + TargetSelectionMode.getInstance().enter(fullKey, recipient); + p.closeMenu(); + } else { + p.eventBus.emit(new SendQuickChatEvent(recipient, fullKey)); + p.closeMenu(); + } + }, + }; + } + + // --- Emoji: opens the full emoji panel, same as the existing emoji button --- + if (slot.type === "emoji") { + return { + id: "preset-emoji-panel", + name: "emoji", + disabled: () => false, + icon: emojiIcon, + color: COLORS.infoEmoji, + action: (p: MenuElementParams) => { + const target = + p.selected === p.game.myPlayer() ? AllPlayers : p.selected; + p.emojiTable.showTable((emoji) => { + p.playerActionHandler.handleEmoji( + target!, + flattenedEmojiTable.indexOf(emoji as Emoji), + ); + p.emojiTable.hideTable(); + }); + // Do NOT call closeMenu() here — the emoji table needs to stay interactive + }, + }; + } + + // --- Trade: smart toggle — label/color reflect live canEmbargo state --- + if (slot.type === "trade") { + return { + id: "preset-trade-toggle", + name: "trade", + disabled: (p: MenuElementParams) => + p.selected === null || + p.selected?.id() === p.myPlayer?.id() || + (!p.playerActions?.interaction?.canEmbargo && + !p.playerActions?.interaction?.canDonateGold), + color: (p: MenuElementParams) => + p.playerActions?.interaction?.canEmbargo + ? COLORS.embargo + : COLORS.trade, + text: (p: MenuElementParams) => { + const label = p.playerActions?.interaction?.canEmbargo + ? translateText("player_panel.stop_trade") + : translateText("player_panel.start_trade"); + return shortenPresetText(label); + }, + fontSize: "10px", + action: (p: MenuElementParams) => { + if ( + p.selected === null || + p.selected?.id() === p.myPlayer?.id() || + (!p.playerActions?.interaction?.canEmbargo && + !p.playerActions?.interaction?.canDonateGold) + ) + return; + const canEmbargo = !!p.playerActions?.interaction?.canEmbargo; + p.playerActionHandler.handleEmbargo( + p.selected, + canEmbargo ? "start" : "stop", + ); + p.closeMenu(); + }, + }; + } + + // Fallback (should never happen) + return { + id: "preset-unknown", + name: "?", + disabled: () => true, + color: COLORS.infoDetails, + }; +} + +/** + * The "i" (Info) button — opens a Quick Chat / actions submenu. + * + * Submenu contains: + * 1. User-configured preset slots (up to 5): quickchat, emoji, or trade + * 2. A permanent "Info" button that opens the player panel + * 3. A permanent "Customize Presets" settings button + */ export const infoMenuElement: MenuElement = { id: Slot.Info, name: "info", @@ -374,8 +498,57 @@ export const infoMenuElement: MenuElement = { !params.selected || params.game.inSpawnPhase(), icon: infoIcon, color: COLORS.info, - action: (params: MenuElementParams) => { - params.playerPanel.show(params.playerActions, params.tile); + subMenu: (params: MenuElementParams): MenuElement[] => { + if (params === undefined || !params.selected) return []; + + const recipient = params.selected; + const isSelf = params.selected?.id() === params.myPlayer?.id(); + + // On own territory: skip chat presets, just show Info and Configure + const presets = isSelf ? [] : QuickChatPresetService.getInstance().load(); + + const presetItems = presets.map((slot) => buildPresetItem(slot, recipient)); + + // Permanent: open the player info panel (original Info button behaviour) + const playerInfoItem: MenuElement = { + id: "player-info", + name: "info", + disabled: () => false, + icon: infoIcon, + color: COLORS.info, + tooltipItems: [ + { text: translateText("quick_chat.player_info"), className: "title" }, + ], + action: (p: MenuElementParams) => { + p.playerPanel.show(p.playerActions, p.tile); + }, + }; + + // Permanent: open the preset config modal + const configureItem: MenuElement = { + id: "configure-quick-chat", + name: translateText("quick_chat.configure_presets"), + disabled: () => false, + icon: settingsIcon, + color: COLORS.infoDetails, + tooltipItems: [ + { + text: translateText("quick_chat.configure_presets"), + className: "title", + }, + ], + action: (p: MenuElementParams) => { + const modal = document.querySelector("quick-chat-config-modal") as any; + if (modal) { + modal.open(); + } else { + console.error("[QuickChat] quick-chat-config-modal not found"); + } + p.closeMenu(); + }, + }; + + return [...presetItems, playerInfoItem, configureItem]; }, }; diff --git a/src/client/graphics/layers/TargetSelectionLayer.ts b/src/client/graphics/layers/TargetSelectionLayer.ts new file mode 100644 index 0000000000..726f109893 --- /dev/null +++ b/src/client/graphics/layers/TargetSelectionLayer.ts @@ -0,0 +1,91 @@ +import { LitElement, html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; +import { MouseOverEvent } from "../../InputHandler"; +import { translateText } from "../../Utils"; +import { TransformHandler } from "../TransformHandler"; +import { Layer } from "./Layer"; +import { TargetSelectionMode } from "./TargetSelectionMode"; + +/** + * While in target-selection mode, renders a small badge that follows the + * cursor and sets a pointer cursor on document.body. + */ +@customElement("target-selection-layer") +export class TargetSelectionLayer extends LitElement implements Layer { + @state() private isActive = false; + @state() private cursorX = 0; + @state() private cursorY = 0; + + eventBus!: EventBus; + game!: GameView; + transformHandler!: TransformHandler; + + private _mouseHandler: ((e: MouseOverEvent) => void) | null = null; + + /** Uses light DOM so the layer shares global game CSS. */ + createRenderRoot() { + return this; + } + + /** No-op — subscriptions are set up lazily in tick() when mode activates. */ + init() {} + + /** Polls TargetSelectionMode each frame; subscribes/unsubscribes the mouse-over handler as mode changes. */ + tick() { + const mode = TargetSelectionMode.getInstance(); + const wasActive = this.isActive; + this.isActive = mode.active; + + if (this.isActive && !wasActive) { + this._mouseHandler = (e: MouseOverEvent) => { + this.cursorX = e.x; + this.cursorY = e.y; + this.requestUpdate(); + }; + this.eventBus.on(MouseOverEvent, this._mouseHandler); + document.body.style.cursor = "pointer"; + this.requestUpdate(); + } else if (!this.isActive && wasActive) { + if (this._mouseHandler) { + this.eventBus.off(MouseOverEvent, this._mouseHandler); + this._mouseHandler = null; + } + document.body.style.cursor = ""; + this.requestUpdate(); + } + } + + /** Renders a floating badge that follows the cursor while target-selection mode is active. */ + render() { + if (!this.isActive) return html``; + + // Offset badge slightly so it doesn't sit under the cursor tip + const x = this.cursorX + 14; + const y = this.cursorY - 10; + + return html` +
+ ${translateText("quick_chat.select_target")} +
+ `; + } +} diff --git a/src/client/graphics/layers/TargetSelectionMode.ts b/src/client/graphics/layers/TargetSelectionMode.ts new file mode 100644 index 0000000000..1acf872874 --- /dev/null +++ b/src/client/graphics/layers/TargetSelectionMode.ts @@ -0,0 +1,53 @@ +import { PlayerView } from "../../../core/game/GameView"; + +/** + * Singleton that tracks whether the player is in "pick a target country" mode + * after selecting a requiresPlayer quick-chat preset. + * + * Intentionally has no EventBus dependency — callers poll `active` and call + * `enter` / `exit` directly, keeping the state machine simple and testable. + */ +export class TargetSelectionMode { + private static instance: TargetSelectionMode; + + private _active = false; + private _pendingKey: string | null = null; + private _pendingRecipient: PlayerView | null = null; + + static getInstance(): TargetSelectionMode { + if (!TargetSelectionMode.instance) { + TargetSelectionMode.instance = new TargetSelectionMode(); + } + return TargetSelectionMode.instance; + } + + get active(): boolean { + return this._active; + } + + get pendingKey(): string | null { + return this._pendingKey; + } + + get pendingRecipient(): PlayerView | null { + return this._pendingRecipient; + } + + /** + * Activates target-selection mode. + * @param key Full quick-chat key, e.g. "attack.attack" + * @param recipient The player whose tile was right-clicked (message recipient) + */ + enter(key: string, recipient: PlayerView): void { + this._active = true; + this._pendingKey = key; + this._pendingRecipient = recipient; + } + + /** Deactivates target-selection mode and clears all pending state. */ + exit(): void { + this._active = false; + this._pendingKey = null; + this._pendingRecipient = null; + } +} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index e5d72f0e8f..62320bc2e3 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -15,6 +15,7 @@ import { import { MoveWarshipIntentEvent } from "../../Transport"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { TargetSelectionMode } from "./TargetSelectionMode"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { @@ -127,6 +128,7 @@ export class UnitLayer implements Layer { clickRef?: TileRef, nearbyWarships?: UnitView[], ) { + if (TargetSelectionMode.getInstance().active) return; if (clickRef === undefined) { // Convert screen coordinates to world coordinates const cell = this.transformHandler.screenToWorldCoordinates( @@ -157,6 +159,7 @@ export class UnitLayer implements Layer { } private onTouch(event: TouchEvent) { + if (TargetSelectionMode.getInstance().active) return; const cell = this.transformHandler.screenToWorldCoordinates( event.x, event.y, diff --git a/tests/setup.ts b/tests/setup.ts index a4b1368ce2..8e81a8ca7f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,2 +1,31 @@ // Add global mocks or configuration here if needed import "vitest-canvas-mock"; + +// Provide a fully-functional in-memory localStorage for all tests. +// jsdom's built-in localStorage can be a no-op stub when the environment +// is initialised without a valid URL (e.g. the --localstorage-file warning), +// which causes `removeItem` / `setItem` to be missing or throw. +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + get length() { + return Object.keys(store).length; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, +});