From 311d43ab4fb13555a37fc59e8b2f45ae89cdaece Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Thu, 2 Oct 2025 21:38:28 +0200 Subject: [PATCH] Build bar (#2059) ## Description: Make the unit display bar a proper unit build bar Add shortcuts for all structures and units Add ranges for ranged structures and units Changed the shortcuts to use the key instead of the code for internationalization purposes ![buildbar](https://github.com/user-attachments/assets/6407dc9c-14b4-40cc-8faa-cdd9e88c9fd2) image ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: Mr.Box --------- Co-authored-by: evanpelle Co-authored-by: icslucas Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- resources/lang/en.json | 19 + src/client/ClientGameRunner.ts | 6 +- src/client/InputHandler.ts | 92 ++- src/client/UserSettingModal.ts | 127 +++- .../baseComponents/setting/SettingKeybind.ts | 2 +- src/client/graphics/GameRenderer.ts | 5 +- src/client/graphics/UIState.ts | 3 + .../graphics/layers/StructureDrawingUtils.ts | 447 +++++++++++ .../graphics/layers/StructureIconsLayer.ts | 700 ++++++++---------- src/client/graphics/layers/UnitDisplay.ts | 297 ++++++-- src/core/GameRunner.ts | 11 +- src/core/game/Game.ts | 2 +- src/core/game/GameView.ts | 6 +- src/core/game/PlayerImpl.ts | 13 +- src/core/worker/WorkerClient.ts | 4 +- src/core/worker/WorkerMessages.ts | 4 +- tests/InputHandler.test.ts | 60 +- 17 files changed, 1283 insertions(+), 515 deletions(-) create mode 100644 src/client/graphics/layers/StructureDrawingUtils.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 6b9ed962b..f3ef57e7d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -326,6 +326,25 @@ "view_options": "View Options", "toggle_view": "Toggle View", "toggle_view_desc": "Alternate view (terrain/countries)", + "build_controls": "Build Controls", + "build_city": "Build City", + "build_city_desc": "Build a City under your cursor.", + "build_factory": "Build Factory", + "build_factory_desc": "Build a Factory under your cursor.", + "build_defense_post": "Build Defense Post", + "build_defense_post_desc": "Build a Defense Post under your cursor.", + "build_port": "Build Port", + "build_port_desc": "Build a Port under your cursor.", + "build_warship": "Build Warship", + "build_warship_desc": "Build a Warship under your cursor.", + "build_missile_silo": "Build Missile Silo", + "build_missile_silo_desc": "Build a Missile Silo under your cursor.", + "build_sam_launcher": "Build SAM Launcher", + "build_sam_launcher_desc": "Build a SAM Launcher under your cursor.", + "build_atom_bomb": "Build Atom Bomb", + "build_atom_bomb_desc": "Build an Atom Bomb under your cursor.", + "build_hydrogen_bomb": "Build Hydrogen Bomb", + "build_hydrogen_bomb_desc": "Build a Hydrogen Bomb under your cursor.", "attack_ratio_controls": "Attack Ratio Controls", "attack_ratio_up": "Increase Attack Ratio", "attack_ratio_up_desc": "Increase attack ratio by 10%", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 3d38c9a34..1129b0947 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -185,7 +185,7 @@ async function createClientGame( lobbyConfig, eventBus, gameRenderer, - new InputHandler(canvas, eventBus), + new InputHandler(gameRenderer.uiState, canvas, eventBus), transport, worker, gameView, @@ -198,7 +198,6 @@ export class ClientGameRunner { private turnsSeen = 0; private hasJoined = false; - private lastMousePosition: { x: number; y: number } | null = null; private lastMessageTime: number = 0; @@ -257,6 +256,7 @@ export class ClientGameRunner { 1000, ); }, 20000); + this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this)); this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this)); this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this)); @@ -387,7 +387,7 @@ export class ClientGameRunner { } private inputEvent(event: MouseUpEvent) { - if (!this.isActive) { + if (!this.isActive || this.renderer.uiState.ghostStructure !== null) { return; } const cell = this.renderer.transformHandler.screenToWorldCoordinates( diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index f6d661f07..69eb3b2e7 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -2,6 +2,7 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { UnitType } from "../core/game/Game"; import { UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; +import { UIState } from "./graphics/UIState"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class MouseUpEvent implements GameEvent { @@ -75,7 +76,7 @@ export class RefreshGraphicsEvent implements GameEvent {} export class TogglePerformanceOverlayEvent implements GameEvent {} export class ToggleStructureEvent implements GameEvent { - constructor(public readonly structureType: UnitType | null) {} + constructor(public readonly structureTypes: UnitType[] | null) {} } export class ShowBuildMenuEvent implements GameEvent { @@ -136,14 +137,36 @@ export class InputHandler { private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; - private userSettings: UserSettings = new UserSettings(); + private readonly userSettings: UserSettings = new UserSettings(); constructor( + public uiState: UIState, private canvas: HTMLCanvasElement, private eventBus: EventBus, ) {} initialize() { + let saved: Record = {}; + try { + const parsed = JSON.parse( + localStorage.getItem("settings.keybinds") ?? "{}", + ); + // flatten { key: {key, value} } → { key: value } and accept legacy string values + saved = Object.fromEntries( + Object.entries(parsed) + .map(([k, v]) => { + if (v && typeof v === "object" && "value" in (v as any)) { + return [k, (v as any).value as string]; + } + if (typeof v === "string") return [k, v]; + return [k, undefined]; + }) + .filter(([, v]) => typeof v === "string" && v !== "Null"), + ) as Record; + } catch (e) { + console.warn("Invalid keybinds JSON:", e); + } + this.keybinds = { toggleView: "Space", centerCamera: "KeyC", @@ -153,13 +176,22 @@ export class InputHandler { moveRight: "KeyD", zoomOut: "KeyQ", zoomIn: "KeyE", - attackRatioDown: "Digit1", - attackRatioUp: "Digit2", + attackRatioDown: "KeyT", + attackRatioUp: "KeyY", boatAttack: "KeyB", groundAttack: "KeyG", modifierKey: "ControlLeft", altKey: "AltLeft", - ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), + buildCity: "Digit1", + buildFactory: "Digit2", + buildPort: "Digit3", + buildDefensePost: "Digit4", + buildMissileSilo: "Digit5", + buildSamLauncher: "Digit6", + buildAtomBomb: "Digit7", + buildHydrogenBomb: "Digit8", + buildWarship: "Digit9", + ...saved, }; // Mac users might have different keybinds @@ -266,6 +298,7 @@ export class InputHandler { if (e.code === "Escape") { e.preventDefault(); this.eventBus.emit(new CloseViewEvent()); + this.uiState.ghostStructure = null; } if ( @@ -331,6 +364,51 @@ export class InputHandler { this.eventBus.emit(new CenterCameraEvent()); } + if (e.code === this.keybinds.buildCity) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.City; + } + + if (e.code === this.keybinds.buildFactory) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.Factory; + } + + if (e.code === this.keybinds.buildPort) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.Port; + } + + if (e.code === this.keybinds.buildDefensePost) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.DefensePost; + } + + if (e.code === this.keybinds.buildMissileSilo) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.MissileSilo; + } + + if (e.code === this.keybinds.buildSamLauncher) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.SAMLauncher; + } + + if (e.code === this.keybinds.buildAtomBomb) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.AtomBomb; + } + + if (e.code === this.keybinds.buildHydrogenBomb) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.HydrogenBomb; + } + + if (e.code === this.keybinds.buildWarship) { + e.preventDefault(); + this.uiState.ghostStructure = UnitType.Warship; + } + // Shift-D to toggle performance overlay console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { @@ -489,6 +567,10 @@ export class InputHandler { private onContextMenu(event: MouseEvent) { event.preventDefault(); + if (this.uiState.ghostStructure !== null) { + this.uiState.ghostStructure = null; + return; + } this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index d55f644ea..06d989009 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -13,7 +13,8 @@ export class UserSettingModal extends LitElement { private userSettings: UserSettings = new UserSettings(); @state() private settingsMode: "basic" | "keybinds" = "basic"; - @state() private keybinds: Record = {}; + @state() private keybinds: Record = + {}; @state() private keySequence: string[] = []; @state() private showEasterEggSettings = false; @@ -206,14 +207,15 @@ export class UserSettingModal extends LitElement { } private handleKeybindChange( - e: CustomEvent<{ action: string; value: string }>, + e: CustomEvent<{ action: string; value: string; key: string }>, ) { - const { action, value } = e.detail; - const prevValue = this.keybinds[action] ?? ""; + console.log("Keybind change event:", e); + const { action, value, key } = e.detail; + const prevValue = this.keybinds[action]?.value ?? ""; const values = Object.entries(this.keybinds) .filter(([k]) => k !== action) - .map(([, v]) => v); + .map(([, v]) => v.value); if (values.includes(value) && value !== "Null") { const popup = document.createElement("div"); popup.className = "setting-popup"; @@ -228,7 +230,7 @@ export class UserSettingModal extends LitElement { } return; } - this.keybinds = { ...this.keybinds, [action]: value }; + this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } }; localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds)); } @@ -430,7 +432,92 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.toggle_view")} description=${translateText("user_setting.toggle_view_desc")} defaultKey="Space" - .value=${this.keybinds["toggleView"] ?? ""} + .value=${this.keybinds["toggleView"]?.key ?? ""} + @change=${this.handleKeybindChange} + > + +
+ ${translateText("user_setting.build_controls")} +
+ + + + + + + + + + + + + + + + + + @@ -442,8 +529,8 @@ export class UserSettingModal extends LitElement { action="attackRatioDown" label=${translateText("user_setting.attack_ratio_down")} description=${translateText("user_setting.attack_ratio_down_desc")} - defaultKey="Digit1" - .value=${this.keybinds["attackRatioDown"] ?? ""} + defaultKey="KeyT" + .value=${this.keybinds["attackRatioDown"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -451,8 +538,8 @@ export class UserSettingModal extends LitElement { action="attackRatioUp" label=${translateText("user_setting.attack_ratio_up")} description=${translateText("user_setting.attack_ratio_up_desc")} - defaultKey="Digit2" - .value=${this.keybinds["attackRatioUp"] ?? ""} + defaultKey="KeyY" + .value=${this.keybinds["attackRatioUp"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -465,7 +552,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.boat_attack")} description=${translateText("user_setting.boat_attack_desc")} defaultKey="KeyB" - .value=${this.keybinds["boatAttack"] ?? ""} + .value=${this.keybinds["boatAttack"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -474,7 +561,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.ground_attack")} description=${translateText("user_setting.ground_attack_desc")} defaultKey="KeyG" - .value=${this.keybinds["groundAttack"] ?? ""} + .value=${this.keybinds["groundAttack"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -487,7 +574,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.zoom_out")} description=${translateText("user_setting.zoom_out_desc")} defaultKey="KeyQ" - .value=${this.keybinds["zoomOut"] ?? ""} + .value=${this.keybinds["zoomOut"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -496,7 +583,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.zoom_in")} description=${translateText("user_setting.zoom_in_desc")} defaultKey="KeyE" - .value=${this.keybinds["zoomIn"] ?? ""} + .value=${this.keybinds["zoomIn"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -509,7 +596,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.center_camera")} description=${translateText("user_setting.center_camera_desc")} defaultKey="KeyC" - .value=${this.keybinds["centerCamera"] ?? ""} + .value=${this.keybinds["centerCamera"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -518,7 +605,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.move_up")} description=${translateText("user_setting.move_up_desc")} defaultKey="KeyW" - .value=${this.keybinds["moveUp"] ?? ""} + .value=${this.keybinds["moveUp"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -527,7 +614,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.move_left")} description=${translateText("user_setting.move_left_desc")} defaultKey="KeyA" - .value=${this.keybinds["moveLeft"] ?? ""} + .value=${this.keybinds["moveLeft"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -536,7 +623,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.move_down")} description=${translateText("user_setting.move_down_desc")} defaultKey="KeyS" - .value=${this.keybinds["moveDown"] ?? ""} + .value=${this.keybinds["moveDown"]?.key ?? ""} @change=${this.handleKeybindChange} > @@ -545,7 +632,7 @@ export class UserSettingModal extends LitElement { label=${translateText("user_setting.move_right")} description=${translateText("user_setting.move_right_desc")} defaultKey="KeyD" - .value=${this.keybinds["moveRight"] ?? ""} + .value=${this.keybinds["moveRight"]?.key ?? ""} @change=${this.handleKeybindChange} > `; diff --git a/src/client/components/baseComponents/setting/SettingKeybind.ts b/src/client/components/baseComponents/setting/SettingKeybind.ts index 049306613..4e102ee0b 100644 --- a/src/client/components/baseComponents/setting/SettingKeybind.ts +++ b/src/client/components/baseComponents/setting/SettingKeybind.ts @@ -80,7 +80,7 @@ export class SettingKeybind extends LitElement { this.dispatchEvent( new CustomEvent("change", { - detail: { action: this.action, value: code }, + detail: { action: this.action, value: code, key: e.key }, bubbles: true, composed: true, }), diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 01c25ed5c..c7c450eb7 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -48,7 +48,7 @@ export function createRenderer( const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); - const uiState = { attackRatio: 20 }; + const uiState = { attackRatio: 20, ghostStructure: null } as UIState; //hide when the game renders const startingModal = document.querySelector( @@ -167,6 +167,7 @@ export function createRenderer( } unitDisplay.game = game; unitDisplay.eventBus = eventBus; + unitDisplay.uiState = uiState; const playerPanel = document.querySelector("player-panel") as PlayerPanel; if (!(playerPanel instanceof PlayerPanel)) { @@ -240,7 +241,7 @@ export function createRenderer( new UnitLayer(game, eventBus, transformHandler), new FxLayer(game), new UILayer(game, eventBus, transformHandler), - new StructureIconsLayer(game, eventBus, transformHandler), + new StructureIconsLayer(game, eventBus, uiState, transformHandler), new NameLayer(game, transformHandler, eventBus), eventsDisplay, chatDisplay, diff --git a/src/client/graphics/UIState.ts b/src/client/graphics/UIState.ts index be985a580..01c4a60cb 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/graphics/UIState.ts @@ -1,3 +1,6 @@ +import { UnitType } from "../../core/game/Game"; + export interface UIState { attackRatio: number; + ghostStructure: UnitType | null; } diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts new file mode 100644 index 000000000..d2c3cbb44 --- /dev/null +++ b/src/client/graphics/layers/StructureDrawingUtils.ts @@ -0,0 +1,447 @@ +import * as PIXI from "pixi.js"; +import { Theme } from "../../../core/configuration/Config"; +import { Cell, UnitType } from "../../../core/game/Game"; +import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { TransformHandler } from "../TransformHandler"; + +import anchorIcon from "../../../../resources/images/AnchorIcon.png"; +import cityIcon from "../../../../resources/images/CityIcon.png"; +import factoryIcon from "../../../../resources/images/FactoryUnit.png"; +import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png"; +import SAMMissileIcon from "../../../../resources/images/SamLauncherUnit.png"; +import shieldIcon from "../../../../resources/images/ShieldIcon.png"; + +export const STRUCTURE_SHAPES: Partial> = { + [UnitType.City]: "circle", + [UnitType.Port]: "pentagon", + [UnitType.Factory]: "circle", + [UnitType.DefensePost]: "octagon", + [UnitType.SAMLauncher]: "square", + [UnitType.MissileSilo]: "triangle", + [UnitType.AtomBomb]: "cross", + [UnitType.HydrogenBomb]: "cross", + [UnitType.Warship]: "cross", +}; +export const LEVEL_SCALE_FACTOR = 3; +export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5; +export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4; +export const DOTS_ZOOM_THRESHOLD = 0.5; +export const ZOOM_THRESHOLD = 4.3; +export const ICON_SIZE = { + circle: 28, + octagon: 28, + pentagon: 30, + square: 28, + triangle: 28, + cross: 20, +}; +export const OFFSET_ZOOM_Y = 4; + +export type ShapeType = + | "triangle" + | "square" + | "pentagon" + | "octagon" + | "circle" + | "cross"; + +export class SpriteFactory { + private theme: Theme; + private game: GameView; + private transformHandler: TransformHandler; + private renderSprites: boolean; + private readonly textureCache: Map = new Map(); + + private readonly structuresInfos: Map< + UnitType, + { iconPath: string; image: HTMLImageElement | null } + > = new Map([ + [UnitType.City, { iconPath: cityIcon, image: null }], + [UnitType.Factory, { iconPath: factoryIcon, image: null }], + [UnitType.DefensePost, { iconPath: shieldIcon, image: null }], + [UnitType.Port, { iconPath: anchorIcon, image: null }], + [UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }], + [UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }], + ]); + constructor( + theme: Theme, + game: GameView, + transformHandler: TransformHandler, + renderSprites: boolean, + ) { + this.theme = theme; + this.game = game; + this.transformHandler = transformHandler; + this.renderSprites = renderSprites; + this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType)); + } + + private loadIcon( + unitInfo: { + iconPath: string; + image: HTMLImageElement | null; + }, + unitType: UnitType, + ) { + const image = new Image(); + image.src = unitInfo.iconPath; + image.onload = () => { + unitInfo.image = image; + this.invalidateTextureCache(unitType); + }; + image.onerror = () => { + console.error( + `Failed to load icon for ${unitType}: ${unitInfo.iconPath}`, + ); + }; + } + + private invalidateTextureCache(unitType: UnitType) { + for (const key of Array.from(this.textureCache.keys())) { + if ( + key.endsWith(`-${unitType}-icon`) || + key === `construction-${unitType}-icon` + ) { + this.textureCache.delete(key); + } + } + } + + createGhostContainer( + player: PlayerView, + ghostStage: PIXI.Container, + pos: { x: number; y: number }, + structureType: UnitType, + ): PIXI.Container { + const parentContainer = new PIXI.Container(); + const texture = this.createTexture(structureType, player, false, true); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5); + sprite.alpha = 0.5; + parentContainer.addChild(sprite); + parentContainer.position.set(pos.x, pos.y); + parentContainer.scale.set( + Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT), + ); + ghostStage.addChild(parentContainer); + return parentContainer; + } + + // --- internal helpers --- + + public createUnitContainer( + unit: UnitView, + options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container }, + ): PIXI.Container { + const parentContainer = new PIXI.Container(); + const tile = unit.tile(); + const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); + const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos); + + const isConstruction = unit.type() === UnitType.Construction; + const constructionType = unit.constructionType(); + const structureType = isConstruction ? constructionType! : unit.type(); + const { type, stage } = options; + const { scale } = this.transformHandler; + + if (type === "icon" || type === "dot") { + if (isConstruction && constructionType === undefined) { + console.warn( + `Unit ${unit.id()} is a construction but has no construction type.`, + ); + return parentContainer; + } + const texture = this.createTexture( + structureType, + unit.owner(), + isConstruction, + type === "icon", + ); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5); + parentContainer.addChild(sprite); + } + + if ((type === "icon" || type === "level") && unit.level() > 1) { + const text = new PIXI.BitmapText({ + text: unit.level().toString(), + style: { fontFamily: "round_6x6_modified", fontSize: 14 }, + }); + text.anchor.set(0.5); + + const shape = STRUCTURE_SHAPES[structureType]; + if (shape !== undefined) { + text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2); + } + parentContainer.addChild(text); + } + + const posX = Math.round(screenPos.x); + let posY = Math.round(screenPos.y); + if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) { + posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y); + } + parentContainer.position.set(posX, posY); + + if (type === "icon") { + const s = + scale >= ZOOM_THRESHOLD && !this.renderSprites + ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) + : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); + parentContainer.scale.set(s); + } else if (type === "level") { + parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR)); + } + + stage.addChild(parentContainer); + return parentContainer; + } + + private createTexture( + type: UnitType, + owner: PlayerView, + isConstruction: boolean, + renderIcon: boolean, + ): PIXI.Texture { + const cacheKey = isConstruction + ? `construction-${type}` + (renderIcon ? "-icon" : "") + : `${this.theme.territoryColor(owner).toRgbString()}-${type}` + + (renderIcon ? "-icon" : ""); + + if (this.textureCache.has(cacheKey)) { + return this.textureCache.get(cacheKey)!; + } + const shape = STRUCTURE_SHAPES[type]; + const texture = shape + ? this.createIcon(owner, type, isConstruction, shape, renderIcon) + : PIXI.Texture.EMPTY; + this.textureCache.set(cacheKey, texture); + return texture; + } + + private createIcon( + owner: PlayerView, + structureType: UnitType, + isConstruction: boolean, + shape: string, + renderIcon: boolean, + ): PIXI.Texture { + const structureCanvas = document.createElement("canvas"); + let iconSize = ICON_SIZE[shape]; + if (!renderIcon) { + iconSize /= 2.5; + } + structureCanvas.width = Math.ceil(iconSize); + structureCanvas.height = Math.ceil(iconSize); + const context = structureCanvas.getContext("2d")!; + + const tc = owner.territoryColor(); + const bc = owner.borderColor(); + + const darker = bc.luminance() < tc.luminance() ? bc : tc; + const lighter = bc.luminance() < tc.luminance() ? tc : bc; + + let borderColor: string; + if (isConstruction) { + context.fillStyle = "rgb(198, 198, 198)"; + borderColor = "rgb(128, 127, 127)"; + } else { + context.fillStyle = lighter + .lighten(0.13) + .alpha(renderIcon ? 0.65 : 1) + .toRgbString(); + const darken = darker.isLight() ? 0.17 : 0.15; + borderColor = darker.darken(darken).toRgbString(); + } + context.strokeStyle = borderColor; + context.lineWidth = 1; + const halfIconSize = iconSize / 2; + + switch (shape) { + case "triangle": + context.beginPath(); + context.moveTo(halfIconSize, 1); // Top + context.lineTo(iconSize - 1, iconSize - 1); // Bottom right + context.lineTo(0, iconSize - 1); // Bottom left + context.closePath(); + context.fill(); + context.stroke(); + break; + + case "square": + context.fillRect(1, 1, iconSize - 2, iconSize - 2); + context.strokeRect(1, 1, iconSize - 3, iconSize - 3); + break; + + case "octagon": + { + const cx = halfIconSize; + const cy = halfIconSize; + const r = halfIconSize - 1; + const step = (Math.PI * 2) / 8; + + context.beginPath(); + for (let i = 0; i < 8; i++) { + const angle = step * i - Math.PI / 8; // slight rotation for flat top + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + if (i === 0) { + context.moveTo(x, y); + } else { + context.lineTo(x, y); + } + } + context.closePath(); + context.fill(); + context.stroke(); + } + break; + case "pentagon": + { + const cx = halfIconSize; + const cy = halfIconSize; + const r = halfIconSize - 1; + const step = (Math.PI * 2) / 5; + + context.beginPath(); + for (let i = 0; i < 5; i++) { + const angle = step * i - Math.PI / 2; // rotate to have flat base or point up + const x = cx + r * Math.cos(angle); + const y = cy + r * Math.sin(angle); + if (i === 0) { + context.moveTo(x, y); + } else { + context.lineTo(x, y); + } + } + context.closePath(); + context.fill(); + context.stroke(); + } + break; + case "cross": { + context.strokeStyle = "rgba(0, 0, 0, 1)"; + context.fillStyle = "rgba(0, 0, 0, 1)"; + + const gap = iconSize * 0.18; // gap at center + const lineLen = iconSize / 2; + context.save(); + context.translate(halfIconSize, halfIconSize); + // Up + context.beginPath(); + context.moveTo(0, -gap); + context.lineTo(0, -lineLen); + context.stroke(); + // Down + context.beginPath(); + context.moveTo(0, gap); + context.lineTo(0, lineLen); + context.stroke(); + // Left + context.beginPath(); + context.moveTo(-gap, 0); + context.lineTo(-lineLen, 0); + context.stroke(); + // Right + context.beginPath(); + context.moveTo(gap, 0); + context.lineTo(lineLen, 0); + context.stroke(); + context.restore(); + break; + } + + case "circle": + context.beginPath(); + context.arc( + halfIconSize, + halfIconSize, + halfIconSize - 1, + 0, + Math.PI * 2, + ); + context.fill(); + context.stroke(); + break; + + default: + throw new Error(`Unknown shape: ${shape}`); + } + + const structureInfo = this.structuresInfos.get(structureType); + if (!structureInfo?.image) { + return PIXI.Texture.from(structureCanvas); + } + + if (renderIcon) { + const SHAPE_OFFSETS = { + triangle: [6, 11], + square: [5, 5], + octagon: [6, 6], + pentagon: [7, 7], + circle: [6, 6], + cross: [0, 0], + }; + const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0]; + context.drawImage( + this.getImageColored(structureInfo.image, borderColor), + offsetX, + offsetY, + ); + } + return PIXI.Texture.from(structureCanvas); + } + + public createRange( + type: UnitType, + stage: PIXI.Container, + pos: { x: number; y: number }, + ): PIXI.Container | null { + if (stage === undefined) throw new Error("Not initialized"); + const parentContainer = new PIXI.Container(); + const circle = new PIXI.Graphics(); + let radius = 0; + switch (type) { + case UnitType.SAMLauncher: + radius = this.game.config().defaultSamRange(); + break; + case UnitType.Factory: + radius = this.game.config().trainStationMaxRange(); + break; + case UnitType.DefensePost: + radius = this.game.config().defensePostRange(); + break; + case UnitType.AtomBomb: + radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer; + break; + case UnitType.HydrogenBomb: + radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer; + break; + default: + return null; + } + circle + .circle(0, 0, radius) + .stroke({ width: 1, color: 0xffffff, alpha: 0.2 }); + parentContainer.addChild(circle); + parentContainer.position.set(pos.x, pos.y); + parentContainer.scale.set(this.transformHandler.scale); + stage.addChild(parentContainer); + return parentContainer; + } + + private getImageColored( + image: HTMLImageElement, + color: string, + ): HTMLCanvasElement { + const imageCanvas = document.createElement("canvas"); + imageCanvas.width = image.width; + imageCanvas.height = image.height; + const ctx = imageCanvas.getContext("2d")!; + ctx.fillStyle = color; + ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height); + ctx.globalCompositeOperation = "destination-in"; + ctx.drawImage(image, 0, 0); + return imageCanvas; + } +} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 1869bba92..a2e41c4fa 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -3,25 +3,44 @@ import a11yPlugin from "colord/plugins/a11y"; import { OutlineFilter } from "pixi-filters"; import * as PIXI from "pixi.js"; import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml"; -import anchorIcon from "../../../../resources/images/AnchorIcon.png"; -import cityIcon from "../../../../resources/images/CityIcon.png"; -import factoryIcon from "../../../../resources/images/FactoryUnit.png"; -import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png"; -import SAMMissileIcon from "../../../../resources/images/SamLauncherUnit.png"; -import shieldIcon from "../../../../resources/images/ShieldIcon.png"; import { Theme } from "../../../core/configuration/Config"; import { EventBus } from "../../../core/EventBus"; -import { Cell, PlayerID, UnitType } from "../../../core/game/Game"; +import { + BuildableUnit, + Cell, + PlayerActions, + PlayerID, + UnitType, +} from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { ToggleStructureEvent } from "../../InputHandler"; +import { GameView, UnitView } from "../../../core/game/GameView"; +import { + MouseMoveEvent, + MouseUpEvent, + ToggleStructureEvent as ToggleStructuresEvent, +} from "../../InputHandler"; +import { + BuildUnitIntentEvent, + SendUpgradeStructureIntentEvent, +} from "../../Transport"; import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; +import { + DOTS_ZOOM_THRESHOLD, + ICON_SCALE_FACTOR_ZOOMED_IN, + ICON_SCALE_FACTOR_ZOOMED_OUT, + ICON_SIZE, + LEVEL_SCALE_FACTOR, + OFFSET_ZOOM_Y, + SpriteFactory, + STRUCTURE_SHAPES, + ZOOM_THRESHOLD, +} from "./StructureDrawingUtils"; extend([a11yPlugin]); -type ShapeType = "triangle" | "square" | "pentagon" | "octagon" | "circle"; - class StructureRenderInfo { public isOnScreen: boolean = false; constructor( @@ -35,68 +54,50 @@ class StructureRenderInfo { ) {} } -const STRUCTURE_SHAPES: Partial> = { - [UnitType.City]: "circle", - [UnitType.Port]: "pentagon", - [UnitType.Factory]: "circle", - [UnitType.DefensePost]: "octagon", - [UnitType.SAMLauncher]: "square", - [UnitType.MissileSilo]: "triangle", -}; -const LEVEL_SCALE_FACTOR = 3; -const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5; -const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4; -const DOTS_ZOOM_THRESHOLD = 0.5; -const ZOOM_THRESHOLD = 4.3; -const ICON_SIZE = { - circle: 28, - octagon: 28, - pentagon: 30, - square: 28, - triangle: 28, -}; -const OFFSET_ZOOM_Y = 4; // offset for the y position of the level over the sprite - export class StructureIconsLayer implements Layer { + private ghostUnit: { + container: PIXI.Container; + range: PIXI.Container | null; + buildableUnit: BuildableUnit; + } | null = null; private pixicanvas: HTMLCanvasElement; private iconsStage: PIXI.Container; + private ghostStage: PIXI.Container; private levelsStage: PIXI.Container; + private rootStage: PIXI.Container = new PIXI.Container(); + public playerActions: PlayerActions | null = null; private dotsStage: PIXI.Container; - private shouldRedraw: boolean = true; - private textureCache: Map = new Map(); - private theme: Theme; + private readonly theme: Theme; private renderer: PIXI.Renderer; private renders: StructureRenderInfo[] = []; - private seenUnits: Set = new Set(); - private structures: Map< - UnitType, - { visible: boolean; iconPath: string; image: HTMLImageElement | null } - > = new Map([ - [UnitType.City, { visible: true, iconPath: cityIcon, image: null }], - [UnitType.Factory, { visible: true, iconPath: factoryIcon, image: null }], - [ - UnitType.DefensePost, - { visible: true, iconPath: shieldIcon, image: null }, - ], - [UnitType.Port, { visible: true, iconPath: anchorIcon, image: null }], - [ - UnitType.MissileSilo, - { visible: true, iconPath: missileSiloIcon, image: null }, - ], - [ - UnitType.SAMLauncher, - { visible: true, iconPath: SAMMissileIcon, image: null }, - ], - ]); + private readonly seenUnits: Set = new Set(); + private readonly mousePos = { x: 0, y: 0 }; private renderSprites = true; + private factory: SpriteFactory; + private readonly structures: Map = new Map([ + [UnitType.City, { visible: true }], + [UnitType.Factory, { visible: true }], + [UnitType.DefensePost, { visible: true }], + [UnitType.Port, { visible: true }], + [UnitType.MissileSilo, { visible: true }], + [UnitType.SAMLauncher, { visible: true }], + ]); + private lastGhostQueryAt: number; + potentialUpgrade: StructureRenderInfo | undefined; constructor( private game: GameView, private eventBus: EventBus, + public uiState: UIState, private transformHandler: TransformHandler, ) { this.theme = game.config().theme(); - this.structures.forEach((u, unitType) => this.loadIcon(u, unitType)); + this.factory = new SpriteFactory( + this.theme, + game, + transformHandler, + this.renderSprites, + ); } async setupRenderer() { @@ -114,6 +115,10 @@ export class StructureIconsLayer implements Layer { this.iconsStage.position.set(0, 0); this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.ghostStage = new PIXI.Container(); + this.ghostStage.position.set(0, 0); + this.ghostStage.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.levelsStage = new PIXI.Container(); this.levelsStage.position.set(0, 0); this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); @@ -122,6 +127,15 @@ export class StructureIconsLayer implements Layer { this.dotsStage.position.set(0, 0); this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); + this.rootStage.addChild( + this.dotsStage, + this.iconsStage, + this.levelsStage, + this.ghostStage, + ); + this.rootStage.position.set(0, 0); + this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height); + await this.renderer.init({ canvas: this.pixicanvas, resolution: 1, @@ -134,33 +148,18 @@ export class StructureIconsLayer implements Layer { }); } - private loadIcon( - unitInfo: { - iconPath: string; - image: HTMLImageElement | null; - }, - unitType: UnitType, - ) { - const image = new Image(); - image.src = unitInfo.iconPath; - image.onload = () => { - unitInfo.image = image; - }; - image.onerror = () => { - console.error( - `Failed to load icon for ${unitType}: ${unitInfo.iconPath}`, - ); - }; - } - shouldTransform(): boolean { return false; } async init() { - this.eventBus.on(ToggleStructureEvent, (e) => - this.toggleStructure(e.structureType), + this.eventBus.on(ToggleStructuresEvent, (e) => + this.toggleStructures(e.structureTypes), ); + this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); + + this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e)); + window.addEventListener("resize", () => this.resizeCanvas()); await this.setupRenderer(); this.redraw(); @@ -171,11 +170,10 @@ export class StructureIconsLayer implements Layer { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer.resize(innerWidth, innerHeight, 1); - this.shouldRedraw = true; } } - public tick() { + tick() { this.game .updatesSinceLastTick() ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) @@ -192,15 +190,216 @@ export class StructureIconsLayer implements Layer { this.game.config().userSettings()?.structureSprites() ?? true; } - private toggleStructure(toggleStructureType: UnitType | null): void { + redraw() { + this.resizeCanvas(); + } + + renderLayer(mainContext: CanvasRenderingContext2D) { + if (!this.renderer) { + return; + } + + if (this.ghostUnit) { + if (this.uiState.ghostStructure === null) { + this.removeGhostStructure(); + } else if ( + this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type + ) { + this.clearGhostStructure(); + } + } else if (this.uiState.ghostStructure !== null) { + this.createGhostStructure(this.uiState.ghostStructure); + } + this.renderGhost(); + + if (this.transformHandler.hasChanged()) { + for (const render of this.renders) { + this.computeNewLocation(render); + } + } + const scale = this.transformHandler.scale; + + this.dotsStage!.visible = scale <= DOTS_ZOOM_THRESHOLD; + this.iconsStage!.visible = + scale > DOTS_ZOOM_THRESHOLD && + (scale <= ZOOM_THRESHOLD || !this.renderSprites); + this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites; + this.renderer.render(this.rootStage); + mainContext.drawImage(this.renderer.canvas, 0, 0); + } + + renderGhost() { + if (!this.ghostUnit) return; + + const now = performance.now(); + if (now - this.lastGhostQueryAt < 50) { + return; + } + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + + const localX = this.mousePos.x - rect.left; + const localY = this.mousePos.y - rect.top; + this.lastGhostQueryAt = now; + let tileRef: TileRef | undefined; + const tile = this.transformHandler.screenToWorldCoordinates(localX, localY); + if (this.game.isValidCoord(tile.x, tile.y)) { + tileRef = this.game.ref(tile.x, tile.y); + } + + this.game + ?.myPlayer() + ?.actions(tileRef) + .then((actions) => { + if (this.potentialUpgrade) { + this.potentialUpgrade.iconContainer.filters = []; + this.potentialUpgrade.dotContainer.filters = []; + } + this.ghostUnit?.container && (this.ghostUnit.container.filters = []); + + if (!this.ghostUnit) return; + + const unit = actions.buildableUnits.find( + (u) => u.type === this.ghostUnit!.buildableUnit.type, + ); + if (!unit) { + Object.assign(this.ghostUnit.buildableUnit, { + canBuild: false, + canUpgrade: false, + }); + this.ghostUnit.container.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), + ]; + return; + } + + this.ghostUnit.buildableUnit = unit; + + if (unit.canUpgrade) { + this.potentialUpgrade = this.renders.find( + (r) => r.unit.id() === unit.canUpgrade, + ); + if (this.potentialUpgrade) { + this.potentialUpgrade.iconContainer.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), + ]; + this.potentialUpgrade.dotContainer.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), + ]; + } + } else if (unit.canBuild === false) { + this.ghostUnit.container.filters = [ + new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), + ]; + } + + const scale = this.transformHandler.scale; + const s = + scale >= ZOOM_THRESHOLD + ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) + : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); + this.ghostUnit.container.scale.set(s); + this.ghostUnit.range?.scale.set(this.transformHandler.scale); + }); + } + + private createStructure(e: MouseUpEvent) { + if (!this.ghostUnit) return; + if ( + this.ghostUnit.buildableUnit.canBuild === false && + this.ghostUnit.buildableUnit.canUpgrade === false + ) { + this.removeGhostStructure(); + return; + } + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + const x = e.x - rect.left; + const y = e.y - rect.top; + const tile = this.transformHandler.screenToWorldCoordinates(x, y); + if (this.ghostUnit.buildableUnit.canUpgrade !== false) { + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + this.ghostUnit.buildableUnit.canUpgrade, + this.ghostUnit.buildableUnit.type, + ), + ); + } else if (this.ghostUnit.buildableUnit.canBuild) { + this.eventBus.emit( + new BuildUnitIntentEvent( + this.ghostUnit.buildableUnit.type, + this.game.ref(tile.x, tile.y), + ), + ); + } + this.removeGhostStructure(); + } + + private moveGhost(e: MouseMoveEvent) { + this.mousePos.x = e.x; + this.mousePos.y = e.y; + + if (!this.ghostUnit) return; + const rect = this.transformHandler.boundingRect(); + if (!rect) return; + + const localX = e.x - rect.left; + const localY = e.y - rect.top; + this.ghostUnit.container.position.set(localX, localY); + this.ghostUnit.range?.position.set(localX, localY); + } + + private createGhostStructure(type: UnitType | null) { + const player = this.game.myPlayer(); + if (!player) return; + if (type === null) { + return; + } + const rect = this.transformHandler.boundingRect(); + const localX = this.mousePos.x - rect.left; + const localY = this.mousePos.y - rect.top; + this.ghostUnit = { + container: this.factory.createGhostContainer( + player, + this.ghostStage, + { x: localX, y: localY }, + type, + ), + range: this.factory.createRange(type, this.ghostStage, { + x: localX, + y: localY, + }), + buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n }, + }; + } + + private clearGhostStructure() { + if (this.ghostUnit) { + this.ghostUnit.container.destroy(); + this.ghostUnit.range?.destroy(); + this.ghostUnit = null; + } + if (this.potentialUpgrade) { + this.potentialUpgrade.iconContainer.filters = []; + this.potentialUpgrade.dotContainer.filters = []; + this.potentialUpgrade = undefined; + } + } + + private removeGhostStructure() { + this.clearGhostStructure(); + this.uiState.ghostStructure = null; + } + + private toggleStructures(toggleStructureType: UnitType[] | null): void { for (const [structureType, infos] of this.structures) { infos.visible = - structureType === toggleStructureType || toggleStructureType === null; + toggleStructureType?.indexOf(structureType) !== -1 || + toggleStructureType === null; } for (const render of this.renders) { this.modifyVisibility(render); } - this.shouldRedraw = true; } private findRenderByUnit( @@ -229,7 +428,6 @@ export class StructureIconsLayer implements Layer { const render = this.findRenderByUnit(unitView); if (render) { this.deleteStructure(render); - this.shouldRedraw = true; } } @@ -278,7 +476,6 @@ export class StructureIconsLayer implements Layer { render.iconContainer = this.createIconSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); - this.shouldRedraw = true; } } @@ -290,7 +487,6 @@ export class StructureIconsLayer implements Layer { render.iconContainer = this.createIconSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); - this.shouldRedraw = true; } } @@ -304,317 +500,9 @@ export class StructureIconsLayer implements Layer { render.levelContainer = this.createLevelSprite(unit); render.dotContainer = this.createDotSprite(unit); this.modifyVisibility(render); - this.shouldRedraw = true; } } - redraw() { - this.resizeCanvas(); - } - - renderLayer(mainContext: CanvasRenderingContext2D) { - if (!this.renderer) { - return; - } - - if (this.transformHandler.hasChanged()) { - for (const render of this.renders) { - this.computeNewLocation(render); - } - } - - if (this.transformHandler.hasChanged() || this.shouldRedraw) { - if (this.transformHandler.scale > ZOOM_THRESHOLD && this.renderSprites) { - this.renderer.render(this.levelsStage); - } else if (this.transformHandler.scale > DOTS_ZOOM_THRESHOLD) { - this.renderer.render(this.iconsStage); - } else { - this.renderer.render(this.dotsStage); - } - this.shouldRedraw = false; - } - mainContext.drawImage(this.renderer.canvas, 0, 0); - } - - private createTexture(unit: UnitView, renderIcon: boolean): PIXI.Texture { - const isConstruction = unit.type() === UnitType.Construction; - const constructionType = unit.constructionType(); - if (isConstruction && constructionType === undefined) { - console.warn( - `Unit ${unit.id()} is a construction but has no construction type.`, - ); - return PIXI.Texture.EMPTY; - } - const structureType = isConstruction ? constructionType! : unit.type(); - const cacheKey = isConstruction - ? `construction-${structureType}` + (renderIcon ? "-icon" : "") - : `${unit.owner().territoryColor().toRgbString()}-${structureType}` + - (renderIcon ? "-icon" : ""); - if (this.textureCache.has(cacheKey)) { - return this.textureCache.get(cacheKey)!; - } - - const shape = STRUCTURE_SHAPES[structureType]; - const texture = shape - ? this.createIcon( - unit.owner(), - structureType, - isConstruction, - shape, - renderIcon, - ) - : PIXI.Texture.EMPTY; - - this.textureCache.set(cacheKey, texture); - return texture; - } - - private createIcon( - owner: PlayerView, - structureType: UnitType, - isConstruction: boolean, - shape: ShapeType, - renderIcon: boolean, - ): PIXI.Texture { - const structureCanvas = document.createElement("canvas"); - let iconSize = ICON_SIZE[shape]; - if (!renderIcon) { - iconSize /= 2.5; - } - structureCanvas.width = Math.ceil(iconSize); - structureCanvas.height = Math.ceil(iconSize); - const context = structureCanvas.getContext("2d")!; - - const tc = owner.territoryColor(); - const bc = owner.borderColor(); - - const darker = bc.luminance() < tc.luminance() ? bc : tc; - const lighter = bc.luminance() < tc.luminance() ? tc : bc; - - let borderColor: string; - if (isConstruction) { - context.fillStyle = "rgb(198, 198, 198)"; - borderColor = "rgb(128, 127, 127)"; - } else { - context.fillStyle = lighter - .lighten(0.13) - .alpha(renderIcon ? 0.65 : 1) - .toRgbString(); - const darken = darker.isLight() ? 0.17 : 0.15; - borderColor = darker.darken(darken).toRgbString(); - } - - context.strokeStyle = borderColor; - context.lineWidth = 1; - const halfIconSize = iconSize / 2; - switch (shape) { - case "triangle": - context.beginPath(); - context.moveTo(halfIconSize, 1); // Top - context.lineTo(iconSize - 1, iconSize - 1); // Bottom right - context.lineTo(0, iconSize - 1); // Bottom left - context.closePath(); - context.fill(); - context.stroke(); - break; - - case "square": - context.fillRect(1, 1, iconSize - 2, iconSize - 2); - context.strokeRect(1, 1, iconSize - 3, iconSize - 3); - break; - - case "octagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 8; - - context.beginPath(); - for (let i = 0; i < 8; i++) { - const angle = step * i - Math.PI / 8; // slight rotation for flat top - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "pentagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 5; - - context.beginPath(); - for (let i = 0; i < 5; i++) { - const angle = step * i - Math.PI / 2; // rotate to have flat base or point up - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "circle": - context.beginPath(); - context.arc( - halfIconSize, - halfIconSize, - halfIconSize - 1, - 0, - Math.PI * 2, - ); - context.fill(); - context.stroke(); - break; - - default: - throw new Error(`Unknown shape: ${shape}`); - } - - const structureInfo = this.structures.get(structureType); - if (!structureInfo?.image) { - console.warn(`Image not loaded for unit type: ${structureType}`); - return PIXI.Texture.from(structureCanvas); - } - - if (renderIcon) { - const SHAPE_OFFSETS = { - triangle: [6, 11], - square: [5, 5], - octagon: [6, 6], - pentagon: [7, 7], - circle: [6, 6], - }; - const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0]; - context.drawImage( - this.getImageColored(structureInfo.image, borderColor), - offsetX, - offsetY, - ); - } - return PIXI.Texture.from(structureCanvas); - } - - private createLevelSprite(unit: UnitView): PIXI.Container { - return this.createUnitContainer(unit, { - type: "level", - stage: this.levelsStage, - }); - } - - private createDotSprite(unit: UnitView): PIXI.Container { - return this.createUnitContainer(unit, { - type: "dot", - stage: this.dotsStage, - }); - } - - private createIconSprite(unit: UnitView): PIXI.Container { - return this.createUnitContainer(unit, { - type: "icon", - stage: this.iconsStage, - }); - } - - private createUnitContainer( - unit: UnitView, - options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container }, - ): PIXI.Container { - const parentContainer = new PIXI.Container(); - const tile = unit.tile(); - const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); - const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos); - - const { type, stage } = options; - const scale = this.transformHandler.scale; - const spritesEnabled = this.game - .config() - .userSettings() - ?.structureSprites?.(); - - // Add sprite if needed - if (type === "icon" || type === "dot") { - const texture = this.createTexture(unit, type === "icon"); - const sprite = new PIXI.Sprite(texture); - sprite.anchor.set(0.5); - parentContainer.addChild(sprite); - } - - // Add level text if needed - if ((type === "icon" || type === "level") && unit.level() > 1) { - const text = new PIXI.BitmapText({ - text: unit.level().toString(), - style: { - fontFamily: "round_6x6_modified", - fontSize: 14, - }, - }); - text.anchor.set(0.5); - - const unitType = - unit.type() === UnitType.Construction - ? unit.constructionType() - : unit.type(); - const shape = STRUCTURE_SHAPES[unitType!]; - if (shape !== undefined) { - text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2); - } - parentContainer.addChild(text); - } - - // Positioning - const posX = Math.round(screenPos.x); - let posY = Math.round(screenPos.y); - if (type === "level" && scale >= ZOOM_THRESHOLD && spritesEnabled) { - posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y); - } - parentContainer.position.set(posX, posY); - - // Scaling - if (type === "icon") { - const s = - scale >= ZOOM_THRESHOLD && !spritesEnabled - ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) - : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); - parentContainer.scale.set(s); - } else if (type === "level") { - parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR)); - } - - stage.addChild(parentContainer); - return parentContainer; - } - - private getImageColored( - image: HTMLImageElement, - color: string, - ): HTMLCanvasElement { - const imageCanvas = document.createElement("canvas"); - imageCanvas.width = image.width; - imageCanvas.height = image.height; - const ctx = imageCanvas.getContext("2d")!; - ctx.fillStyle = color; - ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height); - ctx.globalCompositeOperation = "destination-in"; - ctx.drawImage(image, 0, 0); - return imageCanvas; - } - private computeNewLocation(render: StructureRenderInfo) { const tile = render.unit.tile(); const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); @@ -691,7 +579,27 @@ export class StructureIconsLayer implements Layer { this.renders.push(render); this.computeNewLocation(render); this.modifyVisibility(render); - this.shouldRedraw = true; + } + + private createLevelSprite(unit: UnitView): PIXI.Container { + return this.factory.createUnitContainer(unit, { + type: "level", + stage: this.levelsStage, + }); + } + + private createDotSprite(unit: UnitView): PIXI.Container { + return this.factory.createUnitContainer(unit, { + type: "dot", + stage: this.dotsStage, + }); + } + + private createIconSprite(unit: UnitView): PIXI.Container { + return this.factory.createUnitContainer(unit, { + type: "icon", + stage: this.iconsStage, + }); } private deleteStructure(render: StructureRenderInfo) { diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 7cc669313..a78766616 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -1,30 +1,38 @@ -import { html, LitElement } from "lit"; +import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; -import portIcon from "../../../../resources/images/AnchorIcon.png"; +import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg"; import cityIcon from "../../../../resources/images/CityIconWhite.svg"; import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg"; import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png"; +import hydrogenBombIcon from "../../../../resources/images/MushroomCloudIconWhite.svg"; +import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg"; +import portIcon from "../../../../resources/images/PortIcon.svg"; import samLauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg"; import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; +import { Gold, PlayerActions, UnitType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { ToggleStructureEvent } from "../../InputHandler"; -import { renderNumber } from "../../Utils"; +import { renderNumber, translateText } from "../../Utils"; +import { UIState } from "../UIState"; import { Layer } from "./Layer"; @customElement("unit-display") export class UnitDisplay extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; - private _selectedStructure: UnitType | null = null; + public uiState: UIState; + private playerActions: PlayerActions | null = null; + private keybinds: Record = {}; 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 _hoveredUnit: UnitType | null = null; createRenderRoot() { return this; @@ -32,18 +40,63 @@ export class UnitDisplay extends LitElement implements Layer { init() { const config = this.game.config(); + + const savedKeybinds = localStorage.getItem("settings.keybinds"); + if (savedKeybinds) { + try { + this.keybinds = JSON.parse(savedKeybinds); + } catch (e) { + console.warn("Invalid keybinds JSON:", e); + } + } + this.allDisabled = config.isUnitDisabled(UnitType.City) && config.isUnitDisabled(UnitType.Factory) && config.isUnitDisabled(UnitType.Port) && config.isUnitDisabled(UnitType.DefensePost) && config.isUnitDisabled(UnitType.MissileSilo) && - config.isUnitDisabled(UnitType.SAMLauncher); + config.isUnitDisabled(UnitType.SAMLauncher) && + config.isUnitDisabled(UnitType.Warship) && + config.isUnitDisabled(UnitType.AtomBomb) && + config.isUnitDisabled(UnitType.HydrogenBomb); this.requestUpdate(); } + private cost(item: UnitType): Gold { + for (const bu of this.playerActions?.buildableUnits ?? []) { + if (bu.type === item) { + return bu.cost; + } + } + return 0n; + } + + private canBuild(item: UnitType): boolean { + if (this.game?.config().isUnitDisabled(item)) return false; + const player = this.game?.myPlayer(); + switch (item) { + case UnitType.AtomBomb: + case UnitType.HydrogenBomb: + return ( + this.cost(item) <= (player?.gold() ?? 0n) && + (player?.units(UnitType.MissileSilo).length ?? 0) > 0 + ); + case UnitType.Warship: + return ( + this.cost(item) <= (player?.gold() ?? 0n) && + (player?.units(UnitType.Port).length ?? 0) > 0 + ); + default: + return this.cost(item) <= (player?.gold() ?? 0n); + } + } + tick() { const player = this.game?.myPlayer(); + player?.actions().then((actions) => { + this.playerActions = actions; + }); if (!player) return; this._cities = player.totalUnitLevels(UnitType.City); this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo); @@ -51,42 +104,10 @@ export class UnitDisplay extends LitElement implements Layer { 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(); } - private renderUnitItem( - icon: string, - number: number, - unitType: UnitType, - altText: string, - ) { - if (this.game.config().isUnitDisabled(unitType)) { - return html``; - } - - return html` -
- ${altText} - ${renderNumber(number)} -
- `; - } - render() { const myPlayer = this.game?.myPlayer(); if ( @@ -97,42 +118,182 @@ export class UnitDisplay extends LitElement implements Layer { ) { return null; } - if (this.allDisabled) { return null; } return html`