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>Splash screen</s>
* <s>Theming system</s>
* Game pre-installation
* <s>Game pre-installation</s>
* Launcher auto-updates
* Statistics window
* Chengelog window

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -5,9 +5,6 @@
<script lang="ts">
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 Launcher from './ts/Launcher';
@ -15,11 +12,15 @@
import Game from './ts/Game';
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', () => {
Window.open('splash', {
title: 'Splash',
width: 400,
height: 500,
width: 300,
height: 400,
borderless: true,
exitProcessOnClose: false
});
@ -82,5 +83,9 @@
<img src={GearActive} class="active" alt="Settings">
</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>
</main>

View file

@ -35,16 +35,32 @@
background-color: #7284b6
#launch
position: absolute
width: 238px
height: 64px
font-size: 22px
position: absolute
right: 128px
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
width: 76px
height: 76px

View file

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

View file

@ -11,6 +11,8 @@ import AbstractInstaller from './core/AbstractInstaller';
import Domain from './core/Domain';
import promisify from './core/promisify';
import Debug, { DebugThread } from './core/Debug';
import Downloader, { Stream as DownloadingStream } from './core/Downloader';
import Cache from './core/Cache';
declare const Neutralino;
@ -67,12 +69,24 @@ export default class Game
if (response.ok)
{
const json: ServerResponse = JSON.parse(await response.body());
const cache = await Cache.get('Game.getLatestData.ServerResponse');
if (json.message == 'OK')
resolve(json.data);
if (cache && !cache.expired)
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`));
@ -141,16 +155,19 @@ export default class Game
/**
* Get the game installation stream
*
* @param version current game version to download difference from
*
* @returns null if the version can't be found
* @returns Error if company's servers are unreachable or they responded with an error
*/
public static update(version: string|null = null): Promise<Stream|null>
{
Debug.log(
version !== null ?
`Updating the game from the ${version} version` :
'Installing the game'
);
Debug.log({
function: 'Game.update',
message: version !== null ?
`Updating the game from the ${version} version` :
'Installing the game'
});
return new Promise((resolve, reject) => {
(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
*/

View file

@ -5,7 +5,8 @@ import constants from './Constants';
import Game from './Game';
import AbstractInstaller from './core/AbstractInstaller';
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;
@ -118,13 +119,14 @@ export default class Voice
* @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
*/
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(
version !== null ?
`Updating the voice package from the ${version} version` :
'Installing the voice package'
);
Debug.log({
function: 'Voice.update',
message: version !== null ?
`Updating the voice package from the ${version} version` :
'Installing the voice package'
});
return new Promise((resolve, reject) => {
(version === null ? this.latest : this.getDiff(version))
@ -142,6 +144,65 @@ export default class Voice
.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 };

View file

@ -1,4 +1,5 @@
import constants from '../Constants';
import Debug from './Debug';
type Record = {
expired: boolean;
@ -9,6 +10,10 @@ declare const Neutralino;
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
*
@ -17,19 +22,44 @@ export default class Cache
public static get(name: string): Promise<Record|null>
{
return new Promise(async (resolve) => {
Neutralino.filesystem.readFile(await constants.paths.cache)
.then((cache) => {
cache = JSON.parse(cache);
if (this.cache !== null && this.cache[name] !== undefined)
{
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);
else
{
resolve({
expired: cache[name].ttl !== null ? Date.now() > cache[name].ttl * 1000 : false,
value: JSON.parse(atob(cache[name].value))
const output = {
expired: this.cache![name].ttl !== null ? Date.now() > this.cache![name].ttl * 1000 : false,
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));
@ -49,19 +79,40 @@ export default class Cache
{
return new Promise((resolve) => {
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)
.then((cacheRaw) => cache = JSON.parse(cacheRaw))
.catch(() => {});
writeCache();
})
.catch(() => {
this.cache = {};
cache[name] = {
ttl: ttl !== null ? Math.round(Date.now() / 1000) + ttl : null,
value: btoa(JSON.stringify(value))
writeCache();
});
}
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 launchButton: HTMLElement;
public predownloadButton: HTMLElement;
protected _state: LauncherState = 'game-launch-available';
@ -34,6 +35,7 @@ export default class State
this.launcher = launcher;
this.launchButton = <HTMLElement>document.getElementById('launch');
this.predownloadButton = <HTMLElement>document.getElementById('predownload');
this.launchButton.onclick = () => {
if (this.events[this._state])
@ -42,14 +44,30 @@ export default class State
this.events[this._state].then((event) => {
event.default(this.launcher).then(() => {
this.launchButton.style['display'] = 'block';
this.update();
this.update().then(() => {
this.launchButton.style['display'] = 'block';
});
});
});
}
};
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(() => {
Neutralino.storage.setData('launcherLoaded', 'aboba');
@ -73,6 +91,7 @@ export default class State
this._state = state;
this.launcher.progressBar!.hide();
this.predownloadButton.style['display'] = 'none';
switch(state)
{
@ -81,6 +100,14 @@ export default class State
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':
this.launchButton.textContent = 'Install';
@ -125,14 +152,14 @@ export default class State
let state: LauncherState;
const gameCurrent = await Game.current;
const gameLatest = (await Game.latest).version;
const gameLatest = await Game.getLatestData();
const patch = await Patch.latest;
const voiceData = await Voice.current;
if (gameCurrent === null)
state = 'game-installation-available';
else if (gameCurrent != gameLatest)
else if (gameCurrent != gameLatest.game.latest.version)
state = 'game-update-available';
// TODO: update this thing if the user selected another voice language
@ -146,6 +173,12 @@ export default class State
'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';
this.set(state);

View file

@ -5,6 +5,7 @@ import Notifications from '../../core/Notifications';
import Runners from '../../core/Runners';
import Game from '../../Game';
import Process from '../../neutralino/Process';
import Window from '../../neutralino/Window';
declare const Neutralino;
@ -32,6 +33,8 @@ export default (): Promise<void> => {
// Otherwise run the game
else
{
Window.current.hide();
/**
* Selecting wine executable
*/
@ -139,6 +142,8 @@ export default (): Promise<void> => {
process.finish(() => {
const stopTime = Date.now();
Window.current.show();
// todo
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;
}
type PreDownloadGame = {
latest: Latest;
diffs: Diff[];
};
type Data = {
game: Game;
plugin: Plugin;
web_url: string;
force_update?: any;
pre_download_game?: any;
pre_download_game?: PreDownloadGame;
deprecated_packages: DeprecatedPackage[];
sdk?: any;
}

View file

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