Instances can now be hidden at start from the properties panel (#8884)

+ AI tool can now set instance variables
This commit is contained in:
Clément Pasteau
2026-07-30 16:43:23 +02:00
committed by GitHub
parent 9ed644c807
commit dbde5a9347
24 changed files with 1233 additions and 5 deletions
@@ -77,6 +77,10 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.SetResourceName("InGameEditor-OrbitCameraIcon")
.SetFilePath("InGameEditor/OrbitCameraIcon.svg")
.SetKind("internal-in-game-editor-only-svg");
extension.AddInGameEditorResource()
.SetResourceName("InGameEditor-HiddenInstanceIcon")
.SetFilePath("InGameEditor/HiddenInstanceIcon.svg")
.SetKind("internal-in-game-editor-only-svg");
gd::ObjectMetadata& obj = extension.AddObject<gd::ObjectConfiguration>(
"", _("Base object"), _("Base object"), "res/functions/object_black.svg");
+2
View File
@@ -68,6 +68,7 @@ void InitialInstance::UnserializeFrom(gd::Project &project,
SetLocked(element.GetBoolAttribute("locked", false));
SetSealed(element.GetBoolAttribute("sealed", false));
SetShouldKeepRatio(element.GetBoolAttribute("keepRatio", false));
SetHidden(element.GetBoolAttribute("hidden", false));
persistentUuid = element.GetStringAttribute("persistentUuid");
if (persistentUuid.empty()) ResetPersistentUuid();
@@ -154,6 +155,7 @@ void InitialInstance::SerializeTo(SerializerElement& element) const {
if (IsLocked()) element.SetAttribute("locked", IsLocked());
if (IsSealed()) element.SetAttribute("sealed", IsSealed());
if (ShouldKeepRatio()) element.SetAttribute("keepRatio", ShouldKeepRatio());
if (IsHidden()) element.SetAttribute("hidden", IsHidden());
if (persistentUuid.empty()) persistentUuid = UUID::MakeUuid4();
element.SetStringAttribute("persistentUuid", persistentUuid);
+15
View File
@@ -267,6 +267,19 @@ class GD_CORE_API InitialInstance {
*/
void SetShouldKeepRatio(bool enable = true) { keepRatio = enable; }
/**
* \brief Return true if the instance is hidden when the scene starts.
*/
bool IsHidden() const { return hidden; };
/**
* \brief Set if the instance is hidden when the scene starts.
*
* A hidden instance is still created but not displayed, until it is
* shown with the "Show" action.
*/
void SetHidden(bool enable = true) { hidden = enable; }
///@}
/** \name Variable management
@@ -483,6 +496,8 @@ class GD_CORE_API InitialInstance {
bool sealed = false; ///< True if the instance is sealed
bool keepRatio = true; ///< True if the instance's dimensions
/// should keep the same ratio.
bool hidden = false; ///< True if the instance is hidden when
/// the scene starts.
mutable gd::String persistentUuid; ///< A persistent random version 4 UUID,
/// useful for hot reloading.
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill="black" fill-opacity="0.6"/>
<ellipse cx="12" cy="12" rx="8" ry="4.7" stroke="white" stroke-width="2"/>
<circle cx="12" cy="12" r="2.3" fill="white"/>
<line x1="3.5" y1="20.5" x2="20.5" y2="3.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

+183
View File
@@ -849,6 +849,10 @@ namespace gdjs {
private _selection = new Selection();
private _selectionBoxes: Map<RuntimeObject, ObjectSelectionBoxHelper> =
new Map();
// Markers displayed on instances that are hidden when the scene starts
// (which stay visible in the editor).
private _hiddenInstanceMarkers: Map<RuntimeObject, THREE.Sprite> =
new Map();
private _objectMover = new ObjectMover(this);
private _wasMouseLeftButtonPressed = false;
@@ -1130,6 +1134,7 @@ namespace gdjs {
this._selectedLayerName = '';
// Clear any reference to `RuntimeObject` from the unloaded scene.
this._selectionBoxes.clear();
this._hiddenInstanceMarkers.clear();
this._selectionControls = null;
this._draggedNewObject = null;
this._draggedSelectedObject = null;
@@ -2073,6 +2078,107 @@ namespace gdjs {
}
}
/**
* Display a crossed-eye marker on every instance that is hidden when the
* scene starts: those instances stay visible in the editor (so they can
* be seen and manipulated), and the marker is what tells them apart.
*/
private _updateHiddenInstanceMarkers() {
const editedInstanceContainer = this.getEditedInstanceContainer();
const stillHiddenObjects = new Set<RuntimeObject>();
if (editedInstanceContainer) {
for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) {
if (!object.persistentUuid) continue;
const instanceData = this._getInstanceData(object.persistentUuid);
if (!instanceData || !instanceData.hidden) continue;
const objectLayer = this.getEditorLayer(object.getLayer());
const threeGroup =
objectLayer && objectLayer.getRenderer().getThreeGroup();
if (!threeGroup) continue;
let marker = this._hiddenInstanceMarkers.get(object);
if (!marker) {
marker = makeHiddenInstanceMarker(this._runtimeGame);
this._hiddenInstanceMarkers.set(object, marker);
}
// Also handles the object having changed of layer.
if (marker.parent !== threeGroup) {
threeGroup.add(marker);
}
const width = object.getWidth();
const height = object.getHeight();
const depth = is3D(object) ? object.getDepth() : 0;
const boundingRadius =
Math.sqrt(width * width + height * height + depth * depth) / 2;
// The marker size follows the size of the object (so it never
// dwarfs a small object seen from afar, and stays legible on a big
// object seen from close), within absolute limits.
let markerSize = Math.min(Math.max(boundingRadius * 0.4, 16), 128);
// Start from the true center of the object (the world can be
// navigated from any angle in 3D)...
marker.position.set(
object.getCenterXInScene(),
object.getCenterYInScene(),
is3D(object) ? object.getZ() + object.getDepth() / 2 : 0
);
// ...then move the marker towards the camera, just out of the
// object's bounding sphere: on screen it stays at the center of the
// object, but the object can't occlude its own marker - while
// other objects in front of it properly do (the marker is
// depth-tested).
const threeCamera = objectLayer.getRenderer().getThreeCamera();
if (threeCamera) {
const cameraLocalPosition = threeGroup.worldToLocal(
threeCamera.getWorldPosition(temporaryVector3)
);
const towardsCamera = cameraLocalPosition.sub(marker.position);
const cameraDistance = towardsCamera.length();
if (cameraDistance > 0) {
// Don't go past the camera if it's inside the bounding sphere.
const offset = Math.min(
boundingRadius * 1.05,
cameraDistance * 0.9
);
marker.position.addScaledVector(
towardsCamera.multiplyScalar(1 / cameraDistance),
offset
);
// When the camera gets close to a (big) object, the marker
// would take a lot of space on screen: cap its size to a
// fraction of the viewport height.
if (threeCamera instanceof THREE.PerspectiveCamera) {
const markerCameraDistance = Math.max(
cameraDistance - offset,
1
);
const viewportWorldHeightAtMarker =
2 *
markerCameraDistance *
Math.tan(THREE.MathUtils.degToRad(threeCamera.fov / 2));
markerSize = Math.min(
markerSize,
viewportWorldHeightAtMarker * 0.05
);
}
}
}
marker.scale.set(markerSize, markerSize, 1);
stillHiddenObjects.add(object);
}
}
this._hiddenInstanceMarkers.forEach((marker, object) => {
if (!stillHiddenObjects.has(object)) {
marker.removeFromParent();
this._hiddenInstanceMarkers.delete(object);
}
});
}
private _updateSelectionOutline({
objectUnderCursor,
}: {
@@ -2658,6 +2764,9 @@ namespace gdjs {
depth: depth === defaultDepth ? undefined : depth,
locked: oldData ? oldData.locked : false,
sealed: oldData ? oldData.sealed : false,
// Not modified by the InGameEditor (which always shows instances,
// even those hidden at start), but must be preserved:
hidden: oldData ? oldData.hidden : undefined,
// TODO: how to transmit/should we transmit other properties?
numberProperties: [],
stringProperties: [],
@@ -3549,6 +3658,7 @@ namespace gdjs {
this._updateSelectionBox();
this._handleSelection({ objectUnderCursor });
this._updateSelectionOutline({ objectUnderCursor });
this._updateHiddenInstanceMarkers();
// Custom objects only update their position at the end of the frame
// because they don't override position setters like built-in objects do.
// Since the instance position is not yet set when `onCreated` is called,
@@ -5011,6 +5121,79 @@ namespace gdjs {
}
};
const temporaryVector3 = new THREE.Vector3();
// The material is shared by all the markers, and stays invisible until the
// icon texture is loaded.
let hiddenInstanceMarkerMaterial: THREE.SpriteMaterial | null = null;
let hiddenInstanceMarkerTextureLoadStarted = false;
const getOrLoadHiddenInstanceMarkerMaterial = (
game: RuntimeGame
): THREE.SpriteMaterial => {
if (!hiddenInstanceMarkerMaterial) {
hiddenInstanceMarkerMaterial = new THREE.SpriteMaterial({
// The marker has a size in the world, based on the size of the object
// (see `_updateHiddenInstanceMarkers`): like the object, it gets
// smaller when far away and bigger when close.
sizeAttenuation: true,
// The marker is depth-tested: an object between the camera and the
// hidden instance occludes its marker (the marker is moved towards the
// camera, out of the instance itself, so the instance can't occlude
// its own marker - see `_updateHiddenInstanceMarkers`). Writing the
// depth also protects the marker from the plane showing the 2D
// objects, which is rendered afterwards (with
// `renderOrder = Number.MAX_SAFE_INTEGER`).
depthTest: true,
depthWrite: true,
// Discard the fully transparent pixels of the marker, so they don't
// write the depth (which would punch an invisible rectangle in the
// plane showing the 2D objects, rendered afterwards).
alphaTest: 0.1,
transparent: true,
// Nothing to show until the icon texture is loaded.
visible: false,
});
}
if (!hiddenInstanceMarkerTextureLoadStarted) {
hiddenInstanceMarkerTextureLoadStarted = true;
const url = getSvgIconUrl(game, 'InGameEditor-HiddenInstanceIcon');
if (url) {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
if (!hiddenInstanceMarkerMaterial) return;
// Rasterize the SVG at a fixed resolution (its intrinsic size is
// small), so the texture stays crisp at the biggest marker sizes.
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) return;
context.drawImage(image, 0, 0, size, size);
hiddenInstanceMarkerMaterial.map = new THREE.CanvasTexture(canvas);
hiddenInstanceMarkerMaterial.visible = true;
hiddenInstanceMarkerMaterial.needsUpdate = true;
};
image.src = url;
}
}
return hiddenInstanceMarkerMaterial;
};
const makeHiddenInstanceMarker = (game: RuntimeGame): THREE.Sprite => {
const marker = new THREE.Sprite(
getOrLoadHiddenInstanceMarkerMaterial(game)
);
// Never picked by the editor raycasts (used to select objects).
marker.raycast = () => {};
return marker;
};
class ObjectSelectionBoxHelper {
object: gdjs.RuntimeObject;
dummyObject3DForObject2D: THREE.Object3D | null = null;
+6
View File
@@ -379,6 +379,12 @@ namespace gdjs {
newObject.setZOrder(instanceData.zOrder);
newObject.setLayer(instanceData.layer);
// Instances hidden at start are not hidden in the in-game editor:
// they must stay visible to be seen and manipulated (like in the
// 2D editor).
if (instanceData.hidden && !this.getGame().isInGameEdition()) {
newObject.hide(true);
}
newObject
.getVariables()
.initFrom(instanceData.initialVariables, true);
@@ -1857,6 +1857,15 @@ namespace gdjs {
runtimeObject.setLayer(newInstance.layer);
somethingChanged = true;
}
if (
// Instances hidden at start are not hidden in the in-game editor:
// they must stay visible to be seen and manipulated.
!this._runtimeGame.isInGameEdition() &&
!oldInstance.hidden !== !newInstance.hidden
) {
runtimeObject.hide(!!newInstance.hidden);
somethingChanged = true;
}
if (gdjs.Base3DHandler && gdjs.Base3DHandler.is3D(runtimeObject)) {
if (oldInstance.z !== newInstance.z) {
runtimeObject.setZ(newInstance.z || 0);
+2
View File
@@ -445,6 +445,8 @@ declare interface InstanceData extends InstancePersistentUuidData {
layer: string;
locked?: boolean;
sealed?: boolean;
/** True if the instance starts hidden (it can be shown with the "Show" action). */
hidden?: boolean;
name: string;
x: number;
+93
View File
@@ -310,6 +310,99 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => {
expect(instances[1].getY()).to.be(234);
});
it('can hide then show again an instance at hot-reload', async () => {
const oldProjectData = createProjectData({
layouts: [
createSceneData({
instances: [{ persistentUuid: '1', x: 111, y: 123 }],
}),
],
});
const runtimeGame = new gdjs.RuntimeGame(oldProjectData);
const hotReloader = new gdjs.HotReloader(runtimeGame);
await runtimeGame.loadFirstAssetsAndStartBackgroundLoading(
'Scene1',
() => {}
);
runtimeGame._sceneStack.push('Scene1');
const scene = runtimeGame.getSceneStack().getCurrentScene();
if (!scene) throw new Error("Couldn't set a current scene for testing.");
expect(scene.getInstancesOf('MyObject')[0].isVisible()).to.be(true);
// Mark the instance as hidden at start (the serializer only writes the
// attribute when true).
const hiddenProjectData = createProjectData({
layouts: [
createSceneData({
instances: [{ persistentUuid: '1', x: 111, y: 123, hidden: true }],
}),
],
});
await hotReloader._hotReloadRuntimeGame(
oldProjectData,
hiddenProjectData,
[],
runtimeGame
);
expect(scene.getInstancesOf('MyObject')[0].isVisible()).to.be(false);
// Mark it as visible again: the attribute is absent from the data, like
// the serializer produces.
const visibleAgainProjectData = createProjectData({
layouts: [
createSceneData({
instances: [{ persistentUuid: '1', x: 111, y: 123 }],
}),
],
});
await hotReloader._hotReloadRuntimeGame(
hiddenProjectData,
visibleAgainProjectData,
[],
runtimeGame
);
expect(scene.getInstancesOf('MyObject')[0].isVisible()).to.be(true);
});
it('keeps instances hidden at start visible when in the in-game editor', async () => {
const oldProjectData = createProjectData({
layouts: [
createSceneData({
instances: [{ persistentUuid: '1', x: 111, y: 123 }],
}),
],
});
const runtimeGame = new gdjs.RuntimeGame(oldProjectData);
// Simulate the in-game editor (where hidden instances must stay visible,
// to be seen and manipulated).
runtimeGame.isInGameEdition = () => true;
const hotReloader = new gdjs.HotReloader(runtimeGame);
await runtimeGame.loadFirstAssetsAndStartBackgroundLoading(
'Scene1',
() => {}
);
runtimeGame._sceneStack.push('Scene1');
const scene = runtimeGame.getSceneStack().getCurrentScene();
if (!scene) throw new Error("Couldn't set a current scene for testing.");
const hiddenProjectData = createProjectData({
layouts: [
createSceneData({
instances: [{ persistentUuid: '1', x: 111, y: 123, hidden: true }],
}),
],
});
await hotReloader._hotReloadRuntimeGame(
oldProjectData,
hiddenProjectData,
[],
runtimeGame
);
// The instance is hidden at start, but stays visible in the editor.
expect(scene.getInstancesOf('MyObject')[0].isVisible()).to.be(true);
});
it('can move instances of a global object at hot-reload', async () => {
const oldProjectData = createProjectData({
globalObjects: [createSpriteData({ name: 'MyGlobalObject' })],
+2
View File
@@ -1489,6 +1489,8 @@ interface InitialInstance {
void SetSealed(boolean seal);
boolean ShouldKeepRatio();
void SetShouldKeepRatio(boolean keepRatio);
boolean IsHidden();
void SetHidden(boolean hidden);
long GetZOrder();
void SetZOrder(long zOrder);
long GetOpacity();
+4
View File
@@ -775,6 +775,9 @@ describe('libGD.js', function () {
expect(initialInstance.getLayer()).toBe('MyLayer');
initialInstance.setLocked(true);
expect(initialInstance.isLocked()).toBe(true);
expect(initialInstance.isHidden()).toBe(false);
initialInstance.setHidden(true);
expect(initialInstance.isHidden()).toBe(true);
initialInstance.setHasCustomSize(true);
expect(initialInstance.hasCustomSize()).toBe(true);
initialInstance.setCustomWidth(34);
@@ -820,6 +823,7 @@ describe('libGD.js', function () {
expect(initialInstance2.getZOrder()).toBe(12);
expect(initialInstance2.getLayer()).toBe('MyLayer');
expect(initialInstance2.isLocked()).toBe(true);
expect(initialInstance2.isHidden()).toBe(true);
expect(initialInstance2.hasCustomSize()).toBe(true);
expect(initialInstance2.hasCustomDepth()).toBe(false);
expect(initialInstance2.getCustomWidth()).toBe(34);
+2
View File
@@ -1255,6 +1255,8 @@ export class InitialInstance extends EmscriptenObject {
setSealed(seal: boolean): void;
shouldKeepRatio(): boolean;
setShouldKeepRatio(keepRatio: boolean): void;
isHidden(): boolean;
setHidden(hidden: boolean): void;
getZOrder(): number;
setZOrder(zOrder: number): void;
getOpacity(): number;
+2
View File
@@ -21,6 +21,8 @@ declare class gdInitialInstance {
setSealed(seal: boolean): void;
shouldKeepRatio(): boolean;
setShouldKeepRatio(keepRatio: boolean): void;
isHidden(): boolean;
setHidden(hidden: boolean): void;
getZOrder(): number;
setZOrder(zOrder: number): void;
getOpacity(): number;
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="22" height="22" rx="5" fill="black" fill-opacity="0.6"/>
<ellipse cx="12" cy="12" rx="8" ry="4.7" stroke="white" stroke-width="2"/>
<circle cx="12" cy="12" r="2.3" fill="white"/>
<line x1="3.5" y1="20.5" x2="20.5" y2="3.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

+1 -1
View File
@@ -101,7 +101,7 @@ export const useRefreshLimits = (
// The tools of the orchestrator AND of the sub-agents it creates server-side.
// Only bump it once the matching prompts and generation-api are deployed;
// reverting it is the flip-back (every past version stays served).
export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v12';
export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v13';
/**
* A pending request for the user to approve (or refuse) a project-modifying
@@ -143,7 +143,7 @@ describe('add_or_edit_variable', () => {
expect(result.success).toBe(false);
expect(result.message).toBe(
'Invalid "variable_scope": "world". Use `scene`, `object`, `group` or `global`.'
'Invalid "variable_scope": "world". Use `scene`, `object`, `group`, `instance` or `global`.'
);
});
@@ -288,3 +288,198 @@ describe('add_or_edit_variable', () => {
expect(project.getVariables().has('orphan')).toBe(false);
});
});
describe('add_or_edit_variable (instance scope)', () => {
let project: gdProject;
let testScene: gdLayout;
let doorInstance1: gdInitialInstance;
let doorInstance2: gdInitialInstance;
let playerInstance: gdInitialInstance;
const getIdOf = (instance: gdInitialInstance): string =>
instance.getPersistentUuid().slice(0, 10);
beforeEach(() => {
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
testScene = project.insertNewLayout('TestScene', 0);
testScene.getObjects().insertNewObject(project, 'Sprite', 'Door', 0);
testScene.getObjects().insertNewObject(project, 'Sprite', 'Player', 0);
doorInstance1 = testScene.getInitialInstances().insertNewInitialInstance();
doorInstance1.setObjectName('Door');
doorInstance2 = testScene.getInitialInstances().insertNewInitialInstance();
doorInstance2.setObjectName('Door');
playerInstance = testScene.getInitialInstances().insertNewInitialInstance();
playerInstance.setObjectName('Player');
});
afterEach(() => {
project.delete();
});
const addOrEditVariable = async (args: any) =>
editorFunctions.add_or_edit_variable.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args,
});
it('sets a variable on a single instance, leaving the others and the object untouched', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
instance_id: getIdOf(doorInstance1),
variable_name_or_path: 'LevelNumber',
value: '3',
});
expect(result.success).toBe(true);
expect(result.message).toContain('Added instance');
expect(result.message).toContain(`"${getIdOf(doorInstance1)}" (Door)`);
expect(result.message).toContain('variable "LevelNumber" (Number) = 3');
expect(doorInstance1.getVariables().has('LevelNumber')).toBe(true);
expect(
doorInstance1
.getVariables()
.get('LevelNumber')
.getValue()
).toBe(3);
// The other instances and the shared object variables are untouched.
expect(doorInstance2.getVariables().has('LevelNumber')).toBe(false);
expect(playerInstance.getVariables().has('LevelNumber')).toBe(false);
expect(
testScene
.getObjects()
.getObject('Door')
.getVariables()
.has('LevelNumber')
).toBe(false);
});
it('sets several variables (including nested paths) on one instance', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
instance_id: getIdOf(doorInstance1),
variables: [
{ variable_name_or_path: 'IsOpen', value: 'false' },
{ variable_name_or_path: 'Config.Speed', value: '12' },
],
});
expect(result.success).toBe(true);
expect(
doorInstance1
.getVariables()
.get('IsOpen')
.getBool()
).toBe(false);
// Nested paths work, backed by the same ApplyVariableChange helpers.
expect(
doorInstance1
.getVariables()
.get('Config')
.getChild('Speed')
.getValue()
).toBe(12);
// The other instances are untouched: one call changes one instance.
expect(doorInstance2.getVariables().has('IsOpen')).toBe(false);
expect(playerInstance.getVariables().has('IsOpen')).toBe(false);
});
it('deletes an instance variable', async () => {
doorInstance1
.getVariables()
.insertNew('Obsolete', 0)
.setValue(1);
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
instance_id: getIdOf(doorInstance1),
variables: [
{ variable_name_or_path: 'Obsolete', delete_this_variable: true },
],
});
expect(result.success).toBe(true);
expect(result.message).toContain('Deleted instance');
expect(doorInstance1.getVariables().has('Obsolete')).toBe(false);
});
it('fails without scene_name', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
instance_id: getIdOf(doorInstance1),
variable_name_or_path: 'LevelNumber',
value: '3',
});
expect(result.success).toBe(false);
expect(result.message).toContain('Missing "scene_name"');
});
it('fails without instance_id', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
variable_name_or_path: 'LevelNumber',
value: '3',
});
expect(result.success).toBe(false);
expect(result.message).toContain('Missing "instance_id"');
expect(result.message).toContain('describe_instances');
});
it('fails (and changes nothing) when the id is unknown', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
instance_id: 'ffffffffff',
variable_name_or_path: 'LevelNumber',
value: '3',
});
expect(result.success).toBe(false);
expect(result.message).toContain('ffffffffff');
expect(result.message).toContain('describe_instances');
expect(doorInstance1.getVariables().has('LevelNumber')).toBe(false);
});
it('fails when an id belongs to another object than object_name', async () => {
const result = await addOrEditVariable({
variable_scope: 'instance',
scene_name: 'TestScene',
object_name: 'Door',
instance_id: getIdOf(playerInstance),
variable_name_or_path: 'LevelNumber',
value: '3',
});
expect(result.success).toBe(false);
expect(result.message).toContain('not an instance of object "Door"');
expect(result.message).toContain('(instance of "Player")');
expect(playerInstance.getVariables().has('LevelNumber')).toBe(false);
});
it('inspect_variables works with the instance scope', async () => {
doorInstance1
.getVariables()
.insertNew('LevelNumber', 0)
.setValue(7);
const result = await editorFunctions.inspect_variables.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
variable_scope: 'instance',
scene_name: 'TestScene',
instance_id: getIdOf(doorInstance1),
},
});
expect(result.success).toBe(true);
expect(JSON.stringify(result.variables)).toContain('LevelNumber');
});
});
@@ -115,3 +115,98 @@ describe('describe_instances', () => {
);
});
});
describe('describe_instances (per-instance state)', () => {
let project: gdProject;
let testScene: gdLayout;
beforeEach(() => {
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
testScene = project.insertNewLayout('TestScene', 0);
testScene.getObjects().insertNewObject(project, 'Sprite', 'Player', 0);
});
afterEach(() => {
project.delete();
});
const describeInstances = async () => {
const result: EditorFunctionGenericOutput = await editorFunctions.describe_instances.launchFunction(
{
...makeFakeLaunchFunctionOptionsWithProject(project),
args: { scene_name: 'TestScene' },
}
);
expect(result.success).toBe(true);
return result.instances || [];
};
it('reports the per-instance variables, only when the instance has some', async () => {
const plainInstance = testScene
.getInitialInstances()
.insertNewInitialInstance();
plainInstance.setObjectName('Player');
const instanceWithVariables = testScene
.getInitialInstances()
.insertNewInitialInstance();
instanceWithVariables.setObjectName('Player');
instanceWithVariables
.getVariables()
.insertNew('LevelNumber', 0)
.setValue(3);
const instances = await describeInstances();
expect(instances).toHaveLength(2);
const plain = instances.find(
instance => instance.id === plainInstance.getPersistentUuid().slice(0, 10)
);
const withVariables = instances.find(
instance =>
instance.id === instanceWithVariables.getPersistentUuid().slice(0, 10)
);
if (!plain || !withVariables)
throw new Error('Expected to find both instances in the description.');
// No key at all when the instance has no variable of its own.
expect(plain.initialVariables).toBeUndefined();
expect(withVariables.initialVariables).toHaveLength(1);
expect(withVariables.initialVariables[0]).toMatchObject({
name: 'LevelNumber',
type: 'number',
value: 3,
});
// Internal per-instance properties stay unexposed.
instances.forEach(instance => {
expect(instance.numberProperties).toBeUndefined();
expect(instance.stringProperties).toBeUndefined();
});
});
it('reports hidden only when the instance starts hidden', async () => {
const visibleInstance = testScene
.getInitialInstances()
.insertNewInitialInstance();
visibleInstance.setObjectName('Player');
const hiddenInstance = testScene
.getInitialInstances()
.insertNewInitialInstance();
hiddenInstance.setObjectName('Player');
hiddenInstance.setHidden(true);
const instances = await describeInstances();
const visible = instances.find(
instance =>
instance.id === visibleInstance.getPersistentUuid().slice(0, 10)
);
const hidden = instances.find(
instance =>
instance.id === hiddenInstance.getPersistentUuid().slice(0, 10)
);
if (!visible || !hidden)
throw new Error('Expected to find both instances in the description.');
expect(visible.hidden).toBeUndefined();
expect(hidden.hidden).toBe(true);
});
});
@@ -346,3 +346,137 @@ describe('put_2d_instances (modifications of existing instances)', () => {
expect(positions).toEqual([{ x: 0, y: 50 }, { x: 300, y: 50 }]);
});
});
describe('put_2d_instances (instances_hidden)', () => {
let project: gdProject;
let testScene: gdLayout;
beforeEach(() => {
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
testScene = project.insertNewLayout('TestScene', 0);
testScene.getObjects().insertNewObject(project, 'Sprite', 'Player', 0);
});
afterEach(() => {
project.delete();
});
const getRawInstances = (scene: gdLayout): Array<gdInitialInstance> => {
const instances = [];
const functor = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
functor.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
instances.push(instance);
};
// $FlowFixMe[incompatible-type]
scene.getInitialInstances().iterateOverInstances(functor);
functor.delete();
return instances;
};
const putInstances = async (args: any) =>
editorFunctions.put_2d_instances.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scene_name: 'TestScene',
object_name: 'Player',
layer_name: '',
...args,
},
});
it('creates instances hidden at start', async () => {
const result = await putInstances({
brush_kind: 'point',
brush_position: '100,200',
new_instances_count: 1,
instances_hidden: true,
});
expect(result.success).toBe(true);
expect(result.message).toEqual(expect.stringContaining('hidden at start'));
const [created] = getRawInstances(testScene);
expect(created.isHidden()).toBe(true);
});
it('hides then shows an existing instance with the none brush', async () => {
await putInstances({
brush_kind: 'point',
brush_position: '100,200',
new_instances_count: 1,
});
const [created] = getRawInstances(testScene);
expect(created.isHidden()).toBe(false);
const id = created.getPersistentUuid().slice(0, 10);
const hideResult = await putInstances({
brush_kind: 'none',
existing_instance_ids: id,
instances_hidden: true,
});
expect(hideResult.success).toBe(true);
expect(hideResult.message).toEqual(
expect.stringContaining(
'Marked 1 instance of "Player" as hidden at start'
)
);
expect(created.isHidden()).toBe(true);
const showResult = await putInstances({
brush_kind: 'none',
existing_instance_ids: id,
instances_hidden: false,
});
expect(showResult.success).toBe(true);
expect(showResult.message).toEqual(
expect.stringContaining(
'Marked 1 instance of "Player" as visible at start'
)
);
expect(created.isHidden()).toBe(false);
});
it('leaves the hidden flag untouched when instances_hidden is omitted', async () => {
await putInstances({
brush_kind: 'point',
brush_position: '100,200',
new_instances_count: 1,
instances_hidden: true,
});
const [created] = getRawInstances(testScene);
const id = created.getPersistentUuid().slice(0, 10);
const result = await putInstances({
brush_kind: 'none',
existing_instance_ids: id,
instances_z_order: 5,
});
expect(result.success).toBe(true);
expect(created.isHidden()).toBe(true);
});
it('accepts instances_hidden alone as a change on existing instances', async () => {
await putInstances({
brush_kind: 'point',
brush_position: '100,200',
new_instances_count: 1,
});
const [created] = getRawInstances(testScene);
// instances_hidden must count as a requested mutation (no
// "no change was requested" failure), even when it is the only parameter.
const result = await putInstances({
brush_kind: 'none',
existing_instance_ids: created.getPersistentUuid().slice(0, 10),
instances_hidden: true,
});
expect(result.success).toBe(true);
expect(created.isHidden()).toBe(true);
});
});
@@ -329,3 +329,86 @@ describe('put_3d_instances (modifications of existing instances)', () => {
expect(instances[0].z).toBe(30);
});
});
describe('put_3d_instances (instances_hidden)', () => {
let project: gdProject;
let testScene: gdLayout;
beforeEach(() => {
makeTestExtensions(gd);
// $FlowFixMe[invalid-constructor]
project = new gd.ProjectHelper.createNewGDJSProject();
testScene = project.insertNewLayout('TestScene', 0);
testScene
.getObjects()
.insertNewObject(project, 'FakeScene3D::Cube3DObject', 'Cube', 0);
});
afterEach(() => {
project.delete();
});
const getRawInstances = (scene: gdLayout): Array<gdInitialInstance> => {
const instances = [];
const functor = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
functor.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
instances.push(instance);
};
// $FlowFixMe[incompatible-type]
scene.getInitialInstances().iterateOverInstances(functor);
functor.delete();
return instances;
};
const putInstances = async (args: any) =>
editorFunctions.put_3d_instances.launchFunction({
...makeFakeLaunchFunctionOptionsWithProject(project),
args: {
scene_name: 'TestScene',
object_name: 'Cube',
layer_name: '',
...args,
},
});
it('creates 3D instances hidden at start', async () => {
const result = await putInstances({
brush_kind: 'point',
brush_position: '10,20,30',
new_instances_count: 1,
instances_hidden: true,
});
expect(result.success).toBe(true);
expect(result.message).toEqual(expect.stringContaining('hidden at start'));
const [created] = getRawInstances(testScene);
expect(created.isHidden()).toBe(true);
});
it('hides an existing 3D instance with the none brush, alone as a change', async () => {
await putInstances({
brush_kind: 'point',
brush_position: '10,20,30',
new_instances_count: 1,
});
const [created] = getRawInstances(testScene);
expect(created.isHidden()).toBe(false);
const result = await putInstances({
brush_kind: 'none',
existing_instance_ids: created.getPersistentUuid().slice(0, 10),
instances_hidden: true,
});
expect(result.success).toBe(true);
expect(result.message).toEqual(
expect.stringContaining('Marked 1 instance of "Cube" as hidden at start')
);
expect(created.isHidden()).toBe(true);
});
});
+159 -3
View File
@@ -3386,8 +3386,15 @@ const describeInstances: EditorFunction = {
width,
height,
depth,
// Expose the per-instance variables (overrides of the object
// variables), but only when there are some, to keep the output
// compact. Absence means the instance uses the object variables.
initialVariables:
serializedInstance.initialVariables &&
serializedInstance.initialVariables.length > 0
? serializedInstance.initialVariables
: undefined,
// For now, don't expose these:
initialVariables: undefined,
numberProperties: undefined,
stringProperties: undefined,
});
@@ -3893,6 +3900,7 @@ const put2dInstances: EditorFunction = {
originalZOrder: instance.getZOrder(),
originalRotation: instance.getAngle(),
originalOpacity: instance.getOpacity(),
originalHidden: instance.isHidden(),
originalCustomWidth: instance.hasCustomSize()
? instance.getCustomWidth()
: null,
@@ -4036,6 +4044,10 @@ const put2dInstances: EditorFunction = {
args,
'instances_opacity'
);
const instancesHidden = SafeExtractor.extractBooleanProperty(
args,
'instances_hidden'
);
modifiedAndCreatedInstances.forEach(instance => {
if (instancesSize) {
@@ -4052,6 +4064,9 @@ const put2dInstances: EditorFunction = {
if (instancesOpacity !== null) {
instance.setOpacity(instancesOpacity);
}
if (instancesHidden !== null) {
instance.setHidden(instancesHidden);
}
});
// Track specific changes that were made
@@ -4063,6 +4078,8 @@ const put2dInstances: EditorFunction = {
attrs.push(`rotation ${instancesRotation}°`);
if (instancesOpacity !== null)
attrs.push(`opacity ${instancesOpacity}/255`);
if (instancesHidden !== null)
attrs.push(instancesHidden ? 'hidden at start' : 'visible at start');
if (instances_z_order !== null)
attrs.push(`z-order ${instances_z_order}`);
const effectiveSize = instancesSize
@@ -4108,6 +4125,7 @@ const put2dInstances: EditorFunction = {
let resizedCount = 0;
let rotatedCount = 0;
let opacityChangedCount = 0;
let hiddenChangedCount = 0;
let zOrderChangedCount = 0;
existingInstanceStates.forEach((originalState, instance) => {
@@ -4139,6 +4157,12 @@ const put2dInstances: EditorFunction = {
) {
opacityChangedCount++;
}
if (
instancesHidden !== null &&
originalState.originalHidden !== instance.isHidden()
) {
hiddenChangedCount++;
}
if (
instances_z_order !== null &&
originalState.originalZOrder !== instance.getZOrder()
@@ -4200,6 +4224,18 @@ const put2dInstances: EditorFunction = {
);
}
if (hiddenChangedCount > 0 && instancesHidden !== null) {
changes.push(
instancesHidden
? `Marked ${hiddenChangedCount} instance${
hiddenChangedCount > 1 ? 's' : ''
}${ofObjectsSuffix} as hidden at start (they can be displayed with the "Show" action).`
: `Marked ${hiddenChangedCount} instance${
hiddenChangedCount > 1 ? 's' : ''
}${ofObjectsSuffix} as visible at start.`
);
}
if (zOrderChangedCount > 0 && instances_z_order !== null) {
changes.push(
`Changed Z-order of ${zOrderChangedCount} instance${
@@ -4236,6 +4272,7 @@ const put2dInstances: EditorFunction = {
!!instancesSize ||
instancesRotation !== null ||
instancesOpacity !== null ||
instancesHidden !== null ||
instances_z_order !== null;
const hasPositionBrush =
brush_kind === 'point' ||
@@ -4749,6 +4786,7 @@ const put3dInstances: EditorFunction = {
originalRotationX: instance.getRotationX(),
originalRotationY: instance.getRotationY(),
originalRotationZ: instance.getAngle(),
originalHidden: instance.isHidden(),
originalCustomWidth: instance.hasCustomSize()
? instance.getCustomWidth()
: null,
@@ -4854,6 +4892,10 @@ const put3dInstances: EditorFunction = {
const instancesRotationArray = instances_rotation
? instances_rotation.split(',').map(coord => parseFloat(coord) || 0)
: null;
const instancesHidden = SafeExtractor.extractBooleanProperty(
args,
'instances_hidden'
);
modifiedAndCreatedInstances.forEach(instance => {
if (instancesSizeArray) {
@@ -4868,6 +4910,9 @@ const put3dInstances: EditorFunction = {
instance.setRotationY(instancesRotationArray[1]);
instance.setAngle(instancesRotationArray[2]);
}
if (instancesHidden !== null) {
instance.setHidden(instancesHidden);
}
});
// Track specific changes that were made
@@ -4885,6 +4930,8 @@ const put3dInstances: EditorFunction = {
instancesRotationArray[1]
}°, ${instancesRotationArray[2]}°)`
);
if (instancesHidden !== null)
attrs.push(instancesHidden ? 'hidden at start' : 'visible at start');
const effectiveSize = instancesSizeArray
? instancesSizeArray
: objectSizeInfo &&
@@ -4928,6 +4975,7 @@ const put3dInstances: EditorFunction = {
let movedPositionCount = 0;
let resizedCount = 0;
let rotatedCount = 0;
let hiddenChangedCount = 0;
existingInstanceStates.forEach((originalState, instance) => {
if (originalState.originalLayer !== instance.getLayer()) {
@@ -4957,6 +5005,12 @@ const put3dInstances: EditorFunction = {
) {
rotatedCount++;
}
if (
instancesHidden !== null &&
originalState.originalHidden !== instance.isHidden()
) {
hiddenChangedCount++;
}
});
// Name the modified object(s) in messages so a wrongly targeted call is
@@ -5008,6 +5062,18 @@ const put3dInstances: EditorFunction = {
);
}
if (hiddenChangedCount > 0 && instancesHidden !== null) {
changes.push(
instancesHidden
? `Marked ${hiddenChangedCount} instance${
hiddenChangedCount > 1 ? 's' : ''
}${ofObjectsSuffix} as hidden at start (they can be displayed with the "Show" action).`
: `Marked ${hiddenChangedCount} instance${
hiddenChangedCount > 1 ? 's' : ''
}${ofObjectsSuffix} as visible at start.`
);
}
if (notFoundExistingInstanceIds.size > 0) {
// If NONE of the requested instances were found and nothing new was
// created, the call did nothing. Return a failure so the agent gets a
@@ -5034,7 +5100,8 @@ const put3dInstances: EditorFunction = {
const matchedCount = existingInstanceStates.size;
const hasMutationParams =
!!instancesSizeArray ||
(!!instancesRotationArray && instancesRotationArray.length >= 3);
(!!instancesRotationArray && instancesRotationArray.length >= 3) ||
instancesHidden !== null;
const hasPositionBrush =
brush_kind === 'point' ||
brush_kind === 'line' ||
@@ -7850,11 +7917,13 @@ const resolveVariablesContainers = ({
variable_scope,
scene_name,
object_name,
instance_id,
}: {|
project: gdProject,
variable_scope: string,
scene_name: ?string,
object_name: ?string,
instance_id: ?string,
|}): VariablesContainersResolution => {
const fail = (message: string): VariablesContainersResolution => ({
failure: makeGenericFailure(message),
@@ -7862,6 +7931,65 @@ const resolveVariablesContainers = ({
scopeDescription: '',
});
if (variable_scope === 'instance') {
if (!scene_name) {
return fail(`Missing "scene_name" (required for instance variables).`);
}
if (!project.hasLayoutNamed(scene_name)) {
return fail(getSceneNotFoundMessage(project, scene_name));
}
const instanceId = instance_id ? instance_id.trim() : '';
if (!instanceId) {
return fail(
`Missing "instance_id" (required for instance variables) - get it from \`describe_instances\` (the \`id\` field of each instance).`
);
}
const layout = project.getLayout(scene_name);
let wrongObjectDescription = null;
// The id is a prefix of the instance persistent uuid (like everywhere
// else, see `describe_instances`) - it normally matches a single
// instance.
const matchedInstances: Array<gdInitialInstance> = [];
iterateOnInstances(layout.getInitialInstances(), instance => {
if (!instance.getPersistentUuid().startsWith(instanceId)) return;
if (object_name && instance.getObjectName() !== object_name) {
wrongObjectDescription = `"${instanceId}" (instance of "${instance.getObjectName()}")`;
return;
}
matchedInstances.push(instance);
});
if (object_name && wrongObjectDescription) {
return fail(
`This instance id is not an instance of object "${object_name}": ${wrongObjectDescription}. Nothing was changed. Pass the right \`object_name\` (or omit it), or fix the id using \`describe_instances\`.`
);
}
if (matchedInstances.length === 0) {
return fail(
`No instance with id "${instanceId}" found in scene "${scene_name}". Nothing was changed. Call \`describe_instances\` to get valid ids (the \`id\` field of each instance).`
);
}
const instancesLabel = matchedInstances
.map(
instance =>
`"${instance
.getPersistentUuid()
.slice(0, 10)}" (${instance.getObjectName()})`
)
.join(', ');
return {
failure: null,
variablesContainers: matchedInstances.map(instance =>
instance.getVariables()
),
scopeDescription: `instance${
matchedInstances.length > 1 ? 's' : ''
} ${instancesLabel} of scene "${scene_name}"`,
};
}
if (variable_scope === 'scene') {
if (!scene_name) {
return fail(`Missing "scene_name" (required for scene variable).`);
@@ -7944,7 +8072,7 @@ const resolveVariablesContainers = ({
}
return fail(
`Invalid "variable_scope": "${variable_scope}". Use \`scene\`, \`object\`, \`group\` or \`global\`.`
`Invalid "variable_scope": "${variable_scope}". Use \`scene\`, \`object\`, \`group\`, \`instance\` or \`global\`.`
);
};
@@ -8011,6 +8139,17 @@ const addOrEditVariable: EditorFunction = {
details,
hasDetailsToShow: true,
};
} else if (variable_scope === 'instance') {
return {
text: (
<Trans>
Set variable <b>{variable_name_or_path}</b> on an instance of
scene {scene_name}.
</Trans>
),
details,
hasDetailsToShow: true,
};
}
return {
@@ -8056,6 +8195,17 @@ const addOrEditVariable: EditorFunction = {
details,
hasDetailsToShow: true,
};
} else if (variable_scope === 'instance') {
return {
text: (
<Trans>
Update variables <b>{variableNames}</b> on an instance of scene{' '}
{scene_name}.
</Trans>
),
details,
hasDetailsToShow: true,
};
}
return {
@@ -8075,6 +8225,10 @@ const addOrEditVariable: EditorFunction = {
'object_name'
);
const scene_name = SafeExtractor.extractStringProperty(args, 'scene_name');
const instance_id = SafeExtractor.extractStringProperty(
args,
'instance_id'
);
const operations = extractVariableOperations(args);
if (operations.length === 0) {
return makeGenericFailure(
@@ -8087,6 +8241,7 @@ const addOrEditVariable: EditorFunction = {
variable_scope,
scene_name,
object_name,
instance_id,
});
if (resolved.failure) return resolved.failure;
const { variablesContainers, scopeDescription } = resolved;
@@ -8236,6 +8391,7 @@ const inspectVariables: EditorFunction = {
variable_scope,
scene_name,
object_name,
instance_id: SafeExtractor.extractStringProperty(args, 'instance_id'),
});
if (resolved.failure) return resolved.failure;
const { variablesContainers, scopeDescription } = resolved;
@@ -28,6 +28,8 @@ import Unlink from '../../UI/CustomSvgIcons/Unlink';
import RemoveCircle from '../../UI/CustomSvgIcons/RemoveCircle';
import Lock from '../../UI/CustomSvgIcons/Lock';
import LockOpen from '../../UI/CustomSvgIcons/LockOpen';
import Visibility from '../../UI/CustomSvgIcons/Visibility';
import VisibilityOff from '../../UI/CustomSvgIcons/VisibilityOff';
import Restore from '../../UI/CustomSvgIcons/Restore';
import Object3d from '../../UI/CustomSvgIcons/Object3d';
import Object2d from '../../UI/CustomSvgIcons/Object2d';
@@ -175,6 +177,33 @@ const getTitleRow = ({ i18n }: {| i18n: I18nType |}): Field => ({
nonFieldType: 'title',
defaultValue: i18n._(t`Different objects`),
},
{
name: 'Hide instance',
getLabel: (instance: gdInitialInstance) =>
instance.isHidden()
? i18n._(t`Hidden when the scene starts`)
: i18n._(t`Visible when the scene starts`),
valueType: 'enumIcon',
renderIcon: value =>
value === 'hidden' ? (
<VisibilityOff style={styles.icon} />
) : (
<Visibility style={styles.icon} />
),
isHighlighted: value => value === 'hidden',
getValue: (instance: gdInitialInstance) =>
instance.isHidden() ? 'hidden' : 'visible',
setValue: (
instance: gdInitialInstance,
newValue: 'hidden' | 'visible'
) => {
instance.setHidden(newValue === 'hidden');
},
getNextValue: (
currentValue: 'hidden' | 'visible'
): 'hidden' | 'visible' =>
currentValue === 'hidden' ? 'visible' : 'hidden',
},
{
name: 'Lock instance',
getLabel: (instance: gdInitialInstance) =>
@@ -0,0 +1,145 @@
// @flow
import * as PIXI from 'pixi.js-legacy';
import transformRect from '../Utils/TransformRect';
import { type InstanceMeasurer } from './InstancesRenderer';
import Rectangle from '../Utils/Rectangle';
const gd: libGDevelop = global.gd;
/**
* Displays a crossed-eye icon on every instance that is hidden when the
* scene starts. Those instances are still shown in the editor, and the icon
* makes their state visible at a glance.
*/
export default class HiddenInstancesDecorations {
instances: gdInitialInstancesContainer;
layersContainer: gdLayersContainer;
instanceMeasurer: InstanceMeasurer;
toCanvasCoordinates: (x: number, y: number) => [number, number];
// $FlowFixMe[value-as-type]
decorations: PIXI.Container;
// $FlowFixMe[value-as-type]
iconTexture: PIXI.Texture;
instancesFunctor: gdInitialInstanceJSFunctor;
_temporaryRectangle: Rectangle = new Rectangle();
_shownIconsCount = 0;
constructor({
instances,
layersContainer,
instanceMeasurer,
toCanvasCoordinates,
}: {
instances: gdInitialInstancesContainer,
layersContainer: gdLayersContainer,
instanceMeasurer: InstanceMeasurer,
toCanvasCoordinates: (x: number, y: number) => [number, number],
}) {
this.instances = instances;
this.layersContainer = layersContainer;
this.instanceMeasurer = instanceMeasurer;
this.toCanvasCoordinates = toCanvasCoordinates;
this.decorations = new PIXI.Container();
// The same icon as the one shown by the in-game editor on hidden
// instances (see `HiddenInstanceIcon.svg`), rasterized at 8x its
// intrinsic size so it stays crisp at the biggest icon sizes.
this.iconTexture = PIXI.Texture.from('res/hidden-instance-icon.svg', {
resourceOptions: { scale: 8 },
});
// $FlowFixMe[invalid-constructor] - JSFunctor is a class-like.
this.instancesFunctor = new gd.InitialInstanceJSFunctor();
// $FlowFixMe[cannot-write]
this.instancesFunctor.invoke = instancePtr => {
const instance: gdInitialInstance = gd.wrapPointer(
// $FlowFixMe[incompatible-type]
instancePtr,
gd.InitialInstance
);
if (!instance.isHidden()) return;
if (!this._isOnVisibleLayer(instance)) return;
const canvasRectangle = transformRect(
this.toCanvasCoordinates,
this.instanceMeasurer.getInstanceAABB(
instance,
this._temporaryRectangle
)
);
// The icon size follows the on-screen size of the instance (so it
// never dwarfs a small or zoomed-out instance, and stays legible on a
// big one), within limits - like in the 3D editor.
const width = canvasRectangle.width();
const height = canvasRectangle.height();
const onScreenRadius = Math.sqrt(width * width + height * height) / 2;
// The 10px legibility floor never exceeds the size of the instance
// itself: when zoomed far out, the icon shrinks with the instance
// instead of dwarfing it.
const iconSize = Math.min(
Math.max(onScreenRadius * 0.4, 10),
24,
onScreenRadius
);
// Too small to be readable: don't show a smudge.
if (iconSize < 4) return;
// Centered on the instance, like in the 3D editor.
this._showIcon(
canvasRectangle.left + width / 2,
canvasRectangle.top + height / 2,
iconSize
);
};
}
_isOnVisibleLayer(instance: gdInitialInstance): boolean {
const layerName = instance.getLayer();
if (!this.layersContainer.hasLayerNamed(layerName)) return true;
return this.layersContainer.getLayer(layerName).getVisibility();
}
/**
* Show an icon centered on the given canvas position, with the given size
* (in canvas pixels), reusing the sprites of the previous renders.
*/
_showIcon(x: number, y: number, size: number) {
let sprite = this.decorations.children[this._shownIconsCount];
if (!sprite) {
sprite = new PIXI.Sprite(this.iconTexture);
sprite.anchor.set(0.5, 0.5);
this.decorations.addChild(sprite);
}
sprite.visible = true;
sprite.position.set(x, y);
// Set on every render: this also fixes the size of the sprites created
// before the texture finished loading.
sprite.width = size;
sprite.height = size;
this._shownIconsCount++;
}
// $FlowFixMe[value-as-type]
getPixiObject(): PIXI.Container {
return this.decorations;
}
render() {
this._shownIconsCount = 0;
// $FlowFixMe[incompatible-type]
this.instances.iterateOverInstances(this.instancesFunctor);
// Hide the pooled sprites that were not used by this render.
for (
let i = this._shownIconsCount;
i < this.decorations.children.length;
i++
) {
this.decorations.children[i].visible = false;
}
}
delete() {
this.instancesFunctor.delete();
}
}
@@ -13,6 +13,8 @@ import CompactSearchBar from '../../UI/CompactSearchBar';
import RemoveCircle from '../../UI/CustomSvgIcons/RemoveCircle';
import Lock from '../../UI/CustomSvgIcons/Lock';
import LockOpen from '../../UI/CustomSvgIcons/LockOpen';
import Visibility from '../../UI/CustomSvgIcons/Visibility';
import VisibilityOff from '../../UI/CustomSvgIcons/VisibilityOff';
import RotateZ from '../../UI/CustomSvgIcons/RotateZ';
import Layers from '../../UI/CustomSvgIcons/Layers';
import SortArrowUp from '../../UI/CustomSvgIcons/SortArrowUp';
@@ -35,6 +37,7 @@ type RenderedRowInfo = {
instance: gdInitialInstance,
name: string,
locked: boolean,
hidden: boolean,
x: string,
y: string,
angle: string,
@@ -153,6 +156,7 @@ class InstancesList extends Component<Props, State> {
instance,
name,
locked: instance.isLocked(),
hidden: instance.isHidden(),
x: toFixedWithoutTrailingZeros(instance.getX(), 2),
y: toFixedWithoutTrailingZeros(instance.getY(), 2),
angle: toFixedWithoutTrailingZeros(instance.getAngle(), 2),
@@ -192,6 +196,29 @@ class InstancesList extends Component<Props, State> {
}
};
_renderVisibilityCell = ({
rowData: { instance },
}: {
rowData: RenderedRowInfo,
}): React.Node => {
return (
<IconButton
size="small"
tooltip={
instance.isHidden()
? t`Hidden when the scene starts`
: t`Visible when the scene starts`
}
onClick={() => {
instance.setHidden(!instance.isHidden());
this.props.onInstancesModified([instance]);
}}
>
{instance.isHidden() ? <VisibilityOff /> : <Visibility />}
</IconButton>
);
};
_renderLockCell = ({
rowData: { instance },
}: {
@@ -379,6 +406,13 @@ class InstancesList extends Component<Props, State> {
className={'tableColumn tableColumnSecondary'}
headerRenderer={renderSortableHeader}
/>
<RVColumn
label=""
dataKey="hidden"
width={Math.max(width * 0.05, minimumWidths.numberProperty)}
className={'tableColumn'}
cellRenderer={this._renderVisibilityCell}
/>
<RVColumn
label=""
dataKey="locked"
+21
View File
@@ -7,6 +7,7 @@ import InstancesRenderer from './InstancesRenderer';
import ViewPosition from './ViewPosition';
import SelectedInstances from './SelectedInstances';
import HighlightedInstance from './HighlightedInstance';
import HiddenInstancesDecorations from './HiddenInstancesDecorations';
import SelectionRectangle from './SelectionRectangle';
import InstancesResizer, {
type ResizeGrabbingLocation,
@@ -169,6 +170,7 @@ export default class InstancesEditor extends Component<Props, State> {
tileMapPaintingPreview: TileMapPaintingPreview;
clickInterceptor: ClickInterceptor;
highlightedInstance: HighlightedInstance;
hiddenInstancesDecorations: HiddenInstancesDecorations;
instancesResizer: InstancesResizer;
instancesRotator: InstancesRotator;
instancesMover: InstancesMover;
@@ -493,6 +495,12 @@ export default class InstancesEditor extends Component<Props, State> {
this.highlightedInstance.getPixiObject()
);
}
if (this.hiddenInstancesDecorations) {
this.uiPixiContainer.removeChild(
this.hiddenInstancesDecorations.getPixiObject()
);
this.hiddenInstancesDecorations.delete();
}
if (this.tileMapPaintingPreview) {
this.uiPixiContainer.removeChild(
this.tileMapPaintingPreview.getPixiObject()
@@ -595,6 +603,12 @@ export default class InstancesEditor extends Component<Props, State> {
toCanvasCoordinates: this.viewPosition.toCanvasCoordinates,
isInstanceOf3DObject: this.props.isInstanceOf3DObject,
});
this.hiddenInstancesDecorations = new HiddenInstancesDecorations({
instances: props.initialInstances,
layersContainer: props.layersContainer,
instanceMeasurer: this.instancesRenderer.getInstanceMeasurer(),
toCanvasCoordinates: this.viewPosition.toCanvasCoordinates,
});
this.instancesResizer = new InstancesResizer({
instanceMeasurer: this.instancesRenderer.getInstanceMeasurer(),
instancesEditorSettings: this.props.instancesEditorSettings,
@@ -630,6 +644,9 @@ export default class InstancesEditor extends Component<Props, State> {
this.uiPixiContainer.addChild(this.windowMask.getPixiObject());
this.uiPixiContainer.addChild(this.selectedInstances.getPixiContainer());
this.uiPixiContainer.addChild(this.highlightedInstance.getPixiObject());
this.uiPixiContainer.addChild(
this.hiddenInstancesDecorations.getPixiObject()
);
this.uiPixiContainer.addChild(this.tileMapPaintingPreview.getPixiObject());
this.uiPixiContainer.addChild(this.clickInterceptor.getPixiObject());
this.uiPixiContainer.addChild(this.statusBar.getPixiObject());
@@ -653,6 +670,9 @@ export default class InstancesEditor extends Component<Props, State> {
if (this.selectionRectangle) {
this.selectionRectangle.delete();
}
if (this.hiddenInstancesDecorations) {
this.hiddenInstancesDecorations.delete();
}
if (this.instancesRenderer) {
this.instancesRenderer.delete();
}
@@ -1805,6 +1825,7 @@ export default class InstancesEditor extends Component<Props, State> {
this.canvasCursor.render();
this.grid.render();
this.highlightedInstance.render();
this.hiddenInstancesDecorations.render();
this.tileMapPaintingPreview.render();
this.clickInterceptor.render();
this.selectedInstances.render();