Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions GDJS/GDJS/IDE/ExporterHelper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -492,17 +492,22 @@ void ExporterHelper::SerializeRuntimeGameOptions(
}

// Pass in the options the list of scripts files - useful for hot-reloading.
auto &scriptFilesElement = runtimeGameOptions.AddChild("scriptFiles");
scriptFilesElement.ConsiderAsArrayOf("scriptFile");

for (const auto &includeFile : includesFiles) {
auto hashIt = options.includeFileHashes.find(includeFile);
gd::String scriptSrc = GetExportedIncludeFilename(fs, gdjsRoot, includeFile);
scriptFilesElement.AddChild("scriptFile")
.SetStringAttribute("path", scriptSrc)
.SetIntAttribute(
"hash",
hashIt != options.includeFileHashes.end() ? hashIt->second : 0);
// If includeFiles is empty, it means that the include files have not been
// generated, so do not even add them to the runtime game options, so the
// hot-reloader will not try to reload them.
if (!includesFiles.empty()) {
auto &scriptFilesElement = runtimeGameOptions.AddChild("scriptFiles");
scriptFilesElement.ConsiderAsArrayOf("scriptFile");

for (const auto &includeFile : includesFiles) {
auto hashIt = options.includeFileHashes.find(includeFile);
gd::String scriptSrc = GetExportedIncludeFilename(fs, gdjsRoot, includeFile);
scriptFilesElement.AddChild("scriptFile")
.SetStringAttribute("path", scriptSrc)
.SetIntAttribute(
"hash",
hashIt != options.includeFileHashes.end() ? hashIt->second : 0);
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion GDJS/Runtime/ResourceLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,11 @@ namespace gdjs {
*/
getFullUrl(url: string) {
if (this._runtimeGame.isInGameEdition()) {
url = addSearchParameterToUrl(url, 'cache', '' + Date.now());
// Avoid adding cache burst to URLs which are assumed to be immutable files,
// to avoid costly useless requests each time the game is hot-reloaded.
if (url.startsWith('file://')) {
url = addSearchParameterToUrl(url, 'cache', '' + Date.now());
}
}
const { gdevelopResourceToken } = this._runtimeGame._options;
if (!gdevelopResourceToken) return url;
Expand Down
234 changes: 170 additions & 64 deletions GDJS/Runtime/debugger-client/hot-reloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ namespace gdjs {
behaviorTypeName: string;
};

/** Each hot-reload has a unique ID to ease the debugging/reading logs. */
let nextHotReloadId = 1;

type HotReloadOptions = {
shouldReloadResources: boolean;
projectData: ProjectData;
runtimeGameOptions: RuntimeGameOptions;
};

const cloneHotReloadOptions = (
options: HotReloadOptions
): HotReloadOptions => {
return JSON.parse(JSON.stringify(options));
};

const getOptionsLogString = (options: HotReloadOptions): string => {
return JSON.stringify({
shouldReloadResources: options.shouldReloadResources,
shouldReloadLibraries: options.runtimeGameOptions.shouldReloadLibraries,
shouldGenerateScenesEventsCode:
options.runtimeGameOptions.shouldGenerateScenesEventsCode,
newScriptFilesCount: options.runtimeGameOptions.scriptFiles?.length,
});
};

/**
* Reload scripts/data of an exported game and applies the changes
* to the running runtime game.
Expand All @@ -20,13 +45,25 @@ namespace gdjs {
_reloadedScriptElement: Record<string, HTMLScriptElement> = {};
_logs: HotReloaderLog[] = [];
_alreadyLoadedScriptFiles: Record<string, boolean> = {};
_isHotReloading: boolean = false;
_existingScriptFiles: RuntimeGameOptionsScriptFile[] | null = null;
_isHotReloadingSince: number | null = null;

_hotReloadsQueue: Array<{
hotReloadId: number;
onDone: (logs: HotReloaderLog[]) => void;
options: HotReloadOptions;
}> = [];

/**
* @param runtimeGame - The `gdjs.RuntimeGame` to be hot-reloaded.
*/
constructor(runtimeGame: gdjs.RuntimeGame) {
this._runtimeGame = runtimeGame;

// Remember the script files that were loaded when the game was started.
if (this._runtimeGame._options.scriptFiles) {
this._existingScriptFiles = this._runtimeGame._options.scriptFiles;
}
}

static indexByPersistentUuid<
Expand Down Expand Up @@ -145,23 +182,57 @@ namespace gdjs {
});
}

async hotReload({
shouldReloadResources,
projectData: newProjectData,
runtimeGameOptions: newRuntimeGameOptions,
}: {
shouldReloadResources: boolean;
projectData: ProjectData;
runtimeGameOptions: RuntimeGameOptions;
}): Promise<HotReloaderLog[]> {
if (this._isHotReloading) {
console.error('Hot reload already in progress, skipping.');
return [];
/**
* Trigger a hot-reload of the game.
* The hot-reload is added to a queue and processed in order.
*
* This allows the editor to trigger multiple hot-reloads in a row (even if
* it's sub-optimal) and not miss any (one could for example be reloading libraries
* or code, while other are just reloading resources).
*/
async hotReload(options: HotReloadOptions): Promise<HotReloaderLog[]> {
return new Promise((resolve) => {
const hotReloadId = nextHotReloadId++;

this._hotReloadsQueue.push({
hotReloadId,
onDone: resolve,
// Clone the options to avoid any mutation while
// waiting for the hot-reload to be processed.
options: cloneHotReloadOptions(options),
});

if (this._hotReloadsQueue.length > 1) {
logger.info(
`Hot reload #${hotReloadId} added to queue. Options are: ${getOptionsLogString(options)}.`
);
}

this._processHotReloadsQueue();
});
}

private async _processHotReloadsQueue(): Promise<void> {
// Don't do anything if a hot-reload is already in progress:
// it will be processed later (see the end).
if (this._isHotReloadingSince || this._hotReloadsQueue.length === 0) {
return;
}

this._isHotReloading = true;
// Mark the hot reload as started (so no other hot-reload is started).
this._isHotReloadingSince = Date.now();

const { options, onDone, hotReloadId } = this._hotReloadsQueue.shift()!;
const {
shouldReloadResources,
projectData: newProjectData,
runtimeGameOptions: newRuntimeGameOptions,
} = options;

logger.info(
`Hot reload #${hotReloadId} started. Options are: ${getOptionsLogString(options)}.`
);

logger.info('Hot reload started');
const wasPaused = this._runtimeGame.isPaused();
this._runtimeGame.pause(true);
this._logs = [];
Expand All @@ -171,17 +242,13 @@ namespace gdjs {
const oldProjectData: ProjectData = gdjs.projectData;
gdjs.projectData = newProjectData;

const oldRuntimeGameOptions = gdjs.runtimeGameOptions;
gdjs.runtimeGameOptions = newRuntimeGameOptions;

const oldScriptFiles =
oldRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[];

oldScriptFiles.forEach((scriptFile) => {
(this._existingScriptFiles || []).forEach((scriptFile) => {
this._alreadyLoadedScriptFiles[scriptFile.path] = true;
});
const oldBehaviorConstructors: { [key: string]: Function } = {};

gdjs.runtimeGameOptions = newRuntimeGameOptions;

const oldBehaviorConstructors: { [key: string]: Function } = {};
for (let behaviorTypeName in gdjs.behaviorsTypes.items) {
oldBehaviorConstructors[behaviorTypeName] =
gdjs.behaviorsTypes.items[behaviorTypeName];
Expand All @@ -195,8 +262,7 @@ namespace gdjs {
);
}

const newScriptFiles =
newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[];
const newScriptFiles = newRuntimeGameOptions.scriptFiles;
const shouldGenerateScenesEventsCode =
!!newRuntimeGameOptions.shouldGenerateScenesEventsCode;
const shouldReloadLibraries =
Expand All @@ -210,7 +276,7 @@ namespace gdjs {
if (shouldReloadLibraries) {
await this.reloadScriptFiles(
newProjectData,
oldScriptFiles,
this._existingScriptFiles,
newScriptFiles,
shouldGenerateScenesEventsCode
);
Expand Down Expand Up @@ -278,13 +344,32 @@ namespace gdjs {
}
}

this._isHotReloading = false;
// Remember the script files that were loaded for the game now that
// the hot-reload is finished. This will allow a next hot-reload to
// reload the scripts files that have been added or changed.
// Note that some hot-reload options do not have any "scriptFiles", in which
// case the game script files have not changed.
if (newRuntimeGameOptions.scriptFiles) {
this._existingScriptFiles = newRuntimeGameOptions.scriptFiles;
}

logger.info(
'Hot reload finished with logs:',
this._logs.map((log) => '\n' + log.kind + ': ' + log.message)
`Hot reload #${hotReloadId} finished in ${Math.ceil(Date.now() - this._isHotReloadingSince)}ms with logs:\n${
this._logs.length > 0
? this._logs.map((log) => '\n' + log.kind + ': ' + log.message)
: '(no logs)'
}`
);
this._isHotReloadingSince = null;
this._runtimeGame.pause(wasPaused);
return this._logs;
onDone(this._logs);

if (this._hotReloadsQueue.length > 0) {
logger.info(
`Still ${this._hotReloadsQueue.length} hot-reloads in queue. Starting the next one...`
);
this._processHotReloadsQueue();
}
}

_computeChangedRuntimeBehaviors(
Expand Down Expand Up @@ -327,8 +412,8 @@ namespace gdjs {

reloadScriptFiles(
newProjectData: ProjectData,
oldScriptFiles: RuntimeGameOptionsScriptFile[],
newScriptFiles: RuntimeGameOptionsScriptFile[],
oldScriptFiles: RuntimeGameOptionsScriptFile[] | null,
newScriptFiles: RuntimeGameOptionsScriptFile[] | undefined,
shouldGenerateScenesEventsCode: boolean
): Promise<void[]> {
const reloadPromises: Array<Promise<void>> = [];
Expand All @@ -339,46 +424,67 @@ namespace gdjs {
reloadPromises.push(this._reloadScript('code' + index + '.js'));
});
}
for (let i = 0; i < newScriptFiles.length; ++i) {
const newScriptFile = newScriptFiles[i];
const oldScriptFile = oldScriptFiles.filter(
(scriptFile) => scriptFile.path === newScriptFile.path
)[0];
if (!oldScriptFile) {
// Script file added
this._logs.push({
kind: 'info',
message:
'Loading ' +
newScriptFile.path +
' as it was added to the list of scripts.',
});
reloadPromises.push(this._reloadScript(newScriptFile.path));
} else {
// Script file changed, which can be the case for extensions created
// from the editor, containing free functions or behaviors.
if (newScriptFile.hash !== oldScriptFile.hash) {

if (!newScriptFiles) {
// Script files were not exported for this hot-reload.
// This means the hot-reload was just done for a new resource, object
// or other thing not reload to code generation. Just do nothing.
logger.info(
'Script files were not exported (previously or now for this hot-reload).'
);
} else if (!oldScriptFiles) {
// Script files are not available. This is suspicious as we should always
// have them stored.
logger.error(
'Existing script files are not available for the hot-reload. No new or modified script will be hot-reloaded.'
);

// TODO: Consider if this should be communicated as an error or fatal error.
} else {
for (let i = 0; i < newScriptFiles.length; ++i) {
const newScriptFile = newScriptFiles[i];
const oldScriptFile = oldScriptFiles.filter(
(scriptFile) => scriptFile.path === newScriptFile.path
)[0];
if (!oldScriptFile) {
// Script file added
this._logs.push({
kind: 'info',
message:
'Reloading ' + newScriptFile.path + ' because it was changed.',
'Loading ' +
newScriptFile.path +
' as it was added to the list of scripts.',
});
reloadPromises.push(this._reloadScript(newScriptFile.path));
} else {
// Script file changed, which can be the case for extensions created
// from the editor, containing free functions or behaviors.
if (newScriptFile.hash !== oldScriptFile.hash) {
this._logs.push({
kind: 'info',
message:
'Reloading ' +
newScriptFile.path +
' because it was changed.',
});
reloadPromises.push(this._reloadScript(newScriptFile.path));
}
}
}
}
for (let i = 0; i < oldScriptFiles.length; ++i) {
const oldScriptFile = oldScriptFiles[i];
const newScriptFile = newScriptFiles.filter(
(scriptFile) => scriptFile.path === oldScriptFile.path
)[0];
for (let i = 0; i < oldScriptFiles.length; ++i) {
const oldScriptFile = oldScriptFiles[i];
const newScriptFile = newScriptFiles.filter(
(scriptFile) => scriptFile.path === oldScriptFile.path
)[0];

// A file may be removed because of a partial preview.
if (!newScriptFile && !shouldGenerateScenesEventsCode) {
this._logs.push({
kind: 'warning',
message: 'Script file ' + oldScriptFile.path + ' was removed.',
});
// A file may be removed because of a partial preview.
if (!newScriptFile && !shouldGenerateScenesEventsCode) {
this._logs.push({
kind: 'warning',
message:
'Script file ' + oldScriptFile.path + ' was removed. Ignoring.',
});
}
}
}
return Promise.all(reloadPromises);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,4 +312,5 @@ namespace gdjs {

//Register the class to let the engine use it.
export const LoadingScreenRenderer = LoadingScreenPixiRenderer;
export type LoadingScreenRenderer = LoadingScreenPixiRenderer;
}
Loading