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
This commit is contained in:
evanpelle
2025-06-21 19:29:30 -07:00
committed by GitHub
parent e907993f51
commit ce991f97a7
5 changed files with 282 additions and 614 deletions
-1
View File
@@ -230,7 +230,6 @@ export function createRenderer(
emojiTable as EmojiTable,
buildMenu,
uiState,
playerInfo,
playerPanel,
),
new SpawnTimer(game, transformHandler),
+58 -183
View File
@@ -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();
}
}
}
@@ -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);
}
}
+125 -202
View File
@@ -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<RadialMenuConfig>;
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<MenuElement>()
.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<MenuElement>, 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<MenuElement>, 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<MenuElement>, 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);
@@ -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,
];