mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 02:05:40 +00:00
Unit menu (#867)
## Description: We are adding a modal to display information about a unit.  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
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string, ImageData> = 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<Record<UnitType, UnitRenderConfig>> = {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="modal"
|
||||
style="display: ${this.open ? "block" : "none"}; left: ${this
|
||||
.x}px; top: ${this.y}px; position: absolute;"
|
||||
>
|
||||
<div style="margin-bottom: 8px; font-size: 16px; font-weight: bold;">
|
||||
Structure Info
|
||||
</div>
|
||||
<div style="margin-bottom: 4px;">
|
||||
<strong>Type:</strong> ${this.unit.type?.() ?? "Unknown"}
|
||||
</div>
|
||||
${secondsLeft > 0
|
||||
? html`<div style="margin-bottom: 4px;">
|
||||
<strong>Cooldown:</strong> ${secondsLeft}s
|
||||
</div>`
|
||||
: ""}
|
||||
<div style="margin-top: 14px; display: flex; justify-content: center;">
|
||||
<button
|
||||
@click=${() => {
|
||||
this.onCloseStructureModal();
|
||||
if (this.structureLayer) {
|
||||
this.structureLayer.unSelectStructureUnit();
|
||||
}
|
||||
}}
|
||||
class="close-button"
|
||||
title="Close"
|
||||
style="width: 100px; height: 32px;"
|
||||
>
|
||||
CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -381,6 +381,7 @@
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<unit-info-modal></unit-info-modal>
|
||||
<news-modal></news-modal>
|
||||
<left-in-game-ad></left-in-game-ad>
|
||||
<div
|
||||
|
||||
@@ -53,7 +53,8 @@ export class MissileSiloExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.silo.ticksLeftInCooldown() === 0) {
|
||||
const cooldown = this.silo.ticksLeftInCooldown();
|
||||
if (typeof cooldown === "number" && cooldown >= 0) {
|
||||
this.silo.touch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user