mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 19:52:43 +00:00
81bd98c8d6
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
596 lines
17 KiB
TypeScript
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);
|
|
},
|
|
};
|