Files
OpenFrontIO/src/client/graphics/layers/RadialMenuElements.ts
T
Scott Anderson 81bd98c8d6 Nations send emoji when declining assistance requests (#1911)
Nations will now send emoji when declining assistance requests.

- [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

Cancel alliance requests if the recipient attacks (#1733)

Problem: attacking a player right before accepting an alliance request
is very effective since the requester can't fight back or reclaim his
territory without canceling the alliance and being penalized with the
traitor debuff.

Change:
- Attacking a player after he requested an alliance automatically
rejects the request
- No changes to existing attacks in both directions, only new attacks
affect the request

- [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).

regression is found:

IngloriousTom
2025-10-06 15:37:00 -07:00

596 lines
17 KiB
TypeScript

import { Config } from "../../../core/configuration/Config";
import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, 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 swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import xIcon from "../../../../resources/images/XIcon.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[];
tooltipKeys?: TooltipKey[];
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 TooltipKey {
key: string;
className: string;
params?: Record<string, string | number>;
}
export interface CenterButtonElement {
disabled: (params: MenuElementParams) => boolean;
action: (params: MenuElementParams) => void;
}
export const COLORS = {
build: "#ebe250",
building: "#2c2c2c",
boat: "#3f6ab1",
ally: "#53ac75",
breakAlly: "#c74848",
delete: "#ff0000",
info: "#64748B",
target: "#ff0000",
attack: "#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",
Attack = "attack",
Ally = "ally",
Back = "back",
Delete = "delete",
}
// 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 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();
},
};
// 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<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;
}
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 deleteUnitElement: MenuElement = {
id: Slot.Delete,
name: "delete",
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.canDeleteUnit()) {
return true;
}
const DELETE_SELECTION_RADIUS = 5;
const myUnits = params.myPlayer
.units()
.filter(
(unit) =>
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,
);
if (myUnits.length > 0) {
myUnits.sort(
(a, b) =>
params.game.manhattanDist(a.tile(), params.tile) -
params.game.manhattanDist(b.tile(), params.tile),
);
params.playerActionHandler.handleDeleteUnit(myUnits[0].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) => {
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 !params.playerActions.canAttack;
},
action: (params: MenuElementParams) => {
if (params.game.inSpawnPhase()) {
params.playerActionHandler.handleSpawn(params.tile);
} 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) => {
let ally = allyRequestElement;
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
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);
menuItems.push(deleteUnitElement);
} else {
menuItems.push(attackMenuElement);
}
return menuItems.filter((item): item is MenuElement => item !== null);
},
};