diff --git a/src/features/resources/resource.ts b/src/features/resources/resource.ts index ff78ba4..2b47cde 100644 --- a/src/features/resources/resource.ts +++ b/src/features/resources/resource.ts @@ -1,5 +1,5 @@ import { globalBus } from "game/events"; -import type { State } from "game/persistence"; +import { NonPersistent, Persistent, State } from "game/persistence"; import { persistent } from "game/persistence"; import type { DecimalSource } from "util/bignum"; import Decimal, { format, formatWhole } from "util/bignum"; @@ -13,18 +13,38 @@ export interface Resource extends Ref { small?: boolean; } +export function createResource( + defaultValue: T, + displayName?: string, + precision?: number, + small?: boolean | undefined +): Resource & Persistent & { [NonPersistent]: Resource }; +export function createResource( + defaultValue: Ref, + displayName?: string, + precision?: number, + small?: boolean | undefined +): Resource; export function createResource( defaultValue: T | Ref, displayName = "points", precision = 0, - small = undefined -): Resource { + small: boolean | undefined = undefined +) { const resource: Partial> = isRef(defaultValue) ? defaultValue : persistent(defaultValue); resource.displayName = displayName; resource.precision = precision; resource.small = small; + if (!isRef(defaultValue)) { + const nonPersistentResource = (resource as Persistent)[ + NonPersistent + ] as unknown as Resource; + nonPersistentResource.displayName = displayName; + nonPersistentResource.precision = precision; + nonPersistentResource.small = small; + } return resource as Resource; } diff --git a/src/game/events.ts b/src/game/events.ts index 4543e15..cf7ae65 100644 --- a/src/game/events.ts +++ b/src/game/events.ts @@ -1,12 +1,6 @@ -import projInfo from "data/projInfo.json"; -import player from "game/player"; import type { Settings } from "game/settings"; -import settings from "game/settings"; -import state from "game/state"; import { createNanoEvents } from "nanoevents"; -import Decimal from "util/bignum"; -import type { App, Ref } from "vue"; -import { watch } from "vue"; +import type { App } from "vue"; import type { GenericLayer } from "./layers"; /** All types of events able to be sent or emitted from the global event bus. */ @@ -60,102 +54,4 @@ export interface GlobalEvents { /** A global event bus for hooking into {@link GlobalEvents}. */ export const globalBus = createNanoEvents(); -let intervalID: NodeJS.Timer | null = null; - -// Not imported immediately due to dependency cycles -// This gets set during startGameLoop(), and will only be used in the update function -let hasWon: null | Ref = null; - -function update() { - const now = Date.now(); - let diff = (now - player.time) / 1e3; - player.time = now; - const trueDiff = diff; - - state.lastTenTicks.push(trueDiff); - if (state.lastTenTicks.length > 10) { - state.lastTenTicks = state.lastTenTicks.slice(1); - } - - // Stop here if the game is paused on the win screen - if (hasWon?.value && !player.keepGoing) { - return; - } - // Stop here if the player had a NaN value - if (state.hasNaN) { - return; - } - - diff = Math.max(diff, 0); - - if (player.devSpeed === 0) { - return; - } - - // Add offline time if any - if (player.offlineTime != undefined) { - if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) { - player.offlineTime = projInfo.offlineLimit * 3600; - } - if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) { - const offlineDiff = Math.max(player.offlineTime / 10, diff); - player.offlineTime = player.offlineTime - offlineDiff; - diff += offlineDiff; - } else if (player.devSpeed === 0) { - player.offlineTime += diff; - } - if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) { - player.offlineTime = null; - } - } - - // Cap at max tick length - diff = Math.min(diff, projInfo.maxTickLength); - - // Apply dev speed - if (player.devSpeed != undefined) { - diff *= player.devSpeed; - } - - if (!Number.isFinite(diff)) { - diff = 1e308; - } - - // Update - if (Decimal.eq(diff, 0)) { - return; - } - - player.timePlayed += diff; - if (!Number.isFinite(player.timePlayed)) { - player.timePlayed = 1e308; - } - globalBus.emit("update", diff, trueDiff); - - if (settings.unthrottled) { - requestAnimationFrame(update); - if (intervalID != null) { - clearInterval(intervalID); - intervalID = null; - } - } else if (intervalID == null) { - intervalID = setInterval(update, 50); - } -} - -/** Starts the game loop for the project, which updates the game in ticks. */ -export async function startGameLoop() { - hasWon = (await import("data/projEntry")).hasWon; - watch(hasWon, hasWon => { - if (hasWon) { - globalBus.emit("gameWon"); - } - }); - if (settings.unthrottled) { - requestAnimationFrame(update); - } else { - intervalID = setInterval(update, 50); - } -} - document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded"); diff --git a/src/game/gameLoop.ts b/src/game/gameLoop.ts new file mode 100644 index 0000000..dc3ca87 --- /dev/null +++ b/src/game/gameLoop.ts @@ -0,0 +1,106 @@ +import projInfo from "data/projInfo.json"; +import { globalBus } from "game/events"; +import settings from "game/settings"; +import Decimal from "util/bignum"; +import type { Ref } from "vue"; +import { watch } from "vue"; +import player from "./player"; +import state from "./state"; + +let intervalID: NodeJS.Timer | null = null; + +// Not imported immediately due to dependency cycles +// This gets set during startGameLoop(), and will only be used in the update function +let hasWon: null | Ref = null; + +function update() { + const now = Date.now(); + let diff = (now - player.time) / 1e3; + player.time = now; + const trueDiff = diff; + + state.lastTenTicks.push(trueDiff); + if (state.lastTenTicks.length > 10) { + state.lastTenTicks = state.lastTenTicks.slice(1); + } + + // Stop here if the game is paused on the win screen + if (hasWon?.value && !player.keepGoing) { + return; + } + // Stop here if the player had a NaN value + if (state.hasNaN) { + return; + } + + diff = Math.max(diff, 0); + + if (player.devSpeed === 0) { + return; + } + + // Add offline time if any + if (player.offlineTime != undefined) { + if (Decimal.gt(player.offlineTime, projInfo.offlineLimit * 3600)) { + player.offlineTime = projInfo.offlineLimit * 3600; + } + if (Decimal.gt(player.offlineTime, 0) && player.devSpeed !== 0) { + const offlineDiff = Math.max(player.offlineTime / 10, diff); + player.offlineTime = player.offlineTime - offlineDiff; + diff += offlineDiff; + } else if (player.devSpeed === 0) { + player.offlineTime += diff; + } + if (!player.offlineProd || Decimal.lt(player.offlineTime, 0)) { + player.offlineTime = null; + } + } + + // Cap at max tick length + diff = Math.min(diff, projInfo.maxTickLength); + + // Apply dev speed + if (player.devSpeed != undefined) { + diff *= player.devSpeed; + } + + if (!Number.isFinite(diff)) { + diff = 1e308; + } + + // Update + if (Decimal.eq(diff, 0)) { + return; + } + + player.timePlayed += diff; + if (!Number.isFinite(player.timePlayed)) { + player.timePlayed = 1e308; + } + globalBus.emit("update", diff, trueDiff); + + if (settings.unthrottled) { + requestAnimationFrame(update); + if (intervalID != null) { + clearInterval(intervalID); + intervalID = null; + } + } else if (intervalID == null) { + intervalID = setInterval(update, 50); + } +} + +/** Starts the game loop for the project, which updates the game in ticks. */ +export async function startGameLoop() { + hasWon = (await import("data/projEntry")).hasWon; + watch(hasWon, hasWon => { + if (hasWon) { + globalBus.emit("gameWon"); + } + }); + if (settings.unthrottled) { + requestAnimationFrame(update); + } else { + intervalID = setInterval(update, 50); + } +} diff --git a/src/game/persistence.ts b/src/game/persistence.ts index b871fbf..b063e2c 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -1,3 +1,4 @@ +import { identifier } from "@babel/types"; import { isArray } from "@vue/shared"; import { globalBus } from "game/events"; import type { GenericLayer } from "game/layers"; @@ -5,8 +6,8 @@ import { addingLayers, persistentRefs } from "game/layers"; import type { DecimalSource } from "util/bignum"; import Decimal from "util/bignum"; import { ProxyState } from "util/proxies"; -import type { Ref } from "vue"; -import { isReactive, isRef, ref } from "vue"; +import type { Ref, WritableComputedRef } from "vue"; +import { computed, isReactive, isRef, ref } from "vue"; /** * A symbol used in {@link Persistent} objects. @@ -28,6 +29,16 @@ export const StackTrace = Symbol("StackTrace"); * @see {@link Persistent[Deleted]} */ export const Deleted = Symbol("Deleted"); +/** + * A symbol used in {@link Persistent} objects. + * @see {@link Persistent[NonPersistent]} + */ +export const NonPersistent = Symbol("NonPersistent"); +/** + * A symbol used in {@link Persistent} objects. + * @see {@link Persistent[SaveDataPath]} + */ +export const SaveDataPath = Symbol("SaveDataPath"); /** * This is a union of things that should be safely stringifiable without needing special processes or knowing what to load them in as. @@ -57,6 +68,14 @@ export type Persistent = Ref & { * @see {@link deletePersistent} for marking a persistent ref as deleted. */ [Deleted]: boolean; + /** + * A non-persistent ref that just reads and writes ot the persistent ref. Used for passing to other features without duplicating the persistent ref in the constructed save data object. + */ + [NonPersistent]: WritableComputedRef; + /** + * The path this persistent appears in within the save data object. Predominantly used to ensure it's only placed in there one time. + */ + [SaveDataPath]: string[] | undefined; }; function getStackTrace() { @@ -83,6 +102,15 @@ export function persistent(defaultValue: T | Ref): Persisten persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue; persistent[StackTrace] = getStackTrace(); persistent[Deleted] = false; + persistent[NonPersistent] = computed({ + get() { + return persistent.value; + }, + set(value) { + persistent.value = value; + } + }); + persistent[SaveDataPath] = undefined; if (addingLayers.length === 0) { console.warn( @@ -97,6 +125,25 @@ export function persistent(defaultValue: T | Ref): Persisten return persistent as Persistent; } +/** + * Type guard for whether an arbitrary value is a persistent ref + * @param value The value that may or may not be a persistent ref + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isPersistent(value: any): value is Persistent { + return value && typeof value === "object" && PersistentState in value; +} + +/** + * Unwraps the non-persistent ref inside of persistent refs, to be passed to other features without duplicating values in the save data object. + * @param persistent The persistent ref to unwrap + */ +export function noPersist, S extends State>( + persistent: T +): T[typeof NonPersistent] { + return persistent[NonPersistent]; +} + /** * Mark a {@link Persistent} as deleted, so it won't be saved and loaded. * Since persistent refs must be created during a layer's options func, features can not create persistent refs after evaluating their own options funcs. @@ -117,24 +164,40 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record const handleObject = (obj: Record, path: string[] = []): boolean => { let foundPersistent = false; Object.keys(obj).forEach(key => { - const value = obj[key]; + let value = obj[key]; if (value && typeof value === "object") { - if (PersistentState in value) { + if (ProxyState in value) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value = (value as any)[ProxyState] as object; + } + if (isPersistent(value)) { foundPersistent = true; - if ((value as Persistent)[Deleted]) { + if (value[Deleted]) { console.warn( "Deleted persistent ref present in returned object. Ignoring...", value, - "\nCreated at:\n" + (value as Persistent)[StackTrace] + "\nCreated at:\n" + value[StackTrace] ); return; } - persistentRefs[layer.id].delete( - ProxyState in value - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - ((value as any)[ProxyState] as Persistent) - : (value as Persistent) - ); + persistentRefs[layer.id].delete(value); + + // Handle SaveDataPath + const newPath = [layer.id, ...path, key]; + if ( + value[SaveDataPath] != undefined && + JSON.stringify(newPath) !== JSON.stringify(value[SaveDataPath]) + ) { + console.error( + `Persistent ref is being saved to \`${newPath.join( + "." + )}\` when it's already present at \`${value[SaveDataPath].join( + "." + )}\`. This can cause unexpected behavior when loading saves between updates.`, + value + ); + } + value[SaveDataPath] = newPath; // Construct save path if it doesn't exist const persistentState = path.reduce>((acc, curr) => { @@ -147,21 +210,19 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record // Cache currently saved value const savedValue = persistentState[key]; // Add ref to save data - persistentState[key] = (value as Persistent)[PersistentState]; + persistentState[key] = value[PersistentState]; // Load previously saved value if (isReactive(persistentState)) { if (savedValue != null) { persistentState[key] = savedValue; } else { - persistentState[key] = (value as Persistent)[DefaultValue]; + persistentState[key] = value[DefaultValue]; } } else { if (savedValue != null) { (persistentState[key] as Ref).value = savedValue; } else { - (persistentState[key] as Ref).value = (value as Persistent)[ - DefaultValue - ]; + (persistentState[key] as Ref).value = value[DefaultValue]; } } } else if ( @@ -200,7 +261,8 @@ globalBus.on("addLayer", (layer: GenericLayer, saveData: Record }); return foundPersistent; }; - handleObject(layer); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handleObject((layer as any)[ProxyState]); persistentRefs[layer.id].forEach(persistent => { console.error( `Created persistent ref in ${layer.id} without registering it to the layer! Make sure to include everything persistent in the returned object`, diff --git a/src/main.ts b/src/main.ts index 2d27dcc..503b599 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,7 +36,8 @@ requestAnimationFrame(async () => { "padding: 4px;" ); await load(); - const { globalBus, startGameLoop } = await import("./game/events"); + const { globalBus } = await import("./game/events"); + const { startGameLoop } = await import("./game/gameLoop"); // Create Vue const vue = (window.vue = createApp(App)); diff --git a/src/util/proxies.ts b/src/util/proxies.ts index a41b8d1..a855dee 100644 --- a/src/util/proxies.ts +++ b/src/util/proxies.ts @@ -1,3 +1,4 @@ +import { NonPersistent } from "game/persistence"; import Decimal from "util/bignum"; export const ProxyState = Symbol("ProxyState"); @@ -37,7 +38,11 @@ export function createLazyProxy( return calculateObj(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (calculateObj() as any)[key]; + const val = (calculateObj() as any)[key]; + if (val && typeof val === "object" && NonPersistent in val) { + return val[NonPersistent]; + } + return val; }, set(target, key, value) { // TODO give warning about this? It should only be done with caution