mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 06:53:22 +00:00
Merge branch 'main' into icslucas-patch-1
This commit is contained in:
@@ -26,6 +26,7 @@ import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { WorkerClient } from "../core/worker/WorkerClient";
|
||||
import {
|
||||
AutoUpgradeEvent,
|
||||
DoBoatAttackEvent,
|
||||
DoGroundAttackEvent,
|
||||
InputHandler,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
SendBoatAttackIntentEvent,
|
||||
SendHashEvent,
|
||||
SendSpawnIntentEvent,
|
||||
SendUpgradeStructureIntentEvent,
|
||||
Transport,
|
||||
} from "./Transport";
|
||||
import { createCanvas } from "./Utils";
|
||||
@@ -248,6 +250,7 @@ export class ClientGameRunner {
|
||||
}, 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));
|
||||
this.eventBus.on(
|
||||
DoBoatAttackEvent,
|
||||
this.doBoatAttackUnderCursor.bind(this),
|
||||
@@ -424,6 +427,76 @@ export class ClientGameRunner {
|
||||
});
|
||||
}
|
||||
|
||||
private autoUpgradeEvent(event: AutoUpgradeEvent) {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
|
||||
event.x,
|
||||
event.y,
|
||||
);
|
||||
if (!this.gameView.isValidCoord(cell.x, cell.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.gameView.ref(cell.x, cell.y);
|
||||
|
||||
if (this.myPlayer === null) {
|
||||
const myPlayer = this.gameView.playerByClientID(this.lobby.clientID);
|
||||
if (myPlayer === null) return;
|
||||
this.myPlayer = myPlayer;
|
||||
}
|
||||
|
||||
if (this.gameView.inSpawnPhase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.findAndUpgradeNearestBuilding(tile);
|
||||
}
|
||||
|
||||
private findAndUpgradeNearestBuilding(clickedTile: TileRef) {
|
||||
this.myPlayer!.actions(clickedTile).then((actions) => {
|
||||
const upgradeUnits: {
|
||||
unitId: number;
|
||||
unitType: UnitType;
|
||||
distance: number;
|
||||
}[] = [];
|
||||
|
||||
for (const bu of actions.buildableUnits) {
|
||||
if (bu.canUpgrade !== false) {
|
||||
const existingUnit = this.gameView
|
||||
.units()
|
||||
.find((unit) => unit.id() === bu.canUpgrade);
|
||||
if (existingUnit) {
|
||||
const distance = this.gameView.manhattanDist(
|
||||
clickedTile,
|
||||
existingUnit.tile(),
|
||||
);
|
||||
|
||||
upgradeUnits.push({
|
||||
unitId: bu.canUpgrade,
|
||||
unitType: bu.type,
|
||||
distance: distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (upgradeUnits.length > 0) {
|
||||
upgradeUnits.sort((a, b) => a.distance - b.distance);
|
||||
const bestUpgrade = upgradeUnits[0];
|
||||
|
||||
this.eventBus.emit(
|
||||
new SendUpgradeStructureIntentEvent(
|
||||
bestUpgrade.unitId,
|
||||
bestUpgrade.unitType,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private doBoatAttackUnderCursor(): void {
|
||||
const tile = this.getTileUnderCursor();
|
||||
if (tile === null) {
|
||||
|
||||
+62
-126
@@ -1,149 +1,85 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { COSMETICS } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
|
||||
import { getApiBase, getAuthHeader } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
interface StripeProduct {
|
||||
id: string;
|
||||
object: "product";
|
||||
active: boolean;
|
||||
created: number;
|
||||
description: string | null;
|
||||
images: string[];
|
||||
livemode: boolean;
|
||||
metadata: Record<string, string>;
|
||||
name: string;
|
||||
shippable: boolean | null;
|
||||
type: "good" | "service";
|
||||
updated: number;
|
||||
url: string | null;
|
||||
price: string;
|
||||
price_id: string;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
name: string;
|
||||
key: string;
|
||||
roles: string[];
|
||||
price?: string;
|
||||
priceId?: string;
|
||||
lockedReason?: string;
|
||||
notShown?: boolean;
|
||||
}
|
||||
|
||||
export async function patterns(
|
||||
userMe: UserMeResponse | null,
|
||||
): Promise<Pattern[]> {
|
||||
const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map(
|
||||
([key, patternData]) => {
|
||||
return {
|
||||
name: patternData.name,
|
||||
key,
|
||||
roles: patternData.role_group
|
||||
? (COSMETICS.role_groups[patternData.role_group] ?? [])
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const cosmetics = await getCosmetics();
|
||||
|
||||
const products = await listAllProducts();
|
||||
patterns.forEach((pattern) => {
|
||||
addRestrictions(pattern, userMe, products);
|
||||
});
|
||||
if (cosmetics === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patterns: Pattern[] = [];
|
||||
const playerFlares = new Set(userMe?.player.flares);
|
||||
|
||||
for (const name in cosmetics.patterns) {
|
||||
const patternData = cosmetics.patterns[name];
|
||||
const hasAccess = playerFlares.has(`pattern:${name}`);
|
||||
if (hasAccess) {
|
||||
// Remove product info because player already has access.
|
||||
patternData.product = null;
|
||||
patterns.push(patternData);
|
||||
} else if (patternData.product !== null) {
|
||||
// Player doesn't have access, but product is available for purchase.
|
||||
patterns.push(patternData);
|
||||
}
|
||||
// If player doesn't have access and product is null, don't show it.
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
function addRestrictions(
|
||||
pattern: Pattern,
|
||||
userMe: UserMeResponse | null,
|
||||
products: Map<string, StripeProduct>,
|
||||
) {
|
||||
if (userMe === null) {
|
||||
if (products.has(`pattern:${pattern.name}`)) {
|
||||
// Purchasable (flare-gated) patterns are shown as disabled
|
||||
pattern.lockedReason = translateText("territory_patterns.blocked.login");
|
||||
} else {
|
||||
// Role-gated patterns are not shown
|
||||
pattern.notShown = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const flares = userMe.player.flares ?? [];
|
||||
if (
|
||||
flares.includes("pattern:*") ||
|
||||
flares.includes(`pattern:${pattern.name}`)
|
||||
) {
|
||||
// Pattern is unlocked by flare
|
||||
return;
|
||||
}
|
||||
|
||||
const myRoles = userMe.player.roles ?? [];
|
||||
if (
|
||||
pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole))
|
||||
) {
|
||||
// Pattern is unlocked by role
|
||||
return;
|
||||
}
|
||||
|
||||
const product = products.get(`pattern:${pattern.name}`);
|
||||
if (product) {
|
||||
pattern.price = product.price;
|
||||
pattern.priceId = product.price_id;
|
||||
pattern.lockedReason = translateText("territory_patterns.blocked.purchase");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pattern is locked by role group and not purchasable, don't show it.
|
||||
pattern.notShown = true;
|
||||
}
|
||||
|
||||
export async function handlePurchase(priceId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/stripe/create-checkout-session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
authorization: getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: priceId,
|
||||
successUrl: `${window.location.href}purchase-success`,
|
||||
cancelUrl: `${window.location.href}purchase-cancel`,
|
||||
}),
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/stripe/create-checkout-session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
authorization: getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: priceId,
|
||||
|
||||
successUrl: `${window.location.href}purchase-success`,
|
||||
cancelUrl: `${window.location.href}purchase-cancel`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Error purchasing pattern:${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
if (response.status === 401) {
|
||||
alert("You are not logged in. Please log in to purchase a pattern.");
|
||||
} else {
|
||||
alert("Something went wrong. Please try again later.");
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Purchase error:", error);
|
||||
alert("Something went wrong. Please try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Returns a map of flare -> product
|
||||
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
|
||||
async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/stripe/products`);
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const products = (await response.json()) as StripeProduct[];
|
||||
const productMap = new Map<string, StripeProduct>();
|
||||
products.forEach((product) => {
|
||||
productMap.set(product.metadata.flare, product);
|
||||
});
|
||||
return productMap;
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
return new Map();
|
||||
console.error("Error getting cosmetics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-90
@@ -1,13 +1,11 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import Countries from "./data/countries.json";
|
||||
import { renderPlayerFlag } from "../core/CustomFlag";
|
||||
const flagKey: string = "flag";
|
||||
|
||||
@customElement("flag-input")
|
||||
export class FlagInput extends LitElement {
|
||||
@state() private flag: string = "";
|
||||
@state() private search: string = "";
|
||||
@state() private showModal: boolean = false;
|
||||
@state() public flag: string = "";
|
||||
|
||||
static styles = css`
|
||||
@media (max-width: 768px) {
|
||||
@@ -21,19 +19,6 @@ export class FlagInput extends LitElement {
|
||||
}
|
||||
`;
|
||||
|
||||
private handleSearch(e: Event) {
|
||||
this.search = String((e.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
private setFlag(flag: string) {
|
||||
if (flag === "xx") {
|
||||
flag = "";
|
||||
}
|
||||
this.flag = flag;
|
||||
this.showModal = false;
|
||||
this.storeFlag(flag);
|
||||
}
|
||||
|
||||
public getCurrentFlag(): string {
|
||||
return this.flag;
|
||||
}
|
||||
@@ -46,14 +31,6 @@ export class FlagInput extends LitElement {
|
||||
return "";
|
||||
}
|
||||
|
||||
private storeFlag(flag: string) {
|
||||
if (flag) {
|
||||
localStorage.setItem(flagKey, flag);
|
||||
} else if (flag === "") {
|
||||
localStorage.removeItem(flagKey);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchFlagEvent() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("flag-change", {
|
||||
@@ -68,86 +45,51 @@ export class FlagInput extends LitElement {
|
||||
super.connectedCallback();
|
||||
this.flag = this.getStoredFlag();
|
||||
this.dispatchFlagEvent();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.showModal = false;
|
||||
}
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="absolute left-0 top-0 w-full h-full ${this.showModal
|
||||
? ""
|
||||
: "hidden"}"
|
||||
@click=${() => (this.showModal = false)}
|
||||
></div>
|
||||
<div class="flex relative">
|
||||
<button
|
||||
@click=${() => (this.showModal = !this.showModal)}
|
||||
id="flag-input_"
|
||||
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
|
||||
title="Pick a flag!"
|
||||
>
|
||||
<img class="size-[48px]" src="/flags/${this.flag || "xx"}.svg" />
|
||||
<span
|
||||
id="flag-preview"
|
||||
style="display:inline-block;width:48px;height:64px;vertical-align:middle;background:#333;border-radius:6px;overflow:hidden;"
|
||||
></span>
|
||||
</button>
|
||||
${this.showModal
|
||||
? html`
|
||||
<div
|
||||
class="text-white flex flex-col gap-[0.5rem] absolute top-[60px] left-[0px] w-[780%] h-[500px] max-h-[50vh] max-w-[87vw] bg-gray-900/80 backdrop-blur-md p-[10px] rounded-[8px] z-[3] ${this
|
||||
.showModal
|
||||
? ""
|
||||
: "hidden"}"
|
||||
>
|
||||
<input
|
||||
class="h-[2rem] border-none text-center border border-gray-300 rounded-xl shadow-sm text-2xl text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@change=${this.handleSearch}
|
||||
@keyup=${this.handleSearch}
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap justify-evenly gap-[1rem] overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
${Countries.filter(
|
||||
(country) =>
|
||||
country.name
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase()) ||
|
||||
country.code
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase()),
|
||||
).map(
|
||||
(country) => html`
|
||||
<button
|
||||
@click=${() => this.setFlag(country.code)}
|
||||
class="text-center cursor-pointer border-none bg-none opacity-70 sm:w-[calc(33.3333%-15px) w-[calc(100%/3-15px)] md:w-[calc(100%/4-15px)]"
|
||||
>
|
||||
<img
|
||||
class="country-flag w-full h-auto"
|
||||
src="/flags/${country.code}.svg"
|
||||
/>
|
||||
<span class="country-name">${country.name}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updated() {
|
||||
const preview = this.renderRoot.querySelector(
|
||||
"#flag-preview",
|
||||
) as HTMLElement;
|
||||
if (!preview) return;
|
||||
|
||||
preview.innerHTML = "";
|
||||
|
||||
if (this.flag?.startsWith("!")) {
|
||||
renderPlayerFlag(this.flag, preview);
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = this.flag ? `/flags/${this.flag}.svg` : `/flags/xx.svg`;
|
||||
img.style.width = "100%";
|
||||
img.style.height = "100%";
|
||||
img.style.objectFit = "contain";
|
||||
img.onerror = () => {
|
||||
if (!img.src.endsWith("/flags/xx.svg")) {
|
||||
img.src = "/flags/xx.svg";
|
||||
}
|
||||
};
|
||||
preview.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import Countries from "./data/countries.json";
|
||||
|
||||
@customElement("flag-input-modal")
|
||||
export class FlagInputModal extends LitElement {
|
||||
@query("o-modal") private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
@state() private search: string = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal title="Flag Selector Modal" alwaysMaximized>
|
||||
<input
|
||||
class="h-[2rem] border-none text-center border border-gray-300 rounded-xl shadow-sm text-2xl text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@change=${this.handleSearch}
|
||||
@keyup=${this.handleSearch}
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap justify-evenly gap-[1rem] overflow-y-auto overflow-x-hidden h-[90%]"
|
||||
>
|
||||
${Countries.filter(
|
||||
(country) =>
|
||||
country.name.toLowerCase().includes(this.search.toLowerCase()) ||
|
||||
country.code.toLowerCase().includes(this.search.toLowerCase()),
|
||||
).map(
|
||||
(country) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.setFlag(country.code);
|
||||
this.close();
|
||||
}}
|
||||
class="text-center cursor-pointer border-none bg-none opacity-70
|
||||
w-[calc(100%/2-15px)] sm:w-[calc(100%/4-15px)]
|
||||
md:w-[calc(100%/6-15px)] lg:w-[calc(100%/8-15px)]
|
||||
xl:w-[calc(100%/10-15px)] min-w-[80px]"
|
||||
>
|
||||
<img
|
||||
class="country-flag w-full h-auto"
|
||||
src="/flags/${country.code}.svg"
|
||||
@error=${(e: Event) => {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
const fallback = "/flags/xx.svg";
|
||||
if (img.src && !img.src.endsWith(fallback)) {
|
||||
img.src = fallback;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span class="country-name">${country.name}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSearch(event: Event) {
|
||||
this.search = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private setFlag(flag: string) {
|
||||
localStorage.setItem("flag", flag);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("flag-change", {
|
||||
detail: { flag },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
}
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("keydown", this.handleKeyDown);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Escape") {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -138,6 +138,14 @@ export class HelpModal extends LitElement {
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_reset_gfx")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mouse-shell">
|
||||
<div class="mouse-wheel" id="highlighted-wheel"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_auto_upgrade")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,13 @@ export class CenterCameraEvent implements GameEvent {
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
export class AutoUpgradeEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class InputHandler {
|
||||
private lastPointerX: number = 0;
|
||||
private lastPointerY: number = 0;
|
||||
@@ -325,6 +332,12 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
private onPointerDown(event: PointerEvent) {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.eventBus.emit(new AutoUpgradeEvent(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -346,6 +359,11 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
onPointerUp(event: PointerEvent) {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button > 0) {
|
||||
return;
|
||||
}
|
||||
@@ -398,6 +416,11 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
private onPointerMove(event: PointerEvent) {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,14 +135,25 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
if (id.startsWith("http")) {
|
||||
this.lobbyIdInput.value = id.split("join/")[1];
|
||||
private extractLobbyIdFromUrl(input: string): string {
|
||||
if (input.startsWith("http")) {
|
||||
if (input.includes("#join=")) {
|
||||
const params = new URLSearchParams(input.split("#")[1]);
|
||||
return params.get("join") ?? input;
|
||||
} else if (input.includes("join/")) {
|
||||
return input.split("join/")[1];
|
||||
} else {
|
||||
return input;
|
||||
}
|
||||
} else {
|
||||
this.lobbyIdInput.value = id;
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
this.setLobbyId(value);
|
||||
@@ -151,15 +162,7 @@ export class JoinPrivateLobbyModal extends LitElement {
|
||||
private async pasteFromClipboard() {
|
||||
try {
|
||||
const clipText = await navigator.clipboard.readText();
|
||||
|
||||
let lobbyId: string;
|
||||
if (clipText.startsWith("http")) {
|
||||
lobbyId = clipText.split("join/")[1];
|
||||
} else {
|
||||
lobbyId = clipText;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
this.setLobbyId(clipText);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard contents: ", err);
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,4 +1,3 @@
|
||||
import favicon from "../../resources/images/Favicon.svg";
|
||||
import version from "../../resources/version.txt";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
@@ -12,6 +11,7 @@ import "./DarkModeButton";
|
||||
import { DarkModeButton } from "./DarkModeButton";
|
||||
import "./FlagInput";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
import { FlagInputModal } from "./FlagInputModal";
|
||||
import { GameStartingModal } from "./GameStartingModal";
|
||||
import "./GoogleAdElement";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
@@ -64,6 +64,7 @@ declare global {
|
||||
// Extend the global interfaces to include your custom events
|
||||
interface DocumentEventMap {
|
||||
"join-lobby": CustomEvent<JoinLobbyEvent>;
|
||||
"kick-player": CustomEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +164,6 @@ class Client {
|
||||
}
|
||||
});
|
||||
|
||||
setFavicon();
|
||||
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
||||
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
||||
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
|
||||
@@ -195,6 +195,16 @@ class Client {
|
||||
hlpModal.open();
|
||||
});
|
||||
|
||||
const flagInputModal = document.querySelector(
|
||||
"flag-input-modal",
|
||||
) as FlagInputModal;
|
||||
flagInputModal instanceof FlagInputModal;
|
||||
const flgInput = document.getElementById("flag-input_");
|
||||
if (flgInput === null) throw new Error("Missing flag-input_");
|
||||
flgInput.addEventListener("click", () => {
|
||||
flagInputModal.open();
|
||||
});
|
||||
|
||||
const territoryModal = document.querySelector(
|
||||
"territory-patterns-modal",
|
||||
) as TerritoryPatternsModal;
|
||||
@@ -536,14 +546,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
new Client().initialize();
|
||||
});
|
||||
|
||||
function setFavicon(): void {
|
||||
const link = document.createElement("link");
|
||||
link.type = "image/x-icon";
|
||||
link.rel = "shortcut icon";
|
||||
link.href = favicon;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// WARNING: DO NOT EXPOSE THIS ID
|
||||
function getPlayToken(): string {
|
||||
const result = isLoggedIn();
|
||||
|
||||
+14
-1
@@ -83,7 +83,7 @@ export class NewsModal extends LitElement {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
${translateText("news.full_changelog")}
|
||||
${translateText("news.see_all_releases")}
|
||||
<a
|
||||
href="https://github.com/openfrontio/OpenFrontIO/releases"
|
||||
target="_blank"
|
||||
@@ -105,6 +105,19 @@ export class NewsModal extends LitElement {
|
||||
this.initialized = true;
|
||||
fetch(changelog)
|
||||
.then((response) => (response.ok ? response.text() : "Failed to load"))
|
||||
.then((markdown) =>
|
||||
markdown
|
||||
.replace(
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/pull\/(\d+)\b/g,
|
||||
(_match, prNumber) =>
|
||||
`[#${prNumber}](https://github.com/openfrontio/OpenFrontIO/pull/${prNumber})`,
|
||||
)
|
||||
.replace(
|
||||
/(?<!\()\bhttps:\/\/github\.com\/openfrontio\/OpenFrontIO\/compare\/([\w.-]+)\b/g,
|
||||
(_match, comparison) =>
|
||||
`[${comparison}](https://github.com/openfrontio/OpenFrontIO/compare/${comparison})`,
|
||||
),
|
||||
)
|
||||
.then((markdown) => (this.markdown = markdown));
|
||||
}
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
import "./components/Difficulties";
|
||||
import "./components/Maps";
|
||||
import { handlePurchase, Pattern, patterns } from "./Cosmetics";
|
||||
import { handlePurchase, patterns } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
@@ -107,14 +108,14 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderTooltip(): TemplateResult | null {
|
||||
if (this.hoveredPattern && this.hoveredPattern.lockedReason) {
|
||||
if (this.hoveredPattern && this.hoveredPattern.product !== undefined) {
|
||||
return html`
|
||||
<div
|
||||
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
|
||||
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
|
||||
.x + 12}px;"
|
||||
>
|
||||
${this.hoveredPattern.lockedReason}
|
||||
${translateText("territory_patterns.blocked.purchase")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderPatternButton(pattern: Pattern): TemplateResult {
|
||||
const isSelected = this.selectedPattern === pattern.key;
|
||||
const isSelected = this.selectedPattern === pattern.pattern;
|
||||
|
||||
return html`
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
@@ -131,9 +132,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
${isSelected
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
|
||||
${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
@click=${() =>
|
||||
!pattern.lockedReason && this.selectPattern(pattern.key)}
|
||||
pattern.product === null && this.selectPattern(pattern.pattern)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
@@ -155,23 +156,23 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
"
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.key,
|
||||
pattern.pattern,
|
||||
this.buttonWidth,
|
||||
this.buttonWidth,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
${pattern.priceId !== undefined && pattern.lockedReason
|
||||
${pattern.product !== null
|
||||
? html`
|
||||
<button
|
||||
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
handlePurchase(pattern.priceId!);
|
||||
handlePurchase(pattern.product!.priceId);
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${pattern.price})
|
||||
(${pattern.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
@@ -183,7 +184,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const pattern of this.patterns) {
|
||||
if (!this.showChocoPattern && pattern.name === "choco") continue;
|
||||
if (pattern.notShown === true) continue;
|
||||
|
||||
const result = this.renderPatternButton(pattern);
|
||||
buttons.push(result);
|
||||
@@ -243,6 +243,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.modalEl?.open();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public close() {
|
||||
@@ -332,7 +333,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
|
||||
if (pattern.lockedReason) {
|
||||
if (pattern.product !== null) {
|
||||
this.hoveredPattern = pattern;
|
||||
this.hoverPosition = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
@@ -125,6 +125,15 @@ export class UserSettingModal extends LitElement {
|
||||
console.log("💥 Special effects:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleStructureSprites(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.structureSprites", enabled);
|
||||
|
||||
console.log("🏠 Structure sprites:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
@@ -291,6 +300,15 @@ export class UserSettingModal extends LitElement {
|
||||
@change=${this.toggleFxLayer}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🏠 Structure Sprites -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.structure_sprites_label")}"
|
||||
description="${translateText("user_setting.structure_sprites_desc")}"
|
||||
id="structure_sprites-toggle"
|
||||
.checked=${this.userSettings.structureSprites()}
|
||||
@change=${this.toggleStructureSprites}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🖱️ Left Click Menu -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.left_click_label")}"
|
||||
|
||||
+8
-5
@@ -5,24 +5,27 @@ export function renderTroops(troops: number): string {
|
||||
return renderNumber(troops / 10);
|
||||
}
|
||||
|
||||
export function renderNumber(num: number | bigint): string {
|
||||
export function renderNumber(
|
||||
num: number | bigint,
|
||||
fixedPoints?: number,
|
||||
): string {
|
||||
num = Number(num);
|
||||
num = Math.max(num, 0);
|
||||
|
||||
if (num >= 10_000_000) {
|
||||
const value = Math.floor(num / 100000) / 10;
|
||||
return value.toFixed(1) + "M";
|
||||
return value.toFixed(fixedPoints ?? 1) + "M";
|
||||
} else if (num >= 1_000_000) {
|
||||
const value = Math.floor(num / 10000) / 100;
|
||||
return value.toFixed(2) + "M";
|
||||
return value.toFixed(fixedPoints ?? 2) + "M";
|
||||
} else if (num >= 100000) {
|
||||
return Math.floor(num / 1000) + "K";
|
||||
} else if (num >= 10000) {
|
||||
const value = Math.floor(num / 100) / 10;
|
||||
return value.toFixed(1) + "K";
|
||||
return value.toFixed(fixedPoints ?? 1) + "K";
|
||||
} else if (num >= 1000) {
|
||||
const value = Math.floor(num / 10) / 100;
|
||||
return value.toFixed(2) + "K";
|
||||
return value.toFixed(fixedPoints ?? 2) + "K";
|
||||
} else {
|
||||
return Math.floor(num).toString();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export class OModal extends LitElement {
|
||||
@state() public isModalOpen = false;
|
||||
@property({ type: String }) title = "";
|
||||
@property({ type: String }) translationKey = "";
|
||||
@property({ type: Boolean }) alwaysMaximized = false;
|
||||
|
||||
static styles = css`
|
||||
.c-modal {
|
||||
@@ -31,6 +32,17 @@ export class OModal extends LitElement {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.c-modal__wrapper.always-maximized {
|
||||
width: 100%;
|
||||
min-width: 340px;
|
||||
max-width: 860px;
|
||||
min-height: 320px;
|
||||
/* Fallback for older browsers */
|
||||
height: 60vh;
|
||||
/* Use dvh if supported for dynamic viewport handling */
|
||||
height: 60dvh;
|
||||
}
|
||||
|
||||
.c-modal__header {
|
||||
position: relative;
|
||||
border-top-left-radius: 4px;
|
||||
@@ -74,7 +86,11 @@ export class OModal extends LitElement {
|
||||
${this.isModalOpen
|
||||
? html`
|
||||
<aside class="c-modal">
|
||||
<div class="c-modal__wrapper">
|
||||
<div
|
||||
class="c-modal__wrapper ${this.alwaysMaximized
|
||||
? "always-maximized"
|
||||
: ""}"
|
||||
>
|
||||
<header class="c-modal__header">
|
||||
${`${this.translationKey}` === ""
|
||||
? `${this.title}`
|
||||
|
||||
@@ -66,33 +66,27 @@ export class FxLayer implements Layer {
|
||||
}
|
||||
|
||||
onBonusEvent(bonus: BonusEventUpdate) {
|
||||
const tile = bonus.tile;
|
||||
if (this.game.owner(tile) !== this.game.myPlayer()) {
|
||||
if (this.game.player(bonus.player) !== this.game.myPlayer()) {
|
||||
// Only display text fx for the current player
|
||||
return;
|
||||
}
|
||||
const tile = bonus.tile;
|
||||
const x = this.game.x(tile);
|
||||
let y = this.game.y(tile);
|
||||
const gold = bonus.gold;
|
||||
const troops = bonus.troops;
|
||||
const workers = bonus.workers;
|
||||
|
||||
if (gold > 0) {
|
||||
const shortened = renderNumber(gold);
|
||||
const shortened = renderNumber(gold, 0);
|
||||
this.addTextFx(`+ ${shortened}`, x, y);
|
||||
y += 10; // increase y so the next popup starts bellow
|
||||
}
|
||||
|
||||
if (troops > 0) {
|
||||
const shortened = renderNumber(troops);
|
||||
const shortened = renderNumber(troops, 0);
|
||||
this.addTextFx(`+ ${shortened} troops`, x, y);
|
||||
y += 10;
|
||||
}
|
||||
|
||||
if (workers > 0) {
|
||||
const shortened = renderNumber(workers);
|
||||
this.addTextFx(`+ ${shortened} workers`, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
addTextFx(text: string, x: number, y: number) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg
|
||||
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
|
||||
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
|
||||
import infoIcon from "../../../../resources/images/InfoIcon.svg";
|
||||
import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
|
||||
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -66,6 +67,7 @@ export const COLORS = {
|
||||
breakAlly: "#c74848",
|
||||
info: "#64748B",
|
||||
target: "#ff0000",
|
||||
attack: "#ff0000",
|
||||
infoDetails: "#7f8c8d",
|
||||
infoEmoji: "#f1c40f",
|
||||
trade: "#008080",
|
||||
@@ -89,6 +91,7 @@ export enum Slot {
|
||||
Info = "info",
|
||||
Boat = "boat",
|
||||
Build = "build",
|
||||
Attack = "attack",
|
||||
Ally = "ally",
|
||||
Back = "back",
|
||||
}
|
||||
@@ -319,6 +322,88 @@ function getAllEnabledUnits(myPlayer: boolean, config: Config): Set<UnitType> {
|
||||
return Units;
|
||||
}
|
||||
|
||||
const ATTACK_UNIT_TYPES: UnitType[] = [
|
||||
UnitType.AtomBomb,
|
||||
UnitType.MIRV,
|
||||
UnitType.HydrogenBomb,
|
||||
UnitType.Warship,
|
||||
];
|
||||
|
||||
function createMenuElements(
|
||||
params: MenuElementParams,
|
||||
filterType: "attack" | "build",
|
||||
elementIdPrefix: string,
|
||||
): MenuElement[] {
|
||||
const unitTypes: Set<UnitType> = getAllEnabledUnits(
|
||||
params.selected === params.myPlayer,
|
||||
params.game.config(),
|
||||
);
|
||||
|
||||
return flattenedBuildTable
|
||||
.filter(
|
||||
(item) =>
|
||||
unitTypes.has(item.unitType) &&
|
||||
(filterType === "attack"
|
||||
? ATTACK_UNIT_TYPES.includes(item.unitType)
|
||||
: !ATTACK_UNIT_TYPES.includes(item.unitType)),
|
||||
)
|
||||
.map((item: BuildItemDisplay) => ({
|
||||
id: `${elementIdPrefix}_${item.unitType}`,
|
||||
name: item.key
|
||||
? item.key.replace("unit_type.", "")
|
||||
: item.unitType.toString(),
|
||||
disabled: (params: MenuElementParams) =>
|
||||
!params.buildMenu.canBuildOrUpgrade(item),
|
||||
color: params.buildMenu.canBuildOrUpgrade(item)
|
||||
? filterType === "attack"
|
||||
? COLORS.attack
|
||||
: COLORS.building
|
||||
: undefined,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
{
|
||||
text: translateText(item.description ?? ""),
|
||||
className: "description",
|
||||
},
|
||||
{
|
||||
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
|
||||
className: "cost",
|
||||
},
|
||||
item.countable
|
||||
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
|
||||
: null,
|
||||
].filter(
|
||||
(tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
|
||||
),
|
||||
action: (params: MenuElementParams) => {
|
||||
const buildableUnit = params.playerActions.buildableUnits.find(
|
||||
(bu) => bu.type === item.unitType,
|
||||
);
|
||||
if (buildableUnit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (params.buildMenu.canBuildOrUpgrade(item)) {
|
||||
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
|
||||
}
|
||||
params.closeMenu();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const attackMenuElement: MenuElement = {
|
||||
id: Slot.Attack,
|
||||
name: "radial_attack",
|
||||
disabled: (params: MenuElementParams) => params.game.inSpawnPhase(),
|
||||
icon: swordIcon,
|
||||
color: COLORS.attack,
|
||||
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
if (params === undefined) return [];
|
||||
return createMenuElements(params, "attack", "attack");
|
||||
},
|
||||
};
|
||||
|
||||
export const buildMenuElement: MenuElement = {
|
||||
id: Slot.Build,
|
||||
name: "build",
|
||||
@@ -328,53 +413,7 @@ export const buildMenuElement: MenuElement = {
|
||||
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
if (params === undefined) return [];
|
||||
|
||||
const unitTypes: Set<UnitType> = getAllEnabledUnits(
|
||||
params.selected === params.myPlayer,
|
||||
params.game.config(),
|
||||
);
|
||||
const buildElements: MenuElement[] = flattenedBuildTable
|
||||
.filter((item) => unitTypes.has(item.unitType))
|
||||
.map((item: BuildItemDisplay) => ({
|
||||
id: `build_${item.unitType}`,
|
||||
name: item.key
|
||||
? item.key.replace("unit_type.", "")
|
||||
: item.unitType.toString(),
|
||||
disabled: (params: MenuElementParams) =>
|
||||
!params.buildMenu.canBuildOrUpgrade(item),
|
||||
color: params.buildMenu.canBuildOrUpgrade(item)
|
||||
? COLORS.building
|
||||
: undefined,
|
||||
icon: item.icon,
|
||||
tooltipItems: [
|
||||
{ text: translateText(item.key ?? ""), className: "title" },
|
||||
{
|
||||
text: translateText(item.description ?? ""),
|
||||
className: "description",
|
||||
},
|
||||
{
|
||||
text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
|
||||
className: "cost",
|
||||
},
|
||||
item.countable
|
||||
? { text: `${params.buildMenu.count(item)}x`, className: "count" }
|
||||
: null,
|
||||
].filter((item): item is TooltipItem => item !== null),
|
||||
action: (params: MenuElementParams) => {
|
||||
const buildableUnit = params.playerActions.buildableUnits.find(
|
||||
(bu) => bu.type === item.unitType,
|
||||
);
|
||||
if (buildableUnit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (params.buildMenu.canBuildOrUpgrade(item)) {
|
||||
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
|
||||
}
|
||||
params.closeMenu();
|
||||
},
|
||||
}));
|
||||
|
||||
return buildElements;
|
||||
return createMenuElements(params, "build", "build");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -444,6 +483,24 @@ export const rootMenuElement: MenuElement = {
|
||||
if (params.selected?.isAlliedWith(params.myPlayer)) {
|
||||
ally = allyBreakElement;
|
||||
}
|
||||
return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
|
||||
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isOwnTerritory =
|
||||
tileOwner.isPlayer() &&
|
||||
(tileOwner as PlayerView).id() === params.myPlayer.id();
|
||||
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
boatMenuElement,
|
||||
ally,
|
||||
];
|
||||
|
||||
if (isOwnTerritory) {
|
||||
menuItems.push(buildMenuElement);
|
||||
} else {
|
||||
menuItems.push(attackMenuElement);
|
||||
}
|
||||
|
||||
return menuItems.filter((item): item is MenuElement => item !== null);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import structureIcon from "../../../../resources/images/CityIconWhite.svg";
|
||||
import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg";
|
||||
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
|
||||
import exitIcon from "../../../../resources/images/ExitIconWhite.svg";
|
||||
@@ -93,6 +94,11 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleStructureSpritesButtonClick() {
|
||||
this.userSettings.toggleStructureSprites();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleSpecialEffectsButtonClick() {
|
||||
this.userSettings.toggleFxLayer();
|
||||
this.requestUpdate();
|
||||
@@ -259,6 +265,33 @@ export class SettingsModal extends LitElement implements Layer {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
|
||||
@click="${this.onToggleStructureSpritesButtonClick}"
|
||||
>
|
||||
<img
|
||||
src=${structureIcon}
|
||||
alt="structureSprites"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText("user_setting.structure_sprites_label")}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.structureSprites()
|
||||
? translateText("user_setting.structure_sprites_enabled")
|
||||
: translateText("user_setting.structure_sprites_disabled")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.structureSprites()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
|
||||
@click="${this.onToggleRandomNameModeButtonClick}"
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ToggleStructureEvent } from "../../InputHandler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
type ShapeType = "triangle" | "square" | "octagon" | "circle";
|
||||
type ShapeType = "triangle" | "square" | "pentagon" | "octagon" | "circle";
|
||||
|
||||
class StructureRenderInfo {
|
||||
public isOnScreen: boolean = false;
|
||||
@@ -25,6 +25,7 @@ class StructureRenderInfo {
|
||||
public owner: PlayerID,
|
||||
public iconContainer: PIXI.Container,
|
||||
public levelContainer: PIXI.Container,
|
||||
public dotContainer: PIXI.Container,
|
||||
public level: number = 0,
|
||||
public underConstruction: boolean = true,
|
||||
) {}
|
||||
@@ -32,20 +33,31 @@ class StructureRenderInfo {
|
||||
|
||||
const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
|
||||
[UnitType.City]: "circle",
|
||||
[UnitType.Port]: "circle",
|
||||
[UnitType.Port]: "pentagon",
|
||||
[UnitType.Factory]: "circle",
|
||||
[UnitType.DefensePost]: "octagon",
|
||||
[UnitType.SAMLauncher]: "square",
|
||||
[UnitType.MissileSilo]: "triangle",
|
||||
};
|
||||
const ZOOM_THRESHOLD = 3.5;
|
||||
const ICON_SIZE = 24;
|
||||
const OFFSET_ZOOM_Y = 5; // offset for the y position of the icon to avoid hiding the structure beneath
|
||||
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 pixicanvas: HTMLCanvasElement;
|
||||
private iconsStage: PIXI.Container;
|
||||
private levelsStage: PIXI.Container;
|
||||
private dotsStage: PIXI.Container;
|
||||
private shouldRedraw: boolean = true;
|
||||
private textureCache: Map<string, PIXI.Texture> = new Map();
|
||||
private theme: Theme;
|
||||
@@ -72,6 +84,7 @@ export class StructureIconsLayer implements Layer {
|
||||
{ visible: true, iconPath: SAMMissileIcon, image: null },
|
||||
],
|
||||
]);
|
||||
private renderSprites = true;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -95,19 +108,22 @@ export class StructureIconsLayer implements Layer {
|
||||
|
||||
this.iconsStage = new PIXI.Container();
|
||||
this.iconsStage.position.set(0, 0);
|
||||
this.iconsStage.width = this.pixicanvas.width;
|
||||
this.iconsStage.height = this.pixicanvas.height;
|
||||
this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
|
||||
|
||||
this.levelsStage = new PIXI.Container();
|
||||
this.levelsStage.position.set(0, 0);
|
||||
this.levelsStage.width = this.pixicanvas.width;
|
||||
this.levelsStage.height = this.pixicanvas.height;
|
||||
this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
|
||||
|
||||
this.dotsStage = new PIXI.Container();
|
||||
this.dotsStage.position.set(0, 0);
|
||||
this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
|
||||
|
||||
await this.renderer.init({
|
||||
canvas: this.pixicanvas,
|
||||
resolution: 1,
|
||||
width: this.pixicanvas.width,
|
||||
height: this.pixicanvas.height,
|
||||
antialias: false,
|
||||
clearBeforeRender: true,
|
||||
backgroundAlpha: 0,
|
||||
backgroundColor: 0x00000000,
|
||||
@@ -168,6 +184,8 @@ export class StructureIconsLayer implements Layer {
|
||||
this.handleInactiveUnit(unitView);
|
||||
}
|
||||
});
|
||||
this.renderSprites =
|
||||
this.game.config().userSettings()?.structureSprites() ?? true;
|
||||
}
|
||||
|
||||
private toggleStructure(toggleStructureType: UnitType | null): void {
|
||||
@@ -227,12 +245,17 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
if (structureInfos) {
|
||||
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
|
||||
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
|
||||
if (structureInfos.visible && focusStructure) {
|
||||
render.iconContainer.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
|
||||
];
|
||||
render.dotContainer.filters = [
|
||||
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
|
||||
];
|
||||
} else {
|
||||
render.iconContainer.filters = [];
|
||||
render.dotContainer.filters = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +270,9 @@ export class StructureIconsLayer implements Layer {
|
||||
) {
|
||||
render.underConstruction = false;
|
||||
render.iconContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
render.iconContainer = this.createIconSprite(unit);
|
||||
render.dotContainer = this.createDotSprite(unit);
|
||||
this.modifyVisibility(render);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
@@ -257,7 +282,9 @@ export class StructureIconsLayer implements Layer {
|
||||
if (render.owner !== unit.owner().id()) {
|
||||
render.owner = unit.owner().id();
|
||||
render.iconContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
render.iconContainer = this.createIconSprite(unit);
|
||||
render.dotContainer = this.createDotSprite(unit);
|
||||
this.modifyVisibility(render);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
@@ -268,8 +295,10 @@ export class StructureIconsLayer implements Layer {
|
||||
render.level = unit.level();
|
||||
render.iconContainer?.destroy();
|
||||
render.levelContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
render.iconContainer = this.createIconSprite(unit);
|
||||
render.levelContainer = this.createLevelSprite(unit);
|
||||
render.dotContainer = this.createDotSprite(unit);
|
||||
this.modifyVisibility(render);
|
||||
this.shouldRedraw = true;
|
||||
}
|
||||
@@ -291,17 +320,19 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
|
||||
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
|
||||
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
|
||||
if (this.transformHandler.scale > ZOOM_THRESHOLD && this.renderSprites) {
|
||||
this.renderer.render(this.levelsStage);
|
||||
} else {
|
||||
} 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): PIXI.Texture {
|
||||
private createTexture(unit: UnitView, renderIcon: boolean): PIXI.Texture {
|
||||
const isConstruction = unit.type() === UnitType.Construction;
|
||||
const constructionType = unit.constructionType();
|
||||
if (isConstruction && constructionType === undefined) {
|
||||
@@ -312,15 +343,22 @@ export class StructureIconsLayer implements Layer {
|
||||
}
|
||||
const structureType = isConstruction ? constructionType! : unit.type();
|
||||
const cacheKey = isConstruction
|
||||
? `construction-${structureType}`
|
||||
: `${unit.owner().id()}-${structureType}`;
|
||||
? `construction-${structureType}` + (renderIcon ? "-icon" : "")
|
||||
: `${this.theme.territoryColor(unit.owner()).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)
|
||||
? this.createIcon(
|
||||
unit.owner(),
|
||||
structureType,
|
||||
isConstruction,
|
||||
shape,
|
||||
renderIcon,
|
||||
)
|
||||
: PIXI.Texture.EMPTY;
|
||||
|
||||
this.textureCache.set(cacheKey, texture);
|
||||
@@ -331,11 +369,16 @@ export class StructureIconsLayer implements Layer {
|
||||
owner: PlayerView,
|
||||
structureType: UnitType,
|
||||
isConstruction: boolean,
|
||||
shape: "triangle" | "square" | "octagon" | "circle",
|
||||
) {
|
||||
shape: ShapeType,
|
||||
renderIcon: boolean,
|
||||
): PIXI.Texture {
|
||||
const structureCanvas = document.createElement("canvas");
|
||||
structureCanvas.width = ICON_SIZE;
|
||||
structureCanvas.height = ICON_SIZE;
|
||||
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")!;
|
||||
|
||||
let borderColor: string;
|
||||
@@ -345,35 +388,37 @@ export class StructureIconsLayer implements Layer {
|
||||
} else {
|
||||
context.fillStyle = this.theme
|
||||
.territoryColor(owner)
|
||||
.lighten(0.06)
|
||||
.lighten(0.13)
|
||||
.alpha(renderIcon ? 0.65 : 1)
|
||||
.toRgbString();
|
||||
borderColor = this.theme.borderColor(owner).darken(0.08).toRgbString();
|
||||
const darken = this.theme.borderColor(owner).isLight() ? 0.17 : 0.15;
|
||||
borderColor = this.theme.borderColor(owner).darken(darken).toRgbString();
|
||||
}
|
||||
|
||||
context.strokeStyle = borderColor;
|
||||
context.lineWidth = 1;
|
||||
|
||||
const halfIconSize = iconSize / 2;
|
||||
switch (shape) {
|
||||
case "triangle":
|
||||
context.beginPath();
|
||||
context.moveTo(ICON_SIZE / 2, 0); // Top
|
||||
context.lineTo(ICON_SIZE, ICON_SIZE); // Bottom right
|
||||
context.lineTo(0, ICON_SIZE); // Bottom left
|
||||
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(0, 0, ICON_SIZE - 2, ICON_SIZE - 2);
|
||||
context.strokeRect(0.5, 0.5, ICON_SIZE - 3, ICON_SIZE - 3);
|
||||
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
|
||||
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
|
||||
break;
|
||||
|
||||
case "octagon":
|
||||
{
|
||||
const cx = ICON_SIZE / 2;
|
||||
const cy = ICON_SIZE / 2;
|
||||
const r = ICON_SIZE / 2 - 1;
|
||||
const cx = halfIconSize;
|
||||
const cy = halfIconSize;
|
||||
const r = halfIconSize - 1;
|
||||
const step = (Math.PI * 2) / 8;
|
||||
|
||||
context.beginPath();
|
||||
@@ -392,13 +437,35 @@ export class StructureIconsLayer implements Layer {
|
||||
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(
|
||||
ICON_SIZE / 2,
|
||||
ICON_SIZE / 2,
|
||||
ICON_SIZE / 2 - 1,
|
||||
halfIconSize,
|
||||
halfIconSize,
|
||||
halfIconSize - 1,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
@@ -416,81 +483,111 @@ export class StructureIconsLayer implements Layer {
|
||||
return PIXI.Texture.from(structureCanvas);
|
||||
}
|
||||
|
||||
const SHAPE_OFFSETS = {
|
||||
triangle: [4, 8],
|
||||
square: [3, 3],
|
||||
octagon: [4, 4],
|
||||
circle: [4, 4],
|
||||
};
|
||||
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
|
||||
|
||||
context.drawImage(
|
||||
this.getImageColored(structureInfo.image, borderColor),
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
|
||||
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, {
|
||||
addIcon: false,
|
||||
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, {
|
||||
addIcon: true,
|
||||
type: "icon",
|
||||
stage: this.iconsStage,
|
||||
});
|
||||
}
|
||||
|
||||
private createUnitContainer(
|
||||
unit: UnitView,
|
||||
options: { addIcon?: boolean; stage: PIXI.Container },
|
||||
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
|
||||
): PIXI.Container {
|
||||
const parentContainer = new PIXI.Container();
|
||||
const tile = unit.tile();
|
||||
const worldX = this.game.x(tile);
|
||||
const worldY = this.game.y(tile);
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, worldY),
|
||||
);
|
||||
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
|
||||
|
||||
if (options.addIcon) {
|
||||
const sprite = new PIXI.Sprite(this.createTexture(unit));
|
||||
sprite.anchor.set(0.5, 0.5);
|
||||
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);
|
||||
}
|
||||
|
||||
if (unit.level() > 1) {
|
||||
// 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: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
text.anchor.set(0.5, 0.5);
|
||||
text.position.y = -ICON_SIZE / 2 - 2;
|
||||
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);
|
||||
|
||||
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
|
||||
posY = Math.round(
|
||||
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
|
||||
);
|
||||
// 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));
|
||||
}
|
||||
|
||||
parentContainer.position.set(posX, posY);
|
||||
parentContainer.scale.set(Math.min(1, this.transformHandler.scale));
|
||||
|
||||
options.stage.addChild(parentContainer);
|
||||
stage.addChild(parentContainer);
|
||||
return parentContainer;
|
||||
}
|
||||
|
||||
@@ -511,23 +608,27 @@ export class StructureIconsLayer implements Layer {
|
||||
|
||||
private computeNewLocation(render: StructureRenderInfo) {
|
||||
const tile = render.unit.tile();
|
||||
const worldX = this.game.x(tile);
|
||||
const worldY = this.game.y(tile);
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(
|
||||
new Cell(worldX, worldY),
|
||||
);
|
||||
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
|
||||
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
|
||||
screenPos.x = Math.round(screenPos.x);
|
||||
if (this.transformHandler.scale >= ZOOM_THRESHOLD) {
|
||||
// Adjust the y position based on zoom level to avoid hiding the structure beneath
|
||||
screenPos.y = Math.round(
|
||||
screenPos.y - this.transformHandler.scale * OFFSET_ZOOM_Y,
|
||||
);
|
||||
} else {
|
||||
screenPos.y = Math.round(screenPos.y);
|
||||
}
|
||||
|
||||
// Check if the sprite is on screen (with margin for partial visibility)
|
||||
const margin = ICON_SIZE;
|
||||
const scale = this.transformHandler.scale;
|
||||
screenPos.y = Math.round(
|
||||
scale >= ZOOM_THRESHOLD &&
|
||||
this.game.config().userSettings()?.structureSprites()
|
||||
? screenPos.y - scale * OFFSET_ZOOM_Y
|
||||
: screenPos.y,
|
||||
);
|
||||
|
||||
const type =
|
||||
render.unit.type() === UnitType.Construction
|
||||
? render.unit.constructionType()
|
||||
: render.unit.type();
|
||||
const margin =
|
||||
type !== undefined && STRUCTURE_SHAPES[type] !== undefined
|
||||
? ICON_SIZE[STRUCTURE_SHAPES[type]]
|
||||
: 28;
|
||||
|
||||
const onScreen =
|
||||
screenPos.x + margin > 0 &&
|
||||
screenPos.x - margin < this.pixicanvas.width &&
|
||||
@@ -535,21 +636,34 @@ export class StructureIconsLayer implements Layer {
|
||||
screenPos.y - margin < this.pixicanvas.height;
|
||||
|
||||
if (onScreen) {
|
||||
if (this.transformHandler.scale > ZOOM_THRESHOLD) {
|
||||
render.levelContainer.x = screenPos.x;
|
||||
render.levelContainer.y = screenPos.y;
|
||||
} else {
|
||||
render.iconContainer.x = screenPos.x;
|
||||
render.iconContainer.y = screenPos.y;
|
||||
render.iconContainer.scale.set(
|
||||
Math.min(1, this.transformHandler.scale),
|
||||
if (scale > ZOOM_THRESHOLD) {
|
||||
const target = this.game.config().userSettings()?.structureSprites()
|
||||
? render.levelContainer
|
||||
: render.iconContainer;
|
||||
target.position.set(screenPos.x, screenPos.y);
|
||||
target.scale.set(
|
||||
Math.max(
|
||||
1,
|
||||
scale /
|
||||
(target === render.levelContainer
|
||||
? LEVEL_SCALE_FACTOR
|
||||
: ICON_SCALE_FACTOR_ZOOMED_IN),
|
||||
),
|
||||
);
|
||||
} else if (scale > DOTS_ZOOM_THRESHOLD) {
|
||||
render.iconContainer.position.set(screenPos.x, screenPos.y);
|
||||
render.iconContainer.scale.set(
|
||||
Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT),
|
||||
);
|
||||
} else {
|
||||
render.dotContainer.position.set(screenPos.x, screenPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
if (render.isOnScreen !== onScreen) {
|
||||
// prevent unnecessary updates
|
||||
render.isOnScreen = onScreen;
|
||||
render.iconContainer.visible = onScreen;
|
||||
render.dotContainer.visible = onScreen;
|
||||
render.levelContainer.visible = onScreen;
|
||||
}
|
||||
}
|
||||
@@ -561,6 +675,7 @@ export class StructureIconsLayer implements Layer {
|
||||
unitView.owner().id(),
|
||||
this.createIconSprite(unitView),
|
||||
this.createLevelSprite(unitView),
|
||||
this.createDotSprite(unitView),
|
||||
unitView.level(),
|
||||
unitView.type() === UnitType.Construction,
|
||||
);
|
||||
@@ -573,6 +688,7 @@ export class StructureIconsLayer implements Layer {
|
||||
private deleteStructure(render: StructureRenderInfo) {
|
||||
render.iconContainer?.destroy();
|
||||
render.levelContainer?.destroy();
|
||||
render.dotContainer?.destroy();
|
||||
this.renders = this.renders.filter((r) => r.unit !== render.unit);
|
||||
this.seenUnits.delete(render.unit);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const underConstructionColor = colord({ r: 150, g: 150, b: 150 });
|
||||
const BASE_BORDER_RADIUS = 16.5;
|
||||
const BASE_TERRITORY_RADIUS = 13.5;
|
||||
const RADIUS_SCALE_FACTOR = 0.5;
|
||||
const ZOOM_THRESHOLD = 3.5; // below this zoom level, structures are not rendered
|
||||
const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered
|
||||
|
||||
interface UnitRenderConfig {
|
||||
icon: string;
|
||||
@@ -146,7 +146,10 @@ export class StructureLayer implements Layer {
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
if (this.transformHandler.scale <= ZOOM_THRESHOLD) {
|
||||
if (
|
||||
this.transformHandler.scale <= ZOOM_THRESHOLD ||
|
||||
!this.game.config().userSettings()?.structureSprites()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
context.drawImage(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import portIcon from "../../../../resources/images/AnchorIcon.png";
|
||||
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
|
||||
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
|
||||
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
|
||||
import portIcon from "../../../../resources/images/PortIcon.svg";
|
||||
import samLauncherIcon from "../../../../resources/images/SamLauncherUnitWhite.png";
|
||||
import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
|
||||
import samLauncherIcon from "../../../../resources/non-commercial/svg/SamLauncherIconWhite.svg";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
|
||||
@@ -545,7 +545,7 @@ export class UnitLayer implements Layer {
|
||||
const targetable = unit.targetable();
|
||||
if (!targetable) {
|
||||
this.context.save();
|
||||
this.context.globalAlpha = 0.4;
|
||||
this.context.globalAlpha = 0.5;
|
||||
}
|
||||
this.context.drawImage(
|
||||
sprite,
|
||||
|
||||
+30
-1
@@ -2,9 +2,37 @@
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>OpenFront (ALPHA)</title>
|
||||
<link rel="manifest" href="../../resources/manifest.json" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="../../resources/images/Favicon.svg"
|
||||
/>
|
||||
|
||||
<!-- SEO -->
|
||||
<link rel="canonical" href="https://openfront.io/" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Conquer the world in this multiplayer battle royale! Expand your nation, eliminate opponents, and dominate the map in this fast-paced IO game."
|
||||
/>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:url" content="https://openfront.io/" />
|
||||
<meta property="og:title" content="OpenFront - Battle Royale" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Conquer the world in this multiplayer battle royale! Expand your nation, eliminate opponents, and dominate the map in this fast-paced IO game."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://openfront.io/resources/images/GameplayScreenshot.png"
|
||||
/>
|
||||
<meta property="og:type" content="game" />
|
||||
|
||||
<!-- Critical CSS to prevent FOUC -->
|
||||
<style>
|
||||
@@ -386,6 +414,7 @@
|
||||
<news-modal></news-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<spawn-ad></spawn-ad>
|
||||
<flag-input-modal></flag-input-modal>
|
||||
<fps-display></fps-display>
|
||||
<div
|
||||
id="language-modal"
|
||||
|
||||
Reference in New Issue
Block a user