UNPKG

@kitten-science/kitten-scientists

Version:

Add-on for the wonderful incremental browser game: https://kittensgame.com/web/

491 lines 20.4 kB
import { isNil } from "@oliversalzburg/js-utils/data/nil.js"; import { redirectErrorsToConsole } from "@oliversalzburg/js-utils/errors/console.js"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; import { de, enUS, he, zhCN } from "date-fns/locale"; import { Engine } from "../Engine.js"; import { KittenScientists } from "../KittenScientists.js"; import { Icons } from "../images/Icons.js"; import { Unique } from "../tools/Entries.js"; import { cerror, cinfo } from "../tools/Log.js"; import { SavegameLoader } from "../tools/SavegameLoader.js"; import { Button } from "./components/Button.js"; import { ButtonListItem } from "./components/ButtonListItem.js"; import { Container } from "./components/Container.js"; import { Delimiter } from "./components/Delimiter.js"; import { HeaderListItem } from "./components/HeaderListItem.js"; import { IconButton } from "./components/IconButton.js"; import { LabelListItem } from "./components/LabelListItem.js"; import stylesLabelListItem from "./components/LabelListItem.module.css"; import { ListItem } from "./components/ListItem.js"; import { SettingListItem } from "./components/SettingListItem.js"; import stylesSettingListItem from "./components/SettingListItem.module.css"; import { SettingsList } from "./components/SettingsList.js"; import { SettingsPanel } from "./components/SettingsPanel.js"; import { TextButton } from "./components/TextButton.js"; import { ToolbarListItem } from "./components/ToolbarListItem.js"; export class StateManagementUi extends SettingsPanel { games = new Array(); /** * The states persisted to local storage. They use Unique<T> so that when we * provide a state to the engine to load or get a state from the engine to * save, we are not accidentally sharing a reference to a live object. */ states = new Array(); gameList; stateList; locale; constructor(host, settings, locale) { const label = host.engine.i18n("state.title"); super(host, settings, new LabelListItem(host, label, { childrenHead: [ new Container(host, { classes: [stylesLabelListItem.fillSpace], }), ], classes: [stylesSettingListItem.checked, stylesSettingListItem.setting], icon: Icons.State, })); this.gameList = new SettingsList(host, { hasEnableAll: false, hasDisableAll: false, }); this.stateList = new SettingsList(host, { hasEnableAll: false, hasDisableAll: false, }); this.locale = locale.selected === "zh-CN" ? zhCN : locale.selected === "he-IL" ? he : locale.selected === "de-DE" ? de : enUS; this.addChild(new SettingsList(host, { children: [ new SettingListItem(host, this.setting.noConfirm, host.engine.i18n("state.noConfirm")), new ListItem(host, { children: [new Delimiter(host)] }), new HeaderListItem(host, host.engine.i18n("state.local")), new ToolbarListItem(host, [ new Button(host, host.engine.i18n("state.import"), Icons.Import, { onClick: () => { this.import(); }, title: host.engine.i18n("state.importTitle"), }), ]), new ListItem(host, { children: [new Delimiter(host)] }), new HeaderListItem(host, host.engine.i18n("state.localStates")), new ToolbarListItem(host, [ new Button(host, host.engine.i18n("state.store"), Icons.SaveAs, { onClick: () => { this.storeState(); }, title: host.engine.i18n("state.storeState"), }), new Button(host, host.engine.i18n("copy"), Icons.Copy, { onClick: () => { this.copyState().catch(redirectErrorsToConsole(console)); host.engine.imessage("state.copied.stateCurrent"); }, title: host.engine.i18n("state.copy.stateCurrent"), }), new Button(host, host.engine.i18n("state.new"), Icons.Draft, { onClick: () => { this.storeStateFactoryDefaults(); host.engine.imessage("state.stored.state"); }, title: host.engine.i18n("state.storeFactory"), }), new Button(host, host.engine.i18n("state.exportAll"), Icons.Sync, { onClick: () => { this.exportStateAll(); }, title: host.engine.i18n("state.exportAllTitle"), }), ]), new ListItem(host, { children: [this.stateList] }), new ListItem(host, { children: [new Delimiter(host)] }), new HeaderListItem(host, host.engine.i18n("state.localGames")), new ToolbarListItem(host, [ new Button(host, host.engine.i18n("state.store"), Icons.SaveAs, { onClick: () => { this.storeGame(); host.engine.imessage("state.stored.game"); }, title: host.engine.i18n("state.storeGame"), }), new Button(host, host.engine.i18n("copy"), Icons.Copy, { onClick: () => { this.copyGame().catch(redirectErrorsToConsole(console)); host.engine.imessage("state.copied.gameCurrent"); }, title: host.engine.i18n("state.copy.gameCurrent"), }), ]), new ListItem(host, { children: [this.gameList] }), new SettingListItem(host, this.setting.compress, host.engine.i18n("state.compress")), ], hasDisableAll: false, hasEnableAll: false, })); this._loadGames(); this._loadStates(); } _loadGames() { let index = 0; let game = localStorage.getItem(`ks.game.${index}`); this.games.splice(0); try { while (!isNil(game)) { const gameObject = JSON.parse(game); this.games.push(new Unique(gameObject)); game = localStorage.getItem(`ks.game.${++index}`); } } catch (error) { cerror(error); } } _storeGames() { let index = 0; let game = localStorage.getItem(`ks.game.${index}`); while (!isNil(game)) { localStorage.removeItem(`ks.game.${index}`); game = localStorage.getItem(`ks.game.${++index}`); } index = 0; for (const game of this.games) { localStorage.setItem(`ks.game.${index++}`, JSON.stringify(game)); } } _loadStates() { let stateIndex = 0; let state = localStorage.getItem(`ks.state.${stateIndex}`); this.states.splice(0); try { while (!isNil(state)) { const stateObject = JSON.parse(state); KittenScientists.unknownAsEngineStateOrThrow(stateObject.state); this.states.push(new Unique(stateObject)); state = localStorage.getItem(`ks.state.${++stateIndex}`); } } catch (error) { cerror(error); } } _storeStates() { let stateIndex = 0; let state = localStorage.getItem(`ks.state.${stateIndex}`); while (!isNil(state)) { localStorage.removeItem(`ks.state.${stateIndex}`); state = localStorage.getItem(`ks.state.${++stateIndex}`); } stateIndex = 0; for (const state of this.states) { localStorage.setItem(`ks.state.${stateIndex++}`, JSON.stringify(state)); } } refreshUi() { super.refreshUi(); this._refreshGameList(); this._refreshStateList(); } _refreshGameList() { this.gameList.removeChildren(this.gameList.children); this.gameList.addChildren(this.games .sort((a, b) => new Date(a.unwrap().timestamp).getTime() - new Date(b.unwrap().timestamp).getTime()) .map(game => [game.unwrap(), game]) .map(([game, gameSlot]) => new ButtonListItem(this._host, new TextButton(this._host, `${game.label} (${formatDistanceToNow(new Date(game.timestamp), { addSuffix: true, locale: this.locale, })})`, { onClick: () => { this.loadGame(game.game).catch(redirectErrorsToConsole(console)); this._host.engine.imessage("state.loaded.game", [game.label]); }, title: new Date(game.timestamp).toLocaleString(), }), { children: [ new Container(this._host, { classes: [stylesLabelListItem.fillSpace] }), new IconButton(this._host, Icons.Save, this._host.engine.i18n("state.update.game"), { onClick: () => { this.updateGame(gameSlot, this._host.game.save()); this._host.engine.imessage("state.updated.game", [game.label]); }, }), new IconButton(this._host, Icons.Edit, this._host.engine.i18n("state.edit.game"), { onClick: () => { this.storeGame(game.game); this.deleteGame(gameSlot, true); this._host.engine.imessage("state.updated.game", [game.label]); }, }), new IconButton(this._host, Icons.Copy, this._host.engine.i18n("state.copy.game"), { onClick: () => { this.copyGame(game.game).catch(redirectErrorsToConsole(console)); this._host.engine.imessage("state.copied.game", [game.label]); }, }), new IconButton(this._host, Icons.Delete, this._host.engine.i18n("state.delete.game"), { onClick: () => { this.deleteGame(gameSlot); this._host.engine.imessage("state.deleted.game", [game.label]); }, }), ], }))); } _refreshStateList() { this.stateList.removeChildren(this.stateList.children); this.stateList.addChildren(this.states .sort((a, b) => new Date(a.unwrap().timestamp).getTime() - new Date(b.unwrap().timestamp).getTime()) .map(stateSlot => [stateSlot.unwrap(), stateSlot]) .map(([state, stateSlot]) => new ButtonListItem(this._host, new TextButton(this._host, `${state.label} (${formatDistanceToNow(new Date(state.timestamp), { addSuffix: true, locale: this.locale, })})`, { onClick: () => { this.loadState(state.state); this._host.engine.imessage("state.loaded.state", [state.label]); }, title: new Date(state.timestamp).toLocaleString(), }), { children: [ new Container(this._host, { classes: [stylesLabelListItem.fillSpace] }), new IconButton(this._host, Icons.Save, this._host.engine.i18n("state.update.state"), { onClick: () => { this.updateState(stateSlot, this._host.engine.stateSerialize()); this._host.engine.imessage("state.updated.state", [state.label]); }, }), new IconButton(this._host, Icons.Edit, this._host.engine.i18n("state.edit.state"), { onClick: () => { this.storeState(state.state); this.deleteState(stateSlot, true); this._host.engine.imessage("state.updated.state", [state.label]); }, }), new IconButton(this._host, Icons.Copy, this._host.engine.i18n("state.copy.state"), { onClick: () => { this.copyState(state.state).catch(redirectErrorsToConsole(console)); this._host.engine.imessage("state.copied.state", [state.label]); }, }), new IconButton(this._host, Icons.Delete, this._host.engine.i18n("state.delete.state"), { onClick: () => { this.deleteState(stateSlot); this._host.engine.imessage("state.deleted.state", [state.label]); }, }), ], }))); } async copyState(state) { await this._host.copySettings(state, false); } async copyGame(game) { const saveData = game ?? this._host.game.save(); const saveDataString = JSON.stringify(saveData); const encodedData = this.setting.compress.enabled ? this._host.game.compressLZData(saveDataString) : saveDataString; await window.navigator.clipboard.writeText(encodedData); } import() { const input = window.prompt(this._host.engine.i18n("state.loadPrompt")); if (isNil(input)) { return; } try { // decodeSettings throws if the input is not a valid engine state. const state = KittenScientists.decodeSettings(input); this.storeState(state); this._host.engine.imessage("state.imported.state"); return; } catch (_error) { // Not a valid Kitten Scientists state. } // Attempt to parse as KG save. let subjectData; try { subjectData = JSON.parse(input); } catch (_error) { /* expected, as we assume compressed input */ const uncompressed = this._host.game.decompressLZData(input); subjectData = JSON.parse(uncompressed); } // If game contains KS settings, import those separately. let stateLabel; if ("ks" in subjectData && !isNil(subjectData.ks)) { const state = subjectData.ks.state[0]; stateLabel = this.storeState(state) ?? undefined; this._host.engine.imessage("state.imported.state"); subjectData.ks = undefined; } this.storeGame(subjectData, stateLabel); this._host.engine.imessage("state.imported.game"); } storeGame(game, label) { let gameLabel = label; if (isNil(gameLabel)) { gameLabel = window.prompt(this._host.engine.i18n("state.storeGame.prompt")) ?? undefined; } if (isNil(gameLabel)) { return null; } // Normalize empty string to "no label". gameLabel = (gameLabel === "" ? undefined : gameLabel) ?? this._host.engine.i18n("state.unlabeledGame"); // Ensure labels aren't excessively long. gameLabel = gameLabel.substring(0, 127); this.games.push(new Unique({ label: gameLabel, game: game ?? this._host.game.save(), timestamp: new Date().toISOString(), })); this._storeGames(); this.refreshUi(); return gameLabel; } storeState(state, label) { let stateLabel = label; if (isNil(stateLabel)) { stateLabel = window.prompt(this._host.engine.i18n("state.storeState.prompt")) ?? undefined; } if (isNil(stateLabel)) { return null; } // Normalize empty string to "no label". stateLabel = (stateLabel === "" ? undefined : stateLabel) ?? this._host.engine.i18n("state.unlabeledState"); // Ensure labels aren't excessively long. stateLabel = stateLabel.substring(0, 127); this.states.push(new Unique({ label: stateLabel, state: state ?? this._host.engine.stateSerialize(), timestamp: new Date().toISOString(), })); this._storeStates(); this.refreshUi(); return stateLabel; } storeStateFactoryDefaults() { this.storeState(Engine.DEFAULT_STATE); } storeAutoSave(state) { const existing = this.states.find(state => state.unwrap().label === "Auto-Save"); if (!isNil(existing)) { cinfo("Updating existing Auto-Save..."); existing.replace({ ...existing.unwrap(), state, timestamp: new Date().toISOString(), }); this._storeStates(); this.refreshUi(); return; } cinfo("Storing new Auto-Save..."); this.storeState(state, "Auto-Save"); } exportStateAll() { const statesJson = this.states .map(state => KittenScientists.encodeSettings(state.unwrap().state, false)) .join("\n"); const a = document.createElement("a"); const blob = new Blob([statesJson], { type: "application/x-ndjson" }); const url = URL.createObjectURL(blob); a.setAttribute("href", url); a.setAttribute("download", `ks-local-states-${new Date().getTime()}.ndjson`); a.click(); } async loadGame(game) { if (this._destructiveActionPrevented()) { return; } await new SavegameLoader(this._host.game).loadRaw(game); } loadState(state) { if (this._destructiveActionPrevented()) { return; } this._host.engine.stateLoad(state, true); this._host.refreshUi(); } loadAutoSave() { if (this._host.engine.isLoaded) { cinfo("Not attempting to load Auto-Save, because a state is already loaded."); return; } const existing = this.states.find(state => state.unwrap().label === "Auto-Save"); if (isNil(existing)) { cinfo("No Auto-Save settings found."); return; } cinfo("Loading Auto-Save..."); this._host.engine.stateLoad(existing.unwrap().state, false); } updateGame(game, newGame) { if (this._destructiveActionPrevented()) { return; } const label = game.unwrap().label; game.replace({ label, game: newGame, timestamp: new Date().toISOString(), }); this._storeGames(); this.refreshUi(); } updateState(state, newState) { if (this._destructiveActionPrevented()) { return; } const label = state.unwrap().label; state.replace({ label, state: newState, timestamp: new Date().toISOString(), }); this._storeStates(); this.refreshUi(); } deleteGame(game, force = false) { if (!force && this._destructiveActionPrevented()) { return; } const index = this.games.indexOf(game); if (index < 0) { return; } this.games.splice(index, 1); this._storeGames(); this.refreshUi(); } deleteState(state, force = false) { if (!force && this._destructiveActionPrevented()) { return; } const index = this.states.indexOf(state); if (index < 0) { return; } this.states.splice(index, 1); this._storeStates(); this.refreshUi(); } _destructiveActionPrevented() { if (!this.setting.noConfirm.enabled && !window.confirm(this._host.engine.i18n("state.confirmDestruction"))) { return true; } return false; } } //# sourceMappingURL=StateManagementUi.js.map