mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 14:50:44 +00:00
341f344ce5
## Description: Skip slow and blocking LocalStorage reads, replace by a Map. Also some refactoring. ### Contains - No out-of-sync issue between main and worker thread: Earlier PRs got a comment from evan about main & worker.worker thread having their own version of usersettings and possibly getting out-of-sync (see https://github.com/openfrontio/OpenFrontIO/pull/760#pullrequestreview-2845155737, https://github.com/openfrontio/OpenFrontIO/pull/896#pullrequestreview-2871836979 and https://github.com/openfrontio/OpenFrontIO/pull/1266. But userSettings is not used in files ran by worker.worker, not even 10 months after evan's first comment about it. In GameRunner, createGameRunner sends NULL to getConfig as argument for userSettings. And DefaultConfig guards against userSettings being null by throwing an error, but it has never been thrown which points to worker.worker thread not using userSettings. So we do not need to worry about syncing between the threads currently. (If needed in the future after all, we could quite easily sync it, by loading the userSettings cache on worker.worker and listening to the "user-settings-changed" event @scamiv to keep it synced (changes in WorkerMessages and WorkerClient etc would be needed to handle this). - Went with cache in UserSettings, not with listening to "user-settings-changed" event: "user-settings-changed" was added by @scamiv and is used in PerformanceOverlay. Which is great for single files that need the very best performance. But having to add that same system to any file reading settings, scales poorly and would lead to messy code. Also, a developer could make the mistake of not listening to the event and it would end up just reading LocalStorage again just like now. Also a developer might forget removing the listener or so etc. The cache is a central solution and fast, without changes to other files needed and future-proof. - Make sure each setting is cached: UserSettingsModal was using LocalStorage directly by itself for some things. Made it use the central UserSettings methods instead so we avoid LocalStorage reads as much as possible. For this, changed get() and set() in UserSettings to getBool() and setBool(), to introduce a getString() and setString() for use in UserSettingsModal while keeping getCached() and setCached() private within UserSettings. - Remove unused 'focusLocked' and 'toggleFocusLocked' from UserSettings: was last changed 11 months ago to just return false. Since then we've moved to different ways of highlighting and this setting isn't used anymore. No existing references or callers are left. - Other files: -- Have callers call the renamed functions (see point above) -- Remove userSettings from UILayer and Territorylayer: the variable is unused in those files. Also remove from GameRenderer when it calls TerritoryLayer. -- Cache calls to defaultconfig Theme (which in turn calls dark mode setting)/Config better in: GameView and Terrainlayer. ### Update on Contents later on It wasn't really in scope of this PR but further consolidation was called for. These changes could also pave the way for UserSettingsModal (main menu) perhaps being partly mergable with SettingsModal (in-game) one day as it begins to look more like it. Even though UserSettingsModal still does things its own way, and does console.log where SettingsModal doesn't, etc. They both have partially different content and settings but also have a large overlap. - UserSettings: Removed localStorage call from clearFlag() and setFlag() which were added after creation of this PR, and were neatly merged in silence without merge conflicts so i wasn't aware of them yet until now. - UserSettings: added key constants, exported to use both inside UserSettings and in files that listen to its events. - UserSettings 'emitChange': now done from setCached, removed from setBool, setFlag etc. Also removed from the new setFlag. And from setPattern even though it emitted "pattern" instead of key name "territoryPattern"; now it emits the default "territoryPattern" from PATTERN_KEY which is re-used in Store, TerritoryPatternsModal and PatternInput. - UserSettingsModal: made UserSettingsModal call existing toggle functions in UserSettings, or new or existing getter or setter. We do not need CustomEvent: checked anymore. In UserSettingsModal, its toggle functions did not all actually toggle, some like toggleLeftClickOpensMenu actually just set a value. Based on the 'checked' value of the CustomEvent. But we don't need that 'checked' value anymore and none of the checks for it inside the toggle functions in UserSettingsModal, now that we just directly call toggleLeftClickOpensMenu and others in UserSettings. - SettingToggle: continuing about not needing CustomEvent anymore: the old way actually fired two events. The native change event from <input> and our own CustomEvent from handleChange in SettingToggle. It prevented handling both events by checking e.detail?.checked === undefined. But now, the native <input> event is all we need to show the visual toggle change and trigger @changed in UserSettingsModal which calls the toggle function. - Use the toggle functions too from CopyButton and PerformanceOverlay.ts. In PerformanceOverlay, change in onUserSettingsChanged was needed because of how setBool works. - UserSettingsModal 'toggleDarkMode': in UserSettingsModal, removed the event from toggleDarkMode in UserSettingsModal; nothing is listening to this event anymore after DarkModeButton.ts was removed some time ago. Also both UserSettingsModal an UserSettings added/removed "dark" from the document element. Now that UserSettingsModal calls toggleDarkMode in UserSettings, we could centralize that. But UserSettings is in core, not in client like UserSettingsModal. But now that we emit "user-settings-changed", we could handle it even more centralized and not have UserSettingsModal or UserSettings touch the element directly. Instead have Main.ts listen to the event and change it dark mode from there. - UserSettings: added claryfing comment to attackRatioIncrement and the new attackRatio setters/getters, to explain their difference. Noticed a small omitment in its description and fixed that right away in en.json: you can change attack ratio increment by shift+mouse wheel scroll or by hotkey. So made "How much the attack ratio keybinds change per press" also mention "/scroll." **BEFORE** (with getDisplayName added back to NameLayer as a fix i will do soon) get > getItem in UserSettings  renderLayer in NameLayer (with getDisplayName added back to NameLayer as a fix i will do soon)  **AFTER** (with getDisplayName added back to NameLayer as a fix i will do soon) getCached in UserSettings  renderLayer in NameLayer (with getDisplayName added back to NameLayer as a fix i will do soon)  ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33
311 lines
9.3 KiB
TypeScript
311 lines
9.3 KiB
TypeScript
import type { TemplateResult } from "lit";
|
|
import { html } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { UserMeResponse } from "../core/ApiSchemas";
|
|
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
|
import {
|
|
PATTERN_KEY,
|
|
USER_SETTINGS_CHANGED_EVENT,
|
|
UserSettings,
|
|
} from "../core/game/UserSettings";
|
|
import { PlayerPattern } from "../core/Schemas";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/FlagButton";
|
|
import "./components/NotLoggedInWarning";
|
|
import "./components/PatternButton";
|
|
import { modalHeader } from "./components/ui/ModalHeader";
|
|
import {
|
|
fetchCosmetics,
|
|
flagRelationship,
|
|
getPlayerCosmetics,
|
|
handlePurchase,
|
|
patternRelationship,
|
|
} from "./Cosmetics";
|
|
import { translateText } from "./Utils";
|
|
|
|
@customElement("store-modal")
|
|
export class StoreModal extends BaseModal {
|
|
@state() private selectedPattern: PlayerPattern | null;
|
|
@state() private selectedColor: string | null = null;
|
|
@state() private activeTab: "patterns" | "flags" = "patterns";
|
|
|
|
private cosmetics: Cosmetics | null = null;
|
|
private userSettings: UserSettings = new UserSettings();
|
|
private isActive = false;
|
|
private affiliateCode: string | null = null;
|
|
private userMeResponse: UserMeResponse | false = false;
|
|
|
|
private _onPatternSelected = async () => {
|
|
await this.updateFromSettings();
|
|
this.refresh();
|
|
};
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
document.addEventListener(
|
|
"userMeResponse",
|
|
(event: CustomEvent<UserMeResponse | false>) => {
|
|
this.onUserMe(event.detail);
|
|
},
|
|
);
|
|
window.addEventListener(
|
|
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
|
|
this._onPatternSelected,
|
|
);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
window.removeEventListener(
|
|
`${USER_SETTINGS_CHANGED_EVENT}:${PATTERN_KEY}`,
|
|
this._onPatternSelected,
|
|
);
|
|
}
|
|
|
|
private async updateFromSettings() {
|
|
const cosmetics = await getPlayerCosmetics();
|
|
this.selectedPattern = cosmetics.pattern ?? null;
|
|
this.selectedColor = cosmetics.color?.color ?? null;
|
|
}
|
|
|
|
async onUserMe(userMeResponse: UserMeResponse | false) {
|
|
this.userMeResponse = userMeResponse;
|
|
this.cosmetics = await fetchCosmetics();
|
|
await this.updateFromSettings();
|
|
this.refresh();
|
|
}
|
|
|
|
private renderHeader(): TemplateResult {
|
|
return html`
|
|
${modalHeader({
|
|
title: translateText("store.title"),
|
|
onBack: () => this.close(),
|
|
ariaLabel: translateText("common.back"),
|
|
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
|
})}
|
|
<div class="flex items-center gap-2 justify-center pt-2">
|
|
<button
|
|
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
|
.activeTab === "patterns"
|
|
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
|
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
|
@click=${() => (this.activeTab = "patterns")}
|
|
>
|
|
${translateText("store.patterns")}
|
|
</button>
|
|
<button
|
|
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
|
.activeTab === "flags"
|
|
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
|
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
|
@click=${() => (this.activeTab = "flags")}
|
|
>
|
|
${translateText("store.flags")}
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPatternGrid(): TemplateResult {
|
|
const buttons: TemplateResult[] = [];
|
|
const patterns: (Pattern | null)[] = [
|
|
null,
|
|
...Object.values(this.cosmetics?.patterns ?? {}),
|
|
];
|
|
for (const pattern of patterns) {
|
|
const colorPalettes = pattern
|
|
? [...(pattern.colorPalettes ?? []), null]
|
|
: [null];
|
|
for (const colorPalette of colorPalettes) {
|
|
let rel = "owned";
|
|
if (pattern) {
|
|
rel = patternRelationship(
|
|
pattern,
|
|
colorPalette,
|
|
this.userMeResponse,
|
|
this.affiliateCode,
|
|
);
|
|
}
|
|
if (rel === "blocked" || rel === "owned") {
|
|
continue;
|
|
}
|
|
const isDefaultPattern = pattern === null;
|
|
const isSelected =
|
|
(isDefaultPattern && this.selectedPattern === null) ||
|
|
(!isDefaultPattern &&
|
|
this.selectedPattern &&
|
|
this.selectedPattern.name === pattern?.name &&
|
|
(this.selectedPattern.colorPalette?.name ?? null) ===
|
|
(colorPalette?.name ?? null));
|
|
buttons.push(html`
|
|
<pattern-button
|
|
.pattern=${pattern}
|
|
.colorPalette=${this.cosmetics?.colorPalettes?.[
|
|
colorPalette?.name ?? ""
|
|
] ?? null}
|
|
.requiresPurchase=${rel === "purchasable"}
|
|
.selected=${isSelected}
|
|
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
|
|
.onPurchase=${(p: Pattern, cp: ColorPalette | null) =>
|
|
handlePurchase(p.product!, cp?.name)}
|
|
></pattern-button>
|
|
`);
|
|
}
|
|
}
|
|
|
|
if (buttons.length === 0) {
|
|
return html`<div
|
|
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
|
>
|
|
${translateText("store.no_skins")}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
|
>
|
|
${buttons}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderFlagGrid(): TemplateResult {
|
|
const buttons: TemplateResult[] = [];
|
|
const flags = Object.entries(this.cosmetics?.flags ?? {});
|
|
for (const [key, flag] of flags) {
|
|
const rel = flagRelationship(
|
|
flag,
|
|
this.userMeResponse,
|
|
this.affiliateCode,
|
|
);
|
|
if (rel === "blocked" || rel === "owned") continue;
|
|
const selectedFlag = new UserSettings().getFlag() ?? "";
|
|
buttons.push(html`
|
|
<flag-button
|
|
.flag=${{ ...flag, key: `flag:${key}` }}
|
|
.selected=${selectedFlag === `flag:${key}`}
|
|
.requiresPurchase=${rel === "purchasable"}
|
|
.onPurchase=${() => handlePurchase(flag.product!)}
|
|
></flag-button>
|
|
`);
|
|
}
|
|
|
|
if (buttons.length === 0) {
|
|
return html`<div
|
|
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
|
>
|
|
${translateText("store.no_flags")}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
|
>
|
|
${buttons}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
if (!this.isActive && !this.inline) return html``;
|
|
|
|
const content = html`
|
|
<div class="${this.modalContainerClass}">
|
|
${this.renderHeader()}
|
|
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
|
${this.activeTab === "patterns"
|
|
? this.renderPatternGrid()
|
|
: this.renderFlagGrid()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
if (this.inline) {
|
|
return content;
|
|
}
|
|
|
|
return html`
|
|
<o-modal
|
|
id="storeModal"
|
|
title="${translateText("store.title")}"
|
|
?inline=${this.inline}
|
|
?hideHeader=${true}
|
|
?hideCloseButton=${true}
|
|
>
|
|
${content}
|
|
</o-modal>
|
|
`;
|
|
}
|
|
|
|
public async open(options?: string | { affiliateCode?: string }) {
|
|
if (this.isModalOpen) return;
|
|
this.isActive = true;
|
|
if (typeof options === "string") {
|
|
this.affiliateCode = options;
|
|
} else if (
|
|
options !== null &&
|
|
typeof options === "object" &&
|
|
!Array.isArray(options)
|
|
) {
|
|
this.affiliateCode = options.affiliateCode ?? null;
|
|
} else {
|
|
this.affiliateCode = null;
|
|
}
|
|
|
|
this.cosmetics ??= await fetchCosmetics();
|
|
await this.refresh();
|
|
super.open();
|
|
}
|
|
|
|
public close() {
|
|
this.isActive = false;
|
|
this.affiliateCode = null;
|
|
super.close();
|
|
}
|
|
|
|
private selectPattern(pattern: PlayerPattern | null) {
|
|
this.selectedColor = null;
|
|
this.userSettings.setSelectedColor(undefined);
|
|
if (pattern === null) {
|
|
this.userSettings.setSelectedPatternName(undefined);
|
|
} else {
|
|
const name =
|
|
pattern.colorPalette?.name === undefined
|
|
? pattern.name
|
|
: `${pattern.name}:${pattern.colorPalette.name}`;
|
|
this.userSettings.setSelectedPatternName(`pattern:${name}`);
|
|
}
|
|
this.selectedPattern = pattern;
|
|
this.refresh();
|
|
this.showSelectedPopup(pattern);
|
|
this.close();
|
|
}
|
|
|
|
private showSelectedPopup(pattern: PlayerPattern | null) {
|
|
let skinName = translateText("territory_patterns.pattern.default");
|
|
if (pattern && pattern.name) {
|
|
skinName = pattern.name
|
|
.split("_")
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ");
|
|
if (pattern.colorPalette && pattern.colorPalette.name) {
|
|
skinName += ` (${pattern.colorPalette.name})`;
|
|
}
|
|
}
|
|
window.dispatchEvent(
|
|
new CustomEvent("show-message", {
|
|
detail: {
|
|
message: `${skinName} ${translateText("territory_patterns.selected")}`,
|
|
duration: 2000,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
public async refresh() {
|
|
this.requestUpdate();
|
|
}
|
|
}
|