import { BuildableUnit, BuildMenus, Gold, PlayerBuildableUnitType, UnitType, } from "@openfront/engine-api/game/GameTypes"; import { renderNumber } from "@openfront/engine-lib/Format"; import { EventBus } from "@openfront/shared/EventBus"; import { html, LitElement, nothing } from "lit"; import { customElement } from "lit/decorators.js"; import "../../components/OfIcon"; import type { OfIconName } from "../../components/OfIcon"; import { Controller } from "../../Controller"; import { ConfirmGhostStructureEvent, InputHandler, ToggleStructureEvent, TouchGhostPlacementMoveEvent, TouchGhostPlacementSelectEvent, highlightTypesForUnit, isForwardedTouchClick, } from "../../InputHandler"; import { KeyboardLayoutController } from "../../KeyboardLayout"; import { KEYBOARD_DETECTED_EVENT, Platform } from "../../Platform"; import { UIState } from "../../UIState"; import { UserSettings } from "../../UserSettings"; import { resolveKeybindLabel, translateText } from "../../Utils"; import { GameView } from "../../view"; import { TutorialHighlight, TutorialHighlightEvent } from "../Tutorial"; /** Why a unit can or cannot be picked: no gold for it, or something it needs is missing. */ type BuildState = "ready" | "poor" | "locked"; /** What picking a unit depends on, read once per render. */ interface BuildNeeds { gold: Gold; hasSilo: boolean; hasPort: boolean; } // Look. Sizes, colours, radii and spacing of each part live here, so a // change of look is one edit. The templates below only hold structure. const ITEM = "gap-0.5"; // The border is a real one: the tutorial highlight in styles.css recolours it. // From lg every tile is 44px wide, so the prices above them line up. // The column's scroller gets 100dvh - 5.5rem and ten tiles need ten heights // plus nine 2px gaps (1.125rem), so on short screens the tiles shrink, down // to 36px, instead of scrolling. From 590px tall they stay 48px. const TILE = "relative flex w-full items-center justify-center rounded-sm border text-white cursor-pointer transition-colors h-6.5 lg:w-11 max-lg:landscape:h-7.5 side-bar:h-[clamp(2.25rem,calc((100dvh-6.625rem)/10),3rem)] side-bar:min-w-6.5"; // The armed unit (picked, waiting for a spot on the map) is filled blue. const TILE_SELECTION = { idle: "border-white/15 pointer-fine:hover:bg-white/10 active:bg-white/15", selected: "border-aquarius bg-malibu-blue/25 pointer-fine:hover:bg-malibu-blue/35", } as const; // A unit you cannot build dims its icon, count and price but keeps its // tile's edge, so at the start of a game the bar does not look switched off. // A locked one (no silo for a bomb, no port for a warship) dims further. const ICON_STATE: Record = { ready: "", poor: "opacity-50", locked: "opacity-30", }; const PRICE_STATE: Record = { ready: "text-cyber-yellow/80", poor: "text-red-400", locked: "text-white/30", }; // In place of the count from lg; below lg, where no count is shown, a badge // in the tile's bottom end corner. The lock is narrower than it is tall, so // end-px leaves as much room beside it as below it. const LOCK = "absolute bottom-0.5 end-px text-white/60 lg:static lg:ms-0.5"; const REASON_STATE: Record, string> = { poor: "text-red-400", locked: "text-white/55", }; // Drawn above the tile with order-first; it keeps its height while empty so // the row does not jump when the costs arrive. No room for it in the column. const PRICE = "order-first h-2.5 text-[10px] font-semibold leading-[10px] tabular-nums side-bar:hidden"; // In the corner, out of the tile's flow, so it does not push the icon aside. const HOTKEY = "absolute top-0.5 start-0.5 max-w-full truncate text-[10px] leading-none text-white/45"; const COUNT = "hidden lg:inline text-xs leading-none tabular-nums hud-caps"; const COUNT_EMPTY = "text-white/45"; /** How long a build button has to be held before it shows its details (cost etc.) */ const PEEK_HOLD_MS = 450; /** How long the details stay up after the browser cancels the touch itself. */ const PEEK_GRACE_MS = 1200; // A unit dragged from the bar sits this far above the finger (about 7mm on a // phone), so the finger doesn't hide where it will be built. const DRAG_GHOST_LIFT_PX = 44; @customElement("unit-display") export class UnitDisplay extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; public uiState: UIState; public inputHandler: InputHandler; private playerBuildables: BuildableUnit[] | null = null; private keybinds: Record = {}; private keyboardLayout = new KeyboardLayoutController(this); private _cities = 0; private _warships = 0; private _factories = 0; private _missileSilo = 0; private _port = 0; private _defensePost = 0; private _samLauncher = 0; private allDisabled = false; private enabledUnitCount = 0; private tutorialMode = false; private _hoveredUnitTooltip: { unitType: PlayerBuildableUnitType; structureKey: string; hotkey: string; element: HTMLElement; } | null = null; /** * The unit the player is holding down. A touch device has no hover, so a * press-and-hold is how the cost and description are read before the unit * is picked. */ private _peekedUnitTooltip: { unitType: PlayerBuildableUnitType; structureKey: string; hotkey: string; //touch device may still have a keyboard element: HTMLElement; } | null = null; private peekTimer: ReturnType | null = null; private peekGraceTimer: ReturnType | null = null; private suppressNextClick = false; // Keep the hold's release click suppressed, but not a later physical click. private suppressReleaseClick = false; private lastTouchClickItem: HTMLElement | null = null; private peekPointerId: number | null = null; private peekTouchIdentifier: number | null = null; private activeTouchIdentifiers = new Set(); // Where the tooltip is centred along the bar, in the bar's own coordinates: // x for the bottom row, y for the side column. Kept for the clamp after the // render, since the tooltip cannot be measured before it exists. private _tooltipCenter: number | null = null; private tutorialHighlight: PlayerBuildableUnitType | null = null; // Whether the build bar has actions hidden off either edge. The fade on that // edge tells the player there is more to scroll to. private moreActionsStart = false; private moreActionsEnd = false; private observedScroller: HTMLElement | null = null; private windowListenersAttached = false; /** * A touch on a build button that may become a drag onto the map. It * becomes active once the finger leaves the bar: the unit is picked and * its ghost follows the finger. Letting go on the map builds there; letting * go back over the bar cancels. */ private placeDrag: { pointerId: number; unitType: PlayerBuildableUnitType; active: boolean; } | null = null; createRenderRoot() { return this; } init() { const config = this.game.config(); const userSettings = new UserSettings(); this.keybinds = userSettings.parsedUserKeybinds(); this.tutorialMode = config.gameConfig().tutorial ?? false; // The bar is hidden on small screens, where the radial menu builds, but // the onboarding tutorial fires its atom bomb from here on every screen. if (this.tutorialMode) this.classList.remove("hidden"); this.enabledUnitCount = BuildMenus.types.filter((unitType) => this.isUnitDisplayed(unitType), ).length; this.allDisabled = this.enabledUnitCount === 0; const highlightUnits: Partial< Record > = { city: UnitType.City, port: UnitType.Port, defense_post: UnitType.DefensePost, factory: UnitType.Factory, warship: UnitType.Warship, silo: UnitType.MissileSilo, atom: UnitType.AtomBomb, hydrogen: UnitType.HydrogenBomb, mirv: UnitType.MIRV, sam: UnitType.SAMLauncher, }; this.eventBus.on(TutorialHighlightEvent, (e) => { this.tutorialHighlight = (e.target && highlightUnits[e.target]) ?? null; this.requestUpdate(); }); this.requestUpdate(); } connectedCallback() { super.connectedCallback(); if (this.windowListenersAttached) return; window.addEventListener("resize", this.onLayoutChange); window.addEventListener( KEYBOARD_DETECTED_EVENT, this.handleKeyboardDetected, ); document.addEventListener("pointerdown", this.handleDocumentPointerDown); document.addEventListener("pointermove", this.handleDocumentPointerMove); document.addEventListener("pointerup", this.handleDocumentPointerUp); document.addEventListener( "pointercancel", this.handleDocumentPointerCancel, ); document.addEventListener("touchstart", this.handlePeekTouchEvent, true); document.addEventListener("touchend", this.handlePeekTouchEvent, true); document.addEventListener("touchcancel", this.handlePeekTouchEvent, true); this.windowListenersAttached = true; } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("resize", this.onLayoutChange); window.removeEventListener( KEYBOARD_DETECTED_EVENT, this.handleKeyboardDetected, ); document.removeEventListener("pointerdown", this.handleDocumentPointerDown); document.removeEventListener("pointermove", this.handleDocumentPointerMove); document.removeEventListener("pointerup", this.handleDocumentPointerUp); document.removeEventListener( "pointercancel", this.handleDocumentPointerCancel, ); document.removeEventListener("touchstart", this.handlePeekTouchEvent, true); document.removeEventListener("touchend", this.handlePeekTouchEvent, true); document.removeEventListener( "touchcancel", this.handlePeekTouchEvent, true, ); this.dismissPeek(); this.placeDrag = null; this.activeTouchIdentifiers.clear(); this.suppressNextClick = false; this.suppressReleaseClick = false; this.clearTouchClickTarget(); this.windowListenersAttached = false; } /** The bar moved or resized: the tooltip and the edge fades have to follow. */ private onLayoutChange = () => { this.requestUpdate(); }; /** * A keyboard can show up after the page loaded (a tablet with a Bluetooth * keyboard, a desktop user who only now typed). Show the hotkeys from then on. */ private handleKeyboardDetected = () => { this.requestUpdate(); }; /** * Holding a build button shows its cost and description. Touch devices have * no hover, and a tap selects the unit right away, so the details need a * press of their own. */ private startPeek = ( event: PointerEvent, unitType: PlayerBuildableUnitType, structureKey: string, hotkey: string, ) => { if (event.pointerType !== "touch") return; // A new press replaces the peek of the previous one. this.dismissPeek(); this.peekPointerId = event.pointerId; // The press that follows is a new gesture: it may select the unit. this.suppressNextClick = false; const element = event.currentTarget as HTMLElement; this.peekTimer = setTimeout(() => { this.peekTimer = null; // The hold becomes a peek: releasing it must not select the unit. this.suppressNextClick = true; this._peekedUnitTooltip = { unitType, structureKey, hotkey, element }; this.requestUpdate(); }, PEEK_HOLD_MS); }; private cancelPeekTimer() { if (this.peekTimer === null) return; clearTimeout(this.peekTimer); this.peekTimer = null; } private dismissPeek() { this.cancelPeekTimer(); this.peekPointerId = null; this.peekTouchIdentifier = null; if (this.peekGraceTimer !== null) { clearTimeout(this.peekGraceTimer); this.peekGraceTimer = null; } if (this._peekedUnitTooltip === null) return; this._peekedUnitTooltip = null; this.requestUpdate(); } /** A press outside the bar dismisses the peek it opened. */ private handleDocumentPointerDown = (event: PointerEvent) => { this.suppressReleaseClick = false; if (event.pointerType !== "touch") this.clearTouchClickTarget(); if (this._peekedUnitTooltip === null) return; const root = this.querySelector(".unit-display-root"); if (root && event.target instanceof Node && root.contains(event.target)) { return; } this.dismissPeek(); }; private armPlaceDrag(event: PointerEvent, unitType: PlayerBuildableUnitType) { if (event.pointerType !== "touch") return; this.placeDrag = { pointerId: event.pointerId, unitType, active: false }; } private isOverBar(x: number, y: number): boolean { const bounds = this.querySelector(".unit-display-root")?.getBoundingClientRect(); return ( bounds !== undefined && x >= bounds.left && x <= bounds.right && y >= bounds.top && y <= bounds.bottom ); } /** * The touch keeps its button as target (implicit pointer capture), so its * moves arrive here even over the map, and the map's input never sees it. */ private handleDocumentPointerMove = (event: PointerEvent) => { const drag = this.placeDrag; if (drag === null || drag.pointerId !== event.pointerId) return; if (!drag.active) { if (this.isOverBar(event.clientX, event.clientY)) return; if (!this.canBuild(drag.unitType)) { this.placeDrag = null; return; } drag.active = true; // A drag is not a hold: no details, and no selection on release. this.dismissPeek(); // Picking the unit that's already held would raise its upgrade // multiplier, as a second click does. if (this.uiState.ghostStructure !== drag.unitType) { this.inputHandler.setGhostStructure(drag.unitType, "GUIclick"); } this.eventBus?.emit(new TouchGhostPlacementSelectEvent()); this.eventBus?.emit( new ToggleStructureEvent(highlightTypesForUnit(drag.unitType)), ); this.requestUpdate(); } this.eventBus?.emit( new TouchGhostPlacementMoveEvent( event.clientX, event.clientY - DRAG_GHOST_LIFT_PX, ), ); }; /** * Ends a drag from the bar: a release on the map builds where the ghost * is, just above the finger. Either way the unit is dropped afterwards. * Returns whether the pointer was dragging, so the hold logic skips it. */ private finishPlaceDrag(event: PointerEvent): boolean { const drag = this.placeDrag; if (drag === null || drag.pointerId !== event.pointerId) return false; this.placeDrag = null; if (!drag.active) return false; this.suppressReleaseClick = true; if ( event.type === "pointerup" && !this.isOverBar(event.clientX, event.clientY) ) { this.eventBus?.emit( new TouchGhostPlacementMoveEvent( event.clientX, event.clientY - DRAG_GHOST_LIFT_PX, ), ); // Letting go ends the placement: the unit is dropped whether it builds // there or not, nukes included. this.eventBus?.emit(new ConfirmGhostStructureEvent(true)); } else { this.inputHandler.setGhostStructure(null, "GUIclick"); } this.requestUpdate(); return true; } /** Releasing anywhere ends the hold, wherever the finger ended up. */ private handleDocumentPointerUp = (event: PointerEvent) => { if (this.finishPlaceDrag(event)) return; if (this.peekPointerId !== event.pointerId) return; this.suppressReleaseClick = this.suppressNextClick; this.suppressNextClick = false; this.cancelPeekTimer(); this.peekPointerId = null; this.peekTouchIdentifier = null; // The details are only useful while the button is held down. if (this._peekedUnitTooltip !== null) this.dismissPeek(); }; /** Pointer cancellation can precede touchend; the touch event ends the peek. */ private handleDocumentPointerCancel = (event: PointerEvent) => { if (this.finishPlaceDrag(event)) return; if (this.peekPointerId !== event.pointerId) return; this.cancelPeekTimer(); if ( this._peekedUnitTooltip !== null && this.activeTouchIdentifiers.size > 1 ) { this.startPeekGraceTimer(); } }; private handlePeekTouchEvent = (event: TouchEvent) => { const root = this.querySelector(".unit-display-root"); const isInBuildBar = event.target instanceof Node && root?.contains(event.target); const changedTouches = Array.from(event.changedTouches); if (event.type === "touchstart") { this.clearTouchClickTarget(); for (const touch of changedTouches) { this.activeTouchIdentifiers.add(touch.identifier); } if (isInBuildBar && this.peekTouchIdentifier === null) { this.peekTouchIdentifier = changedTouches[0]?.identifier ?? null; } } else { if (event.type === "touchend" && isInBuildBar) { this.lastTouchClickItem = event.target instanceof Element ? event.target.closest(".unit-display-grid > div") : null; } const trackedTouchEnded = changedTouches.some( (touch) => touch.identifier === this.peekTouchIdentifier, ); for (const touch of changedTouches) { this.activeTouchIdentifiers.delete(touch.identifier); } if (!trackedTouchEnded) return; this.peekTouchIdentifier = null; this.peekPointerId = null; this.cancelPeekTimer(); if (event.type === "touchend") { this.suppressReleaseClick ||= this.suppressNextClick; this.suppressNextClick = false; if (this._peekedUnitTooltip !== null) this.dismissPeek(); } else { this.clearTouchClickTarget(); this.suppressNextClick = false; this.suppressReleaseClick = false; if (this._peekedUnitTooltip !== null) { this.startPeekGraceTimer(); } } } }; private clearTouchClickTarget() { this.lastTouchClickItem = null; } private isTouchClick(event: Event, item: Element | null): boolean { const isTouch = (event instanceof PointerEvent && event.pointerType === "touch") || isForwardedTouchClick(event) || (event instanceof MouseEvent && event.detail > 0 && item === this.lastTouchClickItem); this.clearTouchClickTarget(); return isTouch; } private startPeekGraceTimer() { if (this.peekGraceTimer !== null) clearTimeout(this.peekGraceTimer); this.peekGraceTimer = setTimeout(() => { this.peekGraceTimer = null; if (this._peekedUnitTooltip !== null) this.dismissPeek(); }, PEEK_GRACE_MS); } private onScroll = () => { this.requestUpdate(); }; /** * Below lg in portrait the bar is a column on the right edge of the * screen, scrolling up and down; everywhere else it is a row in the bottom * panel. Matches the `side-bar:` variant (styles.css) used by the bar and * its host in index.html. */ private isSideBar(): boolean { return ( typeof window.matchMedia === "function" && window.matchMedia("(width < 64rem) and (orientation: portrait)").matches ); } /** * Track which end of the build bar still hides actions, so the fade can * point at it. The bar only scrolls where it does not fit, so on a big * screen neither end is marked. */ private measureScrollState() { const scroller = this.getScroller(); if (!scroller) return; const [scrolled, visible, total] = this.isSideBar() ? [scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight] : [scroller.scrollLeft, scroller.clientWidth, scroller.scrollWidth]; this.moreActionsStart = scrolled > 1; this.moreActionsEnd = scrolled + visible < total - 1; } private getScroller(): HTMLElement | null { return this.querySelector(".unit-display-scroll"); } willUpdate() { this.measureScrollState(); } updated() { const scroller = this.getScroller(); if (scroller !== this.observedScroller) { this.observedScroller?.removeEventListener("scroll", this.onScroll); this.observedScroller = scroller; scroller?.addEventListener("scroll", this.onScroll); } this.clampTooltipToViewport(); } /** * Keep the tooltip on the mobile screen: next to an edge it would * otherwise hang off. The bottom row clamps it sideways, the side column * up and down. */ private clampTooltipToViewport() { const tooltip = this.querySelector(".unit-display-tooltip"); const root = this.querySelector(".unit-display-root"); if (!tooltip || !root || this._tooltipCenter === null) return; const rootBounds = root.getBoundingClientRect(); const tooltipBounds = tooltip.getBoundingClientRect(); const margin = 8; if (this.isSideBar()) { let top = rootBounds.top + this._tooltipCenter - tooltipBounds.height / 2; const maxTop = window.innerHeight - margin - tooltipBounds.height; if (top > maxTop) top = maxTop; if (top < margin) top = margin; tooltip.style.top = `${top - rootBounds.top + tooltipBounds.height / 2}px`; return; } // The tooltip is centred on the item, so its left edge sits half its // width to the left of that centre. let left = rootBounds.left + this._tooltipCenter - tooltipBounds.width / 2; const max = window.innerWidth - margin - tooltipBounds.width; if (left < margin) left = margin; else if (left > max) left = max; // The tooltip is pulled left by half its width with the transform, so the // style has to put that half back, or the box ends up twice shifted. tooltip.style.left = `${left - rootBounds.left + tooltipBounds.width / 2}px`; } private cost(item: UnitType): Gold { for (const bu of this.playerBuildables ?? []) { if (bu.type === item) { return bu.cost; } } return 0n; } // gold() makes a new BigInt and units() filters on every call, so a render // reads them once here instead of once per tile. private buildNeeds(): BuildNeeds { const player = this.game?.myPlayer(); return { gold: player?.gold() ?? 0n, hasSilo: (player?.units(UnitType.MissileSilo).length ?? 0) > 0, hasPort: (player?.units(UnitType.Port).length ?? 0) > 0, }; } // A missing silo or port wins over missing gold: more gold would not help. private buildState(item: UnitType, needs = this.buildNeeds()): BuildState { if (this.game?.config().isUnitDisabled(item)) return "locked"; switch (item) { case UnitType.AtomBomb: case UnitType.HydrogenBomb: case UnitType.MIRV: if (!needs.hasSilo) return "locked"; break; case UnitType.Warship: if (!needs.hasPort) return "locked"; break; } return this.cost(item) <= needs.gold ? "ready" : "poor"; } private canBuild(item: UnitType): boolean { return this.buildState(item) === "ready"; } /** Why a drawn unit cannot be picked. A unit the game disables has no tile. */ private unbuildableReason( item: UnitType, state: Exclude, ): string { if (state === "poor") return translateText("build_menu.not_enough_money"); return translateText("build_menu.requires", { unit: translateText( item === UnitType.Warship ? "unit_type.port" : "unit_type.missile_silo", ), }); } private isUnitDisplayed(unitType: PlayerBuildableUnitType): boolean { return ( !this.game.config().isUnitDisabled(unitType) && (!this.tutorialMode || unitType === UnitType.AtomBomb) ); } tick() { const player = this.game?.myPlayer(); if (!player) return; player .buildables(undefined, BuildMenus.types) .then((buildables) => { this.playerBuildables = buildables; }) .catch((error) => console.warn("Failed to refresh unit costs", error)); this._cities = player.totalUnitLevels(UnitType.City); this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo); this._port = player.totalUnitLevels(UnitType.Port); this._defensePost = player.totalUnitLevels(UnitType.DefensePost); this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher); this._factories = player.totalUnitLevels(UnitType.Factory); this._warships = player.totalUnitLevels(UnitType.Warship); this.requestUpdate(); } render() { const myPlayer = this.game?.myPlayer(); if ( !this.game || !myPlayer || this.game.inSpawnPhase() || !myPlayer.isAlive() ) { return null; } if (this.allDisabled) { return null; } // The onboarding tutorial's one build is the atom bomb, fired from the // nation's silo once captured. if ( this.tutorialMode && myPlayer.units(UnitType.MissileSilo).length === 0 ) { return null; } const needs = this.buildNeeds(); // A row in the bottom panel: below the control panel from lg up, above // it on landscape phones (index.html orders it), spread over the panel's // width there. Below lg in portrait (side-bar) it is a column on the // right edge of the screen, where index.html fixes the host. return html`
event.preventDefault()} >
${this.renderUnitItem( "city", this._cities, UnitType.City, "city", this.getHotkey("buildCity", "Digit1"), needs, )} ${this.renderUnitItem( "factory", this._factories, UnitType.Factory, "factory", this.getHotkey("buildFactory", "Digit2"), needs, )} ${this.renderUnitItem( "port", this._port, UnitType.Port, "port", this.getHotkey("buildPort", "Digit3"), needs, )} ${this.renderUnitItem( "shield", this._defensePost, UnitType.DefensePost, "defense_post", this.getHotkey("buildDefensePost", "Digit4"), needs, )} ${this.renderUnitItem( "missile-silo", this._missileSilo, UnitType.MissileSilo, "missile_silo", this.getHotkey("buildMissileSilo", "Digit5"), needs, )} ${this.renderUnitItem( "sam-launcher", this._samLauncher, UnitType.SAMLauncher, "sam_launcher", this.getHotkey("buildSamLauncher", "Digit6"), needs, )} ${this.renderUnitItem( "warship", this._warships, UnitType.Warship, "warship", this.getHotkey("buildWarship", "Digit7"), needs, )} ${this.renderUnitItem( "atom-bomb", null, UnitType.AtomBomb, "atom_bomb", this.getHotkey("buildAtomBomb", "Digit8"), needs, )} ${this.renderUnitItem( "hydrogen-bomb", null, UnitType.HydrogenBomb, "hydrogen_bomb", this.getHotkey("buildHydrogenBomb", "Digit9"), needs, )} ${this.renderUnitItem( "mirv", null, UnitType.MIRV, "mirv", this.getHotkey("buildMIRV", "Digit0"), needs, )}
${this.renderUnitTooltip(needs)}
`; } private getHotkey(action: string, defaultCode: string): string { // Without a keyboard the hotkeys cannot be used, so don't resolve them // (or the keyboard layout behind them) and don't show them. if (!Platform.hasKeyboard) return ""; return resolveKeybindLabel( this.keybinds[action], defaultCode, this.keyboardLayout.map, ); } private renderUnitItem( icon: OfIconName, number: number | null, unitType: PlayerBuildableUnitType, structureKey: string, hotkey: string, needs: BuildNeeds, ) { if (!this.isUnitDisplayed(unitType)) { return html``; } const selected = this.uiState.ghostStructure === unitType; const state = this.buildState(unitType, needs); const name = translateText("unit_type." + structureKey); const highlighted = this.tutorialHighlight === unitType; const cost = this.cost(unitType); const priceId = `unit-price-${structureKey}`; const displayHotkey = hotkey .replace("Digit", "") .replace("Key", "") .toUpperCase(); return html`
{ const item = event.currentTarget as HTMLElement; this._hoveredUnitTooltip = { unitType, structureKey, hotkey, element: item, }; this.requestUpdate(); }} @mouseleave=${() => { this._hoveredUnitTooltip = null; this.requestUpdate(); }} @pointerdown=${(event: PointerEvent) => { this.startPeek(event, unitType, structureKey, hotkey); this.armPlaceDrag(event, unitType); }} >
this.onUnitClick(event, unitType, selected)} @pointerenter=${(event: PointerEvent) => this.onUnitPointerEnter(event, unitType)} @pointerleave=${(event: PointerEvent) => this.onUnitPointerLeave(event)} > ${ hotkey ? html`
${displayHotkey}
` : nothing }
${ number !== null && state !== "locked" ? html`${renderNumber(number)}` : null }
${ state === "locked" ? html`` : nothing }
`; } private onUnitClick( event: Event, unitType: PlayerBuildableUnitType, selected: boolean, ) { const item = (event.currentTarget as HTMLElement).closest( ".unit-display-grid > div", ); const fromTouch = this.isTouchClick(event, item); // A press that turned into a peek shows the details instead of // selecting the unit if (this.suppressNextClick || this.suppressReleaseClick) { this.suppressNextClick = false; this.suppressReleaseClick = false; return; } if (selected) { this.inputHandler.setGhostStructure(null, "GUIclick"); } else if (this.canBuild(unitType)) { this.inputHandler.setGhostStructure(unitType, "GUIclick"); if (fromTouch) { this.eventBus?.emit(new TouchGhostPlacementSelectEvent()); this.eventBus?.emit( new ToggleStructureEvent(highlightTypesForUnit(unitType)), ); } } else { const selectedGhost = this.uiState.ghostStructure; this.eventBus?.emit( new ToggleStructureEvent( selectedGhost === null ? null : highlightTypesForUnit(selectedGhost), ), ); } this.requestUpdate(); } private onUnitPointerEnter( event: PointerEvent, unitType: PlayerBuildableUnitType, ) { if (event.pointerType === "touch") return; this.eventBus?.emit( new ToggleStructureEvent(highlightTypesForUnit(unitType)), ); } private onUnitPointerLeave(event: PointerEvent) { if (event.pointerType === "touch") return; const selectedGhost = this.uiState.ghostStructure; this.eventBus?.emit( new ToggleStructureEvent( selectedGhost === null ? null : highlightTypesForUnit(selectedGhost), ), ); } private renderUnitTooltip(needs: BuildNeeds) { // A peek wins over a hover: it is the one the player asked for by // holding the button. const peek = this._peekedUnitTooltip; const tooltip = peek ?? this._hoveredUnitTooltip; if (!tooltip) return nothing; // The item can go away (a unit gets disabled) while it is hovered. if (!tooltip.element.isConnected) return nothing; const state = this.buildState(tooltip.unitType, needs); const displayHotkey = tooltip.hotkey .replace("Digit", "") .replace("Key", "") .toUpperCase(); // Measured at render time so the tooltip follows the item when the bar // scrolls or the layout changes, instead of staying where it was hovered. const root = this.querySelector(".unit-display-root"); const itemBounds = tooltip.element.getBoundingClientRect(); const rootBounds = root?.getBoundingClientRect(); const sideBar = this.isSideBar(); let left: number; let top: number; let placement: string; if (sideBar) { // Left of the item, centred on it, so it doesn't cover the bar. left = itemBounds.left - (rootBounds?.left ?? 0) - 6; top = itemBounds.top + itemBounds.height / 2 - (rootBounds?.top ?? 0); placement = "-translate-x-full -translate-y-1/2 max-w-[min(16rem,100vw_-_5rem)]"; this._tooltipCenter = top; } else { left = itemBounds.left + itemBounds.width / 2 - (rootBounds?.left ?? 0); top = itemBounds.top - (rootBounds?.top ?? 0) - 4; placement = "-translate-x-1/2 -translate-y-full max-w-[min(100%_-_1rem,100vw_-_1rem)]"; this._tooltipCenter = left; } return html`
${translateText("unit_type." + tooltip.structureKey)}${ tooltip.hotkey ? ` [${displayHotkey}]` : "" }
${translateText("build_menu.desc." + tooltip.structureKey)}
${ tooltip.unitType === UnitType.Warship && Platform.hasKeyboard ? html`
⇧ ${translateText("build_menu.warship_shift_hint")}
` : null } ${ state === "ready" ? nothing : html`
${this.unbuildableReason(tooltip.unitType, state)}
` }
${renderNumber(this.cost(tooltip.unitType))}
`; } }