From 871d8c499ccbcd5533e1dcaf9e252539a6e75260 Mon Sep 17 00:00:00 2001 From: oleksandr-shysh Date: Sat, 7 Jun 2025 03:04:24 +0300 Subject: [PATCH] Multi-level radial menu (#1018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: - Refactored the radial menu to enable multi-level functionality. - Organized the actions into submenus. Знімок екрана 2025-06-03 о 16 33 24 Знімок екрана 2025-06-03 о 16 34 17 Знімок екрана 2025-06-03 о 16 40 22 Знімок екрана 2025-06-03 о 16 37 04 Знімок екрана 2025-06-03 о 16 36 32 ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: oleksandr037617_47021 --------- Co-authored-by: Oleksandr Shysh Co-authored-by: evanpelle --- resources/images/BackIconWhite.svg | 11 + src/client/graphics/GameRenderer.ts | 4 +- src/client/graphics/layers/BuildMenu.ts | 14 +- src/client/graphics/layers/ChatIntegration.ts | 102 ++ src/client/graphics/layers/ChatModal.ts | 50 +- src/client/graphics/layers/MainRadialMenu.ts | 285 ++++ .../graphics/layers/MenuEventManager.ts | 185 +++ .../graphics/layers/PlayerActionHandler.ts | 109 ++ src/client/graphics/layers/PlayerPanel.ts | 2 +- src/client/graphics/layers/RadialMenu.ts | 1439 +++++++++++------ .../graphics/layers/RadialMenuElements.ts | 471 ++++++ 11 files changed, 2194 insertions(+), 478 deletions(-) create mode 100644 resources/images/BackIconWhite.svg create mode 100644 src/client/graphics/layers/ChatIntegration.ts create mode 100644 src/client/graphics/layers/MainRadialMenu.ts create mode 100644 src/client/graphics/layers/MenuEventManager.ts create mode 100644 src/client/graphics/layers/PlayerActionHandler.ts create mode 100644 src/client/graphics/layers/RadialMenuElements.ts diff --git a/resources/images/BackIconWhite.svg b/resources/images/BackIconWhite.svg new file mode 100644 index 000000000..651867162 --- /dev/null +++ b/resources/images/BackIconWhite.svg @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index c94a2bbc6..ee3bbc4b9 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -14,13 +14,13 @@ import { FxLayer } from "./layers/FxLayer"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; +import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { OptionsMenu } from "./layers/OptionsMenu"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; import { PlayerTeamLabel } from "./layers/PlayerTeamLabel"; -import { RadialMenu } from "./layers/RadialMenu"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; @@ -199,7 +199,7 @@ export function createRenderer( eventsDisplay, chatDisplay, buildMenu, - new RadialMenu( + new MainRadialMenu( eventBus, game, transformHandler, diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index 70f28ea65..f1ab5973b 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -19,7 +19,7 @@ import { BuildUnitIntentEvent } from "../../Transport"; import { renderNumber } from "../../Utils"; import { Layer } from "./Layer"; -interface BuildItemDisplay { +export interface BuildItemDisplay { unitType: UnitType; icon: string; description?: string; @@ -27,7 +27,7 @@ interface BuildItemDisplay { countable?: boolean; } -const buildTable: BuildItemDisplay[][] = [ +export const buildTable: BuildItemDisplay[][] = [ [ { unitType: UnitType.AtomBomb, @@ -96,12 +96,14 @@ const buildTable: BuildItemDisplay[][] = [ ], ]; +export const flattenedBuildTable = buildTable.flat(); + @customElement("build-menu") export class BuildMenu extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; private clickedTile: TileRef; - private playerActions: PlayerActions | null; + public playerActions: PlayerActions | null; private filteredBuildTable: BuildItemDisplay[][] = buildTable; tick() { @@ -302,7 +304,7 @@ export class BuildMenu extends LitElement implements Layer { @state() private _hidden = true; - private canBuild(item: BuildItemDisplay): boolean { + public canBuild(item: BuildItemDisplay): boolean { if (this.game?.myPlayer() === null || this.playerActions === null) { return false; } @@ -314,7 +316,7 @@ export class BuildMenu extends LitElement implements Layer { return unit[0].canBuild !== false; } - private cost(item: BuildItemDisplay): Gold { + public cost(item: BuildItemDisplay): Gold { for (const bu of this.playerActions?.buildableUnits ?? []) { if (bu.type === item.unitType) { return bu.cost; @@ -323,7 +325,7 @@ export class BuildMenu extends LitElement implements Layer { return 0n; } - private count(item: BuildItemDisplay): string { + public count(item: BuildItemDisplay): string { const player = this.game?.myPlayer(); if (!player) { return "?"; diff --git a/src/client/graphics/layers/ChatIntegration.ts b/src/client/graphics/layers/ChatIntegration.ts new file mode 100644 index 000000000..044b0b18f --- /dev/null +++ b/src/client/graphics/layers/ChatIntegration.ts @@ -0,0 +1,102 @@ +import { EventBus } from "../../../core/EventBus"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { SendQuickChatEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal"; +import { COLORS, MenuElement } from "./RadialMenuElements"; + +export class ChatIntegration { + private ctModal: ChatModal; + + constructor( + private game: GameView, + private eventBus: EventBus, + ) { + this.ctModal = document.querySelector("chat-modal") as ChatModal; + + if (!this.ctModal) { + throw new Error( + "Chat modal element not found. Ensure chat-modal element exists in DOM before initializing ChatIntegration", + ); + } + } + + setupChatModal(sender: PlayerView, recipient: PlayerView) { + this.ctModal.setSender(sender); + this.ctModal.setRecipient(recipient); + } + + createQuickChatMenu(recipient: PlayerView): MenuElement[] { + if (!this.ctModal) { + throw new Error("Chat modal not set"); + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + throw new Error("Current player not found"); + } + + return this.ctModal.categories.map((category) => { + const categoryTranslation = translateText(`chat.cat.${category.id}`); + + const categoryColor = + COLORS.chat[category.id as keyof typeof COLORS.chat] || + COLORS.chat.default; + const phrases = quickChatPhrases[category.id] || []; + + const phraseItems: MenuElement[] = phrases.map( + (phrase: QuickChatPhrase) => { + const phraseText = translateText(`chat.${category.id}.${phrase.key}`); + + return { + id: `phrase-${category.id}-${phrase.key}`, + name: phraseText, + disabled: false, + text: this.shortenText(phraseText), + fontSize: "10px", + color: categoryColor, + tooltipItems: [ + { + text: phraseText, + className: "description", + }, + ], + action: () => { + if (phrase.requiresPlayer) { + this.ctModal.openWithSelection( + category.id, + phrase.key, + myPlayer, + recipient, + ); + } else { + this.eventBus.emit( + new SendQuickChatEvent( + recipient, + `${category.id}.${phrase.key}`, + {}, + ), + ); + } + }, + }; + }, + ); + + return { + id: `chat-category-${category.id}`, + name: categoryTranslation, + disabled: false, + text: categoryTranslation, + color: categoryColor, + _action: () => {}, // Empty action placeholder for RadialMenu + subMenu: () => phraseItems, + }; + }); + } + + shortenText(text: string, maxLength = 15): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + "..."; + } +} diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 7bec6d3b4..1118a027e 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -9,14 +9,14 @@ import { EventBus } from "../../../core/EventBus"; import { SendQuickChatEvent } from "../../Transport"; import { translateText } from "../../Utils"; -type QuickChatPhrase = { +export type QuickChatPhrase = { key: string; requiresPlayer: boolean; }; -type QuickChatPhrases = Record; +export type QuickChatPhrases = Record; -const quickChatPhrases: QuickChatPhrases = quickChatData; +export const quickChatPhrases: QuickChatPhrases = quickChatData; @customElement("chat-modal") export class ChatModal extends LitElement { @@ -57,7 +57,7 @@ export class ChatModal extends LitElement { misc: [{ text: "Let's go!", requiresPlayer: false }], }; - private categories = [ + public categories = [ { id: "help" }, { id: "attack" }, { id: "defend" }, @@ -71,17 +71,6 @@ export class ChatModal extends LitElement { } render() { - const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b)); - - const filteredPlayers = sortedPlayers.filter((player) => - player.toLowerCase().includes(this.playerSearchQuery), - ); - - const otherPlayers = sortedPlayers.filter( - (player) => !player.toLowerCase().includes(this.playerSearchQuery), - ); - - const displayPlayers = [...filteredPlayers, ...otherPlayers]; return html`
@@ -306,4 +295,35 @@ export class ChatModal extends LitElement { public setSender(value: PlayerView) { this.sender = value; } + + public openWithSelection( + categoryId: string, + phraseKey: string, + sender?: PlayerView, + recipient?: PlayerView, + ) { + if (sender && recipient) { + const alivePlayerNames = this.g + .players() + .filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot)) + .map((p) => p.data.name); + + this.players = alivePlayerNames; + this.recipient = recipient; + this.sender = sender; + } + + this.selectCategory(categoryId); + + const phrase = this.getPhrasesForCategory(categoryId).find( + (p) => p.key === phraseKey, + ); + + if (phrase) { + this.selectPhrase(phrase); + } + + this.requestUpdate(); + this.modalEl?.open(); + } } diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts new file mode 100644 index 000000000..739815f3c --- /dev/null +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -0,0 +1,285 @@ +import { LitElement } from "lit"; +import { customElement } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { BuildMenu } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { Layer } from "./Layer"; +import { MenuEventManager } from "./MenuEventManager"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu, RadialMenuConfig } from "./RadialMenu"; +import { + COLORS, + MenuElementParams, + Slot, + createRadialMenuItems, + getRootMenuItems, + updateCenterButton, +} from "./RadialMenuElements"; + +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import infoIcon from "../../../../resources/images/InfoIcon.svg"; +import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; + +@customElement("main-radial-menu") +export class MainRadialMenu extends LitElement implements Layer { + private radialMenu: RadialMenu; + private lastTickRefresh: number = 0; + private tickRefreshInterval: number = 500; + private needsRefresh: boolean = false; + + private playerActionHandler: PlayerActionHandler; + private menuEventManager: MenuEventManager; + private chatIntegration: ChatIntegration; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private emojiTable: EmojiTable, + private buildMenu: BuildMenu, + private uiState: UIState, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) { + super(); + + const menuConfig: RadialMenuConfig = { + centerButtonIcon: swordIcon, + tooltipStyle: ` + .radial-tooltip .cost { + margin-top: 4px; + color: ${COLORS.tooltip.cost}; + } + .radial-tooltip .count { + color: ${COLORS.tooltip.count}; + } + `, + }; + + this.radialMenu = new RadialMenu(menuConfig); + + this.playerActionHandler = new PlayerActionHandler( + this.eventBus, + this.uiState, + ); + + this.menuEventManager = new MenuEventManager( + this.eventBus, + this.game, + this.transformHandler, + this.radialMenu, + this.buildMenu, + this.emojiTable, + this.playerInfoOverlay, + this.playerPanel, + ); + + this.chatIntegration = new ChatIntegration(this.game, this.eventBus); + + this.radialMenu.setRootMenuItems(getRootMenuItems()); + } + + init() { + this.radialMenu.init(); + + this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => { + this.handlePlayerActions(myPlayer, actions, tile); + }); + + this.menuEventManager.init(); + } + + private async handlePlayerActions( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + this.buildMenu.playerActions = actions; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + if (myPlayer && recipient) { + this.chatIntegration.setupChatModal(myPlayer, recipient); + } + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + const menuItems = createRadialMenuItems(params); + + this.radialMenu.setRootMenuItems(menuItems); + + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + async tick() { + const clickedCell = this.menuEventManager.getClickedCell(); + if (!this.radialMenu.isMenuVisible() || clickedCell === null) return; + + const currentTime = new Date().getTime(); + if ( + currentTime - this.lastTickRefresh < this.tickRefreshInterval && + !this.needsRefresh + ) { + return; + } + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null || !myPlayer.isAlive()) return; + + const tile = this.game.ref(clickedCell.x, clickedCell.y); + + const isSpawnPhase = this.game.inSpawnPhase(); + const wasInSpawnPhase = this.menuEventManager.getWasInSpawnPhase(); + + if (wasInSpawnPhase !== isSpawnPhase) { + if (wasInSpawnPhase && !isSpawnPhase) { + this.needsRefresh = true; + this.menuEventManager.setWasInSpawnPhase(isSpawnPhase); + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + this.radialMenu.refreshMenu(); + return; + } + + this.menuEventManager.closeMenu(); + return; + } + + // Check if tile ownership has changed + const originalTileOwner = this.menuEventManager.getOriginalTileOwner(); + if (originalTileOwner && originalTileOwner.isPlayer()) { + if (this.game.owner(tile) !== originalTileOwner) { + this.menuEventManager.closeMenu(); + return; + } + } else if (originalTileOwner) { + if ( + this.game.owner(tile).isPlayer() || + this.game.owner(tile) === myPlayer + ) { + this.menuEventManager.closeMenu(); + return; + } + } + + this.lastTickRefresh = currentTime; + this.needsRefresh = false; + + const actions = await this.playerActionHandler.getPlayerActions( + myPlayer, + tile, + ); + this.updateMenuState(myPlayer, actions, tile); + } + + private updateMenuState( + myPlayer: PlayerView, + actions: PlayerActions, + tile: TileRef, + ) { + if (!this.radialMenu.isMenuVisible()) return; + + const tileOwner = this.game.owner(tile); + const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null; + + const params: MenuElementParams = { + myPlayer, + selected: recipient, + tileOwner, + tile, + playerActions: actions, + game: this.game, + buildMenu: this.buildMenu, + emojiTable: this.emojiTable, + playerActionHandler: this.playerActionHandler, + playerPanel: this.playerPanel, + chatIntegration: this.chatIntegration, + closeMenu: () => this.menuEventManager.closeMenu(), + }; + + if (this.radialMenu.getCurrentLevel() === 0) { + updateCenterButton(params, (enabled, action) => { + this.radialMenu.enableCenterButton(enabled, action); + }); + } + + const canBuildTransport = actions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + this.radialMenu.updateMenuItem( + Slot.Build, + !this.game.inSpawnPhase(), + COLORS.build, + buildIcon, + ); + + if (actions?.interaction?.canSendAllianceRequest) { + this.radialMenu.updateMenuItem(Slot.Ally, true, COLORS.ally, undefined); + } else if (actions?.interaction?.canBreakAlliance) { + this.radialMenu.updateMenuItem( + Slot.Ally, + true, + COLORS.breakAlly, + undefined, + ); + } else { + this.radialMenu.updateMenuItem(Slot.Ally, false, undefined, undefined); + } + + this.radialMenu.updateMenuItem( + Slot.Boat, + !!canBuildTransport, + COLORS.boat, + boatIcon, + ); + + this.radialMenu.updateMenuItem( + Slot.Info, + this.game.hasOwner(tile), + COLORS.info, + infoIcon, + ); + } + + renderLayer(context: CanvasRenderingContext2D) { + this.radialMenu.renderLayer(context); + } + + shouldTransform(): boolean { + return this.radialMenu.shouldTransform(); + } + + redraw() { + // No redraw implementation needed + } +} diff --git a/src/client/graphics/layers/MenuEventManager.ts b/src/client/graphics/layers/MenuEventManager.ts new file mode 100644 index 000000000..1104529b2 --- /dev/null +++ b/src/client/graphics/layers/MenuEventManager.ts @@ -0,0 +1,185 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { + CloseViewEvent, + ContextMenuEvent, + MouseUpEvent, + ShowBuildMenuEvent, +} from "../../InputHandler"; +import { SendSpawnIntentEvent } from "../../Transport"; +import { TransformHandler } from "../TransformHandler"; +import { BuildMenu } from "./BuildMenu"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; +import { PlayerPanel } from "./PlayerPanel"; +import { RadialMenu } from "./RadialMenu"; + +export type ContextMenuCallback = ( + myPlayer: PlayerView, + tile: TileRef, + actions: PlayerActions, +) => void; + +export class MenuEventManager { + private clickedCell: Cell | null = null; + private lastClosed: number = 0; + private originalTileOwner: PlayerView | TerraNullius | null = null; + private wasInSpawnPhase: boolean = false; + private onContextMenuCallback: ContextMenuCallback | null = null; + + constructor( + private eventBus: EventBus, + private game: GameView, + private transformHandler: TransformHandler, + private radialMenu: RadialMenu, + private buildMenu: BuildMenu, + private emojiTable: EmojiTable, + private playerInfoOverlay: PlayerInfoOverlay, + private playerPanel: PlayerPanel, + ) {} + + init() { + this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); + this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); + this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + this.eventBus.on(ShowBuildMenuEvent, (e) => this.onShowBuildMenu(e)); + } + + setContextMenuCallback(callback: ContextMenuCallback) { + this.onContextMenuCallback = callback; + } + + onContextMenu(event: ContextMenuEvent): Cell | null { + if (this.lastClosed + 200 > new Date().getTime()) return null; + + this.closeMenu(); + + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + return null; + } else { + this.radialMenu.showRadialMenu(event.x, event.y); + } + + this.radialMenu.disableAllButtons(); + this.clickedCell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + + if ( + !this.clickedCell || + !this.game.isValidCoord(this.clickedCell.x, this.clickedCell.y) + ) { + return null; + } + + const tile = this.game.ref(this.clickedCell.x, this.clickedCell.y); + this.originalTileOwner = this.game.owner(tile); + this.wasInSpawnPhase = this.game.inSpawnPhase(); + + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) { + throw new Error("my player not found"); + } + + if (myPlayer && !myPlayer.isAlive() && !this.game.inSpawnPhase()) { + this.radialMenu.hideRadialMenu(); + return null; + } + + if (this.game.inSpawnPhase()) { + if (this.game.isLand(tile) && !this.game.hasOwner(tile)) { + this.radialMenu.enableCenterButton(true, () => { + if (this.clickedCell === null) return; + this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); + this.radialMenu.hideRadialMenu(); + }); + + return this.clickedCell; + } + } + + myPlayer.actions(tile).then((actions) => { + if (this.onContextMenuCallback) { + this.onContextMenuCallback(myPlayer, tile, actions); + } + }); + + return this.clickedCell; + } + + getClickedCell(): Cell | null { + return this.clickedCell; + } + + getOriginalTileOwner(): PlayerView | TerraNullius | null { + return this.originalTileOwner; + } + + getWasInSpawnPhase(): boolean { + return this.wasInSpawnPhase; + } + + setWasInSpawnPhase(value: boolean) { + this.wasInSpawnPhase = value; + } + + onPointerUp(event: MouseUpEvent) { + this.playerInfoOverlay.hide(); + this.hideEverything(); + } + + onShowBuildMenu(e: ShowBuildMenuEvent): TileRef | null { + const clickedCell = this.transformHandler.screenToWorldCoordinates( + e.x, + e.y, + ); + if (clickedCell === null) { + return null; + } + if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) { + return null; + } + const tile = this.game.ref(clickedCell.x, clickedCell.y); + const p = this.game.myPlayer(); + if (p === null) { + return null; + } + this.buildMenu.showMenu(tile); + return tile; + } + + closeMenu() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + } + + if (this.buildMenu.isVisible) { + this.buildMenu.hideMenu(); + } + + if (this.emojiTable.isVisible) { + this.emojiTable.hideTable(); + } + + if (this.playerPanel.isVisible) { + this.playerPanel.hide(); + } + } + + hideEverything() { + if (this.radialMenu.isMenuVisible()) { + this.radialMenu.hideRadialMenu(); + this.lastClosed = new Date().getTime(); + } + this.emojiTable.hideTable(); + this.buildMenu.hideMenu(); + } + + enableCenterButton(enabled: boolean, action: () => void) { + this.radialMenu.enableCenterButton(enabled, action); + } +} diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts new file mode 100644 index 000000000..b8bb154bf --- /dev/null +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -0,0 +1,109 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell, PlayerActions, UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { PlayerView } from "../../../core/game/GameView"; +import { + BuildUnitIntentEvent, + SendAllianceRequestIntentEvent, + SendAttackIntentEvent, + SendBoatAttackIntentEvent, + SendBreakAllianceIntentEvent, + SendDonateGoldIntentEvent, + SendDonateTroopsIntentEvent, + SendEmbargoIntentEvent, + SendEmojiIntentEvent, + SendQuickChatEvent, + SendSpawnIntentEvent, + SendTargetPlayerIntentEvent, +} from "../../Transport"; +import { UIState } from "../UIState"; + +export class PlayerActionHandler { + constructor( + private eventBus: EventBus, + private uiState: UIState, + ) {} + + async getPlayerActions( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.actions(tile); + } + + handleAttack(player: PlayerView, targetId: string | null) { + this.eventBus.emit( + new SendAttackIntentEvent( + targetId, + this.uiState.attackRatio * player.troops(), + ), + ); + } + + handleBoatAttack( + player: PlayerView, + targetId: string, + targetCell: Cell, + spawnTile: Cell | null, + ) { + this.eventBus.emit( + new SendBoatAttackIntentEvent( + targetId, + targetCell, + this.uiState.attackRatio * player.troops(), + spawnTile, + ), + ); + } + + async findBestTransportShipSpawn( + player: PlayerView, + tile: TileRef, + ): Promise { + return await player.bestTransportShipSpawn(tile); + } + + handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) { + this.eventBus.emit( + new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)), + ); + } + + handleSpawn(spawnCell: Cell) { + this.eventBus.emit(new SendSpawnIntentEvent(spawnCell)); + } + + handleAllianceRequest(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient)); + } + + handleBreakAlliance(player: PlayerView, recipient: PlayerView) { + this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient)); + } + + handleTargetPlayer(targetId: string | null) { + if (!targetId) return; + + this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId)); + } + + handleDonateGold(recipient: PlayerView) { + this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null)); + } + + handleDonateTroops(recipient: PlayerView) { + this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null)); + } + + handleEmbargo(recipient: PlayerView, action: "start" | "stop") { + this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action)); + } + + handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) { + this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex)); + } + + handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) { + this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params)); + } +} diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index cf9d71a8d..b9804d2d1 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer { private tile: TileRef | null = null; @state() - private isVisible: boolean = false; + public isVisible: boolean = false; @state() private allianceExpiryText: string | null = null; diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 74cbb7b27..0ce75f91e 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -1,261 +1,166 @@ import * as d3 from "d3"; -import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; -import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; -import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import backIcon from "../../../../resources/images/BackIconWhite.svg"; import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; -import infoIcon from "../../../../resources/images/InfoIcon.svg"; -import swordIcon from "../../../../resources/images/SwordIconWhite.svg"; -import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; -import { EventBus } from "../../../core/EventBus"; -import { - Cell, - PlayerActions, - TerraNullius, - UnitType, -} from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { - CloseViewEvent, - ContextMenuEvent, - MouseUpEvent, - ShowBuildMenuEvent, -} from "../../InputHandler"; -import { - SendAllianceRequestIntentEvent, - SendAttackIntentEvent, - SendBoatAttackIntentEvent, - SendBreakAllianceIntentEvent, - SendSpawnIntentEvent, -} from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { BuildMenu } from "./BuildMenu"; -import { EmojiTable } from "./EmojiTable"; import { Layer } from "./Layer"; -import { PlayerInfoOverlay } from "./PlayerInfoOverlay"; -import { PlayerPanel } from "./PlayerPanel"; +import { MenuElement } from "./RadialMenuElements"; -enum Slot { - Info, - Boat, - Build, - Ally, +export interface TooltipItem { + text: string; + className: string; } +export interface RadialMenuConfig { + menuSize?: number; + submenuScale?: number; + centerButtonSize?: number; + iconSize?: number; + centerIconSize?: number; + disabledColor?: string; + menuTransitionDuration?: number; + mainMenuInnerRadius?: number; + centerButtonIcon?: string; + maxNestedLevels?: number; + innerRadiusIncrement?: number; + tooltipStyle?: string; +} + +type RequiredRadialMenuConfig = Required; + export class RadialMenu implements Layer { - private clickedCell: Cell | null = null; - private lastClosed: number = 0; - - private originalTileOwner: PlayerView | TerraNullius; private menuElement: d3.Selection; + private tooltipElement: HTMLDivElement | null = null; private isVisible: boolean = false; - private readonly menuItems: Map< - Slot, - { - name: string; - disabled: boolean; - action: () => void; - color?: string | null; - icon?: string | null; - } - > = new Map([ - [ - Slot.Boat, - { - name: "boat", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - [Slot.Ally, { name: "ally", disabled: true, action: () => {} }], - [Slot.Build, { name: "build", disabled: true, action: () => {} }], - [ - Slot.Info, - { - name: "info", - disabled: true, - action: () => {}, - color: null, - icon: null, - }, - ], - ]); - private readonly menuSize = 190; - private readonly centerButtonSize = 30; - private readonly iconSize = 32; - private readonly centerIconSize = 48; - private readonly disabledColor = d3.rgb(128, 128, 128).toString(); + private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.) + private menuStack: MenuElement[][] = []; // Stack to track menu navigation history + private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level) + private rootMenuItems: MenuElement[] = []; // Store the original root menu items + + private readonly config: RequiredRadialMenuConfig; + private readonly backIconSize: number; private isCenterButtonEnabled = false; + private originalCenterButtonEnabled = false; + private centerButtonAction: (() => void) | null = null; + private originalCenterButtonAction: (() => void) | null = null; + private backAction: (() => void) | null = null; - constructor( - private eventBus: EventBus, - private g: GameView, - private transformHandler: TransformHandler, - private emojiTable: EmojiTable, - private buildMenu: BuildMenu, - private uiState: UIState, - private playerInfoOverlay: PlayerInfoOverlay, - private playerPanel: PlayerPanel, - ) {} + private isTransitioning: boolean = false; + private lastHideTime: number = 0; + private reopenCooldownMs: number = 300; - init() { - this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e)); - this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e)); - this.eventBus.on(ShowBuildMenuEvent, (e) => { - const clickedCell = this.transformHandler.screenToWorldCoordinates( - e.x, - e.y, - ); - if (clickedCell === null) { - return; - } - if (!this.g.isValidCoord(clickedCell.x, clickedCell.y)) { - return; - } - const tile = this.g.ref(clickedCell.x, clickedCell.y); - const p = this.g.myPlayer(); - if (p === null) { - return; - } - this.buildMenu.showMenu(tile); - }); + private menuGroups: Map< + number, + d3.Selection + > = new Map(); + private menuPaths: Map< + string, + d3.Selection + > = new Map(); + private menuIcons: Map< + string, + d3.Selection + > = new Map(); - this.eventBus.on(CloseViewEvent, () => this.closeMenu()); + private selectedItemId: string | null = null; + private submenuHoverTimeout: number | null = null; + private backButtonHoverTimeout: number | null = null; + private navigationInProgress: boolean = false; + private originalCenterButtonIcon: string = ""; - this.createMenuElement(); + constructor(config: RadialMenuConfig = {}) { + this.config = { + menuSize: config.menuSize ?? 190, + submenuScale: config.submenuScale ?? 1.5, + centerButtonSize: config.centerButtonSize ?? 30, + iconSize: config.iconSize ?? 32, + centerIconSize: config.centerIconSize ?? 48, + disabledColor: config.disabledColor ?? d3.rgb(128, 128, 128).toString(), + menuTransitionDuration: config.menuTransitionDuration ?? 300, + mainMenuInnerRadius: config.mainMenuInnerRadius ?? 40, + centerButtonIcon: config.centerButtonIcon ?? "", + maxNestedLevels: config.maxNestedLevels ?? 3, + innerRadiusIncrement: config.innerRadiusIncrement ?? 20, + tooltipStyle: config.tooltipStyle ?? "", + }; + this.originalCenterButtonIcon = this.config.centerButtonIcon; + this.backIconSize = this.config.centerIconSize * 0.8; } - private closeMenu() { - if (this.isVisible) { - this.hideRadialMenu(); - } - - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - } + init() { + this.createMenuElement(); + this.createTooltipElement(); } private createMenuElement() { + // Create an overlay to catch clicks outside the menu this.menuElement = d3 .select(document.body) .append("div") + .attr("class", "radial-menu-container") .style("position", "fixed") .style("display", "none") .style("z-index", "9999") .style("touch-action", "none") + .style("top", "0") + .style("left", "0") + .style("width", "100vw") + .style("height", "100vh") + .on("click", () => { + this.hideRadialMenu(); + }) .on("contextmenu", (e) => { e.preventDefault(); this.hideRadialMenu(); }); + // Calculate the total svg size needed for all potential nested menus + const totalSize = + this.config.menuSize * + Math.pow(this.config.submenuScale, this.config.maxNestedLevels - 1); + const svg = this.menuElement .append("svg") - .attr("width", this.menuSize) - .attr("height", this.menuSize) + .attr("width", totalSize) + .attr("height", totalSize) + .style("position", "absolute") + .style("top", "50%") + .style("left", "50%") + .style("transform", "translate(-50%, -50%)") + .style("pointer-events", "all") + .on("click", (event) => this.hideRadialMenu()); + + const container = svg .append("g") - .attr( - "transform", - `translate(${this.menuSize / 2},${this.menuSize / 2})`, - ); + .attr("class", "menu-container") + .attr("transform", `translate(${totalSize / 2},${totalSize / 2})`); - const pie = d3 - .pie() - .value(() => 1) - .padAngle(0.03) - .startAngle(Math.PI / 4) // Start at 45 degrees (π/4 radians) - .endAngle(2 * Math.PI + Math.PI / 4); // Complete the circle but shifted by 45 degrees - - const arc = d3 - .arc() - .innerRadius(this.centerButtonSize + 5) - .outerRadius(this.menuSize / 2 - 10); - - const arcs = svg - .selectAll("path") - .data(pie(Array.from(this.menuItems.values()))) - .enter() - .append("g"); - - arcs - .append("path") - .attr("d", arc) - .attr("fill", (d) => - d.data.disabled ? this.disabledColor : d.data.color, - ) - .attr("stroke", "#ffffff") - .attr("stroke-width", "2") - .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) - .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) - .attr("data-name", (d) => d.data.name) - .on("mouseover", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1.05)") - .attr("filter", "url(#glow)"); - } - }) - .on("mouseout", function (event, d) { - if (!d.data.disabled) { - d3.select(this) - .transition() - .duration(200) - .attr("transform", "scale(1)") - .attr("filter", null); - } - }) - .on("click", (event, d) => { - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }) - .on("touchstart", (event, d) => { - event.preventDefault(); - if (!d.data.disabled) { - d.data.action(); - this.hideRadialMenu(); - } - }); - - arcs - .append("image") - .attr("xlink:href", (d) => d.data.icon) - .attr("width", this.iconSize) - .attr("height", this.iconSize) - .attr("x", (d) => arc.centroid(d)[0] - this.iconSize / 2) - .attr("y", (d) => arc.centroid(d)[1] - this.iconSize / 2) - .style("pointer-events", "none") - .attr("data-name", (d) => d.data.name); - - // Add glow filter + // Add glow filter for hover effects const defs = svg.append("defs"); const filter = defs.append("filter").attr("id", "glow"); filter .append("feGaussianBlur") - .attr("stdDeviation", "3") + .attr("stdDeviation", "2") .attr("result", "coloredBlur"); const feMerge = filter.append("feMerge"); feMerge.append("feMergeNode").attr("in", "coloredBlur"); feMerge.append("feMergeNode").attr("in", "SourceGraphic"); - const centerButton = svg.append("g").attr("class", "center-button"); + const centerButton = container.append("g").attr("class", "center-button"); centerButton .append("circle") .attr("class", "center-button-hitbox") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "transparent") .style("cursor", "pointer") - .on("click", () => this.handleCenterButtonClick()) + .on("click", (event) => { + event.stopPropagation(); + this.handleCenterButtonClick(); + }) .on("touchstart", (event: Event) => { event.preventDefault(); + event.stopPropagation(); this.handleCenterButtonClick(); }) .on("mouseover", () => this.onCenterButtonHover(true)) @@ -264,41 +169,878 @@ export class RadialMenu implements Layer { centerButton .append("circle") .attr("class", "center-button-visible") - .attr("r", this.centerButtonSize) + .attr("r", this.config.centerButtonSize) .attr("fill", "#2c3e50") .style("pointer-events", "none"); centerButton .append("image") .attr("class", "center-button-icon") - .attr("xlink:href", swordIcon) - .attr("width", this.centerIconSize) - .attr("height", this.centerIconSize) - .attr("x", -this.centerIconSize / 2) - .attr("y", -this.centerIconSize / 2) + .attr("xlink:href", this.config.centerButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2) .style("pointer-events", "none"); } - async tick() { - // Only update when menu is visible - if (!this.isVisible || this.clickedCell === null) return; - const myPlayer = this.g.myPlayer(); - if (myPlayer === null || !myPlayer.isAlive()) return; - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.originalTileOwner.isPlayer()) { - if (this.g.owner(tile) !== this.originalTileOwner) { - this.closeMenu(); - return; + private createTooltipElement() { + this.tooltipElement = document.createElement("div"); + this.tooltipElement.className = "radial-tooltip"; + this.tooltipElement.style.position = "absolute"; + this.tooltipElement.style.pointerEvents = "none"; + this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)"; + this.tooltipElement.style.color = "white"; + this.tooltipElement.style.padding = "6px 10px"; + this.tooltipElement.style.borderRadius = "6px"; + this.tooltipElement.style.fontSize = "12px"; + this.tooltipElement.style.zIndex = "10000"; + this.tooltipElement.style.maxWidth = "250px"; + this.tooltipElement.style.display = "none"; + document.body.appendChild(this.tooltipElement); + + const style = document.createElement("style"); + style.textContent = ` + .radial-tooltip .title { + font-weight: bold; + font-size: 14px; + margin-bottom: 4px; } + + ${this.config.tooltipStyle} + `; + document.head.appendChild(style); + } + + private getInnerRadiusForLevel(level: number): number { + return level === 0 + ? this.config.mainMenuInnerRadius + : this.config.mainMenuInnerRadius + 34; + } + + private getOuterRadiusForLevel(level: number): number { + const innerRadius = this.getInnerRadiusForLevel(level); + const arcWidth = + this.config.menuSize / 2 - this.config.mainMenuInnerRadius - 10; + return innerRadius + arcWidth; + } + + private renderMenuItems(items: MenuElement[], level: number) { + const container = this.menuElement.select(".menu-container"); + container.selectAll(`.menu-level-${level}`).remove(); + + const menuGroup = container + .append("g") + .attr("class", `menu-level-${level}`); + + // Set initial animation styles + if (level === 0) { + menuGroup.style("opacity", 0.5).style("transform", "scale(0.2)"); } else { - if (this.g.owner(tile).isPlayer() || this.g.owner(tile) === myPlayer) { - this.closeMenu(); + menuGroup.style("opacity", 0).style("transform", "scale(0.5)"); + } + + this.menuGroups.set(level, menuGroup as any); + + const pie = d3 + .pie() + .value(() => 1) + .padAngle(0.03) + .startAngle(Math.PI / 3) + .endAngle(2 * Math.PI + Math.PI / 3); + + const innerRadius = this.getInnerRadiusForLevel(level); + const outerRadius = this.getOuterRadiusForLevel(level); + + const arc = d3 + .arc>() + .innerRadius(innerRadius) + .outerRadius(outerRadius); + + const arcs = menuGroup + .selectAll(".menu-item") + .data(pie(items)) + .enter() + .append("g") + .attr("class", "menu-item-group"); + + this.renderPaths(arcs, arc, level); + this.setupEventHandlers(arcs, level); + this.renderIconsAndText(arcs, arc); + this.setupAnimations(menuGroup); + + return menuGroup; + } + + private renderPaths( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + level: number, + ) { + arcs + .append("path") + .attr("class", "menu-item-path") + .attr("d", arc) + .attr("fill", (d) => { + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + + if (d.data.id === this.selectedItemId && this.currentLevel > level) { + return color; + } + + return d3.color(color)?.copy({ opacity: opacity })?.toString() || color; + }) + .attr("stroke", "#ffffff") + .attr("stroke-width", "2") + .style("cursor", (d) => (d.data.disabled ? "not-allowed" : "pointer")) + .style("opacity", (d) => (d.data.disabled ? 0.5 : 1)) + .style( + "transition", + `filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${ + this.config.menuTransitionDuration / 2 + }ms, fill ${this.config.menuTransitionDuration / 2}ms`, + ) + .attr("data-id", (d) => d.data.id); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + this.menuPaths.set(pathId, path as any); + + if ( + pathId === this.selectedItemId && + level === 0 && + this.currentLevel > 0 + ) { + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + } + }); + + // Disable pointer events on previous menu levels + this.menuGroups.forEach((group, menuLevel) => { + if (menuLevel < this.currentLevel) { + group.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else if (menuLevel === this.currentLevel) { + group.selectAll("path").style("pointer-events", "auto"); + } + }); + } + + private setupEventHandlers( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + level: number, + ) { + const onHover = (d: d3.PieArcDatum, path: any) => { + if ( + d.data.disabled || + (this.currentLevel > 0 && this.currentLevel !== level) || + this.navigationInProgress + ) return; + + path.attr("filter", "url(#glow)"); + path.attr("stroke-width", "3"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + path.attr("fill", color); + + if (d.data.tooltipItems && d.data.tooltipItems.length > 0) { + this.showTooltip(d.data.tooltipItems); + } + + if ( + d.data.children && + d.data.children.length > 0 && + !d.data.disabled && + !( + this.currentLevel > 0 && + d.data.id === this.selectedItemId && + level === 0 + ) + ) { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + } + + // Set a small delay before opening submenu to prevent accidental triggers + this.submenuHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress) return; + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + }, 200); + } + }; + + const onMouseOut = (d: d3.PieArcDatum, path: any) => { + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + this.hideTooltip(); + + if ( + d.data.disabled || + (this.currentLevel > 0 && + level === 0 && + d.data.id === this.selectedItemId) + ) + return; + path.attr("filter", null); + path.attr("stroke-width", "2"); + const color = d.data.disabled + ? this.config.disabledColor + : d.data.color || "#333333"; + const opacity = d.data.disabled ? 0.5 : 0.7; + path.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + }; + + const onClick = (d: d3.PieArcDatum, event: Event) => { + event.stopPropagation(); + if (d.data.disabled || this.navigationInProgress) return; + + if ( + this.currentLevel > 0 && + level === 0 && + d.data.id !== this.selectedItemId + ) + return; + + if (d.data.children && d.data.children.length > 0) { + this.navigationInProgress = true; + this.selectedItemId = d.data.id; + this.navigateToSubMenu(d.data.children || []); + this.setCenterButtonAsBack(); + } else if (d.data._action) { + d.data._action(); + this.hideRadialMenu(); + } else { + throw new Error(`Menu item action is not a function: ${d.data.id}`); + } + }; + + function handleMouseMove(event: MouseEvent) { + const tooltipEl = document.querySelector( + ".radial-tooltip", + ) as HTMLElement; + if (tooltipEl && tooltipEl.style.display !== "none") { + tooltipEl.style.left = event.pageX + 10 + "px"; + tooltipEl.style.top = event.pageY + 10 + "px"; } } - const actions = await myPlayer.actions(tile); - this.disableAllButtons(); - this.handlePlayerActions(myPlayer, actions, tile); + + arcs.each((d) => { + const pathId = d.data.id; + const path = d3.select(`path[data-id="${pathId}"]`); + + path.on("mouseover", function () { + onHover(d, path); + }); + + path.on("mouseout", function () { + onMouseOut(d, path); + }); + + path.on("mousemove", function (event) { + handleMouseMove(event as MouseEvent); + }); + + path.on("click", function (event) { + onClick(d, event); + }); + + path.on("touchstart", function (event) { + event.preventDefault(); + event.stopPropagation(); + onClick(d, event); + }); + }); + } + + private renderIconsAndText( + arcs: d3.Selection< + SVGGElement, + d3.PieArcDatum, + SVGGElement, + unknown + >, + arc: d3.Arc>, + ) { + arcs + .append("g") + .attr("class", "menu-item-content") + .style("pointer-events", "none") + .attr("data-id", (d) => d.data.id) + .each((d) => { + const contentId = d.data.id; + const content = d3.select(`g[data-id="${contentId}"]`); + + if (d.data.text) { + content + .append("text") + .attr("text-anchor", "middle") + .attr("dominant-baseline", "central") + .attr("x", arc.centroid(d)[0]) + .attr("y", arc.centroid(d)[1]) + .attr("fill", "white") + .attr("font-size", d.data.fontSize ?? "12px") + .attr("font-family", "Arial, sans-serif") + .style("opacity", d.data.disabled ? 0.5 : 1) + .text(d.data.text); + } else { + content + .append("image") + .attr( + "xlink:href", + d.data.disabled ? disabledIcon : d.data.icon || disabledIcon, + ) + .attr("width", this.config.iconSize) + .attr("height", this.config.iconSize) + .attr("x", arc.centroid(d)[0] - this.config.iconSize / 2) + .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2); + } + + this.menuIcons.set(contentId, content as any); + }); + } + + private setupAnimations( + menuGroup: d3.Selection, + ) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("opacity", 1) + .style("transform", "scale(1)") + .on("start", () => { + this.isTransitioning = true; + }) + .on("end", () => { + this.isTransitioning = false; + }); + } + + private navigateToSubMenu(children: MenuElement[]) { + this.isTransitioning = true; + + this.menuStack.push(this.currentMenuItems); + this.currentMenuItems = children; + this.currentLevel++; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.updateMenuGroupVisibility(); + this.animatePreviousMenu(); + } + + private updateMenuGroupVisibility() { + // Hide all menus except the current and immediate previous one + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + } else if (level === this.currentLevel - 1) { + menuGroup.style("display", "block"); + + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.59)") + .style("opacity", 0.8); + + menuGroup.selectAll("path").each(function () { + const pathElement = d3.select(this); + pathElement.style("pointer-events", "none"); + }); + } else { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animatePreviousMenu() { + const container = this.menuElement.select(".menu-container"); + const currentMenu = container.select( + `.menu-level-${this.currentLevel - 1}`, + ); + + currentMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`) + .style("opacity", 0.8) + .on("end", () => { + this.navigationInProgress = false; + }); + } + + private navigateBack() { + if (this.menuStack.length === 0) { + return; + } + + this.isTransitioning = true; + + this.updateMenuLevels(); + this.clearSelectedItemHoverState(); + this.updateMenuVisibility(); + this.animateMenuTransitions(); + } + + private updateMenuLevels() { + const previousItems = this.menuStack.pop(); + const previousLevel = this.currentLevel - 1; + this.currentLevel = previousLevel; + + if (previousLevel === 0) { + this.selectedItemId = null; + } + + this.currentMenuItems = previousItems || []; + + if (this.currentLevel === 0) { + this.resetCenterButton(); + } + } + + private clearSelectedItemHoverState() { + // Clear the hover state on the item that opened the submenu + if (this.selectedItemId) { + const selectedPath = this.menuPaths.get(this.selectedItemId); + if (selectedPath) { + selectedPath.attr("filter", null); + selectedPath.attr("stroke-width", "2"); + + const item = this.findMenuItem(this.selectedItemId); + if (item) { + const color = item.disabled + ? this.config.disabledColor + : item.color || "#333333"; + const opacity = item.disabled ? 0.5 : 0.7; + selectedPath.attr( + "fill", + d3.color(color)?.copy({ opacity: opacity })?.toString() || color, + ); + } + } + } + } + + private updateMenuVisibility() { + this.menuGroups.forEach((menuGroup, level) => { + if (level === this.currentLevel) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1); + + menuGroup.selectAll("path").style("pointer-events", "auto"); + } else if (level === this.currentLevel - 1 && this.currentLevel > 0) { + menuGroup.style("display", "block"); + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style( + "transform", + `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`, + ) + .style("opacity", 0.8); + } else if (level !== this.currentLevel + 1) { + menuGroup + .transition() + .duration(this.config.menuTransitionDuration * 0.5) + .style("opacity", 0) + .on("end", function () { + d3.select(this).style("display", "none"); + }); + } + }); + } + + private animateMenuTransitions() { + const container = this.menuElement.select(".menu-container"); + const currentSubmenu = container.select( + `.menu-level-${this.currentLevel + 1}`, + ); + const previousMenu = container.select(`.menu-level-${this.currentLevel}`); + + // Animate the current submenu (sliding out) + currentSubmenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(0.5)") + .style("opacity", 0) + .on("end", function () { + d3.select(this).remove(); + }); + + // Handle previous menu animation + if (previousMenu.empty()) { + this.renderAndAnimateNewMenu(); + } else { + this.animateExistingMenu(previousMenu); + } + } + + private renderAndAnimateNewMenu() { + const menu = this.renderMenuItems(this.currentMenuItems, this.currentLevel); + menu + .style("transform", "scale(0.8)") + .style("opacity", 0.3) + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + } + + private animateExistingMenu( + previousMenu: d3.Selection, + ) { + previousMenu + .transition() + .duration(this.config.menuTransitionDuration * 0.8) + .style("transform", "scale(1)") + .style("opacity", 1) + .on("end", () => { + this.isTransitioning = false; + this.navigationInProgress = false; + }); + + previousMenu.selectAll("path").style("pointer-events", "auto"); + } + + private setCenterButtonAsBack() { + if (this.currentLevel === 1) { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + } + + this.backAction = () => { + this.navigateBack(); + }; + + // Clear any hover state on the center button + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + this.menuElement + .select(".center-button-visible") + .transition() + .duration(0) + .attr("r", this.config.centerButtonSize); + + const backIconImg = this.menuElement.select(".center-button-icon"); + backIconImg + .attr("xlink:href", backIcon) + .attr("width", this.backIconSize) + .attr("height", this.backIconSize) + .attr("x", -this.backIconSize / 2) + .attr("y", -this.backIconSize / 2); + + this.enableCenterButton(true, this.backAction); + } + + private resetCenterButton() { + this.backAction = null; + + const iconImg = this.menuElement.select(".center-button-icon"); + iconImg + .attr("xlink:href", this.originalCenterButtonIcon) + .attr("width", this.config.centerIconSize) + .attr("height", this.config.centerIconSize) + .attr("x", -this.config.centerIconSize / 2) + .attr("y", -this.config.centerIconSize / 2); + + this.enableCenterButton( + this.originalCenterButtonEnabled, + this.originalCenterButtonAction, + ); + } + + public showRadialMenu(x: number, y: number) { + if (!this.isReopeningAllowed()) return; + + this.resetMenu(); + this.isTransitioning = false; + this.selectedItemId = null; + + this.menuElement.style("display", "block"); + + this.menuElement + .select("svg") + .style("top", `${y}px`) + .style("left", `${x}px`) + .style("transform", `translate(-50%, -50%)`); + + this.isVisible = true; + + this.renderMenuItems(this.currentMenuItems, this.currentLevel); + this.onCenterButtonHover(true); + } + + public hideRadialMenu() { + if (!this.isVisible || this.isTransitioning) { + return; + } + + this.menuElement.style("display", "none"); + this.isVisible = false; + this.selectedItemId = null; + this.hideTooltip(); + + this.resetMenu(); + this.isTransitioning = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + this.lastHideTime = Date.now(); + } + + private handleCenterButtonClick() { + if ( + !this.isCenterButtonEnabled || + !this.centerButtonAction || + this.navigationInProgress + ) { + return; + } + + if (this.currentLevel > 0 && this.backAction) { + this.navigationInProgress = true; + } + + this.centerButtonAction(); + } + + public disableAllButtons() { + this.originalCenterButtonEnabled = this.isCenterButtonEnabled; + this.originalCenterButtonAction = this.centerButtonAction; + + this.enableCenterButton(false); + + for (const item of this.currentMenuItems) { + item.disabled = true; + item.color = this.config.disabledColor; + } + } + + public enableCenterButton(enabled: boolean, action?: (() => void) | null) { + if (this.currentLevel > 0 && this.backAction) { + this.isCenterButtonEnabled = true; + + if (action !== undefined && action !== this.backAction) { + this.originalCenterButtonAction = action; + } + + this.centerButtonAction = this.backAction; + } else { + this.isCenterButtonEnabled = enabled; + if (action !== undefined) { + this.centerButtonAction = action; + } + } + + const centerButton = this.menuElement.select(".center-button"); + + centerButton + .select(".center-button-hitbox") + .style("cursor", this.isCenterButtonEnabled ? "pointer" : "not-allowed"); + + centerButton + .select(".center-button-visible") + .attr("fill", this.isCenterButtonEnabled ? "#2c3e50" : "#999999"); + + centerButton + .select(".center-button-icon") + .style("opacity", this.isCenterButtonEnabled ? 1 : 0.5); + } + + private onCenterButtonHover(isHovering: boolean) { + if (!this.isCenterButtonEnabled) return; + + const scale = isHovering ? 1.2 : 1; + + this.menuElement + .select(".center-button-hitbox") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + this.menuElement + .select(".center-button-visible") + .transition() + .duration(200) + .attr("r", this.config.centerButtonSize * scale); + + if (this.currentLevel > 0 && this.backAction) { + if (isHovering) { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + } + + this.backButtonHoverTimeout = window.setTimeout(() => { + if (this.navigationInProgress || !this.backAction) return; + + this.navigationInProgress = true; + this.backAction(); + }, 300); + } else { + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + } + } + + public isMenuVisible(): boolean { + return this.isVisible; + } + + public getCurrentLevel(): number { + return this.currentLevel; + } + + public updateMenuItem( + id: string, + enabled: boolean, + color?: string, + icon?: string, + text?: string, + ) { + const path = this.menuPaths.get(id); + if (!path) return; + + const item = this.findMenuItem(id); + if (item) { + item.disabled = !enabled; + if (color) item.color = enabled ? color : this.config.disabledColor; + if (icon) item.icon = icon; + if (text !== undefined) item.text = text; + } + + const fillColor = enabled && color ? color : this.config.disabledColor; + const opacity = enabled ? 0.7 : 0.5; + + const isSelected = id === this.selectedItemId && this.currentLevel > 0; + const finalOpacity = isSelected ? 1.0 : opacity; + + path + .attr( + "fill", + d3.color(fillColor)?.copy({ opacity: finalOpacity })?.toString() || + fillColor, + ) + .style("opacity", enabled ? 1 : 0.5) + .style("cursor", enabled ? "pointer" : "not-allowed"); + + const iconElement = this.menuIcons.get(id); + if (iconElement) { + if (item?.text) { + const textElement = iconElement.select("text"); + if (textElement.size() > 0) { + textElement + .style("opacity", enabled ? 1 : 0.5) + .text(text || item.text); + } + } else if (icon) { + const imageElement = iconElement.select("image"); + if (imageElement.size() > 0) { + imageElement.attr("xlink:href", enabled ? icon : disabledIcon); + } + } + } + } + + public setRootMenuItems(items: MenuElement[]) { + this.currentMenuItems = [...items]; + this.rootMenuItems = [...items]; + if (this.isVisible) { + this.refreshMenu(); + } + } + + private findMenuItem(id: string): MenuElement | undefined { + return this.currentMenuItems.find((item) => item.id === id); + } + + private resetMenu() { + this.currentLevel = 0; + this.menuStack = []; + + this.currentMenuItems = [...this.rootMenuItems]; + + this.backAction = null; + this.navigationInProgress = false; + + this.menuGroups.clear(); + this.menuPaths.clear(); + this.menuIcons.clear(); + + const menuContainer = this.menuElement?.select(".menu-container"); + if (menuContainer) { + menuContainer.selectAll("[class^='menu-level-']").remove(); + } + + this.resetCenterButton(); + + if (this.submenuHoverTimeout !== null) { + window.clearTimeout(this.submenuHoverTimeout); + this.submenuHoverTimeout = null; + } + + if (this.backButtonHoverTimeout !== null) { + window.clearTimeout(this.backButtonHoverTimeout); + this.backButtonHoverTimeout = null; + } + } + + public refreshMenu() { + if (!this.isVisible) return; + this.renderMenuItems(this.currentMenuItems, this.currentLevel); } renderLayer(context: CanvasRenderingContext2D) { @@ -309,241 +1051,30 @@ export class RadialMenu implements Layer { return false; } - private onContextMenu(event: ContextMenuEvent) { - if (this.lastClosed + 200 > new Date().getTime()) return; - if (this.buildMenu.isVisible) { - this.buildMenu.hideMenu(); - return; - } - if (this.isVisible) { - this.hideRadialMenu(); - return; - } else { - this.showRadialMenu(event.x, event.y); - } - this.disableAllButtons(); - this.clickedCell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.g.isValidCoord(this.clickedCell.x, this.clickedCell.y)) { - return; - } - const tile = this.g.ref(this.clickedCell.x, this.clickedCell.y); - this.originalTileOwner = this.g.owner(tile); - if (this.g.inSpawnPhase()) { - if (this.g.isLand(tile) && !this.g.hasOwner(tile)) { - this.enableCenterButton(true); - } - return; - } - - const myPlayer = this.g.myPlayer(); - if (myPlayer === null) { - console.warn("my player not found"); - return; - } - if (myPlayer && !myPlayer.isAlive() && !this.g.inSpawnPhase()) { - return this.hideRadialMenu(); - } - myPlayer.actions(tile).then((actions) => { - this.handlePlayerActions(myPlayer, actions, tile); - }); + private isReopeningAllowed(): boolean { + const now = Date.now(); + const timeSinceHide = now - this.lastHideTime; + return timeSinceHide >= this.reopenCooldownMs; } - private handlePlayerActions( - myPlayer: PlayerView, - actions: PlayerActions, - tile: TileRef, - ) { - if (!this.g.inSpawnPhase()) { - this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => { - this.buildMenu.showMenu(tile); - }); + private showTooltip(items: TooltipItem[]) { + if (!this.tooltipElement) return; + + this.tooltipElement.innerHTML = ""; + + for (const item of items) { + const div = document.createElement("div"); + div.className = item.className; + div.textContent = item.text; + this.tooltipElement.appendChild(div); } - if (this.g.hasOwner(tile)) { - this.activateMenuElement(Slot.Info, "#64748B", infoIcon, () => { - this.playerPanel.show(actions, tile); - }); - } - - if (actions?.interaction?.canSendAllianceRequest) { - this.activateMenuElement(Slot.Ally, "#53ac75", allianceIcon, () => { - this.eventBus.emit( - new SendAllianceRequestIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if (actions?.interaction?.canBreakAlliance) { - this.activateMenuElement(Slot.Ally, "#c74848", traitorIcon, () => { - this.eventBus.emit( - new SendBreakAllianceIntentEvent( - myPlayer, - this.g.owner(tile) as PlayerView, - ), - ); - }); - } - if ( - actions.buildableUnits.find((bu) => bu.type === UnitType.TransportShip) - ?.canBuild - ) { - this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => { - // BestTransportShipSpawn is an expensive operation, so - // we calculate it here and send the spawn tile to other clients. - myPlayer.bestTransportShipSpawn(tile).then((spawn) => { - let spawnTile: Cell | null = null; - if (spawn !== false) { - spawnTile = new Cell(this.g.x(spawn), this.g.y(spawn)); - } - - if (this.clickedCell === null) return; - this.eventBus.emit( - new SendBoatAttackIntentEvent( - this.g.owner(tile).id(), - this.clickedCell, - this.uiState.attackRatio * myPlayer.troops(), - spawnTile, - ), - ); - }); - }); - } - if (actions.canAttack) { - this.enableCenterButton(true); - } - - if (!this.g.hasOwner(tile)) { - return; - } + this.tooltipElement.style.display = "block"; } - private onPointerUp(event: MouseUpEvent) { - this.hideRadialMenu(); - this.emojiTable.hideTable(); - this.buildMenu.hideMenu(); - this.playerInfoOverlay.hide(); - } - - private showRadialMenu(x: number, y: number) { - // Delay so center button isn't clicked immediately on press. - setTimeout(() => { - this.menuElement - .style("left", `${x - this.menuSize / 2}px`) - .style("top", `${y - this.menuSize / 2}px`) - .style("display", "block"); - this.playerInfoOverlay.maybeShow(x, y); - this.isVisible = true; - }, 50); - } - - private hideRadialMenu() { - this.menuElement.style("display", "none"); - this.isVisible = false; - this.playerInfoOverlay.hide(); - this.lastClosed = new Date().getTime(); - } - - private handleCenterButtonClick() { - if (!this.isCenterButtonEnabled) { - return; + private hideTooltip() { + if (this.tooltipElement) { + this.tooltipElement.style.display = "none"; } - console.log("Center button clicked"); - if (this.clickedCell === null) return; - const clicked = this.g.ref(this.clickedCell.x, this.clickedCell.y); - if (this.g.inSpawnPhase()) { - this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell)); - } else { - const myPlayer = this.g.myPlayer(); - if (myPlayer !== null && this.g.owner(clicked) !== myPlayer) { - this.eventBus.emit( - new SendAttackIntentEvent( - this.g.owner(clicked).id(), - this.uiState.attackRatio * myPlayer.troops(), - ), - ); - } - } - this.hideRadialMenu(); - } - - private disableAllButtons() { - this.enableCenterButton(false); - for (const item of this.menuItems.values()) { - item.disabled = true; - this.updateMenuItemState(item); - } - } - - private activateMenuElement( - slot: Slot, - color: string, - icon: string, - action: () => void, - ) { - const menuItem = this.menuItems.get(slot); - if (menuItem === undefined) return; - menuItem.action = action; - menuItem.disabled = false; - menuItem.color = color; - menuItem.icon = icon; - this.updateMenuItemState(menuItem); - } - - private updateMenuItemState(item: any) { - const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`); - menuItem - .attr("fill", item.disabled ? this.disabledColor : item.color) - .style("cursor", item.disabled ? "not-allowed" : "pointer") - .style("opacity", item.disabled ? 0.5 : 1); - - this.menuElement - .select(`image[data-name="${item.name}"]`) - .attr("xlink:href", item.disabled ? disabledIcon : item.icon) - .attr("fill", item.disabled ? "#999999" : "white"); - } - - private onCenterButtonHover(isHovering: boolean) { - if (!this.isCenterButtonEnabled) return; - - const scale = isHovering ? 1.2 : 1; - const fontSize = isHovering ? "18px" : "16px"; - - this.menuElement - .select(".center-button-hitbox") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-visible") - .transition() - .duration(200) - .attr("r", this.centerButtonSize * scale); - this.menuElement - .select(".center-button-text") - .transition() - .duration(200) - .style("font-size", fontSize); - } - - private enableCenterButton(enabled: boolean) { - this.isCenterButtonEnabled = enabled; - const centerButton = this.menuElement.select(".center-button"); - - centerButton - .select(".center-button-hitbox") - .style("cursor", enabled ? "pointer" : "not-allowed"); - - centerButton - .select(".center-button-visible") - .attr("fill", enabled ? "#2c3e50" : "#999999"); - - centerButton - .select(".center-button-text") - .attr("fill", enabled ? "white" : "#cccccc"); } } diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts new file mode 100644 index 000000000..1b5ed0fa0 --- /dev/null +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -0,0 +1,471 @@ +import { + AllPlayers, + Cell, + PlayerActions, + TerraNullius, + UnitType, +} from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView, PlayerView } from "../../../core/game/GameView"; +import { flattenedEmojiTable } from "../../../core/Util"; +import { renderNumber, translateText } from "../../Utils"; +import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; +import { ChatIntegration } from "./ChatIntegration"; +import { EmojiTable } from "./EmojiTable"; +import { PlayerActionHandler } from "./PlayerActionHandler"; +import { PlayerPanel } from "./PlayerPanel"; +import { TooltipItem } from "./RadialMenu"; + +import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg"; +import boatIcon from "../../../../resources/images/BoatIconWhite.svg"; +import buildIcon from "../../../../resources/images/BuildIconWhite.svg"; +import chatIcon from "../../../../resources/images/ChatIconWhite.svg"; +import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg"; +import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg"; +import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; +import infoIcon from "../../../../resources/images/InfoIcon.svg"; +import targetIcon from "../../../../resources/images/TargetIconWhite.svg"; +import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; + +export interface MenuElementParams { + myPlayer: PlayerView; + selected: PlayerView | null; + tileOwner: PlayerView | TerraNullius; + tile: TileRef; + playerActions: PlayerActions; + game: GameView; + buildMenu: BuildMenu; + emojiTable: EmojiTable; + playerActionHandler: PlayerActionHandler; + playerPanel: PlayerPanel; + chatIntegration: ChatIntegration; + closeMenu: () => void; +} + +export interface MenuElement { + id: string; + name: string; + disabled: boolean; + displayed?: boolean; + color?: string; + icon?: string; + text?: string; + fontSize?: string; + tooltipItems?: TooltipItem[]; + + action?: (params: MenuElementParams) => void; // For leaf items that perform actions + subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus + + // Runtime properties used by RadialMenu (not to be set by menu element creators) + children?: MenuElement[]; + _action?: () => void; +} + +export const COLORS = { + build: "#ebe250", + building: "#2c2c2c", + boat: "#3f6ab1", + ally: "#53ac75", + breakAlly: "#c74848", + info: "#64748B", + target: "#ff0000", + infoDetails: "#7f8c8d", + infoEmoji: "#f1c40f", + trade: "#008080", + embargo: "#6600cc", + tooltip: { + cost: "#ffd700", + count: "#aaa", + }, + chat: { + default: "#66c", + help: "#4caf50", + attack: "#f44336", + defend: "#2196f3", + greet: "#ff9800", + misc: "#9c27b0", + warnings: "#e3c532", + }, +}; + +export enum Slot { + Info = "info", + Boat = "boat", + Build = "build", + Ally = "ally", + Back = "back", +} + +/** + * Convert a MenuElement tree to a version usable by the RadialMenu + * by resolving subMenu functions and setting up actions + */ +export function prepareMenuElementsForRadialMenu( + elements: MenuElement[], + params: MenuElementParams, +): MenuElement[] { + return elements.map((element) => { + const prepared: MenuElement = { ...element }; + + // If the element has a subMenu function, execute it to get the children + if (element.subMenu) { + prepared.children = prepareMenuElementsForRadialMenu( + element.subMenu(params), + params, + ); + // We don't need the subMenu function anymore + prepared.subMenu = undefined; + } + + // Set up the action function to call the element's action with params + if (element.action) { + prepared._action = () => element.action!(params); + } else { + prepared._action = () => {}; + } + + return prepared; + }); +} + +export const buildMenuElement: MenuElement = { + id: Slot.Build, + name: "build", + disabled: false, + icon: buildIcon, + color: COLORS.build, + + subMenu: (params: MenuElementParams) => { + const buildElements: MenuElement[] = flattenedBuildTable.map( + (item: BuildItemDisplay) => ({ + id: `build_${item.unitType}`, + name: item.key + ? item.key.replace("unit_type.", "") + : item.unitType.toString(), + disabled: !params.buildMenu.canBuild(item), + color: params.buildMenu.canBuild(item) ? COLORS.building : undefined, + icon: item.icon, + tooltipItems: [ + { text: translateText(item.key || ""), className: "title" }, + { + text: translateText(item.description || ""), + className: "description", + }, + { + text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`, + className: "cost", + }, + item.countable + ? { text: `${params.buildMenu.count(item)}x`, className: "count" } + : null, + ].filter((item): item is TooltipItem => item !== null), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleBuildUnit( + item.unitType, + params.game.x(params.tile), + params.game.y(params.tile), + ); + params.closeMenu(); + }, + }), + ); + + buildElements.push({ + id: "build_menu", + name: "build", + disabled: false, + color: COLORS.build, + icon: buildIcon, + action: (params: MenuElementParams) => { + params.buildMenu.showMenu(params.tile); + }, + }); + + return buildElements; + }, +}; + +export const boatMenuElement: MenuElement = { + id: Slot.Boat, + name: "boat", + disabled: false, + icon: boatIcon, + color: COLORS.boat, + + action: async (params: MenuElementParams) => { + if (!params.selected) return; + + const spawn = await params.playerActionHandler.findBestTransportShipSpawn( + params.myPlayer, + params.tile, + ); + + let spawnTile: Cell | null = null; + if (spawn !== false) { + spawnTile = new Cell(params.game.x(spawn), params.game.y(spawn)); + } + + params.playerActionHandler.handleBoatAttack( + params.myPlayer, + params.selected.id(), + new Cell(params.game.x(params.tile), params.game.y(params.tile)), + spawnTile, + ); + + params.closeMenu(); + }, +}; + +export const infoMenuElement: MenuElement = { + id: Slot.Info, + name: "info", + disabled: false, + icon: infoIcon, + color: COLORS.info, + + subMenu: (params: MenuElementParams) => { + if (!params.selected) return []; + + return [ + { + id: "info_chat", + name: "chat", + disabled: false, + color: COLORS.chat.default, + icon: chatIcon, + subMenu: (params: MenuElementParams) => + params.chatIntegration + .createQuickChatMenu(params.selected!) + .map((item) => ({ + ...item, + action: item.action + ? (_params: MenuElementParams) => item.action!(params) + : undefined, + })), + }, + { + id: "ally_target", + name: "target", + disabled: false, + color: COLORS.target, + icon: targetIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleTargetPlayer(params.selected!.id()); + params.closeMenu(); + }, + }, + { + id: "ally_trade", + name: "trade", + disabled: !!params.playerActions?.interaction?.canEmbargo, + displayed: !params.playerActions?.interaction?.canEmbargo, + color: COLORS.trade, + text: translateText("player_panel.start_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "start"); + params.closeMenu(); + }, + }, + { + id: "ally_embargo", + name: "embargo", + disabled: !params.playerActions?.interaction?.canEmbargo, + displayed: !!params.playerActions?.interaction?.canEmbargo, + color: COLORS.embargo, + text: translateText("player_panel.stop_trade"), + action: (params: MenuElementParams) => { + params.playerActionHandler.handleEmbargo(params.selected!, "stop"); + params.closeMenu(); + }, + }, + { + id: "ally_request", + name: "request", + disabled: !params.playerActions?.interaction?.canSendAllianceRequest, + displayed: !params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.ally, + icon: allianceIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleAllianceRequest( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + { + id: "ally_break", + name: "break", + disabled: !params.playerActions?.interaction?.canBreakAlliance, + displayed: !!params.playerActions?.interaction?.canBreakAlliance, + color: COLORS.breakAlly, + icon: traitorIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleBreakAlliance( + params.myPlayer, + params.selected!, + ); + params.closeMenu(); + }, + }, + + { + id: "ally_donate_gold", + name: "donate gold", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateGoldIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateGold(params.selected!); + params.closeMenu(); + }, + }, + { + id: "ally_donate_troops", + name: "donate troops", + disabled: !params.playerActions?.interaction?.canDonate, + color: COLORS.ally, + icon: donateTroopIcon, + action: (params: MenuElementParams) => { + params.playerActionHandler.handleDonateTroops(params.selected!); + params.closeMenu(); + }, + }, + { + id: "info_player", + name: "player", + disabled: false, + color: COLORS.info, + icon: infoIcon, + action: (params: MenuElementParams) => { + params.playerPanel.show(params.playerActions, params.tile); + }, + }, + { + id: "info_emoji", + name: "emoji", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + subMenu: () => { + const emojiElements: MenuElement[] = []; + + const emojiCount = 15; + for (let i = 0; i < emojiCount; i++) { + emojiElements.push({ + id: `emoji_${i}`, + name: flattenedEmojiTable[i], + text: flattenedEmojiTable[i], + disabled: false, + fontSize: "25px", + action: (params: MenuElementParams) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji(targetPlayer!, i); + params.closeMenu(); + }, + }); + } + + emojiElements.push({ + id: "emoji_more", + name: "more", + disabled: false, + color: COLORS.infoEmoji, + icon: emojiIcon, + action: (params: MenuElementParams) => { + params.emojiTable.showTable((emoji) => { + const targetPlayer = + params.selected === params.game.myPlayer() + ? AllPlayers + : params.selected; + params.playerActionHandler.handleEmoji( + targetPlayer!, + flattenedEmojiTable.indexOf(emoji), + ); + params.emojiTable.hideTable(); + }); + }, + }); + + return emojiElements; + }, + }, + ].filter((item) => item.displayed !== false); + }, +}; + +export function createMenuItems(params: MenuElementParams): MenuElement[] { + const canBuildTransport = params.playerActions.buildableUnits.find( + (bu) => bu.type === UnitType.TransportShip, + )?.canBuild; + + return [ + { + ...boatMenuElement, + disabled: !canBuildTransport || !params.selected, + }, + { + ...buildMenuElement, + disabled: params.game.inSpawnPhase(), + }, + { + ...infoMenuElement, + disabled: !params.game.hasOwner(params.tile), + }, + ]; +} + +export function createRadialMenuItems( + params: MenuElementParams, +): MenuElement[] { + const elements = createMenuItems(params); + return prepareMenuElementsForRadialMenu(elements, params); +} + +export function getRootMenuItems(): MenuElement[] { + return [ + { + id: Slot.Boat, + name: "boat", + disabled: true, + _action: () => {}, + icon: boatIcon, + }, + { + id: Slot.Build, + name: "build", + disabled: true, + _action: () => {}, + icon: buildIcon, + }, + { + id: Slot.Info, + name: "info", + disabled: true, + _action: () => {}, + icon: infoIcon, + }, + ]; +} + +export function updateCenterButton( + params: MenuElementParams, + enableCenterButton: (enabled: boolean, action?: (() => void) | null) => void, +) { + if (params.playerActions.canAttack) { + enableCenterButton(true, () => { + if (params.tileOwner !== params.myPlayer) { + params.playerActionHandler.handleAttack( + params.myPlayer, + params.tileOwner.id(), + ); + } + params.closeMenu(); + }); + } else { + enableCenterButton(false); + } +}