Created
January 31, 2023 19:17
-
-
Save viridia/b0b64123746c95e252baeef11db801c5 to your computer and use it in GitHub Desktop.
Example of Structural Sharing for saved games.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export class SavedGameStore<T extends object> { | |
private prev: { [key: string]: string } = {}; | |
private next: { [key: string]: string } = {}; | |
constructor(private options: ISaveGameOptions = {}) {} | |
/** Return a list of saved games. */ | |
public list(): ISavedGameRoot[] { | |
/** Get all the local storage keys that begin with the word 'save'. */ | |
return Object.keys(localStorage) | |
.filter(key => key.startsWith(SAVE_GAME_PREFIX)) | |
.map(key => { | |
const sg = localStorage.getItem(key); | |
if (sg) { | |
try { | |
const json = JSON.parse(sg) as ISavedGameRootSer; | |
return { ...json, key, time: new Date(json.time) }; | |
} catch (e) { | |
return undefined; | |
} | |
} | |
return undefined; | |
}) | |
.filter(isDefined) | |
.sort(elapsedOrder); | |
} | |
public get<K extends keyof T>(key: K): T[K] | null { | |
return this.getChunk(key as string) as T[K]; | |
} | |
public set<K extends keyof T>(key: K, data: T[K]) { | |
const chunkId = key as string; | |
const prevData = this.getChunk(chunkId); | |
if (!equals(prevData, data)) { | |
const json = JSON.stringify(data); | |
for (let seed = 0; ; seed++) { | |
// Version number is hash of chunk content | |
const chunkKey = `${CHUNK_PREFIX}${chunkId}:${quickHash(json, seed)}`; | |
if (chunkKey in localStorage) { | |
// Collision, try again. | |
continue; | |
} | |
this.next[chunkId] = chunkKey; | |
localStorage.setItem(chunkKey, JSON.stringify(data)); | |
return; | |
} | |
} else { | |
delete this.next[chunkId]; | |
} | |
} | |
/** Load a saved game. This only loads the root index, which updates the chunk id maps. */ | |
public load(saveId: string): ISavedGameRoot | null { | |
invariant(saveId.startsWith(SAVE_GAME_PREFIX)); | |
const sg = localStorage.getItem(saveId); | |
if (sg) { | |
try { | |
const json = JSON.parse(sg) as ISavedGameRootSer; | |
this.prev = json.chunks; | |
this.next = {}; | |
return { ...json, key: saveId, time: new Date(json.time) }; | |
} catch (e) { | |
console.error('Invalid save game format:', saveId); | |
} | |
} | |
return null; | |
} | |
/** Save a saved game. This stores the root index. | |
@param saveName Display name of this save ('Autosave', etc.). | |
@param elapsed Elapsed play time, in seconds. | |
@param character Name of character. | |
@param type Save type - quick save, autosave, etc. | |
*/ | |
public save(saveName: string, elapsed: number, character: string, type: SaveType): string { | |
const sg: ISavedGameRootSer = { | |
name: saveName, | |
type, | |
elapsed, | |
character, | |
time: new Date().toUTCString(), | |
chunks: { ...this.prev, ...this.next }, | |
}; | |
let nextIndex = 0; | |
for (const key of Object.keys(localStorage)) { | |
const m = key.match(/save_(\d+)/); | |
if (m) { | |
nextIndex = Math.max(nextIndex, Number(m[1]) + 1); | |
} | |
} | |
const rootKey = `${SAVE_GAME_PREFIX}${nextIndex}`; | |
localStorage.setItem(rootKey, JSON.stringify(sg)); | |
// localStorage.setItem(LAST_SAVE_GAME_KEY, rootKey); | |
// This save becomes the basis for the next save. | |
this.prev = sg.chunks; | |
this.next = {}; | |
return rootKey; | |
} | |
/** Delete a saved game by id. */ | |
public deleteSave(saveId: string) { | |
localStorage.removeItem(saveId); | |
this.prune(); | |
} | |
/** Remove all save chunks that are no longer referenced. */ | |
public prune(): void { | |
const { maxAutoSaves = MAX_AUTOSAVES, maxQuickSaves = MAX_QUICKSAVES } = this.options; | |
const list = this.list(); | |
// Remove the older autosaves and quicksaves. | |
let numAutosaves = 0; | |
let numQuicksaves = 0; | |
for (let i = 0; i < list.length; ) { | |
const sg = list[i]; | |
if (sg.type === 'auto') { | |
numAutosaves++; | |
if (numAutosaves > maxAutoSaves) { | |
localStorage.removeItem(sg.key); | |
list.splice(i, 1); | |
} else { | |
i++; | |
} | |
} else if (sg.type === 'quick') { | |
numQuicksaves++; | |
if (numQuicksaves > maxQuickSaves) { | |
localStorage.removeItem(sg.key); | |
list.splice(i, 1); | |
} else { | |
i++; | |
} | |
} else { | |
i++; | |
} | |
} | |
// Look for chunks that are no longer referenced. | |
const unusedChunks = new Set( | |
Object.keys(localStorage).filter(key => key.startsWith(CHUNK_PREFIX)) | |
); | |
for (const saveGame of list) { | |
Object.values(saveGame.chunks).forEach(chunkId => unusedChunks.delete(chunkId)); | |
} | |
// Remove chunks from local storage. | |
for (const chunkId of unusedChunks) { | |
localStorage.removeItem(chunkId); | |
} | |
} | |
private getChunk(key: string): T | null { | |
const chunkId = this.next[key] ?? this.prev[key]; | |
if (chunkId) { | |
const sg = localStorage.getItem(chunkId); | |
if (sg) { | |
try { | |
return JSON.parse(sg) as T; | |
} catch (e) { | |
console.error('Invalid chunk format:', chunkId); | |
} | |
} | |
} | |
return null; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment