import { assetUrl } from "../../../core/AssetUrls"; import { Config } from "../../../core/configuration/Config"; import { AllPlayers, BuildableAttacks, PlayerActions, PlayerBuildableUnitType, Structures, UnitType, } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, findClosestBy, flattenedEmojiTable } from "../../../core/Util"; import { renderNumber, translateText } from "../../Utils"; import { UIState } from "../UIState"; 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 { EventBus } from "../../../core/EventBus"; const allianceIcon = assetUrl("images/AllianceIconWhite.svg"); const boatIcon = assetUrl("images/BoatIconWhite.svg"); const buildIcon = assetUrl("images/BuildIconWhite.svg"); const chatIcon = assetUrl("images/ChatIconWhite.svg"); const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg"); const donateTroopIcon = assetUrl("images/DonateTroopIconWhite.svg"); const emojiIcon = assetUrl("images/EmojiIconWhite.svg"); const infoIcon = assetUrl("images/InfoIcon.svg"); const swordIcon = assetUrl("images/SwordIconWhite.svg"); const targetIcon = assetUrl("images/TargetIconWhite.svg"); const traitorIcon = assetUrl("images/TraitorIconWhite.svg"); const xIcon = assetUrl("images/XIcon.svg"); export interface MenuElementParams { myPlayer: PlayerView; selected: PlayerView | null; tile: TileRef; playerActions: PlayerActions; game: GameView; buildMenu: BuildMenu; emojiTable: EmojiTable; playerActionHandler: PlayerActionHandler; playerPanel: PlayerPanel; chatIntegration: ChatIntegration; eventBus: EventBus; uiState?: UIState; closeMenu: () => void; } export interface MenuElement { id: string; name: string; displayed?: boolean | ((params: MenuElementParams) => boolean); color?: string | ((params: MenuElementParams) => string); icon?: string; text?: string; fontSize?: string; tooltipItems?: TooltipItem[]; tooltipKeys?: TooltipKey[]; cooldown?: (params: MenuElementParams) => number; disabled: (params: MenuElementParams) => boolean; action?: (params: MenuElementParams) => void; // For leaf items that perform actions subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus renderType?: string; timerFraction?: (params: MenuElementParams) => number; // 0..1, for arc timer overlay } export interface TooltipKey { key: string; className: string; params?: Record; } export interface CenterButtonElement { disabled: (params: MenuElementParams) => boolean; action: (params: MenuElementParams) => void; } export const COLORS = { build: "#e6c74a", building: "#1e3a5f", boat: "#2a82c9", ally: "#4ade80", breakAlly: "#dc2626", breakAllyNoDebuff: "#d97706", delete: "#ef4444", info: "#475569", target: "#ef4444", attack: "#ef4444", infoDetails: "#7f8c8d", infoEmoji: "#fbbf24", trade: "#0891b2", embargo: "#7c3aed", tooltip: { cost: "#f59e0b", count: "#94a3b8", }, chat: { default: "#6366f1", help: "#22c55e", attack: "#ef4444", defend: "#3b82f6", greet: "#f97316", misc: "#a855f7", warnings: "#fbbf24", }, }; export enum Slot { Info = "info", Boat = "boat", Build = "build", Attack = "attack", Ally = "ally", Back = "back", Delete = "delete", } function isFriendlyTarget(params: MenuElementParams): boolean { const selectedPlayer = params.selected; if (selectedPlayer === null) return false; const isFriendly = (selectedPlayer as PlayerView).isFriendly; if (typeof isFriendly !== "function") return false; return isFriendly.call(selectedPlayer, params.myPlayer); } function isDisconnectedTarget(params: MenuElementParams): boolean { const selectedPlayer = params.selected; if (selectedPlayer === null) return false; const isDisconnected = (selectedPlayer as PlayerView).isDisconnected; if (typeof isDisconnected !== "function") return false; return isDisconnected.call(selectedPlayer); } // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoChatElement: MenuElement = { 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, })), }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyTargetElement: MenuElement = { id: "ally_target", name: "target", disabled: (params: MenuElementParams): boolean => { if (params.selected === null) return true; return !params.playerActions.interaction?.canTarget; }, color: COLORS.target, icon: targetIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleTargetPlayer(params.selected!.id()); params.closeMenu(); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyTradeElement: MenuElement = { id: "ally_trade", name: "trade", disabled: (params: MenuElementParams) => !!params.playerActions?.interaction?.canEmbargo, displayed: (params: MenuElementParams) => !params.playerActions?.interaction?.canEmbargo, color: COLORS.trade, text: translateText("player_panel.start_trade"), action: (params: MenuElementParams) => { params.playerActionHandler.handleEmbargo(params.selected!, "stop"); params.closeMenu(); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyEmbargoElement: MenuElement = { id: "ally_embargo", name: "embargo", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canEmbargo, displayed: (params: MenuElementParams) => !!params.playerActions?.interaction?.canEmbargo, color: COLORS.embargo, text: translateText("player_panel.stop_trade"), action: (params: MenuElementParams) => { params.playerActionHandler.handleEmbargo(params.selected!, "start"); params.closeMenu(); }, }; const allyRequestElement: MenuElement = { id: "ally_request", name: "request", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canSendAllianceRequest, displayed: (params: MenuElementParams) => !params.playerActions?.interaction?.canBreakAlliance, color: COLORS.ally, icon: allianceIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleAllianceRequest( params.myPlayer, params.selected!, ); params.closeMenu(); }, }; const allyExtendElement: MenuElement = { id: "ally_extend", name: "extend", displayed: (params: MenuElementParams) => !!params.playerActions?.interaction?.allianceInfo?.inExtensionWindow, disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.allianceInfo?.canExtend, color: COLORS.ally, icon: allianceIcon, action: (params: MenuElementParams) => { if (!params.playerActions?.interaction?.allianceInfo?.canExtend) return; params.playerActionHandler.handleExtendAlliance(params.selected!); params.closeMenu(); }, timerFraction: (params: MenuElementParams): number => { const interaction = params.playerActions?.interaction; if (!interaction?.allianceInfo) return 1; const remaining = Math.max( 0, interaction.allianceInfo.expiresAt - params.game.ticks(), ); const extensionWindow = Math.max( 1, params.game.config().allianceExtensionPromptOffset(), ); return Math.max(0, Math.min(1, remaining / extensionWindow)); }, renderType: "allyExtend", }; const allyBreakElement: MenuElement = { id: "ally_break", name: "break", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canBreakAlliance, displayed: (params: MenuElementParams) => !!params.playerActions?.interaction?.canBreakAlliance, color: (params: MenuElementParams) => params.selected?.isTraitor() || params.selected?.isDisconnected() ? COLORS.breakAllyNoDebuff : COLORS.breakAlly, icon: traitorIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleBreakAlliance( params.myPlayer, params.selected!, ); params.closeMenu(); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateGoldElement: MenuElement = { id: "ally_donate_gold", name: "donate gold", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canDonateGold, color: COLORS.ally, icon: donateGoldIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleDonateGold(params.selected!); params.closeMenu(); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const allyDonateTroopsElement: MenuElement = { id: "ally_donate_troops", name: "donate troops", disabled: (params: MenuElementParams) => !params.playerActions?.interaction?.canDonateTroops, color: COLORS.ally, icon: donateTroopIcon, action: (params: MenuElementParams) => { params.playerActionHandler.handleDonateTroops(params.selected!); params.closeMenu(); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoPlayerElement: MenuElement = { id: "info_player", name: "player", disabled: () => false, color: COLORS.info, icon: infoIcon, action: (params: MenuElementParams) => { params.playerPanel.show(params.playerActions, params.tile); }, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars const infoEmojiElement: MenuElement = { id: "info_emoji", name: "emoji", disabled: () => false, color: COLORS.infoEmoji, icon: emojiIcon, subMenu: (params: MenuElementParams) => { 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 as Emoji), ); params.emojiTable.hideTable(); }); }, }, ]; const emojiCount = 8; 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(); }, }); } return emojiElements; }, }; export const infoMenuElement: MenuElement = { id: Slot.Info, name: "info", disabled: (params: MenuElementParams) => !params.selected || params.game.inSpawnPhase(), icon: infoIcon, color: COLORS.info, action: (params: MenuElementParams) => { params.playerPanel.show(params.playerActions, params.tile); }, }; function getAllEnabledUnits( myPlayer: boolean, config: Config, ): Set { const units: Set = new Set(); const addIfEnabled = (unitType: PlayerBuildableUnitType) => { if (!config.isUnitDisabled(unitType)) { units.add(unitType); } }; if (myPlayer) { Structures.types.forEach(addIfEnabled); } else { BuildableAttacks.types.forEach(addIfEnabled); } return units; } function createMenuElements( params: MenuElementParams, filterType: "attack" | "build", elementIdPrefix: string, ): MenuElement[] { const unitTypes: Set = getAllEnabledUnits( params.selected === params.myPlayer, params.game.config(), ); return flattenedBuildTable .filter( (item) => unitTypes.has(item.unitType) && (filterType === "attack" ? BuildableAttacks.has(item.unitType) : !BuildableAttacks.has(item.unitType)), ) .map((item: BuildItemDisplay) => { return { id: `${elementIdPrefix}_${item.unitType}`, name: item.key ? item.key.replace("unit_type.", "") : item.unitType.toString(), disabled: (p: MenuElementParams) => !p.buildMenu.canBuildOrUpgrade(item), color: (p: MenuElementParams) => p.buildMenu.canBuildOrUpgrade(item) ? filterType === "attack" ? COLORS.attack : COLORS.building : COLORS.building, 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( (tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null, ), action: (params: MenuElementParams) => { const buildableUnit = params.playerActions.buildableUnits.find( (bu) => bu.type === item.unitType, ); if (buildableUnit === undefined) { return; } if (params.buildMenu.canBuildOrUpgrade(item)) { params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile); } params.closeMenu(); }, }; }); } export const attackMenuElement: MenuElement = { id: Slot.Attack, name: "radial_attack", disabled: (params: MenuElementParams) => params.game.inSpawnPhase(), icon: swordIcon, color: COLORS.attack, subMenu: (params: MenuElementParams) => { if (params === undefined) return []; return createMenuElements(params, "attack", "attack"); }, }; const donateGoldRadialElement: MenuElement = { id: Slot.Attack, name: "radial_donate_gold", disabled: (params: MenuElementParams) => params.game.inSpawnPhase() || !params.playerActions?.interaction?.canDonateGold, icon: donateGoldIcon, color: "#f59e0b", action: (params: MenuElementParams) => { if (!params.selected) return; params.playerPanel.openSendGoldModal( params.playerActions, params.tile, params.selected, ); }, }; export const deleteUnitElement: MenuElement = { id: Slot.Delete, name: "delete", cooldown: (params: MenuElementParams) => params.myPlayer.deleteUnitCooldown(), disabled: (params: MenuElementParams) => { const tileOwner = params.game.owner(params.tile); const isLand = params.game.isLand(params.tile); if (!tileOwner.isPlayer() || tileOwner.id() !== params.myPlayer.id()) { return true; } if (!isLand) { return true; } if (params.game.inSpawnPhase()) { return true; } if (params.myPlayer.deleteUnitCooldown() > 0) { return true; } const DELETE_SELECTION_RADIUS = 5; const myUnits = params.myPlayer .units() .filter( (unit) => !unit.isUnderConstruction() && unit.markedForDeletion() === false && params.game.manhattanDist(unit.tile(), params.tile) <= DELETE_SELECTION_RADIUS, ); return myUnits.length === 0; }, icon: xIcon, color: COLORS.delete, tooltipKeys: [ { key: "radial_menu.delete_unit_title", className: "title", }, { key: "radial_menu.delete_unit_description", className: "description", }, ], action: (params: MenuElementParams) => { const DELETE_SELECTION_RADIUS = 5; const myUnits = params.myPlayer .units() .filter( (unit) => params.game.manhattanDist(unit.tile(), params.tile) <= DELETE_SELECTION_RADIUS, ); const closestUnit = findClosestBy(myUnits, (unit) => params.game.manhattanDist(unit.tile(), params.tile), ); if (closestUnit) { params.playerActionHandler.handleDeleteUnit(closestUnit.id()); } params.closeMenu(); }, }; export const buildMenuElement: MenuElement = { id: Slot.Build, name: "build", disabled: (params: MenuElementParams) => params.game.inSpawnPhase(), icon: buildIcon, color: COLORS.build, subMenu: (params: MenuElementParams) => { if (params === undefined) return []; return createMenuElements(params, "build", "build"); }, }; export const boatMenuElement: MenuElement = { id: Slot.Boat, name: "boat", disabled: (params: MenuElementParams) => !params.playerActions.buildableUnits.some( (unit) => unit.type === UnitType.TransportShip && unit.canBuild, ), icon: boatIcon, color: COLORS.boat, action: async (params: MenuElementParams) => { params.playerActionHandler.handleBoatAttack(params.myPlayer, params.tile); params.closeMenu(); }, }; 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 (params.game.config().isRandomSpawn()) { return true; } if (tileOwner.isPlayer()) { return true; } return false; } if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { return !params.playerActions.interaction?.canDonateTroops; } return !params.playerActions.canAttack; }, action: (params: MenuElementParams) => { if (params.game.inSpawnPhase()) { params.playerActionHandler.handleSpawn(params.tile); } else { if (isFriendlyTarget(params) && !isDisconnectedTarget(params)) { const selectedPlayer = params.selected as PlayerView; const ratio = params.uiState?.attackRatio ?? 1; const troopsToDonate = Math.floor(ratio * params.myPlayer.troops()); if (troopsToDonate > 0) { params.playerActionHandler.handleDonateTroops( selectedPlayer, troopsToDonate, ); } } else { params.playerActionHandler.handleAttack( params.myPlayer, params.selected?.id() ?? null, ); } } params.closeMenu(); }, }; export const rootMenuElement: MenuElement = { id: "root", name: "root", disabled: () => false, icon: infoIcon, color: COLORS.info, subMenu: (params: MenuElementParams) => { const isAllied = params.selected?.isAlliedWith(params.myPlayer); const isDisconnected = isDisconnectedTarget(params); const tileOwner = params.game.owner(params.tile); const isOwnTerritory = tileOwner.isPlayer() && (tileOwner as PlayerView).id() === params.myPlayer.id(); const inExtensionWindow = params.playerActions.interaction?.allianceInfo?.inExtensionWindow; const menuItems: (MenuElement | null)[] = [ infoMenuElement, ...(isOwnTerritory ? [deleteUnitElement, allyRequestElement, buildMenuElement] : [ isAllied && !isDisconnected ? allyBreakElement : boatMenuElement, inExtensionWindow ? allyExtendElement : allyRequestElement, isFriendlyTarget(params) && !isDisconnected ? donateGoldRadialElement : attackMenuElement, ]), ]; return menuItems.filter((item): item is MenuElement => item !== null); }, };