Calculate Sprite collision masks automatically (#5447)

* Sprites now have an option to calculate their collision mask automatically, based on the first image of their first animation
* The generated collision mask consists of the biggest rectangle possible, encapsulating the image and avoiding transparent pixels
* This option is enabled by default for all new sprites, and can be disabled to create a custom collision mask like before
* When enabled, the mask will adapt automatically when the first image is updated
This commit is contained in:
Clément Pasteau
2023-06-30 15:33:50 +02:00
committed by GitHub
parent d4db61a595
commit 90d8afb5a0
27 changed files with 2317 additions and 493 deletions
@@ -110,11 +110,11 @@ void Direction::UnserializeFrom(const gd::SerializerElement& element) {
.GetBoolAttribute("automatic", true));
if (spriteElement.HasChild("CustomCollisionMask"))
sprite.SetCollisionMaskAutomatic(
sprite.SetFullImageCollisionMask(
!spriteElement.GetChild("CustomCollisionMask")
.GetBoolAttribute("custom", false));
else
sprite.SetCollisionMaskAutomatic(
sprite.SetFullImageCollisionMask(
!spriteElement.GetBoolAttribute("hasCustomCollisionMask", false));
std::vector<Polygon2d> mask;
@@ -173,7 +173,7 @@ void SaveSpritesDirection(const vector<Sprite>& sprites,
.SetAttribute("automatic", sprites[i].IsDefaultCenterPoint());
spriteElement.SetAttribute("hasCustomCollisionMask",
!sprites[i].IsCollisionMaskAutomatic());
!sprites[i].IsFullImageCollisionMask());
gd::SerializerElement& collisionMaskElement =
spriteElement.AddChild("customCollisionMask");
@@ -4,7 +4,9 @@
* reserved. This project is released under the MIT License.
*/
#include "GDCore/Extensions/Builtin/SpriteExtension/Sprite.h"
#include <iostream>
#include "GDCore/Extensions/Builtin/SpriteExtension/Polygon2d.h"
using namespace std;
@@ -14,11 +16,10 @@ namespace gd {
Point Sprite::badPoint("");
Sprite::Sprite()
: automaticCollisionMask(true),
: fullImageCollisionMask(false),
origine("origine"),
centre("centre"),
automaticCentre(true) {
}
automaticCentre(true) {}
Sprite::~Sprite(){};
@@ -7,6 +7,7 @@
#ifndef SPRITE_H
#define SPRITE_H
#include <memory>
#include "GDCore/Extensions/Builtin/SpriteExtension/Point.h"
#include "GDCore/Extensions/Builtin/SpriteExtension/Polygon2d.h"
#include "GDCore/String.h"
@@ -43,7 +44,7 @@ class GD_CORE_API Sprite {
/**
* \brief Get the collision mask (custom or automatically generated owing to
* IsCollisionMaskAutomatic())
* IsFullImageCollisionMask())
*
* \warning If the image has not been loaded ( using LoadImage ) and the
* collision mask is set as automatic, the returned mask won't be correct.
@@ -66,7 +67,7 @@ class GD_CORE_API Sprite {
/**
* \brief Set the custom collision mask.
* Call then `SetCollisionMaskAutomatic(false)` to use it.
* Call then `SetFullImageCollisionMask(false)` to use it.
*/
void SetCustomCollisionMask(const std::vector<Polygon2d>& collisionMask);
@@ -74,15 +75,15 @@ class GD_CORE_API Sprite {
* \brief Return true if the collision mask is a bounding box, false if a
* custom collision mask is used.
*/
inline bool IsCollisionMaskAutomatic() const {
return automaticCollisionMask;
inline bool IsFullImageCollisionMask() const {
return fullImageCollisionMask;
}
/**
* \brief Un/set use of the custom collision mask.
*/
inline void SetCollisionMaskAutomatic(bool enabled) {
automaticCollisionMask = enabled;
inline void SetFullImageCollisionMask(bool enabled) {
fullImageCollisionMask = enabled;
};
/**
@@ -161,9 +162,9 @@ class GD_CORE_API Sprite {
private:
gd::String image; ///< Name of the image to be loaded in Image Manager.
bool automaticCollisionMask; ///< True to use the custom collision mask.
///< Otherwise, a basic bounding box is returned
///< by GetCollisionMask()
bool fullImageCollisionMask; ///< True to use a bounding box wrapping the
///< whole image as collision mask. If false,
///< custom collision mask is used.
std::vector<Polygon2d> customCollisionMask; ///< Custom collision mask
std::vector<Point> points; ///< List of the points used by the sprite
@@ -25,13 +25,16 @@ namespace gd {
Animation SpriteObject::badAnimation;
SpriteObject::SpriteObject() : updateIfNotVisible(false) {}
SpriteObject::SpriteObject()
: updateIfNotVisible(false), adaptCollisionMaskAutomatically(true) {}
SpriteObject::~SpriteObject(){};
void SpriteObject::DoUnserializeFrom(gd::Project& project,
const gd::SerializerElement& element) {
updateIfNotVisible = element.GetBoolAttribute("updateIfNotVisible", true);
adaptCollisionMaskAutomatically =
element.GetBoolAttribute("adaptCollisionMaskAutomatically", false);
RemoveAllAnimations();
const gd::SerializerElement& animationsElement =
@@ -80,6 +83,8 @@ void SpriteObject::DoUnserializeFrom(gd::Project& project,
void SpriteObject::DoSerializeTo(gd::SerializerElement& element) const {
element.SetAttribute("updateIfNotVisible", updateIfNotVisible);
element.SetAttribute("adaptCollisionMaskAutomatically",
adaptCollisionMaskAutomatically);
// Animations
gd::SerializerElement& animationsElement = element.AddChild("animations");
@@ -47,8 +47,7 @@ class GD_CORE_API SpriteObject : public gd::ObjectConfiguration {
void ExposeResources(gd::ArbitraryResourceWorker& worker) override;
std::map<gd::String, gd::PropertyDescriptor> GetProperties() const override;
bool UpdateProperty(const gd::String& name,
const gd::String& value) override;
bool UpdateProperty(const gd::String& name, const gd::String& value) override;
std::map<gd::String, gd::PropertyDescriptor> GetInitialInstanceProperties(
const gd::InitialInstance& position,
@@ -118,14 +117,30 @@ class GD_CORE_API SpriteObject : public gd::ObjectConfiguration {
const std::vector<Animation>& GetAllAnimations() const { return animations; }
/**
* \brief Set if the object animation should be played even if the object is hidden
* or far from the camera.
* @brief Check if the collision mask adapts automatically to the animation.
*/
void SetUpdateIfNotVisible(bool updateIfNotVisible_) { updateIfNotVisible = updateIfNotVisible_; }
bool AdaptCollisionMaskAutomatically() const {
return adaptCollisionMaskAutomatically;
}
/**
* \brief Check if the object animation should be played even if the object is hidden
* or far from the camera (false by default).
* @brief Set if the collision mask adapts automatically to the animation.
*/
void SetAdaptCollisionMaskAutomatically(bool enable) {
adaptCollisionMaskAutomatically = enable;
}
/**
* \brief Set if the object animation should be played even if the object is
* hidden or far from the camera.
*/
void SetUpdateIfNotVisible(bool updateIfNotVisible_) {
updateIfNotVisible = updateIfNotVisible_;
}
/**
* \brief Check if the object animation should be played even if the object
* is hidden or far from the camera (false by default).
*/
bool GetUpdateIfNotVisible() const { return updateIfNotVisible; }
///@}
@@ -137,11 +152,15 @@ class GD_CORE_API SpriteObject : public gd::ObjectConfiguration {
mutable std::vector<Animation> animations;
bool updateIfNotVisible; ///< If set to true, ask the game engine to play
///< object animation even if hidden or far from the
///< screen.
///< object animation even if hidden or far from
///< the screen.
static Animation badAnimation; //< Bad animation when an out of bound
// animation is requested.
static Animation badAnimation; //< Bad animation when an out of bound
// animation is requested.
bool adaptCollisionMaskAutomatically; ///< If set to true, the collision
///< mask will be automatically
///< adapted to the animation of the
///< object.
};
} // namespace gd
+5 -2
View File
@@ -2965,8 +2965,8 @@ interface Sprite {
[Ref] Point GetPoint([Const] DOMString name);
boolean HasPoint([Const] DOMString name);
boolean IsCollisionMaskAutomatic();
void SetCollisionMaskAutomatic(boolean enabled);
boolean IsFullImageCollisionMask();
void SetFullImageCollisionMask(boolean enabled);
[Ref] VectorPolygon2d GetCustomCollisionMask();
void SetCustomCollisionMask([Const, Ref] VectorPolygon2d collisionMask);
@@ -3023,6 +3023,9 @@ interface SpriteObject {
void SetUpdateIfNotVisible(boolean updateIfNotVisible);
boolean GetUpdateIfNotVisible();
boolean AdaptCollisionMaskAutomatically();
void SetAdaptCollisionMaskAutomatically(boolean adaptCollisionMaskAutomatically);
};
SpriteObject implements ObjectConfiguration;
+2 -2
View File
@@ -12,8 +12,8 @@ declare class gdSprite {
delPoint(name: string): void;
getPoint(name: string): gdPoint;
hasPoint(name: string): boolean;
isCollisionMaskAutomatic(): boolean;
setCollisionMaskAutomatic(enabled: boolean): void;
isFullImageCollisionMask(): boolean;
setFullImageCollisionMask(enabled: boolean): void;
getCustomCollisionMask(): gdVectorPolygon2d;
setCustomCollisionMask(collisionMask: gdVectorPolygon2d): void;
delete(): void;
+2
View File
@@ -11,6 +11,8 @@ declare class gdSpriteObject extends gdObjectConfiguration {
moveAnimation(oldIndex: number, newIndex: number): void;
setUpdateIfNotVisible(updateIfNotVisible: boolean): void;
getUpdateIfNotVisible(): boolean;
adaptCollisionMaskAutomatically(): boolean;
setAdaptCollisionMaskAutomatically(adaptCollisionMaskAutomatically: boolean): void;
delete(): void;
ptr: number;
};
+6
View File
@@ -9,4 +9,10 @@ module.exports = {
},
'storybook-addon-mock/register',
],
// This fix the error "Can't resolve 'fs'" because of the package "jimp".
// See https://github.com/storybookjs/storybook/issues/16833#issuecomment-1060655174
webpackFinal: config => {
config.node = { fs: 'empty' };
return config;
},
};
+1382 -35
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -50,6 +50,7 @@
"firebase": "9.0.0-beta.2",
"fontfaceobserver": "2.0.13",
"fuse.js": "^6.5.3",
"jimp": "0.22.8",
"js-worker-search": "^1.4.1",
"jss-rtl": "^0.3.0",
"lodash": "4.17.4",
@@ -173,4 +174,4 @@
"<rootDir>/node_modules/(?!react-markdown|unified|remark-parse|mdast-util-from-markdown|micromark|decode-named-character-reference|remark-rehype|trim-lines|hast-util-whitespace|remark-gfm|mdast-util-gfm|mdast-util-find-and-replace|mdast-util-to-markdown|markdown-table)"
]
}
}
}
@@ -0,0 +1,147 @@
// @flow
import axios from 'axios';
import optionalLazyRequire from '../../../../Utils/OptionalLazyRequire';
import { checkIfCredentialsRequired } from '../../../../Utils/CrossOrigin';
import Jimp from 'jimp/es';
const lazyRequireFs = optionalLazyRequire('fs');
const gd: libGDevelop = global.gd;
async function getJimpImage(pathToFile: string) {
try {
if (pathToFile.startsWith('http://') || pathToFile.startsWith('https://')) {
const response = await axios.get(pathToFile, {
responseType: 'arraybuffer',
withCredentials: checkIfCredentialsRequired(pathToFile),
});
const imageBuffer = response.data;
const jimpImage = await Jimp.read(imageBuffer);
return jimpImage;
} else if (pathToFile.startsWith('file://')) {
const cleanedPath = pathToFile.replace('file://', '').split('?')[0];
// For some reason, calling directly Jimp.read with a path does not work
// in the renderer process of Electron. So we read the file ourselves
// and pass the ArrayBuffer to Jimp.
const fs = lazyRequireFs();
if (!fs) return null;
const file = fs.readFileSync(cleanedPath);
const imageBuffer = new Uint8Array(file).buffer;
const jimpImage = await Jimp.read(imageBuffer);
return jimpImage;
} else {
console.error(
'Unsupported image path. Only http://, https:// and file:// are supported.'
);
return null;
}
} catch (error) {
console.error('Error fetching image:', error);
return null;
}
}
// 25% of 255 to accept pixels that are not fully transparent (like effects)
const PIXEL_TRANSPARENCY_THRESHOLD = 64;
export const getMatchingCollisionMask = async (imageFile: string) => {
// First detect, the most left, right, top and bottom pixels that are not transparent.
// This will be used to crop the image.
// To do so, we scan the image starting from the borders and going to the center,
// until we find a non-transparent pixel. (To avoid going through the whole image)
if (!Jimp) return null;
const jimpImage = await getJimpImage(imageFile);
if (!jimpImage) return null;
const { width, height } = jimpImage.bitmap;
if (!width || !height) return null;
let minX = null;
let maxX = null;
let minY = null;
let maxY = null;
// Step 1: Scanning Rows from Top to Bottom, until we find a non-transparent pixel
for (let y = 0; y < height; y++) {
let foundNonTransparent = false;
for (let x = 0; x < width; x++) {
const idx = (y * width + x) << 2; // Calculate the pixel index
const alpha = jimpImage.bitmap.data[idx + 3];
if (alpha >= PIXEL_TRANSPARENCY_THRESHOLD) {
minY = y;
foundNonTransparent = true;
break;
}
}
if (foundNonTransparent) break;
}
if (minY === null) return null;
// Step 2: Scanning Rows from Bottom to Top, until we find a non-transparent pixel
for (let y = height - 1; y >= 0; y--) {
let foundNonTransparent = false;
for (let x = 0; x < width; x++) {
const idx = (y * width + x) << 2; // Calculate the pixel index
const alpha = jimpImage.bitmap.data[idx + 3];
if (alpha >= PIXEL_TRANSPARENCY_THRESHOLD) {
maxY = y;
foundNonTransparent = true;
break;
}
}
if (foundNonTransparent) break;
}
if (maxY === null) return null;
// Step 3: Scanning Columns from Left to Right, until we find a non-transparent pixel
for (let x = 0; x < width; x++) {
let foundNonTransparent = false;
for (let y = minY; y <= maxY; y++) {
const idx = (y * width + x) << 2; // Calculate the pixel index
const alpha = jimpImage.bitmap.data[idx + 3];
if (alpha >= PIXEL_TRANSPARENCY_THRESHOLD) {
minX = x;
foundNonTransparent = true;
break;
}
}
if (foundNonTransparent) break;
}
if (minX === null) return null;
// Step 4: Scanning Columns from Right to Left, until we find a non-transparent pixel
for (let x = width - 1; x >= 0; x--) {
let foundNonTransparent = false;
for (let y = minY; y <= maxY; y++) {
const idx = (y * width + x) << 2; // Calculate the pixel index
const alpha = jimpImage.bitmap.data[idx + 3];
if (alpha >= PIXEL_TRANSPARENCY_THRESHOLD) {
maxX = x;
foundNonTransparent = true;
break;
}
}
if (foundNonTransparent) break;
}
if (maxX === null) return null;
const collisionMaskWidth = maxX - minX + 1;
const collisionMaskHeight = maxY - minY + 1;
const collisionMaskXCenter = (minX + maxX + 1) / 2;
const collisionMaskYCenter = (minY + maxY + 1) / 2;
if (collisionMaskWidth <= 0 || collisionMaskHeight <= 0) return null;
const newPolygon = gd.Polygon2d.createRectangle(
collisionMaskWidth,
collisionMaskHeight
);
newPolygon.move(collisionMaskXCenter, collisionMaskYCenter);
const polygons = new gd.VectorPolygon2d();
polygons.push_back(newPolygon);
return polygons;
};
@@ -32,6 +32,7 @@ type Props = {|
onPolygonsUpdated: () => void,
forcedCursor: string | null,
deactivateControls?: boolean,
hideControls?: boolean,
|};
const CollisionMasksPreview = (props: Props) => {
@@ -57,6 +58,7 @@ const CollisionMasksPreview = (props: Props) => {
onClickVertice,
forcedCursor,
deactivateControls,
hideControls,
} = props;
if (deactivateControls) {
@@ -329,36 +331,39 @@ const CollisionMasksPreview = (props: Props) => {
/>
);
})}
{mapVector(polygons, (polygon, polygonIndex) => {
const vertices = polygon.getVertices();
return mapVector(vertices, (vertex, vertexIndex) => (
<circle
onPointerDown={
deactivateControls
? null
: () => onStartDragVertex(vertex, polygonIndex, vertexIndex)
}
{...dataObjectToProps({ draggable: 'true' })}
key={`polygon-${polygonIndex}-vertex-${vertexIndex}`}
fill={
vertex.ptr === props.highlightedVerticePtr
? 'rgba(0,0,0,0.75)'
: vertex.ptr === props.selectedVerticePtr
? 'rgba(107,175,255,0.75)'
: 'rgba(255,133,105,0.75)'
}
stroke={
vertex.ptr === props.highlightedVerticePtr ? 'white' : undefined
}
strokeWidth={2}
cx={vertex.get_x() * imageZoomFactor}
cy={vertex.get_y() * imageZoomFactor}
r={7}
style={vertexCircleStyle}
/>
));
})}
{newVertexHintPoint && (
{!hideControls &&
mapVector(polygons, (polygon, polygonIndex) => {
const vertices = polygon.getVertices();
return mapVector(vertices, (vertex, vertexIndex) => (
<circle
onPointerDown={
deactivateControls
? null
: () => onStartDragVertex(vertex, polygonIndex, vertexIndex)
}
{...dataObjectToProps({ draggable: 'true' })}
key={`polygon-${polygonIndex}-vertex-${vertexIndex}`}
fill={
vertex.ptr === props.highlightedVerticePtr
? 'rgba(0,0,0,0.75)'
: vertex.ptr === props.selectedVerticePtr
? 'rgba(107,175,255,0.75)'
: 'rgba(255,133,105,0.75)'
}
stroke={
vertex.ptr === props.highlightedVerticePtr
? 'white'
: undefined
}
strokeWidth={2}
cx={vertex.get_x() * imageZoomFactor}
cy={vertex.get_y() * imageZoomFactor}
r={7}
style={vertexCircleStyle}
/>
));
})}
{!hideControls && newVertexHintPoint && (
<circle
onPointerDown={
deactivateControls ? null : () => addVertex(newVertexHintPoint)
@@ -193,7 +193,8 @@ const PolygonSection = (props: PolygonSectionProps) => {
type PolygonsListProps = {|
polygons: gdVectorPolygon2d,
onPolygonsUpdated: () => void,
restoreCollisionMask: () => void,
onSetFullImageCollisionMask: () => Promise<void>,
onSetAutomaticallyAdaptCollisionMasks: () => Promise<void>,
onHoverVertice: (ptr: ?number) => void,
onClickVertice: (ptr: ?number) => void,
selectedVerticePtr: ?number,
@@ -210,7 +211,8 @@ const PolygonsList = (props: PolygonsListProps) => {
spriteHeight,
spriteWidth,
onPolygonsUpdated,
restoreCollisionMask,
onSetFullImageCollisionMask,
onSetAutomaticallyAdaptCollisionMasks,
onHoverVertice,
onClickVertice,
selectedVerticePtr,
@@ -229,6 +231,17 @@ const PolygonsList = (props: PolygonsListProps) => {
[spriteHeight, spriteWidth, polygons, onPolygonsUpdated]
);
const onRemovePolygon = React.useCallback(
(index: number) => {
gd.removeFromVectorPolygon2d(polygons, index);
if (polygons.size() === 0) {
onSetFullImageCollisionMask();
}
onPolygonsUpdated();
},
[polygons, onPolygonsUpdated, onSetFullImageCollisionMask]
);
React.useEffect(
() => {
if (polygons.size() === 0) {
@@ -247,13 +260,7 @@ const PolygonsList = (props: PolygonsListProps) => {
key={`polygon-${i}`}
polygon={polygon}
onUpdated={onPolygonsUpdated}
onRemove={() => {
gd.removeFromVectorPolygon2d(polygons, i);
if (polygons.size() === 0) {
restoreCollisionMask();
}
onPolygonsUpdated();
}}
onRemove={() => onRemovePolygon(i)}
onHoverVertice={onHoverVertice}
onClickVertice={onClickVertice}
selectedVerticePtr={selectedVerticePtr}
@@ -268,13 +275,15 @@ const PolygonsList = (props: PolygonsListProps) => {
primary
icon={<Add />}
label={<Trans>Add collision mask</Trans>}
onClick={() => {
addCollisionMask();
}}
onClick={addCollisionMask}
buildMenuTemplate={i18n => [
{
label: i18n._(t`Restore the default collision mask`),
click: restoreCollisionMask,
label: i18n._(t`Reset to automatic collision mask`),
click: onSetAutomaticallyAdaptCollisionMasks,
},
{
label: i18n._(t`Use full image as collision mask`),
click: onSetFullImageCollisionMask,
},
]}
/>
@@ -1,5 +1,5 @@
// @flow
import { Trans } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import React from 'react';
import FlatButton from '../../../../UI/FlatButton';
import EmptyMessage from '../../../../UI/EmptyMessage';
@@ -16,7 +16,6 @@ import {
copyAnimationsSpriteCollisionMasks,
} from '../Utils/SpriteObjectHelper';
import SpriteSelector from '../Utils/SpriteSelector';
import Window from '../../../../Utils/Window';
import every from 'lodash/every';
import ResourcesLoader from '../../../../ResourcesLoader';
import useForceUpdate from '../../../../Utils/UseForceUpdate';
@@ -27,6 +26,7 @@ import EditorMosaic, {
import { useResponsiveWindowWidth } from '../../../../UI/Reponsive/ResponsiveWindowMeasurer';
import Paper from '../../../../UI/Paper';
import ScrollView from '../../../../UI/ScrollView';
import useAlertDialog from '../../../../UI/Alert/useAlertDialog';
const gd: libGDevelop = global.gd;
const styles = {
@@ -59,6 +59,7 @@ type Props = {|
resourcesLoader: typeof ResourcesLoader,
project: gdProject,
onMasksUpdated?: () => void,
onCreateMatchingSpriteCollisionMask: () => Promise<void>,
|};
const CollisionMasksEditor = ({
@@ -66,6 +67,7 @@ const CollisionMasksEditor = ({
resourcesLoader,
project,
onMasksUpdated,
onCreateMatchingSpriteCollisionMask,
}: Props) => {
const [animationIndex, setAnimationIndex] = React.useState(0);
const [directionIndex, setDirectionIndex] = React.useState(0);
@@ -82,6 +84,8 @@ const CollisionMasksEditor = ({
const [spriteHeight, setSpriteHeight] = React.useState(0);
const forceUpdate = useForceUpdate();
const { showConfirmation } = useAlertDialog();
const spriteConfiguration = gd.asSpriteConfiguration(objectConfiguration);
const { animation, sprite } = getCurrentElements(
spriteConfiguration,
@@ -164,10 +168,14 @@ const CollisionMasksEditor = ({
[animation, sprite]
);
const onSetCollisionMaskAutomatic = React.useCallback(
(automatic: boolean = true) => {
const onSetFullImageCollisionMask = React.useCallback(
async (fullImage: boolean = true) => {
if (!sprite) return;
sprite.setCollisionMaskAutomatic(automatic);
if (fullImage) {
// Revert to non-automatic collision mask.
spriteConfiguration.setAdaptCollisionMaskAutomatically(false);
}
sprite.setFullImageCollisionMask(fullImage);
updateCollisionMasks(
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites
@@ -178,54 +186,130 @@ const CollisionMasksEditor = ({
updateCollisionMasks,
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites,
spriteConfiguration,
]
);
const setSameCollisionMasksForAllAnimations = (enable: boolean) => {
if (enable) {
const answer = Window.showConfirmDialog(
"Having the same collision masks for all animations will erase and reset all the other animations collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the animations of the object?"
const setSameCollisionMasksForAllAnimations = React.useCallback(
async (enable: boolean) => {
if (enable) {
const answer = await showConfirmation({
title: t`Use same collision mask for all animations?`,
message: t`Having the same collision masks for all animations will erase and reset all the other animations collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the animations of the object?`,
confirmButtonLabel: t`Use same collision mask`,
dismissButtonLabel: t`Cancel`,
});
if (!answer) return;
}
const newSameCollisionMasksForAnimationsValue = enable;
const newSameCollisionMasksForSpritesValue =
enable || sameCollisionMasksForSprites;
setSameCollisionMasksForAnimations(
newSameCollisionMasksForAnimationsValue
);
if (!answer) return;
}
const newSameCollisionMasksForAnimationsValue = enable;
const newSameCollisionMasksForSpritesValue =
enable || sameCollisionMasksForSprites;
setSameCollisionMasksForAnimations(newSameCollisionMasksForAnimationsValue);
setSameCollisionMasksForSprites(newSameCollisionMasksForSpritesValue);
updateCollisionMasks(
newSameCollisionMasksForAnimationsValue,
newSameCollisionMasksForSpritesValue
);
};
const setSameCollisionMasksForAllSprites = (enable: boolean) => {
if (enable) {
const answer = Window.showConfirmDialog(
"Having the same collision masks for all animations will erase and reset all the other animations collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the animations of the object?"
setSameCollisionMasksForSprites(newSameCollisionMasksForSpritesValue);
updateCollisionMasks(
newSameCollisionMasksForAnimationsValue,
newSameCollisionMasksForSpritesValue
);
if (!answer) return;
}
},
[sameCollisionMasksForSprites, updateCollisionMasks, showConfirmation]
);
const newSameCollisionMasksForAnimationsValue =
enable && sameCollisionMasksForAnimations;
const newSameCollisionMasksForSpritesValue = enable;
const setSameCollisionMasksForAllSprites = React.useCallback(
async (enable: boolean) => {
if (enable) {
const answer = await showConfirmation({
title: t`Use same collision mask for all frames?`,
message: t`
Having the same collision masks for all frames will erase and reset all the other frames collision masks. This can't be undone. Are you sure you want to share these collision masks amongst all the frames of the animation?`,
confirmButtonLabel: t`Use same collision mask`,
dismissButtonLabel: t`Cancel`,
});
if (!answer) return;
}
setSameCollisionMasksForAnimations(newSameCollisionMasksForAnimationsValue);
setSameCollisionMasksForSprites(newSameCollisionMasksForSpritesValue);
updateCollisionMasks(
newSameCollisionMasksForAnimationsValue,
newSameCollisionMasksForSpritesValue
);
};
const newSameCollisionMasksForAnimationsValue =
enable && sameCollisionMasksForAnimations;
const newSameCollisionMasksForSpritesValue = enable;
setSameCollisionMasksForAnimations(
newSameCollisionMasksForAnimationsValue
);
setSameCollisionMasksForSprites(newSameCollisionMasksForSpritesValue);
updateCollisionMasks(
newSameCollisionMasksForAnimationsValue,
newSameCollisionMasksForSpritesValue
);
},
[sameCollisionMasksForAnimations, updateCollisionMasks, showConfirmation]
);
const setCurrentSpriteSize = (spriteWidth: number, spriteHeight: number) => {
setSpriteWidth(spriteWidth);
setSpriteHeight(spriteHeight);
};
const onSetAutomaticallyAdaptCollisionMasks = React.useCallback(
async value => {
// If enabling automatic while custom was selected, then ask for confirmation.
if (value && sprite && !sprite.isFullImageCollisionMask()) {
const answer = await showConfirmation({
title: t`Adapt collision mask?`,
message: t`
You will lose all custom collision masks. Do you want to continue?`,
confirmButtonLabel: t`Adapt automatically`,
dismissButtonLabel: t`Cancel`,
});
if (!answer) return;
}
spriteConfiguration.setAdaptCollisionMaskAutomatically(value);
// Recompute collision mask when enabling automatic, and enable same masks for all animations & sprites.
if (value) {
onCreateMatchingSpriteCollisionMask();
setSameCollisionMasksForAnimations(true);
setSameCollisionMasksForSprites(true);
}
forceUpdate();
},
[
spriteConfiguration,
forceUpdate,
onCreateMatchingSpriteCollisionMask,
showConfirmation,
sprite,
]
);
const onUseCustomCollisionMask = React.useCallback(
() => {
onSetFullImageCollisionMask(false);
onSetAutomaticallyAdaptCollisionMasks(false);
},
[onSetFullImageCollisionMask, onSetAutomaticallyAdaptCollisionMasks]
);
const onPolygonsUpdated = React.useCallback(
() => {
// Revert to non-automatic collision mask.
spriteConfiguration.setAdaptCollisionMaskAutomatically(false);
updateCollisionMasks(
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites
);
},
[
spriteConfiguration,
updateCollisionMasks,
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites,
]
);
// Keep panes vertical for small screens, side-by-side for large screens
const screenSize = useResponsiveWindowWidth();
const editorNodes =
@@ -257,14 +341,10 @@ const CollisionMasksEditor = ({
sprite && (
<CollisionMasksPreview
{...overlayProps}
isDefaultBoundingBox={sprite.isCollisionMaskAutomatic()}
isDefaultBoundingBox={sprite.isFullImageCollisionMask()}
hideControls={spriteConfiguration.adaptCollisionMaskAutomatically()}
polygons={sprite.getCustomCollisionMask()}
onPolygonsUpdated={() =>
updateCollisionMasks(
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites
)
}
onPolygonsUpdated={onPolygonsUpdated}
highlightedVerticePtr={highlightedVerticePtr}
selectedVerticePtr={selectedVerticePtr}
onClickVertice={setSelectedVerticePtr}
@@ -311,40 +391,59 @@ const CollisionMasksEditor = ({
</Column>
</Line>
<ScrollView>
{!!sprite && !sprite.isCollisionMaskAutomatic() && (
<React.Fragment>
<PolygonsList
polygons={sprite.getCustomCollisionMask()}
onPolygonsUpdated={() =>
updateCollisionMasks(
sameCollisionMasksForAnimations,
sameCollisionMasksForSprites
)
}
restoreCollisionMask={() =>
onSetCollisionMaskAutomatic(true)
}
onHoverVertice={setHighlightedVerticePtr}
onClickVertice={setSelectedVerticePtr}
selectedVerticePtr={selectedVerticePtr}
spriteWidth={spriteWidth}
spriteHeight={spriteHeight}
/>
</React.Fragment>
)}
{!!sprite && sprite.isCollisionMaskAutomatic() && (
{!!sprite &&
!sprite.isFullImageCollisionMask() &&
!spriteConfiguration.adaptCollisionMaskAutomatically() && (
<React.Fragment>
<PolygonsList
polygons={sprite.getCustomCollisionMask()}
onPolygonsUpdated={onPolygonsUpdated}
onSetFullImageCollisionMask={() =>
onSetFullImageCollisionMask(true)
}
onSetAutomaticallyAdaptCollisionMasks={() =>
onSetAutomaticallyAdaptCollisionMasks(true)
}
onHoverVertice={setHighlightedVerticePtr}
onClickVertice={setSelectedVerticePtr}
selectedVerticePtr={selectedVerticePtr}
spriteWidth={spriteWidth}
spriteHeight={spriteHeight}
/>
</React.Fragment>
)}
{!!sprite &&
!sprite.isFullImageCollisionMask() &&
spriteConfiguration.adaptCollisionMaskAutomatically() && (
<React.Fragment>
<EmptyMessage>
<Trans>
Automatic collision mask activated. Click on the button
to replace it with a custom one.
</Trans>
</EmptyMessage>
<Line justifyContent="center">
<FlatButton
label={<Trans>Use a custom collision mask</Trans>}
primary={false}
onClick={onUseCustomCollisionMask}
/>
</Line>
</React.Fragment>
)}
{!!sprite && sprite.isFullImageCollisionMask() && (
<React.Fragment>
<EmptyMessage>
<Trans>
This sprite uses the default collision mask, a rectangle
that is as large as the sprite.
This sprite uses a rectangle that is as large as the
sprite for its collision mask.
</Trans>
</EmptyMessage>
<Line justifyContent="center">
<FlatButton
label={<Trans>Use a custom collision mask</Trans>}
primary={false}
onClick={() => onSetCollisionMaskAutomatic(false)}
onClick={onUseCustomCollisionMask}
/>
</Line>
</React.Fragment>
@@ -20,6 +20,7 @@ import Text from '../../../UI/Text';
import Edit from '../../../UI/CustomSvgIcons/Edit';
import Play from '../../../UI/CustomSvgIcons/Play';
import { toFixedWithoutTrailingZeros } from '../../../Utils/Mathematics';
import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext';
const styles = {
container: {
@@ -67,6 +68,8 @@ const DirectionTools = ({
direction.getTimeBetweenFrames(),
6
);
const hasNoSprites = direction.getSpritesCount() === 0;
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const saveTimeBetweenFrames = newTimeBetweenFramesString => {
if (!newTimeBetweenFramesString) return;
@@ -134,11 +137,19 @@ const DirectionTools = ({
label={<Trans>Preview</Trans>}
icon={<Play />}
onClick={() => openPreview(true)}
disabled={hasNoSprites}
/>
</LineStackLayout>
<LineStackLayout noMargin alignItems="center">
<Tooltip title={<Trans>Time between frames</Trans>}>
<Timer style={styles.timeIcon} />
<Timer
style={{
...styles.timeIcon,
color: hasNoSprites
? gdevelopTheme.text.color.disabled
: gdevelopTheme.text.color.primary,
}}
/>
</Tooltip>
<SemiControlledTextField
id="direction-time-between-frames"
@@ -152,15 +163,23 @@ const DirectionTools = ({
commitOnBlur
value={timeBetweenFramesFormatted}
onChange={saveTimeBetweenFrames}
disabled={hasNoSprites}
/>
<InlineCheckbox
checked={direction.isLooping()}
label={
<Text size="body-small">
<Text
size="body-small"
color="primary"
style={{
opacity: hasNoSprites ? 0.5 : 1,
}}
>
<Trans>Loop</Trans>
</Text>
}
onCheck={(e, check) => setLooping(check)}
disabled={hasNoSprites}
/>
</LineStackLayout>
</ResponsiveLineStackLayout>
@@ -1,5 +1,5 @@
// @flow
import { Trans } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import React from 'react';
import EmptyMessage from '../../../../UI/EmptyMessage';
import { Line, Column } from '../../../../UI/Grid';
@@ -15,7 +15,6 @@ import {
copyAnimationsSpritePoints,
} from '../Utils/SpriteObjectHelper';
import SpriteSelector from '../Utils/SpriteSelector';
import Window from '../../../../Utils/Window';
import every from 'lodash/every';
import ResourcesLoader from '../../../../ResourcesLoader';
import useForceUpdate from '../../../../Utils/UseForceUpdate';
@@ -26,6 +25,7 @@ import EditorMosaic, {
import { useResponsiveWindowWidth } from '../../../../UI/Reponsive/ResponsiveWindowMeasurer';
import ScrollView from '../../../../UI/ScrollView';
import Paper from '../../../../UI/Paper';
import useAlertDialog from '../../../../UI/Alert/useAlertDialog';
const gd: libGDevelop = global.gd;
const styles = {
@@ -80,6 +80,7 @@ const PointsEditor = ({
] = React.useState<?string>(null);
const forceUpdate = useForceUpdate();
const { showConfirmation } = useAlertDialog();
const spriteConfiguration = gd.asSpriteConfiguration(objectConfiguration);
const { animation, sprite } = getCurrentElements(
@@ -153,37 +154,50 @@ const PointsEditor = ({
[animation, sprite]
);
const setSamePointsForAllAnimations = (enable: boolean) => {
if (enable) {
const answer = Window.showConfirmDialog(
"Having the same points for all animations will erase and reset all the other animations points. This can't be undone. Are you sure you want to share these points amongst all the animations of the object?"
);
if (!answer) return;
}
const setSamePointsForAllAnimations = React.useCallback(
async (enable: boolean) => {
if (enable) {
const answer = await showConfirmation({
title: t`Use same points for all animations?`,
message: t`
Having the same points for all animations will erase and reset all the other animations points. This can't be undone. Are you sure you want to share these points amongst all the animations of the object?`,
confirmButtonLabel: t`Use same points`,
dismissButtonLabel: t`Cancel`,
});
if (!answer) return;
}
const newSamePointsForAnimations = enable;
const newSamePointsForSprites = enable || samePointsForSprites;
const newSamePointsForAnimations = enable;
const newSamePointsForSprites = enable || samePointsForSprites;
setSamePointsForAnimations(newSamePointsForAnimations);
setSamePointsForSprites(newSamePointsForSprites);
updatePoints(newSamePointsForAnimations, newSamePointsForSprites);
};
setSamePointsForAnimations(newSamePointsForAnimations);
setSamePointsForSprites(newSamePointsForSprites);
updatePoints(newSamePointsForAnimations, newSamePointsForSprites);
},
[samePointsForSprites, updatePoints, showConfirmation]
);
const setSamePointsForAllSprites = (enable: boolean) => {
if (enable) {
const answer = Window.showConfirmDialog(
"Having the same points for all frames will erase and reset all the other frames points. This can't be undone. Are you sure you want to share these points amongst all the frames of the animation?"
);
if (!answer) return;
}
const setSamePointsForAllSprites = React.useCallback(
async (enable: boolean) => {
if (enable) {
const answer = await showConfirmation({
title: t`Use same points for all frames?`,
message: t`Having the same points for all frames will erase and reset all the other frames points. This can't be undone. Are you sure you want to share these points amongst all the frames of the animation?`,
confirmButtonLabel: t`Use same points`,
dismissButtonLabel: t`Cancel`,
});
if (!answer) return;
}
const newSamePointsForAnimations = enable && samePointsForAnimations;
const newSamePointsForSprites = enable;
const newSamePointsForAnimations = enable && samePointsForAnimations;
const newSamePointsForSprites = enable;
setSamePointsForAnimations(newSamePointsForAnimations);
setSamePointsForSprites(newSamePointsForSprites);
updatePoints(newSamePointsForAnimations, newSamePointsForSprites);
};
setSamePointsForAnimations(newSamePointsForAnimations);
setSamePointsForSprites(newSamePointsForSprites);
updatePoints(newSamePointsForAnimations, newSamePointsForSprites);
},
[samePointsForAnimations, updatePoints, showConfirmation]
);
// Keep panes vertical for small screens, side-by-side for large screens
const screenSize = useResponsiveWindowWidth();
@@ -112,6 +112,7 @@ const SortableList = SortableContainer(
) : (
// If there is only one sprite, don't make it draggable.
<ImageThumbnail
key={sprite.ptr}
selectable
key={sprite.ptr}
selected={!!selectedSprites[sprite.ptr]}
@@ -173,6 +174,7 @@ type Props = {|
resourceManagementProps: ResourceManagementProps,
onReplaceByDirection: (newDirection: gdDirection) => void,
onSpriteUpdated?: () => void,
onFirstSpriteUpdated?: () => void,
onChangeName: (newAnimationName: string) => void, // Used by piskel to set the name, if there is no name
objectName: string, // This is used for the default name of images created with Piskel.
animationName: string, // This is used for the default name of images created with Piskel.
@@ -186,6 +188,7 @@ const SpritesList = ({
resourceManagementProps,
onReplaceByDirection,
onSpriteUpdated,
onFirstSpriteUpdated,
onChangeName,
objectName,
animationName,
@@ -269,11 +272,17 @@ const SpritesList = ({
forceUpdate();
onSpriteUpdated && onSpriteUpdated();
if (oldIndex === 0 || newIndex === 0) {
// If a sprite was moved from or to the first position,
// then the first sprite has changed.
onFirstSpriteUpdated && onFirstSpriteUpdated();
}
},
[
direction,
forceUpdate,
onSpriteUpdated,
onFirstSpriteUpdated,
updateSelectionIndexesAfterMoveDown,
updateSelectionIndexesAfterMoveUp,
]
@@ -317,8 +326,19 @@ const SpritesList = ({
await resourceManagementProps.onFetchNewlyAddedResources();
if (resources.length && onSpriteUpdated) onSpriteUpdated();
if (direction.getSpritesCount() === 1 && onFirstSpriteUpdated) {
// If the sprites count is 1, we can assume the first sprite was added.
onFirstSpriteUpdated();
}
},
[direction, project, resourceManagementProps, forceUpdate, onSpriteUpdated]
[
direction,
project,
resourceManagementProps,
forceUpdate,
onSpriteUpdated,
onFirstSpriteUpdated,
]
);
const editWith = React.useCallback(
@@ -405,6 +425,8 @@ const SpritesList = ({
newDirection.delete();
if (onSpriteUpdated) onSpriteUpdated();
// If an external editor is used to edit the sprites, we assume the first sprite was edited.
if (onFirstSpriteUpdated) onFirstSpriteUpdated();
} catch (error) {
setExternalEditorOpened(false);
console.error(
@@ -424,6 +446,7 @@ const SpritesList = ({
objectName,
onReplaceByDirection,
onSpriteUpdated,
onFirstSpriteUpdated,
project,
resourceManagementProps,
resourcesLoader,
@@ -434,6 +457,11 @@ const SpritesList = ({
const deleteSprites = React.useCallback(
() => {
const sprites = selectedSprites.current;
const firstSpritePtr = spriteConfiguration
.getAnimation(0)
.getDirection(0)
.getSprite(0).ptr;
const isFirstSpriteDeleted = !!sprites[firstSpritePtr];
mapFor(0, spriteConfiguration.getAnimationsCount(), index => {
const animation = spriteConfiguration.getAnimation(index);
deleteSpritesFromAnimation(animation, sprites);
@@ -443,8 +471,9 @@ const SpritesList = ({
selectedSprites.current = {};
forceUpdate();
if (onSpriteUpdated) onSpriteUpdated();
if (isFirstSpriteDeleted && onFirstSpriteUpdated) onFirstSpriteUpdated();
},
[onSpriteUpdated, spriteConfiguration, forceUpdate]
[onSpriteUpdated, onFirstSpriteUpdated, spriteConfiguration, forceUpdate]
);
const duplicateSprites = React.useCallback(
@@ -463,7 +492,7 @@ const SpritesList = ({
[onSpriteUpdated, spriteConfiguration, forceUpdate]
);
const selectSprite = React.useCallback(
const addSpriteToSelection = React.useCallback(
(sprite, selected) => {
selectedSprites.current = {
...selectedSprites.current,
@@ -529,7 +558,7 @@ const SpritesList = ({
onAddSprite={onAddSprite}
resourceManagementProps={resourceManagementProps}
selectedSprites={selectedSprites.current}
onSelectSprite={selectSprite}
onSelectSprite={addSpriteToSelection}
onOpenSpriteContextMenu={openSpriteContextMenu}
helperClass="sortable-helper"
lockAxis="x"
@@ -186,8 +186,8 @@ export const copySpritePolygons = (
) => {
if (originalSprite.ptr === destinationSprite.ptr) return;
destinationSprite.setCollisionMaskAutomatic(
originalSprite.isCollisionMaskAutomatic()
destinationSprite.setFullImageCollisionMask(
originalSprite.isFullImageCollisionMask()
);
destinationSprite.getCustomCollisionMask().clear();
@@ -230,10 +230,10 @@ export const haveSameCollisionMasks = (
sprite1: gdSprite,
sprite2: gdSprite
) => {
if (sprite1.isCollisionMaskAutomatic() !== sprite2.isCollisionMaskAutomatic())
if (sprite1.isFullImageCollisionMask() !== sprite2.isFullImageCollisionMask())
return false;
if (sprite1.isCollisionMaskAutomatic() && sprite2.isCollisionMaskAutomatic())
if (sprite1.isFullImageCollisionMask() && sprite2.isFullImageCollisionMask())
return true;
const sprite1CollisionMask = sprite1.getCustomCollisionMask();
@@ -136,16 +136,20 @@ describe('SpriteObjectHelper', () => {
vertice.delete();
};
// Empty sprites have the same collision masks.
const sprite1 = new gd.Sprite();
const sprite2 = new gd.Sprite();
expect(haveSameCollisionMasks(sprite1, sprite2)).toBe(true);
expect(haveSameCollisionMasks(sprite2, sprite1)).toBe(true);
sprite1.setCollisionMaskAutomatic(false);
// A sprite with a full image collision mask is different from a sprite without.
sprite1.setFullImageCollisionMask(true);
expect(haveSameCollisionMasks(sprite1, sprite2)).toBe(false);
expect(haveSameCollisionMasks(sprite2, sprite1)).toBe(false);
sprite1.setFullImageCollisionMask(false);
{
// Adding a polygon to a sprite makes it different from a sprite without.
const polygon1 = new gd.Polygon2d();
addVertice(polygon1, 0, 0);
addVertice(polygon1, 0, 10);
@@ -157,7 +161,7 @@ describe('SpriteObjectHelper', () => {
}
{
sprite2.setCollisionMaskAutomatic(false);
// Adding the same polygon to the other sprite makes them the same.
const polygon2 = new gd.Polygon2d();
addVertice(polygon2, 0, 0);
addVertice(polygon2, 0, 10);
@@ -167,6 +171,7 @@ describe('SpriteObjectHelper', () => {
expect(haveSameCollisionMasks(sprite1, sprite2)).toBe(true);
expect(haveSameCollisionMasks(sprite2, sprite1)).toBe(true);
// Moving a vertice of the polygon makes them different again.
sprite2
.getCustomCollisionMask()
.at(0)
@@ -176,6 +181,7 @@ describe('SpriteObjectHelper', () => {
expect(haveSameCollisionMasks(sprite1, sprite2)).toBe(false);
expect(haveSameCollisionMasks(sprite2, sprite1)).toBe(false);
// Moving the same vertice of the other polygon makes them the same again.
sprite1
.getCustomCollisionMask()
.at(0)
@@ -25,11 +25,11 @@ type Props = {|
sameForAllAnimations: boolean,
sameForAllSprites: boolean,
setSameForAllAnimations: boolean => void,
setSameForAllSprites: boolean => void,
setSameForAllAnimations: boolean => Promise<void>,
setSameForAllSprites: boolean => Promise<void>,
setSameForAllAnimationsLabel: string | React.Node,
setSameForAllSpritesLabel: string | React.Node,
setSameForAllAnimationsLabel: React.Node,
setSameForAllSpritesLabel: React.Node,
|};
/**
@@ -38,99 +38,106 @@ type Props = {|
* (typically, the points or the collision masks of the sprite) should be shared between
* all sprites of an animation, or between all sprites of all animations of the object.
*/
export default class SpriteSelector extends React.Component<Props, void> {
render() {
const {
spriteConfiguration,
animationIndex,
directionIndex,
spriteIndex,
sameForAllAnimations,
sameForAllSprites,
chooseAnimation,
chooseDirection,
chooseSprite,
setSameForAllAnimations,
setSameForAllSprites,
setSameForAllAnimationsLabel,
setSameForAllSpritesLabel,
} = this.props;
const SpriteSelector = ({
spriteConfiguration,
animationIndex,
directionIndex,
spriteIndex,
sameForAllAnimations,
sameForAllSprites,
chooseAnimation,
chooseDirection,
chooseSprite,
setSameForAllAnimations,
setSameForAllSprites,
setSameForAllAnimationsLabel,
setSameForAllSpritesLabel,
}: Props) => {
const { animation, direction, sprite } = getCurrentElements(
spriteConfiguration,
animationIndex,
directionIndex,
spriteIndex
);
const { animation, direction } = getCurrentElements(
spriteConfiguration,
animationIndex,
directionIndex,
spriteIndex
);
const hideControls =
!direction ||
!direction.getSpritesCount() ||
spriteConfiguration.adaptCollisionMaskAutomatically() ||
!sprite ||
sprite.isFullImageCollisionMask();
return (
<React.Fragment>
<ResponsiveLineStackLayout>
return (
<React.Fragment>
<ResponsiveLineStackLayout>
<SelectField
fullWidth
floatingLabelText={<Trans>Animation</Trans>}
value={animationIndex}
onChange={(e, i, value: string) =>
chooseAnimation(parseInt(value, 10) || 0)
}
>
{mapFor(0, spriteConfiguration.getAnimationsCount(), i => {
const animation = spriteConfiguration.getAnimation(i);
return (
<SelectOption
key={i}
value={i}
label={t`Animation #${i} ${animation.getName()}`}
/>
);
})}
</SelectField>
{animation && animation.getDirectionsCount() > 1 && (
<SelectField
fullWidth
floatingLabelText={<Trans>Animation</Trans>}
value={this.props.animationIndex}
floatingLabelText={<Trans>Direction</Trans>}
value={directionIndex}
onChange={(e, i, value: string) =>
chooseAnimation(parseInt(value, 10) || 0)
chooseDirection(parseInt(value, 10) || 0)
}
>
{mapFor(0, spriteConfiguration.getAnimationsCount(), i => {
const animation = spriteConfiguration.getAnimation(i);
{mapFor(0, animation.getDirectionsCount(), i => {
return (
<SelectOption
key={i}
value={i}
label={t`Animation #${i} ${animation.getName()}`}
/>
<SelectOption value={i} key={i} label={t`Direction #${i}`} />
);
})}
</SelectField>
{animation && animation.getDirectionsCount() > 1 && (
<SelectField
fullWidth
floatingLabelText={<Trans>Direction</Trans>}
value={this.props.directionIndex}
onChange={(e, i, value: string) =>
chooseDirection(parseInt(value, 10) || 0)
}
>
{mapFor(0, animation.getDirectionsCount(), i => {
return (
<SelectOption value={i} key={i} label={t`Direction #${i}`} />
);
})}
</SelectField>
)}
{direction && (
<SelectField
fullWidth
floatingLabelText={<Trans>Frame</Trans>}
value={this.props.spriteIndex}
onChange={(e, i, value: string) =>
chooseSprite(parseInt(value, 10) || 0)
}
>
{mapFor(0, direction.getSpritesCount(), i => {
return (
<SelectOption value={i} key={i} label={t`Frame #${i}`} />
);
})}
</SelectField>
)}
</ResponsiveLineStackLayout>
<Toggle
label={setSameForAllAnimationsLabel}
labelPosition="right"
toggled={sameForAllAnimations}
onToggle={(e, checked) => setSameForAllAnimations(checked)}
/>
<Toggle
label={setSameForAllSpritesLabel}
labelPosition="right"
toggled={sameForAllSprites}
onToggle={(e, checked) => setSameForAllSprites(checked)}
/>
</React.Fragment>
);
}
}
)}
{direction && (
<SelectField
fullWidth
floatingLabelText={<Trans>Frame</Trans>}
value={spriteIndex}
onChange={(e, i, value: string) =>
chooseSprite(parseInt(value, 10) || 0)
}
>
{mapFor(0, direction.getSpritesCount(), i => {
return <SelectOption value={i} key={i} label={t`Frame #${i}`} />;
})}
</SelectField>
)}
</ResponsiveLineStackLayout>
{!hideControls && (
<>
<Toggle
label={setSameForAllAnimationsLabel}
labelPosition="right"
toggled={sameForAllAnimations}
onToggle={(e, checked) => setSameForAllAnimations(checked)}
/>
<Toggle
label={setSameForAllSpritesLabel}
labelPosition="right"
toggled={sameForAllSprites}
onToggle={(e, checked) => setSameForAllSprites(checked)}
/>
</>
)}
</React.Fragment>
);
};
export default SpriteSelector;
@@ -33,6 +33,7 @@ import { DragHandleIcon } from '../../../UI/DragHandle';
import DropIndicator from '../../../UI/SortableVirtualizedItemList/DropIndicator';
import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext';
import useAlertDialog from '../../../UI/Alert/useAlertDialog';
import { getMatchingCollisionMask } from './CollisionMasksEditor/CollisionMaskHelper';
const gd: libGDevelop = global.gd;
@@ -109,7 +110,24 @@ export default function SpriteEditor({
const forceUpdate = useForceUpdate();
const spriteConfiguration = gd.asSpriteConfiguration(objectConfiguration);
const windowWidth = useResponsiveWindowWidth();
const hasNoAnimations = spriteConfiguration.getAnimationsCount() === 0;
const hasNoSprites = () => {
for (
let animationIndex = 0;
animationIndex < spriteConfiguration.getAnimationsCount();
animationIndex++
) {
const animation = spriteConfiguration.getAnimation(animationIndex);
for (
let directionIndex = 0;
directionIndex < animation.getDirectionsCount();
directionIndex++
) {
const direction = animation.getDirection(directionIndex);
if (direction.getSpritesCount() > 0) return false;
}
}
return true;
};
const scrollView = React.useRef<?ScrollViewInterface>(null);
@@ -143,6 +161,59 @@ export default function SpriteEditor({
{}
);
// The matching collision mask only takes the first sprite of the first
// animation of the object. We consider this is enough to start with, and
// the user can then edit the collision mask for further needs.
const onCreateMatchingSpriteCollisionMask = React.useCallback(
async () => {
if (spriteConfiguration.getAnimationsCount() === 0) return;
const firstAnimation = spriteConfiguration.getAnimation(0);
if (firstAnimation.getDirectionsCount() === 0) return;
const firstDirection = firstAnimation.getDirection(0);
if (firstDirection.getSpritesCount() === 0) return;
const firstSprite = firstDirection.getSprite(0);
const firstSpriteResourceName = firstSprite.getImageName();
const firstAnimationResourceSource = ResourcesLoader.getResourceFullUrl(
project,
firstSpriteResourceName,
{}
);
const matchingCollisionMask = await getMatchingCollisionMask(
firstAnimationResourceSource
);
if (!matchingCollisionMask) {
return;
}
for (
let animationIndex = 0;
animationIndex < spriteConfiguration.getAnimationsCount();
animationIndex++
) {
const animation = spriteConfiguration.getAnimation(animationIndex);
for (
let directionIndex = 0;
directionIndex < animation.getDirectionsCount();
directionIndex++
) {
const direction = animation.getDirection(directionIndex);
for (
let spriteIndex = 0;
spriteIndex < direction.getSpritesCount();
spriteIndex++
) {
const sprite = direction.getSprite(spriteIndex);
sprite.setFullImageCollisionMask(false);
sprite.setCustomCollisionMask(matchingCollisionMask);
}
}
}
forceUpdate();
},
[spriteConfiguration, project, forceUpdate]
);
const moveAnimation = React.useCallback(
(targetIndex: number) => {
const draggedIndex = draggedAnimationIndex.current;
@@ -154,9 +225,17 @@ export default function SpriteEditor({
draggedIndex,
targetIndex > draggedIndex ? targetIndex - 1 : targetIndex
);
if (
(draggedIndex === 0 || targetIndex === 0) &&
spriteConfiguration.adaptCollisionMaskAutomatically()
) {
// If the first animation is changed and the collision mask is adapted automatically,
// then we need to recompute it.
onCreateMatchingSpriteCollisionMask();
}
forceUpdate();
},
[spriteConfiguration, forceUpdate]
[spriteConfiguration, forceUpdate, onCreateMatchingSpriteCollisionMask]
);
const addAnimation = React.useCallback(
@@ -199,6 +278,14 @@ export default function SpriteEditor({
spriteConfiguration.removeAnimation(index);
forceUpdate();
onSizeUpdated();
if (
index === 0 &&
spriteConfiguration.adaptCollisionMaskAutomatically()
) {
// If the first animation is removed and the collision mask is
// automatically adapted, then recompute it.
onCreateMatchingSpriteCollisionMask();
}
if (onObjectUpdated) onObjectUpdated();
},
[
@@ -207,6 +294,7 @@ export default function SpriteEditor({
onSizeUpdated,
showConfirmation,
spriteConfiguration,
onCreateMatchingSpriteCollisionMask,
]
);
@@ -281,175 +369,185 @@ export default function SpriteEditor({
<I18n>
{({ i18n }) => (
<>
<ScrollView ref={scrollView}>
<Column noMargin expand useFullHeight>
{spriteConfiguration.getAnimationsCount() === 0 ? (
<Column noMargin expand justifyContent="center">
<EmptyPlaceholder
title={<Trans>Add your first animation</Trans>}
description={
<Trans>Animations are a sequence of images.</Trans>
}
actionLabel={<Trans>Add an animation</Trans>}
helpPagePath="/objects/sprite"
tutorialId="intermediate-changing-animations"
onAction={addAnimation}
/>
</Column>
) : (
<React.Fragment>
<SpacedDismissableTutorialMessage />
{mapFor(
0,
spriteConfiguration.getAnimationsCount(),
animationIndex => {
const animation = spriteConfiguration.getAnimation(
animationIndex
);
const animationName = animation.getName();
const animationRef =
justAddedAnimationName === animationName
? justAddedAnimationElement
: null;
return (
<DragSourceAndDropTarget
key={animationIndex}
beginDrag={() => {
draggedAnimationIndex.current = animationIndex;
return {};
}}
canDrag={() => true}
canDrop={() => true}
drop={() => {
moveAnimation(animationIndex);
}}
>
{({
connectDragSource,
connectDropTarget,
isOver,
canDrop,
}) =>
connectDropTarget(
<div
key={animationIndex}
style={styles.rowContainer}
>
{isAnimationListLocked && (
<Column expand noMargin>
<Text size="block-title">
{animationName}
</Text>
</Column>
)}
{!isAnimationListLocked && isOver && (
<DropIndicator canDrop={canDrop} />
)}
{!isAnimationListLocked && (
<div
ref={animationRef}
style={{
...styles.rowContent,
backgroundColor:
gdevelopTheme.list.itemsBackgroundColor,
}}
>
<Line noMargin expand alignItems="center">
{connectDragSource(
<span>
<Column>
<DragHandleIcon />
</Column>
</span>
)}
<Text noMargin noShrink>
<Trans>
Animation #{animationIndex}
</Trans>
</Text>
<Spacer />
<SemiControlledTextField
margin="none"
commitOnBlur
errorText={nameErrors[animationIndex]}
translatableHintText={t`Optional animation name`}
value={animation.getName()}
onChange={newName =>
changeAnimationName(
animationIndex,
newName
)
}
fullWidth
/>
<IconButton
size="small"
onClick={() =>
removeAnimation(animationIndex, i18n)
}
>
<Trash />
</IconButton>
</Line>
<Spacer />
</div>
)}
<div style={styles.animationLine}>
<Column expand noMargin>
{mapFor(
0,
animation.getDirectionsCount(),
directionIndex => {
const direction = animation.getDirection(
directionIndex
);
return (
<SpritesList
spriteConfiguration={
spriteConfiguration
}
direction={direction}
key={directionIndex}
project={project}
resourcesLoader={ResourcesLoader}
resourceManagementProps={
resourceManagementProps
}
onReplaceByDirection={newDirection =>
replaceDirection(
animationIndex,
directionIndex,
newDirection
)
}
objectName={objectName}
animationName={animationName}
onChangeName={newName =>
changeAnimationName(
animationIndex,
newName
)
}
onSpriteUpdated={onObjectUpdated}
/>
);
}
)}
</Column>
</div>
</div>
)
}
</DragSourceAndDropTarget>
);
}
)}
</React.Fragment>
)}
{spriteConfiguration.getAnimationsCount() === 0 ? (
<Column noMargin expand justifyContent="center">
<EmptyPlaceholder
title={<Trans>Add your first animation</Trans>}
description={
<Trans>Animations are a sequence of images.</Trans>
}
actionLabel={<Trans>Add an animation</Trans>}
helpPagePath="/objects/sprite"
tutorialId="intermediate-changing-animations"
onAction={addAnimation}
/>
</Column>
</ScrollView>
) : (
<ScrollView ref={scrollView}>
<React.Fragment>
<SpacedDismissableTutorialMessage />
{mapFor(
0,
spriteConfiguration.getAnimationsCount(),
animationIndex => {
const animation = spriteConfiguration.getAnimation(
animationIndex
);
const animationName = animation.getName();
const animationRef =
justAddedAnimationName === animationName
? justAddedAnimationElement
: null;
return (
<DragSourceAndDropTarget
key={animationIndex}
beginDrag={() => {
draggedAnimationIndex.current = animationIndex;
return {};
}}
canDrag={() => true}
canDrop={() => true}
drop={() => {
moveAnimation(animationIndex);
}}
>
{({
connectDragSource,
connectDropTarget,
isOver,
canDrop,
}) =>
connectDropTarget(
<div
key={animationIndex}
style={styles.rowContainer}
>
{isAnimationListLocked && (
<Column expand noMargin>
<Text size="block-title">
{animationName}
</Text>
</Column>
)}
{!isAnimationListLocked && isOver && (
<DropIndicator canDrop={canDrop} />
)}
{!isAnimationListLocked && (
<div
ref={animationRef}
style={{
...styles.rowContent,
backgroundColor:
gdevelopTheme.list.itemsBackgroundColor,
}}
>
<Line noMargin expand alignItems="center">
{connectDragSource(
<span>
<Column>
<DragHandleIcon />
</Column>
</span>
)}
<Text noMargin noShrink>
<Trans>Animation #{animationIndex}</Trans>
</Text>
<Spacer />
<SemiControlledTextField
margin="none"
commitOnBlur
errorText={nameErrors[animationIndex]}
translatableHintText={t`Optional animation name`}
value={animation.getName()}
onChange={newName =>
changeAnimationName(
animationIndex,
newName
)
}
fullWidth
/>
<IconButton
size="small"
onClick={() =>
removeAnimation(animationIndex, i18n)
}
>
<Trash />
</IconButton>
</Line>
<Spacer />
</div>
)}
<div style={styles.animationLine}>
<Column expand noMargin>
{mapFor(
0,
animation.getDirectionsCount(),
directionIndex => {
const direction = animation.getDirection(
directionIndex
);
return (
<SpritesList
spriteConfiguration={
spriteConfiguration
}
direction={direction}
key={directionIndex}
project={project}
resourcesLoader={ResourcesLoader}
resourceManagementProps={
resourceManagementProps
}
onReplaceByDirection={newDirection =>
replaceDirection(
animationIndex,
directionIndex,
newDirection
)
}
objectName={objectName}
animationName={animationName}
onChangeName={newName =>
changeAnimationName(
animationIndex,
newName
)
}
onSpriteUpdated={onObjectUpdated}
onFirstSpriteUpdated={
animationIndex === 0
? () => {
// If the first sprite of the first animation is updated,
// we update the automatic collision mask of the object,
// if the option is enabled.
if (
spriteConfiguration.adaptCollisionMaskAutomatically()
) {
onCreateMatchingSpriteCollisionMask();
}
}
: undefined
}
/>
);
}
)}
</Column>
</div>
</div>
)
}
</DragSourceAndDropTarget>
);
}
)}
</React.Fragment>
</ScrollView>
)}
<Column noMargin>
<ResponsiveLineStackLayout
justifyContent="space-between"
@@ -460,20 +558,20 @@ export default function SpriteEditor({
<FlatButton
label={<Trans>Edit collision masks</Trans>}
onClick={() => setCollisionMasksEditorOpen(true)}
disabled={hasNoAnimations}
disabled={hasNoSprites()}
/>
{!isAnimationListLocked && (
<FlatButton
label={<Trans>Edit points</Trans>}
onClick={() => setPointsEditorOpen(true)}
disabled={hasNoAnimations}
disabled={hasNoSprites()}
/>
)}
{!isAnimationListLocked && (
<FlatButton
label={<Trans>Advanced options</Trans>}
onClick={() => setAdvancedOptionsOpen(true)}
disabled={hasNoAnimations}
disabled={hasNoSprites()}
/>
)}
</ResponsiveLineStackLayout>
@@ -481,16 +579,16 @@ export default function SpriteEditor({
<FlatButtonWithSplitMenu
label={<Trans>Edit collision masks</Trans>}
onClick={() => setCollisionMasksEditorOpen(true)}
disabled={hasNoAnimations}
disabled={hasNoSprites()}
buildMenuTemplate={i18n => [
{
label: i18n._(t`Edit points`),
disabled: hasNoAnimations,
disabled: hasNoSprites(),
click: () => setPointsEditorOpen(true),
},
{
label: i18n._(t`Advanced options`),
disabled: hasNoAnimations,
disabled: hasNoSprites(),
click: () => setAdvancedOptionsOpen(true),
},
]}
@@ -612,6 +710,9 @@ export default function SpriteEditor({
resourcesLoader={ResourcesLoader}
project={project}
onMasksUpdated={onObjectUpdated}
onCreateMatchingSpriteCollisionMask={
onCreateMatchingSpriteCollisionMask
}
/>
</Dialog>
)}
@@ -131,9 +131,8 @@ export const NewResourceDialog = ({
]}
secondaryActions={[
importTabAdvancedResourceSources.length > 0 ? (
<Column>
<Column key="show-advanced-toggle">
<Toggle
key="show-advanced-toggle"
onToggle={(e, check) => setIsShowingAdvanced(check)}
toggled={isShowingAdvanced}
labelPosition="right"
+1
View File
@@ -478,6 +478,7 @@ export function createGdevelopTheme({
color: {
primary: styles['ThemeTextDefaultColor'],
secondary: styles['ThemeTextSecondaryColor'],
disabled: styles['ThemeTextDisabledColor'],
},
highlighted: {
backgroundColor: styles['ThemeTextHighlightedBackgroundColor'],
+1 -1
View File
@@ -46,7 +46,7 @@ const useSwitchStyles = ({
type Props = {|
label: React.Node,
toggled: boolean,
onToggle: (e: {||}, toggled: boolean) => void,
onToggle: (e: {||}, toggled: boolean) => void | Promise<void>,
disabled?: boolean,
labelPosition: 'right' | 'left',
@@ -79,6 +79,9 @@ export const CollisionMasks = () => (
objectConfiguration={testProject.spriteObjectConfiguration}
project={testProject.project}
resourcesLoader={ResourcesLoader}
onCreateMatchingSpriteCollisionMask={action(
'Created a matching sprite collision mask'
)}
/>
</FixedHeightFlexContainer>
</DragAndDropContextProvider>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gdevelop",
"version": "5.0.139",
"version": "5.2.166",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "gdevelop",
"version": "5.0.139",
"version": "5.2.166",
"license": "MIT",
"dependencies": {
"@electron/remote": "^2.0.8",