From ce991f97a792af498bd23fede3e99f0008f9dcd4 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 21 Jun 2025 19:29:30 -0700 Subject: [PATCH] Refactor radial menu (#1246) ## Description: Refactor & clean up the Radial menu. * Only show certain build menu items, depending on whether or not you clicked on your own territory * show items as greyed out instead of just the disabled icon * remove back button on hover trigger ## 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: evan --- src/client/graphics/GameRenderer.ts | 1 - src/client/graphics/layers/MainRadialMenu.ts | 241 ++++--------- .../graphics/layers/MenuEventManager.ts | 185 ---------- src/client/graphics/layers/RadialMenu.ts | 327 +++++++----------- .../graphics/layers/RadialMenuElements.ts | 142 +++++--- 5 files changed, 282 insertions(+), 614 deletions(-) delete mode 100644 src/client/graphics/layers/MenuEventManager.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 200239e74..19fc4616b 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -230,7 +230,6 @@ export function createRenderer( emojiTable as EmojiTable, buildMenu, uiState, - playerInfo, playerPanel, ), new SpawnTimer(game, transformHandler), diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index eb93a2642..889052357 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -1,7 +1,7 @@ import { LitElement } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; -import { PlayerActions, UnitType } from "../../../core/game/Game"; +import { PlayerActions, TerraNullius } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; @@ -10,34 +10,29 @@ 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 { + centerButtonElement, COLORS, MenuElementParams, rootMenuItems, - Slot, } 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"; +import { ContextMenuEvent } from "../../InputHandler"; @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; + private clickedTile: TileRef | null = null; + private selectedPlayer: PlayerView | TerraNullius | null = null; + constructor( private eventBus: EventBus, private game: GameView, @@ -45,7 +40,6 @@ export class MainRadialMenu extends LitElement implements Layer { private emojiTable: EmojiTable, private buildMenu: BuildMenu, private uiState: UIState, - private playerInfoOverlay: PlayerInfoOverlay, private playerPanel: PlayerPanel, ) { super(); @@ -70,36 +64,47 @@ export class MainRadialMenu extends LitElement implements Layer { 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(rootMenuItems); + this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement); } init() { this.radialMenu.init(); - - this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => { - this.handlePlayerActions(myPlayer, actions, tile); + this.eventBus.on(ContextMenuEvent, (event) => { + const worldCoords = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { + return; + } + if (this.game.myPlayer() === null) { + return; + } + this.clickedTile = this.game.ref(worldCoords.x, worldCoords.y); + this.selectedPlayer = this.game.owner(this.clickedTile); + this.game + .myPlayer()! + .actions(this.clickedTile) + .then((actions) => { + this.handlePlayerActions( + this.game.myPlayer()!, + actions, + this.clickedTile!, + event.x, + event.y, + ); + }); }); - - this.menuEventManager.init(); } private async handlePlayerActions( myPlayer: PlayerView, actions: PlayerActions, tile: TileRef, + screenX: number, + screenY: number, ) { this.buildMenu.playerActions = actions; @@ -113,7 +118,6 @@ export class MainRadialMenu extends LitElement implements Layer { const params: MenuElementParams = { myPlayer, selected: recipient, - tileOwner, tile, playerActions: actions, game: this.game, @@ -122,150 +126,22 @@ export class MainRadialMenu extends LitElement implements Layer { playerActionHandler: this.playerActionHandler, playerPanel: this.playerPanel, chatIntegration: this.chatIntegration, - closeMenu: () => this.menuEventManager.closeMenu(), + closeMenu: () => this.closeMenu(), }; - this.radialMenu.setRootMenuItems(rootMenuItems); + this.radialMenu.setRootMenuItems(rootMenuItems, centerButtonElement); this.radialMenu.setParams(params); - - updateCenterButton(params, (enabled, action) => { - this.radialMenu.enableCenterButton(enabled, action); - }); + this.radialMenu.showRadialMenu(screenX, screenY); } 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 - ) { + if (!this.radialMenu.isMenuVisible() || this.clickedTile === null) return; + if (this.selectedPlayer === null) return; + const currentPlayer = this.game.owner(this.clickedTile); + if (currentPlayer.id() !== this.selectedPlayer.id()) { + this.closeMenu(); 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) { @@ -279,23 +155,22 @@ export class MainRadialMenu extends LitElement implements Layer { redraw() { // No redraw implementation needed } -} -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); + 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(); + } } } diff --git a/src/client/graphics/layers/MenuEventManager.ts b/src/client/graphics/layers/MenuEventManager.ts deleted file mode 100644 index 1104529b2..000000000 --- a/src/client/graphics/layers/MenuEventManager.ts +++ /dev/null @@ -1,185 +0,0 @@ -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/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 561c5523b..199f28cf6 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -1,8 +1,11 @@ import * as d3 from "d3"; import backIcon from "../../../../resources/images/BackIconWhite.svg"; -import disabledIcon from "../../../../resources/images/DisabledIcon.svg"; import { Layer } from "./Layer"; -import { MenuElement, MenuElementParams } from "./RadialMenuElements"; +import { + CenterButtonElement, + MenuElement, + MenuElementParams, +} from "./RadialMenuElements"; export interface TooltipItem { text: string; @@ -24,6 +27,8 @@ export interface RadialMenuConfig { tooltipStyle?: string; } +type CenterButtonState = "default" | "back"; + type RequiredRadialMenuConfig = Required; export class RadialMenu implements Layer { @@ -39,11 +44,8 @@ export class RadialMenu implements Layer { 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; + private centerButtonState: CenterButtonState = "default"; + private centerButtonElement: CenterButtonElement | null = null; private isTransitioning: boolean = false; private lastHideTime: number = 0; @@ -68,7 +70,7 @@ export class RadialMenu implements Layer { private navigationInProgress: boolean = false; private originalCenterButtonIcon: string = ""; - private params: MenuElementParams; + private params: MenuElementParams | null = null; constructor(config: RadialMenuConfig = {}) { this.config = { @@ -215,15 +217,15 @@ export class RadialMenu implements Layer { } private getInnerRadiusForLevel(level: number): number { - return level === 0 - ? this.config.mainMenuInnerRadius - : this.config.mainMenuInnerRadius + 34; + return level === 0 ? 50 : 50 + 25; } private getOuterRadiusForLevel(level: number): number { const innerRadius = this.getInnerRadiusForLevel(level); - const arcWidth = - this.config.menuSize / 2 - this.config.mainMenuInnerRadius - 10; + let arcWidth = 55; + if (level !== 0) { + arcWidth = 65; + } return innerRadius + arcWidth; } @@ -244,12 +246,14 @@ export class RadialMenu implements Layer { this.menuGroups.set(level, menuGroup as any); + const offset = -Math.PI / items.length; + const pie = d3 .pie() .value(() => 1) .padAngle(0.03) - .startAngle(Math.PI / 3) - .endAngle(2 * Math.PI + Math.PI / 3); + .startAngle(offset) + .endAngle(2 * Math.PI + offset); const innerRadius = this.getInnerRadiusForLevel(level); const outerRadius = this.getOuterRadiusForLevel(level); @@ -289,10 +293,11 @@ export class RadialMenu implements Layer { .attr("class", "menu-item-path") .attr("d", arc) .attr("fill", (d) => { - const color = d.data.disabled(this.params) + const disabled = this.params === null || d.data.disabled(this.params); + const color = disabled ? this.config.disabledColor : d.data.color || "#333333"; - const opacity = d.data.disabled(this.params) ? 0.5 : 0.7; + const opacity = disabled ? 0.5 : 0.7; if (d.data.id === this.selectedItemId && this.currentLevel > level) { return color; @@ -303,9 +308,13 @@ export class RadialMenu implements Layer { .attr("stroke", "#ffffff") .attr("stroke-width", "2") .style("cursor", (d) => - d.data.disabled(this.params) ? "not-allowed" : "pointer", + this.params === null || d.data.disabled(this.params) + ? "not-allowed" + : "pointer", + ) + .style("opacity", (d) => + this.params === null || d.data.disabled(this.params) ? 0.5 : 1, ) - .style("opacity", (d) => (d.data.disabled(this.params) ? 0.5 : 1)) .style( "transition", `filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${ @@ -327,9 +336,10 @@ export class RadialMenu implements Layer { path.attr("filter", "url(#glow)"); path.attr("stroke-width", "3"); - const color = d.data.disabled(this.params) - ? this.config.disabledColor - : d.data.color || "#333333"; + const color = + this.params === null || d.data.disabled(this.params) + ? this.config.disabledColor + : d.data.color || "#333333"; path.attr("fill", color); } }); @@ -357,29 +367,31 @@ export class RadialMenu implements Layer { level: number, ) { const onHover = (d: d3.PieArcDatum, path: any) => { + const disabled = this.params === null || d.data.disabled(this.params); + if (d.data.tooltipItems && d.data.tooltipItems.length > 0) { + this.showTooltip(d.data.tooltipItems); + } if ( - d.data.disabled(this.params) || + 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.params) + const color = 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); - } - - const subMenu = d.data.subMenu?.(this.params); + const subMenu = + this.params !== null ? d.data.subMenu?.(this.params) : null; if ( subMenu && subMenu.length > 0 && - !d.data.disabled(this.params) && + !disabled && !( this.currentLevel > 0 && d.data.id === this.selectedItemId && @@ -396,12 +408,13 @@ export class RadialMenu implements Layer { this.navigationInProgress = true; this.selectedItemId = d.data.id; this.navigateToSubMenu(subMenu); - this.setCenterButtonAsBack(); + this.updateCenterButtonState("back"); }, 200); } }; const onMouseOut = (d: d3.PieArcDatum, path: any) => { + const disabled = this.params === null || d.data.disabled(this.params); if (this.submenuHoverTimeout !== null) { window.clearTimeout(this.submenuHoverTimeout); this.submenuHoverTimeout = null; @@ -410,7 +423,7 @@ export class RadialMenu implements Layer { this.hideTooltip(); if ( - d.data.disabled(this.params) || + disabled || (this.currentLevel > 0 && level === 0 && d.data.id === this.selectedItemId) @@ -418,10 +431,10 @@ export class RadialMenu implements Layer { return; path.attr("filter", null); path.attr("stroke-width", "2"); - const color = d.data.disabled(this.params) + const color = disabled ? this.config.disabledColor : d.data.color || "#333333"; - const opacity = d.data.disabled(this.params) ? 0.5 : 0.7; + const opacity = disabled ? 0.5 : 0.7; path.attr( "fill", d3.color(color)?.copy({ opacity: opacity })?.toString() || color, @@ -430,7 +443,12 @@ export class RadialMenu implements Layer { const onClick = (d: d3.PieArcDatum, event: Event) => { event.stopPropagation(); - if (d.data.disabled(this.params) || this.navigationInProgress) return; + if ( + this.params === null || + d.data.disabled(this.params) || + this.navigationInProgress + ) + return; if ( this.currentLevel > 0 && @@ -444,7 +462,7 @@ export class RadialMenu implements Layer { this.navigationInProgress = true; this.selectedItemId = d.data.id; this.navigateToSubMenu(subMenu); - this.setCenterButtonAsBack(); + this.updateCenterButtonState("back"); } else { d.data.action?.(this.params); this.hideRadialMenu(); @@ -506,6 +524,10 @@ export class RadialMenu implements Layer { .each((d) => { const contentId = d.data.id; const content = d3.select(`g[data-id="${contentId}"]`); + const disabled = + this.params === null || + this.params.game.inSpawnPhase() || + d.data.disabled(this.params); if (d.data.text) { content @@ -517,21 +539,17 @@ export class RadialMenu implements Layer { .attr("fill", "white") .attr("font-size", d.data.fontSize ?? "12px") .attr("font-family", "Arial, sans-serif") - .style("opacity", d.data.disabled(this.params) ? 0.5 : 1) + .style("opacity", disabled ? 0.5 : 1) .text(d.data.text); } else { content .append("image") - .attr( - "xlink:href", - d.data.disabled(this.params) - ? disabledIcon - : d.data.icon || disabledIcon, - ) + .attr("xlink:href", d.data.icon!) .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); + .attr("y", arc.centroid(d)[1] - this.config.iconSize / 2) + .attr("opacity", disabled ? 0.5 : 1); } this.menuIcons.set(contentId, content as any); @@ -577,7 +595,7 @@ export class RadialMenu implements Layer { menuGroup .transition() .duration(this.config.menuTransitionDuration * 0.8) - .style("transform", "scale(0.59)") + .style("transform", "scale(0.5)") .style("opacity", 0.8); menuGroup.selectAll("path").each(function () { @@ -606,7 +624,7 @@ export class RadialMenu implements Layer { currentMenu .transition() .duration(this.config.menuTransitionDuration * 0.8) - .style("transform", `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`) + .style("transform", `scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`) .style("opacity", 0.8) .on("end", () => { this.navigationInProgress = false; @@ -638,7 +656,7 @@ export class RadialMenu implements Layer { this.currentMenuItems = previousItems || []; if (this.currentLevel === 0) { - this.resetCenterButton(); + this.updateCenterButtonState("default"); } } @@ -652,10 +670,11 @@ export class RadialMenu implements Layer { const item = this.findMenuItem(this.selectedItemId); if (item) { - const color = item.disabled(this.params) + const disabled = this.params === null || item.disabled(this.params); + const color = disabled ? this.config.disabledColor : item.color || "#333333"; - const opacity = item.disabled(this.params) ? 0.5 : 0.7; + const opacity = disabled ? 0.5 : 0.7; selectedPath.attr( "fill", d3.color(color)?.copy({ opacity: opacity })?.toString() || color, @@ -683,7 +702,7 @@ export class RadialMenu implements Layer { .duration(this.config.menuTransitionDuration * 0.8) .style( "transform", - `scale(${this.currentLevel === 1 ? "0.8" : "0.59"})`, + `scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`, ) .style("opacity", 0.8); } else if (level !== this.currentLevel + 1) { @@ -754,56 +773,6 @@ export class RadialMenu implements Layer { 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; @@ -846,65 +815,86 @@ export class RadialMenu implements Layer { } private handleCenterButtonClick() { - if ( - !this.isCenterButtonEnabled || - !this.centerButtonAction || - this.navigationInProgress - ) { + if (this.centerButtonState === "default") { + if (this.params) { + this.centerButtonElement?.action(this.params); + } return; } - if (this.currentLevel > 0 && this.backAction) { + if (this.centerButtonState === "back") { this.navigationInProgress = true; + this.navigateBack(); + return; } - - this.centerButtonAction(); } public disableAllButtons() { - this.originalCenterButtonEnabled = this.isCenterButtonEnabled; - this.originalCenterButtonAction = this.centerButtonAction; - - this.enableCenterButton(false); + this.updateCenterButtonState("default"); for (const item of this.currentMenuItems) { item.color = this.config.disabledColor; } } - public enableCenterButton(enabled: boolean, action?: (() => void) | null) { - if (this.currentLevel > 0 && this.backAction) { - this.isCenterButtonEnabled = true; + public updateCenterButtonState(state: CenterButtonState) { + this.centerButtonState = state; + if (state === "back") { + 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); - if (action !== undefined && action !== this.backAction) { - this.originalCenterButtonAction = action; - } - - this.centerButtonAction = this.backAction; - } else { - this.isCenterButtonEnabled = enabled; - if (action !== undefined) { - this.centerButtonAction = action; - } + 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); + } + if (state === "default") { + 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); } const centerButton = this.menuElement.select(".center-button"); + const enabled = this.isCenterButtonEnabled(); + centerButton .select(".center-button-hitbox") - .style("cursor", this.isCenterButtonEnabled ? "pointer" : "not-allowed"); + .style("cursor", enabled ? "pointer" : "not-allowed"); centerButton .select(".center-button-visible") - .attr("fill", this.isCenterButtonEnabled ? "#2c3e50" : "#999999"); + .attr("fill", enabled ? "#2c3e50" : "#999999"); centerButton .select(".center-button-icon") - .style("opacity", this.isCenterButtonEnabled ? 1 : 0.5); + .style("opacity", enabled ? 1 : 0.5); + } + + private isCenterButtonEnabled(): boolean { + if (this.params && this.centerButtonElement) { + return !this.centerButtonElement.disabled(this.params); + } + return false; } private onCenterButtonHover(isHovering: boolean) { - if (!this.isCenterButtonEnabled) return; + if (!this.isCenterButtonEnabled()) return; const scale = isHovering ? 1.2 : 1; @@ -919,26 +909,6 @@ export class RadialMenu implements Layer { .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 { @@ -949,59 +919,13 @@ export class RadialMenu implements Layer { return this.currentLevel; } - public updateMenuItem( - id: string, - enabled: boolean, - color?: string, - icon?: string, - text?: string, + public setRootMenuItems( + items: MenuElement[], + centerButton: CenterButtonElement, ) { - const path = this.menuPaths.get(id); - if (!path) return; - - const item = this.findMenuItem(id); - if (item) { - 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]; + this.centerButtonElement = centerButton; if (this.isVisible) { this.refreshMenu(); } @@ -1021,7 +945,6 @@ export class RadialMenu implements Layer { this.currentMenuItems = [...this.rootMenuItems]; - this.backAction = null; this.navigationInProgress = false; this.menuGroups.clear(); @@ -1033,7 +956,7 @@ export class RadialMenu implements Layer { menuContainer.selectAll("[class^='menu-level-']").remove(); } - this.resetCenterButton(); + this.updateCenterButtonState("default"); if (this.submenuHoverTimeout !== null) { window.clearTimeout(this.submenuHoverTimeout); diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index a24ddd8d1..9db5543c4 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -1,7 +1,7 @@ import { AllPlayers, + Cell, PlayerActions, - TerraNullius, UnitType, } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; @@ -29,7 +29,6 @@ import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg"; export interface MenuElementParams { myPlayer: PlayerView; selected: PlayerView | null; - tileOwner: PlayerView | TerraNullius; tile: TileRef; playerActions: PlayerActions; game: GameView; @@ -56,6 +55,11 @@ export interface MenuElement { subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus } +export interface CenterButtonElement { + disabled: (params: MenuElementParams) => boolean; + action: (params: MenuElementParams) => void; +} + export const COLORS = { build: "#ebe250", building: "#2c2c2c", @@ -111,7 +115,10 @@ const infoChatElement: MenuElement = { const allyTargetElement: MenuElement = { id: "ally_target", name: "target", - disabled: () => false, + disabled: (params: MenuElementParams): boolean => { + if (params.selected === null) return true; + return !params.playerActions.interaction?.canTarget; + }, color: COLORS.target, icon: targetIcon, action: (params: MenuElementParams) => { @@ -130,7 +137,7 @@ const allyTradeElement: MenuElement = { color: COLORS.trade, text: translateText("player_panel.start_trade"), action: (params: MenuElementParams) => { - params.playerActionHandler.handleEmbargo(params.selected!, "start"); + params.playerActionHandler.handleEmbargo(params.selected!, "stop"); params.closeMenu(); }, }; @@ -145,7 +152,7 @@ const allyEmbargoElement: MenuElement = { color: COLORS.embargo, text: translateText("player_panel.stop_trade"), action: (params: MenuElementParams) => { - params.playerActionHandler.handleEmbargo(params.selected!, "stop"); + params.playerActionHandler.handleEmbargo(params.selected!, "start"); params.closeMenu(); }, }; @@ -230,9 +237,30 @@ const infoEmojiElement: MenuElement = { color: COLORS.infoEmoji, icon: emojiIcon, subMenu: (params: MenuElementParams) => { - const emojiElements: MenuElement[] = []; + const emojiElements: MenuElement[] = [ + { + 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(); + }); + }, + }, + ]; - const emojiCount = 15; + const emojiCount = 8; for (let i = 0; i < emojiCount; i++) { emojiElements.push({ id: `emoji_${i}`, @@ -251,27 +279,6 @@ const infoEmojiElement: MenuElement = { }); } - 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; }, }; @@ -279,32 +286,46 @@ const infoEmojiElement: MenuElement = { export const infoMenuElement: MenuElement = { id: Slot.Info, name: "info", - disabled: () => false, + disabled: (params: MenuElementParams) => + !params.selected || params.game.inSpawnPhase(), icon: infoIcon, color: COLORS.info, subMenu: (params: MenuElementParams) => { - if (params === undefined || params.selected === null) return []; + if (!params.selected || params.game.inSpawnPhase()) return []; - return [ - infoChatElement, - allyTargetElement, - allyTradeElement, - allyEmbargoElement, - allyRequestElement, - allyBreakElement, - allyDonateGoldElement, - allyDonateTroopsElement, + if (params.selected === params.myPlayer) { + return [infoPlayerElement, infoEmojiElement]; + } + + const elements: MenuElement[] = [ infoPlayerElement, infoEmojiElement, - ].filter((item) => item.displayed !== false); + infoChatElement, + ]; + if (params.myPlayer.isAlliedWith(params.selected)) { + elements.push( + allyBreakElement, + allyDonateGoldElement, + allyDonateTroopsElement, + ); + } else { + elements.push(allyTargetElement, allyRequestElement); + } + if (params.myPlayer.hasEmbargoAgainst(params.selected)) { + elements.push(allyTradeElement); + } else { + elements.push(allyEmbargoElement); + } + + return elements; }, }; export const buildMenuElement: MenuElement = { id: Slot.Build, name: "build", - disabled: () => false, + disabled: (params: MenuElementParams) => params.game.inSpawnPhase(), icon: buildIcon, color: COLORS.build, @@ -367,7 +388,10 @@ export const buildMenuElement: MenuElement = { export const boatMenuElement: MenuElement = { id: Slot.Boat, name: "boat", - disabled: () => false, + disabled: (params: MenuElementParams) => + !params.playerActions.buildableUnits.some( + (unit) => unit.type === UnitType.TransportShip && unit.canBuild, + ), icon: boatIcon, color: COLORS.boat, @@ -388,8 +412,40 @@ export const boatMenuElement: MenuElement = { }, }; +export const centerButtonElement: CenterButtonElement = { + disabled: (params: MenuElementParams): boolean => { + const tileOwner = params.game.owner(params.tile); + const isLand = params.game.isLand(params.tile); + if (!isLand) { + return true; + } + if (params.game.inSpawnPhase()) { + if (tileOwner.isPlayer()) { + return true; + } + return false; + } + return false; + }, + action: (params: MenuElementParams) => { + if (params.game.inSpawnPhase()) { + const cell = new Cell( + params.game.x(params.tile), + params.game.y(params.tile), + ); + params.playerActionHandler.handleSpawn(cell); + } else { + params.playerActionHandler.handleAttack( + params.myPlayer, + params.selected?.id() ?? null, + ); + } + params.closeMenu(); + }, +}; + export const rootMenuItems: MenuElement[] = [ + infoMenuElement, boatMenuElement, buildMenuElement, - infoMenuElement, ];