Allow to edit layers/masks (from the Physics engines) and some other properties of behaviors in the properties panel (#9030)

This commit is contained in:
Florian Rival
2026-08-28 19:42:30 +02:00
committed by GitHub
parent 310d61fd89
commit 2a47c7cf01
15 changed files with 725 additions and 290 deletions
+78 -19
View File
@@ -219,6 +219,13 @@ module.exports = {
physics2Behavior.getProperties = function (behaviorContent) {
var behaviorProperties = new gd.MapStringPropertyDescriptor();
// The shape decides which dimensions are meaningful and how they
// should be labelled, so these properties adapt themselves to it.
const shape = behaviorContent.getChild('shape').getStringValue();
const isCircleShape = shape === 'Circle';
const isEdgeShape = shape === 'Edge';
const isPolygonShape = shape === 'Polygon';
behaviorProperties
.getOrCreate('bodyType')
.setValue(behaviorContent.getChild('bodyType').getStringValue())
@@ -283,12 +290,18 @@ module.exports = {
.getOrCreate('shape')
.setValue(behaviorContent.getChild('shape').getStringValue())
.setType('Choice')
.setLabel('Shape')
.setLabel(_('Shape'))
.setDescription(
_(
'The shape used for collisions. It gives their meaning to the dimension properties. A polygon is defined by its "Vertices" and positioned according to "Polygon origin".'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.addChoice('Box', _('Box'))
.addChoice('Circle', _('Circle'))
.addChoice('Edge', _('Edge'))
.addChoice('Polygon', _('Polygon'));
.addChoice('Polygon', _('Polygon'))
.setHasImpactOnOtherProperties(true);
behaviorProperties
.getOrCreate('shapeDimensionA')
.setValue(
@@ -299,9 +312,17 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Dimension A')
.setLabel(
isCircleShape ? _('Radius') : isEdgeShape ? _('Length') : _('Width')
)
.setDescription(
_(
'Width of the box, radius of the circle or length of the edge. Use 0 to follow the object size.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// A polygon is defined by its vertices instead of dimensions.
.setHidden(isPolygonShape);
behaviorProperties
.getOrCreate('shapeDimensionB')
.setValue(
@@ -311,10 +332,20 @@ module.exports = {
.toString(10)
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Dimension B')
.setMeasurementUnit(
isEdgeShape
? gd.MeasurementUnit.getDegreeAngle()
: gd.MeasurementUnit.getPixel()
)
.setLabel(isEdgeShape ? _('Angle') : _('Height'))
.setDescription(
_(
'Height of the box, or angle of the edge in degrees. Not used by a circle. Use 0 to follow the object height.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// A circle only needs a radius and a polygon uses its vertices.
.setHidden(isPolygonShape || isCircleShape);
behaviorProperties
.getOrCreate('shapeOffsetX')
.setValue(
@@ -322,9 +353,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Offset X')
.setLabel(_('Shape offset X'))
.setDescription(
_(
'Offset of the collision shape relative to the object center, on the X axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
behaviorProperties
.getOrCreate('shapeOffsetY')
.setValue(
@@ -332,9 +368,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Offset Y')
.setLabel(_('Shape offset Y'))
.setDescription(
_(
'Offset of the collision shape relative to the object center, on the Y axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
behaviorProperties
.getOrCreate('polygonOrigin')
.setValue(
@@ -343,12 +384,16 @@ module.exports = {
: 'Center'
)
.setType('Choice')
.setLabel('Polygon Origin')
.setLabel(_('Polygon origin'))
.setDescription(
_('Point of the object the polygon vertices are relative to.')
)
.addChoice('Center', _('Center'))
.addChoice('Origin', _('Origin'))
.addChoice('TopLeft', _('TopLeft'))
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// Only used by the "Polygon" shape.
.setHidden(!isPolygonShape);
behaviorProperties
.getOrCreate('vertices')
.setValue(
@@ -434,17 +479,31 @@ module.exports = {
behaviorProperties
.getOrCreate('layers')
.setValue(behaviorContent.getChild('layers').getIntValue().toString(10))
.setType('Number')
.setLabel('Layers')
.setType('Bitmask')
.addExtraInfo('bitCount=16')
.setLabel(_('Layers'))
.setDescription(
_(
'Layers the object belongs to, as a bitmask: layer 1 is 1, layer 2 is 2, layer 3 is 4, and so on up to layer 16 which is 32768.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setGroup(_('Collision filtering'))
.setAdvanced(true);
behaviorProperties
.getOrCreate('masks')
.setValue(behaviorContent.getChild('masks').getIntValue().toString(10))
.setType('Number')
.setLabel('Masks')
.setType('Bitmask')
.addExtraInfo('bitCount=16')
.setLabel(_('Masks'))
.setDescription(
_(
'Layers the object can collide with, as a bitmask: layer 1 is 1, layer 2 is 2, layer 3 is 4, and so on up to layer 16 which is 32768.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setGroup(_('Collision filtering'))
.setAdvanced(true);
return behaviorProperties;
};
+118 -35
View File
@@ -285,6 +285,15 @@ module.exports = {
behavior.getProperties = function (behaviorContent) {
const behaviorProperties = new gd.MapStringPropertyDescriptor();
// The shape decides which dimensions are meaningful and how they
// should be labelled, and the body type decides which collision
// layers are used, so these properties adapt themselves to them.
const shape = behaviorContent.getChild('shape').getStringValue();
const isBoxShape = shape === 'Box';
const isMeshShape = shape === 'Mesh';
const isStaticBody =
behaviorContent.getChild('bodyType').getStringValue() === 'Static';
behaviorProperties
.getOrCreate('object3D')
.setValue(behaviorContent.getChild('object3D').getStringValue())
@@ -343,13 +352,19 @@ module.exports = {
.getOrCreate('shape')
.setValue(behaviorContent.getChild('shape').getStringValue())
.setType('Choice')
.setLabel('Shape')
.setLabel(_('Shape'))
.setDescription(
_(
'The shape used for collisions. It gives their meaning to the dimension properties. A capsule or a cylinder is extended along the axis given by "Shape orientation" and a mesh uses the model given by "Simplified 3D model".'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.addChoice('Box', _('Box'))
.addChoice('Capsule', _('Capsule'))
.addChoice('Sphere', _('Sphere'))
.addChoice('Cylinder', _('Cylinder'))
.addChoice('Mesh', _('Mesh (works for Static only)'));
.addChoice('Mesh', _('Mesh (works for Static only)'))
.setHasImpactOnOtherProperties(true);
behaviorProperties
.getOrCreate('meshShapeResourceName')
.setValue(
@@ -359,8 +374,8 @@ module.exports = {
.addExtraInfo('model3D')
.setLabel(_('Simplified 3D model'))
.setDescription(_("Leave empty to use object's one"))
// Hidden as required to be changed in the full editor.
.setHidden(true)
// Only used by the "Mesh" shape.
.setHidden(!isMeshShape)
.setHasImpactOnOtherProperties(true);
behaviorProperties
.getOrCreate('shapeOrientation')
@@ -368,11 +383,16 @@ module.exports = {
behaviorContent.getChild('shapeOrientation').getStringValue()
)
.setType('Choice')
.setLabel('Shape orientation')
.setLabel(_('Shape orientation'))
.setDescription(
_('Axis along which the capsule or cylinder is extended.')
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.addChoice('Z', _('Z'))
.addChoice('Y', _('Y'))
.addChoice('X', _('X'));
.addChoice('X', _('X'))
// Only capsules and cylinders can be oriented.
.setHidden(shape !== 'Capsule' && shape !== 'Cylinder');
behaviorProperties
.getOrCreate('shapeDimensionA')
.setValue(
@@ -383,9 +403,15 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Dimension A')
.setLabel(isBoxShape ? _('Width') : _('Radius'))
.setDescription(
_(
'Width of the box, or radius of the sphere, capsule or cylinder. Use 0 to follow the object size.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// The "Mesh" shape uses a 3D model instead of dimensions.
.setHidden(isMeshShape);
behaviorProperties
.getOrCreate('shapeDimensionB')
.setValue(
@@ -396,9 +422,15 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Dimension B')
.setLabel(isBoxShape ? _('Height') : _('Depth'))
.setDescription(
_(
'Height of the box, or size of the capsule or cylinder along its orientation axis. Not used by a sphere. Use 0 to follow the object size.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// A sphere only needs a radius and a mesh uses a 3D model.
.setHidden(isMeshShape || shape === 'Sphere');
behaviorProperties
.getOrCreate('shapeDimensionC')
.setValue(
@@ -409,9 +441,15 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape Dimension C')
.setLabel(_('Depth'))
.setDescription(
_(
'Depth of the box. Only used by a box. Use 0 to follow the object depth.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
// Only a box has a third dimension to configure.
.setHidden(!isBoxShape);
if (!behaviorContent.hasChild('shapeOffsetX')) {
behaviorContent.addChild('shapeOffsetX').setDoubleValue(0);
}
@@ -425,10 +463,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape offset X')
.setLabel(_('Shape offset X'))
.setDescription(
_(
'Offset of the collision shape relative to the object center, on the X axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setAdvanced(true)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
if (!behaviorContent.hasChild('shapeOffsetY')) {
behaviorContent.addChild('shapeOffsetY').setDoubleValue(0);
}
@@ -442,9 +484,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape offset Y')
.setLabel(_('Shape offset Y'))
.setDescription(
_(
'Offset of the collision shape relative to the object center, on the Y axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
if (!behaviorContent.hasChild('shapeOffsetZ')) {
behaviorContent.addChild('shapeOffsetZ').setDoubleValue(0);
}
@@ -458,10 +505,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Shape offset Z')
.setLabel(_('Shape offset Z'))
.setDescription(
_(
'Offset of the collision shape relative to the object center, on the Z axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setAdvanced(true)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
if (!behaviorContent.hasChild('massCenterOffsetX')) {
behaviorContent.addChild('massCenterOffsetX').setDoubleValue(0);
}
@@ -475,10 +526,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Center of mass offset X')
.setLabel(_('Center of mass X'))
.setDescription(
_(
'Offset of the center of mass relative to the object center, on the X axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setAdvanced(true)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
if (!behaviorContent.hasChild('massCenterOffsetY')) {
behaviorContent.addChild('massCenterOffsetY').setDoubleValue(0);
}
@@ -492,10 +547,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Center of mass offset Y')
.setLabel(_('Center of mass Y'))
.setDescription(
_(
'Offset of the center of mass relative to the object center, on the Y axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setAdvanced(true)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
if (!behaviorContent.hasChild('massCenterOffsetZ')) {
behaviorContent.addChild('massCenterOffsetZ').setDoubleValue(0);
}
@@ -509,10 +568,14 @@ module.exports = {
)
.setType('Number')
.setMeasurementUnit(gd.MeasurementUnit.getPixel())
.setLabel('Center of mass offset Z')
.setLabel(_('Center of mass Z'))
.setDescription(
_(
'Offset of the center of mass relative to the object center, on the Z axis.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setAdvanced(true)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setAdvanced(true);
behaviorProperties
.getOrCreate('density')
.setValue(
@@ -627,19 +690,39 @@ module.exports = {
.setValue(
behaviorContent.getChild('layers').getIntValue().toString(10)
)
.setType('Number')
.setLabel('Layers')
.setType('Bitmask')
// Static objects use the layers 1 to 4 and moving ones the layers
// 5 to 8, so that they never register in the wrong layer group.
.addExtraInfo('bitCount=4')
.addExtraInfo(isStaticBody ? 'firstBit=0' : 'firstBit=4')
.setLabel(_('Layers'))
.setDescription(
_(
'Layers the object belongs to, as a bitmask. Static objects use the layers 1 to 4 (1, 2, 4 and 8) and moving ones the layers 5 to 8 (16, 32, 64 and 128).'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setGroup(_('Collision filtering'))
.setAdvanced(true);
behaviorProperties
.getOrCreate('masks')
.setValue(
behaviorContent.getChild('masks').getIntValue().toString(10)
)
.setType('Number')
.setLabel('Masks')
.setType('Bitmask')
.addExtraInfo('bitCount=8')
.setLabel(_('Masks'))
.setDescription(
_(
'Layers the object can collide with, as a bitmask: layer 1 is 1, layer 2 is 2, layer 3 is 4, and so on up to layer 8 which is 128. Not used by static objects, which accept every collision.'
)
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setHidden(true); // Hidden as required to be changed in the full editor.
.setGroup(_('Collision filtering'))
.setAdvanced(true)
// Static objects accept every collision: it's the mask of the moving
// objects that matters.
.setHidden(isStaticBody);
return behaviorProperties;
};
@@ -240,6 +240,8 @@ const translateTypeToHumanReadableDescription = type => {
if (caseInsensitiveCheck(type, 'boolean')) return '🔘 Boolean';
if (caseInsensitiveCheck(type, 'color')) return '🎨 Color';
if (caseInsensitiveCheck(type, 'resource')) return '🗂️ Resource';
if (caseInsensitiveCheck(type, 'bitmask'))
return '🔢 Bitmask, one bit per option (Number)';
return type;
};
@@ -1168,7 +1170,9 @@ const generateExtensionRawText = (
].join(' ');
}),
'',
effectMetadata.getHelpPath() ? generateReadMoreLink(effectMetadata.getHelpPath()) : '',
effectMetadata.getHelpPath()
? generateReadMoreLink(effectMetadata.getHelpPath())
: '',
'',
].join(`\n`),
};
@@ -17,13 +17,14 @@ import MeasurementUnitDocumentation from '../../../PropertiesEditor/MeasurementU
import ShapePreview from './ShapePreview';
import PolygonEditor from './PolygonEditor';
import { type BehaviorEditorProps } from '../BehaviorEditorProps.flow';
import Text from '../../../UI/Text';
import DismissableAlertMessage from '../../../UI/DismissableAlertMessage';
import { ResponsiveLineStackLayout } from '../../../UI/Layout';
import {
ColumnStackLayout,
ResponsiveLineStackLayout,
} from '../../../UI/Layout';
import { CompactBitmaskField } from '../../../UI/CompactBitmaskField';
import EmptyMessage from '../../../UI/EmptyMessage';
import useForceUpdate from '../../../Utils/UseForceUpdate';
import Button from '@material-ui/core/Button';
import ButtonGroup from '@material-ui/core/ButtonGroup';
import InputAdornment from '@material-ui/core/InputAdornment';
import Tooltip from '@material-ui/core/Tooltip';
import CircledInfo from '../../../UI/CustomSvgIcons/SmallCircledInfo';
@@ -129,38 +130,6 @@ export const UnitAdornment = (props: {|
);
};
const BitGroupEditor = (props: {|
bits: Array<boolean>,
onChange: (index: number, value: boolean) => void,
|}) => {
return (
<div style={{ overflowX: 'auto', flex: 1 }}>
<ButtonGroup disableElevation fullWidth>
{props.bits.map((bit, index) => (
<Button
key={index}
variant={bit ? 'contained' : 'outlined'}
color={bit ? 'primary' : 'default'}
onClick={() => props.onChange(index, !bit)}
>
{index + 1}
</Button>
))}
</ButtonGroup>
</div>
);
};
const isBitEnabled = (bitsValue: number, pos: number) => {
return !!(bitsValue & (1 << pos));
};
const enableBit = (bitsValue: number, pos: number, enable: boolean) => {
if (enable) bitsValue |= 1 << pos;
else bitsValue &= ~(1 << pos);
return bitsValue;
};
const Physics2Editor = (props: Props): React.Node => {
const { current: resourcesLoader } = React.useRef(ResourcesLoader);
const [image, setImage] = React.useState('');
@@ -178,7 +147,6 @@ const Physics2Editor = (props: Props): React.Node => {
);
const properties = behavior.getProperties();
const bits = Array(16).fill(null);
const shape = properties.get('shape').getValue();
const layersValues = parseInt(properties.get('layers').getValue(), 10);
const masksValues = parseInt(properties.get('masks').getValue(), 10);
@@ -247,20 +215,14 @@ const Physics2Editor = (props: Props): React.Node => {
/>
</Line>
<ResponsiveLineStackLayout>
{shape !== 'Polygon' && (
{/* Labels and visibility are given by the properties themselves,
as they depend on the shape. */}
{!properties.get('shapeDimensionA').isHidden() && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionA').getValue()}
key={'shapeDimensionA'}
floatingLabelText={
shape === 'Circle' ? (
<Trans>Radius</Trans>
) : shape === 'Edge' ? (
<Trans>Length</Trans>
) : (
<Trans>Width</Trans>
)
}
floatingLabelText={properties.get('shapeDimensionA').getLabel()}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionA', newValue)
@@ -271,14 +233,12 @@ const Physics2Editor = (props: Props): React.Node => {
}
/>
)}
{shape !== 'Polygon' && shape !== 'Circle' && (
{!properties.get('shapeDimensionB').isHidden() && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionB').getValue()}
key={'shapeDimensionB'}
floatingLabelText={
shape === 'Edge' ? <Trans>Angle</Trans> : <Trans>Height</Trans>
}
floatingLabelText={properties.get('shapeDimensionB').getLabel()}
min={shape === 'Edge' ? undefined : 0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionB', newValue)
@@ -289,7 +249,7 @@ const Physics2Editor = (props: Props): React.Node => {
}
/>
)}
{shape === 'Polygon' && (
{!properties.get('polygonOrigin').isHidden() && (
<ChoiceProperty
properties={properties}
propertyName={'polygonOrigin'}
@@ -504,29 +464,27 @@ const Physics2Editor = (props: Props): React.Node => {
}
/>
</ResponsiveLineStackLayout>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('layers').getLabel()}
</Text>
<BitGroupEditor
bits={bits.map((_, idx) => isBitEnabled(layersValues, idx))}
onChange={(index, value) => {
const newValue = enableBit(layersValues, index, value);
updateBehaviorProperty('layers', newValue.toString(10));
}}
/>
</Line>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('masks').getLabel()}
</Text>
<BitGroupEditor
bits={bits.map((_, idx) => isBitEnabled(masksValues, idx))}
onChange={(index, value) => {
const newValue = enableBit(masksValues, index, value);
updateBehaviorProperty('masks', newValue.toString(10));
}}
/>
<Line expand>
<ColumnStackLayout expand noMargin>
<CompactBitmaskField
label={properties.get('layers').getLabel()}
markdownDescription={properties.get('layers').getDescription()}
value={layersValues}
bitCount={16}
onChange={newValue =>
updateBehaviorProperty('layers', newValue.toString(10))
}
/>
<CompactBitmaskField
label={properties.get('masks').getLabel()}
markdownDescription={properties.get('masks').getDescription()}
value={masksValues}
bitCount={16}
onChange={newValue =>
updateBehaviorProperty('masks', newValue.toString(10))
}
/>
</ColumnStackLayout>
</Line>
</Column>
);
@@ -2,16 +2,18 @@
import { Trans } from '@lingui/macro';
import * as React from 'react';
import { Line, Column, Spacer } from '../../../UI/Grid';
import { Line, Column } from '../../../UI/Grid';
import Checkbox from '../../../UI/Checkbox';
import SemiControlledTextField from '../../../UI/SemiControlledTextField';
import { type BehaviorEditorProps } from '../BehaviorEditorProps.flow';
import Text from '../../../UI/Text';
import DismissableAlertMessage from '../../../UI/DismissableAlertMessage';
import { ResponsiveLineStackLayout } from '../../../UI/Layout';
import {
ColumnStackLayout,
ResponsiveLineStackLayout,
} from '../../../UI/Layout';
import { CompactBitmaskField } from '../../../UI/CompactBitmaskField';
import useForceUpdate from '../../../Utils/UseForceUpdate';
import Button from '@material-ui/core/Button';
import ButtonGroup from '@material-ui/core/ButtonGroup';
import {
NumericProperty,
UnitAdornment,
@@ -57,40 +59,6 @@ const areAdvancedPropertiesModified = (
return hasFoundModifiedAdvancedProperty;
};
const BitGroupEditor = (props: {|
bits: Array<boolean>,
onChange: (index: number, value: boolean) => void,
firstIndex: number,
disabled: boolean,
|}) => {
return (
<div style={{ overflowX: 'auto', flex: 1 }}>
<ButtonGroup disableElevation fullWidth disabled={props.disabled}>
{props.bits.map((bit, index) => (
<Button
key={props.firstIndex + index}
variant={bit ? 'contained' : 'outlined'}
color={bit ? 'primary' : 'default'}
onClick={() => props.onChange(props.firstIndex + index, !bit)}
>
{props.firstIndex + index + 1}
</Button>
))}
</ButtonGroup>
</div>
);
};
const isBitEnabled = (bitsValue: number, pos: number) => {
return !!(bitsValue & (1 << pos));
};
const enableBit = (bitsValue: number, pos: number, enable: boolean) => {
if (enable) bitsValue |= 1 << pos;
else bitsValue &= ~(1 << pos);
return bitsValue;
};
const Physics3DEditor = (props: Props): React.Node => {
const {
object,
@@ -132,17 +100,14 @@ const Physics3DEditor = (props: Props): React.Node => {
);
const properties = behavior.getProperties();
const staticBits = Array(4).fill(null);
const dynamicBits = Array(4).fill(null);
const shape = properties.get('shape').getValue();
const layersValues = parseInt(properties.get('layers').getValue(), 10);
const masksValues = parseInt(properties.get('masks').getValue(), 10);
const isStatic = properties.get('bodyType').getValue() === 'Static';
const canShapeBeOriented =
properties.get('shape').getValue() !== 'Sphere' &&
properties.get('shape').getValue() !== 'Box';
// The property is hidden for the shapes that can't be oriented.
const canShapeBeOriented = !properties.get('shapeOrientation').isHidden();
// $FlowFixMe[value-as-type]
const [gltf, setGltf] = React.useState<GLTF | null>(null);
@@ -281,56 +246,26 @@ const Physics3DEditor = (props: Props): React.Node => {
)}
{shape !== 'Mesh' && (
<ResponsiveLineStackLayout>
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionA').getValue()}
key={'shapeDimensionA'}
floatingLabelText={
shape === 'Box' ? <Trans>Width</Trans> : <Trans>Radius</Trans>
}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionA', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionA')} />
}
/>
{shape !== 'Sphere' && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionB').getValue()}
key={'shapeDimensionB'}
floatingLabelText={
shape === 'Box' ? <Trans>Height</Trans> : <Trans>Depth</Trans>
}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionB', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionB')} />
}
/>
)}
{shape === 'Box' && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionC').getValue()}
key={'shapeDimensionC'}
floatingLabelText={<Trans>Depth</Trans>}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionC', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionC')} />
}
/>
)}
{/* Labels and visibility are given by the properties themselves,
as they depend on the shape. */}
{['shapeDimensionA', 'shapeDimensionB', 'shapeDimensionC']
.filter(propertyName => !properties.get(propertyName).isHidden())
.map(propertyName => (
<SemiControlledTextField
fullWidth
value={properties.get(propertyName).getValue()}
key={propertyName}
floatingLabelText={properties.get(propertyName).getLabel()}
min={0}
onChange={newValue =>
updateBehaviorProperty(propertyName, newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get(propertyName)} />
}
/>
))}
</ResponsiveLineStackLayout>
)}
{shape === 'Mesh' && (
@@ -441,65 +376,32 @@ const Physics3DEditor = (props: Props): React.Node => {
}
/>
</ResponsiveLineStackLayout>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('layers').getLabel()}
</Text>
<BitGroupEditor
key={'static-layers'}
firstIndex={0}
bits={staticBits.map(
(_, index) => isBitEnabled(layersValues, index) && isStatic
<Line expand>
<ColumnStackLayout expand noMargin>
{/* Static and moving objects don't use the same layers, so only
the ones of the current body type are shown. */}
<CompactBitmaskField
label={properties.get('layers').getLabel()}
markdownDescription={properties.get('layers').getDescription()}
value={layersValues}
firstBit={isStatic ? 0 : 4}
bitCount={4}
onChange={newValue =>
updateBehaviorProperty('layers', newValue.toString(10))
}
/>
{!properties.get('masks').isHidden() && (
<CompactBitmaskField
label={properties.get('masks').getLabel()}
markdownDescription={properties.get('masks').getDescription()}
value={masksValues}
bitCount={8}
onChange={newValue =>
updateBehaviorProperty('masks', newValue.toString(10))
}
/>
)}
onChange={(index, value) => {
const newValue = enableBit(layersValues, index, value);
updateBehaviorProperty('layers', newValue.toString(10));
}}
disabled={!isStatic}
/>
<Spacer />
<BitGroupEditor
key={'dynamic-layers'}
firstIndex={4}
bits={dynamicBits.map(
(_, index) => isBitEnabled(layersValues, index + 4) && !isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(layersValues, index, value);
updateBehaviorProperty('layers', newValue.toString(10));
}}
disabled={isStatic}
/>
</Line>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('masks').getLabel()}
</Text>
<BitGroupEditor
key={'static-mask'}
firstIndex={0}
bits={staticBits.map(
(_, index) => isBitEnabled(masksValues, index) || isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(masksValues, index, value);
updateBehaviorProperty('masks', newValue.toString(10));
}}
disabled={isStatic}
/>
<Spacer />
<BitGroupEditor
key={'dynamic-mask'}
firstIndex={4}
bits={dynamicBits.map(
(_, index) => isBitEnabled(masksValues, index + 4) || isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(masksValues, index, value);
updateBehaviorProperty('masks', newValue.toString(10));
}}
disabled={isStatic}
/>
</ColumnStackLayout>
</Line>
<Accordion
defaultExpanded={areAdvancedPropertiesExpandedByDefault}
@@ -24,6 +24,7 @@ import CompactPropertiesEditorRowField from './CompactPropertiesEditorRowField';
import CompactToggleButtons from '../UI/CompactToggleButtons';
import { CompactToggleField } from '../UI/CompactToggleField';
import { CompactTextAreaField } from '../UI/CompactTextAreaField';
import { CompactBitmaskField } from '../UI/CompactBitmaskField';
import { CompactColorField } from '../UI/CompactColorField';
import { rgbOrHexToRGBString } from '../Utils/ColorTransformer';
import { CompactResourceSelectorWithThumbnail } from '../ResourcesList/CompactResourceSelectorWithThumbnail';
@@ -399,6 +400,31 @@ const CompactPropertiesEditor = ({
{field.renderIcon(value)}
</IconButton>
);
} else if (field.valueType === 'bitmask') {
const { setValue, firstBit, bitCount } = field;
const mixedValues = hasMixedValues({ instances, field });
return (
<CompactBitmaskField
key={field.name}
id={field.name}
label={getFieldLabel({ instances, field })}
markdownDescription={getFieldDescription(field)}
value={mixedValues ? 0 : getFieldValue({ instances, field })}
firstBit={firstBit}
bitCount={bitCount}
onChange={newValue => {
instances.forEach(i => setValue(i, newValue));
onFieldChanged({
instances,
hasImpactOnAllOtherFields: field.hasImpactOnAllOtherFields,
});
}}
disabled={getDisabled({ instances, field, mixedValues })}
labelColor={
isFieldHighlighted({ instances, field }) ? 'primary' : 'secondary'
}
/>
);
} else if (field.valueType === 'multilinestring') {
const { setValue } = field;
const mixedValues = hasMixedValues({ instances, field });
@@ -65,16 +65,25 @@ export const CompactBehaviorPropertiesEditor = ({
resourceManagementProps,
}: CompactBehaviorPropertiesEditorProps): React.Node => {
const [schemaRecomputeTrigger, forceRecomputeSchema] = useForceRecompute();
// The schema is built from the first behavior. There is always one, but
// stay safe as an empty list would break the whole properties panel.
const behavior = behaviors.length > 0 ? behaviors[0] : null;
// The behavior is identified by its pointer, as a new wrapper object is
// given at each render.
const behaviorPtr = behavior ? behavior.ptr : null;
const propertiesSchema = React.useMemo(
const propertiesSchema: Schema = React.useMemo(
() => {
if (schemaRecomputeTrigger) {
// schemaRecomputeTrigger allows to invalidate the schema when required.
}
const behaviorMetadataProperties = behaviorMetadata.getProperties();
if (!behavior) return [];
return propertiesMapToSchema({
properties: behaviorMetadataProperties,
defaultValueProperties: behaviorMetadataProperties,
// Use the behavior properties (and not the metadata ones) so that
// properties adapting themselves to the current values (labels,
// visibility...) are properly displayed.
properties: behavior.getProperties(),
defaultValueProperties: behaviorMetadata.getProperties(),
getPropertyValue: (instance, name) =>
instance
.getProperties()
@@ -89,7 +98,14 @@ export const CompactBehaviorPropertiesEditor = ({
shouldDisabledFieldsWithMixedValues: true,
});
},
[schemaRecomputeTrigger, behaviorMetadata, object, layersContainer]
// eslint-disable-next-line react-hooks/exhaustive-deps
[
schemaRecomputeTrigger,
behaviorPtr,
behaviorMetadata,
object,
layersContainer,
]
);
return (
@@ -92,6 +92,15 @@ export type PrimitiveValueField =
setValue: (instance: Instance, newValue: string) => void,
...ValueFieldCommonProperties,
|}
| {|
/** A number of which each bit is displayed as a toggle. */
valueType: 'bitmask',
getValue: Instance => number,
setValue: (instance: Instance, newValue: number) => void,
firstBit: number,
bitCount: number,
...ValueFieldCommonProperties,
|}
| {|
valueType: 'multilinestring',
getValue: Instance => string,
@@ -320,6 +320,34 @@ const createField = (
isHighlighted: isHighlightedForString,
disabled,
};
} else if (valueType === 'bitmask') {
// A bitmask is a number where each bit is a flag. The extra information
// tells which bits are meaningful, for example: "bitCount=4", "firstBit=4".
const extraInfo = property.getExtraInfo().toJSArray();
const getExtraInfoNumber = (key: string, defaultValue: number): number => {
const entry = extraInfo.find(info => info.startsWith(`${key}=`));
if (!entry) return defaultValue;
const parsedValue = parseInt(entry.substring(key.length + 1), 10);
return Number.isFinite(parsedValue) ? parsedValue : defaultValue;
};
return {
name,
valueType: 'bitmask',
getValue: getValueForNumber,
setValue: (instance: Instance, newValue: number) => {
setNumberValue(instance, name, newValue);
},
firstBit: getExtraInfoNumber('firstBit', 0),
bitCount: getExtraInfoNumber('bitCount', 8),
defaultValue: defaultValueNumber,
getLabel,
getDescription,
hasImpactOnAllOtherFields: property.hasImpactOnOtherProperties(),
visibility,
isHighlighted: isHighlightedForNumber,
disabled,
};
} else if (valueType === 'multilinestring') {
return {
name,
@@ -0,0 +1,73 @@
// @flow
import propertiesMapToSchema from './PropertiesMapToSchema';
const gd: libGDevelop = global.gd;
describe('propertiesMapToSchema (bitmask properties)', () => {
const makeSchema = (
setUpProperties: (properties: gdMapStringPropertyDescriptor) => void
) => {
const properties = new gd.MapStringPropertyDescriptor();
setUpProperties(properties);
const values = new Map<string, string>();
const schema = propertiesMapToSchema({
properties,
defaultValueProperties: properties,
getPropertyValue: (instance, name) => values.get(name) || '0',
onUpdateProperty: (instance, name, value) => {
values.set(name, value);
},
object: null,
layersContainer: null,
shouldDisabledFieldsWithMixedValues: false,
});
properties.delete();
return schema;
};
it('reads the edited bits from the extra information', () => {
const schema = makeSchema(properties => {
properties
.getOrCreate('layers')
.setValue('16')
.setType('Bitmask')
.addExtraInfo('bitCount=4')
.addExtraInfo('firstBit=4')
.setLabel('Layers');
});
expect(schema).toHaveLength(1);
const field = schema[0];
expect(field.valueType).toBe('bitmask');
expect(field.firstBit).toBe(4);
expect(field.bitCount).toBe(4);
});
it('falls back to the whole first byte without extra information', () => {
const schema = makeSchema(properties => {
properties
.getOrCreate('masks')
.setValue('255')
.setType('Bitmask')
.setLabel('Masks');
});
const field = schema[0];
expect(field.firstBit).toBe(0);
expect(field.bitCount).toBe(8);
});
it('leaves out a hidden bitmask, like any other hidden property', () => {
const schema = makeSchema(properties => {
properties
.getOrCreate('masks')
.setValue('255')
.setType('Bitmask')
.setLabel('Masks')
.setHidden(true);
});
expect(schema).toHaveLength(0);
});
});
+19
View File
@@ -8,6 +8,7 @@ import Subheader from '../UI/Subheader';
import SelectField from '../UI/SelectField';
import SelectOption from '../UI/SelectOption';
import ColorField from '../UI/ColorField';
import { CompactBitmaskField } from '../UI/CompactBitmaskField';
import { MarkdownText } from '../UI/MarkdownText';
import { rgbOrHexToRGBString } from '../Utils/ColorTransformer';
import FormHelperText from '@material-ui/core/FormHelperText';
@@ -307,6 +308,24 @@ const PropertiesEditor = ({
/>
</Column>
);
} else if (field.valueType === 'bitmask') {
const { setValue, firstBit, bitCount } = field;
return (
<Column key={field.name} expand noMargin>
<CompactBitmaskField
id={field.name}
label={getFieldLabel({ instances, field })}
markdownDescription={getFieldDescription(field)}
value={getFieldValue({ instances, field })}
firstBit={firstBit}
bitCount={bitCount}
onChange={newValue => {
instances.forEach(i => setValue(i, newValue));
_onInstancesModified(instances);
}}
/>
</Column>
);
} else if (field.valueType === 'multilinestring') {
const { setValue } = field;
return (
@@ -28,10 +28,12 @@ export const CompactBehaviorSharedDataPropertiesEditor = ({
if (schemaRecomputeTrigger) {
// schemaRecomputeTrigger allows to invalidate the schema when required.
}
const behaviorMetadataSharedProperties = behaviorMetadata.getSharedProperties();
return propertiesMapToSchema({
properties: behaviorMetadataSharedProperties,
defaultValueProperties: behaviorMetadataSharedProperties,
// Use the shared data properties (and not the metadata ones) so that
// properties adapting themselves to the current values (labels,
// visibility...) are properly displayed.
properties: behaviorSharedData.getProperties(),
defaultValueProperties: behaviorMetadata.getSharedProperties(),
getPropertyValue: (instance, name) =>
instance
.getProperties()
@@ -46,7 +48,10 @@ export const CompactBehaviorSharedDataPropertiesEditor = ({
shouldDisabledFieldsWithMixedValues: false,
});
},
[schemaRecomputeTrigger, behaviorMetadata]
// The shared data is identified by its pointer, as a new wrapper object is
// given at each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[schemaRecomputeTrigger, behaviorSharedData.ptr, behaviorMetadata]
);
return (
@@ -0,0 +1,67 @@
.container {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
gap: 4px;
}
.bits {
display: grid;
/* The number of columns is given by the component, so that bits are laid
out on rows of at most 8, like they are usually read. */
gap: 2px;
padding: 2px;
border-radius: 4px;
background-color: var(--theme-text-field-default-background-color);
/* Keep the toggles at the same size whatever the number of bits, but let
them shrink rather than overflow in a narrow panel. */
width: fit-content;
max-width: 100%;
}
.bit {
appearance: none;
border: 0;
padding: 0;
height: 20px;
width: 100%;
min-width: 0px;
border-radius: 3px;
background-color: transparent;
color: var(--theme-text-default-color);
font-family: var(--gdevelop-modern-font-family);
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 0.1s linear, color 0.1s linear;
}
.bit:hover {
background-color: var(--theme-hover-background-color);
}
.bit.enabled {
background-color: var(--theme-primary-color);
color: var(--theme-primary-text-contrast-color);
}
.bit:focus-visible {
outline: 1px solid var(--theme-text-field-active-border-color);
outline-offset: -1px;
}
.container.disabled .bit {
color: var(--theme-text-field-disabled-color);
cursor: default;
}
.container.disabled .bit:hover {
background-color: transparent;
}
.container.disabled .bit.enabled {
background-color: var(--theme-text-field-disabled-color);
color: var(--theme-text-field-default-background-color);
}
@@ -0,0 +1,125 @@
// @flow
import * as React from 'react';
import classNames from 'classnames';
import Tooltip from '@material-ui/core/Tooltip';
import classes from './CompactBitmaskField.module.css';
import Text from '../Text';
import { MarkdownText } from '../MarkdownText';
import { tooltipEnterDelay } from '../Tooltip';
const styles = {
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '17px',
maxHeight: 34, // 2 * lineHeight to limit to 2 lines.
},
};
export const isBitEnabled = (bitsValue: number, pos: number): boolean =>
!!(bitsValue & (1 << pos));
export const enableBit = (
bitsValue: number,
pos: number,
enable: boolean
): number => (enable ? bitsValue | (1 << pos) : bitsValue & ~(1 << pos));
export type CompactBitmaskFieldProps = {|
label?: string,
markdownDescription?: ?string,
/** The whole bitmask value. Bits outside of the edited ones are kept as is. */
value: number,
onChange: (newValue: number) => void,
/** Index of the first edited bit (0 by default). */
firstBit?: number,
/** Number of bits to edit, starting at `firstBit`. */
bitCount: number,
id?: string,
disabled?: boolean,
labelColor?: 'primary' | 'secondary',
|};
/**
* Edit the bits of a number, displayed as a grid of numbered toggles.
* Bits are labelled from 1, so that they match the layer numbers used
* in events.
*/
export const CompactBitmaskField = ({
label,
markdownDescription,
value,
onChange,
firstBit = 0,
bitCount,
id,
disabled,
labelColor,
}: CompactBitmaskFieldProps): React.Node => {
const title = !markdownDescription
? label
: [
label,
' - ',
<MarkdownText key="markdown-desc" source={markdownDescription} />,
];
return (
<div
className={classNames({
[classes.container]: true,
[classes.disabled]: disabled,
})}
>
{label && (
<Tooltip
title={title}
enterDelay={tooltipEnterDelay}
placement="bottom"
>
<Text
noMargin
// $FlowFixMe[incompatible-type]
style={styles.label}
color={labelColor === 'primary' ? 'primary' : 'secondary'}
>
{label}
</Text>
</Tooltip>
)}
<div
className={classes.bits}
id={id}
style={{
gridTemplateColumns: `repeat(${Math.min(
bitCount,
8
)}, minmax(0, 24px))`,
}}
>
{Array.from({ length: bitCount }, (_, index) => {
const bit = firstBit + index;
const enabled = isBitEnabled(value, bit);
return (
<button
key={bit}
type="button"
className={classNames({
[classes.bit]: true,
[classes.enabled]: enabled,
})}
aria-pressed={enabled}
disabled={disabled}
onClick={() => onChange(enableBit(value, bit, !enabled))}
>
{bit + 1}
</button>
);
})}
</div>
</div>
);
};
export default CompactBitmaskField;
@@ -0,0 +1,61 @@
// @flow
import * as React from 'react';
import muiDecorator from '../../ThemeDecorator';
import paperDecorator from '../../PaperDecorator';
import { CompactBitmaskField } from '../../../UI/CompactBitmaskField';
import { ColumnStackLayout } from '../../../UI/Layout';
import Text from '../../../UI/Text';
export default {
title: 'UI Building Blocks/CompactBitmaskField',
component: CompactBitmaskField,
decorators: [paperDecorator, muiDecorator],
};
export const Default = (): React.Node => {
const [layers, setLayers] = React.useState<number>(0b101);
const [masks, setMasks] = React.useState<number>(0xffff);
const [movingLayers, setMovingLayers] = React.useState<number>(0b00110000);
return (
<ColumnStackLayout expand>
<Text size="sub-title">16 bits (Physics2 layers)</Text>
<CompactBitmaskField
label="Layers"
markdownDescription="Layers the object belongs to, as a bitmask."
value={layers}
bitCount={16}
onChange={setLayers}
id="layers"
/>
<Text size="sub-title">16 bits, all enabled</Text>
<CompactBitmaskField
label="Masks"
value={masks}
bitCount={16}
onChange={setMasks}
/>
<Text size="sub-title">
4 bits starting at the 5th one (Physics3D layers of a moving object)
</Text>
<CompactBitmaskField
label="Layers"
value={movingLayers}
firstBit={4}
bitCount={4}
onChange={setMovingLayers}
/>
<Text size="sub-title">Disabled</Text>
<CompactBitmaskField
label="Layers"
value={0b1010}
bitCount={8}
onChange={() => {}}
disabled
/>
<Text size="sub-title">Without label</Text>
<CompactBitmaskField value={layers} bitCount={8} onChange={setLayers} />
</ColumnStackLayout>
);
};