import { Util } from "./util" ; import { executeOnWorker } from "./worker" ; /** name, time, scoreId, timestamp */ export type BestTimes = [ string , number , string , number ] [ ] ; const MAX_SCORE_TIME = ( 99 * 60 + 59 ) * 1000 + 999.99 ; export interface StorageData { settings : { resolution : number ; videoDriver : number ; screenStyle : number ; colorDepth : number ; shadows : boolean ; musicVolume : number ; soundVolume : number ; gameButtonMapping : { "up" : string ; "down" : string ; "left" : string ; "right" : string ; "jump" : string ; "use" : string ; "cameraUp" : string ; "cameraDown" : string ; "cameraLeft" : string ; "cameraRight" : string ; "freeLook" : string ; "restart" : string ; "blast" : string ; } ; mouseSensitivity : number ; keyboardSensitivity : number ; invertMouse : number ; alwaysFreeLook : boolean ; marbleReflectivity : number ; showFrameRate : boolean ; showThousandths : boolean ; fov : number ; fancyShaders : boolean ; /** 0: Max 0.5, 1: Max 1.0, 2: Max 1.5, 3: Max 2.0, 4: Max Infinity */ pixelRatio : number ; inputType : number ; frameRateCap : number ; canvasDesynchronized : boolean ; joystickPosition : number ; joystickSize : number ; joystickLeftOffset : number ; joystickVerticalPosition : number ; actionButtonOrder : number ; actionButtonSize : number ; actionButtonRightOffset : number ; actionButtonBottomOffset : number ; actionButtonAsJoystickMultiplier : number ; } ; bestTimes : Record < string , BestTimes > ; /** Used for the name entry in the post-game screen. */ lastUsedName : string ; /** A random ID to somewhat uniquely identify this user, even if they change their username. */ randomId : string ; /** The queue of scores that are still to be sent to the server. */ bestTimeSubmissionQueue : Record < string , BestTimes [ number ] > ; /** The last-seen version of the game. */ lastSeenVersion : string ; /** Mission paths whose eggs have been collected. */ collectedEggs : string [ ] ; /** Which modification was last used. */ modification : 'gold' | 'platinum' ; } const DEFAULT_STORAGE_DATA = { settings : { resolution : 2 , videoDriver : 0 , screenStyle : 0 , colorDepth : 1 , shadows : false , musicVolume : 0.5 , soundVolume : 0.7 , gameButtonMapping : { "up" : "KeyW" , "down" : "KeyS" , "left" : "KeyA" , "right" : "KeyD" , "jump" : "Space" , "use" : "LMB" , "cameraUp" : "ArrowUp" , "cameraDown" : "ArrowDown" , "cameraLeft" : "ArrowLeft" , "cameraRight" : "ArrowRight" , "freeLook" : "RMB" , "restart" : "KeyR" , "blast" : "KeyE" } , mouseSensitivity : 0.2 , keyboardSensitivity : 0.1 , invertMouse : 0 , alwaysFreeLook : true , marbleReflectivity : 0 , showFrameRate : true , showThousandths : true , fov : 60 , fancyShaders : true , pixelRatio : 2 , inputType : 0 , frameRateCap : 7 , canvasDesynchronized : false , joystickPosition : 0 , joystickSize : 250 , joystickLeftOffset : 75 , joystickVerticalPosition : 0.5 , actionButtonOrder : 0 , actionButtonSize : 120 , actionButtonRightOffset : 30 , actionButtonBottomOffset : 30 , actionButtonAsJoystickMultiplier : 1.5 } , bestTimes : { } , lastUsedName : '' , randomId : Util . getRandomId ( ) , bestTimeSubmissionQueue : { } , lastSeenVersion : null , collectedEggs : [ ] , modification : 'platinum' } ; const VERSION_UPGRADE_PROCEDURES = { '2.1.5' : async ( ) => { StorageManager . data . settings . marbleReflectivity = 0 ; await StorageManager . store ( ) ; } , '2.3.0' : async ( ) => { StorageManager . data . settings . marbleReflectivity = 0 ; await StorageManager . store ( ) ; } } ; /** Manages storage and persistence. */ export abstract class StorageManager { static data : StorageData ; static idbDatabase : Promise < IDBDatabase > ; static async init ( ) { this . idbDatabase = new Promise ( ( resolve ) => { let request = indexedDB . open ( "mb-database" , 3 ) ; request . onsuccess = ( e ) => { resolve ( ( e . target as any ) . result ) ; } ; request . onupgradeneeded = ( e ) => { let db = ( e . target as any ) . result as IDBDatabase ; let transaction = ( e . target as any ) . transaction as IDBTransaction ; try { db . createObjectStore ( 'replays' , { } ) ; } catch ( error ) { transaction . objectStore ( 'replays' ) ; } try { db . createObjectStore ( 'keyvalue' , { } ) ; } catch ( error ) { transaction . objectStore ( 'keyvalue' ) ; } } ; } ) ; if ( localStorage . getItem ( 'mb-storage' ) ) await this . migrate ( ) ; let storageData = await this . databaseGet ( 'keyvalue' , 'storageData' ) ; if ( storageData ) { this . data = this . correctFields ( DEFAULT_STORAGE_DATA , storageData ) ; } else { this . data = DEFAULT_STORAGE_DATA ; } if ( this . data . settings . inputType === 1 ) Util . isTouchDevice = false ; else if ( this . data . settings . inputType === 2 ) Util . isTouchDevice = true ; this . data . bestTimes = { } ; let compressedBestTimes = await this . databaseGet ( 'keyvalue' , 'bestTimes' ) ; if ( compressedBestTimes ) { try { let uncompressed = pako . inflate ( compressedBestTimes , { to : 'string' } ) ; let json = JSON . parse ( uncompressed ) ; this . data . bestTimes = json ; } catch ( e ) { console . error ( "Error decoding best times!" , e ) ; } } let changed = false ; for ( let missionPath in this . data . bestTimes ) { if ( ! missionPath . startsWith ( 'mbu/' ) ) continue ; for ( let i = 0 ; i < this . data . bestTimes [ missionPath ] . length ; i ++ ) { let score = this . data . bestTimes [ missionPath ] [ i ] ; if ( score [ 3 ] < 1634861292099 ) { this . data . bestTimes [ missionPath ] . splice ( i -- , 1 ) ; changed = true ; } } if ( this . data . bestTimes [ missionPath ] . length === 0 ) delete this . data . bestTimes [ missionPath ] ; } if ( changed ) await this . storeBestTimes ( ) ; Util . getDefaultSecondsToTimeStringDecimalDigits = ( ) => this . data . settings . showThousandths ? 3 : 2 ; } /** Migrates from localStorage to IndexedDB. */ static async migrate ( ) { let stored = JSON . parse ( localStorage . getItem ( 'mb-storage' ) ) as StorageData ; this . data = stored ; for ( let key in stored . bestTimes ) { for ( let bestTime of stored . bestTimes [ key ] ) { bestTime [ 3 ] = 0 ; } } await this . store ( ) ; await this . storeBestTimes ( ) ; localStorage . removeItem ( 'mb-storage' ) ; } static async store ( ) { let obj = Util . shallowClone ( this . data ) ; delete obj . bestTimes ; await this . databasePut ( 'keyvalue' , obj , 'storageData' ) ; } static async storeBestTimes ( ) { let string = JSON . stringify ( this . data . bestTimes ) ; let compressed = await executeOnWorker ( 'compress' , string ) as string ; await this . databasePut ( 'keyvalue' , compressed , 'bestTimes' ) ; } /** Get the three best times for a mission path. */ static getBestTimesForMission ( path , count , placeholderName ) { let result = [ ] ; let stored = this . data . bestTimes [ path ] ; if ( stored ) { result . push ( ... stored ) ; } result . sort ( ( a , b ) => a [ 1 ] - b [ 1 ] ) ; let remaining = count - result . length ; for ( let i = 0 ; i < remaining ; i ++ ) { result . push ( [ placeholderName , MAX_SCORE_TIME , "" , 0 ] ) ; } return result ; } static maxScoresPerLevel = 5 ; returns static insertNewTime ( path , name , time ) { let stored = this . data . bestTimes [ path ] ?? [ ] ; let scoreId = Util . getRandomId ( ) ; let toInsert = [ name , time , scoreId , Date . now ( ) ] ; let index ; for ( index = 0 ; index < stored . length ; index ++ ) { if ( stored [ index ] [ 1 ] > time ) break ; } stored . splice ( index , 0 , toInsert ) ; if ( stored . length > this . maxScoresPerLevel ) { let lost = stored [ this . maxScoresPerLevel ] ; stored = stored . slice ( 0 , this . maxScoresPerLevel ) ; if ( lost [ 2 ] ) { this . databaseGet ( 'replays' , lost [ 2 ] ) . then ( replayData => { if ( ! replayData ) return ; this . databaseDelete ( 'replays' , lost [ 2 ] ) ; } ) ; } } this . data . bestTimes [ path ] = stored ; this . storeBestTimes ( ) ; if ( index === this . maxScoresPerLevel ) return null ; return { index , score : toInsert } ; } /** Gets an entry from an IndexedDB store by key. */ static async databaseGet ( storeName , key ) { let db = await this . idbDatabase ; let transaction = db . transaction ( storeName , 'readonly' ) ; let store = transaction . objectStore ( storeName ) ; let request = store . get ( key ) ; await new Promise ( resolve => request . onsuccess = resolve ) ; return request . result ?? null ; } /** Puts an entry into an IndexedDB store by key. */ static async databasePut ( storeName , value , key ? ) { let db = await this . idbDatabase ; let transaction = db . transaction ( storeName , 'readwrite' ) ; let store = transaction . objectStore ( storeName ) ; store . put ( value , key ) ; await new Promise ( resolve => transaction . addEventListener ( 'complete' , resolve ) ) ; } /** Deletes an entry from an IndexedDB store by key. */ static async databaseDelete ( storeName , key ) { let db = await this . idbDatabase ; let transaction = db . transaction ( storeName , 'readwrite' ) ; let store = transaction . objectStore ( storeName ) ; store . delete ( key ) ; await new Promise ( resolve => transaction . addEventListener ( 'complete' , resolve ) ) ; } /** Counts all entries in an IndexedDB store with a specific key. */ static async databaseCount ( storeName , key ) { let db = await this . idbDatabase ; let transaction = db . transaction ( storeName , 'readonly' ) ; let store = transaction . objectStore ( storeName ) ; let request = store . count ( key ) ; await new Promise ( resolve => request . onsuccess = resolve ) ; return request . result ?? null ; } /** Makes sure the second parameter has the same deep structure as the first. */ static correctFields < T > ( truth , obj ) { for ( let key in truth ) { if ( ! ( key in obj ) ) obj [ key ] = truth [ key ] ; if ( truth [ key ] && typeof truth [ key ] === 'object' && ! Array . isArray ( truth [ key ] ) && Object . keys ( truth [ key ] ) . length ) this . correctFields ( truth [ key ] , obj [ key ] ) ; } for ( let key in obj ) { if ( ! ( key in truth ) ) delete obj [ key ] ; } return obj ; } /** Performs a series of modification needed to upgrade an old version. */ static async onVersionUpgrade ( from ) { for ( let vers in VERSION_UPGRADE_PROCEDURES ) { if ( Util . compareVersions ( from , vers ) >= 0 ) continue ; await VERSION_UPGRADE_PROCEDURES [ vers ] ( ) ; } } }