diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp index d57d9ce3696d..32516203182e 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/Direction.cpp @@ -98,6 +98,15 @@ void Direction::UnserializeFrom(const gd::SerializerElement& element) { Sprite sprite; sprite.SetImageName(spriteElement.GetStringAttribute("image")); + + if (spriteElement.HasAttribute("spritesheetResourceName") || + spriteElement.HasChild("spritesheetResourceName")) { + sprite.SetSpritesheetResourceName( + spriteElement.GetStringAttribute("spritesheetResourceName", "")); + sprite.SetSpritesheetFrameName( + spriteElement.GetStringAttribute("spritesheetFrameName", "")); + } + OpenPointsSprites(sprite.GetAllNonDefaultPoints(), spriteElement.GetChild("points", 0, "Points")); @@ -164,6 +173,14 @@ void SaveSpritesDirection(const vector& sprites, gd::SerializerElement& spriteElement = element.AddChild("sprite"); spriteElement.SetAttribute("image", sprites[i].GetImageName()); + + if (sprites[i].UsesSpritesheetFrame()) { + spriteElement.SetAttribute("spritesheetResourceName", + sprites[i].GetSpritesheetResourceName()); + spriteElement.SetAttribute("spritesheetFrameName", + sprites[i].GetSpritesheetFrameName()); + } + SavePointsSprites(sprites[i].GetAllNonDefaultPoints(), spriteElement.AddChild("points")); diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/Sprite.h b/Core/GDCore/Extensions/Builtin/SpriteExtension/Sprite.h index 13291e16238d..11a6fb02c412 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/Sprite.h +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/Sprite.h @@ -16,7 +16,14 @@ namespace gd { /** - * \brief Represents a sprite to be displayed on the screen. + * \brief Represents a sprite (also called "frame" in this context) to be displayed on the screen. + * + * A sprite can display either: + * - A standalone image (using `image` field) + * - A frame from a spritesheet (using `spritesheetResourceName` and `spritesheetFrameName`) + * + * When using a spritesheet, the `image` field should be empty, and the texture + * will be read from the spritesheet's frame data. * * \see Direction * \see SpriteObject @@ -29,6 +36,7 @@ class GD_CORE_API Sprite { /** * \brief Change the name of the sprite image. + * \note When using a spritesheet, this should be empty. */ inline void SetImageName(const gd::String& image_) { image = image_; } @@ -42,6 +50,46 @@ class GD_CORE_API Sprite { */ inline gd::String& GetImageName() { return image; } + /** + * \brief Set the spritesheet resource name for this frame. + * \note When set, the frame texture will be read from the spritesheet. + */ + inline void SetSpritesheetResourceName(const gd::String& resourceName) { + spritesheetResourceName = resourceName; + } + + /** + * \brief Get the spritesheet resource name for this frame. + * \return The spritesheet resource name, or empty string if not using a spritesheet. + */ + inline const gd::String& GetSpritesheetResourceName() const { + return spritesheetResourceName; + } + + /** + * \brief Set the frame name within the spritesheet. + * \note This corresponds to a key in the "frames" object of the spritesheet JSON. + */ + inline void SetSpritesheetFrameName(const gd::String& frameName) { + spritesheetFrameName = frameName; + } + + /** + * \brief Get the frame name within the spritesheet. + * \return The frame name within the spritesheet, or empty string if not using a spritesheet. + */ + inline const gd::String& GetSpritesheetFrameName() const { + return spritesheetFrameName; + } + + /** + * \brief Check if this sprite uses a spritesheet frame. + * \return true if using a spritesheet frame, false if using a standalone image. + */ + inline bool UsesSpritesheetFrame() const { + return !spritesheetResourceName.empty() && !spritesheetFrameName.empty(); + } + /** * \brief Get the collision mask (custom or automatically generated owing to * IsFullImageCollisionMask()) @@ -162,6 +210,9 @@ class GD_CORE_API Sprite { private: gd::String image; ///< Name of the image to be loaded in Image Manager. + gd::String spritesheetResourceName; ///< Name of the spritesheet resource (optional). + gd::String spritesheetFrameName; ///< Frame name within the spritesheet (key in "frames" object inside the spritesheet JSON). + bool fullImageCollisionMask; ///< True to use a bounding box wrapping the ///< whole image as collision mask. If false, ///< custom collision mask is used. diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.cpp b/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.cpp index e9db2bfd9ee2..4b80b00b0441 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.cpp +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.cpp @@ -109,8 +109,25 @@ void SpriteAnimationList::ExposeResources(gd::ArbitraryResourceWorker& worker) { for (std::size_t l = 0; l < GetAnimation(j).GetDirection(k).GetSpritesCount(); l++) { - worker.ExposeImage( - GetAnimation(j).GetDirection(k).GetSprite(l).GetImageName()); + Sprite& sprite = GetAnimation(j).GetDirection(k).GetSprite(l); + + // Expose either the image or the spritesheet resource + if (sprite.UsesSpritesheetFrame()) { + // Expose the spritesheet resource (and its embedded image) + gd::String spritesheetResourceName = sprite.GetSpritesheetResourceName(); + worker.ExposeSpritesheet(spritesheetResourceName); + + if (spritesheetResourceName != sprite.GetSpritesheetResourceName()) { + // Update the resource name if it was changed by the worker + sprite.SetSpritesheetResourceName(spritesheetResourceName); + } + + // The spritesheet has a reference to an image (often with the same name + // as the resource name, but the mapping can vary) which should also be exposed. + worker.ExposeEmbeddeds(spritesheetResourceName); + } else { + worker.ExposeImage(sprite.GetImageName()); + } } } } diff --git a/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.h b/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.h index 377aab0a1281..4964f37d7bf4 100644 --- a/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.h +++ b/Core/GDCore/Extensions/Builtin/SpriteExtension/SpriteAnimationList.h @@ -19,8 +19,10 @@ namespace gd { /** * \brief A list of animations, containing directions with images and collision mask. - * + * * It's used in the configuration of object that implements image-based animations. + * Each image is called a "Sprite" (not to be confused with the "Sprite" object), + * and can be either a standalone image or a frame from a spritesheet. * * \see Animation * \see Direction diff --git a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp index 0b7e8490b9a6..05d897ed1257 100644 --- a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp +++ b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp @@ -62,6 +62,11 @@ void ArbitraryResourceWorker::ExposeSpine(gd::String& resourceName){ // do. }; +void ArbitraryResourceWorker::ExposeSpritesheet(gd::String& resourceName){ + // Nothing to do by default - each child class can define here the action to + // do. +}; + void ArbitraryResourceWorker::ExposeJavaScript(gd::String& resourceName){ // Nothing to do by default - each child class can define here the action to // do. @@ -129,7 +134,6 @@ void ArbitraryResourceWorker::ExposeEmbeddeds(gd::String& resourceName) { child.second->GetValue().GetString(); if (resourcesManager->HasResource(targetResourceName)) { - std::cout << targetResourceName << std::endl; gd::Resource& targetResource = resourcesManager->GetResource(targetResourceName); @@ -198,6 +202,12 @@ void ArbitraryResourceWorker::ExposeResourceWithType( } if (resourceType == "spine") { ExposeSpine(resourceName); + ExposeEmbeddeds(resourceName); + return; + } + if (resourceType == "spritesheet") { + ExposeSpritesheet(resourceName); + ExposeEmbeddeds(resourceName); return; } if (resourceType == "javascript") { @@ -277,6 +287,11 @@ bool ResourceWorkerInEventsWorker::DoVisitInstruction(gd::Instruction& instructi gd::String updatedParameterValue = parameterValue; worker.ExposeSpine(updatedParameterValue); instruction.SetParameter(parameterIndex, updatedParameterValue); + } else if (parameterMetadata.GetType() == "spritesheetResource") { + gd::String updatedParameterValue = parameterValue; + worker.ExposeSpritesheet(updatedParameterValue); + worker.ExposeEmbeddeds(updatedParameterValue); + instruction.SetParameter(parameterIndex, updatedParameterValue); } }); diff --git a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h index e037fc88777e..7efe64ba6062 100644 --- a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h +++ b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h @@ -107,6 +107,11 @@ class GD_CORE_API ArbitraryResourceWorker { */ virtual void ExposeSpine(gd::String &resourceName); + /** + * \brief Expose a spritesheet, which is always a reference to a "spritesheet" resource. + */ + virtual void ExposeSpritesheet(gd::String &resourceName); + /** * \brief Expose a video, which is always a reference to a "video" resource. */ diff --git a/Core/GDCore/IDE/Project/ObjectsUsingResourceCollector.h b/Core/GDCore/IDE/Project/ObjectsUsingResourceCollector.h index 6fa376df0332..1199cbbdcc82 100644 --- a/Core/GDCore/IDE/Project/ObjectsUsingResourceCollector.h +++ b/Core/GDCore/IDE/Project/ObjectsUsingResourceCollector.h @@ -88,6 +88,9 @@ class GD_CORE_API ResourceNameMatcher : public ArbitraryResourceWorker { virtual void ExposeSpine(gd::String& otherResourceName) override { MatchResourceName(otherResourceName); }; + virtual void ExposeSpritesheet(gd::String& otherResourceName) override { + MatchResourceName(otherResourceName); + }; void MatchResourceName(gd::String& otherResourceName) { if (otherResourceName == resourceName) matchesResourceName = true; diff --git a/Core/GDCore/IDE/Project/ResourcesInUseHelper.h b/Core/GDCore/IDE/Project/ResourcesInUseHelper.h index 9ceb0990072e..8e3ad1aaa3f5 100644 --- a/Core/GDCore/IDE/Project/ResourcesInUseHelper.h +++ b/Core/GDCore/IDE/Project/ResourcesInUseHelper.h @@ -48,6 +48,7 @@ class ResourcesInUseHelper : public gd::ArbitraryResourceWorker { std::set& GetAll3DModels() { return GetAll("model3D"); }; std::set& GetAllAtlases() { return GetAll("atlas"); }; std::set& GetAllSpines() { return GetAll("spine"); }; + std::set& GetAllSpritesheets() { return GetAll("spritesheet"); }; std::set& GetAll(const gd::String& resourceType) { if (resourceType == "image") return allImages; if (resourceType == "audio") return allAudios; @@ -61,6 +62,7 @@ class ResourcesInUseHelper : public gd::ArbitraryResourceWorker { if (resourceType == "atlas") return allAtlases; if (resourceType == "spine") return allSpines; if (resourceType == "javascript") return allJavaScripts; + if (resourceType == "spritesheet") return allSpritesheets; return emptyResources; }; @@ -104,6 +106,9 @@ class ResourcesInUseHelper : public gd::ArbitraryResourceWorker { virtual void ExposeSpine(gd::String& resourceName) override { allSpines.insert(resourceName); }; + virtual void ExposeSpritesheet(gd::String& resourceName) override { + allSpritesheets.insert(resourceName); + }; protected: std::vector allResources; @@ -119,6 +124,7 @@ class ResourcesInUseHelper : public gd::ArbitraryResourceWorker { std::set allAtlases; std::set allSpines; std::set allJavaScripts; + std::set allSpritesheets; std::set emptyResources; static const std::vector resourceTypes; diff --git a/Core/GDCore/IDE/Project/ResourcesRenamer.h b/Core/GDCore/IDE/Project/ResourcesRenamer.h index 996a1f22d26b..ad3f0b369642 100644 --- a/Core/GDCore/IDE/Project/ResourcesRenamer.h +++ b/Core/GDCore/IDE/Project/ResourcesRenamer.h @@ -74,6 +74,9 @@ class ResourcesRenamer : public gd::ArbitraryResourceWorker { virtual void ExposeSpine(gd::String& resourceName) override { RenameIfNeeded(resourceName); }; + virtual void ExposeSpritesheet(gd::String& resourceName) override { + RenameIfNeeded(resourceName); + }; private: void RenameIfNeeded(gd::String& resourceName) { diff --git a/Core/GDCore/IDE/Project/SceneResourcesFinder.h b/Core/GDCore/IDE/Project/SceneResourcesFinder.h index ea3db951208b..41ccfb1f7723 100644 --- a/Core/GDCore/IDE/Project/SceneResourcesFinder.h +++ b/Core/GDCore/IDE/Project/SceneResourcesFinder.h @@ -97,6 +97,9 @@ class SceneResourcesFinder : private gd::ArbitraryResourceWorker { void ExposeSpine(gd::String &resourceName) override { AddUsedResource(resourceName); }; + void ExposeSpritesheet(gd::String &resourceName) override { + AddUsedResource(resourceName); + }; std::set resourceNames; }; diff --git a/Core/GDCore/Project/BehaviorConfigurationContainer.cpp b/Core/GDCore/Project/BehaviorConfigurationContainer.cpp index 9dea002cc620..6786374f5596 100644 --- a/Core/GDCore/Project/BehaviorConfigurationContainer.cpp +++ b/Core/GDCore/Project/BehaviorConfigurationContainer.cpp @@ -58,6 +58,8 @@ void BehaviorConfigurationContainer::ExposeResources(gd::ArbitraryResourceWorker worker.ExposeAtlas(newPropertyValue); } else if (resourceType == "spine") { worker.ExposeSpine(newPropertyValue); + } else if (resourceType == "spritesheet") { + worker.ExposeSpritesheet(newPropertyValue); } if (newPropertyValue != oldPropertyValue) { diff --git a/Core/GDCore/Project/CustomObjectConfiguration.cpp b/Core/GDCore/Project/CustomObjectConfiguration.cpp index 85e04ab70de2..5ae5c32856fb 100644 --- a/Core/GDCore/Project/CustomObjectConfiguration.cpp +++ b/Core/GDCore/Project/CustomObjectConfiguration.cpp @@ -236,6 +236,8 @@ void CustomObjectConfiguration::ExposeResources(gd::ArbitraryResourceWorker& wor worker.ExposeAtlas(newPropertyValue); } else if (resourceType == "spine") { worker.ExposeSpine(newPropertyValue); + } else if (resourceType == "spritesheet") { + worker.ExposeSpritesheet(newPropertyValue); } if (newPropertyValue != oldPropertyValue) { diff --git a/Core/GDCore/Project/ResourcesContainer.cpp b/Core/GDCore/Project/ResourcesContainer.cpp index 76ab9ed60fb6..b09f54735753 100644 --- a/Core/GDCore/Project/ResourcesContainer.cpp +++ b/Core/GDCore/Project/ResourcesContainer.cpp @@ -130,6 +130,8 @@ ResourcesContainer::CreateResource(const gd::String &kind) { return std::make_shared(); else if (kind == "spine") return std::make_shared(); + else if (kind == "spritesheet") + return std::make_shared(); else if (kind == "javascript") return std::make_shared(); else if (kind == "internal-in-game-editor-only-svg") @@ -150,6 +152,7 @@ const gd::String Resource::bitmapType = "bitmapFont"; const gd::String Resource::model3DType = "model3D"; const gd::String Resource::atlasType = "atlas"; const gd::String Resource::spineType = "spine"; +const gd::String Resource::spritesheetType = "spritesheet"; const gd::String Resource::javaScriptType = "javascript"; const gd::String Resource::internalInGameEditorOnlySvgType = "internal-in-game-editor-only-svg"; @@ -616,6 +619,20 @@ void AtlasResource::SerializeTo(SerializerElement &element) const { element.SetAttribute("file", GetFile()); } +void SpritesheetResource::SetFile(const gd::String &newFile) { + file = NormalizePathSeparator(newFile); +} + +void SpritesheetResource::UnserializeFrom(const SerializerElement &element) { + SetUserAdded(element.GetBoolAttribute("userAdded")); + SetFile(element.GetStringAttribute("file")); +} + +void SpritesheetResource::SerializeTo(SerializerElement &element) const { + element.SetAttribute("userAdded", IsUserAdded()); + element.SetAttribute("file", GetFile()); +} + void JavaScriptResource::SetFile(const gd::String &newFile) { file = NormalizePathSeparator(newFile); } diff --git a/Core/GDCore/Project/ResourcesContainer.h b/Core/GDCore/Project/ResourcesContainer.h index 4efce89ee6cf..9e4af3bfd867 100644 --- a/Core/GDCore/Project/ResourcesContainer.h +++ b/Core/GDCore/Project/ResourcesContainer.h @@ -37,6 +37,7 @@ class GD_CORE_API Resource { static const gd::String model3DType; static const gd::String atlasType; static const gd::String spineType; + static const gd::String spritesheetType; static const gd::String javaScriptType; static const gd::String internalInGameEditorOnlySvgType; @@ -566,6 +567,37 @@ class GD_CORE_API AtlasResource : public Resource { gd::String file; }; +/** + * \brief Describe a spritesheet JSON file used by a project. + * + * A spritesheet resource contains JSON data that describes frames in a texture + * atlas. The JSON format follows the standard PixiJS/TexturePacker format with + * "frames" (mapping frame names to coordinates/dimensions) and "meta" (containing + * the texture atlas image reference). + * + * \see Resource + * \ingroup ResourcesManagement + */ +class GD_CORE_API SpritesheetResource : public Resource { +public: + SpritesheetResource() : Resource() { SetKind("spritesheet"); }; + virtual ~SpritesheetResource(){}; + virtual SpritesheetResource *Clone() const override { + return new SpritesheetResource(*this); + } + + virtual const gd::String &GetFile() const override { return file; }; + virtual void SetFile(const gd::String &newFile) override; + + virtual bool UseFile() const override { return true; } + void SerializeTo(SerializerElement &element) const override; + + void UnserializeFrom(const SerializerElement &element) override; + +private: + gd::String file; +}; + /** * \brief Describe a JavaScript file used by a project. * diff --git a/Extensions/3D/CustomRuntimeObject3DRenderer.ts b/Extensions/3D/CustomRuntimeObject3DRenderer.ts index 314ce22e8458..605de7ea3aeb 100644 --- a/Extensions/3D/CustomRuntimeObject3DRenderer.ts +++ b/Extensions/3D/CustomRuntimeObject3DRenderer.ts @@ -136,7 +136,8 @@ namespace gdjs { } static getAnimationFrameTextureManager( - imageManager: gdjs.PixiImageManager + imageManager: gdjs.PixiImageManager, + spritesheetManager: gdjs.PixiSpritesheetManager // Unused for 3D custom objects. ): ThreeAnimationFrameTextureManager { if (!imageManager._threeAnimationFrameTextureManager) { imageManager._threeAnimationFrameTextureManager = @@ -163,6 +164,22 @@ namespace gdjs { }); } + getAnimationFrameTextureFromSpritesheet( + spritesheetResourceName: string, + frameName: string + ): THREE.Material { + // "Spritesheet" frames are not supported for 3D objects. + // Return an invalid/empty material. + console.warn( + 'Spritesheet frames are not supported for 3D objects. Returning empty material.' + ); + return this._imageManager.getThreeMaterial('', { + useTransparentTexture: true, + forceBasicMaterial: true, + vertexColors: false, + }); + } + getAnimationFrameWidth(material: THREE.Material) { const map = ( material as THREE.MeshBasicMaterial | THREE.MeshStandardMaterial diff --git a/GDJS/GDJS/Events/CodeGeneration/ObjectCodeGenerator.cpp b/GDJS/GDJS/Events/CodeGeneration/ObjectCodeGenerator.cpp index 4913690d69a3..b98fdf197657 100644 --- a/GDJS/GDJS/Events/CodeGeneration/ObjectCodeGenerator.cpp +++ b/GDJS/GDJS/Events/CodeGeneration/ObjectCodeGenerator.cpp @@ -119,7 +119,8 @@ gd::String ObjectCodeGenerator::GenerateRuntimeObjectCompleteCode( this._animator = new gdjs.SpriteAnimator( objectData.animatable.animations, gdjs.RENDERER_CLASS_NAME.getAnimationFrameTextureManager( - parentInstanceContainer.getGame().getImageManager())); + parentInstanceContainer.getGame().getImageManager(), + parentInstanceContainer.getGame().getSpritesheetManager())); )jscode_template") .FindAndReplace("RENDERER_CLASS_NAME", eventsBasedObject.IsRenderedIn3D() ? "CustomRuntimeObject3DRenderer" : "CustomRuntimeObject2DRenderer"); }, diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index 561f264fd21a..e94138368174 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -1209,6 +1209,7 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers, InsertUnique(includesFiles, "pixi-renderers/runtimescene-pixi-renderer.js"); InsertUnique(includesFiles, "pixi-renderers/layer-pixi-renderer.js"); InsertUnique(includesFiles, "pixi-renderers/pixi-image-manager.js"); + InsertUnique(includesFiles, "pixi-renderers/pixi-spritesheet-manager.js"); InsertUnique(includesFiles, "pixi-renderers/pixi-bitmapfont-manager.js"); InsertUnique(includesFiles, "pixi-renderers/spriteruntimeobject-pixi-renderer.js"); diff --git a/GDJS/Runtime/ResourceCache.ts b/GDJS/Runtime/ResourceCache.ts index e6edc8102ae5..ab91c5ebf335 100644 --- a/GDJS/Runtime/ResourceCache.ts +++ b/GDJS/Runtime/ResourceCache.ts @@ -50,5 +50,13 @@ namespace gdjs { this._nameToContent.clear(); this._fileToContent.clear(); } + + /** + * Get all the values stored in the cache. + * @returns An iterable of all cached content values. + */ + getAllValues(): IterableIterator { + return this._nameToContent.values(); + } } } diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index 16fc5d6b754c..f94c8969d66e 100644 --- a/GDJS/Runtime/ResourceLoader.ts +++ b/GDJS/Runtime/ResourceLoader.ts @@ -146,6 +146,7 @@ namespace gdjs { private _bitmapFontManager: BitmapFontManager; private _spineAtlasManager: SpineAtlasManager | null = null; private _spineManager: SpineManager | null = null; + private _spritesheetManager: PixiSpritesheetManager; private _svgManager: InternalInGameEditorOnlySvgManager; /** @@ -192,6 +193,10 @@ namespace gdjs { this._imageManager ); this._model3DManager = new gdjs.Model3DManager(this); + this._spritesheetManager = new gdjs.PixiSpritesheetManager( + this, + this._imageManager + ); this._svgManager = new InternalInGameEditorOnlySvgManager(); // add spine related managers only if spine extension is used @@ -213,6 +218,7 @@ namespace gdjs { this._jsonManager, this._bitmapFontManager, this._model3DManager, + this._spritesheetManager, this._svgManager, ]; @@ -814,6 +820,15 @@ namespace gdjs { return this._spineAtlasManager; } + /** + * Get the Spritesheet manager of the game, used to load spritesheet JSON + * data and access individual frame textures. + * @return The Spritesheet manager for the game + */ + getSpritesheetManager(): gdjs.PixiSpritesheetManager { + return this._spritesheetManager; + } + injectMockResourceManagerForTesting( resourceKind: ResourceKind, resourceManager: ResourceManager diff --git a/GDJS/Runtime/SpriteAnimator.ts b/GDJS/Runtime/SpriteAnimator.ts index 3397df7de298..b260a3a1261f 100644 --- a/GDJS/Runtime/SpriteAnimator.ts +++ b/GDJS/Runtime/SpriteAnimator.ts @@ -31,8 +31,15 @@ namespace gdjs { /** Represents a {@link gdjs.SpriteAnimationFrame}. */ export type SpriteFrameData = { - /** The resource name of the image used in this frame. */ + /** The resource name of the image used in this frame. + * If using a spritesheet, this should be empty. */ image: string; + /** The spritesheet resource name (if using a spritesheet frame). + * When set, the texture is obtained from the spritesheet's frame data. */ + spritesheetResourceName?: string; + /** The frame name within the spritesheet (key in the "frames" object). + * Required when spritesheetResourceName is set. */ + spritesheetFrameName?: string; /** The points of the frame. */ points: Array; /** The origin point. */ @@ -76,12 +83,27 @@ namespace gdjs { }; /** - * Abstraction from graphic libraries texture classes. + * Interface allowing access to textures for object having animations. + * See notably: `gdjs.SpriteAnimator`. In practice, it's either giving access + * to textures for PixiJS or Three.js. */ export interface AnimationFrameTextureManager { - getAnimationFrameTexture(imageName: string): T; - getAnimationFrameWidth(pixiTexture: T); - getAnimationFrameHeight(pixiTexture: T); + /** + * Get a texture from a standalone image. + */ + getAnimationFrameTexture(imageResourceName: string): T; + /** + * Get a texture from a spritesheet frame. + * @param spritesheetResourceName The spritesheet resource name. + * @param frameName The frame name within the spritesheet. + * @returns The texture for the specified frame. + */ + getAnimationFrameTextureFromSpritesheet( + spritesheetResourceName: string, + frameName: string + ): T; + getAnimationFrameWidth(texture: T); + getAnimationFrameHeight(texture: T); } /** @@ -91,7 +113,12 @@ namespace gdjs { * or the collision mask. */ export class SpriteAnimationFrame { + /** The image name (for standalone images) or empty string (for spritesheet frames). */ image: string; + /** The spritesheet resource name (if using a spritesheet frame). */ + spritesheetResourceName: string | null = null; + /** The frame name within the spritesheet. */ + spritesheetFrameName: string | null = null; //TODO: Rename in imageName, and do not store it in the object? texture: T; @@ -110,11 +137,28 @@ namespace gdjs { textureManager: gdjs.AnimationFrameTextureManager ) { this.image = frameData ? frameData.image : ''; - this.texture = textureManager.getAnimationFrameTexture(this.image); + this.spritesheetResourceName = frameData?.spritesheetResourceName || null; + this.spritesheetFrameName = frameData?.spritesheetFrameName || null; + this.texture = this._getAnimationFrameTexture(textureManager); this.points = new Hashtable(); this.reinitialize(frameData, textureManager); } + /** + * Get the texture for this frame (from either standalone image or spritesheet). + */ + private _getAnimationFrameTexture( + textureManager: gdjs.AnimationFrameTextureManager + ): T { + if (this.spritesheetResourceName && this.spritesheetFrameName) { + return textureManager.getAnimationFrameTextureFromSpritesheet( + this.spritesheetResourceName, + this.spritesheetFrameName + ); + } + return textureManager.getAnimationFrameTexture(this.image); + } + /** * @param frameData The frame data used to initialize the frame * @param textureManager The game image manager @@ -124,7 +168,9 @@ namespace gdjs { textureManager: gdjs.AnimationFrameTextureManager ) { this.image = frameData.image; - this.texture = textureManager.getAnimationFrameTexture(this.image); + this.spritesheetResourceName = frameData.spritesheetResourceName || ''; + this.spritesheetFrameName = frameData.spritesheetFrameName || ''; + this.texture = this._getAnimationFrameTexture(textureManager); this.points.clear(); for (let i = 0, len = frameData.points.length; i < len; ++i) { diff --git a/GDJS/Runtime/pixi-renderers/CustomRuntimeObject2DPixiRenderer.ts b/GDJS/Runtime/pixi-renderers/CustomRuntimeObject2DPixiRenderer.ts index f5b5940a2ac0..6a6f8595f90a 100644 --- a/GDJS/Runtime/pixi-renderers/CustomRuntimeObject2DPixiRenderer.ts +++ b/GDJS/Runtime/pixi-renderers/CustomRuntimeObject2DPixiRenderer.ts @@ -150,10 +150,12 @@ namespace gdjs { } static getAnimationFrameTextureManager( - imageManager: gdjs.PixiImageManager + imageManager: gdjs.PixiImageManager, + spritesheetManager: gdjs.PixiSpritesheetManager ) { return gdjs.SpriteRuntimeObjectPixiRenderer.getAnimationFrameTextureManager( - imageManager + imageManager, + spritesheetManager ); } } diff --git a/GDJS/Runtime/pixi-renderers/pixi-spritesheet-manager.ts b/GDJS/Runtime/pixi-renderers/pixi-spritesheet-manager.ts new file mode 100644 index 000000000000..d85f49e0efb8 --- /dev/null +++ b/GDJS/Runtime/pixi-renderers/pixi-spritesheet-manager.ts @@ -0,0 +1,273 @@ +/* + * GDevelop JS Platform + * Copyright 2013-present Florian Rival (Florian.Rival@gmail.com). All rights reserved. + * This project is released under the MIT License. + */ +namespace gdjs { + const logger = new gdjs.Logger('PIXI Spritesheet manager'); + + /** + * The standard PixiJS/TexturePacker spritesheet JSON format. + * This follows the format described at: https://pixijs.com/7.x/guides/components/sprite-sheets + */ + export interface SpritesheetJsonData extends PIXI.ISpritesheetData { + // Nothing new. + } + + const spritesheetKinds: ResourceKind[] = ['spritesheet']; + + /** + * PixiSpritesheetManager loads and manages spritesheet JSON files and their associated + * PIXI.Spritesheet objects. A spritesheet contains frame definitions that reference + * regions within a texture atlas image. + * + * The spritesheet JSON format follows the standard PixiJS/TexturePacker format with: + * - "frames": Object mapping frame names to their coordinates/dimensions in the atlas + * - "meta": Object containing the texture atlas image reference and optional metadata + */ + export class PixiSpritesheetManager implements gdjs.ResourceManager { + private _imageManager: gdjs.PixiImageManager; + private _resourceLoader: gdjs.ResourceLoader; + + /** + * Parsed spritesheet data (the raw JSON) for each spritesheet resource. + */ + private _loadedSpritesheetData = + new gdjs.ResourceCache(); + + /** + * PIXI.Spritesheet objects that have been created and parsed. + */ + private _loadedPixiSpritesheets = + new gdjs.ResourceCache(); + + /** + * Promises for spritesheets that are currently being loaded. + */ + private _loadingSpritesheets = new gdjs.ResourceCache< + Promise + >(); + + /** + * @param resourceLoader The resources loader of the game. + * @param imageManager The image manager of the game. + */ + constructor( + resourceLoader: gdjs.ResourceLoader, + imageManager: gdjs.PixiImageManager + ) { + this._resourceLoader = resourceLoader; + this._imageManager = imageManager; + } + + getResourceKinds(): ResourceKind[] { + return spritesheetKinds; + } + + async processResource(resourceName: string): Promise { + // Do nothing because spritesheets are parsed during loading. + } + + async loadResource(resourceName: string): Promise { + await this.getOrLoad(resourceName); + } + + /** + * Get the spritesheet resource data for the given resource name. + */ + private _getSpritesheetResource(resourceName: string): ResourceData | null { + const resource = this._resourceLoader.getResource(resourceName); + return resource && this.getResourceKinds().includes(resource.kind) + ? resource + : null; + } + + /** + * Returns the PIXI.Spritesheet for the given resource if already loaded, + * or loads it if not yet available. + * + * @param resourceName The name of the spritesheet resource. + * @returns A promise that resolves to the PIXI.Spritesheet. + */ + getOrLoad(resourceName: string): Promise { + const resource = this._getSpritesheetResource(resourceName); + + if (!resource) { + return Promise.reject( + `Unable to find spritesheet resource '${resourceName}'.` + ); + } + + // Check if already loaded + const loadedSpritesheet = this._loadedPixiSpritesheets.get(resource); + if (loadedSpritesheet) { + return Promise.resolve(loadedSpritesheet); + } + + // Check if loading is in progress + let loadingPromise = this._loadingSpritesheets.get(resource); + if (loadingPromise) { + return loadingPromise; + } + + // Start loading + loadingPromise = this._loadSpritesheet(resource); + this._loadingSpritesheets.set(resource, loadingPromise); + + return loadingPromise; + } + + /** + * Load and parse a spritesheet resource. + */ + private async _loadSpritesheet( + resource: ResourceData + ): Promise { + try { + // Fetch the JSON data + const url = this._resourceLoader.getFullUrl(resource.file); + const response = await fetch(url, { + credentials: this._resourceLoader.checkIfCredentialsRequired( + resource.file + ) + ? 'include' + : 'same-origin', + }); + + if (!response.ok) { + throw new Error( + `Failed to load spritesheet JSON: ${response.status} ${response.statusText}` + ); + } + + const jsonData: SpritesheetJsonData = await response.json(); + this._loadedSpritesheetData.set(resource, jsonData); + + // Get the base texture from the image referenced in the spritesheet + if (!jsonData.meta || !jsonData.meta.image) { + throw new Error( + `Spritesheet JSON is missing the 'meta.image' field: ${resource.name}` + ); + } + + const game = this._resourceLoader.getRuntimeGame(); + const imageResourceName = game.resolveEmbeddedResource( + resource.name, + jsonData.meta.image + ); + + // Ensure the image is loaded + await this._imageManager.loadResource(imageResourceName); + const baseTexture = + this._imageManager.getPIXITexture(imageResourceName); + + if (!baseTexture || !baseTexture.valid) { + throw new Error( + `Failed to load atlas texture '${imageResourceName}' for spritesheet '${resource.name}'.` + ); + } + + // Create the PIXI.Spritesheet and parse it to generate frame `PIXI.Texture`s. + const spritesheet = new PIXI.Spritesheet( + baseTexture.baseTexture, + jsonData + ); + await spritesheet.parse(); + + this._loadedPixiSpritesheets.set(resource, spritesheet); + return spritesheet; + } catch (error) { + logger.error(`Error loading spritesheet '${resource.name}':`, error); + throw error; + } + } + + /** + * Check if the given spritesheet resource is loaded. + * @param resourceName The name of the spritesheet resource. + * @returns true if the spritesheet is loaded, false otherwise. + */ + isLoaded(resourceName: string): boolean { + return !!this._loadedPixiSpritesheets.getFromName(resourceName); + } + + /** + * Get a specific frame texture from a loaded spritesheet. + * @param resourceName The name of the spritesheet resource. + * @param frameName The name of the frame within the spritesheet. + * @returns The PIXI.Texture for the frame, or the invalid texture if not found. + */ + getSpritesheetFramePixiTexture( + resourceName: string, + frameName: string + ): PIXI.Texture { + const spritesheet = + this._loadedPixiSpritesheets.getFromName(resourceName); + + if (!spritesheet) { + logger.warn( + `Spritesheet '${resourceName}' is not loaded. Returning invalid texture.` + ); + return this._imageManager.getInvalidPIXITexture(); + } + + const texture = spritesheet.textures[frameName]; + + if (!texture) { + logger.warn( + `Frame '${frameName}' not found in spritesheet '${resourceName}'. Returning invalid texture.` + ); + return this._imageManager.getInvalidPIXITexture(); + } + + return texture; + } + + // Additional getters: + + /** + * Get the PIXI.Spritesheet for the given resource that is already loaded. + * If the resource is not loaded, null will be returned. + * @param resourceName The name of the spritesheet resource. + * @returns The PIXI.Spritesheet if loaded, null otherwise. + */ + getPixiSpritesheet(resourceName: string): PIXI.Spritesheet | null { + return this._loadedPixiSpritesheets.getFromName(resourceName); + } + + /** + * Get the parsed JSON data for a loaded spritesheet. + * @param resourceName The name of the spritesheet resource. + * @returns The spritesheet JSON data, or null if not loaded. + */ + getSpritesheetData(resourceName: string): SpritesheetJsonData | null { + return this._loadedSpritesheetData.getFromName(resourceName); + } + + /** + * To be called when the game is disposed. + * Clear all loaded spritesheets (both the JSON data and the associated PIXI.Spritesheet objects). + */ + dispose(): void { + for (const spritesheet of this._loadedPixiSpritesheets.getAllValues()) { + spritesheet.destroy(false); // Don't destroy base texture (image manager handles that) + } + this._loadedPixiSpritesheets.clear(); + this._loadedSpritesheetData.clear(); + this._loadingSpritesheets.clear(); + } + + unloadResource(resourceData: ResourceData): void { + const spritesheet = this._loadedPixiSpritesheets.getFromName( + resourceData.name + ); + if (spritesheet) { + spritesheet.destroy(false); // Don't destroy base texture (image manager handles that) + this._loadedPixiSpritesheets.delete(resourceData); + } + + this._loadedSpritesheetData.delete(resourceData); + this._loadingSpritesheets.delete(resourceData); + } + } +} diff --git a/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts index 49f62777a34b..0bec071fef60 100644 --- a/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts @@ -79,15 +79,15 @@ namespace gdjs { originX = animationFrame.origin.x; originY = animationFrame.origin.y; } else { - centerX = this._sprite.texture.frame.width / 2; - centerY = this._sprite.texture.frame.height / 2; + centerX = this._sprite.texture.orig.width / 2; + centerY = this._sprite.texture.orig.height / 2; originX = 0; originY = 0; } const scaleX = this._object._scaleX * this._object._preScale; const scaleY = this._object._scaleY * this._object._preScale; - this._sprite.anchor.x = centerX / this._sprite.texture.frame.width; - this._sprite.anchor.y = centerY / this._sprite.texture.frame.height; + this._sprite.anchor.x = centerX / this._sprite.texture.orig.width; + this._sprite.anchor.y = centerY / this._sprite.texture.orig.height; this._sprite.position.x = this._object.x + (centerX - originX) * Math.abs(scaleX); this._sprite.position.y = @@ -191,19 +191,23 @@ namespace gdjs { } getUnscaledWidth(): float { - return this._sprite.texture.frame.width; + return this._sprite.texture.orig.width; } getUnscaledHeight(): float { - return this._sprite.texture.frame.height; + return this._sprite.texture.orig.height; } static getAnimationFrameTextureManager( - imageManager: gdjs.PixiImageManager + imageManager: gdjs.PixiImageManager, + spritesheetManager: gdjs.PixiSpritesheetManager ): PixiAnimationFrameTextureManager { if (!imageManager._pixiAnimationFrameTextureManager) { imageManager._pixiAnimationFrameTextureManager = - new PixiAnimationFrameTextureManager(imageManager); + new PixiAnimationFrameTextureManager( + imageManager, + spritesheetManager + ); } return imageManager._pixiAnimationFrameTextureManager; } @@ -213,21 +217,42 @@ namespace gdjs { implements gdjs.AnimationFrameTextureManager { private _imageManager: gdjs.PixiImageManager; + private _spritesheetManager: gdjs.PixiSpritesheetManager; - constructor(imageManager: gdjs.PixiImageManager) { + constructor( + imageManager: gdjs.PixiImageManager, + spritesheetManager: gdjs.PixiSpritesheetManager + ) { this._imageManager = imageManager; + this._spritesheetManager = spritesheetManager; } getAnimationFrameTexture(imageName: string) { return this._imageManager.getPIXITexture(imageName); } + /** + * Get a texture from a spritesheet frame. + * @param spritesheetResourceName The spritesheet resource name. + * @param frameName The frame name within the spritesheet. + * @returns The texture for the specified frame. + */ + getAnimationFrameTextureFromSpritesheet( + spritesheetResourceName: string, + frameName: string + ): PIXI.Texture { + return this._spritesheetManager.getSpritesheetFramePixiTexture( + spritesheetResourceName, + frameName + ); + } + getAnimationFrameWidth(pixiTexture: PIXI.Texture) { - return pixiTexture.width; + return pixiTexture.orig.width; } getAnimationFrameHeight(pixiTexture: PIXI.Texture) { - return pixiTexture.height; + return pixiTexture.orig.height; } } diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index abb091061b8f..734cb3fc88c8 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -528,6 +528,15 @@ namespace gdjs { return this._resourcesLoader.getSpineAtlasManager(); } + /** + * Get the Spritesheet manager of the game, used to load spritesheet JSON + * data and access individual frame textures. + * @return The Spritesheet manager for the game + */ + getSpritesheetManager(): gdjs.PixiSpritesheetManager { + return this._resourcesLoader.getSpritesheetManager(); + } + /** * Get the input manager of the game, storing mouse, keyboard * and touches states. diff --git a/GDJS/Runtime/spriteruntimeobject.ts b/GDJS/Runtime/spriteruntimeobject.ts index fe11fb317f8d..b7f60734347f 100644 --- a/GDJS/Runtime/spriteruntimeobject.ts +++ b/GDJS/Runtime/spriteruntimeobject.ts @@ -72,7 +72,8 @@ namespace gdjs { this._animator = new gdjs.SpriteAnimator( spriteObjectData.animations, gdjs.SpriteRuntimeObjectRenderer.getAnimationFrameTextureManager( - instanceContainer.getGame().getImageManager() + instanceContainer.getGame().getImageManager(), + instanceContainer.getGame().getSpritesheetManager() ) ); this._updateAnimationFrame(); diff --git a/GDJS/Runtime/types/project-data.d.ts b/GDJS/Runtime/types/project-data.d.ts index 6eb8238c8714..a652d54fc9e7 100644 --- a/GDJS/Runtime/types/project-data.d.ts +++ b/GDJS/Runtime/types/project-data.d.ts @@ -615,5 +615,6 @@ declare type ResourceKind = | 'model3D' | 'atlas' | 'spine' + | 'spritesheet' | 'internal-in-game-editor-only-svg' | 'fake-resource-kind-for-testing-only'; diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 5ddbe2d01686..46dc049cfebf 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -1387,6 +1387,11 @@ interface SpineResource { }; SpineResource implements JsonResource; +interface SpritesheetResource { + void SpritesheetResource(); +}; +SpritesheetResource implements Resource; + interface TilemapResource { void TilemapResource(); }; @@ -3626,6 +3631,12 @@ interface Sprite { void SetImageName([Const] DOMString name); [Const, Ref] DOMString GetImageName(); + void SetSpritesheetResourceName([Const] DOMString name); + [Const, Ref] DOMString GetSpritesheetResourceName(); + void SetSpritesheetFrameName([Const] DOMString name); + [Const, Ref] DOMString GetSpritesheetFrameName(); + boolean UsesSpritesheetFrame(); + [Ref] Point GetOrigin(); [Ref] Point GetCenter(); boolean IsDefaultCenterPoint(); diff --git a/GDevelop.js/Bindings/ObjectJsImplementation.cpp b/GDevelop.js/Bindings/ObjectJsImplementation.cpp index 26cf801a0b35..4410642bd76b 100644 --- a/GDevelop.js/Bindings/ObjectJsImplementation.cpp +++ b/GDevelop.js/Bindings/ObjectJsImplementation.cpp @@ -210,6 +210,10 @@ void ObjectJsImplementation::ExposeResources(gd::ArbitraryResourceWorker& worker worker.ExposeAtlas(newPropertyValue); } else if (resourceType == "spine") { worker.ExposeSpine(newPropertyValue); + worker.ExposeEmbeddeds(newPropertyValue); + } else if (resourceType == "spritesheet") { + worker.ExposeSpritesheet(newPropertyValue); + worker.ExposeEmbeddeds(newPropertyValue); } if (newPropertyValue != oldPropertyValue) { diff --git a/GDevelop.js/scripts/generate-types.js b/GDevelop.js/scripts/generate-types.js index 3705f1e9357b..5ceb42511987 100644 --- a/GDevelop.js/scripts/generate-types.js +++ b/GDevelop.js/scripts/generate-types.js @@ -452,13 +452,13 @@ type CustomObjectConfiguration_EdgeAnchor = 0 | 1 | 2 | 3 | 4` shell.sed( '-i', /setKind\(kind: string\): void/, - "setKind(kind: 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine'): void", + "setKind(kind: 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine' | 'spritesheet'): void", 'types/gdresource.js' ); shell.sed( '-i', /getKind\(\): string/, - "getKind(): 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine'", + "getKind(): 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine' | 'spritesheet'", 'types/gdresource.js' ); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 4f40c764e896..dd4373cd2a00 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -1149,6 +1149,10 @@ export class SpineResource extends JsonResource { constructor(); } +export class SpritesheetResource extends Resource { + constructor(); +} + export class TilemapResource extends Resource { constructor(); } @@ -2662,6 +2666,11 @@ export class Sprite extends EmscriptenObject { constructor(); setImageName(name: string): void; getImageName(): string; + setSpritesheetResourceName(name: string): void; + getSpritesheetResourceName(): string; + setSpritesheetFrameName(name: string): void; + getSpritesheetFrameName(): string; + usesSpritesheetFrame(): boolean; getOrigin(): Point; getCenter(): Point; isDefaultCenterPoint(): boolean; diff --git a/GDevelop.js/types/gdresource.js b/GDevelop.js/types/gdresource.js index e522807fea10..25280c41702e 100644 --- a/GDevelop.js/types/gdresource.js +++ b/GDevelop.js/types/gdresource.js @@ -4,8 +4,8 @@ declare class gdResource { clone(): gdResource; setName(name: string): void; getName(): string; - setKind(kind: 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine'): void; - getKind(): 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine'; + setKind(kind: 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine' | 'spritesheet'): void; + getKind(): 'image' | 'audio' | 'font' | 'video' | 'json' | 'tilemap' | 'tileset' | 'model3D' | 'atlas' | 'spine' | 'spritesheet'; isUserAdded(): boolean; setUserAdded(yes: boolean): void; useFile(): boolean; diff --git a/GDevelop.js/types/gdsprite.js b/GDevelop.js/types/gdsprite.js index fc1539ab8395..4df7ce207c99 100644 --- a/GDevelop.js/types/gdsprite.js +++ b/GDevelop.js/types/gdsprite.js @@ -3,6 +3,11 @@ declare class gdSprite { constructor(): void; setImageName(name: string): void; getImageName(): string; + setSpritesheetResourceName(name: string): void; + getSpritesheetResourceName(): string; + setSpritesheetFrameName(name: string): void; + getSpritesheetFrameName(): string; + usesSpritesheetFrame(): boolean; getOrigin(): gdPoint; getCenter(): gdPoint; isDefaultCenterPoint(): boolean; diff --git a/GDevelop.js/types/gdspritesheetresource.js b/GDevelop.js/types/gdspritesheetresource.js new file mode 100644 index 000000000000..c02d784730ec --- /dev/null +++ b/GDevelop.js/types/gdspritesheetresource.js @@ -0,0 +1,6 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdSpritesheetResource extends gdResource { + constructor(): void; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/libgdevelop.js b/GDevelop.js/types/libgdevelop.js index ffc1b62b68fe..7295c7c863d7 100644 --- a/GDevelop.js/types/libgdevelop.js +++ b/GDevelop.js/types/libgdevelop.js @@ -126,6 +126,7 @@ declare class libGDevelop { VideoResource: Class; JsonResource: Class; SpineResource: Class; + SpritesheetResource: Class; TilemapResource: Class; TilesetResource: Class; Model3DResource: Class; diff --git a/newIDE/app/src/AssetStore/ResourceStore/ResourceCard.js b/newIDE/app/src/AssetStore/ResourceStore/ResourceCard.js index 1a96cd5ca4c2..694667c16dc4 100644 --- a/newIDE/app/src/AssetStore/ResourceStore/ResourceCard.js +++ b/newIDE/app/src/AssetStore/ResourceStore/ResourceCard.js @@ -205,6 +205,7 @@ export const ResourceCard = ({ case 'tilemap': case 'tileset': case 'spine': + case 'spritesheet': return ( diff --git a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js index 4c1d730dc54c..2e9a59906605 100644 --- a/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js +++ b/newIDE/app/src/ObjectEditor/Editors/SpriteEditor/SpritesList.js @@ -39,6 +39,9 @@ import ContextMenu, { import useAlertDialog from '../../../UI/Alert/useAlertDialog'; import { groupResourcesByAnimations } from './AnimationImportHelper'; import { type ResourceExternalEditor } from '../../../ResourcesList/ResourceExternalEditor'; +import SpritesheetFramesSelectorDialog, { + type SpritesheetSelectionResult, +} from '../Spritesheet/SpritesheetFramesSelectorDialog'; const gd: libGDevelop = global.gd; @@ -252,6 +255,23 @@ export const addAnimationFrame = ( sprite.delete(); }; +export const addAnimationFrameFromSpritesheetFrame = ( + animations: gdSpriteAnimationList, + direction: gdDirection, + spritesheetResourceName: string, + spritesheetFrameName: string, + onSpriteAdded: (sprite: gdSprite) => void +) => { + const sprite = new gd.Sprite(); + sprite.setSpritesheetResourceName(spritesheetResourceName); + sprite.setSpritesheetFrameName(spritesheetFrameName); + applyPointsAndMasksToSpriteIfNecessary(animations, direction, sprite); + + onSpriteAdded(sprite); // Call the callback before `addSprite`, as `addSprite` will store a copy of it. + direction.addSprite(sprite); + sprite.delete(); +}; + type Props = {| animations: gdSpriteAnimationList, direction: gdDirection, @@ -299,15 +319,26 @@ const SpritesList = ({ const spriteContextMenu = React.useRef(null); const forceUpdate = useForceUpdate(); const { showConfirmation } = useAlertDialog(); + const [ + selectedSpritesheetResourceName, + setSelectedSpritesheetResourceName, + ] = React.useState(null); const storageProvider = resourceManagementProps.getStorageProvider(); - const resourceSources = resourceManagementProps.resourceSources + const imageResourceSources = resourceManagementProps.resourceSources .filter(source => source.kind === 'image') .filter( ({ onlyForStorageProvider }) => !onlyForStorageProvider || onlyForStorageProvider === storageProvider.internalName ); + const spritesheetResourceSources = resourceManagementProps.resourceSources + .filter(source => source.kind === 'spritesheet') + .filter( + ({ onlyForStorageProvider }) => + !onlyForStorageProvider || + onlyForStorageProvider === storageProvider.internalName + ); const updateSelectionIndexesAfterMoveUp = React.useCallback( (oldIndex: number, newIndex: number, wasMovedItemSelected: boolean) => { @@ -406,7 +437,7 @@ const SpritesList = ({ }); if (!selectedResources.length) return; - const selectedResourceSource = resourceSources.find( + const selectedResourceSource = imageResourceSources.find( source => source.name === selectedSourceName ); if (!selectedResourceSource) return; @@ -475,10 +506,89 @@ const SpritesList = ({ addAnimations, animations, onSpriteAdded, - resourceSources, + imageResourceSources, ] ); + const onAddSpriteFromSpritesheet = React.useCallback( + async (initialResourceSource: ResourceSource) => { + try { + if (!initialResourceSource) return; + + const { + selectedResources, + selectedSourceName, + } = await resourceManagementProps.onChooseResource({ + initialSourceName: initialResourceSource.name, + multiSelection: false, + resourceKind: 'spritesheet', + }); + + if (!selectedResources.length) return; + const selectedResourceSource = spritesheetResourceSources.find( + source => source.name === selectedSourceName + ); + if (!selectedResourceSource) return; + + const resource = selectedResources[0]; + const resourceName = resource.getName(); + + if (selectedResourceSource.shouldCreateResource) { + applyResourceDefaults(project, resource); + + // addResource will check if a resource with the same name exists, and if it is + // the case, no new resource will be added. + const hasCreatedAnyResource = project + .getResourcesManager() + .addResource(resource); + + // Important, we are responsible for deleting the resources that were given to us. + // Otherwise we have a memory leak, as calling addResource is making a copy of the resource. + selectedResources.forEach(resource => resource.delete()); + + if (hasCreatedAnyResource) { + await resourceManagementProps.onFetchNewlyAddedResources(); + resourceManagementProps.onNewResourcesAdded(); + } + } + + // TODO: show a dialog allowing the user to select the animation of one/more frames of the spritesheet to add. + // And then create the sprites: + setSelectedSpritesheetResourceName(resourceName); + + // if (selectedResources.length && onSpriteUpdated) onSpriteUpdated(); + // if (directionSpritesCountBeforeAdding === 0 && onFirstSpriteUpdated) { + // // If there was no sprites before, we can assume the first sprite was added. + // onFirstSpriteUpdated(); + // } + } catch (err) { + // Should never happen, errors should be shown in the interface. + console.error('Unable to choose a resource', err); + } + }, + [project, resourceManagementProps, spritesheetResourceSources] + ); + + const onSelectFromSpritesheet = React.useCallback( + (selection: SpritesheetSelectionResult) => { + if (!selectedSpritesheetResourceName) return; + const { frameNames } = selection; + + frameNames.forEach(frameName => { + addAnimationFrameFromSpritesheetFrame( + animations, + direction, + selectedSpritesheetResourceName, + frameName, + onSpriteAdded + ); + }); + + setSelectedSpritesheetResourceName(null); + }, + [animations, direction, onSpriteAdded, selectedSpritesheetResourceName] + ); + const deleteSprites = React.useCallback( async () => { const sprites = selectedSprites.current; @@ -634,30 +744,62 @@ const SpritesList = ({ { - onAddSprite(resourceSources[0]); + onAddSprite(imageResourceSources[0]); }} // The event-based object editor gives an empty list. - disabled={resourceSources.length === 0} + disabled={imageResourceSources.length === 0} label={Add a sprite} icon={} primary buildMenuTemplate={(i18n: I18nType) => { const storageProvider = resourceManagementProps.getStorageProvider(); - return resourceManagementProps.resourceSources - .filter(source => source.kind === 'image') - .filter( - ({ onlyForStorageProvider }) => - !onlyForStorageProvider || - onlyForStorageProvider === storageProvider.internalName - ) - .map(source => ({ - label: i18n._(source.displayName), - click: () => onAddSprite(source), - })); + return [ + { + label: i18n._(t`Image(s):`), + enabled: false, + }, + ...resourceManagementProps.resourceSources + .filter(source => source.kind === 'image') + .filter( + ({ onlyForStorageProvider }) => + !onlyForStorageProvider || + onlyForStorageProvider === storageProvider.internalName + ) + .map(source => ({ + label: i18n._(source.displayName), + click: () => onAddSprite(source), + })), + { + type: 'separator', + }, + { + label: i18n._(t`From a spritesheet (PixiJS format):`), + enabled: false, + }, + ...resourceManagementProps.resourceSources + .filter(source => source.kind === 'spritesheet') + .filter( + ({ onlyForStorageProvider }) => + !onlyForStorageProvider || + onlyForStorageProvider === storageProvider.internalName + ) + .map(source => ({ + label: i18n._(source.displayName), + click: () => onAddSpriteFromSpritesheet(source), + })), + ]; }} /> + {selectedSpritesheetResourceName && ( + setSelectedSpritesheetResourceName(null)} + /> + )} ); }; diff --git a/newIDE/app/src/ObjectEditor/Editors/Spritesheet/SpritesheetFrameThumbnail.js b/newIDE/app/src/ObjectEditor/Editors/Spritesheet/SpritesheetFrameThumbnail.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/newIDE/app/src/ObjectEditor/Editors/Spritesheet/SpritesheetFramesSelectorDialog.js b/newIDE/app/src/ObjectEditor/Editors/Spritesheet/SpritesheetFramesSelectorDialog.js new file mode 100644 index 000000000000..0a4db9abca76 --- /dev/null +++ b/newIDE/app/src/ObjectEditor/Editors/Spritesheet/SpritesheetFramesSelectorDialog.js @@ -0,0 +1,417 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; + +import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog'; +import FlatButton from '../../../UI/FlatButton'; +import AlertMessage from '../../../UI/AlertMessage'; +import { ColumnStackLayout } from '../../../UI/Layout'; +import Text from '../../../UI/Text'; +import Checkbox from '../../../UI/Checkbox'; +import PixiResourcesLoader from '../../../ObjectsRendering/PixiResourcesLoader'; +import { type SpritesheetOrLoadingError } from '../../../ObjectsRendering/PixiResourcesLoader'; +import { CorsAwareImage } from '../../../UI/CorsAwareImage'; +import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext'; +import CheckeredBackground from '../../../ResourcesList/CheckeredBackground'; +import ScrollView from '../../../UI/ScrollView'; +import { List, ListItem } from '../../../UI/List'; + +const FRAME_SIZE = 80; + +const styles = { + framesGrid: { + display: 'flex', + flexWrap: 'wrap', + gap: 8, + }, + frameThumbnail: { + position: 'relative', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + textAlign: 'center', + boxSizing: 'border-box', + flexShrink: 0, + width: FRAME_SIZE, + height: FRAME_SIZE + 24, // Extra space for the label + flexDirection: 'column', + cursor: 'pointer', + }, + frameImageContainer: { + position: 'relative', + width: FRAME_SIZE, + height: FRAME_SIZE, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: 4, + overflow: 'hidden', + }, + frameImage: { + position: 'relative', + pointerEvents: 'none', + maxWidth: FRAME_SIZE, + maxHeight: FRAME_SIZE, + }, + frameLabel: { + fontSize: 10, + marginTop: 4, + maxWidth: FRAME_SIZE, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + checkboxContainer: { + position: 'absolute', + bottom: 0, + right: 0, + }, +}; + +type FrameThumbnailProps = {| + frameName: string, + texture: any, // PIXI.Texture + selected: boolean, + onSelect: (selected: boolean) => void, +|}; + +const FrameThumbnail = ({ + frameName, + texture, + selected, + onSelect, +}: FrameThumbnailProps) => { + const theme = React.useContext(GDevelopThemeContext); + const borderColor = selected + ? theme.palette.secondary + : theme.imagePreview.borderColor; + + // Get the image source from the texture + const imageSrc = React.useMemo( + () => { + if (!texture || !texture.baseTexture || !texture.baseTexture.resource) { + return ''; + } + const source = texture.baseTexture.resource.source; + if (source instanceof HTMLImageElement) { + return source.src; + } + return ''; + }, + [texture] + ); + + // Calculate the crop for this frame + const frameStyle = React.useMemo( + () => { + if (!texture || !texture.frame) { + return {}; + } + const frame = texture.frame; + const orig = texture.orig; + return { + objectFit: 'none', + objectPosition: `-${frame.x}px -${frame.y}px`, + width: orig.width, + height: orig.height, + maxWidth: 'none', + maxHeight: 'none', + transform: `scale(${Math.min( + FRAME_SIZE / orig.width, + FRAME_SIZE / orig.height, + 1 + )})`, + }; + }, + [texture] + ); + + return ( +
onSelect(!selected)} + title={frameName} + > +
+ + {imageSrc && ( + + )} +
e.stopPropagation()} + > + { + onSelect(checked); + }} + /> +
+
+ {frameName} +
+ ); +}; + +export type SpritesheetSelectionResult = {| + frameNames: string[], +|}; + +type Props = {| + project: gdProject, + spritesheetResourceName: string, + onSelect: (selection: SpritesheetSelectionResult) => void, + onRequestClose: () => void, +|}; + +const SpritesheetFramesSelectorDialog = ({ + project, + spritesheetResourceName, + onSelect, + onRequestClose, +}: Props) => { + const [ + spritesheetData, + setSpritesheetData, + ] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + const [selectedFrames, setSelectedFrames] = React.useState>([]); + const [selectedAnimation, setSelectedAnimation] = React.useState< + string | null + >(null); + + React.useEffect( + () => { + (async () => { + setIsLoading(true); + const result = await PixiResourcesLoader.getSpritesheet( + project, + spritesheetResourceName + ); + setSpritesheetData(result); + setIsLoading(false); + })(); + }, + [project, spritesheetResourceName] + ); + + const handleFrameSelect = React.useCallback( + (frameName: string, selected: boolean) => { + // When selecting frames, clear any selected animation + setSelectedAnimation(null); + setSelectedFrames(prevSelected => { + if (selected) { + return [...prevSelected, frameName]; + } else { + return prevSelected.filter(name => name !== frameName); + } + }); + }, + [] + ); + + const handleAnimationSelect = React.useCallback((animationName: string) => { + // When selecting an animation, clear any selected frames + setSelectedFrames([]); + setSelectedAnimation(prevAnimation => + prevAnimation === animationName ? null : animationName + ); + }, []); + + const hasSelection = selectedFrames.length > 0 || selectedAnimation !== null; + + // Get frames and animations from the spritesheet + const frames = React.useMemo( + () => { + if (!spritesheetData || !spritesheetData.spritesheet) return {}; + return spritesheetData.spritesheet.textures || {}; + }, + [spritesheetData] + ); + + const animations = React.useMemo( + () => { + if ( + !spritesheetData || + !spritesheetData.spritesheet || + !spritesheetData.spritesheet.data + ) + return {}; + return spritesheetData.spritesheet.data.animations || {}; + }, + [spritesheetData] + ); + + const handleConfirm = React.useCallback( + () => { + const frameNames = + selectedFrames.length > 0 + ? selectedFrames + : animations[selectedAnimation || ''] || []; + + onSelect({ + frameNames, + }); + }, + [onSelect, selectedFrames, animations, selectedAnimation] + ); + + const frameNames = Object.keys(frames); + const animationNames = Object.keys(animations); + + const renderError = () => { + if (!spritesheetData) return null; + + const { loadingError, loadingErrorReason } = spritesheetData; + + if (loadingErrorReason) { + let errorMessage; + switch (loadingErrorReason) { + case 'invalid-spritesheet-resource': + errorMessage = ( + The spritesheet resource could not be found. + ); + break; + case 'spritesheet-json-loading-error': + errorMessage = ( + + Failed to load the spritesheet JSON file. Verify if it a proper + PixiJS spritesheet file in JSON format. + + ); + break; + case 'missing-spritesheet-image-field': + errorMessage = ( + The spritesheet JSON is missing the image reference. + ); + break; + case 'invalid-spritesheet-image-resource': + errorMessage = ( + The spritesheet image could not be loaded. + ); + break; + case 'spritesheet-pixi-loading-error': + errorMessage = ( + + An error occurred while parsing the spritesheet. + {loadingError ? ` ${loadingError.message}` : ''} + + ); + break; + default: + errorMessage = ( + + An unknown error occurred. + {loadingError ? ` ${loadingError.message}` : ''} + + ); + } + + return {errorMessage}; + } + + return null; + }; + + return ( + Select frames from spritesheet} + open + maxWidth="md" + fullHeight + actions={[ + Cancel} + onClick={onRequestClose} + />, + Select} + primary + onClick={handleConfirm} + disabled={!hasSelection} + />, + ]} + onRequestClose={onRequestClose} + onApply={hasSelection ? handleConfirm : undefined} + > + + {isLoading && ( + + Loading spritesheet... + + )} + + {!isLoading && renderError()} + + {!isLoading && spritesheetData && spritesheetData.spritesheet && ( + + + {animationNames.length > 0 && ( + <> + + Animations + + + {animationNames.map(animationName => ( + handleAnimationSelect(animationName)} + selected={selectedAnimation === animationName} + /> + ))} + + + )} + + {frameNames.length > 0 && ( + <> + + Frames + +
+ {frameNames.map(frameName => ( + + handleFrameSelect(frameName, selected) + } + /> + ))} +
+ + )} + + {frameNames.length === 0 && animationNames.length === 0 && ( + + + No frames or animations found in this spritesheet. + + + )} +
+
+ )} +
+
+ ); +}; + +export default SpritesheetFramesSelectorDialog; diff --git a/newIDE/app/src/ObjectsRendering/PixiResourcesLoader.js b/newIDE/app/src/ObjectsRendering/PixiResourcesLoader.js index 45513583905f..b2c2c21c1752 100644 --- a/newIDE/app/src/ObjectsRendering/PixiResourcesLoader.js +++ b/newIDE/app/src/ObjectsRendering/PixiResourcesLoader.js @@ -38,6 +38,18 @@ export type SpineDataOrLoadingError = {| | 'atlas-resource-loading-error', |}; +export type SpritesheetOrLoadingError = {| + spritesheet: ?PIXI.Spritesheet, + loadingError: ?Error, + loadingErrorReason: + | null + | 'invalid-spritesheet-resource' + | 'spritesheet-json-loading-error' + | 'missing-spritesheet-image-field' + | 'invalid-spritesheet-image-resource' + | 'spritesheet-pixi-loading-error', +|}; + type ResourcePromise = { [resourceName: string]: Promise }; let loadedBitmapFonts = {}; @@ -52,6 +64,7 @@ let loadedOrLoadingThreeMaterials: ResourcePromise = {}; let loadedOrLoading3DModelPromises: ResourcePromise = {}; let spineAtlasPromises: ResourcePromise = {}; let spineDataPromises: ResourcePromise = {}; +let spritesheetPromises: ResourcePromise = {}; const createInvalidModel = (): GLTF => { /** @@ -285,6 +298,7 @@ export default class PixiResourcesLoader { // Also reload any resource embedding this resource: await this._reloadEmbedderResources(project, resourceName, 'atlas'); + await this._reloadEmbedderResources(project, resourceName, 'spritesheet'); } await PixiResourcesLoader.loadTextures(project, [resourceName]); @@ -330,6 +344,9 @@ export default class PixiResourcesLoader { // and pick up fresh data next time we call `getSpineData`. PIXI.Assets.resolver.prefer(); } + if (spritesheetPromises[resourceName]) { + delete spritesheetPromises[resourceName]; + } const matchingMaterialCacheKeys = Object.keys( loadedOrLoadingThreeMaterials @@ -839,6 +856,139 @@ export default class PixiResourcesLoader { })); } + static async getSpritesheet( + project: gdProject, + spritesheetName: string + ): Promise { + const promise = spritesheetPromises[spritesheetName]; + if (promise) return promise; + + return (spritesheetPromises[spritesheetName] = (async () => { + const resourceManager = project.getResourcesManager(); + if (!spritesheetName || !resourceManager.hasResource(spritesheetName)) { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'invalid-spritesheet-resource', + }; + } + + const resource = resourceManager.getResource(spritesheetName); + if (resource.getKind() !== 'spritesheet') { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'invalid-spritesheet-resource', + }; + } + + const fullUrl = ResourcesLoader.getResourceFullUrl( + project, + resource.getName(), + { + isResourceForPixi: true, + } + ); + + const spritesheetJsonData = await axios + .get(fullUrl, { + withCredentials: checkIfCredentialsRequired(fullUrl), + }) + .then(response => response.data, error => null); + if (!spritesheetJsonData) { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'spritesheet-json-loading-error', + }; + } + + const imageField = + spritesheetJsonData.meta && spritesheetJsonData.meta.image; + const embeddedResourcesMapping = readEmbeddedResourcesMapping(resource); + const spritesheetImageName = embeddedResourcesMapping + ? embeddedResourcesMapping[imageField] + : null; + if (typeof spritesheetImageName !== 'string') { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'missing-spritesheet-image-field', + }; + } + + if ( + spritesheetImageName.length === 0 || + !resourceManager.hasResource(spritesheetImageName) + ) { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'invalid-spritesheet-image-resource', + }; + } + + const spritesheetImageResource = resourceManager.getResource( + spritesheetImageName + ); + if (spritesheetImageResource.getKind() !== 'image') { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'invalid-spritesheet-image-resource', + }; + } + + const spritesheetTexture = this.getPIXITexture( + project, + spritesheetImageName + ); + if (!spritesheetTexture) { + return { + spritesheet: null, + loadingError: null, + loadingErrorReason: 'invalid-spritesheet-image-resource', + }; + } + + try { + const spritesheet = new PIXI.Spritesheet( + spritesheetTexture.baseTexture, + spritesheetJsonData + ); + await spritesheet.parse(); + + return { + spritesheet, + loadingError: null, + loadingErrorReason: null, + }; + } catch (error) { + return { + spritesheet: null, + loadingError: error, + loadingErrorReason: 'spritesheet-pixi-loading-error', + }; + } + })()); + } + + static async getSpritesheetFramePIXITexture( + project: gdProject, + spritesheetName: string, + frameName: string + ): Promise { + const spritesheetOrLoadingError = await this.getSpritesheet( + project, + spritesheetName + ); + const spritesheet = spritesheetOrLoadingError.spritesheet; + if (!spritesheet) { + return invalidTexture; + } + return spritesheet.textures[frameName] || invalidTexture; + } + /** * Return the PIXI video texture represented by the given resource. * If not loaded, it will load it. diff --git a/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js b/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js index 0ba5cb2f24c4..a5b570380e92 100644 --- a/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js +++ b/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js @@ -19,6 +19,9 @@ export default class RenderedSpriteInstance extends RenderedInstance { _shouldNotRotate: boolean = false; _preScale = 1; + _currentSpritesheetResourceName: string = ''; + _currentSpritesheetFrameName: string = ''; + constructor( project: gdProject, instance: gdInitialInstance, @@ -94,20 +97,20 @@ export default class RenderedSpriteInstance extends RenderedInstance { if (!this._pixiObject) { return; } - const objectTextureFrame = this._pixiObject.texture.frame; + const objectTexture = this._pixiObject.texture; // In case the texture is not loaded yet, we don't want to crash. - if (!objectTextureFrame) return; + if (!objectTexture || !objectTexture.orig) return; - this._pixiObject.anchor.x = this._centerX / objectTextureFrame.width; - this._pixiObject.anchor.y = this._centerY / objectTextureFrame.height; + this._pixiObject.anchor.x = this._centerX / objectTexture.orig.width; + this._pixiObject.anchor.y = this._centerY / objectTexture.orig.height; this._pixiObject.rotation = this._shouldNotRotate ? 0 : RenderedInstance.toRad(this._instance.getAngle()); if (this._instance.hasCustomSize()) { this._pixiObject.scale.x = - this.getCustomWidth() / objectTextureFrame.width; + this.getCustomWidth() / objectTexture.orig.width; this._pixiObject.scale.y = - this.getCustomHeight() / objectTextureFrame.height; + this.getCustomHeight() / objectTexture.orig.height; } else { this._pixiObject.scale.x = this._preScale; this._pixiObject.scale.y = this._preScale; @@ -179,28 +182,56 @@ export default class RenderedSpriteInstance extends RenderedInstance { const sprite = this._sprite; if (!sprite) return; - const texture = this._pixiResourcesLoader.getPIXITexture( - this._project, - sprite.getImageName() - ); - this._pixiObject.texture = texture; - - if (!texture.baseTexture.valid) { - // Post pone texture update if texture is not loaded. - texture.once('update', () => { - if (this._wasDestroyed) return; - this.updatePIXITextureAndSprite(); - }); - return; + if (sprite.usesSpritesheetFrame()) { + if ( + this._currentSpritesheetResourceName !== + sprite.getSpritesheetResourceName() || + this._currentSpritesheetFrameName !== sprite.getSpritesheetFrameName() + ) { + (async () => { + const texture = await this._pixiResourcesLoader.getSpritesheetFramePIXITexture( + this._project, + sprite.getSpritesheetResourceName(), + sprite.getSpritesheetFrameName() + ); + if (this._wasDestroyed) return; + + this._pixiObject.texture = texture; + this._currentSpritesheetResourceName = sprite.getSpritesheetResourceName(); + this._currentSpritesheetFrameName = sprite.getSpritesheetFrameName(); + + this.updatePIXITextureAndSprite(); + })(); + } + } else { + const texture = this._pixiResourcesLoader.getPIXITexture( + this._project, + sprite.getImageName() + ); + this._pixiObject.texture = texture; + + if (!texture.baseTexture.valid) { + // Post pone texture update if texture is not loaded. + texture.once('update', () => { + if (this._wasDestroyed) return; + this.updatePIXITextureAndSprite(); + }); + return; + } } const origin = sprite.getOrigin(); this._originX = origin.getX(); this._originY = origin.getY(); + const texture = this._pixiObject.texture; + if (!texture.baseTexture.valid) { + return; // Should never happen. + } + if (sprite.isDefaultCenterPoint()) { - this._centerX = texture.width / 2; - this._centerY = texture.height / 2; + this._centerX = texture.orig.width / 2; + this._centerY = texture.orig.height / 2; } else { const center = sprite.getCenter(); this._centerX = center.getX(); @@ -232,19 +263,19 @@ export default class RenderedSpriteInstance extends RenderedInstance { } getDefaultWidth(): number { - const objectTextureFrame = this._pixiObject.texture.frame; + const objectTextureFrame = this._pixiObject.texture; // In case the texture is not loaded yet, we don't want to crash. - if (!objectTextureFrame) return 32; + if (!objectTextureFrame || !objectTextureFrame.orig) return 32; - return Math.abs(objectTextureFrame.width) * this._preScale; + return Math.abs(objectTextureFrame.orig.width) * this._preScale; } getDefaultHeight(): number { - const objectTextureFrame = this._pixiObject.texture.frame; + const objectTextureFrame = this._pixiObject.texture; // In case the texture is not loaded yet, we don't want to crash. - if (!objectTextureFrame) return 32; + if (!objectTextureFrame || !objectTextureFrame.orig) return 32; - return Math.abs(objectTextureFrame.height) * this._preScale; + return Math.abs(objectTextureFrame.orig.height) * this._preScale; } getCenterX(): number { diff --git a/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.js b/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.js index 08336701a339..34ee4298df74 100644 --- a/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.js +++ b/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.js @@ -49,6 +49,7 @@ const resourceKindToInputAcceptedMimes = { atlas: [], spine: ['application/json'], javascript: ['text/javascript'], + spritesheet: ['application/json'], }; const getAcceptedExtensions = ( diff --git a/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.spec.js b/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.spec.js index 65830a156693..758da0bca0ef 100644 --- a/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.spec.js +++ b/newIDE/app/src/ResourcesList/FileToCloudProjectResourceUploader.spec.js @@ -37,5 +37,8 @@ describe('FileToCloudProjectResourceUploader', () => { expect(getInputAcceptedMimesAndExtensions('spine')).toMatchInlineSnapshot( `"application/json,.json"` ); + expect( + getInputAcceptedMimesAndExtensions('spritesheet') + ).toMatchInlineSnapshot(`"application/json,.json"`); }); }); diff --git a/newIDE/app/src/ResourcesList/LocalEmbeddedResourceSources.js b/newIDE/app/src/ResourcesList/LocalEmbeddedResourceSources.js index 50b3c97b42d6..b5cf79a8ea88 100644 --- a/newIDE/app/src/ResourcesList/LocalEmbeddedResourceSources.js +++ b/newIDE/app/src/ResourcesList/LocalEmbeddedResourceSources.js @@ -314,9 +314,88 @@ export async function listSpineTextureAtlasEmbeddedResources( }; } +/** + * List the embedded resources of a PixiJS Spritesheet JSON resource. + * A spritesheet JSON file contains a `meta.image` field that references the + * texture atlas image file. + * + * @param project The project + * @param filePath The file path of a spritesheet JSON resource + * @returns The embedded resources (the texture image), or null if parsing fails + */ +export async function listSpritesheetEmbeddedResources( + project: gdProject, + filePath: string +): Promise { + if (!fs || !path) return null; + + let spritesheetContent: ?string = null; + try { + spritesheetContent = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + console.error( + `Unable to read spritesheet JSON file at path ${filePath}:`, + error + ); + return null; + } + + if (!spritesheetContent) return null; + + try { + const spritesheetData = JSON.parse(spritesheetContent); + + // Check if this looks like a valid PixiJS spritesheet JSON + // (must have "frames" and "meta" fields) + if ( + !spritesheetData || + typeof spritesheetData !== 'object' || + !spritesheetData.frames || + !spritesheetData.meta + ) { + console.warn( + `File ${filePath} does not appear to be a valid PixiJS spritesheet JSON (missing "frames" or "meta" fields).` + ); + return null; + } + + // Get the image path from meta.image + const imageRelPath = spritesheetData.meta.image; + if (!imageRelPath || typeof imageRelPath !== 'string') { + console.warn( + `Spritesheet JSON file ${filePath} is missing the "meta.image" field or it is not a string.` + ); + return null; + } + + const dir = path.dirname(filePath); + const embeddedResources = new Map(); + + const fullPath = path.resolve(dir, imageRelPath); + const isOutsideProjectFolder = !isPathInProjectFolder(project, fullPath); + const resource: EmbeddedResource = { + resourceKind: 'image', + relPath: imageRelPath, + fullPath, + isOutsideProjectFolder, + }; + + embeddedResources.set(imageRelPath, resource); + + return { + embeddedResources, + hasAnyEmbeddedResourceOutsideProjectFolder: isOutsideProjectFolder, + }; + } catch (error) { + console.error(`Unable to parse spritesheet JSON file ${filePath}:`, error); + return null; + } +} + export const embeddedResourcesParsers: { [string]: ParseEmbeddedFiles } = { tilemap: listTileMapEmbeddedResources, json: listTileMapEmbeddedResources, spine: listSpineEmbeddedResources, atlas: listSpineTextureAtlasEmbeddedResources, + spritesheet: listSpritesheetEmbeddedResources, }; diff --git a/newIDE/app/src/ResourcesList/ResourceSource.js b/newIDE/app/src/ResourcesList/ResourceSource.js index 867c0ec4f932..dc2f37c7e421 100644 --- a/newIDE/app/src/ResourcesList/ResourceSource.js +++ b/newIDE/app/src/ResourcesList/ResourceSource.js @@ -31,6 +31,7 @@ export type ResourceKind = | 'model3D' | 'atlas' | 'spine' + | 'spritesheet' | 'javascript'; export const resourcesKindSupportedByResourceStore = ['audio', 'font']; @@ -62,7 +63,7 @@ export const allResourceKindsAndMetadata = [ }, { kind: 'json', - displayName: t`Json`, + displayName: t`JSON file`, fileExtensions: ['json'], createNewResource: () => { return new gd.JsonResource(); @@ -70,42 +71,50 @@ export const allResourceKindsAndMetadata = [ }, { kind: 'tilemap', - displayName: t`Tile Map`, + displayName: t`Tilemap (LDtk .ldtk/JSON or Tiled .tmj/JSON)`, fileExtensions: ['json', 'ldtk', 'tmj'], createNewResource: () => new gd.TilemapResource(), }, { kind: 'tileset', - displayName: t`Tile Set`, + displayName: t`Tileset (Tiled .tsj/JSON)`, fileExtensions: ['json', 'tsj'], createNewResource: () => new gd.TilesetResource(), }, { kind: 'bitmapFont', - displayName: t`Bitmap Font`, + displayName: t`Bitmap Font (.fnt/XML file)`, fileExtensions: ['fnt', 'xml'], createNewResource: () => new gd.BitmapFontResource(), }, { kind: 'model3D', - displayName: t`3D model`, + displayName: t`3D model (GLB format)`, fileExtensions: ['glb'], createNewResource: () => new gd.Model3DResource(), }, { kind: 'atlas', - displayName: t`Atlas`, + displayName: t`Spine Atlas`, fileExtensions: ['atlas'], createNewResource: () => new gd.AtlasResource(), }, { kind: 'spine', - displayName: t`Spine Json`, + displayName: t`Spine Skeleton JSON`, fileExtensions: ['json'], createNewResource: () => { return new gd.SpineResource(); }, }, + { + kind: 'spritesheet', + displayName: t`Spritesheet (PixiJS format)`, + fileExtensions: ['json'], + createNewResource: () => { + return new gd.SpritesheetResource(); + }, + }, { kind: 'javascript', displayName: t`JavaScript file`, diff --git a/newIDE/app/src/ResourcesList/index.js b/newIDE/app/src/ResourcesList/index.js index fef320c14113..35bdcd21d8a7 100644 --- a/newIDE/app/src/ResourcesList/index.js +++ b/newIDE/app/src/ResourcesList/index.js @@ -38,6 +38,7 @@ export const getDefaultResourceThumbnail = (resource: gdResource) => { case 'tilemap': case 'tileset': case 'spine': + case 'spritesheet': return 'res/actions/fichier24.png'; case 'video': return 'JsPlatform/Extensions/videoicon24.png'; diff --git a/newIDE/app/src/SceneEditor/index.js b/newIDE/app/src/SceneEditor/index.js index 9248c50b492a..248b3007bc55 100644 --- a/newIDE/app/src/SceneEditor/index.js +++ b/newIDE/app/src/SceneEditor/index.js @@ -602,6 +602,8 @@ export default class SceneEditor extends React.Component { editorDisplay.forceUpdateObjectsList(); + // Reset the "instance renderers" of objects using the resource. This means + // that instances renderers will be recreated, making sure they are up-to-date. const objectsCollector = new gd.ObjectsUsingResourceCollector( project.getResourcesManager(), resourceName