Files
OpenFrontIO/src/client/graphics/layers/RadialMenuElements.ts
T
evanpelle 513fcb0944 upgrade unit when building a unit of same type (#1328)
## Description:

When building a structure in the same location as a nearby structure, it
will update the existing structure instead of creating a new one.

Also fix ctrl+click shortcut to bring up the build menu.

## 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
2025-07-03 18:34:18 -07:00

477 lines
14 KiB
TypeScript

import { Config } from "../../../core/configuration/Config";
import {
AllPlayers,
Cell,
PlayerActions,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { renderNumber, translateText } from "../../Utils";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
import { PlayerActionHandler } from "./PlayerActionHandler";
import { PlayerPanel } from "./PlayerPanel";
import { TooltipItem } from "./RadialMenu";
import allianceIcon from "../../../../resources/images/AllianceIconWhite.svg";
import boatIcon from "../../../../resources/images/BoatIconWhite.svg";
import buildIcon from "../../../../resources/images/BuildIconWhite.svg";
import chatIcon from "../../../../resources/images/ChatIconWhite.svg";
import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg";
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
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;
closeMenu: () => void;
}
export interface MenuElement {
id: string;
name: string;
displayed?: boolean | ((params: MenuElementParams) => boolean);
color?: string;
icon?: string;
text?: string;
fontSize?: string;
tooltipItems?: TooltipItem[];
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
}
export interface CenterButtonElement {
disabled: (params: MenuElementParams) => boolean;
action: (params: MenuElementParams) => void;
}
export const COLORS = {
build: "#ebe250",
building: "#2c2c2c",
boat: "#3f6ab1",
ally: "#53ac75",
breakAlly: "#c74848",
info: "#64748B",
target: "#ff0000",
infoDetails: "#7f8c8d",
infoEmoji: "#f1c40f",
trade: "#008080",
embargo: "#6600cc",
tooltip: {
cost: "#ffd700",
count: "#aaa",
},
chat: {
default: "#66c",
help: "#4caf50",
attack: "#f44336",
defend: "#2196f3",
greet: "#ff9800",
misc: "#9c27b0",
warnings: "#e3c532",
},
};
export enum Slot {
Info = "info",
Boat = "boat",
Build = "build",
Ally = "ally",
Back = "back",
}
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,
})),
};
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();
},
};
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();
},
};
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 allyBreakElement: MenuElement = {
id: "ally_break",
name: "break",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canBreakAlliance,
displayed: (params: MenuElementParams) =>
!!params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.breakAlly,
icon: traitorIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
params.selected!,
);
params.closeMenu();
},
};
const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
name: "donate gold",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonate,
color: COLORS.ally,
icon: donateGoldIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateGold(params.selected!);
params.closeMenu();
},
};
const allyDonateTroopsElement: MenuElement = {
id: "ally_donate_troops",
name: "donate troops",
disabled: (params: MenuElementParams) =>
!params.playerActions?.interaction?.canDonate,
color: COLORS.ally,
icon: donateTroopIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateTroops(params.selected!);
params.closeMenu();
},
};
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);
},
};
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),
);
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,
subMenu: (params: MenuElementParams) => {
if (!params.selected || params.game.inSpawnPhase()) return [];
if (params.selected === params.myPlayer) {
return [infoPlayerElement, infoEmojiElement];
}
const elements: MenuElement[] = [
infoPlayerElement,
infoEmojiElement,
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;
},
};
function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
const Units: Set<UnitType> = new Set<UnitType>();
const addStructureIfEnabled = (unitType: UnitType) => {
if (!config.isUnitDisabled(unitType)) {
Units.add(unitType);
}
};
if (myPlayer) {
addStructureIfEnabled(UnitType.City);
addStructureIfEnabled(UnitType.DefensePost);
addStructureIfEnabled(UnitType.Port);
addStructureIfEnabled(UnitType.MissileSilo);
addStructureIfEnabled(UnitType.SAMLauncher);
addStructureIfEnabled(UnitType.Factory);
} else {
addStructureIfEnabled(UnitType.Warship);
addStructureIfEnabled(UnitType.HydrogenBomb);
addStructureIfEnabled(UnitType.MIRV);
addStructureIfEnabled(UnitType.AtomBomb);
}
return Units;
}
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 [];
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;
},
};
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) => {
const spawn = await params.playerActionHandler.findBestTransportShipSpawn(
params.myPlayer,
params.tile,
);
params.playerActionHandler.handleBoatAttack(
params.myPlayer,
params.selected?.id() || null,
params.tile,
spawn !== false ? spawn : null,
);
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 (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,
];