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`