Multi-level radial menu (#1018)

## Description:

- Refactored the radial menu to enable multi-level functionality.
- Organized the actions into submenus.

<img width="192" alt="Знімок екрана 2025-06-03 о 16 33 24"
src="https://github.com/user-attachments/assets/6dae9792-bcae-4fc9-8ce4-1203d0efbfac"
/>
<img width="313" alt="Знімок екрана 2025-06-03 о 16 34 17"
src="https://github.com/user-attachments/assets/5d78098f-b05b-40c4-bd70-8f2e3c08da2b"
/>
<img width="308" alt="Знімок екрана 2025-06-03 о 16 40 22"
src="https://github.com/user-attachments/assets/01b00906-9e8b-47e9-8f97-cfd3c023c352"
/>
<img width="277" alt="Знімок екрана 2025-06-03 о 16 37 04"
src="https://github.com/user-attachments/assets/60718c5b-8544-43e6-b891-2833d7fb789a"
/>
<img width="353" alt="Знімок екрана 2025-06-03 о 16 36 32"
src="https://github.com/user-attachments/assets/8c35a0f8-5588-470f-8af4-8e6d4ba66d88"
/>


## 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:

oleksandr037617_47021

---------

Co-authored-by: Oleksandr Shysh <oleksandr.s@develops.today>
Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
oleksandr-shysh
2025-06-07 03:04:24 +03:00
committed by GitHub
parent 5a617b5481
commit 871d8c499c
11 changed files with 2194 additions and 478 deletions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg fill="#fff" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 26.676 26.676" xml:space="preserve">
<g>
<path d="M26.105,21.891c-0.229,0-0.439-0.131-0.529-0.346l0,0c-0.066-0.156-1.716-3.857-7.885-4.59
c-1.285-0.156-2.824-0.236-4.693-0.25v4.613c0,0.213-0.115,0.406-0.304,0.508c-0.188,0.098-0.413,0.084-0.588-0.033L0.254,13.815
C0.094,13.708,0,13.528,0,13.339c0-0.191,0.094-0.365,0.254-0.477l11.857-7.979c0.175-0.121,0.398-0.129,0.588-0.029
c0.19,0.102,0.303,0.295,0.303,0.502v4.293c2.578,0.336,13.674,2.33,13.674,11.674c0,0.271-0.191,0.508-0.459,0.562
C26.18,21.891,26.141,21.891,26.105,21.891z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 764 B

+2 -2
View File
@@ -14,13 +14,13 @@ import { FxLayer } from "./layers/FxLayer";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { OptionsMenu } from "./layers/OptionsMenu";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { PlayerTeamLabel } from "./layers/PlayerTeamLabel";
import { RadialMenu } from "./layers/RadialMenu";
import { SpawnTimer } from "./layers/SpawnTimer";
import { StructureLayer } from "./layers/StructureLayer";
import { TeamStats } from "./layers/TeamStats";
@@ -199,7 +199,7 @@ export function createRenderer(
eventsDisplay,
chatDisplay,
buildMenu,
new RadialMenu(
new MainRadialMenu(
eventBus,
game,
transformHandler,
+8 -6
View File
@@ -19,7 +19,7 @@ import { BuildUnitIntentEvent } from "../../Transport";
import { renderNumber } from "../../Utils";
import { Layer } from "./Layer";
interface BuildItemDisplay {
export interface BuildItemDisplay {
unitType: UnitType;
icon: string;
description?: string;
@@ -27,7 +27,7 @@ interface BuildItemDisplay {
countable?: boolean;
}
const buildTable: BuildItemDisplay[][] = [
export const buildTable: BuildItemDisplay[][] = [
[
{
unitType: UnitType.AtomBomb,
@@ -96,12 +96,14 @@ const buildTable: BuildItemDisplay[][] = [
],
];
export const flattenedBuildTable = buildTable.flat();
@customElement("build-menu")
export class BuildMenu extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private clickedTile: TileRef;
private playerActions: PlayerActions | null;
public playerActions: PlayerActions | null;
private filteredBuildTable: BuildItemDisplay[][] = buildTable;
tick() {
@@ -302,7 +304,7 @@ export class BuildMenu extends LitElement implements Layer {
@state()
private _hidden = true;
private canBuild(item: BuildItemDisplay): boolean {
public canBuild(item: BuildItemDisplay): boolean {
if (this.game?.myPlayer() === null || this.playerActions === null) {
return false;
}
@@ -314,7 +316,7 @@ export class BuildMenu extends LitElement implements Layer {
return unit[0].canBuild !== false;
}
private cost(item: BuildItemDisplay): Gold {
public cost(item: BuildItemDisplay): Gold {
for (const bu of this.playerActions?.buildableUnits ?? []) {
if (bu.type === item.unitType) {
return bu.cost;
@@ -323,7 +325,7 @@ export class BuildMenu extends LitElement implements Layer {
return 0n;
}
private count(item: BuildItemDisplay): string {
public count(item: BuildItemDisplay): string {
const player = this.game?.myPlayer();
if (!player) {
return "?";
@@ -0,0 +1,102 @@
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { ChatModal, QuickChatPhrase, quickChatPhrases } from "./ChatModal";
import { COLORS, MenuElement } from "./RadialMenuElements";
export class ChatIntegration {
private ctModal: ChatModal;
constructor(
private game: GameView,
private eventBus: EventBus,
) {
this.ctModal = document.querySelector("chat-modal") as ChatModal;
if (!this.ctModal) {
throw new Error(
"Chat modal element not found. Ensure chat-modal element exists in DOM before initializing ChatIntegration",
);
}
}
setupChatModal(sender: PlayerView, recipient: PlayerView) {
this.ctModal.setSender(sender);
this.ctModal.setRecipient(recipient);
}
createQuickChatMenu(recipient: PlayerView): MenuElement[] {
if (!this.ctModal) {
throw new Error("Chat modal not set");
}
const myPlayer = this.game.myPlayer();
if (!myPlayer) {
throw new Error("Current player not found");
}
return this.ctModal.categories.map((category) => {
const categoryTranslation = translateText(`chat.cat.${category.id}`);
const categoryColor =
COLORS.chat[category.id as keyof typeof COLORS.chat] ||
COLORS.chat.default;
const phrases = quickChatPhrases[category.id] || [];
const phraseItems: MenuElement[] = phrases.map(
(phrase: QuickChatPhrase) => {
const phraseText = translateText(`chat.${category.id}.${phrase.key}`);
return {
id: `phrase-${category.id}-${phrase.key}`,
name: phraseText,
disabled: false,
text: this.shortenText(phraseText),
fontSize: "10px",
color: categoryColor,
tooltipItems: [
{
text: phraseText,
className: "description",
},
],
action: () => {
if (phrase.requiresPlayer) {
this.ctModal.openWithSelection(
category.id,
phrase.key,
myPlayer,
recipient,
);
} else {
this.eventBus.emit(
new SendQuickChatEvent(
recipient,
`${category.id}.${phrase.key}`,
{},
),
);
}
},
};
},
);
return {
id: `chat-category-${category.id}`,
name: categoryTranslation,
disabled: false,
text: categoryTranslation,
color: categoryColor,
_action: () => {}, // Empty action placeholder for RadialMenu
subMenu: () => phraseItems,
};
});
}
shortenText(text: string, maxLength = 15): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
}
+35 -15
View File
@@ -9,14 +9,14 @@ import { EventBus } from "../../../core/EventBus";
import { SendQuickChatEvent } from "../../Transport";
import { translateText } from "../../Utils";
type QuickChatPhrase = {
export type QuickChatPhrase = {
key: string;
requiresPlayer: boolean;
};
type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
export type QuickChatPhrases = Record<string, QuickChatPhrase[]>;
const quickChatPhrases: QuickChatPhrases = quickChatData;
export const quickChatPhrases: QuickChatPhrases = quickChatData;
@customElement("chat-modal")
export class ChatModal extends LitElement {
@@ -57,7 +57,7 @@ export class ChatModal extends LitElement {
misc: [{ text: "Let's go!", requiresPlayer: false }],
};
private categories = [
public categories = [
{ id: "help" },
{ id: "attack" },
{ id: "defend" },
@@ -71,17 +71,6 @@ export class ChatModal extends LitElement {
}
render() {
const sortedPlayers = [...this.players].sort((a, b) => a.localeCompare(b));
const filteredPlayers = sortedPlayers.filter((player) =>
player.toLowerCase().includes(this.playerSearchQuery),
);
const otherPlayers = sortedPlayers.filter(
(player) => !player.toLowerCase().includes(this.playerSearchQuery),
);
const displayPlayers = [...filteredPlayers, ...otherPlayers];
return html`
<o-modal title="${translateText("chat.title")}">
<div class="chat-columns">
@@ -306,4 +295,35 @@ export class ChatModal extends LitElement {
public setSender(value: PlayerView) {
this.sender = value;
}
public openWithSelection(
categoryId: string,
phraseKey: string,
sender?: PlayerView,
recipient?: PlayerView,
) {
if (sender && recipient) {
const alivePlayerNames = this.g
.players()
.filter((p) => p.isAlive() && !(p.data.playerType === PlayerType.Bot))
.map((p) => p.data.name);
this.players = alivePlayerNames;
this.recipient = recipient;
this.sender = sender;
}
this.selectCategory(categoryId);
const phrase = this.getPhrasesForCategory(categoryId).find(
(p) => p.key === phraseKey,
);
if (phrase) {
this.selectPhrase(phrase);
}
this.requestUpdate();
this.modalEl?.open();
}
}
@@ -0,0 +1,285 @@
import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { BuildMenu } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
import { EmojiTable } from "./EmojiTable";
import { Layer } from "./Layer";
import { MenuEventManager } from "./MenuEventManager";
import { PlayerActionHandler } from "./PlayerActionHandler";
import { PlayerInfoOverlay } from "./PlayerInfoOverlay";
import { PlayerPanel } from "./PlayerPanel";
import { RadialMenu, RadialMenuConfig } from "./RadialMenu";
import {
COLORS,
MenuElementParams,
Slot,
createRadialMenuItems,
getRootMenuItems,
updateCenterButton,
} from "./RadialMenuElements";
import boatIcon from "../../../../resources/images/BoatIconWhite.svg";
import buildIcon from "../../../../resources/images/BuildIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
@customElement("main-radial-menu")
export class MainRadialMenu extends LitElement implements Layer {
private radialMenu: RadialMenu;
private lastTickRefresh: number = 0;
private tickRefreshInterval: number = 500;
private needsRefresh: boolean = false;
private playerActionHandler: PlayerActionHandler;
private menuEventManager: MenuEventManager;
private chatIntegration: ChatIntegration;
constructor(
private eventBus: EventBus,
private game: GameView,
private transformHandler: TransformHandler,
private emojiTable: EmojiTable,
private buildMenu: BuildMenu,
private uiState: UIState,
private playerInfoOverlay: PlayerInfoOverlay,
private playerPanel: PlayerPanel,
) {
super();
const menuConfig: RadialMenuConfig = {
centerButtonIcon: swordIcon,
tooltipStyle: `
.radial-tooltip .cost {
margin-top: 4px;
color: ${COLORS.tooltip.cost};
}
.radial-tooltip .count {
color: ${COLORS.tooltip.count};
}
`,
};
this.radialMenu = new RadialMenu(menuConfig);
this.playerActionHandler = new PlayerActionHandler(
this.eventBus,
this.uiState,
);
this.menuEventManager = new MenuEventManager(
this.eventBus,
this.game,
this.transformHandler,
this.radialMenu,
this.buildMenu,
this.emojiTable,
this.playerInfoOverlay,
this.playerPanel,
);
this.chatIntegration = new ChatIntegration(this.game, this.eventBus);
this.radialMenu.setRootMenuItems(getRootMenuItems());
}
init() {
this.radialMenu.init();
this.menuEventManager.setContextMenuCallback((myPlayer, tile, actions) => {
this.handlePlayerActions(myPlayer, actions, tile);
});
this.menuEventManager.init();
}
private async handlePlayerActions(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef,
) {
this.buildMenu.playerActions = actions;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
if (myPlayer && recipient) {
this.chatIntegration.setupChatModal(myPlayer, recipient);
}
const params: MenuElementParams = {
myPlayer,
selected: recipient,
tileOwner,
tile,
playerActions: actions,
game: this.game,
buildMenu: this.buildMenu,
emojiTable: this.emojiTable,
playerActionHandler: this.playerActionHandler,
playerPanel: this.playerPanel,
chatIntegration: this.chatIntegration,
closeMenu: () => this.menuEventManager.closeMenu(),
};
const menuItems = createRadialMenuItems(params);
this.radialMenu.setRootMenuItems(menuItems);
updateCenterButton(params, (enabled, action) => {
this.radialMenu.enableCenterButton(enabled, action);
});
}
async tick() {
const clickedCell = this.menuEventManager.getClickedCell();
if (!this.radialMenu.isMenuVisible() || clickedCell === null) return;
const currentTime = new Date().getTime();
if (
currentTime - this.lastTickRefresh < this.tickRefreshInterval &&
!this.needsRefresh
) {
return;
}
const myPlayer = this.game.myPlayer();
if (myPlayer === null || !myPlayer.isAlive()) return;
const tile = this.game.ref(clickedCell.x, clickedCell.y);
const isSpawnPhase = this.game.inSpawnPhase();
const wasInSpawnPhase = this.menuEventManager.getWasInSpawnPhase();
if (wasInSpawnPhase !== isSpawnPhase) {
if (wasInSpawnPhase && !isSpawnPhase) {
this.needsRefresh = true;
this.menuEventManager.setWasInSpawnPhase(isSpawnPhase);
const actions = await this.playerActionHandler.getPlayerActions(
myPlayer,
tile,
);
this.updateMenuState(myPlayer, actions, tile);
this.radialMenu.refreshMenu();
return;
}
this.menuEventManager.closeMenu();
return;
}
// Check if tile ownership has changed
const originalTileOwner = this.menuEventManager.getOriginalTileOwner();
if (originalTileOwner && originalTileOwner.isPlayer()) {
if (this.game.owner(tile) !== originalTileOwner) {
this.menuEventManager.closeMenu();
return;
}
} else if (originalTileOwner) {
if (
this.game.owner(tile).isPlayer() ||
this.game.owner(tile) === myPlayer
) {
this.menuEventManager.closeMenu();
return;
}
}
this.lastTickRefresh = currentTime;
this.needsRefresh = false;
const actions = await this.playerActionHandler.getPlayerActions(
myPlayer,
tile,
);
this.updateMenuState(myPlayer, actions, tile);
}
private updateMenuState(
myPlayer: PlayerView,
actions: PlayerActions,
tile: TileRef,
) {
if (!this.radialMenu.isMenuVisible()) return;
const tileOwner = this.game.owner(tile);
const recipient = tileOwner.isPlayer() ? (tileOwner as PlayerView) : null;
const params: MenuElementParams = {
myPlayer,
selected: recipient,
tileOwner,
tile,
playerActions: actions,
game: this.game,
buildMenu: this.buildMenu,
emojiTable: this.emojiTable,
playerActionHandler: this.playerActionHandler,
playerPanel: this.playerPanel,
chatIntegration: this.chatIntegration,
closeMenu: () => this.menuEventManager.closeMenu(),
};
if (this.radialMenu.getCurrentLevel() === 0) {
updateCenterButton(params, (enabled, action) => {
this.radialMenu.enableCenterButton(enabled, action);
});
}
const canBuildTransport = actions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
)?.canBuild;
this.radialMenu.updateMenuItem(
Slot.Build,
!this.game.inSpawnPhase(),
COLORS.build,
buildIcon,
);
if (actions?.interaction?.canSendAllianceRequest) {
this.radialMenu.updateMenuItem(Slot.Ally, true, COLORS.ally, undefined);
} else if (actions?.interaction?.canBreakAlliance) {
this.radialMenu.updateMenuItem(
Slot.Ally,
true,
COLORS.breakAlly,
undefined,
);
} else {
this.radialMenu.updateMenuItem(Slot.Ally, false, undefined, undefined);
}
this.radialMenu.updateMenuItem(
Slot.Boat,
!!canBuildTransport,
COLORS.boat,
boatIcon,
);
this.radialMenu.updateMenuItem(
Slot.Info,
this.game.hasOwner(tile),
COLORS.info,
infoIcon,
);
}
renderLayer(context: CanvasRenderingContext2D) {
this.radialMenu.renderLayer(context);
}
shouldTransform(): boolean {
return this.radialMenu.shouldTransform();
}
redraw() {
// No redraw implementation needed
}
}
@@ -0,0 +1,185 @@
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, TerraNullius } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import {
CloseViewEvent,
ContextMenuEvent,
MouseUpEvent,
ShowBuildMenuEvent,
} from "../../InputHandler";
import { SendSpawnIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { BuildMenu } from "./BuildMenu";
import { EmojiTable } from "./EmojiTable";
import { PlayerInfoOverlay } from "./PlayerInfoOverlay";
import { PlayerPanel } from "./PlayerPanel";
import { RadialMenu } from "./RadialMenu";
export type ContextMenuCallback = (
myPlayer: PlayerView,
tile: TileRef,
actions: PlayerActions,
) => void;
export class MenuEventManager {
private clickedCell: Cell | null = null;
private lastClosed: number = 0;
private originalTileOwner: PlayerView | TerraNullius | null = null;
private wasInSpawnPhase: boolean = false;
private onContextMenuCallback: ContextMenuCallback | null = null;
constructor(
private eventBus: EventBus,
private game: GameView,
private transformHandler: TransformHandler,
private radialMenu: RadialMenu,
private buildMenu: BuildMenu,
private emojiTable: EmojiTable,
private playerInfoOverlay: PlayerInfoOverlay,
private playerPanel: PlayerPanel,
) {}
init() {
this.eventBus.on(ContextMenuEvent, (e) => this.onContextMenu(e));
this.eventBus.on(MouseUpEvent, (e) => this.onPointerUp(e));
this.eventBus.on(CloseViewEvent, () => this.closeMenu());
this.eventBus.on(ShowBuildMenuEvent, (e) => this.onShowBuildMenu(e));
}
setContextMenuCallback(callback: ContextMenuCallback) {
this.onContextMenuCallback = callback;
}
onContextMenu(event: ContextMenuEvent): Cell | null {
if (this.lastClosed + 200 > new Date().getTime()) return null;
this.closeMenu();
if (this.radialMenu.isMenuVisible()) {
this.radialMenu.hideRadialMenu();
return null;
} else {
this.radialMenu.showRadialMenu(event.x, event.y);
}
this.radialMenu.disableAllButtons();
this.clickedCell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (
!this.clickedCell ||
!this.game.isValidCoord(this.clickedCell.x, this.clickedCell.y)
) {
return null;
}
const tile = this.game.ref(this.clickedCell.x, this.clickedCell.y);
this.originalTileOwner = this.game.owner(tile);
this.wasInSpawnPhase = this.game.inSpawnPhase();
const myPlayer = this.game.myPlayer();
if (myPlayer === null) {
throw new Error("my player not found");
}
if (myPlayer && !myPlayer.isAlive() && !this.game.inSpawnPhase()) {
this.radialMenu.hideRadialMenu();
return null;
}
if (this.game.inSpawnPhase()) {
if (this.game.isLand(tile) && !this.game.hasOwner(tile)) {
this.radialMenu.enableCenterButton(true, () => {
if (this.clickedCell === null) return;
this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell));
this.radialMenu.hideRadialMenu();
});
return this.clickedCell;
}
}
myPlayer.actions(tile).then((actions) => {
if (this.onContextMenuCallback) {
this.onContextMenuCallback(myPlayer, tile, actions);
}
});
return this.clickedCell;
}
getClickedCell(): Cell | null {
return this.clickedCell;
}
getOriginalTileOwner(): PlayerView | TerraNullius | null {
return this.originalTileOwner;
}
getWasInSpawnPhase(): boolean {
return this.wasInSpawnPhase;
}
setWasInSpawnPhase(value: boolean) {
this.wasInSpawnPhase = value;
}
onPointerUp(event: MouseUpEvent) {
this.playerInfoOverlay.hide();
this.hideEverything();
}
onShowBuildMenu(e: ShowBuildMenuEvent): TileRef | null {
const clickedCell = this.transformHandler.screenToWorldCoordinates(
e.x,
e.y,
);
if (clickedCell === null) {
return null;
}
if (!this.game.isValidCoord(clickedCell.x, clickedCell.y)) {
return null;
}
const tile = this.game.ref(clickedCell.x, clickedCell.y);
const p = this.game.myPlayer();
if (p === null) {
return null;
}
this.buildMenu.showMenu(tile);
return tile;
}
closeMenu() {
if (this.radialMenu.isMenuVisible()) {
this.radialMenu.hideRadialMenu();
}
if (this.buildMenu.isVisible) {
this.buildMenu.hideMenu();
}
if (this.emojiTable.isVisible) {
this.emojiTable.hideTable();
}
if (this.playerPanel.isVisible) {
this.playerPanel.hide();
}
}
hideEverything() {
if (this.radialMenu.isMenuVisible()) {
this.radialMenu.hideRadialMenu();
this.lastClosed = new Date().getTime();
}
this.emojiTable.hideTable();
this.buildMenu.hideMenu();
}
enableCenterButton(enabled: boolean, action: () => void) {
this.radialMenu.enableCenterButton(enabled, action);
}
}
@@ -0,0 +1,109 @@
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { PlayerView } from "../../../core/game/GameView";
import {
BuildUnitIntentEvent,
SendAllianceRequestIntentEvent,
SendAttackIntentEvent,
SendBoatAttackIntentEvent,
SendBreakAllianceIntentEvent,
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendQuickChatEvent,
SendSpawnIntentEvent,
SendTargetPlayerIntentEvent,
} from "../../Transport";
import { UIState } from "../UIState";
export class PlayerActionHandler {
constructor(
private eventBus: EventBus,
private uiState: UIState,
) {}
async getPlayerActions(
player: PlayerView,
tile: TileRef,
): Promise<PlayerActions> {
return await player.actions(tile);
}
handleAttack(player: PlayerView, targetId: string | null) {
this.eventBus.emit(
new SendAttackIntentEvent(
targetId,
this.uiState.attackRatio * player.troops(),
),
);
}
handleBoatAttack(
player: PlayerView,
targetId: string,
targetCell: Cell,
spawnTile: Cell | null,
) {
this.eventBus.emit(
new SendBoatAttackIntentEvent(
targetId,
targetCell,
this.uiState.attackRatio * player.troops(),
spawnTile,
),
);
}
async findBestTransportShipSpawn(
player: PlayerView,
tile: TileRef,
): Promise<TileRef | false> {
return await player.bestTransportShipSpawn(tile);
}
handleBuildUnit(unitType: UnitType, cellX: number, cellY: number) {
this.eventBus.emit(
new BuildUnitIntentEvent(unitType, new Cell(cellX, cellY)),
);
}
handleSpawn(spawnCell: Cell) {
this.eventBus.emit(new SendSpawnIntentEvent(spawnCell));
}
handleAllianceRequest(player: PlayerView, recipient: PlayerView) {
this.eventBus.emit(new SendAllianceRequestIntentEvent(player, recipient));
}
handleBreakAlliance(player: PlayerView, recipient: PlayerView) {
this.eventBus.emit(new SendBreakAllianceIntentEvent(player, recipient));
}
handleTargetPlayer(targetId: string | null) {
if (!targetId) return;
this.eventBus.emit(new SendTargetPlayerIntentEvent(targetId));
}
handleDonateGold(recipient: PlayerView) {
this.eventBus.emit(new SendDonateGoldIntentEvent(recipient, null));
}
handleDonateTroops(recipient: PlayerView) {
this.eventBus.emit(new SendDonateTroopsIntentEvent(recipient, null));
}
handleEmbargo(recipient: PlayerView, action: "start" | "stop") {
this.eventBus.emit(new SendEmbargoIntentEvent(recipient, action));
}
handleEmoji(targetPlayer: PlayerView | "AllPlayers", emojiIndex: number) {
this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex));
}
handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) {
this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params));
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export class PlayerPanel extends LitElement implements Layer {
private tile: TileRef | null = null;
@state()
private isVisible: boolean = false;
public isVisible: boolean = false;
@state()
private allianceExpiryText: string | null = null;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,471 @@
import {
AllPlayers,
Cell,
PlayerActions,
TerraNullius,
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";
export interface MenuElementParams {
myPlayer: PlayerView;
selected: PlayerView | null;
tileOwner: PlayerView | TerraNullius;
tile: TileRef;
playerActions: PlayerActions;
game: GameView;
buildMenu: BuildMenu;
emojiTable: EmojiTable;
playerActionHandler: PlayerActionHandler;
playerPanel: PlayerPanel;
chatIntegration: ChatIntegration;
closeMenu: () => void;
}
export interface MenuElement {
id: string;
name: string;
disabled: boolean;
displayed?: boolean;
color?: string;
icon?: string;
text?: string;
fontSize?: string;
tooltipItems?: TooltipItem[];
action?: (params: MenuElementParams) => void; // For leaf items that perform actions
subMenu?: (params: MenuElementParams) => MenuElement[]; // For non-leaf items that open submenus
// Runtime properties used by RadialMenu (not to be set by menu element creators)
children?: MenuElement[];
_action?: () => 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",
}
/**
* Convert a MenuElement tree to a version usable by the RadialMenu
* by resolving subMenu functions and setting up actions
*/
export function prepareMenuElementsForRadialMenu(
elements: MenuElement[],
params: MenuElementParams,
): MenuElement[] {
return elements.map((element) => {
const prepared: MenuElement = { ...element };
// If the element has a subMenu function, execute it to get the children
if (element.subMenu) {
prepared.children = prepareMenuElementsForRadialMenu(
element.subMenu(params),
params,
);
// We don't need the subMenu function anymore
prepared.subMenu = undefined;
}
// Set up the action function to call the element's action with params
if (element.action) {
prepared._action = () => element.action!(params);
} else {
prepared._action = () => {};
}
return prepared;
});
}
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
disabled: false,
icon: buildIcon,
color: COLORS.build,
subMenu: (params: MenuElementParams) => {
const buildElements: MenuElement[] = flattenedBuildTable.map(
(item: BuildItemDisplay) => ({
id: `build_${item.unitType}`,
name: item.key
? item.key.replace("unit_type.", "")
: item.unitType.toString(),
disabled: !params.buildMenu.canBuild(item),
color: params.buildMenu.canBuild(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) => {
params.playerActionHandler.handleBuildUnit(
item.unitType,
params.game.x(params.tile),
params.game.y(params.tile),
);
params.closeMenu();
},
}),
);
buildElements.push({
id: "build_menu",
name: "build",
disabled: false,
color: COLORS.build,
icon: buildIcon,
action: (params: MenuElementParams) => {
params.buildMenu.showMenu(params.tile);
},
});
return buildElements;
},
};
export const boatMenuElement: MenuElement = {
id: Slot.Boat,
name: "boat",
disabled: false,
icon: boatIcon,
color: COLORS.boat,
action: async (params: MenuElementParams) => {
if (!params.selected) return;
const spawn = await params.playerActionHandler.findBestTransportShipSpawn(
params.myPlayer,
params.tile,
);
let spawnTile: Cell | null = null;
if (spawn !== false) {
spawnTile = new Cell(params.game.x(spawn), params.game.y(spawn));
}
params.playerActionHandler.handleBoatAttack(
params.myPlayer,
params.selected.id(),
new Cell(params.game.x(params.tile), params.game.y(params.tile)),
spawnTile,
);
params.closeMenu();
},
};
export const infoMenuElement: MenuElement = {
id: Slot.Info,
name: "info",
disabled: false,
icon: infoIcon,
color: COLORS.info,
subMenu: (params: MenuElementParams) => {
if (!params.selected) return [];
return [
{
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,
})),
},
{
id: "ally_target",
name: "target",
disabled: false,
color: COLORS.target,
icon: targetIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleTargetPlayer(params.selected!.id());
params.closeMenu();
},
},
{
id: "ally_trade",
name: "trade",
disabled: !!params.playerActions?.interaction?.canEmbargo,
displayed: !params.playerActions?.interaction?.canEmbargo,
color: COLORS.trade,
text: translateText("player_panel.start_trade"),
action: (params: MenuElementParams) => {
params.playerActionHandler.handleEmbargo(params.selected!, "start");
params.closeMenu();
},
},
{
id: "ally_embargo",
name: "embargo",
disabled: !params.playerActions?.interaction?.canEmbargo,
displayed: !!params.playerActions?.interaction?.canEmbargo,
color: COLORS.embargo,
text: translateText("player_panel.stop_trade"),
action: (params: MenuElementParams) => {
params.playerActionHandler.handleEmbargo(params.selected!, "stop");
params.closeMenu();
},
},
{
id: "ally_request",
name: "request",
disabled: !params.playerActions?.interaction?.canSendAllianceRequest,
displayed: !params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.ally,
icon: allianceIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleAllianceRequest(
params.myPlayer,
params.selected!,
);
params.closeMenu();
},
},
{
id: "ally_break",
name: "break",
disabled: !params.playerActions?.interaction?.canBreakAlliance,
displayed: !!params.playerActions?.interaction?.canBreakAlliance,
color: COLORS.breakAlly,
icon: traitorIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleBreakAlliance(
params.myPlayer,
params.selected!,
);
params.closeMenu();
},
},
{
id: "ally_donate_gold",
name: "donate gold",
disabled: !params.playerActions?.interaction?.canDonate,
color: COLORS.ally,
icon: donateGoldIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateGold(params.selected!);
params.closeMenu();
},
},
{
id: "ally_donate_troops",
name: "donate troops",
disabled: !params.playerActions?.interaction?.canDonate,
color: COLORS.ally,
icon: donateTroopIcon,
action: (params: MenuElementParams) => {
params.playerActionHandler.handleDonateTroops(params.selected!);
params.closeMenu();
},
},
{
id: "info_player",
name: "player",
disabled: false,
color: COLORS.info,
icon: infoIcon,
action: (params: MenuElementParams) => {
params.playerPanel.show(params.playerActions, params.tile);
},
},
{
id: "info_emoji",
name: "emoji",
disabled: false,
color: COLORS.infoEmoji,
icon: emojiIcon,
subMenu: () => {
const emojiElements: MenuElement[] = [];
const emojiCount = 15;
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();
},
});
}
emojiElements.push({
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();
});
},
});
return emojiElements;
},
},
].filter((item) => item.displayed !== false);
},
};
export function createMenuItems(params: MenuElementParams): MenuElement[] {
const canBuildTransport = params.playerActions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
)?.canBuild;
return [
{
...boatMenuElement,
disabled: !canBuildTransport || !params.selected,
},
{
...buildMenuElement,
disabled: params.game.inSpawnPhase(),
},
{
...infoMenuElement,
disabled: !params.game.hasOwner(params.tile),
},
];
}
export function createRadialMenuItems(
params: MenuElementParams,
): MenuElement[] {
const elements = createMenuItems(params);
return prepareMenuElementsForRadialMenu(elements, params);
}
export function getRootMenuItems(): MenuElement[] {
return [
{
id: Slot.Boat,
name: "boat",
disabled: true,
_action: () => {},
icon: boatIcon,
},
{
id: Slot.Build,
name: "build",
disabled: true,
_action: () => {},
icon: buildIcon,
},
{
id: Slot.Info,
name: "info",
disabled: true,
_action: () => {},
icon: infoIcon,
},
];
}
export function updateCenterButton(
params: MenuElementParams,
enableCenterButton: (enabled: boolean, action?: (() => void) | null) => void,
) {
if (params.playerActions.canAttack) {
enableCenterButton(true, () => {
if (params.tileOwner !== params.myPlayer) {
params.playerActionHandler.handleAttack(
params.myPlayer,
params.tileOwner.id(),
);
}
params.closeMenu();
});
} else {
enableCenterButton(false);
}
}