Added game pre-downloading feature

- decreased splash window size
- added `Game.getLatestData()` result caching, so basically every
  `Game` and `Voice` method and field now caches most of the data
- Made `Game.predownloadUpdate()` method
- Made `Game.isUpdatePredownloaded()` method
- Made `Voice.predownloadUpdate()` method
- Made `Voice.isUpdatePredownloaded()` method
- fixed `Cache.get()` method work
- now launcher window hides when you launch the game
- added `pre_download_game` API field type definition
- added game pre-downloading button
This commit is contained in:
Observer KRypt0n_ 2021-12-29 18:04:30 +02:00
parent 7bfb3ab0b5
commit d2d690a114
No known key found for this signature in database
GPG key ID: DC5D4EC1303465DA
14 changed files with 376 additions and 52 deletions

View file

@ -168,7 +168,7 @@ This is our current roadmap goals. You can find older ones [here](ROADMAP.md)
* <s>Debugger</s> * <s>Debugger</s>
* <s>Splash screen</s> * <s>Splash screen</s>
* <s>Theming system</s> * <s>Theming system</s>
* Game pre-installation * <s>Game pre-installation</s>
* Launcher auto-updates * Launcher auto-updates
* Statistics window * Statistics window
* Chengelog window * Chengelog window

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -5,9 +5,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Gear from '/src/assets/images/gear.png';
import GearActive from '/src/assets/images/gear-active.png';
import Window from './ts/neutralino/Window'; import Window from './ts/neutralino/Window';
import Launcher from './ts/Launcher'; import Launcher from './ts/Launcher';
@ -15,11 +12,15 @@
import Game from './ts/Game'; import Game from './ts/Game';
import Background from './ts/launcher/Background'; import Background from './ts/launcher/Background';
import Gear from './assets/images/gear.png';
import GearActive from './assets/images/gear-active.png';
import Download from './assets/images/cloud-download.png';
Neutralino.events.on('ready', () => { Neutralino.events.on('ready', () => {
Window.open('splash', { Window.open('splash', {
title: 'Splash', title: 'Splash',
width: 400, width: 300,
height: 500, height: 400,
borderless: true, borderless: true,
exitProcessOnClose: false exitProcessOnClose: false
}); });
@ -82,5 +83,9 @@
<img src={GearActive} class="active" alt="Settings"> <img src={GearActive} class="active" alt="Settings">
</div> </div>
<button class="button hint--left hint--small" aria-label="Pre-download the game" id="predownload">
<img src={Download} alt="Download" />
</button>
<button class="button" id="launch">Launch</button> <button class="button" id="launch">Launch</button>
</main> </main>

View file

@ -35,16 +35,32 @@
background-color: #7284b6 background-color: #7284b6
#launch #launch
position: absolute
width: 238px width: 238px
height: 64px height: 64px
font-size: 22px
position: absolute
right: 128px right: 128px
bottom: 64px bottom: 64px
font-size: 22px
#predownload
position: absolute
display: none
width: 52px
height: 52px
right: 386px
bottom: 70px
border-radius: 32px
img
width: 60%
margin: auto
#settings #settings
width: 76px width: 76px
height: 76px height: 76px

View file

@ -7,11 +7,13 @@
background-color: map.get($theme-map, "background") background-color: map.get($theme-map, "background")
div div
width: 60% width: 80%
margin: 56px auto 0 auto margin: 42px auto 0
img img
width: 100% width: 80%
display: block
margin: 0 auto 16px auto
image-rendering: optimizeQuality image-rendering: optimizeQuality
h2, p h2, p

View file

@ -11,6 +11,8 @@ import AbstractInstaller from './core/AbstractInstaller';
import Domain from './core/Domain'; import Domain from './core/Domain';
import promisify from './core/promisify'; import promisify from './core/promisify';
import Debug, { DebugThread } from './core/Debug'; import Debug, { DebugThread } from './core/Debug';
import Downloader, { Stream as DownloadingStream } from './core/Downloader';
import Cache from './core/Cache';
declare const Neutralino; declare const Neutralino;
@ -67,12 +69,24 @@ export default class Game
if (response.ok) if (response.ok)
{ {
const json: ServerResponse = JSON.parse(await response.body()); const cache = await Cache.get('Game.getLatestData.ServerResponse');
if (json.message == 'OK') if (cache && !cache.expired)
resolve(json.data); resolve(cache.value as Data);
else reject(new Error(`${constants.placeholders.uppercase.company}'s versions server responds with an error: [${json.retcode}] ${json.message}`)); else
{
const json: ServerResponse = JSON.parse(await response.body());
if (json.message == 'OK')
{
Cache.set('Game.getLatestData.ServerResponse', json.data, 24 * 3600);
resolve(json.data);
}
else reject(new Error(`${constants.placeholders.uppercase.company}'s versions server responds with an error: [${json.retcode}] ${json.message}`));
}
} }
else reject(new Error(`${constants.placeholders.uppercase.company}'s versions server is unreachable`)); else reject(new Error(`${constants.placeholders.uppercase.company}'s versions server is unreachable`));
@ -141,16 +155,19 @@ export default class Game
/** /**
* Get the game installation stream * Get the game installation stream
* *
* @param version current game version to download difference from
*
* @returns null if the version can't be found * @returns null if the version can't be found
* @returns Error if company's servers are unreachable or they responded with an error * @returns Error if company's servers are unreachable or they responded with an error
*/ */
public static update(version: string|null = null): Promise<Stream|null> public static update(version: string|null = null): Promise<Stream|null>
{ {
Debug.log( Debug.log({
version !== null ? function: 'Game.update',
`Updating the game from the ${version} version` : message: version !== null ?
'Installing the game' `Updating the game from the ${version} version` :
); 'Installing the game'
});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
(version === null ? this.latest : this.getDiff(version)) (version === null ? this.latest : this.getDiff(version))
@ -159,6 +176,60 @@ export default class Game
}); });
} }
/**
* Pre-download the game update
*
* @param version current game version to download difference from
*
* @returns null if the game pre-downloading is not available. Otherwise - downloading stream
* @returns Error if company's servers are unreachable or they responded with an error
*/
public static predownloadUpdate(version: string|null = null): Promise<DownloadingStream|null>
{
const debugThread = new DebugThread('Game.predownloadUpdate', 'Predownloading game data...')
return new Promise((resolve) => {
this.getLatestData()
.then((data) => {
if (data.pre_download_game)
{
let path = data.pre_download_game.latest.path;
if (version !== null)
for (const diff of data.pre_download_game.diffs)
if (diff.version == version)
{
path = diff.path;
break;
}
debugThread.log(`Downloading update from the path: ${path}`);
constants.paths.launcherDir.then((dir) => {
Downloader.download(path, `${dir}/game-predownloaded.zip`)
.then((stream) => resolve(stream));
});
}
else resolve(null);
})
.catch((error) => resolve(error));
});
}
/**
* Checks whether the update was downloaded or not
*/
public static isUpdatePredownloaded(): Promise<boolean>
{
return new Promise(async (resolve) => {
Neutralino.filesystem.getStats(`${await constants.paths.launcherDir}/game-predownloaded.zip`)
.then(() => resolve(true))
.catch(() => resolve(false));
});
}
/** /**
* Check if the telemetry servers are disabled * Check if the telemetry servers are disabled
*/ */

View file

@ -5,7 +5,8 @@ import constants from './Constants';
import Game from './Game'; import Game from './Game';
import AbstractInstaller from './core/AbstractInstaller'; import AbstractInstaller from './core/AbstractInstaller';
import Configs from './Configs'; import Configs from './Configs';
import Debug from './core/Debug'; import Debug, { DebugThread } from './core/Debug';
import Downloader, { Stream as DownloadingStream } from './core/Downloader';
declare const Neutralino; declare const Neutralino;
@ -118,13 +119,14 @@ export default class Voice
* @returns null if the language or the version can't be found * @returns null if the language or the version can't be found
* @returns rejects Error object if company's servers are unreachable or they responded with an error * @returns rejects Error object if company's servers are unreachable or they responded with an error
*/ */
public static update(lang: string|null = null, version: string|null = null): Promise<Stream|null> public static update(lang: string, version: string|null = null): Promise<Stream|null>
{ {
Debug.log( Debug.log({
version !== null ? function: 'Voice.update',
`Updating the voice package from the ${version} version` : message: version !== null ?
'Installing the voice package' `Updating the voice package from the ${version} version` :
); 'Installing the voice package'
});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
(version === null ? this.latest : this.getDiff(version)) (version === null ? this.latest : this.getDiff(version))
@ -142,6 +144,65 @@ export default class Voice
.catch((error) => reject(error)); .catch((error) => reject(error));
}); });
} }
/**
* Pre-download the game's voice update
*
* @param version current game version to download difference from
*
* @returns null if the game pre-downloading is not available or the language wasn't found. Otherwise - downloading stream
* @returns Error if company's servers are unreachable or they responded with an error
*/
public static predownloadUpdate(lang: string, version: string|null = null): Promise<DownloadingStream|null>
{
const debugThread = new DebugThread('Voice.predownloadUpdate', 'Predownloading game voice data...')
return new Promise((resolve) => {
Game.getLatestData()
.then((data) => {
if (data.pre_download_game)
{
let voicePack = data.pre_download_game.latest.voice_packs.filter(voice => voice.language === lang);
if (version !== null)
for (const diff of data.pre_download_game.diffs)
if (diff.version == version)
{
voicePack = diff.voice_packs.filter(voice => voice.language === lang);
break;
}
if (voicePack.length === 1)
{
debugThread.log(`Downloading update from the path: ${voicePack[0].path}`);
constants.paths.launcherDir.then((dir) => {
Downloader.download(voicePack[0].path, `${dir}/voice-${lang}-predownloaded.zip`)
.then((stream) => resolve(stream));
});
}
else resolve(null);
}
else resolve(null);
})
.catch((error) => resolve(error));
});
}
/**
* Checks whether the update was downloaded or not
*/
public static isUpdatePredownloaded(lang: string): Promise<boolean>
{
return new Promise(async (resolve) => {
Neutralino.filesystem.getStats(`${await constants.paths.launcherDir}/voice-${lang}-predownloaded.zip`)
.then(() => resolve(true))
.catch(() => resolve(false));
});
}
} }
export { Stream }; export { Stream };

View file

@ -1,4 +1,5 @@
import constants from '../Constants'; import constants from '../Constants';
import Debug from './Debug';
type Record = { type Record = {
expired: boolean; expired: boolean;
@ -9,6 +10,10 @@ declare const Neutralino;
export default class Cache export default class Cache
{ {
// Locally stored cache to not to access
// cache.json file every time we want to find something
protected static cache: object|null = null;
/** /**
* Get cached value * Get cached value
* *
@ -17,19 +22,44 @@ export default class Cache
public static get(name: string): Promise<Record|null> public static get(name: string): Promise<Record|null>
{ {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
Neutralino.filesystem.readFile(await constants.paths.cache) if (this.cache !== null && this.cache[name] !== undefined)
.then((cache) => { {
cache = JSON.parse(cache); Debug.log({
function: 'Cache.get',
message: [
`Resolved ${this.cache[name].expired ? 'expired' : 'unexpired'} hot cache record`,
`[name] ${name}`,
`[value]: ${this.cache[name].value}`
]
});
if (cache[name] === undefined) resolve(this.cache[name]);
}
else Neutralino.filesystem.readFile(await constants.paths.cache)
.then((cache) => {
this.cache = JSON.parse(cache);
if (this.cache![name] === undefined)
resolve(null); resolve(null);
else else
{ {
resolve({ const output = {
expired: cache[name].ttl !== null ? Date.now() > cache[name].ttl * 1000 : false, expired: this.cache![name].ttl !== null ? Date.now() > this.cache![name].ttl * 1000 : false,
value: JSON.parse(atob(cache[name].value)) value: this.cache![name].value
};
Debug.log({
function: 'Cache.get',
message: [
`Resolved ${output.expired ? 'expired' : 'unexpired'} cache`,
`[name] ${name}`,
`[value]: ${JSON.stringify(output.value)}`
]
}); });
resolve(output);
} }
}) })
.catch(() => resolve(null)); .catch(() => resolve(null));
@ -49,19 +79,40 @@ export default class Cache
{ {
return new Promise((resolve) => { return new Promise((resolve) => {
constants.paths.cache.then((cacheFile) => { constants.paths.cache.then((cacheFile) => {
let cache = {}; if (this.cache === null)
{
Neutralino.filesystem.readFile(cacheFile)
.then((cacheRaw) =>
{
this.cache = JSON.parse(cacheRaw);
Neutralino.filesystem.readFile(cacheFile) writeCache();
.then((cacheRaw) => cache = JSON.parse(cacheRaw)) })
.catch(() => {}); .catch(() => {
this.cache = {};
cache[name] = { writeCache();
ttl: ttl !== null ? Math.round(Date.now() / 1000) + ttl : null, });
value: btoa(JSON.stringify(value)) }
const writeCache = () => {
Debug.log({
function: 'Cache.set',
message: [
'Caching data:',
`[ttl] ${ttl}`,
`[value] ${JSON.stringify(value)}`
]
});
this.cache![name] = {
ttl: ttl !== null ? Math.round(Date.now() / 1000) + ttl : null,
value: value
};
Neutralino.filesystem.writeFile(cacheFile, JSON.stringify(this.cache))
.then(() => resolve());
}; };
Neutralino.filesystem.writeFile(cacheFile, JSON.stringify(cache))
.then(() => resolve());
}); });
}); });
} }

View file

@ -14,6 +14,7 @@ export default class State
public launcher: Launcher; public launcher: Launcher;
public launchButton: HTMLElement; public launchButton: HTMLElement;
public predownloadButton: HTMLElement;
protected _state: LauncherState = 'game-launch-available'; protected _state: LauncherState = 'game-launch-available';
@ -34,6 +35,7 @@ export default class State
this.launcher = launcher; this.launcher = launcher;
this.launchButton = <HTMLElement>document.getElementById('launch'); this.launchButton = <HTMLElement>document.getElementById('launch');
this.predownloadButton = <HTMLElement>document.getElementById('predownload');
this.launchButton.onclick = () => { this.launchButton.onclick = () => {
if (this.events[this._state]) if (this.events[this._state])
@ -42,14 +44,30 @@ export default class State
this.events[this._state].then((event) => { this.events[this._state].then((event) => {
event.default(this.launcher).then(() => { event.default(this.launcher).then(() => {
this.launchButton.style['display'] = 'block'; this.update().then(() => {
this.launchButton.style['display'] = 'block';
this.update(); });
}); });
}); });
} }
}; };
this.predownloadButton.onclick = () => {
this.launchButton.style['display'] = 'none';
this.predownloadButton.style['display'] = 'none';
const module = this._state === 'game-pre-installation-available' ?
'Predownload' : 'PredownloadVoice';
import(`./states/${module}`).then((module) => {
module.default(this.launcher).then(() => {
this.update().then(() => {
this.launchButton.style['display'] = 'block';
});
});
});
};
this.update().then(() => { this.update().then(() => {
Neutralino.storage.setData('launcherLoaded', 'aboba'); Neutralino.storage.setData('launcherLoaded', 'aboba');
@ -73,6 +91,7 @@ export default class State
this._state = state; this._state = state;
this.launcher.progressBar!.hide(); this.launcher.progressBar!.hide();
this.predownloadButton.style['display'] = 'none';
switch(state) switch(state)
{ {
@ -81,6 +100,14 @@ export default class State
break; break;
case 'game-pre-installation-available':
case 'game-voice-pre-installation-available':
this.launchButton.textContent = 'Launch';
this.predownloadButton.style['display'] = 'block';
break;
case 'game-installation-available': case 'game-installation-available':
this.launchButton.textContent = 'Install'; this.launchButton.textContent = 'Install';
@ -125,14 +152,14 @@ export default class State
let state: LauncherState; let state: LauncherState;
const gameCurrent = await Game.current; const gameCurrent = await Game.current;
const gameLatest = (await Game.latest).version; const gameLatest = await Game.getLatestData();
const patch = await Patch.latest; const patch = await Patch.latest;
const voiceData = await Voice.current; const voiceData = await Voice.current;
if (gameCurrent === null) if (gameCurrent === null)
state = 'game-installation-available'; state = 'game-installation-available';
else if (gameCurrent != gameLatest) else if (gameCurrent != gameLatest.game.latest.version)
state = 'game-update-available'; state = 'game-update-available';
// TODO: update this thing if the user selected another voice language // TODO: update this thing if the user selected another voice language
@ -146,6 +173,12 @@ export default class State
'test-patch-available' : 'patch-available'); 'test-patch-available' : 'patch-available');
} }
else if (gameLatest.pre_download_game && !await Game.isUpdatePredownloaded())
state = 'game-pre-installation-available';
else if (gameLatest.pre_download_game && !await Voice.isUpdatePredownloaded(await Voice.selected))
state = 'game-voice-pre-installation-available';
else state = 'game-launch-available'; else state = 'game-launch-available';
this.set(state); this.set(state);

View file

@ -5,6 +5,7 @@ import Notifications from '../../core/Notifications';
import Runners from '../../core/Runners'; import Runners from '../../core/Runners';
import Game from '../../Game'; import Game from '../../Game';
import Process from '../../neutralino/Process'; import Process from '../../neutralino/Process';
import Window from '../../neutralino/Window';
declare const Neutralino; declare const Neutralino;
@ -32,6 +33,8 @@ export default (): Promise<void> => {
// Otherwise run the game // Otherwise run the game
else else
{ {
Window.current.hide();
/** /**
* Selecting wine executable * Selecting wine executable
*/ */
@ -139,6 +142,8 @@ export default (): Promise<void> => {
process.finish(() => { process.finish(() => {
const stopTime = Date.now(); const stopTime = Date.now();
Window.current.show();
// todo // todo
resolve(); resolve();

View file

@ -0,0 +1,44 @@
import type Launcher from '../../Launcher';
import Game from '../../Game';
import Prefix from '../../core/Prefix';
export default (launcher: Launcher): Promise<void> => {
return new Promise(async (resolve) => {
Prefix.exists().then((exists) => {
if (!exists)
{
import('./CreatePrefix').then((module) => {
module.default(launcher).then(() => updateGame());
});
}
});
const updateGame = async () => {
const prevGameVersion = await Game.current;
Game.predownloadUpdate(prevGameVersion).then((stream) => {
launcher.progressBar?.init({
label: 'Downloading game...',
showSpeed: true,
showEta: true,
showPercents: true,
showTotals: true
});
stream?.start(() => launcher.progressBar?.show());
stream?.progress((current: number, total: number, difference: number) => {
launcher.progressBar?.update(current, total, difference);
});
stream?.finish(() => {
// Predownload voice package when the game itself has been downloaded
import('./PredownloadVoice').then((module) => {
module.default(launcher, prevGameVersion).then(() => resolve());
});
});
});
};
});
};

View file

@ -0,0 +1,29 @@
import type Launcher from '../../Launcher';
import Voice from '../../Voice';
export default (launcher: Launcher, prevGameVersion: string|null = null): Promise<void> => {
return new Promise(async (resolve) => {
Voice.predownloadUpdate(await Voice.selected, prevGameVersion).then((stream) => {
launcher.progressBar?.init({
label: 'Downloading voice package...',
showSpeed: true,
showEta: true,
showPercents: true,
showTotals: true
});
stream?.start(() => launcher.progressBar?.show());
stream?.progress((current: number, total: number, difference: number) => {
launcher.progressBar?.update(current, total, difference);
});
stream?.finish(() => {
launcher.progressBar?.hide();
resolve();
});
});
});
};

View file

@ -52,12 +52,17 @@ type DeprecatedPackage = {
md5: string; md5: string;
} }
type PreDownloadGame = {
latest: Latest;
diffs: Diff[];
};
type Data = { type Data = {
game: Game; game: Game;
plugin: Plugin; plugin: Plugin;
web_url: string; web_url: string;
force_update?: any; force_update?: any;
pre_download_game?: any; pre_download_game?: PreDownloadGame;
deprecated_packages: DeprecatedPackage[]; deprecated_packages: DeprecatedPackage[];
sdk?: any; sdk?: any;
} }

View file

@ -19,6 +19,8 @@ type LauncherState =
| 'game-installation-available' | 'game-installation-available'
| 'game-update-available' | 'game-update-available'
| 'game-voice-update-required' | 'game-voice-update-required'
| 'game-pre-installation-available'
| 'game-voice-pre-installation-available'
| 'game-launch-available'; | 'game-launch-available';
export type { LauncherState }; export type { LauncherState };