Merge branch 'main' into icslucas-patch-1

This commit is contained in:
Drills Kibo
2025-08-05 04:53:42 +02:00
committed by GitHub
94 changed files with 4075 additions and 1030 deletions
+73
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
+105
View File
@@ -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();
}
};
}
+8
View File
@@ -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>
+23
View File
@@ -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;
}
+16 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+13 -12
View File
@@ -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 };
}
+18
View File
@@ -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
View File
@@ -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();
}
+17 -1
View File
@@ -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}`
+4 -10
View File
@@ -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) {
+105 -48
View File
@@ -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}"
+210 -94
View File
@@ -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);
}
+5 -2
View File
@@ -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(
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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
View File
@@ -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"