mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:50:44 +00:00
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  <img width="285" height="517" alt="image" src="https://github.com/user-attachments/assets/91bb01e6-e48c-4255-ace1-306af9cdc25b" /> ## 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 <evanpelle@gmail.com> Co-authored-by: icslucas <carolinacarazolli@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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%",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<string, string>;
|
||||
} 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));
|
||||
}
|
||||
|
||||
|
||||
+107
-20
@@ -13,7 +13,8 @@ export class UserSettingModal extends LitElement {
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@state() private settingsMode: "basic" | "keybinds" = "basic";
|
||||
@state() private keybinds: Record<string, string> = {};
|
||||
@state() private keybinds: Record<string, { value: string; key: string }> =
|
||||
{};
|
||||
|
||||
@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}
|
||||
></setting-keybind>
|
||||
|
||||
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
|
||||
${translateText("user_setting.build_controls")}
|
||||
</div>
|
||||
|
||||
<setting-keybind
|
||||
action="buildCity"
|
||||
label=${translateText("user_setting.build_city")}
|
||||
description=${translateText("user_setting.build_city_desc")}
|
||||
defaultKey="Digit1"
|
||||
.value=${this.keybinds["buildCity"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildFactory"
|
||||
label=${translateText("user_setting.build_factory")}
|
||||
description=${translateText("user_setting.build_factory_desc")}
|
||||
defaultKey="Digit2"
|
||||
.value=${this.keybinds["buildFactory"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildPort"
|
||||
label=${translateText("user_setting.build_port")}
|
||||
description=${translateText("user_setting.build_port_desc")}
|
||||
defaultKey="Digit3"
|
||||
.value=${this.keybinds["buildPort"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildDefensePost"
|
||||
label=${translateText("user_setting.build_defense_post")}
|
||||
description=${translateText("user_setting.build_defense_post_desc")}
|
||||
defaultKey="Digit4"
|
||||
.value=${this.keybinds["buildDefensePost"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildMissileSilo"
|
||||
label=${translateText("user_setting.build_missile_silo")}
|
||||
description=${translateText("user_setting.build_missile_silo_desc")}
|
||||
defaultKey="Digit5"
|
||||
.value=${this.keybinds["buildMissileSilo"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildSamLauncher"
|
||||
label=${translateText("user_setting.build_sam_launcher")}
|
||||
description=${translateText("user_setting.build_sam_launcher_desc")}
|
||||
defaultKey="Digit6"
|
||||
.value=${this.keybinds["buildSamLauncher"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildAtomBomb"
|
||||
label=${translateText("user_setting.build_atom_bomb")}
|
||||
description=${translateText("user_setting.build_atom_bomb_desc")}
|
||||
defaultKey="Digit7"
|
||||
.value=${this.keybinds["buildAtomBomb"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildHydrogenBomb"
|
||||
label=${translateText("user_setting.build_hydrogen_bomb")}
|
||||
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
|
||||
defaultKey="Digit8"
|
||||
.value=${this.keybinds["buildHydrogenBomb"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
<setting-keybind
|
||||
action="buildWarship"
|
||||
label=${translateText("user_setting.build_warship")}
|
||||
description=${translateText("user_setting.build_warship_desc")}
|
||||
defaultKey="Digit9"
|
||||
.value=${this.keybinds["buildWarship"]?.key ?? ""}
|
||||
@change=${this.handleKeybindChange}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
|
||||
@@ -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}
|
||||
></setting-keybind>
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
|
||||
export interface UIState {
|
||||
attackRatio: number;
|
||||
ghostStructure: UnitType | null;
|
||||
}
|
||||
|
||||
@@ -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<Record<UnitType, ShapeType>> = {
|
||||
[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<string, PIXI.Texture> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Record<UnitType, ShapeType>> = {
|
||||
[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<string, PIXI.Texture> = new Map();
|
||||
private theme: Theme;
|
||||
private readonly theme: Theme;
|
||||
private renderer: PIXI.Renderer;
|
||||
private renders: StructureRenderInfo[] = [];
|
||||
private seenUnits: Set<UnitView> = 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<UnitView> = new Set();
|
||||
private readonly mousePos = { x: 0, y: 0 };
|
||||
private renderSprites = true;
|
||||
private factory: SpriteFactory;
|
||||
private readonly structures: Map<UnitType, { visible: boolean }> = 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) {
|
||||
|
||||
@@ -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<string, { value: string; key: string }> = {};
|
||||
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`
|
||||
<div
|
||||
class="px-2 flex items-center gap-2 cursor-pointer hover:bg-slate-700/50 rounded text-white"
|
||||
style="background: ${this._selectedStructure === unitType
|
||||
? "#ffffff2e"
|
||||
: "none"}"
|
||||
@mouseenter="${() =>
|
||||
this.eventBus.emit(new ToggleStructureEvent(unitType))}"
|
||||
@mouseleave="${() =>
|
||||
this.eventBus.emit(new ToggleStructureEvent(null))}"
|
||||
>
|
||||
<img
|
||||
src=${icon}
|
||||
alt=${altText}
|
||||
width="20"
|
||||
height="20"
|
||||
style="vertical-align: middle;"
|
||||
/>
|
||||
${renderNumber(number)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] bg-gray-800/70 backdrop-blur-sm border border-slate-400 rounded-lg p-2 hidden lg:block"
|
||||
class="hidden xl:flex md:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] xl:flex-row lg:flex-col md:flex-col xl:gap-5 lg:gap-2 md:gap-2 justify-center items-center"
|
||||
>
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
|
||||
${this.renderUnitItem(cityIcon, this._cities, UnitType.City, "city")}
|
||||
${this.renderUnitItem(
|
||||
factoryIcon,
|
||||
this._factories,
|
||||
UnitType.Factory,
|
||||
"factory",
|
||||
)}
|
||||
${this.renderUnitItem(portIcon, this._port, UnitType.Port, "port")}
|
||||
${this.renderUnitItem(
|
||||
defensePostIcon,
|
||||
this._defensePost,
|
||||
UnitType.DefensePost,
|
||||
"defense post",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
missileSiloIcon,
|
||||
this._missileSilo,
|
||||
UnitType.MissileSilo,
|
||||
"missile silo",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
samLauncherIcon,
|
||||
this._samLauncher,
|
||||
UnitType.SAMLauncher,
|
||||
"SAM launcher",
|
||||
)}
|
||||
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5">
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
|
||||
${this.renderUnitItem(
|
||||
cityIcon,
|
||||
this._cities,
|
||||
UnitType.City,
|
||||
"city",
|
||||
this.keybinds["buildCity"]?.key ?? "1",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
factoryIcon,
|
||||
this._factories,
|
||||
UnitType.Factory,
|
||||
"factory",
|
||||
this.keybinds["buildFactory"]?.key ?? "2",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
portIcon,
|
||||
this._port,
|
||||
UnitType.Port,
|
||||
"port",
|
||||
this.keybinds["buildPort"]?.key ?? "3",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
defensePostIcon,
|
||||
this._defensePost,
|
||||
UnitType.DefensePost,
|
||||
"defense_post",
|
||||
this.keybinds["buildDefensePost"]?.key ?? "4",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
missileSiloIcon,
|
||||
this._missileSilo,
|
||||
UnitType.MissileSilo,
|
||||
"missile_silo",
|
||||
this.keybinds["buildMissileSilo"]?.key ?? "5",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
samLauncherIcon,
|
||||
this._samLauncher,
|
||||
UnitType.SAMLauncher,
|
||||
"sam_launcher",
|
||||
this.keybinds["buildSamLauncher"]?.key ?? "6",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5 w-fit">
|
||||
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
|
||||
${this.renderUnitItem(
|
||||
atomBombIcon,
|
||||
null,
|
||||
UnitType.AtomBomb,
|
||||
"atom_bomb",
|
||||
this.keybinds["buildAtomBomb"]?.key ?? "7",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
hydrogenBombIcon,
|
||||
null,
|
||||
UnitType.HydrogenBomb,
|
||||
"hydrogen_bomb",
|
||||
this.keybinds["buildHydrogenBomb"]?.key ?? "8",
|
||||
)}
|
||||
${this.renderUnitItem(
|
||||
warshipIcon,
|
||||
this._warships,
|
||||
UnitType.Warship,
|
||||
"warship",
|
||||
this.keybinds["buildWarship"]?.key ?? "9",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderUnitItem(
|
||||
icon: string,
|
||||
number: number | null,
|
||||
unitType: UnitType,
|
||||
structureKey: string,
|
||||
hotkey: string,
|
||||
) {
|
||||
if (this.game.config().isUnitDisabled(unitType)) {
|
||||
return html``;
|
||||
}
|
||||
const selected = this.uiState.ghostStructure === unitType;
|
||||
const hovered = this._hoveredUnit === unitType;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center relative"
|
||||
@mouseenter=${() => {
|
||||
this._hoveredUnit = unitType;
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@mouseleave=${() => {
|
||||
this._hoveredUnit = null;
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
${hovered
|
||||
? html`
|
||||
<div
|
||||
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-sm rounded p-1 z-20 shadow-lg pointer-events-none"
|
||||
>
|
||||
<div class="font-bold text-sm mb-1">
|
||||
${translateText(
|
||||
"unit_type." + structureKey,
|
||||
)}${` [${hotkey.toUpperCase()}]`}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
${translateText("build_menu.desc." + structureKey)}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-yellow-300"
|
||||
>${renderNumber(this.cost(unitType))}</span
|
||||
>
|
||||
${translateText("player_info_overlay.gold")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div
|
||||
class="${this.canBuild(unitType)
|
||||
? ""
|
||||
: "opacity-40"} border border-slate-500 rounded pr-2 pb-1 flex items-center gap-2 cursor-pointer
|
||||
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
|
||||
rounded text-white ${selected ? "bg-slate-400/20" : ""}"
|
||||
@click=${() => {
|
||||
if (selected) {
|
||||
this.uiState.ghostStructure = null;
|
||||
} else if (this.canBuild(unitType)) {
|
||||
this.uiState.ghostStructure = unitType;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}}
|
||||
@mouseenter=${() => {
|
||||
switch (unitType) {
|
||||
case UnitType.AtomBomb:
|
||||
case UnitType.HydrogenBomb:
|
||||
this.eventBus?.emit(
|
||||
new ToggleStructureEvent([
|
||||
UnitType.MissileSilo,
|
||||
UnitType.SAMLauncher,
|
||||
]),
|
||||
);
|
||||
break;
|
||||
case UnitType.Warship:
|
||||
this.eventBus?.emit(new ToggleStructureEvent([UnitType.Port]));
|
||||
break;
|
||||
default:
|
||||
this.eventBus?.emit(new ToggleStructureEvent([unitType]));
|
||||
}
|
||||
}}
|
||||
@mouseleave=${() =>
|
||||
this.eventBus?.emit(new ToggleStructureEvent(null))}
|
||||
>
|
||||
${html`<div class="ml-1 text-xs relative -top-1.5 text-gray-400">
|
||||
${hotkey.toUpperCase()}
|
||||
</div>`}
|
||||
<div class="flex items-center gap-1 pt-1">
|
||||
<img
|
||||
src=${icon}
|
||||
alt=${structureKey}
|
||||
style="vertical-align: middle; width: 24px; height: 24px;"
|
||||
/>
|
||||
${number !== null ? renderNumber(number) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -179,18 +179,19 @@ export class GameRunner {
|
||||
|
||||
public playerActions(
|
||||
playerID: PlayerID,
|
||||
x: number,
|
||||
y: number,
|
||||
x?: number,
|
||||
y?: number,
|
||||
): PlayerActions {
|
||||
const player = this.game.player(playerID);
|
||||
const tile = this.game.ref(x, y);
|
||||
const tile =
|
||||
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
|
||||
const actions = {
|
||||
canAttack: player.canAttack(tile),
|
||||
canAttack: tile !== null && player.canAttack(tile),
|
||||
buildableUnits: player.buildableUnits(tile),
|
||||
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
||||
} as PlayerActions;
|
||||
|
||||
if (this.game.hasOwner(tile)) {
|
||||
if (tile !== null && this.game.hasOwner(tile)) {
|
||||
const other = this.game.owner(tile) as Player;
|
||||
actions.interaction = {
|
||||
sharedBorder: player.sharesBorderWith(other),
|
||||
|
||||
@@ -546,7 +546,7 @@ export interface Player {
|
||||
unitCount(type: UnitType): number;
|
||||
unitsConstructed(type: UnitType): number;
|
||||
unitsOwned(type: UnitType): number;
|
||||
buildableUnits(tile: TileRef): BuildableUnit[];
|
||||
buildableUnits(tile: TileRef | null): BuildableUnit[];
|
||||
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
|
||||
buildUnit<T extends UnitType>(
|
||||
type: T,
|
||||
|
||||
@@ -264,11 +264,11 @@ export class PlayerView {
|
||||
: this._defendedBorderColors.dark;
|
||||
}
|
||||
|
||||
async actions(tile: TileRef): Promise<PlayerActions> {
|
||||
async actions(tile?: TileRef): Promise<PlayerActions> {
|
||||
return this.game.worker.playerInteraction(
|
||||
this.id(),
|
||||
this.game.x(tile),
|
||||
this.game.y(tile),
|
||||
tile && this.game.x(tile),
|
||||
tile && this.game.y(tile),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -862,21 +862,22 @@ export class PlayerImpl implements Player {
|
||||
this.recordUnitConstructed(unit.type());
|
||||
}
|
||||
|
||||
public buildableUnits(tile: TileRef): BuildableUnit[] {
|
||||
const validTiles = this.validStructureSpawnTiles(tile);
|
||||
public buildableUnits(tile: TileRef | null): BuildableUnit[] {
|
||||
const validTiles = tile !== null ? this.validStructureSpawnTiles(tile) : [];
|
||||
return Object.values(UnitType).map((u) => {
|
||||
let canUpgrade: number | false = false;
|
||||
if (!this.mg.inSpawnPhase()) {
|
||||
const existingUnit = this.findUnitToUpgrade(u, tile);
|
||||
const existingUnit = tile !== null && this.findUnitToUpgrade(u, tile);
|
||||
if (existingUnit !== false) {
|
||||
canUpgrade = existingUnit.id();
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: u,
|
||||
canBuild: this.mg.inSpawnPhase()
|
||||
? false
|
||||
: this.canBuild(u, tile, validTiles),
|
||||
canBuild:
|
||||
this.mg.inSpawnPhase() || tile === null
|
||||
? false
|
||||
: this.canBuild(u, tile, validTiles),
|
||||
canUpgrade: canUpgrade,
|
||||
cost: this.mg.config().unitInfo(u).cost(this),
|
||||
} as BuildableUnit;
|
||||
|
||||
@@ -160,8 +160,8 @@ export class WorkerClient {
|
||||
|
||||
playerInteraction(
|
||||
playerID: PlayerID,
|
||||
x: number,
|
||||
y: number,
|
||||
x?: number,
|
||||
y?: number,
|
||||
): Promise<PlayerActions> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isInitialized) {
|
||||
|
||||
@@ -60,8 +60,8 @@ export interface GameUpdateMessage extends BaseWorkerMessage {
|
||||
export interface PlayerActionsMessage extends BaseWorkerMessage {
|
||||
type: "player_actions";
|
||||
playerID: PlayerID;
|
||||
x: number;
|
||||
y: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface PlayerActionsResultMessage extends BaseWorkerMessage {
|
||||
|
||||
@@ -36,7 +36,11 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
|
||||
eventBus = new EventBus();
|
||||
|
||||
inputHandler = new InputHandler(mockCanvas, eventBus);
|
||||
inputHandler = new InputHandler(
|
||||
{ attackRatio: 20, ghostStructure: null },
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Middle Mouse Button Handling", () => {
|
||||
@@ -397,4 +401,58 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
expect(mockListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keybinds JSON parsing", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem("settings.keybinds");
|
||||
});
|
||||
|
||||
test("parses nested object values and flattens them to strings", () => {
|
||||
const nested = {
|
||||
moveUp: { key: "moveUp", value: "KeyZ" },
|
||||
};
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(nested));
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyZ");
|
||||
});
|
||||
|
||||
test("accepts legacy string values", () => {
|
||||
localStorage.setItem(
|
||||
"settings.keybinds",
|
||||
JSON.stringify({ moveUp: "KeyX" }),
|
||||
);
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
|
||||
});
|
||||
|
||||
test("ignores non-string and 'Null' values and preserves defaults", () => {
|
||||
const mixed = {
|
||||
moveUp: { key: "moveUp", value: null },
|
||||
moveLeft: "Null",
|
||||
};
|
||||
localStorage.setItem("settings.keybinds", JSON.stringify(mixed));
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
// defaults from InputHandler should remain
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
||||
expect((inputHandler as any).keybinds.moveLeft).toBe("KeyA");
|
||||
});
|
||||
|
||||
test("handles invalid JSON gracefully and warns", () => {
|
||||
const spy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
localStorage.setItem("settings.keybinds", "not a json");
|
||||
|
||||
inputHandler.initialize();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
// default remains when parsing fails
|
||||
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user