From 0b79d0be164313eef686b8562bd1fae3b364e311 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Wed, 28 May 2025 10:47:50 +0900 Subject: [PATCH] Unit menu (#867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: We are adding a modal to display information about a unit. ![スクリーンショット 2025-05-25 7 45 14](https://github.com/user-attachments/assets/736262cd-6070-4a27-b58c-e85f9a02df75) In the future, this modal will likely include buttons for upgrading or dismantling the unit. ## Please complete the following: - [x] I have added screenshots for all UI updates - [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: aotumuri --- src/client/graphics/GameRenderer.ts | 20 ++- src/client/graphics/layers/StructureLayer.ts | 92 +++++++++++ src/client/graphics/layers/UnitInfoModal.ts | 164 +++++++++++++++++++ src/client/index.html | 1 + src/core/execution/MissileSiloExecution.ts | 3 +- src/core/execution/SAMLauncherExecution.ts | 4 +- 6 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/client/graphics/layers/UnitInfoModal.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 58b298c13..901ff166e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -29,6 +29,7 @@ import { TerrainLayer } from "./layers/TerrainLayer"; import { TerritoryLayer } from "./layers/TerritoryLayer"; import { TopBar } from "./layers/TopBar"; import { UILayer } from "./layers/UILayer"; +import { UnitInfoModal } from "./layers/UnitInfoModal"; import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; @@ -171,10 +172,26 @@ export function createRenderer( } playerTeamLabel.game = game; + const unitInfoModal = document.querySelector( + "unit-info-modal", + ) as UnitInfoModal; + if (!(unitInfoModal instanceof UnitInfoModal)) { + console.error("unit info modal not found"); + } + unitInfoModal.game = game; + const structureLayer = new StructureLayer( + game, + eventBus, + transformHandler, + unitInfoModal, + ); + unitInfoModal.structureLayer = structureLayer; + // unitInfoModal.eventBus = eventBus; + const layers: Layer[] = [ new TerrainLayer(game, transformHandler), new TerritoryLayer(game, eventBus), - new StructureLayer(game, eventBus), + structureLayer, new UnitLayer(game, eventBus, clientID, transformHandler), new FxLayer(game), new UILayer(game, eventBus, clientID, transformHandler), @@ -203,6 +220,7 @@ export function createRenderer( topBar, playerPanel, playerTeamLabel, + unitInfoModal, multiTabModal, ]; diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 11c422c13..ec1ebfb6a 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -1,7 +1,10 @@ import { colord, Colord } from "colord"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; +import { MouseUpEvent } from "../../InputHandler"; +import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { UnitInfoModal } from "./UnitInfoModal"; import cityIcon from "../../../../resources/images/buildings/cityAlt1.png"; import shieldIcon from "../../../../resources/images/buildings/fortAlt2.png"; @@ -22,6 +25,7 @@ import { GameView, UnitView } from "../../../core/game/GameView"; const underConstructionColor = colord({ r: 150, g: 150, b: 150 }); const reloadingColor = colord({ r: 255, g: 0, b: 0 }); +const selectedUnitColor = colord({ r: 0, g: 255, b: 255 }); type DistanceFunction = typeof euclDistFN; @@ -44,6 +48,8 @@ export class StructureLayer implements Layer { private context: CanvasRenderingContext2D; private unitIcons: Map = new Map(); private theme: Theme; + private selectedStructureUnit: UnitView | null = null; + private previouslySelected: UnitView | null = null; // Configuration for supported unit types only private readonly unitConfigs: Partial> = { @@ -82,7 +88,15 @@ export class StructureLayer implements Layer { constructor( private game: GameView, private eventBus: EventBus, + private transformHandler: TransformHandler, + private unitInfoModal: UnitInfoModal | null, ) { + if (!unitInfoModal) { + throw new Error( + "UnitInfoModal instance must be provided to StructureLayer.", + ); + } + this.unitInfoModal = unitInfoModal; this.theme = game.config().theme(); this.loadIconData(); this.loadIcon("reloadingSam", { @@ -147,6 +161,7 @@ export class StructureLayer implements Layer { init() { this.redraw(); + this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); } redraw() { @@ -265,6 +280,10 @@ export class StructureLayer implements Layer { borderColor = underConstructionColor; } + if (this.selectedStructureUnit === unit) { + borderColor = selectedUnitColor; + } + this.drawBorder(unit, borderColor, config, drawFunction); const startX = this.game.x(unit.tile()) - Math.floor(icon.width / 2); @@ -316,4 +335,77 @@ export class StructureLayer implements Layer { clearCell(cell: Cell) { this.context.clearRect(cell.x, cell.y, 1, 1); } + + private findStructureUnitAtCell( + cell: { x: number; y: number }, + maxDistance: number = 10, + ): UnitView | null { + const targetRef = this.game.ref(cell.x, cell.y); + + const allUnitTypes = Object.values(UnitType); + + const nearby = this.game.nearbyUnits(targetRef, maxDistance, allUnitTypes); + + for (const { unit } of nearby) { + if (unit.isActive() && this.isUnitTypeSupported(unit.type())) { + return unit; + } + } + + return null; + } + + private onMouseUp(event: MouseUpEvent) { + const cell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + + const clickedUnit = this.findStructureUnitAtCell(cell); + this.previouslySelected = this.selectedStructureUnit; + + if (clickedUnit) { + const wasSelected = this.previouslySelected === clickedUnit; + if (wasSelected) { + this.selectedStructureUnit = null; + if (this.previouslySelected) { + this.handleUnitRendering(this.previouslySelected); + } + this.unitInfoModal?.onCloseStructureModal(); + } else { + this.selectedStructureUnit = clickedUnit; + if ( + this.previouslySelected && + this.previouslySelected !== clickedUnit + ) { + this.handleUnitRendering(this.previouslySelected); + } + this.handleUnitRendering(clickedUnit); + + const screenPos = this.transformHandler.worldToScreenCoordinates(cell); + const unitTile = clickedUnit.tile(); + this.unitInfoModal?.onOpenStructureModal({ + unit: clickedUnit, + x: screenPos.x, + y: screenPos.y, + tileX: this.game.x(unitTile), + tileY: this.game.y(unitTile), + }); + } + } else { + this.selectedStructureUnit = null; + if (this.previouslySelected) { + this.handleUnitRendering(this.previouslySelected); + } + this.unitInfoModal?.onCloseStructureModal(); + } + } + + public unSelectStructureUnit() { + if (this.selectedStructureUnit) { + this.previouslySelected = this.selectedStructureUnit; + this.selectedStructureUnit = null; + this.handleUnitRendering(this.previouslySelected); + } + } } diff --git a/src/client/graphics/layers/UnitInfoModal.ts b/src/client/graphics/layers/UnitInfoModal.ts new file mode 100644 index 000000000..131f9c028 --- /dev/null +++ b/src/client/graphics/layers/UnitInfoModal.ts @@ -0,0 +1,164 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { UnitType } from "../../../core/game/Game"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; +import { StructureLayer } from "./StructureLayer"; + +@customElement("unit-info-modal") +export class UnitInfoModal extends LitElement implements Layer { + @property({ type: Boolean }) open = false; + @property({ type: Number }) x = 0; + @property({ type: Number }) y = 0; + @property({ type: Object }) unit: UnitView | null = null; + + public game: GameView; + public structureLayer: StructureLayer | null = null; + + constructor() { + super(); + } + + init() {} + + tick() { + if (this.unit) { + this.requestUpdate(); + } + } + + public onOpenStructureModal = ({ + unit, + x, + y, + tileX, + tileY, + }: { + unit: UnitView; + x: number; + y: number; + tileX: number; + tileY: number; + }) => { + if (!this.game) return; + this.x = x; + this.y = y; + const targetRef = this.game.ref(tileX, tileY); + + const allUnitTypes = Object.values(UnitType); + const matchingUnits = this.game + .nearbyUnits(targetRef, 10, allUnitTypes) + .filter(({ unit }) => unit.isActive()); + + if (matchingUnits.length > 0) { + matchingUnits.sort((a, b) => a.distSquared - b.distSquared); + this.unit = matchingUnits[0].unit; + } else { + this.unit = null; + } + this.open = this.unit !== null; + }; + + public onCloseStructureModal = () => { + this.open = false; + this.unit = null; + }; + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + static styles = css` + :host { + position: fixed; + pointer-events: none; + z-index: 1000; + } + + .modal { + pointer-events: auto; + background: rgba(30, 30, 30, 0.95); + color: #f8f8f8; + border: 1px solid #555; + padding: 12px 18px; + border-radius: 8px; + min-width: 220px; + max-width: 300px; + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); + font-family: "Segoe UI", sans-serif; + font-size: 15px; + line-height: 1.6; + backdrop-filter: blur(6px); + position: relative; + } + + .modal strong { + color: #e0e0e0; + } + + .close-button { + background: #d00; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + padding: 6px 12px; + } + + .close-button:hover { + background: #a00; + } + `; + + render() { + if (!this.unit) return null; + + const cooldown = this.unit.ticksLeftInCooldown() ?? 0; + const secondsLeft = Math.ceil(cooldown / 10); + + return html` + + `; + } +} diff --git a/src/client/index.html b/src/client/index.html index 9c6d7624c..e1a224ea0 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -381,6 +381,7 @@ +
= 0) { this.silo.touch(); } } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 36584e36d..e2547218c 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -149,8 +149,8 @@ export class SAMLauncherExecution implements Execution { target = this.getSingleTarget(); } - if (this.sam.ticksLeftInCooldown() === 0) { - // Touch SAM to update sprite to show not in cooldown. + const cooldown = this.sam.ticksLeftInCooldown(); + if (typeof cooldown === "number" && cooldown >= 0) { this.sam.touch(); }