import { AudioManager } from "../audio" ; import { ResourceManager } from "../resources" ; import { Util } from "../util" ; import { BestTimes , StorageManager } from "../storage" ; import { Mission } from "../mission" ; import { Replay } from "../replay" ; import { previousButtonState } from "../input" ; import { Leaderboard } from "../leaderboard" ; import { Menu } from "./menu" ; import { MissionLibrary } from "../mission_library" ; import { state } from "../state" ; export abstract class LevelSelect { menu : Menu ; div : HTMLDivElement ; homeButton : HTMLImageElement ; homeButtonSrc : string ; scrollWindow : HTMLDivElement ; levelTitle : HTMLParagraphElement ; levelArtist : HTMLParagraphElement ; levelDescription : HTMLParagraphElement ; levelQualifyTime : HTMLParagraphElement ; localBestTimesContainer : HTMLDivElement ; leaderboardLoading : HTMLParagraphElement ; leaderboardScores : HTMLDivElement ; levelImage : HTMLImageElement ; prevButton : HTMLImageElement ; playButton : HTMLImageElement ; nextButton : HTMLImageElement ; searchInput : HTMLInputElement ; setImagesTimeout : number = null ; clearImageTimeout : number = null ; currentQuery = '' ; /** The current words in the search query. Used for matching. */ currentQueryWords : string [ ] = [ ] ; lastDisplayBestTimesId : string ; abstract localScoresCount : number ; abstract scorePlaceholderName : string ; abstract scoreElementHeight : number ; currentMissionArray : Mission [ ] ; currentMissionIndex : number ; get currentMission ( ) { return this . currentMissionArray ?. [ this . currentMissionIndex ] ; } constructor ( menu ) { this . menu = menu ; this . initProperties ( ) ; menu . setupButton ( this . homeButton , this . homeButtonSrc , ( ) => { this . hide ( ) ; menu . home . show ( ) ; } , undefined , undefined , state . modification === 'gold' ) ; menu . setupButton ( this . prevButton , 'play/prev' , ( ) => this . cycleMission ( - 1 ) , true , true , state . modification === 'gold' , true ) ; menu . setupButton ( this . playButton , 'play/play' , ( ) => this . playCurrentMission ( ) , true , undefined , state . modification === 'gold' ) ; menu . setupButton ( this . nextButton , 'play/next' , ( ) => this . cycleMission ( 1 ) , true , true , state . modification === 'gold' , true ) ; } abstract initProperties ( ) ; async init ( ) { for ( let i = 0 ; i < this . localScoresCount ; i ++ ) { let element = this . createScoreElement ( true ) ; this . localBestTimesContainer . appendChild ( element ) ; } for ( let i = 0 ; i < 18 ; i ++ ) { let element = this . createScoreElement ( false ) ; this . leaderboardScores . appendChild ( element ) ; } this . scrollWindow . addEventListener ( 'scroll' , ( ) => this . updateOnlineLeaderboard ( ) ) ; window . addEventListener ( 'keydown' , ( e ) => { if ( this . div . classList . contains ( 'hidden' ) ) return ; if ( e . code === 'ArrowLeft' && ( ! this . searchInput . value || document . activeElement === document . body ) ) { this . cycleMission ( - 1 ) ; if ( ! this . prevButton . style . pointerEvents ) this . prevButton . src = this . menu . uiAssetPath + 'play/prev_d.png' ; } else if ( e . code === 'ArrowRight' && ( ! this . searchInput . value || document . activeElement === document . body ) ) { this . cycleMission ( 1 ) ; if ( ! this . nextButton . style . pointerEvents ) this . nextButton . src = this . menu . uiAssetPath + 'play/next_d.png' ; } else if ( e . code === 'Escape' ) { this . homeButton . src = this . menu . uiAssetPath + this . homeButtonSrc + '_d.png' ; } } ) ; window . addEventListener ( 'keyup' , ( e ) => { if ( this . div . classList . contains ( 'hidden' ) ) return ; if ( e . code === 'ArrowLeft' ) { if ( ! this . prevButton . style . pointerEvents ) this . prevButton . src = this . prevButton . hasAttribute ( 'data-hovered' ) ? this . menu . uiAssetPath + 'play/prev_h.png' : this . menu . uiAssetPath + 'play/prev_n.png' ; } else if ( e . code === 'ArrowRight' ) { if ( ! this . nextButton . style . pointerEvents ) this . nextButton . src = this . nextButton . hasAttribute ( 'data-hovered' ) ? this . menu . uiAssetPath + 'play/next_h.png' : this . menu . uiAssetPath + 'play/next_n.png' ; } else if ( e . code === 'Escape' ) { this . homeButton . click ( ) ; } } ) ; this . searchInput . addEventListener ( 'input' , ( ) => { this . onSearchInputChange ( ) ; } ) ; this . searchInput . addEventListener ( 'focus' , ( ) => { this . searchInput . value = '' ; this . onSearchInputChange ( ) ; } ) ; } show ( ) { this . div . classList . remove ( 'hidden' ) ; this . displayMission ( ) ; } hide ( ) { this . div . classList . add ( 'hidden' ) ; } setMissionArray ( arr , doImageTimeout = true ) { this . currentMissionArray = arr ; this . currentMissionIndex = this . getDefaultMissionIndex ( ) ; this . selectBasedOnSearchQuery ( false ) ; this . displayMission ( doImageTimeout ) ; } getDefaultMissionIndex ( ) { if ( [ MissionLibrary . goldCustom , MissionLibrary . platinumCustom , MissionLibrary . ultraCustom ] . includes ( this . currentMissionArray ) ) { return this . currentMissionArray . length - 1 ; } let last = 0 ; for ( let i = 0 ; i < this . currentMissionArray . length ; i ++ ) { let mission = this . currentMissionArray [ i ] ; if ( StorageManager . data . bestTimes [ mission . path ] ?. length ) last = i + 1 ; } return Math . min ( last , this . currentMissionArray . length - 1 ) ; } displayMission ( doImageTimeout = true ) { let mission = this . currentMission ; if ( ! mission ) { this . levelImage . style . display = 'none' ; this . playButton . src = this . menu . uiAssetPath + 'play/play_i.png' ; this . playButton . style . pointerEvents = 'none' ; this . displayEmptyMetadata ( ) ; this . displayBestTimes ( ) ; } else { if ( this . playButton . style . pointerEvents === 'none' ) { this . playButton . src = this . menu . uiAssetPath + 'play/play_n.png' ; this . playButton . style . pointerEvents = '' ; } this . levelImage . style . display = '' ; this . displayMetadata ( ) ; this . displayBestTimes ( ) ; if ( ! this . clearImageTimeout ) this . clearImageTimeout = setTimeout ( ( ) => this . levelImage . src = '' , 16 ) as any as number ; } this . setImages ( false , doImageTimeout ) ; this . updateNextPrevButtons ( ) ; Leaderboard . loadLocal ( ) ; } /** ...for the current level. */ abstract displayMetadata ( ) ; /** Sets the metadata elements for the case that there is no level to display. */ abstract displayEmptyMetadata ( ) ; playCurrentMission ( replayData ? ) { let currentMission = this . currentMission ; if ( ! currentMission ) return ; this . div . classList . add ( 'hidden' ) ; this . menu . loadingScreen . loadLevel ( currentMission , replayData ? ( ) => Replay . fromSerialized ( replayData ) : undefined ) ; } /** Advance the current mission index by the specified count while respecting the search query. That count can be negative. */ cycleMission ( direction ) { let index = this . getCycleMissionIndex ( direction ) ; if ( index === null || index === this . currentMissionIndex ) return ; this . currentMissionIndex = index ; this . displayMission ( ) ; } /** Gets the mission index you would get by skipping a certain amount forwards/backwards while respecting the search query. Returns null if the index would peek outside of the current mission array. */ getCycleMissionIndex ( direction ) { if ( direction === 0 ) return this . currentMissionIndex ; for ( let i = this . currentMissionIndex + Math . sign ( direction ) ; i >= 0 && i < this . currentMissionArray . length ; i += Math . sign ( direction ) ) { if ( this . currentMissionArray [ i ] . matchesSearch ( this . currentQueryWords , this . currentQuery ) ) direction = Math . sign ( direction ) * ( Math . abs ( direction ) - 1 ) ; if ( direction === 0 ) return i ; } return null ; } /** Returns true if there is a next mission to go to. */ canGoNext ( ) { let canGoNext = false ; for ( let i = this . currentMissionIndex + 1 ; i < this . currentMissionArray . length ; i ++ ) { if ( this . currentMissionArray [ i ] . matchesSearch ( this . currentQueryWords , this . currentQuery ) ) { canGoNext = true ; break ; } } return canGoNext ; } /** Returns true if there is a previous mission to go back to. */ canGoPrev ( ) { let canGoPrev = false ; for ( let i = this . currentMissionIndex - 1 ; i >= 0 ; i -- ) { if ( this . currentMissionArray [ i ] . matchesSearch ( this . currentQueryWords , this . currentQuery ) ) { canGoPrev = true ; break ; } } return canGoPrev ; } updateNextPrevButtons ( ) { if ( ! this . canGoNext ( ) ) { this . nextButton . src = this . menu . uiAssetPath + 'play/next_i.png' ; this . nextButton . style . pointerEvents = 'none' ; } else { if ( this . nextButton . src . endsWith ( 'i.png' ) ) this . nextButton . src = this . menu . uiAssetPath + 'play/next_n.png' ; this . nextButton . style . pointerEvents = '' ; } if ( ! this . canGoPrev ( ) ) { this . prevButton . src = this . menu . uiAssetPath + 'play/prev_i.png' ; this . prevButton . style . pointerEvents = 'none' ; } else { if ( this . prevButton . src . endsWith ( 'i.png' ) ) this . prevButton . src = this . menu . uiAssetPath + 'play/prev_n.png' ; this . prevButton . style . pointerEvents = '' ; } } /** Sets and preloads images around the current level. */ setImages ( fromTimeout = false , doTimeout = true ) { if ( fromTimeout ) { clearTimeout ( this . setImagesTimeout ) ; this . setImagesTimeout = null ; } if ( this . setImagesTimeout !== null && doTimeout ) { clearTimeout ( this . setImagesTimeout ) ; this . setImagesTimeout = setTimeout ( ( ) => this . setImages ( true ) , 75 ) as any as number ; return ; } let toLoad = new Set < Mission > ( ) ; for ( let i = 0 ; i <= 10 ; i ++ ) { let index = this . getCycleMissionIndex ( Math . ceil ( i / 2 ) * ( ( i % 2 ) ? 1 : - 1 ) ) ; let mission = this . currentMissionArray [ index ] ; if ( ! mission ) continue ; toLoad . add ( mission ) ; } for ( let mission of this . getNextShuffledMissions ( ) ) toLoad . add ( mission ) ; for ( let mission of toLoad ) { let imagePath = mission . getImagePath ( ) ; let start = performance . now ( ) ; ResourceManager . loadResource ( imagePath ) . then ( async ( blob ) => { if ( ! blob ) return ; if ( mission === this . currentMission ) { let dataUrl = await ResourceManager . readBlobAsDataUrl ( blob ) ; if ( mission === this . currentMission ) { clearTimeout ( this . clearImageTimeout ) ; this . clearImageTimeout = null ; this . levelImage . src = dataUrl ; } } let elapsed = performance . now ( ) - start ; if ( elapsed > 75 && ! this . setImagesTimeout && doTimeout ) { this . setImagesTimeout = setTimeout ( ( ) => this . setImages ( true ) , 75 ) as any as number ; } } ) ; } } shuffle ( ) { if ( this . currentMissionArray . length <= 1 ) return ; let nextIndex = this . currentMissionIndex ; while ( nextIndex === this . currentMissionIndex ) { nextIndex = Math . floor ( Util . popRandomNumber ( ) * this . currentMissionArray . length ) ; } this . currentMissionIndex = nextIndex ; this . displayMission ( ) ; } /** Returns the next few missions that would be selected by repeating pressing of the shuffle button. */ getNextShuffledMissions ( ) { let missions = [ ] ; if ( this . currentMissionArray . length > 1 ) { let lastIndex = this . currentMissionIndex ; let i = 0 ; let count = 0 ; while ( count < 5 ) { let randomNumber = Util . peekRandomNumber ( i ++ ) ; let nextIndex = Math . floor ( randomNumber * this . currentMissionArray . length ) ; if ( lastIndex !== nextIndex ) { let mission = this . currentMissionArray [ nextIndex ] ; missions . push ( mission ) ; count ++ ; } lastIndex = nextIndex ; } } return missions ; } /** Creates a score element that can be used to show local and online scores. */ abstract createScoreElement ( includeReplayButton ) ; /** Updates a previously created score element. */ abstract updateScoreElement ( element , score , rank ) ; displayBestTimes ( ) { let randomId = Util . getRandomId ( ) ; this . lastDisplayBestTimesId = randomId ; let bestTimes = StorageManager . getBestTimesForMission ( this . currentMission ?. path , this . localScoresCount , this . scorePlaceholderName ) ; for ( let i = 0 ; i < this . localScoresCount ; i ++ ) { this . updateScoreElement ( this . localBestTimesContainer . children [ i ] as HTMLDivElement , bestTimes [ i ] , i + 1 ) ; } if ( ! this . currentMission ) { this . leaderboardLoading . style . display = 'none' ; this . leaderboardScores . style . paddingTop = '0px' ; this . leaderboardScores . style . paddingBottom = '0px' ; for ( let element of this . leaderboardScores . children ) ( element as HTMLDivElement ) . style . display = 'none' ; } else { this . leaderboardLoading . style . display = Leaderboard . isLoading ( this . currentMission . path ) ? 'block' : 'none' ; this . updateOnlineLeaderboard ( ) ; setTimeout ( ( ) => this . updateOnlineLeaderboard ( ) ) ; } } /** Creates a replay button for use in score elements. */ createReplayButton ( ) { let icon = document . createElement ( 'img' ) ; icon . src = "./assets/img/round_videocam_black_18dp.png" ; icon . title = "Alt-Click to download" ; const handler = async ( download ) => { let mission = this . currentMission ; if ( ! mission ) return ; let attr = icon . getAttribute ( 'data-score-id' ) ; if ( ! attr ) return ; let replayData = await StorageManager . databaseGet ( 'replays' , attr ) ; if ( ! replayData ) return ; if ( ! download ) { this . playCurrentMission ( replayData ) ; } else { Replay . download ( replayData , mission ) ; if ( Util . isTouchDevice && Util . isInFullscreen ( ) ) this . menu . showAlertPopup ( 'Downloaded' , 'The .wrec has been downloaded.' ) ; } } ; icon . addEventListener ( 'click' , async ( e ) => { if ( e . button !== 0 ) return ; handler ( e . altKey ) ; } ) ; Util . onLongTouch ( icon , ( ) => { handler ( true ) ; } ) ; icon . addEventListener ( 'mouseenter' , ( ) => { AudioManager . play ( 'buttonover.wav' ) ; } ) ; icon . addEventListener ( 'mousedown' , ( e ) => { if ( e . button === 0 ) AudioManager . play ( 'buttonpress.wav' ) ; } ) ; return icon ; } async updateReplayButton ( element , score ) { element . style . display = 'none' ; element . removeAttribute ( 'data-score-id' ) ; if ( ! score [ 2 ] ) return ; let randomId = this . lastDisplayBestTimesId ; let count = await StorageManager . databaseCount ( 'replays' , score [ 2 ] ) ; if ( randomId === this . lastDisplayBestTimesId && count > 0 ) { element . style . display = 'block' ; element . setAttribute ( 'data-score-id' , score [ 2 ] ) ; } } /** Updates the elements in the online leaderboard. Updates only the visible elements and adds padding to increase performance. */ updateOnlineLeaderboard ( ) { let mission = this . currentMission ; if ( ! mission ) return ; let onlineScores = Leaderboard . scores . get ( mission . path ) ?? [ ] ; let elements = this . leaderboardScores . children ; let index = 0 ; this . leaderboardScores . style . paddingTop = '0px' ; this . leaderboardScores . style . paddingBottom = '0px' ; ( elements [ index ] as HTMLDivElement ) . style . display = 'block' ; let currentY = ( elements [ 0 ] as HTMLDivElement ) . offsetTop - this . scrollWindow . scrollTop ; this . leaderboardScores . style . height = onlineScores . length * this . scoreElementHeight + 'px' ; while ( currentY < - this . scoreElementHeight && index < onlineScores . length ) { index ++ ; currentY += this . scoreElementHeight ; } this . leaderboardScores . style . paddingTop = index * this . scoreElementHeight + 'px' ; for ( let i = 0 ; i < elements . length ; i ++ ) { let element = elements [ i ] as HTMLDivElement ; if ( index < onlineScores . length ) { let score = onlineScores [ index ] ; element . style . display = 'block' ; this . updateScoreElement ( element , score as any , index + 1 ) ; } else { element . style . display = 'none' ; } index ++ ; } this . leaderboardScores . style . paddingBottom = Math . max ( onlineScores . length - index , 0 ) * this . scoreElementHeight + 'px' ; } onSearchInputChange ( ) { let str = Util . removeSpecialCharacters ( Util . normalizeString ( this . searchInput . value . trim ( ) ) ) . toLowerCase ( ) ; this . currentQuery = str ; this . currentQueryWords = str . split ( ' ' ) ; if ( ! str ) this . currentQueryWords . length = 0 ; this . selectBasedOnSearchQuery ( ) ; this . updateNextPrevButtons ( ) ; } /** Selects a valid mission based on the current search query. */ selectBasedOnSearchQuery ( display = true ) { if ( this . currentMission ?. matchesSearch ( this . currentQueryWords , this . currentQuery ) ) return ; for ( let i = 0 ; i < this . currentMissionArray . length ; i ++ ) { let mis = this . currentMissionArray [ i ] ; if ( mis . matchesSearch ( this . currentQueryWords , this . currentQuery ) ) { this . currentMissionIndex = i ; if ( display ) this . displayMission ( ) ; break ; } } } showLoadReplayPrompt ( ) { let fileInput = document . createElement ( 'input' ) ; fileInput . setAttribute ( 'type' , 'file' ) ; fileInput . setAttribute ( 'accept' , ".wrec" ) ; fileInput . onchange = async ( ) => { try { let file = fileInput . files [ 0 ] ; let arrayBuffer = await ResourceManager . readBlobAsArrayBuffer ( file ) ; let replay = Replay . fromSerialized ( arrayBuffer ) ; let mission = MissionLibrary . allMissions . find ( x => x . path === replay . missionPath ) ; if ( ! mission ) throw new Error ( "Mission not found." ) ; if ( state . modification === 'gold' && mission . path . startsWith ( 'mbp' ) ) { state . menu . showAlertPopup ( 'Warning' , "You can't watch replays of Platinum level inside Marble Blast Gold." ) ; return ; } this . div . classList . add ( 'hidden' ) ; this . menu . loadingScreen . loadLevel ( mission , ( ) => replay ) ; } catch ( e ) { state . menu . showAlertPopup ( 'Error' , "There was an error loading the replay." ) ; console . error ( e ) ; } } ; fileInput . click ( ) ; } handleControllerInput ( gamepad ) { if ( gamepad . buttons [ 0 ] . value > 0.5 && ! previousButtonState [ 0 ] ) { this . playCurrentMission ( ) ; AudioManager . play ( 'buttonpress.wav' ) ; } if ( gamepad . buttons [ 6 ] . value > 0.5 && ! previousButtonState [ 6 ] ) { if ( this . currentMissionArray === MissionLibrary . goldIntermediate ) this . setMissionArray ( MissionLibrary . goldBeginner ) ; else if ( this . currentMissionArray === MissionLibrary . goldAdvanced ) this . setMissionArray ( MissionLibrary . goldIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . goldCustom ) this . setMissionArray ( MissionLibrary . goldAdvanced ) ; else if ( this . currentMissionArray === MissionLibrary . platinumBeginner ) this . setMissionArray ( MissionLibrary . goldCustom ) ; else if ( this . currentMissionArray === MissionLibrary . platinumIntermediate ) this . setMissionArray ( MissionLibrary . platinumBeginner ) ; else if ( this . currentMissionArray === MissionLibrary . platinumAdvanced ) this . setMissionArray ( MissionLibrary . platinumIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . platinumExpert ) this . setMissionArray ( MissionLibrary . platinumAdvanced ) ; else if ( this . currentMissionArray === MissionLibrary . platinumCustom ) this . setMissionArray ( MissionLibrary . platinumExpert ) ; else if ( this . currentMissionArray === MissionLibrary . ultraBeginner ) this . setMissionArray ( MissionLibrary . platinumCustom ) ; else if ( this . currentMissionArray === MissionLibrary . ultraIntermediate ) this . setMissionArray ( MissionLibrary . ultraBeginner ) ; else if ( this . currentMissionArray === MissionLibrary . ultraAdvanced ) this . setMissionArray ( MissionLibrary . ultraIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . ultraCustom ) this . setMissionArray ( MissionLibrary . ultraAdvanced ) ; AudioManager . play ( 'buttonpress.wav' ) ; } if ( gamepad . buttons [ 7 ] . value > 0.5 && ! previousButtonState [ 7 ] ) { if ( this . currentMissionArray === MissionLibrary . goldBeginner ) this . setMissionArray ( MissionLibrary . goldIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . goldIntermediate ) this . setMissionArray ( MissionLibrary . goldAdvanced ) ; else if ( this . currentMissionArray === MissionLibrary . goldAdvanced ) this . setMissionArray ( MissionLibrary . goldCustom ) ; else if ( this . currentMissionArray === MissionLibrary . goldCustom ) this . setMissionArray ( MissionLibrary . platinumBeginner ) ; else if ( this . currentMissionArray === MissionLibrary . platinumBeginner ) this . setMissionArray ( MissionLibrary . platinumIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . platinumIntermediate ) this . setMissionArray ( MissionLibrary . platinumAdvanced ) ; else if ( this . currentMissionArray === MissionLibrary . platinumAdvanced ) this . setMissionArray ( MissionLibrary . platinumExpert ) ; else if ( this . currentMissionArray === MissionLibrary . platinumExpert ) this . setMissionArray ( MissionLibrary . platinumCustom ) ; else if ( this . currentMissionArray === MissionLibrary . platinumCustom ) this . setMissionArray ( MissionLibrary . ultraBeginner ) ; else if ( this . currentMissionArray === MissionLibrary . ultraBeginner ) this . setMissionArray ( MissionLibrary . ultraIntermediate ) ; else if ( this . currentMissionArray === MissionLibrary . ultraIntermediate ) this . setMissionArray ( MissionLibrary . ultraAdvanced ) ; else if ( this . currentMissionArray === MissionLibrary . ultraAdvanced ) this . setMissionArray ( MissionLibrary . ultraCustom ) ; AudioManager . play ( 'buttonpress.wav' ) ; } if ( gamepad . buttons [ 14 ] . value > 0.5 && ! previousButtonState [ 14 ] ) { this . cycleMission ( - 1 ) ; AudioManager . play ( 'buttonpress.wav' ) ; } if ( gamepad . buttons [ 15 ] . value > 0.5 && ! previousButtonState [ 15 ] ) { this . cycleMission ( 1 ) ; AudioManager . play ( 'buttonpress.wav' ) ; } } }