Add Split radial menu into separate attack and build buttons (#1598)

## Description:

This PR implements a new radial menu system that separates attack and
construction functionalities into distinct buttons. Previously, all
units (both attack and construction) were grouped together in a single
orange button, making the interface confusing and inefficient.

## 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 have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Kipstzz

<img width="420" height="345" alt="image"
src="https://github.com/user-attachments/assets/73d67fe1-d5d2-4c7e-8894-360877fa7004"
/>
<img width="422" height="345" alt="image"
src="https://github.com/user-attachments/assets/e576d543-4156-48f4-81ac-e7a06d26b25b"
/>
This commit is contained in:
Kipstz Avenger
2025-08-02 00:38:16 +02:00
committed by GitHub
parent e7b23e6c85
commit cf662bc1bc
3 changed files with 622 additions and 48 deletions
+1
View File
@@ -70,6 +70,7 @@
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
"radial_attack": "Open the Attack menu.",
"radial_info": "Open the Info menu.",
"radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.",
"radial_close": "Close the menu.",
+105 -48
View File
@@ -19,6 +19,7 @@ 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 swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
@@ -66,6 +67,7 @@ export const COLORS = {
breakAlly: "#c74848",
info: "#64748B",
target: "#ff0000",
attack: "#ff0000",
infoDetails: "#7f8c8d",
infoEmoji: "#f1c40f",
trade: "#008080",
@@ -89,6 +91,7 @@ export enum Slot {
Info = "info",
Boat = "boat",
Build = "build",
Attack = "attack",
Ally = "ally",
Back = "back",
}
@@ -319,6 +322,88 @@ function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
return Units;
}
const ATTACK_UNIT_TYPES: UnitType[] = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
function createMenuElements(
params: MenuElementParams,
filterType: "attack" | "build",
elementIdPrefix: string,
): MenuElement[] {
const unitTypes: Set<UnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
return flattenedBuildTable
.filter(
(item) =>
unitTypes.has(item.unitType) &&
(filterType === "attack"
? ATTACK_UNIT_TYPES.includes(item.unitType)
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
)
.map((item: BuildItemDisplay) => ({
id: `${elementIdPrefix}_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
!params.buildMenu.canBuildOrUpgrade(item),
color: params.buildMenu.canBuildOrUpgrade(item)
? filterType === "attack"
? COLORS.attack
: 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(
(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");
},
};
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
@@ -328,53 +413,7 @@ export const buildMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
const unitTypes: Set<UnitType> = getAllEnabledUnits(
params.selected === params.myPlayer,
params.game.config(),
);
const buildElements: MenuElement[] = flattenedBuildTable
.filter((item) => unitTypes.has(item.unitType))
.map((item: BuildItemDisplay) => ({
id: `build_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: (params: MenuElementParams) =>
!params.buildMenu.canBuildOrUpgrade(item),
color: params.buildMenu.canBuildOrUpgrade(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) => {
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();
},
}));
return buildElements;
return createMenuElements(params, "build", "build");
},
};
@@ -444,6 +483,24 @@ export const rootMenuElement: MenuElement = {
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
const tileOwner = params.game.owner(params.tile);
const isOwnTerritory =
tileOwner.isPlayer() &&
(tileOwner as PlayerView).id() === params.myPlayer.id();
const menuItems: (MenuElement | null)[] = [
infoMenuElement,
boatMenuElement,
ally,
];
if (isOwnTerritory) {
menuItems.push(buildMenuElement);
} else {
menuItems.push(attackMenuElement);
}
return menuItems.filter((item): item is MenuElement => item !== null);
},
};
@@ -0,0 +1,516 @@
/**
* @jest-environment jsdom
*/
import {
attackMenuElement,
buildMenuElement,
COLORS,
MenuElementParams,
rootMenuElement,
Slot,
} from "../../../src/client/graphics/layers/RadialMenuElements";
import { UnitType } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { GameView, PlayerView } from "../../../src/core/game/GameView";
jest.mock("../../../src/client/Utils", () => ({
translateText: jest.fn((key: string) => key),
renderNumber: jest.fn((num: number) => num.toString()),
}));
jest.mock("../../../src/client/graphics/layers/BuildMenu", () => {
const { UnitType } = jest.requireActual("../../../src/core/game/Game");
return {
flattenedBuildTable: [
{
unitType: UnitType.City,
key: "unit_type.city",
description: "unit_type.city_desc",
icon: "city-icon",
countable: true,
},
{
unitType: UnitType.Factory,
key: "unit_type.factory",
description: "unit_type.factory_desc",
icon: "factory-icon",
countable: true,
},
{
unitType: UnitType.AtomBomb,
key: "unit_type.atom_bomb",
description: "unit_type.atom_bomb_desc",
icon: "atom-bomb-icon",
countable: false,
},
{
unitType: UnitType.Warship,
key: "unit_type.warship",
description: "unit_type.warship_desc",
icon: "warship-icon",
countable: true,
},
{
unitType: UnitType.HydrogenBomb,
key: "unit_type.hydrogen_bomb",
description: "unit_type.hydrogen_bomb_desc",
icon: "hydrogen-bomb-icon",
countable: false,
},
{
unitType: UnitType.MIRV,
key: "unit_type.mirv",
description: "unit_type.mirv_desc",
icon: "mirv-icon",
countable: false,
},
],
};
});
jest.mock("nanoid", () => ({
customAlphabet: jest.fn(() => jest.fn(() => "mock-id")),
}));
jest.mock("dompurify", () => ({
__esModule: true,
default: {
sanitize: jest.fn((str: string) => str),
},
}));
jest.mock("twemoji", () => ({
__esModule: true,
default: {
parse: jest.fn((str: string) => str),
},
}));
describe("RadialMenuElements", () => {
let mockParams: MenuElementParams;
let mockPlayer: PlayerView;
let mockGame: GameView;
let mockBuildMenu: any;
let mockPlayerActions: any;
let mockTile: TileRef;
beforeEach(() => {
mockPlayer = {
id: () => 1,
isAlliedWith: jest.fn(() => false),
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockGame = {
inSpawnPhase: jest.fn(() => false),
owner: jest.fn(() => mockPlayer),
isLand: jest.fn(() => true),
config: jest.fn(() => ({
theme: () => ({
territoryColor: () => ({
lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
}),
}),
isUnitDisabled: jest.fn(() => false),
})),
} as unknown as GameView;
mockBuildMenu = {
canBuildOrUpgrade: jest.fn(() => true),
cost: jest.fn(() => 100),
count: jest.fn(() => 5),
sendBuildOrUpgrade: jest.fn(),
};
mockPlayerActions = {
buildableUnits: [
{ type: UnitType.City, canBuild: true },
{ type: UnitType.Factory, canBuild: true },
{ type: UnitType.AtomBomb, canBuild: true },
{ type: UnitType.Warship, canBuild: true },
{ type: UnitType.HydrogenBomb, canBuild: true },
{ type: UnitType.MIRV, canBuild: true },
{ type: UnitType.TransportShip, canBuild: true },
],
canAttack: true,
interaction: {
canSendAllianceRequest: true,
canBreakAlliance: false,
canDonate: true,
},
};
mockTile = {} as TileRef;
mockParams = {
myPlayer: mockPlayer,
selected: mockPlayer,
tile: mockTile,
playerActions: mockPlayerActions,
game: mockGame,
buildMenu: mockBuildMenu,
emojiTable: {} as any,
playerActionHandler: {} as any,
playerPanel: {} as any,
chatIntegration: {} as any,
eventBus: {} as any,
closeMenu: jest.fn(),
};
});
describe("attackMenuElement", () => {
it("should have correct basic properties", () => {
expect(attackMenuElement.id).toBe(Slot.Attack);
expect(attackMenuElement.name).toBe("radial_attack");
expect(attackMenuElement.icon).toBeDefined();
expect(attackMenuElement.color).toBe(COLORS.attack);
});
it("should be disabled during spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => true);
expect(attackMenuElement.disabled(mockParams)).toBe(true);
});
it("should be enabled when not in spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => false);
expect(attackMenuElement.disabled(mockParams)).toBe(false);
});
it("should return attack submenu with attack units only", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
expect(subMenu).toBeDefined();
expect(subMenu.length).toBeGreaterThan(0);
const attackUnitTypes = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("attack_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
returnedUnitTypes.forEach((unitType) => {
expect(attackUnitTypes).toContain(unitType);
});
});
it("should not include construction units in attack menu", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const constructionUnitTypes = [UnitType.City, UnitType.Factory];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("attack_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
constructionUnitTypes.forEach((unitType) => {
expect(returnedUnitTypes).not.toContain(unitType);
});
});
it("should handle undefined params in submenu", () => {
const subMenu = attackMenuElement.subMenu!(undefined as any);
expect(subMenu).toEqual([]);
});
});
describe("buildMenuElement", () => {
it("should have correct basic properties", () => {
expect(buildMenuElement.id).toBe(Slot.Build);
expect(buildMenuElement.name).toBe("build");
expect(buildMenuElement.icon).toBeDefined();
expect(buildMenuElement.color).toBe(COLORS.build);
});
it("should be disabled during spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => true);
expect(buildMenuElement.disabled(mockParams)).toBe(true);
});
it("should be enabled when not in spawn phase", () => {
mockGame.inSpawnPhase = jest.fn(() => false);
expect(buildMenuElement.disabled(mockParams)).toBe(false);
});
it("should return build submenu with construction units only", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
expect(subMenu).toBeDefined();
expect(subMenu.length).toBeGreaterThan(0);
const constructionUnitTypes = [UnitType.City, UnitType.Factory];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("build_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
returnedUnitTypes.forEach((unitType) => {
expect(constructionUnitTypes).toContain(unitType);
});
});
it("should not include attack units in build menu", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const attackUnitTypes = [
UnitType.AtomBomb,
UnitType.MIRV,
UnitType.HydrogenBomb,
UnitType.Warship,
];
const returnedUnitTypes = subMenu.map((item) => {
const unitTypeStr = item.id.replace("build_", "");
return Object.values(UnitType).find(
(type) => type.toString() === unitTypeStr,
);
});
attackUnitTypes.forEach((unitType) => {
expect(returnedUnitTypes).not.toContain(unitType);
});
});
it("should handle undefined params in submenu", () => {
const subMenu = buildMenuElement.subMenu!(undefined as any);
expect(subMenu).toEqual([]);
});
});
describe("rootMenuElement", () => {
it("should have correct basic properties", () => {
expect(rootMenuElement.id).toBe("root");
expect(rootMenuElement.name).toBe("root");
expect(rootMenuElement.disabled(mockParams)).toBe(false);
});
it("should show build menu on own territory", () => {
const subMenu = rootMenuElement.subMenu!(mockParams);
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
expect(buildMenu).toBeDefined();
expect(attackMenu).toBeUndefined();
});
it("should show attack menu on enemy territory", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockGame.owner = jest.fn(() => enemyPlayer);
const subMenu = rootMenuElement.subMenu!(mockParams);
const buildMenu = subMenu.find((item) => item.id === Slot.Build);
const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
expect(attackMenu).toBeDefined();
expect(buildMenu).toBeUndefined();
});
it("should include info and boat menus in both cases", () => {
const subMenu = rootMenuElement.subMenu!(mockParams);
const infoMenu = subMenu.find((item) => item.id === Slot.Info);
const boatMenu = subMenu.find((item) => item.id === Slot.Boat);
expect(infoMenu).toBeDefined();
expect(boatMenu).toBeDefined();
});
it("should handle ally menu correctly", () => {
const allyPlayer = {
id: () => 2,
isAlliedWith: jest.fn(() => true),
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = allyPlayer;
const subMenu = rootMenuElement.subMenu!(mockParams);
const allyMenu = subMenu.find((item) => item.id === "ally_break");
expect(allyMenu).toBeDefined();
});
});
describe("Menu element actions", () => {
it("should execute build action correctly", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement).toBeDefined();
expect(cityElement!.action).toBeDefined();
if (cityElement!.action) {
cityElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
it("should execute attack action correctly", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement).toBeDefined();
expect(atomBombElement!.action).toBeDefined();
if (atomBombElement!.action) {
atomBombElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
expect(mockParams.closeMenu).toHaveBeenCalled();
}
});
it("should not execute action when buildable unit is not found", () => {
mockPlayerActions.buildableUnits = [];
mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
if (cityElement!.action) {
cityElement!.action(mockParams);
expect(mockBuildMenu.sendBuildOrUpgrade).not.toHaveBeenCalled();
expect(mockParams.closeMenu).not.toHaveBeenCalled();
}
});
});
describe("Menu element tooltips", () => {
it("should generate correct tooltip items for build elements", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.tooltipItems).toBeDefined();
expect(cityElement!.tooltipItems!.length).toBeGreaterThan(0);
const tooltipTexts = cityElement!.tooltipItems!.map((item) => item.text);
expect(tooltipTexts).toContain("unit_type.city");
expect(tooltipTexts).toContain("unit_type.city_desc");
expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
expect(tooltipTexts.some((text) => text.includes("5x"))).toBe(true);
});
it("should generate correct tooltip items for attack elements", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement!.tooltipItems).toBeDefined();
expect(atomBombElement!.tooltipItems!.length).toBeGreaterThan(0);
const tooltipTexts = atomBombElement!.tooltipItems!.map(
(item) => item.text,
);
expect(tooltipTexts).toContain("unit_type.atom_bomb");
expect(tooltipTexts).toContain("unit_type.atom_bomb_desc");
expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
});
});
describe("Menu element colors", () => {
it("should use correct colors for build elements", () => {
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBe(COLORS.building);
});
it("should use correct colors for attack elements", () => {
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
const subMenu = attackMenuElement.subMenu!(mockParams);
const atomBombElement = subMenu.find(
(item) => item.id === "attack_Atom Bomb",
);
expect(atomBombElement!.color).toBe(COLORS.attack);
});
it("should not set color when element is disabled", () => {
mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
const subMenu = buildMenuElement.subMenu!(mockParams);
const cityElement = subMenu.find((item) => item.id === "build_City");
expect(cityElement!.color).toBeUndefined();
});
});
describe("Translation integration", () => {
it("should use translateText for tooltip items in build menu", () => {
const { translateText } = jest.requireMock("../../../src/client/Utils");
(translateText as jest.Mock).mockClear();
buildMenuElement.subMenu!(mockParams);
expect(translateText).toHaveBeenCalledWith("unit_type.city");
expect(translateText).toHaveBeenCalledWith("unit_type.city_desc");
expect(translateText).toHaveBeenCalledWith("unit_type.factory");
expect(translateText).toHaveBeenCalledWith("unit_type.factory_desc");
});
it("should use translateText for tooltip items in attack menu", () => {
const { translateText } = jest.requireMock("../../../src/client/Utils");
(translateText as jest.Mock).mockClear();
const enemyPlayer = {
id: () => 2,
isPlayer: jest.fn(() => true),
} as unknown as PlayerView;
mockParams.selected = enemyPlayer;
attackMenuElement.subMenu!(mockParams);
expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb");
expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb_desc");
expect(translateText).toHaveBeenCalledWith("unit_type.hydrogen_bomb");
expect(translateText).toHaveBeenCalledWith(
"unit_type.hydrogen_bomb_desc",
);
});
});
});