sync: upstream 4ian/GDevelop (3 commits: external layouts, translations, gameplay-test fix) (#140)

* Add support for external layouts for agents (#9121)

(cherry picked from commit 09685bfa88)

* [Auto PR] Update translations (#9122)

(cherry picked from commit c6d0921103)

* Fix gameplay test being mistakenly erased and improve layer error messages (#9124)

(cherry picked from commit b9109cfbaa)

---------

Co-authored-by: Florian Rival <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
BillyOutlast
2026-09-21 21:51:01 -04:00
committed by GitHub
co-authored by Florian Rival github-actions[bot]
parent 9e9f1f99ca
commit 960167c27f
84 changed files with 2048 additions and 152 deletions
@@ -13,12 +13,30 @@ void GD_CORE_API
BuiltinExtensionsImplementer::ImplementsExternalLayoutsExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("BuiltinExternalLayouts",
_("External layouts"),
"Provides actions and conditions related to "
"external layouts.",
"Florian Rival",
"Open source (MIT License)")
.SetExtensionInformation(
"BuiltinExternalLayouts",
_("External layouts"),
_("External layouts hold instances placed apart from a scene, "
"using the objects and layers of the scene they are associated "
"with. Their instances are not created when the scene starts: "
"the scene creates them when needed, with the action to create "
"objects from an external layout, at their editor positions "
"(offset by an origin), on their layer, with their size, angle, "
"Z order, visibility and instance variables. The objects and "
"layers used must exist in the scene creating them (any "
"external layout of the project can be loaded, not only the "
"ones associated with the scene). Nothing is removed first: "
"creating the same external layout again duplicates its "
"objects, so delete the previous instances (for example the "
"previous level) before loading new ones. Typical uses: the "
"levels of a game built in a single scene (one external layout "
"per level, loaded at the beginning of the scene according to "
"a variable), a room or an enemy wave spawned on demand. For a "
"UI panel, a custom object is usually a better fit (it is "
"reusable, resizable and can hold its own logic); use an "
"external layout for a simple, one-off UI screen."),
"Florian Rival",
"Open source (MIT License)")
.SetShortDescription("Create objects from an external layout to reuse level sections or UI templates.")
.SetExtensionHelpPath("/interface/scene-editor/external-layouts")
.SetCategory("Advanced");
@@ -28,7 +46,10 @@ BuiltinExtensionsImplementer::ImplementsExternalLayoutsExtension(
extension
.AddAction("CreateObjectsFromExternalLayout",
_("Create objects from an external layout"),
_("Create objects from an external layout."),
_("Create in the scene all the instances of an external "
"layout, as placed in its editor (positions offset by the "
"given origin). Nothing is removed first: calling the "
"action again creates the objects again."),
_("Create objects from the external layout named _PARAM1_ at position _PARAM2_;_PARAM3_;_PARAM4_"),
"",
"res/ribbon_default/externallayout32.png",
@@ -36,10 +57,17 @@ BuiltinExtensionsImplementer::ImplementsExternalLayoutsExtension(
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("externalLayoutName", _("Name of the external layout"))
.AddParameter("expression", _("X position of the origin"), "", true)
.SetParameterLongDescription(
_("Added to the X position of every created instance (0 keeps the "
"positions of the external layout)."))
.SetDefaultValue("0")
.AddParameter("expression", _("Y position of the origin"), "", true)
.SetParameterLongDescription(
_("Added to the Y position of every created instance."))
.SetDefaultValue("0")
.AddParameter("expression", _("Z position of the origin"), "", true)
.SetParameterLongDescription(
_("Added to the Z position of every created 3D instance."))
.SetDefaultValue("0")
.MarkAsAdvanced();
}
@@ -160,6 +160,7 @@ type Props = {|
| 'none',
|}
) => void,
onOpenExternalLayout: (externalLayoutName: string) => void,
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
@@ -296,6 +297,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
onCreateProjectFromExample,
onCreateEmptyProject,
onOpenLayout,
onOpenExternalLayout,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
onObjectsModifiedOutsideEditor,
@@ -391,12 +393,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
const editorCallbacks: EditorCallbacks = React.useMemo(
() => ({
onOpenLayout,
onOpenExternalLayout,
onCreateProject,
onOpenEventsFunctionsExtension: onOpenEventsFunctionsExtensionItem,
onOpenCustomObjectEditor,
}),
[
onOpenLayout,
onOpenExternalLayout,
onCreateProject,
onOpenEventsFunctionsExtensionItem,
onOpenCustomObjectEditor,
@@ -787,12 +791,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
aiRequestId,
userMessage,
createdSceneNames,
createdExternalLayoutNames,
createdProject,
editorFunctionCallResults,
}: {|
aiRequestId: string,
userMessage: string,
createdSceneNames?: Array<string>,
createdExternalLayoutNames?: Array<string>,
createdProject?: ?gdProject,
editorFunctionCallResults: Array<EditorFunctionCallResult>,
|}) => {
@@ -988,6 +994,14 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
});
});
}
if (
createdExternalLayoutNames &&
createdExternalLayoutNames.length > 0
) {
createdExternalLayoutNames.forEach(externalLayoutName => {
onOpenExternalLayout(externalLayoutName);
});
}
},
[
profile,
@@ -1008,6 +1022,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
refreshLimits,
project,
onOpenLayout,
onOpenExternalLayout,
automaticallyUseCreditsForAiRequests,
triggerUnsavedChanges,
]
@@ -1048,6 +1063,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
editorFunctionCallResults: Array<EditorFunctionCallResult>,
options: {|
createdSceneNames?: Array<string>,
createdExternalLayoutNames?: Array<string>,
createdProject?: ?gdProject,
|}
) => {
@@ -1056,6 +1072,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
userMessage: '',
createdProject: options.createdProject,
createdSceneNames: options.createdSceneNames,
createdExternalLayoutNames: options.createdExternalLayoutNames,
editorFunctionCallResults,
});
},
@@ -1810,6 +1827,7 @@ export const renderAskAiEditorContainer = (
onCreateProjectFromExample={props.onCreateProjectFromExample}
onCreateEmptyProject={props.onCreateEmptyProject}
onOpenLayout={props.onOpenLayout}
onOpenExternalLayout={props.onOpenExternalLayout}
onSceneEventsModifiedOutsideEditor={
props.onSceneEventsModifiedOutsideEditor
}
@@ -164,7 +164,8 @@ export const AskAiStandAloneForm = ({
() => ({
onOpenLayout,
onCreateProject,
// The stand-alone form has no extension editors to open.
// The stand-alone form has no external layout or extension editors to open.
onOpenExternalLayout: () => {},
onOpenEventsFunctionsExtension: () => {},
onOpenCustomObjectEditor: () => {},
}),
@@ -594,6 +595,8 @@ export const AskAiStandAloneForm = ({
editorFunctionCallResults: Array<EditorFunctionCallResult>,
options: {|
createdSceneNames?: Array<string>,
// Not opened by the stand-alone form (no editor to open them in).
createdExternalLayoutNames?: Array<string>,
createdProject?: ?gdProject,
|}
) => {
+4 -1
View File
@@ -114,7 +114,7 @@ export const useRefreshLimits = (
// v14 adds gameplay tests (`run_tests` + the tester sub-agent).
// v15 makes read_game_project_json a live, editor-side read (backend stops
// overwriting its output) and exposes it to the edit/explorer script agents.
export const AI_ORCHESTRATOR_TOOLS_VERSION: string = 'v18';
export const AI_ORCHESTRATOR_TOOLS_VERSION: string = 'v19';
/**
* A pending request for the user to approve (or refuse) a project-modifying
@@ -275,6 +275,7 @@ export const useProcessFunctionCalls = ({
editorFunctionCallResults: Array<EditorFunctionCallResult>,
options: {|
createdSceneNames?: Array<string>,
createdExternalLayoutNames?: Array<string>,
createdProject?: ?gdProject,
|}
) => Promise<void>,
@@ -628,6 +629,7 @@ export const useProcessFunctionCalls = ({
const {
results,
createdSceneNames,
createdExternalLayoutNames,
createdProject,
} = await processEditorFunctionCalls({
project,
@@ -731,6 +733,7 @@ export const useProcessFunctionCalls = ({
await onSendEditorFunctionCallResults(aiRequest.id, newResults, {
createdSceneNames,
createdExternalLayoutNames,
createdProject,
});
} finally {
@@ -125,10 +125,12 @@ export const processEditorFunctionCalls = async ({
}: ProcessEditorFunctionCallsOptions): Promise<{|
results: Array<EditorFunctionCallResult>,
createdSceneNames: Array<string>,
createdExternalLayoutNames: Array<string>,
createdProject: ?gdProject,
|}> => {
const results: Array<EditorFunctionCallResult> = [];
const createdSceneNames: Array<string> = [];
const createdExternalLayoutNames: Array<string> = [];
let createdProject: ?gdProject = null;
for (const functionCall of functionCalls) {
@@ -306,6 +308,9 @@ export const processEditorFunctionCalls = async ({
if (meta && meta.newSceneNames) {
createdSceneNames.push(...meta.newSceneNames);
}
if (meta && meta.newExternalLayoutNames) {
createdExternalLayoutNames.push(...meta.newExternalLayoutNames);
}
if (meta && meta.createdProject) {
createdProject = meta.createdProject;
}
@@ -319,5 +324,10 @@ export const processEditorFunctionCalls = async ({
}
}
return { results, createdSceneNames, createdProject };
return {
results,
createdSceneNames,
createdExternalLayoutNames,
createdProject,
};
};
@@ -0,0 +1,723 @@
// @flow
import { editorFunctions, type EditorFunctionGenericOutput } from './index';
import { makeFakeLaunchFunctionOptionsWithProject } from './TestHelpers';
import { makeTestExtensions } from '../fixtures/TestExtensions';
import { makeSimplifiedProjectBuilder } from './SimplifiedProject/SimplifiedProject';
const gd: libGDevelop = global.gd;
const GAME_SCOPE = { type: 'scene', scene_name: 'Game' };
const LEVEL1_SCOPE = {
type: 'external_layout',
external_layout_name: 'Level1',
};
/**
* A game scene with two layers, a scene object and a global object, an
* external layout "Level1" of this scene, and a second scene "Menu" without
* the "Sky" layer nor the "Platform" object.
*/
const createFakeProject = (project: gdProject) => {
const game = project.insertNewLayout('Game', 0);
game.getLayers().insertNewLayer('Sky', 1);
game.getObjects().insertNewObject(project, 'Sprite', 'Platform', 0);
game.getObjects().insertNewObject(project, 'Sprite', 'Coin', 1);
project.getObjects().insertNewObject(project, 'Sprite', 'Hud', 0);
project.insertNewExternalLayout('Level1', 0).setAssociatedLayout('Game');
project.insertNewLayout('Menu', 1);
return game;
};
const addInstance = (
container: gdInitialInstancesContainer,
objectName: string,
{
x,
y,
layer,
zOrder,
}: {| x: number, y: number, layer?: string, zOrder?: number |}
): gdInitialInstance => {
const instance = container.insertNewInitialInstance();
instance.setObjectName(objectName);
instance.setX(x);
instance.setY(y);
if (layer !== undefined) instance.setLayer(layer);
if (zOrder !== undefined) instance.setZOrder(zOrder);
return instance;
};
const getInstanceObjectNames = (
container: gdInitialInstancesContainer
): Array<string> => {
const names = [];
const functor = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
functor.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
names.push(instance.getObjectName());
};
// $FlowFixMe[incompatible-type]
container.iterateOverInstances(functor);
functor.delete();
return names.sort();
};
describe('external layouts as a scope of the container tools', () => {
let project: gdProject;
let game: gdLayout;
beforeEach(() => {
makeTestExtensions(gd);
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
game = createFakeProject(project);
});
afterEach(() => {
project.delete();
});
describe('create_scene with as_external_layout_of_scene', () => {
it('creates an external layout of an existing scene and reports it as created', async () => {
const result: EditorFunctionGenericOutput = await editorFunctions.create_scene.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scene_name: 'Level2',
as_external_layout_of_scene: 'Game',
// Ignored, with a note.
include_ui_layer: true,
},
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'Created external layout "Level2" for scene "Game".'
);
expect(result.message).toContain('include_ui_layer ignored');
expect(result.message).toContain(
'CreateObjectsFromExternalLayout("Level2", 0, 0)'
);
expect(result.meta).toEqual({ newExternalLayoutNames: ['Level2'] });
expect(project.hasExternalLayoutNamed('Level2')).toBe(true);
expect(project.getExternalLayout('Level2').getAssociatedLayout()).toBe(
'Game'
);
// No scene was created.
expect(project.hasLayoutNamed('Level2')).toBe(false);
});
it('is idempotent for an existing external layout of the same scene', async () => {
const result: EditorFunctionGenericOutput = await editorFunctions.create_scene.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: { scene_name: 'Level1', as_external_layout_of_scene: 'Game' },
}
);
expect(result.success).toBe(true);
expect(result.message).toBe(
'External layout "Level1" already exists (for scene "Game").'
);
expect(project.getExternalLayoutsCount()).toBe(1);
});
it('refuses an unknown scene, an existing scene name and another associated scene', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
const unknownScene: EditorFunctionGenericOutput = await editorFunctions.create_scene.launchFunction(
{
...fakeOptions,
args: { scene_name: 'Level2', as_external_layout_of_scene: 'Nope' },
}
);
expect(unknownScene.success).toBe(false);
expect(unknownScene.message).toContain(
'Scene not found: "Nope". Scenes in this project: "Game", "Menu".'
);
const sceneName: EditorFunctionGenericOutput = await editorFunctions.create_scene.launchFunction(
{
...fakeOptions,
args: { scene_name: 'Menu', as_external_layout_of_scene: 'Game' },
}
);
expect(sceneName.success).toBe(false);
expect(sceneName.message).toBe(
'A scene is already named "Menu": choose another name for the external layout.'
);
const otherScene: EditorFunctionGenericOutput = await editorFunctions.create_scene.launchFunction(
{
...fakeOptions,
args: { scene_name: 'Level1', as_external_layout_of_scene: 'Menu' },
}
);
expect(otherScene.success).toBe(false);
expect(otherScene.message).toContain(
'External layout "Level1" already exists and is associated with scene "Game".'
);
expect(project.getExternalLayoutsCount()).toBe(1);
expect(project.getExternalLayout('Level1').getAssociatedLayout()).toBe(
'Game'
);
});
});
describe('change_scene_properties_layers_effects_groups', () => {
it('renames an external layout, updating the events and the open tabs', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
// An event creating the objects of the external layout.
const event = game
.getEvents()
.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 0);
const action = new gd.Instruction();
action.setType('BuiltinExternalLayouts::CreateObjectsFromExternalLayout');
action.setParametersCount(5);
action.setParameter(1, '"Level1"');
gd.asStandardEvent(event)
.getActions()
.insert(action, 0);
action.delete();
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: {
scope: LEVEL1_SCOPE,
changed_properties: [
{ property_name: 'name', new_value: 'Level 1 - Forest' },
],
},
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'Renamed external layout "Level1" to "Level 1 - Forest" (the events creating its objects were updated).'
);
expect(project.hasExternalLayoutNamed('Level 1 - Forest')).toBe(true);
expect(project.hasExternalLayoutNamed('Level1')).toBe(false);
expect(
gd
.asStandardEvent(game.getEvents().getEventAt(0))
.getActions()
.get(0)
.getParameter(1)
.getPlainString()
).toBe('"Level 1 - Forest"');
expect(
fakeOptions.onProjectItemRenamedOutsideEditor
).toHaveBeenCalledWith({
kind: 'external-layout',
oldName: 'Level1',
newName: 'Level 1 - Forest',
});
});
it('keeps names unique when renaming, and refuses an empty name', async () => {
project.insertNewExternalLayout('Level2', 1).setAssociatedLayout('Game');
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: LEVEL1_SCOPE,
changed_properties: [
{ property_name: 'name', new_value: 'Level2' },
],
},
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'Renamed external layout "Level1" to "Level3"'
);
const emptyName: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: { type: 'external_layout', external_layout_name: 'Level3' },
changed_properties: [{ property_name: 'name', new_value: ' ' }],
},
}
);
expect(emptyName.warnings).toContain(
'An external layout name cannot be empty. Skipped.'
);
expect(project.hasExternalLayoutNamed('Level3')).toBe(true);
});
it('re-associates an external layout with another scene when its instances fit there', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
const externalLayout = project.getExternalLayout('Level1');
// A global object on the base layer: fine in any scene.
addInstance(externalLayout.getInitialInstances(), 'Hud', { x: 0, y: 0 });
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: {
scope: LEVEL1_SCOPE,
changed_properties: [
{ property_name: 'associatedScene', new_value: 'Menu' },
],
},
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'External layout "Level1" is now associated with scene "Menu".'
);
expect(externalLayout.getAssociatedLayout()).toBe('Menu');
expect(fakeOptions.onInstancesModifiedOutsideEditor).toHaveBeenCalledWith(
{ scene: project.getLayout('Menu'), externalLayout }
);
});
it('refuses to re-associate an external layout whose instances use objects or layers the scene lacks', async () => {
const externalLayout = project.getExternalLayout('Level1');
addInstance(externalLayout.getInitialInstances(), 'Platform', {
x: 0,
y: 0,
layer: 'Sky',
});
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: LEVEL1_SCOPE,
changed_properties: [
{ property_name: 'associatedScene', new_value: 'Menu' },
{ property_name: 'associatedScene', new_value: 'Nope' },
{ property_name: 'backgroundColor', new_value: '#000000' },
],
},
}
);
expect(result.warnings).toContain(
'External layout "Level1" was NOT associated with scene "Menu": its instances use objects this scene does not have (nor globally): "Platform" and layers this scene does not have: "Sky". Move or erase these instances first (or add the objects/layers to the scene).'
);
expect(result.warnings).toContain(
'Scene not found: "Nope". Scenes in this project: "Game", "Menu". External layout "Level1" was NOT re-associated.'
);
expect(result.warnings).toContain(
'Unknown external layout property: "backgroundColor". Skipped. An external layout only has `name` and `associatedScene`'
);
expect(externalLayout.getAssociatedLayout()).toBe('Game');
});
it('refuses layers, effects and groups changes through an external layout scope, changing nothing', async () => {
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: LEVEL1_SCOPE,
changed_properties: [{ property_name: 'name', new_value: 'Other' }],
changed_layers: [{ layer_name: 'Clouds' }],
},
}
);
expect(result.success).toBe(false);
expect(result.message).toBe(
'Layers, layer effects and object groups belong to the associated scene "Game" of external layout "Level1": use scope { type: "scene", scene_name: "Game" } to change them. Nothing was changed.'
);
expect(project.hasExternalLayoutNamed('Level1')).toBe(true);
expect(game.hasLayerNamed('Clouds')).toBe(false);
});
it('deletes an external layout after letting the editor close its tabs', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
const externalLayout = project.getExternalLayout('Level1');
addInstance(externalLayout.getInitialInstances(), 'Coin', { x: 0, y: 0 });
addInstance(externalLayout.getInitialInstances(), 'Coin', { x: 1, y: 0 });
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: { scope: LEVEL1_SCOPE, delete_this_scene: true },
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'Deleted external layout "Level1" (2 instance(s) removed with it).'
);
expect(result.message).toContain(
'CreateObjectsFromExternalLayout("Level1", ...)'
);
expect(project.hasExternalLayoutNamed('Level1')).toBe(false);
expect(fakeOptions.onWillDeleteScene).toHaveBeenCalledWith({
externalLayout,
});
// The scene itself is untouched.
expect(project.hasLayoutNamed('Game')).toBe(true);
});
});
describe('move_instances', () => {
it('moves every instance of a scene to its external layout, keeping their properties and ids', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
const platform = addInstance(game.getInitialInstances(), 'Platform', {
x: 100,
y: 200,
layer: 'Sky',
zOrder: 7,
});
platform.setCustomWidth(64);
platform.setCustomHeight(32);
platform.setHasCustomSize(true);
platform.setAngle(45);
platform.setLocked(true);
platform
.getVariables()
.insertNew('Speed', 0)
.setValue(3);
const platformUuid = platform.getPersistentUuid();
addInstance(game.getInitialInstances(), 'Coin', { x: 1, y: 2 });
addInstance(game.getInitialInstances(), 'Hud', { x: 3, y: 4 });
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: {
scope: GAME_SCOPE,
move_instances: { to_scope: LEVEL1_SCOPE },
},
}
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'Moved 3 instance(s) from scene "Game" to external layout "Level1" (positions, layers and ids unchanged): 1 Coin, 1 Hud, 1 Platform.'
);
expect(result.movedInstancesCount).toBe(3);
expect(result.movedInstancesCountByObjectName).toEqual({
Platform: 1,
Coin: 1,
Hud: 1,
});
expect(game.getInitialInstances().getInstancesCount()).toBe(0);
const externalLayout = project.getExternalLayout('Level1');
const movedInstances = externalLayout.getInitialInstances();
expect(getInstanceObjectNames(movedInstances)).toEqual([
'Coin',
'Hud',
'Platform',
]);
// Everything is preserved on the moved platform, including its id.
let movedPlatform: gdInitialInstance | null = null;
const functor = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
functor.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
if (instance.getObjectName() === 'Platform') movedPlatform = instance;
};
// $FlowFixMe[incompatible-type]
movedInstances.iterateOverInstances(functor);
functor.delete();
const platformAfterMove = movedPlatform;
if (!platformAfterMove) throw new Error('Platform instance not moved');
expect(platformAfterMove.getPersistentUuid()).toBe(platformUuid);
expect(platformAfterMove.getX()).toBe(100);
expect(platformAfterMove.getY()).toBe(200);
expect(platformAfterMove.getLayer()).toBe('Sky');
expect(platformAfterMove.getZOrder()).toBe(7);
expect(platformAfterMove.hasCustomSize()).toBe(true);
expect(platformAfterMove.getCustomWidth()).toBe(64);
expect(platformAfterMove.getAngle()).toBe(45);
expect(platformAfterMove.isLocked()).toBe(true);
expect(
platformAfterMove
.getVariables()
.get('Speed')
.getValue()
).toBe(3);
// Both editors are refreshed.
expect(fakeOptions.onInstancesModifiedOutsideEditor).toHaveBeenCalledWith(
{
scene: game,
}
);
expect(fakeOptions.onInstancesModifiedOutsideEditor).toHaveBeenCalledWith(
{
scene: game,
externalLayout,
}
);
});
it('moves only the instances matching the object names, layer names and ids filters', async () => {
const externalLayout = project.getExternalLayout('Level1');
// The id is read before the move: the source instance is then gone
// (the moved one, in the target, keeps the same id).
const skyCoinId = addInstance(game.getInitialInstances(), 'Coin', {
x: 0,
y: 0,
layer: 'Sky',
})
.getPersistentUuid()
.slice(0, 10);
addInstance(game.getInitialInstances(), 'Coin', { x: 1, y: 0 });
addInstance(game.getInitialInstances(), 'Platform', {
x: 2,
y: 0,
layer: 'Sky',
});
const byObjectAndLayer: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: LEVEL1_SCOPE,
filter_by_object_names: ['Coin'],
filter_by_layer_names: ['Sky'],
},
},
}
);
expect(byObjectAndLayer.success).toBe(true);
expect(byObjectAndLayer.message).toContain(
'Moved 1 instance(s) of object(s) "Coin", on layer(s) "Sky" from scene "Game" to external layout "Level1"'
);
expect(game.getInitialInstances().getInstancesCount()).toBe(2);
expect(externalLayout.getInitialInstances().getInstancesCount()).toBe(1);
// Back to the scene, by id (the id is unchanged by the move).
const byId: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: LEVEL1_SCOPE,
move_instances: {
to_scope: GAME_SCOPE,
instance_ids: [skyCoinId],
},
},
}
);
expect(byId.success).toBe(true);
expect(byId.movedInstancesCount).toBe(1);
expect(game.getInitialInstances().getInstancesCount()).toBe(3);
expect(externalLayout.getInitialInstances().getInstancesCount()).toBe(0);
// The base layer is selected with "".
const baseLayer: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: LEVEL1_SCOPE,
filter_by_layer_names: [''],
},
},
}
);
expect(baseLayer.success).toBe(true);
expect(baseLayer.movedInstancesCountByObjectName).toEqual({ Coin: 1 });
});
it('is a no-op when nothing matches, and refuses unknown ids', async () => {
addInstance(game.getInitialInstances(), 'Coin', { x: 1, y: 0 });
const nothing: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
toolsVersion: 'v19',
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: LEVEL1_SCOPE,
filter_by_object_names: ['Platform'],
},
},
}
);
expect(nothing.success).toBe(true);
expect(nothing.nothingChanged).toBe(true);
expect(nothing.warnings).toContain(
'No instance of object(s) "Platform" to move in scene "Game": nothing was moved.'
);
const unknownId: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: LEVEL1_SCOPE,
instance_ids: ['not-an-id'],
},
},
}
);
expect(unknownId.success).toBe(false);
expect(unknownId.message).toContain(
'`move_instances.instance_ids` not found in scene "Game": "not-an-id". Nothing was moved.'
);
expect(game.getInitialInstances().getInstancesCount()).toBe(1);
});
it('refuses a move to the same container, to an unknown or invalid target, or from a variant', async () => {
const fakeOptions = makeFakeLaunchFunctionOptionsWithProject(project);
addInstance(game.getInitialInstances(), 'Coin', { x: 1, y: 0 });
const sameContainer: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: { scope: GAME_SCOPE, move_instances: { to_scope: GAME_SCOPE } },
}
);
expect(sameContainer.success).toBe(false);
expect(sameContainer.message).toContain(
'`move_instances.to_scope` is the same container as `scope` (scene "Game"): nothing to move.'
);
const unknownTarget: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: {
type: 'external_layout',
external_layout_name: 'Nope',
},
},
},
}
);
expect(unknownTarget.success).toBe(false);
expect(unknownTarget.message).toBe(
'`move_instances.to_scope`: External layout not found: "Nope". External layouts in this project: "Level1".'
);
const missingTarget: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: { scope: GAME_SCOPE, move_instances: {} },
}
);
expect(missingTarget.success).toBe(false);
expect(missingTarget.message).toContain(
'`move_instances.to_scope` is required'
);
const variantTarget: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...fakeOptions,
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: {
type: 'custom_object_variant',
extension_name: 'UI',
custom_object_name: 'Dialog',
variant_name: '',
},
},
},
}
);
expect(variantTarget.success).toBe(false);
expect(variantTarget.message).toContain(
'`scope.type` "custom_object_variant" is not accepted here: use one of "scene", "external_layout".'
);
expect(game.getInitialInstances().getInstancesCount()).toBe(1);
});
it('refuses, without moving anything, when the target lacks an object or a layer of the moved instances', async () => {
addInstance(game.getInitialInstances(), 'Hud', { x: 0, y: 0 });
addInstance(game.getInitialInstances(), 'Platform', {
x: 1,
y: 0,
layer: 'Sky',
});
addInstance(game.getInitialInstances(), 'Coin', { x: 2, y: 0 });
const result: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: { type: 'scene', scene_name: 'Menu' },
},
},
}
);
expect(result.success).toBe(false);
expect(result.message).toBe(
'Cannot move these instances to scene "Menu": it has no object named "Coin", "Platform" (nor globally) and it has no layer named "Sky". Nothing was moved. Create the missing objects/layers in the target first (or exclude these instances with the filters). An external layout uses the objects and layers of its associated scene.'
);
expect(game.getInitialInstances().getInstancesCount()).toBe(3);
expect(
project
.getLayout('Menu')
.getInitialInstances()
.getInstancesCount()
).toBe(0);
// The global object alone can go.
const globalOnly: EditorFunctionGenericOutput = await editorFunctions.change_scene_properties_layers_effects_groups.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scope: GAME_SCOPE,
move_instances: {
to_scope: { type: 'scene', scene_name: 'Menu' },
filter_by_object_names: ['Hud'],
},
},
}
);
expect(globalOnly.success).toBe(true);
expect(globalOnly.movedInstancesCount).toBe(1);
});
});
describe('listing the external layouts', () => {
it('lists them in the simplified project (read with read_game_project_json)', async () => {
addInstance(
project.getExternalLayout('Level1').getInitialInstances(),
'Coin',
{ x: 0, y: 0 }
);
const simplifiedProject = makeSimplifiedProjectBuilder(
gd
).getSimplifiedProject(project, {});
expect(simplifiedProject.externalLayouts).toEqual([
{
externalLayoutName: 'Level1',
associatedSceneName: 'Game',
instancesCount: 1,
},
]);
// Scoped to a scene: only its external layouts.
expect(
makeSimplifiedProjectBuilder(gd).getSimplifiedProject(project, {
scopeToScene: 'Menu',
}).externalLayouts
).toEqual([]);
});
});
});
@@ -41,6 +41,21 @@ const invalidScopeFailure = () =>
"Invalid `scope`: pass { type: 'project' } or { type: 'extension', extension_name: '...' }."
);
/**
* The test code asked for by a `run_gameplay_test` call, or null to run the
* stored test as-is.
*
* A blank `source` means "run the stored test": models routinely send
* `source: ""` instead of omitting the optional argument, and taking that
* literally would overwrite the stored test with an empty body (and run a
* no-op that reports "it did nothing"). An empty test body is never
* something to store nor to run, so it can safely be read as absent.
*/
const parseSourceArgument = (sourceArgument: mixed): string | null =>
typeof sourceArgument === 'string' && sourceArgument.trim()
? sourceArgument
: null;
/**
* The output sent to the AI for a gameplay test run: the full result of the
* run, with console logs flattened to strings.
@@ -95,7 +110,7 @@ export const runGameplayTest: EditorFunction = {
const testName = args.test_name || '';
return {
text:
args.source && args.persist !== false ? (
parseSourceArgument(args.source) !== null && args.persist !== false ? (
<Trans>Save and run the gameplay test {testName}.</Trans>
) : (
<Trans>Run the gameplay test {testName}.</Trans>
@@ -109,7 +124,7 @@ export const runGameplayTest: EditorFunction = {
if (typeof testName !== 'string' || !testName) {
return makeFailure('Missing or invalid `test_name` argument.');
}
const source = typeof args.source === 'string' ? args.source : null;
const source = parseSourceArgument(args.source);
const persist = args.persist !== false;
const timeoutMs =
typeof args.timeout_ms === 'number'
@@ -199,7 +214,7 @@ export const runGameplayTest: EditorFunction = {
// neither does running an unsaved source with `persist: false` (temporary
// probes and diagnostics).
getModifiesProject: (args: Object) =>
typeof args.source === 'string' && args.persist !== false,
parseSourceArgument(args.source) !== null && args.persist !== false,
};
// Cap on the ordered test list returned after changes (mirrors the array
@@ -0,0 +1,220 @@
// @flow
import { editorFunctions, type EditorFunctionGenericOutput } from './index';
import { makeFakeLaunchFunctionOptionsWithProject } from './TestHelpers';
// The real runner needs a preview launcher (and a browser): only the call it
// receives matters here, so it is replaced by a fake recording it.
const mockRunProjectGameplayTests = jest.fn<Array<any>, any>();
jest.mock('../GameplayTests/GameplayTestRunner', () => ({
...jest.requireActual('../GameplayTests/GameplayTestRunner'),
runProjectGameplayTests: (...args: Array<any>) =>
mockRunProjectGameplayTests(...args),
}));
const gd: libGDevelop = global.gd;
const STORED_SOURCE = 'await harness.goToScene("Scene");';
const makePassedResult = (testName: string) => ({
testName,
status: 'passed',
framesExecuted: 10,
durationMs: 100,
loadingMs: 10,
timeoutMs: 30000,
hiddenStallMs: 0,
gameTimeMs: 160,
assertions: [],
errors: [],
consoleLogs: [],
eventLog: [],
finalState: null,
});
describe('run_gameplay_test', () => {
let project: gdProject;
beforeEach(() => {
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
mockRunProjectGameplayTests.mockReset();
mockRunProjectGameplayTests.mockImplementation(({ tests }) =>
Promise.resolve([makePassedResult(tests[0].testName)])
);
});
afterEach(() => {
project.delete();
});
const addStoredTest = (testName: string) => {
const testsContainer = project.getTests();
const test = testsContainer.insertNewTest(
testName,
testsContainer.getTestsCount()
);
test.setSource(STORED_SOURCE);
return test;
};
const launch = (args: Object): Promise<EditorFunctionGenericOutput> =>
editorFunctions.run_gameplay_test.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args,
});
const getRunTest = () =>
mockRunProjectGameplayTests.mock.calls[0][0].tests[0];
it('runs the stored test as-is when `source` is omitted', async () => {
addStoredTest('MyTest');
const result = await launch({
scope: { type: 'project' },
test_name: 'MyTest',
});
expect(result.success).toBe(true);
expect(getRunTest().source).toBe(undefined);
expect(
project
.getTests()
.getTest('MyTest')
.getSource()
).toBe(STORED_SOURCE);
expect(result.meta).toBe(undefined);
});
it.each([['an empty string', ''], ['whitespace only', ' \n ']])(
'runs the stored test as-is (and keeps its source) when `source` is %s',
async (_label, source) => {
addStoredTest('MyTest');
const result = await launch({
scope: { type: 'project' },
test_name: 'MyTest',
source,
description: '',
persist: true,
});
expect(result.success).toBe(true);
// The blank source must never reach the runner nor the project.
expect(getRunTest().source).toBe(undefined);
expect(
project
.getTests()
.getTest('MyTest')
.getSource()
).toBe(STORED_SOURCE);
expect(result.meta).toBe(undefined);
}
);
it('does not create an empty test when `source` is blank and no test exists', async () => {
const result = await launch({
scope: { type: 'project' },
test_name: 'MissingTest',
source: '',
persist: true,
});
expect(result.success).toBe(false);
expect(result.message).toContain('No test named "MissingTest"');
expect(project.getTests().hasTestNamed('MissingTest')).toBe(false);
expect(mockRunProjectGameplayTests).not.toHaveBeenCalled();
});
it('still creates and runs a test when a real `source` is given', async () => {
const newSource = 'await harness.stepFrames(5);';
const result = await launch({
scope: { type: 'project' },
test_name: 'NewTest',
source: newSource,
description: 'Checks something',
});
expect(result.success).toBe(true);
expect(getRunTest().source).toBe(newSource);
expect(
project
.getTests()
.getTest('NewTest')
.getSource()
).toBe(newSource);
expect(
project
.getTests()
.getTest('NewTest')
.getDescription()
).toBe('Checks something');
expect(result.meta).toEqual({ didModifyProject: true });
});
it('still updates an existing test when a real `source` is given', async () => {
addStoredTest('MyTest');
const newSource = 'await harness.stepFrames(42);';
const result = await launch({
scope: { type: 'project' },
test_name: 'MyTest',
source: newSource,
});
expect(result.success).toBe(true);
expect(getRunTest().source).toBe(newSource);
expect(
project
.getTests()
.getTest('MyTest')
.getSource()
).toBe(newSource);
expect(result.meta).toEqual({ didModifyProject: true });
});
it('runs an unsaved source without touching the stored test when `persist` is false', async () => {
addStoredTest('MyTest');
const probeSource = 'await harness.stepFrames(1);';
const result = await launch({
scope: { type: 'project' },
test_name: 'MyTest',
source: probeSource,
persist: false,
});
expect(result.success).toBe(true);
expect(getRunTest().source).toBe(probeSource);
expect(
project
.getTests()
.getTest('MyTest')
.getSource()
).toBe(STORED_SOURCE);
expect(result.meta).toBe(undefined);
});
describe('getModifiesProject', () => {
it.each([
['a blank source does not modify the project', { source: '' }, false],
[
'a whitespace source does not modify the project',
{ source: ' ' },
false,
],
['no source does not modify the project', {}, false],
['a real source modifies the project', { source: STORED_SOURCE }, true],
[
'a real source not persisted does not modify the project',
{ source: STORED_SOURCE, persist: false },
false,
],
])('%s', (_label, args, expected) => {
expect(
editorFunctions.run_gameplay_test.getModifiesProject &&
editorFunctions.run_gameplay_test.getModifiesProject(args)
).toBe(expected);
});
});
});
@@ -0,0 +1,326 @@
// @flow
import { SafeExtractor } from '../Utils/SafeExtractor';
import {
serializeToJSObject,
unserializeFromJSObject,
} from '../Utils/Serializer';
import {
parseScopeArgument,
resolveScope,
type ResolvedScope,
type ToolScopeType,
} from './Scope';
const gd: libGDevelop = global.gd;
/**
* `change_scene_properties_layers_effects_groups.move_instances`: move the
* instances of a scene or an external layout to another scene or external
* layout, keeping everything (position, layer, z-order, size, rotations,
* flips, opacity, visibility, locks, per-instance variables and the id).
* A move, not a copy: the ids from `describe_instances` stay valid.
*
* Atomic: everything is validated (the objects and layers of the moved
* instances must exist in the target) before anything is moved.
*/
/** The containers instances can be moved between: a scene or an external layout. */
export const MOVE_INSTANCES_SCOPE_TYPES: Array<ToolScopeType> = [
'scene',
'external_layout',
];
export type MoveInstancesOutputFields = {|
movedInstancesCount: number,
movedInstancesCountByObjectName: { [objectName: string]: number },
|};
export type MoveInstancesResult =
| {| success: false, message: string |}
| {|
success: true,
message: string,
targetScope: ResolvedScope,
...MoveInstancesOutputFields,
|};
const listQuoted = (names: Array<string>): string =>
names.map(name => `"${name}"`).join(', ');
const iterateOnInstances = (
initialInstances: gdInitialInstancesContainer,
callback: gdInitialInstance => void
) => {
const instanceGetter = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
instanceGetter.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
callback(instance);
};
// $FlowFixMe[incompatible-type]
initialInstances.iterateOverInstances(instanceGetter);
instanceGetter.delete();
};
/**
* Read an optional array of non-empty strings (the base layer, named "", is
* the exception for `filter_by_layer_names`), or null when absent.
* Throws with a teaching message on anything else.
*/
const extractOptionalStringArray = (
args: any,
propertyName: string,
{ allowEmptyStrings }: {| allowEmptyStrings: boolean |}
): Array<string> | null => {
if (args[propertyName] === undefined || args[propertyName] === null)
return null;
const values = SafeExtractor.extractStringArrayProperty(args, propertyName);
if (!values) {
throw new Error(
`\`move_instances.${propertyName}\` must be an array of strings.`
);
}
if (!allowEmptyStrings && values.some(value => value === '')) {
throw new Error(
`\`move_instances.${propertyName}\` must not contain empty strings.`
);
}
return values;
};
const getScopeInstancesContainer = (
resolvedScope: ResolvedScope
): gdInitialInstancesContainer | null =>
(resolvedScope.layout || resolvedScope.externalLayout) &&
!resolvedScope.variant
? resolvedScope.initialInstances || null
: null;
const doesTargetHaveObject = (
targetScope: ResolvedScope,
objectName: string
): boolean => {
const { objectsContainer, globalObjectsContainer } = targetScope;
return (
(!!objectsContainer && objectsContainer.hasObjectNamed(objectName)) ||
(!!globalObjectsContainer &&
globalObjectsContainer.hasObjectNamed(objectName))
);
};
export const moveInstancesToScope = ({
project,
sourceScope,
moveInstancesArgs,
}: {|
project: gdProject,
sourceScope: ResolvedScope,
moveInstancesArgs: any,
|}): MoveInstancesResult => {
const sourceInstances = getScopeInstancesContainer(sourceScope);
if (!sourceInstances) {
return {
success: false,
message: `\`move_instances\` moves the instances of a scene or of an external layout: it is not available on ${
sourceScope.label
}.`,
};
}
// The target.
let targetScope: ResolvedScope;
try {
const parsedTargetScope = parseScopeArgument(
{ scope: moveInstancesArgs.to_scope },
{ allowedTypes: MOVE_INSTANCES_SCOPE_TYPES }
);
if (!parsedTargetScope) {
throw new Error(
'`move_instances.to_scope` is required: { type: "scene", scene_name } or { type: "external_layout", external_layout_name }.'
);
}
const resolvedTargetScope = resolveScope(project, parsedTargetScope);
if (resolvedTargetScope.success === false) {
throw new Error(
`\`move_instances.to_scope\`: ${resolvedTargetScope.message}`
);
}
targetScope = resolvedTargetScope;
} catch (error) {
return { success: false, message: error.message };
}
const targetInstances = getScopeInstancesContainer(targetScope);
const targetLayersContainer = targetScope.layersContainer;
if (!targetInstances || !targetLayersContainer) {
return {
success: false,
message: `\`move_instances.to_scope\`: ${
targetScope.label
} cannot hold instances.`,
};
}
if (targetInstances.ptr === sourceInstances.ptr) {
return {
success: false,
message: `\`move_instances.to_scope\` is the same container as \`scope\` (${
sourceScope.label
}): nothing to move. To change the layer of instances, use \`put_2d_instances\`/\`put_3d_instances\` with \`existing_instance_ids\`.`,
};
}
// The filters.
let objectNames: Array<string> | null;
let layerNames: Array<string> | null;
let instanceIds: Array<string> | null;
try {
objectNames = extractOptionalStringArray(
moveInstancesArgs,
'filter_by_object_names',
{ allowEmptyStrings: false }
);
layerNames = extractOptionalStringArray(
moveInstancesArgs,
'filter_by_layer_names',
{ allowEmptyStrings: true }
);
instanceIds = extractOptionalStringArray(
moveInstancesArgs,
'instance_ids',
{ allowEmptyStrings: false }
);
} catch (error) {
return { success: false, message: error.message };
}
const objectNamesSet = objectNames ? new Set(objectNames) : null;
const layerNamesSet = layerNames ? new Set(layerNames) : null;
const notFoundInstanceIds = new Set<string>(instanceIds || []);
// Select the instances to move.
const instancesToMove: Array<gdInitialInstance> = [];
iterateOnInstances(sourceInstances, instance => {
if (objectNamesSet && !objectNamesSet.has(instance.getObjectName())) return;
if (layerNamesSet && !layerNamesSet.has(instance.getLayer())) return;
if (instanceIds) {
const foundInstanceId = instanceIds.find(id =>
instance.getPersistentUuid().startsWith(id)
);
if (!foundInstanceId) return;
notFoundInstanceIds.delete(foundInstanceId);
}
instancesToMove.push(instance);
});
if (notFoundInstanceIds.size > 0) {
return {
success: false,
message: `\`move_instances.instance_ids\` not found in ${
sourceScope.label
}${
objectNamesSet || layerNamesSet ? ' (with the given filters)' : ''
}: ${listQuoted([
...notFoundInstanceIds,
])}. Nothing was moved. Get the ids from \`describe_instances\` on this scope.`,
};
}
const filtersLabel = [
objectNames ? `of object(s) ${listQuoted(objectNames)}` : null,
layerNames
? `on layer(s) ${listQuoted(
layerNames.map(name => (name === '' ? '(base layer)' : name))
)}`
: null,
instanceIds ? `with the given ids` : null,
]
.filter(Boolean)
.join(', ');
if (instancesToMove.length === 0) {
return {
success: true,
message: `No instance ${filtersLabel || ''} to move in ${
sourceScope.label
}: nothing was moved.`.replace(' ', ' '),
targetScope,
movedInstancesCount: 0,
movedInstancesCountByObjectName: {},
};
}
// Validate against the target before moving anything.
const missingObjectNames = new Set<string>();
const missingLayerNames = new Set<string>();
instancesToMove.forEach(instance => {
if (!doesTargetHaveObject(targetScope, instance.getObjectName()))
missingObjectNames.add(instance.getObjectName());
if (!targetLayersContainer.hasLayerNamed(instance.getLayer()))
missingLayerNames.add(instance.getLayer());
});
if (missingObjectNames.size > 0 || missingLayerNames.size > 0) {
return {
success: false,
message:
`Cannot move these instances to ${targetScope.label}: ` +
[
missingObjectNames.size > 0
? `it has no object named ${listQuoted(
[...missingObjectNames].sort()
)}${targetScope.globalObjectsContainer ? ' (nor globally)' : ''}`
: null,
missingLayerNames.size > 0
? `it has no layer named ${listQuoted(
[...missingLayerNames].sort()
)}`
: null,
]
.filter(Boolean)
.join(' and ') +
`. Nothing was moved. Create the missing objects/layers in the target first (or exclude these instances with the filters). An external layout uses the objects and layers of its associated scene.`,
};
}
// Move: serialize, insert in the target (the id is kept), remove from the source.
const movedInstancesCountByObjectName: { [objectName: string]: number } = {};
instancesToMove.forEach(instance => {
const serializedInstance = serializeToJSObject(instance);
const newInstance = new gd.InitialInstance();
unserializeFromJSObject(
newInstance,
serializedInstance,
'unserializeFrom',
project
);
targetInstances.insertInitialInstance(newInstance);
newInstance.delete();
const objectName = instance.getObjectName();
movedInstancesCountByObjectName[objectName] =
(movedInstancesCountByObjectName[objectName] || 0) + 1;
});
instancesToMove.forEach(instance => {
sourceInstances.removeInstance(instance);
});
const movedInstancesCount = instancesToMove.length;
return {
success: true,
message: `Moved ${movedInstancesCount} instance(s)${
filtersLabel ? ` ${filtersLabel}` : ''
} from ${sourceScope.label} to ${
targetScope.label
} (positions, layers and ids unchanged): ${Object.keys(
movedInstancesCountByObjectName
)
.sort()
.map(
objectName =>
`${movedInstancesCountByObjectName[objectName]} ${objectName}`
)
.join(', ')}.`,
targetScope,
movedInstancesCount,
movedInstancesCountByObjectName,
};
};
@@ -65,6 +65,7 @@ export type ObjectGroupsOutsideEditorChanges = {|
export type RenamableProjectItemKind =
| 'scene'
| 'external-layout'
| 'gameplay-test'
| 'extension'
| 'custom-object'
@@ -115,10 +116,12 @@ export type WillDeleteExtensionItemChanges = {|
functionName?: string,
|};
// Called before the scene is actually deleted, so its gdLayout is still
// valid (e.g. to let editors close any tab bound to it by object identity).
// Called before the scene (or the external layout: exactly one of the two is
// set) is actually deleted, so its gd object is still valid (e.g. to let
// editors close any tab bound to it by object identity).
export type WillDeleteSceneChanges = {|
scene: gdLayout,
scene?: gdLayout,
externalLayout?: gdExternalLayout,
|};
// Called before the gameplay test is actually deleted, so any tab bound to it
@@ -70,6 +70,33 @@ describe('put_2d_instances (modifications of existing instances)', () => {
return result;
};
it('lists the existing layers when the layer does not exist', async () => {
testScene.insertNewLayer('UI', testScene.getLayersCount());
const result: EditorFunctionGenericOutput = await editorFunctions.put_2d_instances.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scene_name: 'TestScene',
object_name: 'Player',
layer_name: 'HUD',
brush_kind: 'point',
brush_position: '10,20',
},
}
);
expect(result.success).toBe(false);
expect(result.message).toBe(
'Layer not found: HUD in scene "TestScene". ' +
'Existing layers: "" (the base layer), "UI". ' +
'Use one of them, or add the layer first with ' +
'`change_scene_properties_layers_effects_groups` (a `changed_layers` ' +
'entry creates the layer when its `layer_name` does not exist yet).'
);
expect(getInstances(testScene)).toHaveLength(0);
});
it('moves an existing instance to a different layer with the none brush', async () => {
testScene.insertNewLayer('UI', testScene.getLayersCount());
await putInstances({
@@ -156,8 +156,14 @@ describe('scoped instances, layers and groups', () => {
);
expect(result.success).toBe(false);
// The layers of the associated scene are listed, so the next call can
// use an existing one instead of guessing another name.
expect(result.message).toBe(
'Layer not found: NotALayer in external layout "LevelChunk".'
'Layer not found: NotALayer in external layout "LevelChunk". ' +
'Existing layers: "" (the base layer), "Background". ' +
'Use one of them, or add the layer first with ' +
'`change_scene_properties_layers_effects_groups` (a `changed_layers` ' +
'entry creates the layer when its `layer_name` does not exist yet).'
);
expect(
project
@@ -73,6 +73,7 @@ export type CappedRunScriptOutput = {|
error: ScriptExecutionError | null,
didModifyProject: boolean,
newSceneNames: Array<string>,
newExternalLayoutNames: Array<string>,
|};
export const capScriptExecutionResult = (
@@ -159,5 +160,6 @@ export const capScriptExecutionResult = (
error: result.error,
didModifyProject,
newSceneNames: result.newSceneNames || [],
newExternalLayoutNames: result.newExternalLayoutNames || [],
};
};
@@ -50,6 +50,8 @@ export type ScriptExecutionResult = {|
* standalone `create_scene` tool call does.
*/
newSceneNames: Array<string>,
/** Same for the external layouts created by calls made inside the script. */
newExternalLayoutNames: Array<string>,
|};
/**
@@ -237,6 +239,7 @@ export const executeScript = async ({
const functionCallRecords: Array<ScriptFunctionCallRecord> = [];
const consoleLogs: Array<string> = [];
const newSceneNames: Array<string> = [];
const newExternalLayoutNames: Array<string> = [];
const maxCallsCount = maxFunctionCallsCount || 600;
let pendingCallFunctionName: string | null = null;
@@ -274,6 +277,9 @@ export const executeScript = async ({
if (meta && Array.isArray(meta.newSceneNames)) {
newSceneNames.push(...meta.newSceneNames);
}
if (meta && Array.isArray(meta.newExternalLayoutNames)) {
newExternalLayoutNames.push(...meta.newExternalLayoutNames);
}
functionCallRecords.push({
functionName: name,
args,
@@ -340,6 +346,7 @@ export const executeScript = async ({
returnValue: returnValue === undefined ? null : returnValue,
error: null,
newSceneNames,
newExternalLayoutNames,
};
} catch (error) {
const isFunctionCallFailure = error instanceof FunctionCallFailedError;
@@ -366,6 +373,7 @@ export const executeScript = async ({
lastCalledFunctionName,
},
newSceneNames,
newExternalLayoutNames,
};
}
};
@@ -58,6 +58,14 @@ type SimplifiedScene = {|
instancesOnSceneDescription: string,
|};
// An external layout: instances placed apart from a scene (a level chunk, a
// UI template...), using the objects and layers of its associated scene.
export type SimplifiedExternalLayout = {|
externalLayoutName: string,
associatedSceneName: string,
instancesCount: number,
|};
type SimplifiedResource = {|
name: string,
type: string,
@@ -86,6 +94,10 @@ export type SimplifiedProject = {|
globalObjects: Array<SimplifiedObject>,
globalObjectGroups: Array<SimplifiedObjectGroup>,
scenes: Array<SimplifiedScene>,
// The external layouts of the project (their instances are read with
// `describe_instances` on an `external_layout` scope). Absent from the
// projects sent by older editors.
externalLayouts: Array<SimplifiedExternalLayout>,
globalVariables: Array<SimplifiedVariable>,
resources: Array<SimplifiedResource>,
tests?: Array<SimplifiedTest>,
@@ -472,6 +484,20 @@ export const makeSimplifiedProjectBuilder = (
return getSimplifiedScene(project, scene);
}).filter(Boolean);
const externalLayouts = mapFor(0, project.getExternalLayoutsCount(), i => {
const externalLayout = project.getExternalLayoutAt(i);
const associatedSceneName = externalLayout.getAssociatedLayout();
if (options.scopeToScene && associatedSceneName !== options.scopeToScene)
return null;
return {
externalLayoutName: externalLayout.getName(),
associatedSceneName,
instancesCount: externalLayout
.getInitialInstances()
.getInstancesCount(),
};
}).filter(Boolean);
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProject(
project
@@ -495,6 +521,7 @@ export const makeSimplifiedProjectBuilder = (
projectScopedContainers.getObjectsContainersList()
),
scenes,
externalLayouts,
globalVariables: getSimplifiedVariablesContainer(
gd,
project.getVariables()
@@ -297,6 +297,7 @@ describe('SimplifiedProject', () => {
"version": "1.0.0",
},
],
"externalLayouts": Array [],
"globalObjectGroups": Array [],
"globalObjects": Array [
Object {
@@ -33,6 +33,7 @@ export const makeFakeLaunchFunctionOptionsWithoutProject = (): LaunchFunctionOpt
i18n: makeFakeI18n(),
editorCallbacks: {
onOpenLayout: jest.fn(),
onOpenExternalLayout: jest.fn(),
onCreateProject: jest.fn(),
onOpenEventsFunctionsExtension: jest.fn(),
onOpenCustomObjectEditor: jest.fn(),
+27 -1
View File
@@ -1,6 +1,6 @@
// @flow
import { type AssetShortHeader } from '../Utils/GDevelopServices/Asset';
import { mapVector } from '../Utils/MapFor';
import { mapFor, mapVector } from '../Utils/MapFor';
import { SafeExtractor } from '../Utils/SafeExtractor';
import { serializeToJSObject } from '../Utils/Serializer';
import { type EditorFunctionGenericOutput } from './index';
@@ -312,6 +312,32 @@ export const makeGenericFailure = (
message,
});
/**
* The failure for a layer that does not exist, naming the layers that do.
*
* Without the list, the only way out of this error is to guess another name,
* and a conventional one ("UI", "Background", "HUD") is guessed again and
* again on scenes that only have a base layer. Listing the real layers and
* how to add one makes the next call the right one.
*/
export const makeLayerNotFoundFailure = (
layerName: string,
scopeLabel: string,
layersContainer: gdLayersContainer
): EditorFunctionGenericOutput => {
const existingLayerNames = mapFor(0, layersContainer.getLayersCount(), i =>
layersContainer.getLayerAt(i).getName()
).map(name => (name === '' ? '"" (the base layer)' : `"${name}"`));
return makeGenericFailure(
`Layer not found: ${layerName} in ${scopeLabel}. ` +
`Existing layers: ${existingLayerNames.join(', ')}. ` +
'Use one of them, or add the layer first with ' +
'`change_scene_properties_layers_effects_groups` (a `changed_layers` ' +
'entry creates the layer when its `layer_name` does not exist yet).'
);
};
export const shouldHideProperty = (property: gdPropertyDescriptor): boolean => {
return (
property.isHidden() ||
+426 -13
View File
@@ -92,6 +92,7 @@ import {
getObjectSizeInfoHints,
getSimplifiedInstance,
makeGenericFailure,
makeLayerNotFoundFailure,
shouldHideProperty,
type ObjectSizeInfo,
} from './Utils';
@@ -108,6 +109,10 @@ import {
} from './InstanceAnchor';
import { executeScript } from './ScriptExecution/ScriptRunner';
import { buildExposedScriptFunctions } from './ScriptExecution/ExposedFunctions';
import {
moveInstancesToScope,
type MoveInstancesOutputFields,
} from './MoveInstances';
import {
getSceneNotFoundMessage,
resolveScopeFromArgs,
@@ -219,6 +224,9 @@ export type EditorFunctionGenericOutput = {|
success: boolean,
meta?: {
newSceneNames?: Array<string>,
// External layouts created by the call (`create_scene` with
// `as_external_layout_of_scene`): opened by the editor like new scenes.
newExternalLayoutNames?: Array<string>,
createdProject?: gdProject,
// For `run_script`: true when ANY call the script made modified the
// project (so the editor refreshes even if the script ultimately failed).
@@ -334,6 +342,9 @@ export type EditorFunctionGenericOutput = {|
instancesForExternalLayoutNamed?: string,
instancesForScopeLabel?: string,
propertiesLayersEffectsForScopeLabel?: string,
// `change_scene_properties_layers_effects_groups.move_instances`.
movedInstancesCount?: number,
movedInstancesCountByObjectName?: { [objectName: string]: number },
// `inspect_scene_properties_layers_effects` on a custom object variant: a
// variant has no scene properties, but an area, groups and an asset store id.
isDefaultVariant?: boolean,
@@ -445,6 +456,7 @@ export type EditorCallbacks = {|
| 'none',
|}
) => void,
onOpenExternalLayout: (externalLayoutName: string) => void,
onCreateProject: ({|
name: string,
exampleSlug: string | null,
@@ -4396,8 +4408,10 @@ const put2dInstances: EditorFunction = {
// Check if layer exists (empty string is allowed for base layer)
if (layerName !== '' && !layersContainer.hasLayerNamed(layerName)) {
return makeGenericFailure(
`Layer not found: ${layerName} in ${resolvedScope.label}.`
return makeLayerNotFoundFailure(
layerName,
resolvedScope.label,
layersContainer
);
}
@@ -5369,8 +5383,10 @@ const put3dInstances: EditorFunction = {
// Check if layer exists (empty string is allowed for base layer)
if (layerName !== '' && !layersContainer.hasLayerNamed(layerName)) {
return makeGenericFailure(
`Layer not found: ${layerName} in ${resolvedScope.label}.`
return makeLayerNotFoundFailure(
layerName,
resolvedScope.label,
layersContainer
);
}
@@ -7283,11 +7299,109 @@ See errors; verify event contents if needed.`
};
/**
* Creates a new, empty scene
* `create_scene` with `as_external_layout_of_scene`: an external layout (a
* set of instances apart from a scene, using its objects and layers, created
* in the game by the "Create objects from an external layout" action).
*/
const createExternalLayout = ({
project,
externalLayoutName,
associatedSceneName,
ignoredArgumentNames,
}: {|
project: gdProject,
externalLayoutName: string,
associatedSceneName: string,
ignoredArgumentNames: Array<string>,
|}): EditorFunctionGenericOutput => {
if (!project.hasLayoutNamed(associatedSceneName)) {
return makeGenericFailure(
`${getSceneNotFoundMessage(
project,
associatedSceneName
)} \`as_external_layout_of_scene\` must name the existing scene whose objects and layers the external layout uses.`
);
}
// Scenes and external layouts have separate namespaces, but the same name
// for both is a recipe for confusion in the events (`Scene("X")` vs
// `CreateObjectsFromExternalLayout("X")`): refuse it.
if (project.hasLayoutNamed(externalLayoutName)) {
return makeGenericFailure(
`A scene is already named "${externalLayoutName}": choose another name for the external layout.`
);
}
const ignoredSuffix =
ignoredArgumentNames.length > 0
? ` (${ignoredArgumentNames.join(
', '
)} ignored: an external layout has no layers or properties of its own).`
: '';
const howToUseSuffix =
` Its instances use the objects and layers of scene "${associatedSceneName}": place them with \`put_2d_instances\`/\`put_3d_instances\` (or move existing ones with \`change_scene_properties_layers_effects_groups.move_instances\`) using scope { type: "external_layout", external_layout_name: "${externalLayoutName}" }.` +
` The scene creates them at runtime with the action \`CreateObjectsFromExternalLayout("${externalLayoutName}", 0, 0)\`.`;
if (project.hasExternalLayoutNamed(externalLayoutName)) {
const externalLayout = project.getExternalLayout(externalLayoutName);
const currentAssociatedSceneName = externalLayout.getAssociatedLayout();
if (currentAssociatedSceneName === associatedSceneName) {
return makeGenericSuccess(
`External layout "${externalLayoutName}" already exists (for scene "${associatedSceneName}").${ignoredSuffix}`
);
}
return makeGenericFailure(
`External layout "${externalLayoutName}" already exists${
currentAssociatedSceneName
? ` and is associated with scene "${currentAssociatedSceneName}"`
: ' (with no associated scene)'
}. To change its scene, set its \`associatedScene\` property with \`change_scene_properties_layers_effects_groups\`.`
);
}
const externalLayout = project.insertNewExternalLayout(
externalLayoutName,
project.getExternalLayoutsCount()
);
externalLayout.setAssociatedLayout(associatedSceneName);
return {
success: true,
message:
`Created external layout "${externalLayoutName}" for scene "${associatedSceneName}".` +
ignoredSuffix +
howToUseSuffix,
meta: {
newExternalLayoutNames: [externalLayoutName],
},
};
};
/**
* Creates a new, empty scene (or, with `as_external_layout_of_scene`, an
* external layout of an existing scene).
*/
const createScene: EditorFunction = {
renderForEditor: ({ args, editorCallbacks }) => {
const scene_name = extractRequiredString(args, 'scene_name');
const asExternalLayoutOfScene = SafeExtractor.extractStringProperty(
args,
'as_external_layout_of_scene'
);
if (asExternalLayoutOfScene) {
return {
text: (
<Trans>
Create external layout <b>{scene_name}</b> for scene{' '}
<b>{asExternalLayoutOfScene}</b>.{' '}
<Link
href="#"
onClick={() => editorCallbacks.onOpenExternalLayout(scene_name)}
>
Click to open it
</Link>
.
</Trans>
),
};
}
return {
text: (
@@ -7324,6 +7438,22 @@ const createScene: EditorFunction = {
args,
'is_first_scene'
);
const asExternalLayoutOfScene = SafeExtractor.extractStringProperty(
args,
'as_external_layout_of_scene'
);
if (asExternalLayoutOfScene) {
return createExternalLayout({
project,
externalLayoutName: scene_name,
associatedSceneName: asExternalLayoutOfScene,
ignoredArgumentNames: [
include_ui_layer ? 'include_ui_layer' : null,
background_color ? 'background_color' : null,
is_first_scene ? 'is_first_scene' : null,
].filter(Boolean),
});
}
const firstSceneSuffix = is_first_scene
? ' Also set as the first (startup) scene.'
@@ -7660,9 +7790,180 @@ const applyEffectChange = ({
}
};
// The scopes owning layers, layer effects and object groups: a scene, or a
// variant of a custom object (which has an area instead of scene properties).
const PROPERTIES_LAYERS_EFFECTS_SCOPE_TYPES = OBJECTS_SCOPE_TYPES;
// The scopes `inspect_scene_properties_layers_effects` reads: a scene (its
// properties, layers, layer effects and object groups) or a variant of a
// custom object (which has an area instead of scene properties). An external
// layout is read from the project JSON (`read_game_project_json`).
const INSPECT_PROPERTIES_LAYERS_EFFECTS_SCOPE_TYPES = OBJECTS_SCOPE_TYPES;
// The scopes `change_scene_properties_layers_effects_groups` changes: also an
// external layout (only a name and an associated scene: its layers and groups
// are those of the scene).
const PROPERTIES_LAYERS_EFFECTS_SCOPE_TYPES: Array<ToolScopeType> = [
'scene',
'external_layout',
'custom_object_variant',
];
/**
* The message refusing layer, effect or group changes through an external
* layout scope: they belong to its scene. Same text as the backend checker.
*/
const makeExternalLayoutHasNoLayersMessage = (
externalLayoutName: string,
sceneName: string
): string =>
`Layers, layer effects and object groups belong to the associated scene "${sceneName}" of external layout "${externalLayoutName}": use scope { type: "scene", scene_name: "${sceneName}" } to change them. Nothing was changed.`;
// The only properties of an external layout (`inspect_scene_properties_layers_effects`
// returns them along with the layers of its scene).
const EXTERNAL_LAYOUT_PROPERTY_NAMES = ['name', 'associatedScene'];
/**
* The objects and layers used by the instances of an external layout that a
* scene does not have: re-associating it with that scene would leave these
* instances without object or layer.
*/
const getMissingObjectsAndLayersInScene = (
project: gdProject,
initialInstances: gdInitialInstancesContainer,
scene: gdLayout
): {|
missingObjectNames: Array<string>,
missingLayerNames: Array<string>,
|} => {
const missingObjectNames = new Set<string>();
const missingLayerNames = new Set<string>();
iterateOnInstances(initialInstances, instance => {
const objectName = instance.getObjectName();
if (
!scene.getObjects().hasObjectNamed(objectName) &&
!project.getObjects().hasObjectNamed(objectName)
) {
missingObjectNames.add(objectName);
}
if (!scene.getLayers().hasLayerNamed(instance.getLayer())) {
missingLayerNames.add(instance.getLayer());
}
});
return {
missingObjectNames: [...missingObjectNames].sort(),
missingLayerNames: [...missingLayerNames].sort(),
};
};
/** Apply a `changed_properties` item to an external layout: `name` or `associatedScene`. */
const applyExternalLayoutPropertyChange = ({
project,
externalLayout,
propertyName,
newValue,
changes,
warnings,
onProjectItemRenamedOutsideEditor,
onInstancesModifiedOutsideEditor,
}: {|
project: gdProject,
externalLayout: gdExternalLayout,
propertyName: string,
newValue: string,
changes: Array<string>,
warnings: Array<string>,
onProjectItemRenamedOutsideEditor: (
changes: ProjectItemRenamedOutsideEditorChanges
) => void,
onInstancesModifiedOutsideEditor: (
changes: InstancesOutsideEditorChanges
) => void,
|}) => {
if (isFuzzyMatch(propertyName, 'name')) {
const oldName = externalLayout.getName();
if (newValue === oldName) {
changes.push(`External layout already named "${newValue}".`);
return;
}
if (newValue.trim() === '') {
warnings.push(`An external layout name cannot be empty. Skipped.`);
return;
}
// Like scene names, external layout names are free text: only ensure
// unicity.
const newName = newNameGenerator(newValue, tentativeNewName =>
project.hasExternalLayoutNamed(tentativeNewName)
);
externalLayout.setName(newName);
gd.WholeProjectRefactorer.renameExternalLayout(project, oldName, newName);
onProjectItemRenamedOutsideEditor({
kind: 'external-layout',
oldName,
newName,
});
changes.push(
`Renamed external layout "${oldName}" to "${newName}" (the events creating its objects were updated).`
);
return;
}
if (isFuzzyMatch(propertyName, 'associatedScene')) {
const externalLayoutName = externalLayout.getName();
if (newValue === externalLayout.getAssociatedLayout()) {
changes.push(
`External layout "${externalLayoutName}" is already associated with scene "${newValue}".`
);
return;
}
if (!project.hasLayoutNamed(newValue)) {
warnings.push(
`${getSceneNotFoundMessage(
project,
newValue
)} External layout "${externalLayoutName}" was NOT re-associated.`
);
return;
}
const newScene = project.getLayout(newValue);
const {
missingObjectNames,
missingLayerNames,
} = getMissingObjectsAndLayersInScene(
project,
externalLayout.getInitialInstances(),
newScene
);
if (missingObjectNames.length > 0 || missingLayerNames.length > 0) {
warnings.push(
`External layout "${externalLayoutName}" was NOT associated with scene "${newValue}": its instances use ` +
[
missingObjectNames.length > 0
? `objects this scene does not have (nor globally): ${missingObjectNames
.map(name => `"${name}"`)
.join(', ')}`
: null,
missingLayerNames.length > 0
? `layers this scene does not have: ${missingLayerNames
.map(name => `"${name}"`)
.join(', ')}`
: null,
]
.filter(Boolean)
.join(' and ') +
`. Move or erase these instances first (or add the objects/layers to the scene).`
);
return;
}
externalLayout.setAssociatedLayout(newValue);
// The external layout editor, if open, now shows the objects and layers
// of the new scene.
onInstancesModifiedOutsideEditor({ scene: newScene, externalLayout });
changes.push(
`External layout "${externalLayoutName}" is now associated with scene "${newValue}".`
);
return;
}
warnings.push(
`Unknown external layout property: "${propertyName}". Skipped. An external layout only has ${EXTERNAL_LAYOUT_PROPERTY_NAMES.map(
name => `\`${name}\``
).join(' and ')} (its layers and groups are those of its associated scene).`
);
};
/** The layers of a scope, with their effects: the same shape everywhere. */
const describeLayersWithEffects = (
@@ -7754,7 +8055,7 @@ const inspectScenePropertiesLayersEffects: EditorFunction = {
},
launchFunction: async ({ project, args }) => {
const resolvedScope = resolveScopeFromArgs(project, args, {
allowedTypes: PROPERTIES_LAYERS_EFFECTS_SCOPE_TYPES,
allowedTypes: INSPECT_PROPERTIES_LAYERS_EFFECTS_SCOPE_TYPES,
});
if (resolvedScope.success === false)
return makeScopeFailureOutput(resolvedScope);
@@ -7942,6 +8243,20 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
),
};
}
const moveInstances = SafeExtractor.extractObjectProperty(
args,
'move_instances'
);
if (moveInstances) {
return {
text: (
<Trans>
Move instances of scene <b>{scene_name}</b> to{' '}
{getScopeLabelFromArgs({ scope: moveInstances.to_scope })}.
</Trans>
),
};
}
const changed_properties = SafeExtractor.extractArrayProperty(
args,
@@ -8052,13 +8367,22 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
if (readOnlyRejection) return makeScopeFailureOutput(readOnlyRejection);
const scene = resolvedScope.layout;
const { variant, eventsBasedObject, layersContainer } = resolvedScope;
const {
variant,
eventsBasedObject,
layersContainer,
externalLayout,
} = resolvedScope;
if (!layersContainer)
return makeGenericFailure(`${resolvedScope.label} has no layers.`);
// The label is read at message time: a rename in the same call must be
// reflected by the messages that follow it.
const getTargetLabel = () =>
scene ? `scene "${scene.getName()}"` : resolvedScope.label;
externalLayout
? `external layout "${externalLayout.getName()}"`
: scene
? `scene "${scene.getName()}"`
: resolvedScope.label;
const deleteThisScene = SafeExtractor.extractBooleanProperty(
args,
@@ -8069,6 +8393,20 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
'A variant cannot be deleted here: use change_custom_object.changed_variants.'
);
}
if (deleteThisScene && externalLayout) {
// Same flow as a scene: let the editor close the tabs of the external
// layout while it still exists.
await onWillDeleteScene({ externalLayout });
const externalLayoutName = externalLayout.getName();
const instancesCount = externalLayout
.getInitialInstances()
.getInstancesCount();
project.removeExternalLayout(externalLayoutName);
return makeGenericSuccess(
`Deleted external layout "${externalLayoutName}" (${instancesCount} instance(s) removed with it). Events creating its objects with \`CreateObjectsFromExternalLayout("${externalLayoutName}", ...)\`, if any, still name it: update or remove them.`
);
}
if (deleteThisScene && scene) {
// Let the editor close any tab bound to this scene BEFORE it's
// actually deleted (mirrors the manual delete flow, which closes tabs
@@ -8110,6 +8448,27 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
args,
'changed_groups'
);
const move_instances = SafeExtractor.extractObjectProperty(
args,
'move_instances'
);
// An external layout has no layers, effects or groups of its own: refuse
// before changing anything, instead of silently editing its scene.
if (
externalLayout &&
scene &&
[changed_layers, changed_layer_effects, changed_groups].some(
items => items && items.length > 0
)
) {
return makeGenericFailure(
makeExternalLayoutHasNoLayersMessage(
externalLayout.getName(),
scene.getName()
)
);
}
// Object groups are structural: a named variant inherits the ones of the
// default variant instead of having its own.
@@ -8118,6 +8477,37 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
if (namedVariantRejection)
return makeScopeFailureOutput(namedVariantRejection);
}
// The move comes first and is atomic: a refusal leaves the whole call
// unapplied, so "nothing was changed" is true. Names in `to_scope` are
// thus the ones before any rename of this call.
let moveInstancesOutput: MoveInstancesOutputFields | null = null;
if (move_instances) {
const moveResult = moveInstancesToScope({
project,
sourceScope: resolvedScope,
moveInstancesArgs: move_instances,
});
if (moveResult.success === false) {
return makeGenericFailure(moveResult.message);
}
if (moveResult.movedInstancesCount === 0) {
warnings.push(moveResult.message);
} else {
changes.push(moveResult.message);
moveInstancesOutput = {
movedInstancesCount: moveResult.movedInstancesCount,
movedInstancesCountByObjectName:
moveResult.movedInstancesCountByObjectName,
};
onInstancesModifiedOutsideEditor({
...getOutsideEditorChangesTarget(resolvedScope),
});
onInstancesModifiedOutsideEditor({
...getOutsideEditorChangesTarget(moveResult.targetScope),
});
}
}
// The groups of a custom object live on its default variant.
const groupsObjectsContainer = eventsBasedObject
? eventsBasedObject.getObjects()
@@ -8160,6 +8550,19 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
}
return;
}
if (externalLayout) {
applyExternalLayoutPropertyChange({
project,
externalLayout,
propertyName,
newValue,
changes,
warnings,
onProjectItemRenamedOutsideEditor,
onInstancesModifiedOutsideEditor,
});
return;
}
if (isFuzzyMatch(propertyName, 'name')) {
const oldName = scene.getName();
@@ -8805,12 +9208,14 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = {
return {
success: true,
message: ['Done.', ...changes].join('\n'),
...(moveInstancesOutput || {}),
};
} else {
return {
success: true,
message: ['Done with warnings.', ...changes].join('\n'),
warnings: warnings.join('\n'),
...(moveInstancesOutput || {}),
};
}
},
@@ -10534,17 +10939,25 @@ const runScript: EditorFunction = {
error: capped.error,
meta: {
didModifyProject: capped.didModifyProject,
// Forward scene names created inside the script so they auto-open, like
// a standalone create_scene call does.
// Forward the scenes and external layouts created inside the script
// so they auto-open, like a standalone create_scene call does.
...(capped.newSceneNames.length > 0
? { newSceneNames: capped.newSceneNames }
: {}),
...getNewExternalLayoutNamesMeta(capped.newExternalLayoutNames),
},
};
},
modifiesProject: true,
};
// A helper (not an inline conditional spread) keeps Flow from reasoning about
// the product of two union spreads in the `meta` of `run_script`.
const getNewExternalLayoutNamesMeta = (
newExternalLayoutNames: Array<string>
): {| newExternalLayoutNames?: Array<string> |} =>
newExternalLayoutNames.length > 0 ? { newExternalLayoutNames } : {};
const searchResourceStore: EditorFunction = {
renderForEditor: ({ args }) => {
const resourceKind = SafeExtractor.extractStringProperty(
@@ -101,6 +101,7 @@ export type RenderEditorContainerProps = {|
|}
) => void,
onOpenEvents: (sceneName: string) => void,
onOpenExternalLayout: (externalLayoutName: string) => void,
openInstructionOrExpression: (type: string) => void,
onOpenCustomObjectEditor: (
gdEventsFunctionsExtension,
@@ -170,6 +170,7 @@ export type EditorTabsPaneCommonProps = {|
| 'none',
|}
) => void,
openExternalLayout: (name: string) => void,
openTemplateFromTutorial: (tutorialId: string) => Promise<void>,
openTemplateFromCourseChapter: (
courseChapter: CourseChapter,
@@ -393,6 +394,7 @@ const EditorTabsPane: React.ComponentType<{
setPreviewedLayout,
openExternalEvents,
openLayout,
openExternalLayout,
openTemplateFromTutorial,
openTemplateFromCourseChapter,
previewDebuggerServer,
@@ -825,6 +827,7 @@ const EditorTabsPane: React.ComponentType<{
});
},
onOpenLayout: openLayout,
onOpenExternalLayout: openExternalLayout,
onOpenTemplateFromTutorial: openTemplateFromTutorial,
onOpenTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer,
@@ -234,6 +234,7 @@ const PoppedOutEditorContainerWindow = (props: Props): React.Node => {
});
},
onOpenLayout: props.openLayout,
onOpenExternalLayout: props.openExternalLayout,
onOpenTemplateFromTutorial:
props.openTemplateFromTutorial,
onOpenTemplateFromCourseChapter:
+32 -1
View File
@@ -4156,6 +4156,21 @@ const MainFrame = (props: Props): React.MixedElement => {
),
};
}
if (kind === 'external-layout') {
return {
...state,
editorTabs: getEditorTabsWithRenamedProjectItem(
state.editorTabs,
currentProject,
editorTab =>
getRenamedExternalLayoutTabProjectItemName(
editorTab,
oldName,
newName
)
),
};
}
if (kind === 'gameplay-test') {
return {
...state,
@@ -4173,6 +4188,16 @@ const MainFrame = (props: Props): React.MixedElement => {
}
return state;
}).then(() => {
if (kind === 'external-layout') {
// The events creating its objects now name it differently.
notifyChangesToInGameEditor({
shouldReloadProjectData: true,
shouldReloadLibraries: false,
shouldReloadResources: false,
shouldHardReload: false,
reasons: ['renamed-external-layout'],
});
}
if (kind === 'extension' || kind === 'custom-object') {
// The renamed extension (or custom object) is used by the game under
// its new name.
@@ -4197,9 +4222,14 @@ const MainFrame = (props: Props): React.MixedElement => {
const onWillDeleteScene = async (
changes: WillDeleteSceneChanges
): Promise<void> => {
const { scene, externalLayout } = changes;
await setState(state => ({
...state,
editorTabs: closeLayoutTabs(state.editorTabs, changes.scene),
editorTabs: externalLayout
? closeExternalLayoutTabs(state.editorTabs, externalLayout)
: scene
? closeLayoutTabs(state.editorTabs, scene)
: state.editorTabs,
}));
};
@@ -6032,6 +6062,7 @@ const MainFrame = (props: Props): React.MixedElement => {
setPreviewedLayout: setPreviewedLayout,
openExternalEvents: openExternalEvents,
openLayout: openLayout,
openExternalLayout: openExternalLayout,
openTemplateFromTutorial: openTemplateFromTutorial,
openTemplateFromCourseChapter: openTemplateFromCourseChapter,
previewDebuggerServer: previewDebuggerServer,
+60 -60
View File
@@ -5,360 +5,360 @@ module.exports = [
"languageCode": "af_ZA",
"languageName": "Afrikaans",
"languageNativeName": "Afrikaans",
"translationRatio": 0.06578819881794395
"translationRatio": 0.0659436428778929
},
{
"languageCode": "ar_SA",
"languageName": "Arabic",
"languageNativeName": "العربية",
"translationRatio": 0.9900203468656138
"translationRatio": 0.9936089861528034
},
{
"languageCode": "az_AZ",
"languageName": "Azerbaijani",
"languageNativeName": "azərbaycan dili",
"translationRatio": 0.05997480864257343
"translationRatio": 0.060133630289532336
},
{
"languageCode": "bg_BG",
"languageName": "Bulgarian",
"languageNativeName": "български език",
"translationRatio": 0.10570681135548876
"translationRatio": 0.10583906265130238
},
{
"languageCode": "bn_BD",
"languageName": "Bengali",
"languageNativeName": "বাংলা",
"translationRatio": 0.07092336013952139
"translationRatio": 0.07107582066427809
},
{
"languageCode": "br_FR",
"languageName": "Breton",
"languageNativeName": "brezhoneg",
"translationRatio": 0.04834802829183216
"translationRatio": 0.04851360511281111
},
{
"languageCode": "ca_ES",
"languageName": "Catalan",
"languageNativeName": "Català",
"translationRatio": 0.9495203953105319
"translationRatio": 0.9531325651205578
},
{
"languageCode": "cs_CZ",
"languageName": "Czech",
"languageNativeName": "Čeština",
"translationRatio": 0.9853696347253174
"translationRatio": 0.9889609760821149
},
{
"languageCode": "da_DK",
"languageName": "Danish",
"languageNativeName": "Dansk",
"translationRatio": 0.9633756418951652
"translationRatio": 0.9669797617894839
},
{
"languageCode": "de_DE",
"languageName": "German",
"languageNativeName": "Deutsch",
"translationRatio": 0.9786842360236411
"translationRatio": 0.9823762951486394
},
{
"languageCode": "el_GR",
"languageName": "Greek",
"languageNativeName": "Ελληνικά",
"translationRatio": 0.9759713206084681
"translationRatio": 0.9796649559407379
},
{
"languageCode": "en",
"languageName": "English",
"languageNativeName": "English",
"translationRatio": 0.11398489356832231
"translationRatio": 0.11439029970258519
},
{
"languageCode": "eo_UY",
"languageName": "Esperanto",
"languageNativeName": "Esperanto",
"translationRatio": 0.9741304137196009
"translationRatio": 0.977728285077951
},
{
"languageCode": "es_ES",
"languageName": "Spanish",
"languageNativeName": "Español",
"translationRatio": 0.9828504989826568
"translationRatio": 0.9865401375036312
},
{
"languageCode": "fa_IR",
"languageName": "Persian",
"languageNativeName": "فارسی",
"translationRatio": 0.9787811258598973
"translationRatio": 0.9824731286917788
},
{
"languageCode": "fi_FI",
"languageName": "Finnish",
"languageNativeName": "suomi",
"translationRatio": 0.9734521848658075
"translationRatio": 0.977147283819115
},
{
"languageCode": "fil_PH",
"languageName": "Filipino",
"languageNativeName": "Mga Filipino",
"translationRatio": 0.9488421664567387
"translationRatio": 0.9519705626028856
},
{
"languageCode": "fr_FR",
"languageName": "French",
"languageNativeName": "Français",
"translationRatio": 0.9697703710880728
"translationRatio": 0.9734676091798199
},
{
"languageCode": "ha_HG",
"languageName": "Hausa",
"languageNativeName": "هَوُسَ",
"translationRatio": 0.05251429125084783
"translationRatio": 0.052677447467802896
},
{
"languageCode": "he_IL",
"languageName": "Hebrew",
"languageNativeName": "עברית",
"translationRatio": 0.17081678131963962
"translationRatio": 0.17091120364094126
},
{
"languageCode": "hi_IN",
"languageName": "Hindi",
"languageNativeName": "हिन्दी",
"translationRatio": 0.9790717953686658
"translationRatio": 0.9827636293211969
},
{
"languageCode": "hu_HU",
"languageName": "Hungarian",
"languageNativeName": "magyar",
"translationRatio": 0.9819784904563511
"translationRatio": 0.9856686356153772
},
{
"languageCode": "id_ID",
"languageName": "Indonesian",
"languageNativeName": "Bahasa Indonesia",
"translationRatio": 0.9750024222459064
"translationRatio": 0.9786966205093445
},
{
"languageCode": "ig_NG",
"languageName": "Igbo",
"languageNativeName": "Asụsụ Igbo",
"translationRatio": 0.05144850305202986
"translationRatio": 0.0516122784932701
},
{
"languageCode": "it_IT",
"languageName": "Italian",
"languageNativeName": "Italiano",
"translationRatio": 0.9787811258598973
"translationRatio": 0.9824731286917788
},
{
"languageCode": "ja_JP",
"languageName": "Japanese",
"languageNativeName": "日本語",
"translationRatio": 0.9888576688305397
"translationRatio": 0.9925438171782706
},
{
"languageCode": "ka_GE",
"languageName": "Georgian",
"languageNativeName": "ქართული",
"translationRatio": 0.12062784613894006
"translationRatio": 0.12075142829476126
},
{
"languageCode": "km_KH",
"languageName": "Khmer",
"languageNativeName": "ខេមរភាសា",
"translationRatio": 0.051836062397054516
"translationRatio": 0.05199961266582742
},
{
"languageCode": "ko_KR",
"languageName": "Korean",
"languageNativeName": "한국어",
"translationRatio": 0.9755837612634435
"translationRatio": 0.9792776217681805
},
{
"languageCode": "lt_LT",
"languageName": "Lithuanian",
"languageNativeName": "lietuvių kalba",
"translationRatio": 0.9765526596260052
"translationRatio": 0.980245957199574
},
{
"languageCode": "lv_LV",
"languageName": "Latvian",
"languageNativeName": "latviešu valoda",
"translationRatio": 0.04834802829183216
"translationRatio": 0.04851360511281111
},
{
"languageCode": "mr_IN",
"languageName": "Marathi",
"languageNativeName": "मराठी",
"translationRatio": 0.05823079158996225
"translationRatio": 0.05839062651302407
},
{
"languageCode": "ms_MY",
"languageName": "Malay",
"languageNativeName": "Bahasa Melayu",
"translationRatio": 0.07121402964828993
"translationRatio": 0.07136632129369613
},
{
"languageCode": "my_MM",
"languageName": "Burmese",
"languageNativeName": "ဗမာစာ",
"translationRatio": 0.050382714853211885
"translationRatio": 0.05054710951873731
},
{
"languageCode": "nl_NL",
"languageName": "Dutch",
"languageNativeName": "Nederlands",
"translationRatio": 0.9645383199302393
"translationRatio": 0.968141764307156
},
{
"languageCode": "no_NO",
"languageName": "Norwegian",
"languageNativeName": "Norsk",
"translationRatio": 0.9702548202693537
"translationRatio": 0.9739517768955166
},
{
"languageCode": "pl_PL",
"languageName": "Polish",
"languageNativeName": "Polski",
"translationRatio": 0.9864354229241352
"translationRatio": 0.9901229785997869
},
{
"languageCode": "pseudo_LOCALE",
"languageName": "for development only",
"languageNativeName": "Pseudolocalization",
"translationRatio": 0.9967956054016938
"translationRatio": 0.9967970716083276
},
{
"languageCode": "pt_BR",
"languageName": "Brazilian Portuguese",
"languageNativeName": "Português brasileiro",
"translationRatio": 0.9847882957077803
"translationRatio": 0.9884768083664182
},
{
"languageCode": "pt_PT",
"languageName": "Portuguese",
"languageNativeName": "Português",
"translationRatio": 0.9709330491231469
"translationRatio": 0.974629611697492
},
{
"languageCode": "ro_RO",
"languageName": "Romanian",
"languageNativeName": "Română",
"translationRatio": 0.128282143203178
"translationRatio": 0.12840127820276948
},
{
"languageCode": "ru_RU",
"languageName": "Russian",
"languageNativeName": "Русский",
"translationRatio": 0.9894390078480767
"translationRatio": 0.9931248184371066
},
{
"languageCode": "si_LK",
"languageName": "Sinhala",
"languageNativeName": "සිංහල",
"translationRatio": 0.9790717953686658
"translationRatio": 0.9827636293211969
},
{
"languageCode": "sk_SK",
"languageName": "Slovak",
"languageNativeName": "Slovenčina",
"translationRatio": 0.9763588799534929
"translationRatio": 0.9800522901132952
},
{
"languageCode": "sl_SI",
"languageName": "Slovenian",
"languageNativeName": "slovenščina",
"translationRatio": 0.9880825501404903
"translationRatio": 0.9917691488331558
},
{
"languageCode": "sq_AL",
"languageName": "Albanian",
"languageNativeName": "Shqip",
"translationRatio": 0.9708361592868908
"translationRatio": 0.9744359446112133
},
{
"languageCode": "sr_CS",
"languageName": "Serbian (Latin)",
"languageNativeName": "srpski",
"translationRatio": 0.9718050576494526
"translationRatio": 0.9755011135857461
},
{
"languageCode": "sr_SP",
"languageName": "Serbian",
"languageNativeName": "српски језик",
"translationRatio": 0.9766495494622615
"translationRatio": 0.9803427907427132
},
{
"languageCode": "sv_SE",
"languageName": "Swedish",
"languageNativeName": "Svenska",
"translationRatio": 0.9684139133804864
"translationRatio": 0.972111939575869
},
{
"languageCode": "sw_KE",
"languageName": "Swahili",
"languageNativeName": "Kiswahili",
"translationRatio": 0.04844491812808838
"translationRatio": 0.04861043865595038
},
{
"languageCode": "ta_IN",
"languageName": "Tamil",
"languageNativeName": "தமிழ்",
"translationRatio": 0.37040984400736365
"translationRatio": 0.3703883025079888
},
{
"languageCode": "th_TH",
"languageName": "Thai",
"languageNativeName": "ไทย",
"translationRatio": 0.9696734812518167
"translationRatio": 0.9733707756366805
},
{
"languageCode": "tr_TR",
"languageName": "Turkish",
"languageNativeName": "Türkçe",
"translationRatio": 0.9851758550528049
"translationRatio": 0.9888641425389755
},
{
"languageCode": "uk_UA",
"languageName": "Ukrainian",
"languageNativeName": "Українська",
"translationRatio": 0.9871136517779285
"translationRatio": 0.9908008134017624
},
{
"languageCode": "ur_PK",
"languageName": "Urdu",
"languageNativeName": "اردو",
"translationRatio": 0.0560992151923263
"translationRatio": 0.056260288563958594
},
{
"languageCode": "uz_UZ",
"languageName": "Uzbek",
"languageNativeName": "Ўзбек",
"translationRatio": 0.24590640441817657
"translationRatio": 0.24595719957393236
},
{
"languageCode": "vi_VN",
"languageName": "Vietnamese",
"languageNativeName": "Tiếng Việt",
"translationRatio": 0.981881600620095
"translationRatio": 0.9855718020722378
},
{
"languageCode": "yo_NG",
"languageName": "Yoruba",
"languageNativeName": "Yorùbá",
"translationRatio": 0.9674450150179246
"translationRatio": 0.9711436041444756
},
{
"languageCode": "zh_CN",
"languageName": "Chinese Simplified",
"languageNativeName": "简化字",
"translationRatio": 0.9889545586667958
"translationRatio": 0.9925438171782706
},
{
"languageCode": "zh_TW",
"languageName": "Chinese Traditional",
"languageNativeName": "正體字",
"translationRatio": 0.9776184478248232
"translationRatio": 0.9812142926309674
}
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long