diff --git a/Content/Shaders/EditorEdge.glsl b/Content/Shaders/EditorEdge.glsl
new file mode 100644
index 00000000..62ee0f40
--- /dev/null
+++ b/Content/Shaders/EditorEdge.glsl
@@ -0,0 +1,57 @@
+VERTEX:
+#version 330
+
+layout(location=0) in vec2 a_pos;
+layout(location=1) in vec2 a_tex;
+
+
+out vec2 v_tex;
+
+void main(void)
+{
+ gl_Position = vec4(a_pos, 0, 1);
+ v_tex = a_tex;
+
+}
+
+FRAGMENT:
+#version 330
+#include Partials/Methods.gl
+
+uniform sampler2D u_objectID;
+uniform float u_selectedID;
+
+uniform vec2 u_pixel;
+uniform vec4 u_edge;
+
+in vec2 v_tex;
+out vec4 o_color;
+
+float objectID(vec2 uv)
+{
+ const float Eps = 0.0001;
+ return texture(u_objectID, clamp(uv, vec2(Eps), vec2(1.0 - Eps))).r;
+}
+
+void main(void)
+{
+ float a = objectID(v_tex + vec2(u_pixel.x, 0));
+ float b = objectID(v_tex + vec2(-u_pixel.x, 0));
+ float c = objectID(v_tex + vec2(0, u_pixel.y));
+ float d = objectID(v_tex + vec2(0, -u_pixel.y));
+
+ float it = objectID(v_tex);
+ float other =
+ a * 0.25 +
+ b * 0.25 +
+ c * 0.25 +
+ d * 0.25;
+
+ float edge = step(0.0001, other - it);
+
+ if (u_selectedID == 0 || edge == 0 || !(a == u_selectedID || b == u_selectedID || c == u_selectedID || d == u_selectedID || it == u_selectedID))
+ discard;
+
+ o_color = vec4(vec3(u_edge), 1);
+}
+
\ No newline at end of file
diff --git a/Content/Shaders/EditorWorld.glsl b/Content/Shaders/EditorWorld.glsl
new file mode 100644
index 00000000..9a1e74fc
--- /dev/null
+++ b/Content/Shaders/EditorWorld.glsl
@@ -0,0 +1,70 @@
+VERTEX:
+#version 330
+#include Partials/Methods.gl
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+uniform mat4 u_view;
+
+layout(location=0) in vec3 a_position;
+layout(location=1) in vec2 a_tex;
+layout(location=2) in vec3 a_color;
+layout(location=3) in vec3 a_normal;
+
+out vec2 v_tex;
+out vec3 v_color;
+out vec3 v_normal;
+out vec3 v_world;
+
+void main(void)
+{
+ gl_Position = u_mvp * vec4(a_position, 1.0);
+
+ v_tex = a_tex;
+ v_color = a_color;
+ v_normal = TransformNormal(a_normal, u_view * u_model);
+ v_world = vec3(u_model * vec4(a_position, 1.0));
+}
+
+FRAGMENT:
+#version 330
+#include Partials/Methods.gl
+
+uniform sampler2D u_texture;
+uniform vec4 u_color;
+uniform float u_near;
+uniform float u_far;
+uniform vec3 u_sun;
+//uniform float u_cutout;
+uniform float u_objectID;
+
+in vec2 v_tex;
+in vec3 v_normal;
+in vec3 v_color;
+in vec3 v_world;
+
+layout(location = 0) out vec4 o_color;
+layout(location = 1) out vec4 o_objectID;
+
+void main(void)
+{
+ // Get texture color
+ vec4 src = texture(u_texture, v_tex) * u_color * vec4(v_color, 1);
+
+ // TODO: only enable if you want ModelFlags.Cutout types to work, didn't end up using
+// if (src.a < u_cutout)
+// discard;
+
+ // Apply depth values
+ float depth = LinearizeDepth(gl_FragCoord.z, u_near, u_far);
+ gl_FragDepth = depth;
+
+ // Apply shading based on normal relative to the camera
+ float shade = clamp(dot(v_normal, vec3(0, 0, 1)), 0.2, 1.0);
+ src.rgb *= vec3(shade);
+
+ o_color = vec4(src.rgb, 1);
+ // TODO: Support object IDs above 255, since its just 8bits
+ // NOTE: This is still only a float output, however we need to set alpha to avoid weird blending.
+ o_objectID = vec4(u_objectID / 255.0, 0, 0, 1);
+}
\ No newline at end of file
diff --git a/Source/Actors/Actor.cs b/Source/Actors/Actor.cs
index 01e05c63..6d181a75 100644
--- a/Source/Actors/Actor.cs
+++ b/Source/Actors/Actor.cs
@@ -1,3 +1,5 @@
+using Celeste64.Mod.Editor;
+
namespace Celeste64;
public class Actor
@@ -12,6 +14,18 @@ public class Actor
protected BoundingBox worldBounds;
protected bool dirty = true;
+ public readonly Type? DefinitionType;
+ public readonly ActorDefinition? _Data;
+
+ protected Actor(Type? definitionType = null)
+ {
+ DefinitionType = definitionType;
+ if (DefinitionType != null)
+ {
+ _Data = Activator.CreateInstance(DefinitionType) as ActorDefinition;
+ }
+ }
+
///
/// Optional GroupName, used by Strawberries to check what unlocks them. Can
/// be used by other stuff for whatever.
diff --git a/Source/Actors/Attacher.cs b/Source/Actors/Attacher.cs
index 063ecf37..47b9c5e6 100644
--- a/Source/Actors/Attacher.cs
+++ b/Source/Actors/Attacher.cs
@@ -1,6 +1,6 @@
namespace Celeste64;
-public abstract class Attacher : Actor, IRidePlatforms
+public abstract class Attacher(Type? definitionType = null) : Actor(definitionType), IRidePlatforms
{
public virtual Vec3 AttachNormal => -Vec3.UnitZ;
public virtual Vec3 AttachOrigin => Position;
diff --git a/Source/Actors/Solid.cs b/Source/Actors/Solid.cs
index 4ccaf41f..b7bcf7c2 100644
--- a/Source/Actors/Solid.cs
+++ b/Source/Actors/Solid.cs
@@ -1,7 +1,84 @@
+using Celeste64.Mod;
+using Celeste64.Mod.Editor;
+using ImGuiNET;
+using System.Runtime.InteropServices;
+
namespace Celeste64;
public class Solid : Actor, IHaveModels
{
+ public class Definition : GeometryDefinition
+ {
+ protected override Matrix Transform => Matrix.CreateTranslation(Position);
+
+ [SpecialProperty(SpecialPropertyType.PositionXYZ)]
+ public Vec3 Position { get; set; }
+
+ public override Actor[] Load(World.WorldType type)
+ {
+ // Calculate bounds
+ var bounds = new BoundingBox();
+ foreach (var face in Faces)
+ {
+ var faceMin = face.Select(idx => Vertices[idx]).Aggregate(Vec3.Min);
+ var faceMax = face.Select(idx => Vertices[idx]).Aggregate(Vec3.Max);
+ bounds = new BoundingBox(Vec3.Min(bounds.Min, faceMin), Vec3.Max(bounds.Max, faceMax));
+ }
+
+ // Generate visual / collision mesh
+ var colliderVertices = new List();
+ var colliderFaces = new List();
+
+ var meshVertices = new List();
+ var meshIndices = new List();
+
+ foreach (var face in Faces)
+ {
+ int vertexIndex = colliderVertices.Count;
+ var plane = Plane.CreateFromVertices(Vertices[face[0]], Vertices[face[1]], Vertices[face[2]]);
+
+ colliderFaces.Add(new Face
+ {
+ Plane = plane,
+ VertexStart = vertexIndex,
+ VertexCount = face.Count
+ });
+
+ // Triangulate the mesh
+ for (int i = 0; i < face.Count - 2; i++)
+ {
+ meshIndices.Add(vertexIndex + 0);
+ meshIndices.Add(vertexIndex + i + 1);
+ meshIndices.Add(vertexIndex + i + 2);
+ }
+
+ // The center of the bounding box should always be <0, 0, 0>
+ colliderVertices.AddRange(face.Select(idx => Vertices[idx] - bounds.Center));
+ meshVertices.AddRange(face.Select(idx => new Vertex(
+ position: Vertices[idx] - bounds.Center,
+ texcoord: Vec2.Zero,
+ color: Vec3.One,
+ normal: plane.Normal)));
+ }
+
+ var solid = new Solid();
+ solid.Model.Mesh.SetVertices(CollectionsMarshal.AsSpan(meshVertices));
+ solid.Model.Mesh.SetIndices(CollectionsMarshal.AsSpan(meshIndices));
+ solid.Model.Materials.Add(new DefaultMaterial(Assets.Textures["wall"]));
+ solid.Model.Parts.Add(new SimpleModel.Part(0, 0, meshIndices.Count));
+
+ solid.LocalBounds = new BoundingBox(
+ colliderVertices.Aggregate(Vec3.Min),
+ colliderVertices.Aggregate(Vec3.Max)
+ );
+ solid.LocalVertices = colliderVertices.ToArray();
+ solid.LocalFaces = colliderFaces.ToArray();
+ solid.Position = Position + bounds.Center;
+
+ return [solid];
+ }
+ }
+
///
/// If we're currently solid
///
diff --git a/Source/Actors/SpikeBlock.cs b/Source/Actors/SpikeBlock.cs
index 23ee08c7..1863a08b 100644
--- a/Source/Actors/SpikeBlock.cs
+++ b/Source/Actors/SpikeBlock.cs
@@ -1,7 +1,29 @@
-namespace Celeste64;
+using Celeste64.Mod.Editor;
-public class SpikeBlock : Attacher, IHaveModels
+namespace Celeste64;
+
+public class SpikeBlock() : Attacher(typeof(Definition)), IHaveModels
{
+ public class Definition : ActorDefinition
+ {
+ [SpecialProperty(SpecialPropertyType.PositionXYZ)]
+ public Vec3 Position { get; set; } = Vec3.Zero;
+ public Vec3 Rotation { get; set; } = Vec3.Zero;
+ public Vec3 Size { get; set; } = new Vec3(50.0f, 10.0f, 100.0f);
+
+ public override Actor[] Load(World.WorldType type)
+ {
+ return [new SpikeBlock
+ {
+ Position = Position,
+ RotationXYZ = Rotation * Calc.DegToRad,
+ LocalBounds = new BoundingBox(-Size / 2.0f, Size / 2.0f)
+ }];
+ }
+ }
+
+ private Definition Data => (Definition)_Data!;
+
public SimpleModel? Model;
public Vec3 Direction;
diff --git a/Source/Data/Assets.cs b/Source/Data/Assets.cs
index 50537df1..1fa84758 100644
--- a/Source/Data/Assets.cs
+++ b/Source/Data/Assets.cs
@@ -1,4 +1,5 @@
using Celeste64.Mod;
+using Celeste64.Mod.Editor;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text;
@@ -12,7 +13,8 @@ public static class Assets
public const string AssetFolder = "Content";
public const string MapsFolder = "Maps";
- public const string MapsExtension = "map";
+ public const string MapsExtensionSledge = "map";
+ public const string MapsExtensionFuji = "bin";
public const string TexturesFolder = "Textures";
public const string TexturesExtension = "png";
@@ -112,7 +114,7 @@ public static void Load()
Music.Clear();
Audio.Unload();
- Map.ModActorFactories.Clear();
+ SledgeMap.ModActorFactories.Clear();
ModLoader.RegisterAllMods();
var maps = new ConcurrentBag<(Map, GameMod)>();
@@ -126,7 +128,7 @@ public static void Load()
// NOTE: Make sure to update ModManager.OnModFileChanged() as well, for hot-reloading to work!
var globalFs = ModManager.Instance.GlobalFilesystem;
- foreach (var (file, mod) in globalFs.FindFilesInDirectoryRecursiveWithMod(MapsFolder, MapsExtension))
+ foreach (var (file, mod) in globalFs.FindFilesInDirectoryRecursiveWithMod(MapsFolder, MapsExtensionSledge))
{
// Skip the "autosave" folder
if (file.StartsWith($"{MapsFolder}/autosave", StringComparison.OrdinalIgnoreCase))
@@ -135,7 +137,23 @@ public static void Load()
tasks.Add(Task.Run(() =>
{
if (mod.Filesystem != null && mod.Filesystem.TryOpenFile(file,
- stream => new Map(GetResourceNameFromVirt(file, MapsFolder), file, stream), out var map))
+ stream => new SledgeMap(GetResourceNameFromVirt(file, MapsFolder), file, stream), out var map))
+ {
+ maps.Add((map, mod));
+ }
+ }));
+ }
+ foreach (var (file, mod) in globalFs.FindFilesInDirectoryRecursiveWithMod(MapsFolder, MapsExtensionFuji))
+ {
+ // Skip the "autosave" folder
+ if (file.StartsWith($"{MapsFolder}/autosave", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ tasks.Add(Task.Run(() =>
+ {
+ var fullPath = mod.Filesystem is FolderModFilesystem fs ? fs.VirtToRealPath(file) : null;
+ if (mod.Filesystem != null && mod.Filesystem.TryOpenFile(file,
+ stream => new FujiMap(GetResourceNameFromVirt(file, MapsFolder), file, stream, fullPath), out var map))
{
maps.Add((map, mod));
}
@@ -251,8 +269,8 @@ public static void Load()
Levels.AddRange(levels);
}
- // if (mod.Filesystem != null && mod.Filesystem.TryOpenFile("Dialog.json",
- // stream => JsonSerializer.Deserialize(stream, DialogLineDictContext.Default.DictionaryStringListDialogLine) ?? [],
+ // if (mod.Filesystem != null && mod.Filesystem.TryOpenFile("Dialog.json",
+ // stream => JsonSerializer.Deserialize(stream, DialogLineDictContext.Default.DictionaryStringListDialogLine) ?? [],
// out var dialog))
// {
// foreach (var (key, value) in dialog)
diff --git a/Source/Data/Map.cs b/Source/Data/Map.cs
index da4a1db7..24c7b65f 100644
--- a/Source/Data/Map.cs
+++ b/Source/Data/Map.cs
@@ -1,707 +1,23 @@
-using Celeste64.Mod;
-using Sledge.Formats.Map.Formats;
-using System.Globalization;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using SledgeEntity = Sledge.Formats.Map.Objects.Entity;
-using SledgeFace = Sledge.Formats.Map.Objects.Face;
-using SledgeMap = Sledge.Formats.Map.Objects.MapFile;
-using SledgeMapObject = Sledge.Formats.Map.Objects.MapObject;
-using SledgeSolid = Sledge.Formats.Map.Objects.Solid;
-
namespace Celeste64;
-public class Map
+///
+/// Common base calls for all map types.
+/// The vanilla map parser was renamed to SledgeMap.
+///
+public abstract class Map
{
- public class ActorFactory(Func
public bool EnableQuickStart { get; set; } = true;
+ ///
+ /// Fuji Custom - Settings for the in-game editor
+ ///
+ public EditorSettings_V01 Editor { get; set; } = new();
public override JsonTypeInfo GetTypeInfo()
{
diff --git a/Source/Data/Settings.cs b/Source/Data/Settings.cs
index bafea07e..b8e3c0dd 100644
--- a/Source/Data/Settings.cs
+++ b/Source/Data/Settings.cs
@@ -75,6 +75,11 @@ public sealed class Settings
/// Fuji Custom - Whether the QuickStart feature is enabled
///
public static bool EnableQuickStart => Instance.EnableQuickStart;
+
+ ///
+ /// Fuji Custom - Settings for the in-game editor
+ ///
+ public static EditorSettings_V01 Editor => Instance.Editor;
public static void ToggleFullscreen()
{
diff --git a/Source/Data/SledgeMap.cs b/Source/Data/SledgeMap.cs
new file mode 100644
index 00000000..a0351d85
--- /dev/null
+++ b/Source/Data/SledgeMap.cs
@@ -0,0 +1,686 @@
+using Celeste64.Mod;
+using Sledge.Formats.Map.Formats;
+using Sledge.Formats.Map.Objects;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using Path = System.IO.Path;
+using SledgeEntity = Sledge.Formats.Map.Objects.Entity;
+using SledgeFace = Sledge.Formats.Map.Objects.Face;
+using SledgeMapObject = Sledge.Formats.Map.Objects.MapObject;
+using SledgeSolid = Sledge.Formats.Map.Objects.Solid;
+
+namespace Celeste64;
+
+///
+/// Vanilla Celeste 64 map parser using Sledge.
+/// Originally called Map.
+///
+public class SledgeMap : Map
+{
+ public class ActorFactory(Func create)
+ {
+ public bool UseSolidsAsBounds;
+ public bool IsSolidGeometry;
+ public Func Create = create;
+ }
+
+ private const string StartCheckpoint = "Start";
+
+ public readonly MapFile? Data;
+
+ public static readonly Dictionary ActorFactories = new()
+ {
+ ["Strawberry"] = new((map, entity) =>
+ {
+ var id = $"{map.LoadWorld!.Entry.Map}/{map.LoadStrawberryCounter}";
+ var lockedCondition = entity.GetStringProperty("targetname", string.Empty);
+ var isLocked = entity.GetIntProperty("locked", 0) > 0;
+ var playUnlockSound = entity.GetIntProperty("noUnlockSound", 0) == 0;
+ Vec3? bubbleTo = null;
+ if (map.FindTargetNode(entity.GetStringProperty("bubbleto", string.Empty), out var point))
+ bubbleTo = point;
+ map.LoadStrawberryCounter++;
+ return new Strawberry(id, isLocked, lockedCondition, playUnlockSound, bubbleTo);
+ }),
+ ["Refill"] = new((map, entity) => new Refill(entity.GetIntProperty("double", 0) > 0)),
+ ["Cassette"] = new((map, entity) => new Cassette(entity.GetStringProperty("map", string.Empty))),
+ ["Coin"] = new((map, entity) => new Coin()),
+ ["Feather"] = new((map, entity) => new Feather()),
+ ["MovingBlock"] = new((map, entity) =>
+ {
+ return new MovingBlock(
+ entity.GetIntProperty("slow", 0) > 0,
+ map.FindTargetNodeFromParam(entity, "target"));
+ })
+ { IsSolidGeometry = true },
+ ["GateBlock"] = new((map, entity) => new GateBlock(map.FindTargetNodeFromParam(entity, "target"))) { IsSolidGeometry = true },
+ ["TrafficBlock"] = new((map, entity) => new TrafficBlock(map.FindTargetNodeFromParam(entity, "target"))) { IsSolidGeometry = true },
+ ["FallingBlock"] = new((map, entity) =>
+ {
+ return new FallingBlock() { Secret = (entity.GetIntProperty("secret", 0) != 0) };
+ })
+ { IsSolidGeometry = true },
+ ["FloatyBlock"] = new((map, entity) => new FloatyBlock()) { IsSolidGeometry = true },
+ ["DeathBlock"] = new((map, entity) => new DeathBlock()) { UseSolidsAsBounds = true },
+ ["SpikeBlock"] = new((map, entity) => new SpikeBlock()) { UseSolidsAsBounds = true },
+ ["Spring"] = new((map, entity) => new Spring()),
+ ["Granny"] = new((map, entity) => new Granny()),
+ ["Badeline"] = new((map, entity) => new Badeline()),
+ ["Theo"] = new((map, entity) => new Theo()),
+ ["SignPost"] = new((map, entity) => new Signpost(entity.GetStringProperty("dialog", string.Empty))),
+ ["StaticProp"] = new((map, entity) =>
+ {
+ if (Assets.Models.TryGetValueFromFullPath(entity.GetStringProperty("model", string.Empty), out var model))
+ {
+ return new StaticProp(model,
+ entity.GetIntProperty("radius", 6),
+ entity.GetIntProperty("height", 10)
+ );
+ }
+ return null;
+ }),
+ ["BreakBlock"] = new((map, entity) =>
+ {
+ return new BreakBlock(
+ entity.GetIntProperty("bounces", 0) != 0,
+ entity.GetIntProperty("transparent", 0) != 0,
+ entity.GetIntProperty("secret", 0) != 0);
+ })
+ { IsSolidGeometry = true },
+ ["CassetteBlock"] = new((map, entity) => new CassetteBlock(entity.GetIntProperty("startOn", 1) != 0)) { IsSolidGeometry = true },
+ ["NonClimbableBlock"] = new((map, entity) => new NonClimbableBlock()) { IsSolidGeometry = true },
+ ["DoubleDashPuzzleBlock"] = new((map, entity) => new DoubleDashPuzzleBlock()) { IsSolidGeometry = true },
+ ["EndingArea"] = new((map, entity) => new EndingArea()) { UseSolidsAsBounds = true },
+ ["Fog"] = new((map, entity) => new FogRing(entity)),
+ ["FixedCamera"] = new((map, entity) => new FixedCamera(map.FindTargetNodeFromParam(entity, "target"))) { UseSolidsAsBounds = true },
+ ["IntroCar"] = new((map, entity) => new IntroCar(entity.GetFloatProperty("scale", 6))),
+ ["SolidMesh"] = new((map, entity) =>
+ {
+ if (Assets.Models.TryGetValueFromFullPath(entity.GetStringProperty("model", string.Empty), out var model))
+ {
+ return new SolidMesh(model, entity.GetFloatProperty("scale", 6));
+ }
+ return null;
+ })
+ };
+
+ internal static Dictionary ModActorFactories = new();
+
+ private readonly Dictionary currentMaterials = [];
+ private readonly Dictionary groupNames = [];
+ private readonly List<(BoundingBox Bounds, SledgeSolid Solid)> staticSolids = [];
+ private readonly List staticDecorations = [];
+ private readonly List floatingDecorations = [];
+ private readonly List entities = [];
+ public readonly HashSet Checkpoints = [];
+ private readonly BoundingBox localStaticSolidsBounds;
+ private readonly Matrix baseTransform = Matrix.CreateScale(0.2f);
+
+ // kind of a hack, but assigned during load, unset after
+ public World? LoadWorld;
+ public int LoadStrawberryCounter = 0;
+
+ public SledgeMap(string name, string virtPath, Stream stream)
+ {
+ Name = name;
+ Filename = virtPath;
+ Folder = Path.GetDirectoryName(virtPath) ?? string.Empty;
+
+ var format = new QuakeMapFormat();
+ try
+ {
+ Data = format.Read(stream);
+ }
+ catch (Exception e)
+ {
+ Data = null;
+
+ isMalformed = true;
+
+ readExceptionMessage = e.Message;
+
+ Log.Error($"Failed to load map {name}, more details below.");
+ Log.Error(e.ToString());
+ }
+
+ if (Data != null)
+ {
+ Skybox = Data.Worldspawn.GetStringProperty("skybox", "city");
+ SnowAmount = Data.Worldspawn.GetFloatProperty("snowAmount", 1);
+ SnowWind = Data.Worldspawn.GetVectorProperty("snowDirection", -Vec3.UnitZ);
+ Music = Data.Worldspawn.GetStringProperty("music", string.Empty);
+ Ambience = Data.Worldspawn.GetStringProperty("ambience", string.Empty);
+ }
+
+ void QueryObjects(SledgeMapObject obj)
+ {
+ foreach (var child in obj.Children)
+ {
+ if (child is SledgeEntity entity)
+ {
+ if (entity.ClassName == "func_group")
+ {
+ groupNames[entity.GetIntProperty("_tb_id", -1)] = entity.GetStringProperty("_tb_name", "");
+ QueryObjects(child);
+ }
+ else if (entity.ClassName == "Decoration")
+ {
+ staticDecorations.Add(entity);
+ }
+ else if (entity.ClassName == "FloatingDecoration")
+ {
+ floatingDecorations.Add(entity);
+ }
+ else if (entity.ClassName == "PlayerSpawn")
+ {
+ Checkpoints.Add(entity.GetStringProperty("name", StartCheckpoint));
+ entities.Add(entity);
+ }
+ else
+ {
+ entities.Add(entity);
+ }
+ }
+ else if (child is SledgeSolid solid)
+ {
+ staticSolids.Add((CalculateSolidBounds(solid), solid));
+ QueryObjects(child);
+ }
+ else
+ {
+ QueryObjects(child);
+ }
+ }
+ }
+
+ if (Data != null)
+ {
+ QueryObjects(Data.Worldspawn);
+ }
+
+ // figure out entire bounds of static solids (localized)
+ if (staticSolids.Count > 0)
+ {
+ localStaticSolidsBounds = staticSolids[0].Bounds;
+ for (int i = 1; i < staticSolids.Count; i++)
+ localStaticSolidsBounds = localStaticSolidsBounds.Conflate(staticSolids[i].Bounds);
+ }
+
+ // shuffle floating decorations
+ {
+ var rng = new Rng();
+ var n = floatingDecorations.Count;
+ while (n > 1)
+ {
+ int k = rng.Int(n--);
+ (floatingDecorations[k], floatingDecorations[n]) =
+ (floatingDecorations[n], floatingDecorations[k]);
+ }
+ }
+
+ // TODO:
+ // A LOT more data could be cached here instead of done every time the map is Loaded into a World
+ // ....
+ }
+
+ public override void Load(World world)
+ {
+ LoadWorld = world;
+ LoadStrawberryCounter = 0;
+
+ // create materials for each texture type so they can be shared by each surface
+ currentMaterials.Clear();
+ foreach (var it in Assets.Textures)
+ {
+ if (!currentMaterials.ContainsKey(it.Key))
+ {
+ currentMaterials.Add(it.Key, new DefaultMaterial(Assets.Textures[it.Key]));
+ }
+ }
+
+ // load all static solids
+ // group them in big chunks (this helps collision tests so we can cull entire objects based on their bounding box)
+ if (staticSolids.Count > 0)
+ {
+ var combined = new List();
+ var available = new List<(BoundingBox Bounds, SledgeSolid Solid)>(); available.AddRange(staticSolids);
+ var bounds = localStaticSolidsBounds;
+
+ // split into a grid so we don't have one massive solid
+ var chunk = new Vec3(1000, 1000, 1000);
+ for (int x = 0; x < bounds.Size.X / chunk.X; x++)
+ for (int y = 0; y < bounds.Size.Y / chunk.Y; y++)
+ for (int z = 0; z < bounds.Size.Z / chunk.Z; z++)
+ {
+ var box = new BoundingBox(bounds.Min, bounds.Min + chunk * new Vec3(1 + x, 1 + y, 1 + z));
+
+ for (int i = available.Count - 1; i >= 0; i--)
+ if (box.Contains(available[i].Bounds.Center))
+ {
+ combined.Add(available[i].Solid);
+ available.RemoveAt(i);
+ }
+
+ var result = new Solid();
+ GenerateSolid(result, combined);
+ world.Add(result);
+ combined.Clear();
+ }
+ }
+
+ // load all decorations into one big model *shrug*
+ {
+ var decorations = new List();
+ var decoration = new Decoration();
+ foreach (var entity in staticDecorations)
+ CollectSolids(entity, decorations);
+ decoration.LocalBounds = CalculateSolidBounds(decorations, baseTransform);
+ GenerateModel(decoration.Model, decorations, baseTransform);
+ world.Add(decoration);
+ }
+
+ // load floating decorations into 4-ish groups randomly
+ if (floatingDecorations.Count > 0)
+ {
+ int from = 0;
+ while (from < floatingDecorations.Count)
+ {
+ var decorations = new List();
+ var decoration = new FloatingDecoration();
+
+ var to = Math.Min(from + floatingDecorations.Count / 4, floatingDecorations.Count);
+ for (int j = from; j < to; j++)
+ CollectSolids(floatingDecorations[j], decorations);
+ from = to;
+
+ decoration.LocalBounds = CalculateSolidBounds(decorations, baseTransform);
+ GenerateModel(decoration.Model, decorations, baseTransform);
+ world.Add(decoration);
+ }
+ }
+
+ // load actors
+ foreach (var entity in entities)
+ LoadActor(world, entity);
+
+ Log.Info($"Strawb Count: {LoadStrawberryCounter}");
+ LoadStrawberryCounter = 0;
+ LoadWorld = null;
+
+ ModManager.Instance.OnMapLoaded(this);
+ }
+
+ private void LoadActor(World world, SledgeEntity entity)
+ {
+ if (ModActorFactories.TryGetValue(entity.ClassName, out var modfactory))
+ {
+ var it = modfactory.Create(this, entity);
+ if (it != null)
+ HandleActorCreation(world, entity, it, modfactory);
+ }
+ else if (entity.ClassName == "PlayerSpawn")
+ {
+ var name = entity.GetStringProperty("name", StartCheckpoint);
+
+ // spawns ther player if the world entry is this checkpoint
+ // OR the world entry has no checkpoint and we're the start
+ // OR the world entry checkpoint is misconfigured and we're the start
+ var spawnsPlayer =
+ (world.Entry.CheckPoint == name) ||
+ (string.IsNullOrEmpty(world.Entry.CheckPoint) && name == StartCheckpoint) ||
+ (!Checkpoints.Contains(world.Entry.CheckPoint) && name == StartCheckpoint);
+
+ if (spawnsPlayer)
+ HandleActorCreation(world, entity, new Player(), null);
+
+ if (name != StartCheckpoint)
+ HandleActorCreation(world, entity, new Checkpoint(name), null);
+
+ }
+ else if (ActorFactories.TryGetValue(entity.ClassName, out var factory))
+ {
+ var it = factory.Create(this, entity);
+ if (it != null)
+ HandleActorCreation(world, entity, it, factory);
+ }
+ }
+
+ public void HandleActorCreation(World world, SledgeEntity entity, Actor it, ActorFactory? factory)
+ {
+ if (it is Solid solid)
+ {
+ if ((factory?.IsSolidGeometry ?? false))
+ {
+ List collection = [];
+ CollectSolids(entity, collection);
+ GenerateSolid(solid, collection);
+ }
+ if (entity.Properties.ContainsKey("climbable"))
+ solid.Climbable = entity.GetStringProperty("climbable", "true") != "false";
+ }
+
+
+ if (entity.Properties.ContainsKey("origin"))
+ it.Position = Vec3.Transform(entity.GetVectorProperty("origin", Vec3.Zero), baseTransform);
+
+ if (entity.Properties.ContainsKey("_tb_group") &&
+ groupNames.TryGetValue(entity.GetIntProperty("_tb_group", -1), out var groupName))
+ it.GroupName = groupName;
+
+
+ // Fuji Custom - Allows for rotation in maps using either a vec3 rotation property
+ // Or 3 different Angle properties. This is to support compatibility with existing vanilla actors who only use 1 angle property
+ Vec3 rotationXYZ = new(0, 0, -MathF.PI / 2);
+ if (entity.Properties.ContainsKey("rotation") && entity.Properties["rotation"].Split(' ').Length == 3)
+ {
+ var value = entity.Properties["rotation"];
+ var spl = value.Split(' ');
+ if (spl.Length == 3)
+ {
+ if (float.TryParse(spl[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
+ rotationXYZ.X = x * Calc.DegToRad;
+ if (float.TryParse(spl[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
+ rotationXYZ.Y = y * Calc.DegToRad;
+ if (float.TryParse(spl[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var z))
+ rotationXYZ.Z = z * Calc.DegToRad - MathF.PI / 2;
+ }
+ }
+ else
+ {
+ if (entity.Properties.ContainsKey("anglepitch"))
+ rotationXYZ.X = entity.GetIntProperty("anglepitch", 0) * Calc.DegToRad;
+ if (entity.Properties.ContainsKey("angleroll"))
+ rotationXYZ.Y = entity.GetIntProperty("angleroll", 0) * Calc.DegToRad;
+ if (entity.Properties.ContainsKey("angle"))
+ rotationXYZ.Z = entity.GetIntProperty("angle", 0) * Calc.DegToRad - MathF.PI / 2;
+ }
+ it.RotationXYZ = rotationXYZ;
+
+
+ if (factory?.UseSolidsAsBounds ?? false)
+ {
+ BoundingBox bounds = new();
+ if (entity.Children.FirstOrDefault() is SledgeSolid sol)
+ bounds = CalculateSolidBounds(sol, baseTransform);
+
+ it.Position = bounds.Center;
+ bounds.Min -= it.Position;
+ bounds.Max -= it.Position;
+ it.LocalBounds = bounds;
+ }
+
+ world.Add(it);
+ }
+
+ private SledgeEntity? FindTargetEntity(SledgeMapObject obj, string targetName)
+ {
+ if (string.IsNullOrEmpty(targetName))
+ return null;
+
+ if (obj is SledgeEntity en && en.GetStringProperty("targetname", string.Empty) == targetName)
+ return en;
+
+ foreach (var child in obj.Children)
+ {
+ if (FindTargetEntity(child, targetName) is { } it)
+ return it;
+ }
+
+ return null;
+ }
+
+ public bool FindTargetNode(string name, out Vec3 pos)
+ {
+ if (Data == null)
+ {
+ pos = Vec3.Zero;
+ return false;
+ }
+
+ if (FindTargetEntity(Data.Worldspawn, name) is { } target)
+ {
+ pos = Vec3.Transform(target.GetVectorProperty("origin", Vec3.Zero), baseTransform);
+ return true;
+ }
+ pos = Vec3.Zero;
+ return false;
+ }
+
+ public Vec3 FindTargetNodeFromParam(SledgeEntity en, string name)
+ {
+ if (FindTargetNode(en.GetStringProperty(name, string.Empty), out var pos))
+ return pos;
+ return Vec3.Zero;
+ }
+
+ private void CollectSolids(SledgeMapObject obj, List into)
+ {
+ foreach (var child in obj.Children)
+ {
+ if (child is SledgeSolid solid)
+ into.Add(solid);
+ CollectSolids(child, into);
+ }
+ }
+
+ private BoundingBox CalculateSolidBounds(SledgeSolid sol, in Matrix? transform = null)
+ {
+ Vec3 min = default, max = default;
+
+ if (sol.Faces.Count > 0 && sol.Faces[0].Vertices.Count > 0)
+ min = max = sol.Faces[0].Vertices[0];
+
+ foreach (var face in sol.Faces)
+ foreach (var vert in face.Vertices)
+ {
+ min = Vec3.Min(min, vert);
+ max = Vec3.Max(max, vert);
+ }
+
+ if (transform.HasValue)
+ return BoundingBox.Transform(new(min, max), transform.Value);
+ else
+ return new BoundingBox(min, max);
+ }
+
+ private BoundingBox CalculateSolidBounds(List collection, in Matrix transform)
+ {
+ BoundingBox box = new();
+
+ if (collection.Count > 0)
+ box = CalculateSolidBounds(collection[0]);
+
+ for (int i = 1; i < collection.Count; i++)
+ box = box.Conflate(CalculateSolidBounds(collection[i]));
+
+ return BoundingBox.Transform(box, transform);
+ }
+
+ private void GenerateModel(SimpleModel model, List collection, in Matrix transform)
+ {
+ var used = Pool.Get>(); used.Clear();
+
+ // find all used materials
+ foreach (var solid in collection)
+ foreach (var face in solid.Faces)
+ {
+ if (face.TextureName.StartsWith("__") || face.TextureName == "TB_empty" || face.TextureName == "invisible")
+ continue;
+
+ if (!used.Contains(face.TextureName))
+ {
+ used.Add(face.TextureName);
+ if (currentMaterials.ContainsKey(face.TextureName))
+ model.Materials.Add(currentMaterials[face.TextureName]);
+ else
+ model.Materials.Add(currentMaterials["wall"]);
+ }
+ }
+
+ if (used.Count <= 0)
+ {
+ Pool.Return(used);
+ return;
+ }
+
+ var meshVertices = Pool.Get>();
+ var meshIndices = Pool.Get>();
+
+ // merge all faces that share materials together
+ for (int n = 0; n < model.Materials.Count; n++)
+ {
+ var mat = model.Materials[n];
+ var start = meshIndices.Count;
+ var texture = mat.Texture!;
+
+ // add all faces with this material
+ foreach (var solid in collection)
+ foreach (var face in solid.Faces)
+ {
+ if (face.TextureName.StartsWith("__") || face.TextureName == "TB_empty" || face.TextureName == "invisible")
+ continue;
+ if (face.TextureName != texture.Name && texture.Name != "wall")
+ continue;
+
+ var vertexIndex = meshVertices.Count;
+ var plane = Plane.Normalize(Plane.Transform(face.Plane, transform));
+ CalculateRotatedUV(face, out var rotatedUAxis, out var rotatedVAxis);
+
+ // add face vertices
+ for (int i = 0; i < face.Vertices.Count; i++)
+ {
+ var pos = Vec3.Transform(face.Vertices[i], transform);
+ var uv = CalculateUV(face, face.Vertices[i], texture.Size, rotatedUAxis, rotatedVAxis);
+ meshVertices.Add(new Vertex(pos, uv, Color.White, plane.Normal));
+ }
+
+ // add mesh indices
+ for (int i = 0; i < face.Vertices.Count - 2; i++)
+ {
+ meshIndices.Add(vertexIndex + 0);
+ meshIndices.Add(vertexIndex + i + 1);
+ meshIndices.Add(vertexIndex + i + 2);
+ }
+ }
+
+ // add this part of the model
+ model.Parts.Add(new()
+ {
+ MaterialIndex = n,
+ IndexStart = start,
+ IndexCount = meshIndices.Count - start
+ });
+ }
+
+ model.Mesh.SetVertices(CollectionsMarshal.AsSpan(meshVertices));
+ model.Mesh.SetIndices(CollectionsMarshal.AsSpan(meshIndices));
+
+ Pool.Return(meshVertices);
+ Pool.Return(meshIndices);
+ Pool.Return(used);
+ }
+
+ private void GenerateSolid(Solid into, List collection)
+ {
+ if (collection.Count <= 0)
+ return;
+
+ // get bounds
+ var transform = baseTransform;
+ var bounds = CalculateSolidBounds(collection, transform);
+ var center = bounds.Center;
+ transform *= Matrix.CreateTranslation(-center);
+
+ // create the model
+ GenerateModel(into.Model, collection, transform);
+
+ // get lists to build everything
+ var colliderVertices = Pool.Get>();
+ var colliderFaces = Pool.Get>();
+
+ // find all used materials
+ foreach (var solid in collection)
+ foreach (var face in solid.Faces)
+ {
+ if (face.TextureName.StartsWith("__") || face.TextureName == "TB_empty")
+ continue;
+
+ // add collider vertices
+ var vertexIndex = colliderVertices.Count;
+ var last = Vec3.Zero;
+ for (int i = 0; i < face.Vertices.Count; i++)
+ {
+ // skip collider vertices that are too close together ...
+ var it = Vec3.Transform(face.Vertices[i], transform);
+ if (i == 0 || (last - it).LengthSquared() > 1)
+ colliderVertices.Add(last = it);
+ }
+
+ // add collider face
+ if (colliderVertices.Count > vertexIndex)
+ {
+ colliderFaces.Add(new()
+ {
+ Plane = Plane.Normalize(Plane.Transform(face.Plane, transform)),
+ VertexStart = vertexIndex,
+ VertexCount = colliderVertices.Count - vertexIndex
+ });
+ }
+ }
+
+ // set up values
+ if (colliderVertices.Count > 0)
+ {
+ into.LocalBounds = new BoundingBox(
+ colliderVertices.Aggregate(Vec3.Min),
+ colliderVertices.Aggregate(Vec3.Max)
+ );
+ into.LocalVertices = [.. colliderVertices];
+ into.LocalFaces = [.. colliderFaces];
+ into.Position = center;
+ }
+
+ Pool.Return(colliderVertices);
+ Pool.Return(colliderFaces);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void CalculateRotatedUV(in SledgeFace face, out Vec3 rotatedUAxis, out Vec3 rotatedVAxis)
+ {
+ // Determine the dominant axis of the normal vector
+ static Vec3 GetRotationAxis(Vec3 normal)
+ {
+ var abs = Vec3.Abs(normal);
+ if (abs.X > abs.Y && abs.X > abs.Z)
+ return Vec3.UnitX;
+ else if (abs.Y > abs.Z)
+ return Vec3.UnitY;
+ else
+ return Vec3.UnitZ;
+ }
+
+ // Apply scaling to the axes
+ var scaledUAxis = face.UAxis / face.XScale;
+ var scaledVAxis = face.VAxis / face.YScale;
+
+ // Determine the rotation axis based on the face normal
+ var rotationAxis = GetRotationAxis(face.Plane.Normal);
+ var rotationMatrix = Matrix.CreateFromAxisAngle(rotationAxis, face.Rotation * Calc.DegToRad);
+ rotatedUAxis = Vec3.Transform(scaledUAxis, rotationMatrix);
+ rotatedVAxis = Vec3.Transform(scaledVAxis, rotationMatrix);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Vec2 CalculateUV(in SledgeFace face, in Vec3 vertex, in Vec2 textureSize, in Vec3 rotatedUAxis, in Vec3 rotatedVAxis)
+ {
+ Vec2 uv;
+ uv.X = vertex.X * rotatedUAxis.X + vertex.Y * rotatedUAxis.Y + vertex.Z * rotatedUAxis.Z;
+ uv.Y = vertex.X * rotatedVAxis.X + vertex.Y * rotatedVAxis.Y + vertex.Z * rotatedVAxis.Z;
+ uv.X += face.XShift;
+ uv.Y += face.YShift;
+ uv.X /= textureSize.X;
+ uv.Y /= textureSize.Y;
+ return uv;
+ }
+}
diff --git a/Source/Game.cs b/Source/Game.cs
index 456d1a9f..241f8a0c 100644
--- a/Source/Game.cs
+++ b/Source/Game.cs
@@ -1,4 +1,5 @@
-using Celeste64.Mod;
+using Celeste64.Mod;
+using Celeste64.Mod.Editor;
using Celeste64.Mod.Patches;
using System.Diagnostics;
using System.Text;
@@ -78,7 +79,7 @@ public static float ResolutionScale
private static Game? instance;
public static Game Instance => instance ?? throw new Exception("Game isn't running");
- private readonly Stack scenes = new();
+ internal readonly Stack scenes = new();
private Target target = new(Width, Height, [TextureFormat.Color, TextureFormat.Depth24Stencil8]);
private readonly Batcher batcher = new();
private Transition transition;
@@ -95,7 +96,7 @@ public static float ResolutionScale
public SoundHandle? AmbienceWav;
public SoundHandle? MusicWav;
- public Scene? Scene => scenes.TryPeek(out var scene) ? scene : null;
+ public static Scene? Scene => Instance.scenes.TryPeek(out var scene) ? scene : null;
public World? World => Scene as World;
internal bool NeedsReload = false;
@@ -185,7 +186,7 @@ public void UnsafelySetScene(Scene next)
private void HandleError(Exception e)
{
- if (scenes.Peek() is GameErrorMessage)
+ if (Scene is GameErrorMessage)
{
throw e; // If we're already on the error message screen, accept our fate: it's a fatal crash!
}
@@ -440,7 +441,18 @@ internal void ReloadAssets()
if (IsMidTransition)
return;
- if (scene is World world)
+ if (scene is EditorWorld editor)
+ {
+ Goto(new Transition()
+ {
+ Mode = Transition.Modes.Replace,
+ Scene = () => new EditorWorld(editor.Entry),
+ ToPause = true,
+ ToBlack = new AngledWipe(),
+ PerformAssetReload = true
+ });
+ }
+ else if (scene is World world)
{
Goto(new Transition()
{
diff --git a/Source/Mod/Core/GameMod.cs b/Source/Mod/Core/GameMod.cs
index c01bc8c4..7948ec1f 100644
--- a/Source/Mod/Core/GameMod.cs
+++ b/Source/Mod/Core/GameMod.cs
@@ -54,7 +54,7 @@ public abstract class GameMod
// This is here to give mods easier access to these objects, so they don't have to get them themselves
// Warning, these may be null if they haven't been initialized yet, so you should always do a null check before using them.
public Game? Game => Game.Instance;
- public World? World => Game?.World;
+ public World? World => Game.Scene as World;
public Map? Map => World?.Map;
public Player? Player => World?.Get();
@@ -494,11 +494,11 @@ public void EnableDependencies()
///
///
///
- public void AddActorFactory(string name, Map.ActorFactory factory)
+ public void AddActorFactory(string name, SledgeMap.ActorFactory factory)
{
- if (Map.ModActorFactories.TryAdd(name, factory))
+ if (SledgeMap.ModActorFactories.TryAdd(name, factory))
{
- OnUnloadedCleanup += () => Map.ModActorFactories.Remove(name);
+ OnUnloadedCleanup += () => SledgeMap.ModActorFactories.Remove(name);
}
else
{
diff --git a/Source/Mod/Core/ModManager.cs b/Source/Mod/Core/ModManager.cs
index 87eb1f3f..50dfb2c4 100644
--- a/Source/Mod/Core/ModManager.cs
+++ b/Source/Mod/Core/ModManager.cs
@@ -97,7 +97,7 @@ internal void OnModFileChanged(ModFileChangedCtx ctx)
// Important assets taken from Assets.Load()
// TODO: Support non-toplevel mods?
- if ((dir.StartsWith(Assets.MapsFolder) && extension == $".{Assets.MapsExtension}" && !dir.StartsWith($"{Assets.MapsFolder}/autosave")) ||
+ if ((dir.StartsWith(Assets.MapsFolder) && extension is $".{Assets.MapsExtensionSledge}" or $".{Assets.MapsExtensionFuji}" && !dir.StartsWith($"{Assets.MapsFolder}/autosave")) ||
(dir.StartsWith(Assets.TexturesFolder) && extension == $".{Assets.TexturesExtension}") ||
(dir.StartsWith(Assets.FacesFolder) && extension == $".{Assets.FacesExtension}") ||
(dir.StartsWith(Assets.ModelsFolder) && extension == $".{Assets.ModelsExtension}") ||
diff --git a/Source/Mod/Data/PersistedData/EditorSettings_V01.cs b/Source/Mod/Data/PersistedData/EditorSettings_V01.cs
new file mode 100644
index 00000000..1389788e
--- /dev/null
+++ b/Source/Mod/Data/PersistedData/EditorSettings_V01.cs
@@ -0,0 +1,39 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace Celeste64;
+
+public sealed class EditorSettings_V01 : PersistedData
+{
+ public override int Version => 1;
+
+ // Settings
+ public bool PlayMusic { get; set; } = false;
+ public bool PlayAmbience { get; set; } = false;
+
+ // View
+ public bool RenderSnow { get; set; } = false;
+ public bool RenderSkybox { get; set; } = true;
+ public bool PlayAnimations { get; set; } = true;
+
+ public const float MinRenderDistance = 500.0f;
+ public const float MaxRenderDistance = 5000.0f;
+ private float renderDistance = 4000.0f;
+ public float RenderDistance
+ {
+ get => renderDistance;
+ set => renderDistance = Math.Clamp(value, MinRenderDistance, MaxRenderDistance);
+ }
+
+ public enum Resolution { Game = 0, Double = 1, HD = 2, Native = 3 }
+ public Resolution ResolutionType { get; set; } = Resolution.Double;
+
+ public override JsonTypeInfo GetTypeInfo()
+ {
+ return EditorSettings_V01Context.Default.EditorSettings_V01;
+ }
+}
+
+[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true, UseStringEnumConverter = true)]
+[JsonSerializable(typeof(EditorSettings_V01))]
+internal partial class EditorSettings_V01Context : JsonSerializerContext { }
diff --git a/Source/Mod/Editor/Definition/ActorDefinition.cs b/Source/Mod/Editor/Definition/ActorDefinition.cs
new file mode 100644
index 00000000..6376ebc5
--- /dev/null
+++ b/Source/Mod/Editor/Definition/ActorDefinition.cs
@@ -0,0 +1,12 @@
+namespace Celeste64.Mod.Editor;
+
+public abstract class ActorDefinition
+{
+ public event Action OnUpdated = () => {};
+ internal void Updated() => OnUpdated();
+
+ public bool Dirty = true;
+ public SelectionType[] SelectionTypes { get; init; } = [];
+
+ public abstract Actor[] Load(World.WorldType type);
+}
diff --git a/Source/Mod/Editor/Definition/CustomPropertyAttribute.cs b/Source/Mod/Editor/Definition/CustomPropertyAttribute.cs
new file mode 100644
index 00000000..5d76b4b8
--- /dev/null
+++ b/Source/Mod/Editor/Definition/CustomPropertyAttribute.cs
@@ -0,0 +1,28 @@
+using System.Reflection;
+
+namespace Celeste64.Mod.Editor;
+
+public interface ICustomProperty
+{
+ public static abstract void Serialize(T value, BinaryWriter writer);
+ public static abstract T Deserialize(BinaryReader reader);
+ public static abstract bool RenderGui(ref T value);
+}
+
+[AttributeUsage(AttributeTargets.Property)]
+public class CustomPropertyAttribute(Type type) : Attribute
+{
+ private readonly MethodInfo m_Serialize = type.GetMethod(nameof(ICustomProperty