mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:32:41 +00:00
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:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user