@@ -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);
+ }
+}