Files
OpenFrontIO/src/client/graphics/layers/radial/RadialMenu.ts
T
2024-11-03 09:18:29 -08:00

444 lines
17 KiB
TypeScript

import { EventBus } from "../../../../core/EventBus";
import { AllPlayers, Cell, Game, Player } from "../../../../core/game/Game";
import { ClientID } from "../../../../core/Schemas";
import { and, bfs, dist, manhattanDist, manhattanDistWrapped, sourceDstOceanShore } from "../../../../core/Util";
import { ContextMenuEvent, MouseUpEvent } from "../../../InputHandler";
import { SendAllianceRequestIntentEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendBreakAllianceIntentEvent, SendDonateIntentEvent, SendEmojiIntentEvent, SendNukeIntentEvent, SendSpawnIntentEvent, SendTargetPlayerIntentEvent } from "../../../Transport";
import { TransformHandler } from "../../TransformHandler";
import { Layer } from "../Layer";
import * as d3 from 'd3';
import traitorIcon from '../../../../../resources/images/TraitorIconWhite.png';
import allianceIcon from '../../../../../resources/images/AllianceIconWhite.png';
import boatIcon from '../../../../../resources/images/BoatIconWhite.png';
import swordIcon from '../../../../../resources/images/SwordIconWhite.png';
import targetIcon from '../../../../../resources/images/TargetIconWhite.png';
import emojiIcon from '../../../../../resources/images/EmojiIconWhite.png';
import disabledIcon from '../../../../../resources/images/DisabledIcon.png';
import donateIcon from '../../../../../resources/images/DonateIconWhite.png';
import buildIcon from '../../../../../resources/images/BuildIconWhite.svg';
import { EmojiTable } from "./EmojiTable";
import { UIState } from "../../UIState";
import { BuildMenu } from "./BuildMenu";
enum Slot {
Alliance,
Boat,
Target,
Emoji,
Build,
}
export class RadialMenu implements Layer {
private clickedCell: Cell | null = null
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
private isVisible: boolean = false;
private readonly menuItems = new Map([
[Slot.Alliance, { name: "alliance", disabled: true, action: () => { }, color: null, icon: null }],
[Slot.Boat, { name: "boat", disabled: true, action: () => { }, color: null, icon: null }],
[Slot.Target, { name: "target", disabled: true, action: () => { } }],
[Slot.Emoji, { name: "emoji", disabled: true, action: () => { } }],
[Slot.Build, { name: "build", disabled: true, action: () => { } }],
]);
private readonly menuSize = 190;
private readonly centerButtonSize = 30;
private readonly iconSize = 32;
private readonly centerIconSize = 48;
private readonly disabledColor = d3.rgb(128, 128, 128).toString();
private isCenterButtonEnabled = false
constructor(
private eventBus: EventBus,
private game: Game,
private transformHandler: TransformHandler,
private clientID: ClientID,
private emojiTable: EmojiTable,
private buildMenu: BuildMenu,
private uiState: UIState
) { }
init() {
this.eventBus.on(ContextMenuEvent, e => this.onContextMenu(e))
this.eventBus.on(MouseUpEvent, e => this.onPointerUp(e))
this.createMenuElement();
}
private createMenuElement() {
this.menuElement = d3.select(document.body)
.append('div')
.style('position', 'fixed')
.style('display', 'none')
.style('z-index', '9999')
.style('touch-action', 'none');
const svg = this.menuElement.append('svg')
.attr('width', this.menuSize)
.attr('height', this.menuSize)
.append('g')
.attr('transform', `translate(${this.menuSize / 2},${this.menuSize / 2})`);
const pie = d3.pie<any>()
.value(() => 1)
.padAngle(0.03);
const arc = d3.arc<any>()
.innerRadius(this.centerButtonSize + 5)
.outerRadius(this.menuSize / 2 - 10);
const arcs = svg.selectAll('path')
.data(pie(Array.from(this.menuItems.values())))
.enter()
.append('g');
arcs.append('path')
.attr('d', arc)
.attr('fill', d => d.data.disabled ? this.disabledColor : d.data.color)
.attr('stroke', '#ffffff')
.attr('stroke-width', '2')
.style('cursor', d => d.data.disabled ? 'not-allowed' : 'pointer')
.style('opacity', d => d.data.disabled ? 0.5 : 1)
.attr('data-name', d => d.data.name)
.on('mouseover', function (event, d) {
if (!d.data.disabled) {
d3.select(this)
.transition()
.duration(200)
.attr('transform', 'scale(1.05)')
.attr('filter', 'url(#glow)');
}
})
.on('mouseout', function (event, d) {
if (!d.data.disabled) {
d3.select(this)
.transition()
.duration(200)
.attr('transform', 'scale(1)')
.attr('filter', null);
}
})
.on('click', (event, d) => {
if (!d.data.disabled) {
d.data.action();
this.hideRadialMenu();
}
})
.on('touchstart', (event, d) => {
event.preventDefault();
if (!d.data.disabled) {
d.data.action();
this.hideRadialMenu();
}
});
arcs.append('image')
.attr('xlink:href', d => d.data.icon)
.attr('width', this.iconSize)
.attr('height', this.iconSize)
.attr('x', d => arc.centroid(d)[0] - this.iconSize / 2)
.attr('y', d => arc.centroid(d)[1] - this.iconSize / 2)
.style('pointer-events', 'none')
.attr('data-name', d => d.data.name);
// Add glow filter
const defs = svg.append('defs');
const filter = defs.append('filter')
.attr('id', 'glow');
filter.append('feGaussianBlur')
.attr('stdDeviation', '3')
.attr('result', 'coloredBlur');
const feMerge = filter.append('feMerge');
feMerge.append('feMergeNode')
.attr('in', 'coloredBlur');
feMerge.append('feMergeNode')
.attr('in', 'SourceGraphic');
const centerButton = svg.append('g')
.attr('class', 'center-button');
centerButton.append('circle')
.attr('class', 'center-button-hitbox')
.attr('r', this.centerButtonSize)
.attr('fill', 'transparent')
.style('cursor', 'pointer')
.on('click', () => this.handleCenterButtonClick())
.on('touchstart', (event: Event) => {
event.preventDefault();
this.handleCenterButtonClick();
})
.on('mouseover', () => this.onCenterButtonHover(true))
.on('mouseout', () => this.onCenterButtonHover(false));
centerButton.append('circle')
.attr('class', 'center-button-visible')
.attr('r', this.centerButtonSize)
.attr('fill', '#2c3e50')
.style('pointer-events', 'none');
// Replace text with sword icon
centerButton.append('image')
.attr('class', 'center-button-icon')
.attr('xlink:href', swordIcon)
.attr('width', this.centerIconSize)
.attr('height', this.centerIconSize)
.attr('x', -this.centerIconSize / 2)
.attr('y', -this.centerIconSize / 2)
.style('pointer-events', 'none');
}
tick() {
// Update logic if needed
}
renderLayer(context: CanvasRenderingContext2D) {
// No need to render anything on the canvas
}
shouldTransform(): boolean {
return false;
}
private onContextMenu(event: ContextMenuEvent) {
if (this.isVisible) {
this.hideRadialMenu()
return
} else {
this.showRadialMenu(event.x, event.y);
}
this.enableCenterButton(false)
for (const item of this.menuItems.values()) {
item.disabled = true
this.updateMenuItemState(item)
}
this.clickedCell = this.transformHandler.screenToWorldCoordinates(event.x, event.y)
if (!this.game.isOnMap(this.clickedCell)) {
return
}
const tile = this.game.tile(this.clickedCell)
const other = tile.owner()
if (this.game.inSpawnPhase()) {
if (tile.isLand() && !tile.hasOwner()) {
this.enableCenterButton(true)
}
return
}
const myPlayer = this.game.players().find(p => p.clientID() == this.clientID)
if (!myPlayer) {
console.warn('my player not found')
return
}
this.activateMenuElement(Slot.Build, "#ebe250", buildIcon, () => {
this.buildMenu.showMenu(myPlayer, this.clickedCell)
})
if (tile.hasOwner()) {
const target = tile.owner() == myPlayer ? AllPlayers : (tile.owner() as Player)
if (myPlayer.canSendEmoji(target)) {
this.activateMenuElement(Slot.Emoji, "#00a6a4", emojiIcon, () => {
this.emojiTable.onEmojiClicked = (emoji: string) => {
this.emojiTable.hideTable()
this.eventBus.emit(new SendEmojiIntentEvent(target, emoji))
}
this.emojiTable.showTable()
})
}
}
if (tile.owner() != myPlayer && tile.isLand() && myPlayer.sharesBorderWith(other)) {
if (other.isPlayer()) {
if (!myPlayer.isAlliedWith(other)) {
this.enableCenterButton(true)
}
} else {
outer_loop: for (const t of bfs(tile, and(t => !t.hasOwner() && t.isLand(), dist(tile, 200)))) {
for (const n of t.neighbors()) {
if (n.owner() == myPlayer) {
this.enableCenterButton(true)
break outer_loop
}
}
}
}
}
if (tile.hasOwner()) {
const other = tile.owner() as Player
if (other.clientID() == this.clientID) {
return
}
if (myPlayer.canDonate(other)) {
this.activateMenuElement(Slot.Target, "#53ac75", donateIcon, () => {
this.eventBus.emit(
new SendDonateIntentEvent(myPlayer, other, null)
)
})
}
if (myPlayer.isAlliedWith(other)) {
this.activateMenuElement(Slot.Alliance, "#c74848", traitorIcon, () => {
this.eventBus.emit(
new SendBreakAllianceIntentEvent(myPlayer, other)
)
})
} else if (!myPlayer.recentOrPendingAllianceRequestWith(other)) {
this.activateMenuElement(Slot.Alliance, "#53ac75", allianceIcon, () => {
this.eventBus.emit(
new SendAllianceRequestIntentEvent(myPlayer, other)
)
})
}
if (myPlayer.canTarget(other)) {
this.activateMenuElement(Slot.Target, "#c74848", targetIcon, () => {
this.eventBus.emit(
new SendTargetPlayerIntentEvent(other.id())
)
})
}
}
if (!tile.isLand()) {
return
}
if (myPlayer.boats().length >= this.game.config().boatMaxNumber()) {
return
}
let myPlayerBordersOcean = false
for (const bt of myPlayer.borderTiles()) {
if (bt.isOceanShore()) {
myPlayerBordersOcean = true
break
}
}
let otherPlayerBordersOcean = false
if (!tile.hasOwner()) {
otherPlayerBordersOcean = true
} else {
for (const bt of (other as Player).borderTiles()) {
if (bt.isOceanShore()) {
otherPlayerBordersOcean = true
break
}
}
}
if (other.isPlayer() && myPlayer.allianceWith(other)) {
return
}
let nearOcean = false
for (const t of bfs(tile, and(t => t.owner() == tile.owner() && t.isLand(), dist(tile, 25)))) {
if (t.isOceanShore()) {
nearOcean = true
break
}
}
if (!nearOcean) {
return
}
if (myPlayerBordersOcean && otherPlayerBordersOcean) {
const [src, dst] = sourceDstOceanShore(this.game, myPlayer, other, this.clickedCell)
if (src != null && dst != null) {
if (manhattanDistWrapped(src.cell(), dst.cell(), this.game.width()) < this.game.config().boatMaxDistance()) {
this.activateMenuElement(Slot.Boat, "#3f6ab1", boatIcon, () => {
this.eventBus.emit(
new SendBoatAttackIntentEvent(other.id(), this.clickedCell, this.uiState.attackRatio * myPlayer.troops())
)
})
}
}
}
}
private onPointerUp(event: MouseUpEvent) {
this.hideRadialMenu()
this.emojiTable.hideTable()
this.buildMenu.hideMenu()
}
private showRadialMenu(x: number, y: number) {
// Delay so center button isn't clicked immediately on press.
setTimeout(() => {
this.menuElement
.style('left', `${x - this.menuSize / 2}px`)
.style('top', `${y - this.menuSize / 2}px`)
.style('display', 'block');
this.isVisible = true;
}, 50)
}
private hideRadialMenu() {
this.menuElement.style('display', 'none');
this.isVisible = false;
}
private handleCenterButtonClick() {
if (!this.isCenterButtonEnabled) {
return
}
console.log('Center button clicked');
const clicked = this.game.tile(this.clickedCell)
if (this.game.inSpawnPhase()) {
this.eventBus.emit(new SendSpawnIntentEvent(this.clickedCell))
} else {
if (clicked.owner().clientID() != this.clientID) {
const myPlayer = this.game.players().find(p => p.clientID() == this.clientID)
this.eventBus.emit(new SendAttackIntentEvent(clicked.owner().id(), this.uiState.attackRatio * myPlayer.troops()))
}
}
this.hideRadialMenu();
}
private activateMenuElement(slot: Slot, color: string, icon: string, action: () => void) {
const menuItem = this.menuItems.get(slot)
menuItem.action = action
menuItem.disabled = false
menuItem.color = color
menuItem.icon = icon
this.updateMenuItemState(menuItem)
}
private updateMenuItemState(item: any) {
const menuItem = this.menuElement.select(`path[data-name="${item.name}"]`);
menuItem
.attr('fill', item.disabled ? this.disabledColor : item.color)
.style('cursor', item.disabled ? 'not-allowed' : 'pointer')
.style('opacity', item.disabled ? 0.5 : 1);
this.menuElement.select(`image[data-name="${item.name}"]`)
.attr('xlink:href', item.disabled ? disabledIcon : item.icon)
.attr('fill', item.disabled ? '#999999' : 'white');
}
private onCenterButtonHover(isHovering: boolean) {
if (!this.isCenterButtonEnabled) return;
const scale = isHovering ? 1.2 : 1;
const fontSize = isHovering ? '18px' : '16px';
this.menuElement.select('.center-button-hitbox').transition().duration(200).attr('r', this.centerButtonSize * scale);
this.menuElement.select('.center-button-visible').transition().duration(200).attr('r', this.centerButtonSize * scale);
this.menuElement.select('.center-button-text').transition().duration(200).style('font-size', fontSize);
}
private enableCenterButton(enabled: boolean) {
this.isCenterButtonEnabled = enabled
const centerButton = this.menuElement.select('.center-button');
centerButton.select('.center-button-hitbox')
.style('cursor', enabled ? 'pointer' : 'not-allowed');
centerButton.select('.center-button-visible')
.attr('fill', enabled ? '#2c3e50' : '#999999');
centerButton.select('.center-button-text')
.attr('fill', enabled ? 'white' : '#cccccc');
}
}