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 create) - { - public bool UseSolidsAsBounds; - public bool IsSolidGeometry; - public Func Create = create; - } - - private const string StartCheckpoint = "Start"; - - public readonly string Name; - public readonly string Filename; - public readonly string Folder; - public readonly SledgeMap? Data; - public readonly string? Skybox; - public readonly float SnowAmount; - public readonly Vec3 SnowWind; - public readonly string? Music; - public readonly string? Ambience; - public readonly int? ChunkSize; - - public readonly bool isMalformed = false; - public readonly string? readExceptionMessage; - - 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 }, - ["LoadingZone"] = new((map, entity) => new LoadingZone( - entity.GetStringProperty("map", map.Name), - entity.GetStringProperty("checkpointname", string.Empty), - entity.GetIntProperty("issubmap", 0) > 0 - )) - { 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 Map(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); - ChunkSize = Data.Worldspawn.GetIntProperty("chunksize", 1000); - } - - 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 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(ChunkSize ?? 1000, ChunkSize ?? 1000, ChunkSize ?? 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); - - 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")) - { - string climbable = entity.GetStringProperty("climbable", "true").ToLower(); - solid.Climbable = climbable != "false" && climbable != "0"; - } - - if (entity.Properties.ContainsKey("canwalljump")) - { - string canwalljump = entity.GetStringProperty("canwalljump", "true").ToLower(); - solid.AllowWallJumps = canwalljump != "false" && canwalljump != "0"; - } - } - - 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("angles") && entity.Properties["angles"].Split(' ').Length == 3) - { - var value = entity.Properties["angles"]; - var spl = value.Split(' '); - if (spl.Length == 3) - { - if (float.TryParse(spl[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) - rotationXYZ.Y = y * Calc.DegToRad; - if (float.TryParse(spl[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) - rotationXYZ.Z = z * Calc.DegToRad - MathF.PI / 2; - if (float.TryParse(spl[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) - rotationXYZ.X = x * Calc.DegToRad; - } - } - 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; - } + public bool isMalformed { get; init; } = false; + public string? readExceptionMessage { get; init; } = null; - // Apply scaling to the axes - var scaledUAxis = face.UAxis / face.XScale; - var scaledVAxis = face.VAxis / face.YScale; + public string Name { get; init; } + public string Filename { get; init; } + public string Folder { get; init; } - // 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); - } + public string? Skybox { get; internal set; } + public float SnowAmount { get; internal set; } + public Vec3 SnowWind { get; internal set; } + public string? Music { get; internal set; } + public string? Ambience { get; internal set; } - [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; - } + public abstract void Load(World world); } diff --git a/Source/Data/PersistedData/Settings_V01.cs b/Source/Data/PersistedData/Settings_V01.cs index 52fae45d..31044330 100644 --- a/Source/Data/PersistedData/Settings_V01.cs +++ b/Source/Data/PersistedData/Settings_V01.cs @@ -70,6 +70,10 @@ public sealed class Settings_V01 : PersistedData /// 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.Serialize), BindingFlags.Public | BindingFlags.Static) + ?? throw new Exception($"Custom property definition {type} does not inherit from ICustomProperty"); + + private readonly MethodInfo m_Deserialize = type.GetMethod(nameof(ICustomProperty.Deserialize), BindingFlags.Public | BindingFlags.Static) + ?? throw new Exception($"Custom property definition {type} does not inherit from ICustomProperty"); + + private readonly MethodInfo m_RenderGui = type.GetMethod(nameof(ICustomProperty.RenderGui), BindingFlags.Public | BindingFlags.Static) + ?? throw new Exception($"Custom property definition {type} does not inherit from ICustomProperty"); + + internal void Serialize(object value, BinaryWriter writer) => m_Serialize.Invoke(null, [value, writer]); + internal object Deserialize(BinaryReader reader) => m_Deserialize.Invoke(null, [reader])!; + internal bool RenderGui(ref object value) => (bool)m_RenderGui.Invoke(null, [value])!; +} + diff --git a/Source/Mod/Editor/Definition/GeometryDefinition.cs b/Source/Mod/Editor/Definition/GeometryDefinition.cs new file mode 100644 index 00000000..b59d7f07 --- /dev/null +++ b/Source/Mod/Editor/Definition/GeometryDefinition.cs @@ -0,0 +1,87 @@ +namespace Celeste64.Mod.Editor; + +public abstract class GeometryDefinition : ActorDefinition +{ + [IgnoreProperty] + protected abstract Matrix Transform { get; } + + static float size => 10.0f; + + public List Vertices { get; set; } = [ + new Vec3(-size, size, size), new Vec3(size, size, size), new Vec3(size, size, -size), new Vec3(-size, size, -size), + new Vec3(-size, -size, size), new Vec3(size, -size, size), new Vec3(size, -size, -size), new Vec3(-size, -size, -size) + ]; + + public List> Faces { get; set; } = [ + [0, 1, 2, 3], // Front + [7, 6, 5, 4], // Back + [4, 5, 1, 0], // Top + [3, 2, 6, 7], // Bottom + [1, 5, 6, 2], // Left + [4, 0, 3, 7], // Right + ]; + + public GeometryDefinition() + { + SelectionTypes = [ + new VertexSelectionType(this), + ]; + } + + public class VertexSelectionType(GeometryDefinition def) : SelectionType + { + private readonly List targets = []; + // TODO: Support other gizmos depending on current tool. Maybe with some restriction on which tools are allowed tho + private PositionGizmo? vertexGizmo = null; + + public override IEnumerable Targets => targets; + public override IEnumerable Gizmos => vertexGizmo is null ? [] : [vertexGizmo]; + + public override void Awake() + { + EditorWorld.Current.OnTargetSelected += target => + { + // Deselect if something else was selected + if (target is null || !targets.Contains(target) && !(vertexGizmo?.SelectionTargets.Contains(target) ?? false)) + vertexGizmo = null; + }; + + def.OnUpdated += () => + { + targets.Clear(); + // TODO: Detect when geometry changed? + // vertexGizmo = null; + + var transform = def.Transform; + if (!Matrix.Invert(transform, out var inverseTransform)) + return; + + const float selectionRadius = 1.0f; + + foreach (var face in def.Faces) + { + foreach (int idx in face) + { + targets.Add(new SimpleSelectionTarget(transform, new BoundingBox(def.Vertices[idx], selectionRadius * 2.0f)) + { + // OnHovered = () => Log.Info($"Hovered vertex {vertex}"), + OnSelected = () => + { + Log.Info($"Selected vertex {idx} {def.Vertices[idx]}"); + vertexGizmo = new PositionGizmo( + () => Vec3.Transform(def.Vertices[idx], transform), + v => + { + def.Vertices[idx] = Vec3.Transform(v, inverseTransform); + def.Dirty = true; + }, + scale: 0.5f); + }, + // OnDragged = (mouseDelta, mouseRay) => Log.Info($"Dragged vertex {vertex} ({mouseDelta}, {mouseRay})"), + }); + } + } + }; + } + } +} diff --git a/Source/Mod/Editor/Definition/IgnorePropertyAttribute.cs b/Source/Mod/Editor/Definition/IgnorePropertyAttribute.cs new file mode 100644 index 00000000..be933880 --- /dev/null +++ b/Source/Mod/Editor/Definition/IgnorePropertyAttribute.cs @@ -0,0 +1,4 @@ +namespace Celeste64.Mod.Editor; + +[AttributeUsage(AttributeTargets.Property)] +public class IgnorePropertyAttribute : Attribute; diff --git a/Source/Mod/Editor/Definition/SpecialPropertyAttribute.cs b/Source/Mod/Editor/Definition/SpecialPropertyAttribute.cs new file mode 100644 index 00000000..18327152 --- /dev/null +++ b/Source/Mod/Editor/Definition/SpecialPropertyAttribute.cs @@ -0,0 +1,12 @@ +namespace Celeste64.Mod.Editor; + +public enum SpecialPropertyType +{ + PositionXYZ, +} + +[AttributeUsage(AttributeTargets.Property)] +public class SpecialPropertyAttribute(SpecialPropertyType value) : Attribute +{ + public readonly SpecialPropertyType Value = value; +} diff --git a/Source/Mod/Editor/EditorWorld.cs b/Source/Mod/Editor/EditorWorld.cs new file mode 100644 index 00000000..6c34730d --- /dev/null +++ b/Source/Mod/Editor/EditorWorld.cs @@ -0,0 +1,791 @@ +using Celeste64.Mod.Helpers; +using System.Collections.ObjectModel; +using System.Reflection; + +namespace Celeste64.Mod.Editor; + +public class EditorWorld : World +{ + internal readonly ImGuiHandler[] Handlers = [ + new EditorMenuBar(), + + new ToolSelectionWindow(), + new ActorSelectionWindow(), + + new EditActorWindow(), + new EnvironmentSettingsWindow(), + ]; + + internal readonly Tool[] Tools = [ + new MoveTool(), + new PlaceActorTool(), + ]; + + public static EditorWorld Current => (Game.Scene as EditorWorld)!; + + public IReadOnlyList Definitions => Map is FujiMap fujiMap ? fujiMap.Definitions : []; + public ReadOnlyDictionary ActorsFromDefinition => actorsFromDefinition.AsReadOnly(); + public ReadOnlyDictionary DefinitionFromActors => definitionFromActors.AsReadOnly(); + + public event Action? OnDefinitionSelected; + public event Action OnTargetSelected = target => {}; + + private ActorDefinition? selectedDefinition = null; + public ActorDefinition? Selected + { + private set + { + if (selectedDefinition == value) + return; + + selectedDefinition = value; + OnDefinitionSelected?.Invoke(value); + } + get => selectedDefinition; + } + + public Actor[] SelectedActors => Selected is not null && ActorsFromDefinition.TryGetValue(Selected, out var actors) ? actors : []; + + private readonly Dictionary actorsFromDefinition = new(); + private readonly Dictionary definitionFromActors = new(); + + private Tool? currentTool; + public Tool CurrentTool + { + get => currentTool!; + internal set + { + currentTool?.OnDeselectTool(this); + currentTool = value; + currentTool.OnSelectTool(this); + } + } + + private Vec3 cameraPos = new(0, -10, 0); + private Vec2 cameraRot = new(0, 0); + + private readonly Batcher3D batch3D = new(); + + public Vec3 MouseRay { get; private set; } + + private SelectionTarget? dragTarget = null; + private Vec2 dragMouseStart = Vec2.Zero; + + internal EditorWorld(EntryInfo entry) : base(entry) + { + Camera.NearPlane = 0.1f; // Allow getting closer to objects + Camera.FOVMultiplier = 1.25f; // Higher FOV feels better in the editor + + // Store previous game resolution to restore it when exiting + previousScale = Game.ResolutionScale; + + // Load environment + RefreshEnvironment(); + + // Map gets implicitly loaded, since our Definitions are taken directly from it + foreach (var def in Definitions) + { + // Mark all definitions as dirty to ensure they will get added + def.Dirty = true; + } + + // Select default tool + // TODO: Maybe save the last selected tool from the previous session? + CurrentTool = Tools[0]; + } + + public void AddDefinition(ActorDefinition definition) + { + if (Map is not FujiMap fujiMap) + return; + + fujiMap.Definitions.Add(definition); + + // Make sure it'll get loaded + definition.Dirty = true; + + foreach (var selectionType in definition.SelectionTypes) + selectionType.Awake(); + } + + public void RemoveDefinition(ActorDefinition definition) + { + if (Map is not FujiMap fujiMap) + return; + + fujiMap.Definitions.Remove(definition); + if (actorsFromDefinition.Remove(definition, out var actors)) + { + foreach (var actor in actors) + { + definitionFromActors.Remove(actor); + Destroy(actor); + } + } + } + + internal void RefreshEnvironment() + { + Camera.FarPlane = Settings.Editor.RenderDistance; + Game.ResolutionScale = Settings.Editor.ResolutionType switch + { + EditorSettings_V01.Resolution.Game => 1.0f, + EditorSettings_V01.Resolution.Double => 2.0f, + EditorSettings_V01.Resolution.HD => 3.0f, + EditorSettings_V01.Resolution.Native => Math.Max(App.Width / (float)Game.DefaultWidth, App.Height / (float)Game.DefaultHeight), + _ => throw new ArgumentOutOfRangeException(), + }; + + if (Map == null) + return; + + // Taken from World constructor with added cleanup of previously created stuff + + if (Get() is { } snow) + Destroy(snow); + if (Map.SnowAmount > 0 && Settings.Editor.RenderSnow) + { + Add(new Snow(Map.SnowAmount, Map.SnowWind)); + } + + Game.Instance.Music.Stop(); + Game.Instance.MusicWav?.Stop(); + if (Settings.Editor.PlayMusic) + { + if (Map.Music != null && Assets.Music.ContainsKey(Map.Music)) + { + MusicWav = Map.Music; + Music = $"event:/music/"; + } + else + { + MusicWav = ""; + Music = $"event:/music/{Map.Music}"; + } + } + else + { + MusicWav = string.Empty; + Music = string.Empty; + } + if (!string.IsNullOrWhiteSpace(Music)) + Game.Instance.Music = Audio.Play(Music); + if (!string.IsNullOrWhiteSpace(MusicWav)) + Game.Instance.MusicWav = Audio.PlayMusic(MusicWav); + + Game.Instance.Ambience.Stop(); + Game.Instance.AmbienceWav?.Stop(); + if (Settings.Editor.PlayAmbience) + { + if (Map.Ambience != null && Assets.Music.ContainsKey(Map.Ambience)) + { + AmbienceWav = Map.Ambience; + Ambience = $"event:/sfx/ambience/"; + } + else + { + AmbienceWav = ""; + Ambience = $"event:/sfx/ambience/{Map.Ambience}"; + } + } + else + { + AmbienceWav = string.Empty; + Ambience = string.Empty; + } + if (!string.IsNullOrWhiteSpace(Ambience)) + Game.Instance.Ambience = Audio.Play(Ambience); + if (!string.IsNullOrWhiteSpace(AmbienceWav)) + Game.Instance.AmbienceWav = Audio.PlayMusic(AmbienceWav); + + skyboxes.Clear(); + if (!string.IsNullOrEmpty(Map.Skybox) && Settings.Editor.RenderSkybox) + { + // single skybox + if (Assets.Textures.TryGetValue($"skyboxes/{Map.Skybox}", out var skybox)) + { + skyboxes.Add(new(skybox)); + } + // group + else + { + while (Assets.Textures.TryGetValue($"skyboxes/{Map.Skybox}_{skyboxes.Count}", out var nextSkybox)) + skyboxes.Add(new(nextSkybox)); + } + } + } + + private float previousScale = 1.0f; + + public override void Entered() + { + // Awake all SelectionTypes of definitions which already exist + foreach (var def in Definitions) + { + foreach (var selectionType in def.SelectionTypes) + selectionType.Awake(); + } + + // Awake all tools + foreach (var tool in Tools) + { + tool.Awake(this); + } + } + public override void Exited() + { + Game.ResolutionScale = previousScale; + } + + public override void Update() + { + // Toggle to in-game + if (Input.Keyboard.Pressed(Keys.F3)) + { + Game.Scene!.Exited(); + Game.Instance.scenes.Pop(); + Game.Instance.scenes.Push(new World(Entry)); + Game.Scene.Entered(); + return; + } + + if (Input.Keyboard.Ctrl && Input.Keyboard.Pressed(Keys.S) && Map is FujiMap { FullPath: { } fullPath } fujiMap) + { + Log.Info($"Saving map to '{fullPath}'"); + fujiMap.SaveToFile(); + + return; + } + + // Camera movement + var cameraForward = new Vec3( + MathF.Sin(cameraRot.X), + MathF.Cos(cameraRot.X), + 0.0f); + var cameraRight = new Vec3( + MathF.Sin(cameraRot.X - Calc.HalfPI), + MathF.Cos(cameraRot.X - Calc.HalfPI), + 0.0f); + + float moveSpeed = 250.0f; + + if (Input.Keyboard.Down(Keys.W)) + // cameraPos += cameraForward * moveSpeed * Time.Delta; + cameraPos += Camera.Forward * moveSpeed * Time.Delta; + if (Input.Keyboard.Down(Keys.S)) + // cameraPos -= cameraForward * moveSpeed * Time.Delta; + cameraPos -= Camera.Forward * moveSpeed * Time.Delta; + if (Input.Keyboard.Down(Keys.A)) + cameraPos += cameraRight * moveSpeed * Time.Delta; + if (Input.Keyboard.Down(Keys.D)) + cameraPos -= cameraRight * moveSpeed * Time.Delta; + if (Input.Keyboard.Down(Keys.Space)) + cameraPos.Z += moveSpeed * Time.Delta; + if (Input.Keyboard.Down(Keys.LeftShift)) + cameraPos.Z -= moveSpeed * Time.Delta; + + // Camera rotation + float rotateSpeed = 16.5f * Calc.DegToRad; + if (Input.Mouse.Down(MouseButtons.Right) && !ImGuiManager.WantCaptureMouse) + { + cameraRot.X += InputHelper.MouseDelta.X * rotateSpeed * Time.Delta; + cameraRot.Y += InputHelper.MouseDelta.Y * rotateSpeed * Time.Delta; + cameraRot.X %= 360.0f * Calc.DegToRad; + cameraRot.Y = Math.Clamp(cameraRot.Y, -89.9f * Calc.DegToRad, 89.9f * Calc.DegToRad); + } + + // Update camera + var forward = new Vec3( + MathF.Sin(cameraRot.X) * MathF.Cos(cameraRot.Y), + MathF.Cos(cameraRot.X) * MathF.Cos(cameraRot.Y), + MathF.Sin(-cameraRot.Y)); + Camera.Position = cameraPos; + Camera.LookAt = cameraPos + forward; + + // Calculate mouse ray from camera + if (Camera.Target is not null && + Matrix.Invert(Camera.Projection, out var inverseProj) && + Matrix.Invert(Camera.View, out var inverseView)) + { + // The top-left of the image might not be the top-left of the window, when using non 16:9 aspect ratios + var scale = Math.Min(App.WidthInPixels / (float)Camera.Target.Width, App.HeightInPixels / (float)Camera.Target.Height); + var imageRelativeDir = Input.Mouse.Position - (App.SizeInPixels / 2 - Camera.Target.Bounds.Size / 2 * scale); + // Convert into normalized-device-coordinates + var ndcDir = imageRelativeDir / (Camera.Target.Bounds.Size / 2 * scale) - Vec2.One; + // Flip Y, since up is negative in NDC coords + ndcDir.Y *= -1.0f; + var clipDir = new Vec4(ndcDir, -1.0f, 1.0f); + var eyeDir = Vec4.Transform(clipDir, inverseProj); + // We only care about XY, so we set ZW to "forward" + eyeDir.Z = -1.0f; + eyeDir.W = 0.0f; + var worldDir = Vec4.Transform(eyeDir, inverseView); + MouseRay = new Vec3(worldDir.X, worldDir.Y, worldDir.Z).Normalized(); + } + + // Shoot ray cast for selection + if (CurrentTool.EnableSelection) + SelectionRaycast(); + + CurrentTool.Update(this); + + // Update actors of definitions + foreach (var def in Definitions.Where(def => def.Dirty)) + { + if (actorsFromDefinition.Remove(def, out var actors)) + { + foreach (var actor in actors) + { + definitionFromActors.Remove(actor); + Destroy(actor); + } + } + + var newActors = def.Load(WorldType.Editor); + actorsFromDefinition[def] = newActors; + + foreach (var actor in newActors) + { + definitionFromActors.Add(actor, def); + Add(actor); + } + + def.Dirty = false; + def.Updated(); + } + + // Don't call base.Update, since we don't want the actors to update + // Instead we manually call only the things which we want for the editor + + // toggle debug draw + if (Input.Keyboard.Pressed(Keys.F1)) + DebugDraw = !DebugDraw; + + if (Settings.Editor.PlayAnimations) + GeneralTimer += Time.Delta; + + // add / remove actors + ResolveChanges(); + } + + private void SelectionRaycast() + { + if (ImGuiManager.WantCaptureMouse) + return; + + // Continue/Stop dragging + if (Input.Mouse.LeftDown && dragTarget is not null) + { + dragTarget.Dragged(Input.Mouse.Position - dragMouseStart, MouseRay); + return; + } + + if (dragTarget != null) + { + dragTarget.IsDragged = false; + } + dragTarget = null; + + // Collect all active selection targets + List selectionTargets = []; + if (Selected is not null && Selected.SelectionTypes.Length > 0) + { + // TODO: Allow for selecting different types + var selType = Selected.SelectionTypes[0]; + selectionTargets.AddRange(selType.Targets); + selectionTargets.AddRange(selType.Gizmos.SelectMany(static gizmo => gizmo.SelectionTargets)); + } + selectionTargets.AddRange(CurrentTool.Gizmos.SelectMany(static gizmo => gizmo.SelectionTargets)); + + // Un-hover everything + foreach (var target in selectionTargets) + target.IsHovered = false; + + // Handle SelectionTargets + { + SelectionTarget? closest = null; + float closestDist = float.PositiveInfinity; + + foreach (var target in selectionTargets) + { + if (!ModUtils.RayIntersectOBB(Camera.Position, MouseRay, target.Bounds, target.Transform, out float dist) || dist >= closestDist) + continue; + + closest = target; + closestDist = dist; + } + + if (closest is not null) + { + closest.Hovered(); + closest.IsHovered = true; + + if (Input.Mouse.LeftPressed) + { + OnTargetSelected(closest); + closest.Selected(); + + dragTarget = closest; + dragTarget.IsDragged = true; + dragMouseStart = Input.Mouse.Position; + } + return; + } + + if (Input.Mouse.LeftPressed) + { + // No SelectionTarget was hit + OnTargetSelected(null); + } + } + + if (Input.Mouse.LeftPressed) + { + if (ActorRayCast(Camera.Position, MouseRay, 10000.0f, out var hit, ignoreBackfaces: false)) + Selected = hit.Actor is not null && definitionFromActors.TryGetValue(hit.Actor, out var def) ? def : null; + else + Selected = null; + } + } + + public override void Render(Target target) + { + // We copy and modify World.Render, since that's easier + + debugRndTimer.Restart(); + Camera.Target = target; + target.Clear(0x444c83, 1, 0, ClearMask.All); + + // create render state + RenderState state = new(); + { + state.Camera = Camera; + state.ModelMatrix = Matrix.Identity; + state.SunDirection = new Vec3(0, -.7f, -1).Normalized(); + state.Silhouette = false; + state.DepthCompare = DepthCompare.Less; + state.DepthMask = true; + state.VerticalFogColor = 0xdceaf0; + } + + // collect renderable objects + { + sprites.Clear(); + models.Clear(); + + // collect point shadows + foreach (var actor in All()) + { + var alpha = (actor as ICastPointShadow)!.PointShadowAlpha; + if (alpha > 0 && + Camera.Frustum.Contains(actor.WorldBounds.Conflate(actor.WorldBounds - Vec3.UnitZ * 1000))) + sprites.Add(Sprite.CreateShadowSprite(this, actor.Position + Vec3.UnitZ, alpha)); + } + + // collect models & sprites + foreach (var actor in Actors) + { + if (!Camera.Frustum.Contains(actor.WorldBounds.Inflate(1))) + continue; + + (actor as IHaveSprites)?.CollectSprites(sprites); + (actor as IHaveModels)?.CollectModels(models); + } + + // sort models by distance (for transparency) + models.Sort((a, b) => + (int)((b.Actor.Position - Camera.Position).LengthSquared() - + (a.Actor.Position - Camera.Position).LengthSquared())); + + // perp all models + foreach (var it in models) + it.Model.Prepare(); + } + + // draw the skybox first + { + var shift = new Vec3(Camera.Position.X, Camera.Position.Y, Camera.Position.Z); + for (int i = 0; i < skyboxes.Count; i++) + { + skyboxes[i].Render(Camera, + Matrix.CreateRotationZ(i * GeneralTimer * 0.01f) * + Matrix.CreateScale(1, 1, 0.5f) * + Matrix.CreateTranslation(shift), 300); + } + } + + // render solids + RenderModels(ref state, models, ModelFlags.Terrain); + + // render silhouettes + { + var it = state; + it.DepthCompare = DepthCompare.Greater; + it.DepthMask = false; + it.Silhouette = true; + RenderModels(ref it, models, ModelFlags.Silhouette); + state.Triangles = it.Triangles; + state.Calls = it.Calls; + } + + // render main models + RenderModels(ref state, models, ModelFlags.Default); + + // perform post processing effects + ApplyPostEffects(); + + // render alpha threshold transparent stuff + { + state.CutoutMode = true; + RenderModels(ref state, models, ModelFlags.Cutout); + state.CutoutMode = false; + } + + // render 2d sprites + { + spriteRenderer.Render(ref state, sprites, false); + spriteRenderer.Render(ref state, sprites, true); + } + + // render partially transparent models... must be sorted etc + { + state.DepthMask = false; + RenderModels(ref state, models, ModelFlags.Transparent); + state.DepthMask = true; + } + + var selectedLocalBoundsFillColor = Color.Green * 0.4f; + var selectedLocalBoundsOutlineColor = Color.Green; + var selectedWorldBoundsOutlineColor = Color.Blue; + const float selectedBoundsInflate = 0.25f; + + // Render selected actors bounding box + if (CurrentTool.EnableSelection) + { + foreach (var selected in SelectedActors) + { + var matrix = selected.Matrix; + var bounds = selected.LocalBounds.Inflate(selectedBoundsInflate); + + batch3D.Box(bounds.Min, bounds.Max, selectedLocalBoundsFillColor, matrix); + } + batch3D.Render(ref state); + batch3D.Clear(); + + // Render outline on-top of everything else + target.Clear(Color.Black, 1.0f, 0, ClearMask.Depth); + foreach (var selected in SelectedActors) + { + // Scale thickness based on distance + var lineThickness = Vec3.Distance(Camera.Position, selected.WorldBounds.Center) * 0.001f; + + // Transformed local bounds + var matrix = selected.Matrix; + var bounds = selected.LocalBounds.Inflate(selectedBoundsInflate); + var v000 = bounds.Min; + var v100 = bounds.Min with { X = bounds.Max.X }; + var v010 = bounds.Min with { Y = bounds.Max.Y }; + var v001 = bounds.Min with { Z = bounds.Max.Z }; + var v011 = bounds.Max with { X = bounds.Min.X }; + var v101 = bounds.Max with { Y = bounds.Min.Y }; + var v110 = bounds.Max with { Z = bounds.Min.Z }; + var v111 = bounds.Max; + + batch3D.Line(v000, v100, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v000, v010, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v000, v001, selectedLocalBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v111, v011, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v111, v101, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v111, v110, selectedLocalBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v010, v011, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v010, v110, selectedLocalBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v101, v100, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v101, v001, selectedLocalBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v100, v110, selectedLocalBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v001, v011, selectedLocalBoundsOutlineColor, matrix, lineThickness); + + // World bounds + matrix = Matrix.Identity; + bounds = selected.WorldBounds.Inflate(selectedBoundsInflate); + v000 = bounds.Min; + v100 = bounds.Min with { X = bounds.Max.X }; + v010 = bounds.Min with { Y = bounds.Max.Y }; + v001 = bounds.Min with { Z = bounds.Max.Z }; + v011 = bounds.Max with { X = bounds.Min.X }; + v101 = bounds.Max with { Y = bounds.Min.Y }; + v110 = bounds.Max with { Z = bounds.Min.Z }; + v111 = bounds.Max; + + batch3D.Line(v000, v100, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v000, v010, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v000, v001, selectedWorldBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v111, v011, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v111, v101, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v111, v110, selectedWorldBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v010, v011, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v010, v110, selectedWorldBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v101, v100, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v101, v001, selectedWorldBoundsOutlineColor, matrix, lineThickness); + + batch3D.Line(v100, v110, selectedWorldBoundsOutlineColor, matrix, lineThickness); + batch3D.Line(v001, v011, selectedWorldBoundsOutlineColor, matrix, lineThickness); + } + batch3D.Render(ref state); + batch3D.Clear(); + } + + // Render gizmos on-top + target.Clear(Color.Black, 1.0f, 0, ClearMask.Depth); + { + if (Selected is not null && Selected.SelectionTypes.Length > 0) + { + // TODO: Allow for selecting different types + var selType = Selected.SelectionTypes[0]; + foreach (var g in selType.Gizmos) + { + g.Render(batch3D); + } + } + + foreach (var gizmo in CurrentTool.Gizmos) + { + gizmo.Render(batch3D); + } + } + batch3D.Render(ref state); + batch3D.Clear(); + ApplyPostEffects(); + + // ui + { + batch.SetSampler(new TextureSampler(TextureFilter.Linear, TextureWrap.ClampToEdge, TextureWrap.ClampToEdge)); + var bounds = new Rect(0, 0, target.Width, target.Height); + var font = Language.Current.SpriteFont; + + // debug + if (DebugDraw) + { + var updateMs = debugUpdTimer.Elapsed.TotalMilliseconds; + var renderMs = lastDebugRndTime.TotalMilliseconds; + var frameMs = debugFpsTimer.Elapsed.TotalMilliseconds; + var fps = (int)(1000 / frameMs); + debugFpsTimer.Restart(); + + batch.Text(font, $"Draws: {state.Calls}, Tris: {state.Triangles}, Upd: {debugUpdateCount}", bounds.BottomLeft, new Vec2(0, 1), Color.Red); + batch.Text(font, $"u:{updateMs:0.00}ms | r:{renderMs:0.00}ms | f:{frameMs:0.00}ms / {fps}fps", bounds.BottomLeft - new Vec2(0, font.LineHeight), new Vec2(0, 1), Color.Red); + batch.Text(font, $"m: {Entry.Map}, c: {Entry.CheckPoint}, s: {Entry.Submap}", bounds.BottomLeft - new Vec2(0, font.LineHeight * 2), new Vec2(0, 1), Color.Red); + } + + batch.Render(Camera.Target); + batch.Clear(); + } + + lastDebugRndTime = debugRndTimer.Elapsed; + debugRndTimer.Stop(); + } + + public bool ActorRayCast(in Vec3 point, in Vec3 direction, float distance, out RayHit hit, bool ignoreBackfaces = true, bool ignoreTransparent = false) + { + hit = default; + float? closest = null; + + var p0 = point; + var p1 = point + direction * distance; + var box = new BoundingBox(Vec3.Min(p0, p1), Vec3.Max(p0, p1)).Inflate(1); + + foreach (var actor in Actors) + { + if (!actor.WorldBounds.Intersects(box)) + continue; + + // TODO: Allow selecting decorations, since they're currently one giant object + if (actor is Decoration or FloatingDecoration) + continue; + // Snow is not edited as an actor, but rather through the environment settings + if (actor is Snow) + continue; + + if (actor is not Solid solid) + { + if (ModUtils.RayIntersectOBB(point, direction, actor.LocalBounds, actor.Matrix, out float dist)) + { + // too far away + if (dist > distance) + continue; + + hit.Intersections++; + + // we have a closer value + if (closest.HasValue && dist > closest.Value) + continue; + + // store as closest + hit.Point = point + direction * dist; + hit.Distance = dist; + hit.Actor = actor; + closest = dist; + } + + continue; + } + + // Special handling for solid to properly check against mesh + if (!solid.Collidable || solid.Destroying) + continue; + + if (solid.Transparent && ignoreTransparent) + continue; + + var verts = solid.WorldVertices; + var faces = solid.WorldFaces; + + foreach (var face in faces) + { + // only do planes that are facing against us + if (ignoreBackfaces && Vec3.Dot(face.Plane.Normal, direction) >= 0) + continue; + + // ignore faces that are definitely too far away + if (point.DistanceToPlane(face.Plane) > distance) + continue; + + // check against each triangle in the face + for (int i = 0; i < face.VertexCount - 2; i++) + { + if (Utils.RayIntersectsTriangle(point, direction, + verts[face.VertexStart + 0], + verts[face.VertexStart + i + 1], + verts[face.VertexStart + i + 2], out float dist)) + { + // too far away + if (dist > distance) + continue; + + hit.Intersections++; + + // we have a closer value + if (closest.HasValue && dist > closest.Value) + continue; + + // store as closest + hit.Point = point + direction * dist; + hit.Normal = face.Plane.Normal; + hit.Distance = dist; + hit.Actor = solid; + closest = dist; + break; + } + } + } + } + + return closest.HasValue; + } +} diff --git a/Source/Mod/Editor/FujiMap.cs b/Source/Mod/Editor/FujiMap.cs new file mode 100644 index 00000000..954f09be --- /dev/null +++ b/Source/Mod/Editor/FujiMap.cs @@ -0,0 +1,290 @@ +using System.Collections; +using System.Reflection; +using System.Reflection.Emit; + +namespace Celeste64.Mod.Editor; + +/// +/// Map parser for the custom Fuji map format. +/// +public class FujiMap : Map +{ + /// + /// Magic 4 bytes at the start of the file, to indicate the format. + /// + private static readonly byte[] FormatMagic = [(byte)'F', (byte)'U', (byte)'J', (byte)'I']; + + /// + /// Current version of the map format. Needs to be incremented with every change to it. + /// + private const byte FormatVersion = 1; + + public readonly string? FullPath; + public readonly List Definitions = []; + + public FujiMap(string name, string virtPath, Stream stream, string? fullPath) + { + Name = name; + Filename = virtPath; + Folder = Path.GetDirectoryName(virtPath) ?? string.Empty; + FullPath = fullPath; + + using var reader = new BinaryReader(stream); + + try + { + // Header + var magic = reader.ReadBytes(4); + if (!magic.SequenceEqual(FormatMagic)) + { + isMalformed = true; + readExceptionMessage = $"Invalid magic bytes! Found '{(char)magic[0]}{(char)magic[1]}{(char)magic[2]}{(char)magic[3]}'"; + return; + } + var version = reader.ReadByte(); // Not currently used + + // Metadata + Skybox = reader.ReadString(); + SnowAmount = reader.ReadSingle(); + SnowWind = reader.ReadVec3(); + Ambience = reader.ReadString(); + Music = reader.ReadString(); + + // Definitions + var defCount = reader.ReadInt32(); + for (int i = 0; i < defCount; i++) + { + // Get the definition data type, by the full name + var fullName = reader.ReadString(); + var defType = Assembly.GetExecutingAssembly().GetType(fullName); + if (defType is null || !defType.IsAssignableTo(typeof(ActorDefinition))) + { + isMalformed = true; + readExceptionMessage = $"The definition type {fullName} is invalid"; + return; + } + + var def = Activator.CreateInstance(defType); + + Log.Info($"Reading def: {def}"); + + var props = defType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(prop => !prop.HasAttr() && prop.Name != nameof(ActorDefinition.SelectionTypes)); + + foreach (var prop in props) + { + if (prop.GetCustomAttribute() is { } custom) + { + prop.SetValue(def, custom.Deserialize(reader)); + continue; + } + + if (DeserializeObject(prop.PropertyType, reader) is not { } obj) + throw new Exception($"Property '{prop.Name}' of type {prop.PropertyType} from definition '{def}' cannot be deserialized"); + + prop.SetValue(def, obj); + + Log.Info($" - {prop.Name}: {prop.GetValue(def)}"); + } + + Definitions.Add((ActorDefinition)def!); + } + } + catch (Exception ex) + { + isMalformed = true; + readExceptionMessage = ex.Message; + + Log.Error($"Failed to load map {name}, more details below."); + Log.Error(ex.ToString()); + } + } + + public void SaveToFile() + { + // Only allow saving when the mod is a folder + if (FullPath == null) + { + Log.Warning("Tried to save zipped map file"); + return; + } + + using var fs = File.Open(FullPath, FileMode.Create); + using var writer = new BinaryWriter(fs); + + // Header + writer.Write(FormatMagic); + writer.Write(FormatVersion); + + // Metadata + // Skybox + writer.Write("city"); + // Snow amount + writer.Write(1.0f); + // Snow direction + writer.Write(new Vec3(0.0f, 0.0f, -1.0f)); + // Ambience + writer.Write("mountain"); + // Music + writer.Write("mus_lvl1"); + + // Definitions + writer.Write(Definitions.Count); + foreach (var def in Definitions) + { + Log.Info($"Writing def: {def}"); + writer.Write(def.GetType().FullName!); + + var props = def.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic) + .Where(prop => !prop.HasAttr() && prop.Name != nameof(ActorDefinition.SelectionTypes)); + + foreach (var prop in props) + { + if (prop.GetCustomAttribute() is { } custom) + { + custom.Serialize(prop.GetValue(def)!, writer); + continue; + } + + if (!SerializeObject(prop.GetValue(def), writer)) + throw new Exception($"Property '{prop.Name}' of type {prop.PropertyType} from definition '{def}' cannot be serialized"); + + Log.Info($" * {prop.Name}: {prop.GetValue(def)}"); + } + } + } + + public override void Load(World world) + { + foreach (var def in Definitions) + { + var newActors = def.Load(world.Type); + foreach (var actor in newActors) + { + world.Add(actor); + } + } + world.Add(new Player { Position = new Vec3(0, 0, 100) }); + } + + private bool SerializeObject(object? obj, BinaryWriter writer) + { + switch (obj) + { + // Primitives + case bool v: + writer.Write(v); + break; + case byte v: + writer.Write(v); + break; + case char v: + writer.Write(v); + break; + case decimal v: + writer.Write(v); + break; + case double v: + writer.Write(v); + break; + case float v: + writer.Write(v); + break; + case int v: + writer.Write(v); + break; + case long v: + writer.Write(v); + break; + case sbyte v: + writer.Write(v); + break; + case short v: + writer.Write(v); + break; + case Half v: + writer.Write(v); + break; + case string v: + writer.Write(v); + break; + + // Special support + case Vec2 v: + writer.Write(v); + break; + case Vec3 v: + writer.Write(v); + break; + case Color v: + writer.Write(v); + break; + + // Collections + case IList v: + writer.Write7BitEncodedInt(v.Count); + foreach (var item in v) + SerializeObject(item, writer); + break; + + default: + return false; + } + + return true; + } + + private object? DeserializeObject(Type type, BinaryReader reader) + { + // Primitives + if (type == typeof(bool)) + return reader.ReadBoolean(); + if (type == typeof(byte)) + return reader.ReadByte(); + if (type == typeof(byte[])) + return reader.ReadBytes(reader.Read7BitEncodedInt()); + if (type == typeof(char)) + return reader.ReadChar(); + if (type == typeof(char[])) + return reader.ReadChars(reader.Read7BitEncodedInt()); + if (type == typeof(decimal)) + return reader.ReadDecimal(); + if (type == typeof(double)) + return reader.ReadDouble(); + if (type == typeof(float)) + return reader.ReadSingle(); + if (type == typeof(int)) + return reader.ReadInt32(); + if (type == typeof(long)) + return reader.ReadInt64(); + if (type == typeof(sbyte)) + return reader.ReadSByte(); + if (type == typeof(short)) + return reader.ReadInt16(); + if (type == typeof(Half)) + return reader.ReadHalf(); + if (type == typeof(string)) + return reader.ReadString(); + // Special support + if (type == typeof(Vec2)) + return reader.ReadVec2(); + if (type == typeof(Vec3)) + return reader.ReadVec3(); + if (type == typeof(Color)) + return reader.ReadColor(); + // Collections + if (type.IsAssignableTo(typeof(IList)) && type.IsGenericType) + { + var itemType = type.GenericTypeArguments[0]; + var list = (IList)Activator.CreateInstance(type)!; + int count = reader.Read7BitEncodedInt(); + for (int i = 0; i < count; i++) + list.Add(DeserializeObject(itemType, reader)); + return list; + } + + return null; + } +} diff --git a/Source/Mod/Editor/GUI/ActorSelectionWindow.cs b/Source/Mod/Editor/GUI/ActorSelectionWindow.cs new file mode 100644 index 00000000..6b6857b3 --- /dev/null +++ b/Source/Mod/Editor/GUI/ActorSelectionWindow.cs @@ -0,0 +1,25 @@ +using ImGuiNET; + +namespace Celeste64.Mod.Editor; + +public class ActorSelectionWindow() : EditorWindow("ActorSelect") +{ + protected override string Title => "Select Actor"; + + protected override void RenderWindow(EditorWorld editor) + { + if (editor.CurrentTool is not PlaceActorTool placeActorTool) + return; + + foreach (var def in placeActorTool.Definitions) + { + bool isSelected = placeActorTool.CurrentDefinition == def; + + if (ImGui.Selectable(def.FullName, isSelected)) + placeActorTool.CurrentDefinition = def; + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + } +} diff --git a/Source/Mod/Editor/GUI/EditActorWindow.cs b/Source/Mod/Editor/GUI/EditActorWindow.cs new file mode 100644 index 00000000..2fffbfea --- /dev/null +++ b/Source/Mod/Editor/GUI/EditActorWindow.cs @@ -0,0 +1,72 @@ +using ImGuiNET; +using System.Reflection; + +namespace Celeste64.Mod.Editor; + +public class EditActorWindow() : EditorWindow("EditActor") +{ + // TODO: Properly display selected name + protected override string Title => EditorWorld.Current.Selected is { } selected + ? $"Edit Actor - {selected}" + : "Edit Actor - Nothing selected"; + + protected override void RenderWindow(EditorWorld editor) + { + // TODO: Add some actor picker + if (ImGui.Button("DEBUG: Add Spikes")) + { + editor.AddDefinition(new SpikeBlock.Definition()); + } + if (ImGui.Button("DEBUG: Add Solid")) + { + editor.AddDefinition(new Solid.Definition()); + } + + if (editor.Selected is { } selected) + { + var props = selected.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(prop => !prop.HasAttr()); + + foreach (var prop in props) + { + if (prop.GetCustomAttribute() is { } custom) + { + object obj = prop.GetValue(selected)!; + if (custom.RenderGui(ref obj)) + { + prop.SetValue(selected, obj); + selected.Dirty = true; + } + + continue; + } + + switch (prop.GetValue(selected)) + { + case Vec3 v: + if (ImGui.DragFloat3(prop.Name, ref v)) + { + prop.SetValue(selected, v); + selected.Dirty = true; + } + break; + + default: + ImGui.Text($" - {prop.Name}: {prop.GetValue(selected)}"); + break; + } + } + + ImGui.NewLine(); + if (ImGui.Button("Remove Actor") || Input.Keyboard.Pressed(Keys.Delete)) + { + editor.RemoveDefinition(selected); + } + } + else + { + ImGui.Text("Nothing selected"); + } + } +} diff --git a/Source/Mod/Editor/GUI/EditorMenuBar.cs b/Source/Mod/Editor/GUI/EditorMenuBar.cs new file mode 100644 index 00000000..2642ed03 --- /dev/null +++ b/Source/Mod/Editor/GUI/EditorMenuBar.cs @@ -0,0 +1,86 @@ +using ImGuiNET; + +namespace Celeste64.Mod.Editor; + +public class EditorMenuBar : ImGuiHandler +{ + public override void Render() + { + bool changed = false; + + ImGui.BeginMainMenuBar(); + + if (ImGui.BeginMenu("Settings")) + { + bool music = Settings.Editor.PlayMusic; + changed |= ImGui.Checkbox("Player Music", ref music); + Settings.Editor.PlayMusic = music; + + bool ambience = Settings.Editor.PlayAmbience; + changed |= ImGui.Checkbox("Play Ambience", ref ambience); + Settings.Editor.PlayAmbience = ambience; + + ImGui.EndMenu(); + } + + if (ImGui.BeginMenu("View")) + { + bool snow = Settings.Editor.RenderSnow; + changed |= ImGui.Checkbox("Show Snow", ref snow); + Settings.Editor.RenderSnow = snow; + + bool skybox = Settings.Editor.RenderSkybox; + changed |= ImGui.Checkbox("Show Skybox", ref skybox); + Settings.Editor.RenderSkybox = skybox; + + bool anim = Settings.Editor.PlayAnimations; + changed |= ImGui.Checkbox("Play Animations", ref anim); + Settings.Editor.PlayAnimations = anim; + + float renderDistance = Settings.Editor.RenderDistance; + changed |= ImGui.DragFloat("Render Distance", ref renderDistance, v_speed: 10.0f, v_min: EditorSettings_V01.MinRenderDistance, v_max: EditorSettings_V01.MaxRenderDistance); + Settings.Editor.RenderDistance = renderDistance; + + string[] displayStrings = [ + "Game (640x360)", + "720p (1280x720)", + "HD (1920x1080)", + $"Native ({App.Width}x{App.Height})", + ]; + + var resolutionType = Settings.Editor.ResolutionType; + if (ImGui.BeginCombo("Resolution", displayStrings[(int)resolutionType])) + { + if (ImGui.Selectable(displayStrings[(int)EditorSettings_V01.Resolution.Game], resolutionType == EditorSettings_V01.Resolution.Game)) + { + Settings.Editor.ResolutionType = EditorSettings_V01.Resolution.Game; + changed = true; + } + if (ImGui.Selectable(displayStrings[(int)EditorSettings_V01.Resolution.Double], resolutionType == EditorSettings_V01.Resolution.Double)) + { + Settings.Editor.ResolutionType = EditorSettings_V01.Resolution.Double; + changed = true; + } + if (ImGui.Selectable(displayStrings[(int)EditorSettings_V01.Resolution.HD], resolutionType == EditorSettings_V01.Resolution.HD)) + { + Settings.Editor.ResolutionType = EditorSettings_V01.Resolution.HD; + changed = true; + } + if (ImGui.Selectable(displayStrings[(int)EditorSettings_V01.Resolution.Native], resolutionType == EditorSettings_V01.Resolution.Native)) + { + Settings.Editor.ResolutionType = EditorSettings_V01.Resolution.Native; + changed = true; + } + + ImGui.EndCombo(); + } + + ImGui.EndMenu(); + } + + ImGui.EndMainMenuBar(); + + if (changed) + EditorWorld.Current.RefreshEnvironment(); + } +} diff --git a/Source/Mod/Editor/GUI/EditorWindow.cs b/Source/Mod/Editor/GUI/EditorWindow.cs new file mode 100644 index 00000000..4324d90e --- /dev/null +++ b/Source/Mod/Editor/GUI/EditorWindow.cs @@ -0,0 +1,16 @@ +using ImGuiNET; + +namespace Celeste64.Mod.Editor; + +public abstract class EditorWindow(string id) : ImGuiHandler +{ + protected virtual string Title => id; + + protected abstract void RenderWindow(EditorWorld editor); + public sealed override void Render() + { + ImGui.Begin($"{Title}###{id}"); + RenderWindow(EditorWorld.Current); + ImGui.End(); + } +} diff --git a/Source/Mod/Editor/GUI/EnvironmentSettingsWindow.cs b/Source/Mod/Editor/GUI/EnvironmentSettingsWindow.cs new file mode 100644 index 00000000..f53e4208 --- /dev/null +++ b/Source/Mod/Editor/GUI/EnvironmentSettingsWindow.cs @@ -0,0 +1,43 @@ +using ImGuiNET; + +namespace Celeste64.Mod.Editor; + +public class EnvironmentSettingsWindow() : EditorWindow("EnvironmentSettings") +{ + protected override string Title => "Environment Settings"; + + protected override void RenderWindow(EditorWorld editor) + { + if (editor.Map == null) + return; + + bool changed = false; + + // AFAIK just passing a large value works fine for C# strings + const int bufferSize = 32767; + + // TODO: Add an asset picker?? + string skybox = editor.Map.Skybox ?? string.Empty; + changed |= ImGui.InputText("Skybox", ref skybox, bufferSize); + editor.Map.Skybox = skybox; + + float snowAmount = editor.Map.SnowAmount; + changed |= ImGui.DragFloat("Snow Amount", ref snowAmount, v_speed: 0.1f, v_min: 0.0f); + editor.Map.SnowAmount = snowAmount; + + var snowWind = editor.Map.SnowWind; + changed |= ImGui.DragFloat3("Snow Wind", ref snowWind, v_speed: 0.1f); + editor.Map.SnowWind = snowWind; + + string music = editor.Map.Music ?? string.Empty; + changed |= ImGui.InputText("Music", ref music, bufferSize); + editor.Map.Music = music; + + string ambience = editor.Map.Ambience ?? string.Empty; + changed |= ImGui.InputText("Ambience", ref ambience, bufferSize); + editor.Map.Ambience = ambience; + + if (changed) + editor.RefreshEnvironment(); + } +} diff --git a/Source/Mod/Editor/GUI/ToolSelectionWindow.cs b/Source/Mod/Editor/GUI/ToolSelectionWindow.cs new file mode 100644 index 00000000..f423938f --- /dev/null +++ b/Source/Mod/Editor/GUI/ToolSelectionWindow.cs @@ -0,0 +1,22 @@ +using ImGuiNET; + +namespace Celeste64.Mod.Editor; + +public class ToolSelectionWindow() : EditorWindow("ToolSelect") +{ + protected override string Title => "Select Tool"; + + protected override void RenderWindow(EditorWorld editor) + { + foreach (var tool in editor.Tools) + { + bool isSelected = editor.CurrentTool == tool; + + if (ImGui.Selectable(tool.Name, isSelected)) + editor.CurrentTool = tool; + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + } +} diff --git a/Source/Mod/Editor/Gizmo/Gizmo.cs b/Source/Mod/Editor/Gizmo/Gizmo.cs new file mode 100644 index 00000000..4181e6d2 --- /dev/null +++ b/Source/Mod/Editor/Gizmo/Gizmo.cs @@ -0,0 +1,8 @@ +namespace Celeste64.Mod.Editor; + +public abstract class Gizmo +{ + public abstract IEnumerable SelectionTargets { get; } + + public abstract void Render(Batcher3D batch3D); +} diff --git a/Source/Mod/Editor/Gizmo/PositionGizmo.cs b/Source/Mod/Editor/Gizmo/PositionGizmo.cs new file mode 100644 index 00000000..5a5adbec --- /dev/null +++ b/Source/Mod/Editor/Gizmo/PositionGizmo.cs @@ -0,0 +1,274 @@ +namespace Celeste64.Mod.Editor; + +public enum GizmoTarget +{ + None, + AxisX, AxisY, AxisZ, + PlaneXZ, PlaneYZ, PlaneXY, + CubeXYZ, +} + +public class PositionGizmo : Gizmo +{ + private class PositionGizmoSelectionTarget(PositionGizmo gizmo, GizmoTarget target, BoundingBox bounds) : SelectionTarget + { + public readonly GizmoTarget Target = target; + + public override Matrix Transform => gizmo.Transform; + public override BoundingBox Bounds { get; } = bounds; + + public override void Selected() => gizmo.DragStart(); + public override void Dragged(Vec2 mouseDelta, Vec3 mouseRay) => gizmo.Drag(mouseDelta, mouseRay); + } + + public delegate Vec3 GetPositionDelegate(); + public delegate void SetPositionDelegate(Vec3 value); + + private readonly GetPositionDelegate getPosition; + private readonly SetPositionDelegate setPosition; + private readonly float scale; + + private GizmoTarget target; + private Vec3 beforeDragPosition = Vec3.Zero; + + private const float CubeSize = 0.15f; + private const float PlaneSize = 0.6f; + private const float Padding = 0.15f; + private const float AxisLen = 1.5f; + private const float AxisRadius = AxisLen / 35.0f; + private const float ConeLen = AxisLen / 2.5f; + private const float ConeRadius = ConeLen / 3.0f; + + private const float BoundsPadding = 0.1f; + + // Axis + private const float AxisBoundsLengthMin = CubeSize + Padding; + private const float AxisBoundsLengthMax = AxisLen + ConeLen * 0.9f; + private const float AxisBoundsRadiusMin = -AxisRadius - BoundsPadding; + private const float AxisBoundsRadiusMax = AxisRadius + BoundsPadding; + + private static readonly BoundingBox XAxisBounds = new( + new Vec3(AxisBoundsLengthMin, AxisBoundsRadiusMin, AxisBoundsRadiusMin), + new Vec3(AxisBoundsLengthMax, AxisBoundsRadiusMax, AxisBoundsRadiusMax)); + + private static readonly BoundingBox YAxisBounds = new( + new Vec3(AxisBoundsRadiusMin, AxisBoundsLengthMin, AxisBoundsRadiusMin), + new Vec3(AxisBoundsRadiusMax, AxisBoundsLengthMax, AxisBoundsRadiusMax)); + + private static readonly BoundingBox ZAxisBounds = new( + new Vec3(AxisBoundsRadiusMin, AxisBoundsRadiusMin, AxisBoundsLengthMin), + new Vec3(AxisBoundsRadiusMax, AxisBoundsRadiusMax, AxisBoundsLengthMax)); + + // Planes + private const float PlaneBoundsMin = CubeSize + AxisLen / 2.0f - PlaneSize / 2.0f - BoundsPadding; + private const float PlaneBoundsMax = CubeSize + AxisLen / 2.0f + PlaneSize / 2.0f + BoundsPadding; + + private static readonly BoundingBox XZPlaneBounds = new( + new Vec3(PlaneBoundsMin, 0.0f, PlaneBoundsMin), + new Vec3(PlaneBoundsMax, 0.0f, PlaneBoundsMax)); + + private static readonly BoundingBox YZPlaneBounds = new( + new Vec3(0.0f, PlaneBoundsMin, PlaneBoundsMin), + new Vec3(0.0f, PlaneBoundsMax, PlaneBoundsMax)); + + private static readonly BoundingBox XYPlaneBounds = new( + new Vec3(PlaneBoundsMin, PlaneBoundsMin, 0.0f), + new Vec3(PlaneBoundsMax, PlaneBoundsMax, 0.0f)); + + // Cube + private static readonly BoundingBox XYZCubeBounds = new( + -new Vec3(CubeSize + BoundsPadding), + new Vec3(CubeSize + BoundsPadding)); + + // TODO: Please cache these properties + public Matrix Transform + { + get + { + var position = getPosition(); + + const float minScale = 10.0f; + float distanceScale = Math.Max(minScale, Vec3.Distance(EditorWorld.Current.Camera.Position, position) / 20.0f); + + return Matrix.CreateScale(distanceScale * scale) * + Matrix.CreateTranslation(position); + } + } + + private readonly PositionGizmoSelectionTarget[] selectionTargets; + public override IEnumerable SelectionTargets => selectionTargets; + + public PositionGizmo(GetPositionDelegate getPosition, SetPositionDelegate setPosition, float scale) + { + this.getPosition = getPosition; + this.setPosition = setPosition; + this.scale = scale; + + selectionTargets = [ + new PositionGizmoSelectionTarget(this, GizmoTarget.AxisX, XAxisBounds), + new PositionGizmoSelectionTarget(this, GizmoTarget.AxisY, YAxisBounds), + new PositionGizmoSelectionTarget(this, GizmoTarget.AxisZ, ZAxisBounds), + + new PositionGizmoSelectionTarget(this, GizmoTarget.PlaneXZ, XZPlaneBounds), + new PositionGizmoSelectionTarget(this, GizmoTarget.PlaneYZ, YZPlaneBounds), + new PositionGizmoSelectionTarget(this, GizmoTarget.PlaneXY, XYPlaneBounds), + + new PositionGizmoSelectionTarget(this, GizmoTarget.CubeXYZ, XYZCubeBounds), + ]; + } + + public override void Render(Batcher3D batch3D) + { + // Check which part is targeted + target = GizmoTarget.None; + foreach (var selectionTarget in selectionTargets) + { + if (selectionTarget.IsHovered || selectionTarget.IsDragged) + { + target = selectionTarget.Target; + break; + } + } + + const byte normalAlpha = 0xff; + const byte hoverAlpha = 0xff; + const byte dragAlpha = 0xff; + + var xColorNormal = new Color(0xde1100, normalAlpha); + var xColorHover = new Color(0xff6450, hoverAlpha); + var xColorDrag = new Color(0xff9989, dragAlpha); + + var yColorNormal = new Color(0x4aed00, normalAlpha); + var yColorHover = new Color(0x83ff66, hoverAlpha); + var yColorDrag = new Color(0xccffbe, dragAlpha); + + var zColorNormal = new Color(0x0d00f3, normalAlpha); + var zColorHover = new Color(0x3064ff, hoverAlpha); + var zColorDrag = new Color(0x6693ff, dragAlpha); + + var xyzColorNormal = new Color(0xc7c7c7, normalAlpha); + var xyzColorHover = new Color(0xe2e2e2, hoverAlpha); + var xyzColorDrag = new Color(0xffffff, dragAlpha); + + var xAxisColor = target == GizmoTarget.AxisX + ? Input.Mouse.LeftDown ? xColorDrag : xColorHover + : xColorNormal; + var yAxisColor = target == GizmoTarget.AxisY + ? Input.Mouse.LeftDown ? yColorDrag : yColorHover + : yColorNormal; + var zAxisColor = target == GizmoTarget.AxisZ + ? Input.Mouse.LeftDown ? zColorDrag : zColorHover + : zColorNormal; + + var xzPlaneColor = target == GizmoTarget.PlaneXZ + ? Input.Mouse.LeftDown ? yColorDrag : yColorHover + : yColorNormal; + var yzPlaneColor = target == GizmoTarget.PlaneYZ + ? Input.Mouse.LeftDown ? xColorDrag : xColorHover + : xColorNormal; + var xyPlaneColor = target == GizmoTarget.PlaneXY + ? Input.Mouse.LeftDown ? zColorDrag : zColorHover + : zColorNormal; + + var xyzCubeColor = target == GizmoTarget.CubeXYZ + ? Input.Mouse.LeftDown ? xyzColorDrag : xyzColorHover + : xyzColorNormal; + + // X + batch3D.Line(Vec3.UnitX * (CubeSize + Padding), Vec3.UnitX * AxisLen, xAxisColor, Transform, AxisRadius); + batch3D.Cone(Vec3.UnitX * AxisLen, Batcher3D.Direction.X, ConeLen, ConeRadius, 12, xAxisColor, Transform); + // Y + batch3D.Line(Vec3.UnitY * (CubeSize + Padding), Vec3.UnitY * AxisLen, yAxisColor, Transform, AxisRadius); + batch3D.Cone(Vec3.UnitY * AxisLen, Batcher3D.Direction.Y, ConeLen, ConeRadius, 12, yAxisColor, Transform); + // Z + batch3D.Line(Vec3.UnitZ * (CubeSize + Padding), Vec3.UnitZ * AxisLen, zAxisColor, Transform, AxisRadius); + batch3D.Cone(Vec3.UnitZ * AxisLen, Batcher3D.Direction.Z, ConeLen, ConeRadius, 12, zAxisColor, Transform); + + // XZ + batch3D.Square(Vec3.UnitX * (CubeSize + AxisLen / 2.0f) + Vec3.UnitZ * (CubeSize + AxisLen / 2.0f), + Vec3.UnitY, xzPlaneColor, Transform, PlaneSize / 2.0f); + // YZ + batch3D.Square(Vec3.UnitY * (CubeSize + AxisLen / 2.0f) + Vec3.UnitZ * (CubeSize + AxisLen / 2.0f), + Vec3.UnitX, yzPlaneColor, Transform, PlaneSize / 2.0f); + // XY + batch3D.Square(Vec3.UnitX * (CubeSize + AxisLen / 2.0f) + Vec3.UnitY * (CubeSize + AxisLen / 2.0f), + Vec3.UnitZ, xyPlaneColor, Transform, PlaneSize / 2.0f); + + // XYZ + batch3D.Cube(Vec3.Zero, xyzCubeColor, Transform, CubeSize); + } + + private void DragStart() + { + beforeDragPosition = getPosition(); + } + + private void Drag(Vec2 mouseDelta, Vec3 mouseRay) + { + var editor = EditorWorld.Current; + + var axisMatrix = Transform * editor.Camera.ViewProjection; + var screenXAxis = Vec3.TransformNormal(Vec3.UnitX, axisMatrix).XY(); + var screenYAxis = Vec3.TransformNormal(Vec3.UnitY, axisMatrix).XY(); + var screenZAxis = Vec3.TransformNormal(Vec3.UnitZ, axisMatrix).XY(); + // Flip Y, since down is positive in screen coords + screenXAxis.Y *= -1.0f; + screenYAxis.Y *= -1.0f; + screenZAxis.Y *= -1.0f; + + // Linear scalar for the movement. Chosen on what felt best. + const float dotScale = 1.0f / 50.0f; + float dotX = Vec2.Dot(mouseDelta, screenXAxis) * dotScale; + float dotY = Vec2.Dot(mouseDelta, screenYAxis) * dotScale; + float dotZ = Vec2.Dot(mouseDelta, screenZAxis) * dotScale; + + Vec3 newPosition = getPosition(); + + var xzPlaneDelta = Vec3.Transform(XZPlaneBounds.Center, Transform) - newPosition; + var yzPlaneDelta = Vec3.Transform(YZPlaneBounds.Center, Transform) - newPosition; + var xyPlaneDelta = Vec3.Transform(XYPlaneBounds.Center, Transform) - newPosition; + + var cameraPlaneNormal = (editor.Camera.Position - beforeDragPosition).Normalized(); + var cameraPlane = new Plane(cameraPlaneNormal, Vec3.Dot(cameraPlaneNormal, beforeDragPosition)); + + switch (target) + { + case GizmoTarget.AxisX: + newPosition = beforeDragPosition + Vec3.UnitX * dotX; + break; + case GizmoTarget.AxisY: + newPosition = beforeDragPosition + Vec3.UnitY * dotY; + break; + case GizmoTarget.AxisZ: + newPosition = beforeDragPosition + Vec3.UnitZ * dotZ; + break; + + case GizmoTarget.PlaneXZ: + float tY = (beforeDragPosition.Y - editor.Camera.Position.Y) / mouseRay.Y; + newPosition = editor.Camera.Position + mouseRay * tY - xzPlaneDelta; + break; + case GizmoTarget.PlaneYZ: + float tX = (beforeDragPosition.X - editor.Camera.Position.X) / mouseRay.X; + newPosition = editor.Camera.Position + mouseRay * tX - yzPlaneDelta; + break; + case GizmoTarget.PlaneXY: + float tZ = (beforeDragPosition.Z - editor.Camera.Position.Z) / mouseRay.Z; + newPosition = editor.Camera.Position + mouseRay * tZ - xyPlaneDelta; + break; + + case GizmoTarget.CubeXYZ: + if (ModUtils.RayIntersectsPlane(editor.Camera.Position, mouseRay, cameraPlane, out var hit)) + { + newPosition = hit; + } + break; + + case GizmoTarget.None: + default: + break; + } + + setPosition(newPosition); + } +} + diff --git a/Source/Mod/Editor/Selection/SelectionTarget.cs b/Source/Mod/Editor/Selection/SelectionTarget.cs new file mode 100644 index 00000000..b7c67510 --- /dev/null +++ b/Source/Mod/Editor/Selection/SelectionTarget.cs @@ -0,0 +1,33 @@ +namespace Celeste64.Mod.Editor; + +public abstract class SelectionTarget +{ + public abstract Matrix Transform { get; } + public abstract BoundingBox Bounds { get; } + + public bool IsHovered { get; internal set; } = false; + public bool IsDragged { get; internal set; } = false; + + public virtual void Update() { } + public virtual void Render(ref RenderState state, Batcher3D batch3D) { } + + public virtual void Hovered() { } + public virtual void Selected() { } + public virtual void Dragged(Vec2 mouseDelta, Vec3 mouseRay) { } +} + +// TODO: Is this a good name? It provides Actions so you don't need to create a subclass for every type +public class SimpleSelectionTarget(Matrix transform, BoundingBox bounds) : SelectionTarget +{ + public override Matrix Transform { get; } = transform; + public override BoundingBox Bounds { get; } = bounds; + + public Action? OnHovered = null; + public Action? OnSelected = null; + public Action? OnDragged = null; + + public override void Hovered() => OnHovered?.Invoke(); + public override void Selected() => OnSelected?.Invoke(); + public override void Dragged(Vec2 mouseDelta, Vec3 mouseRay) => OnDragged?.Invoke(mouseDelta, mouseRay); +} + diff --git a/Source/Mod/Editor/Selection/SelectionType.cs b/Source/Mod/Editor/Selection/SelectionType.cs new file mode 100644 index 00000000..c025b147 --- /dev/null +++ b/Source/Mod/Editor/Selection/SelectionType.cs @@ -0,0 +1,9 @@ +namespace Celeste64.Mod.Editor; + +public abstract class SelectionType +{ + public abstract IEnumerable Targets { get; } + public abstract IEnumerable Gizmos { get; } + + public virtual void Awake() { } +} diff --git a/Source/Mod/Editor/Tool/MoveTool.cs b/Source/Mod/Editor/Tool/MoveTool.cs new file mode 100644 index 00000000..0146d778 --- /dev/null +++ b/Source/Mod/Editor/Tool/MoveTool.cs @@ -0,0 +1,43 @@ +using System.Reflection; + +namespace Celeste64.Mod.Editor; + +public class MoveTool : Tool +{ + public override string Name => "Move"; + public override Gizmo[] Gizmos => gizmo == null ? [] : [gizmo]; + public override bool EnableSelection => true; + + private Gizmo? gizmo; + + public override void Awake(EditorWorld editor) + { + editor.OnDefinitionSelected += def => + { + if (def is null) + { + gizmo = null; + return; + } + + var positionProp = def + .GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(prop => + !prop.HasAttr() && + prop.GetCustomAttribute() is { Value: SpecialPropertyType.PositionXYZ }); + + if (positionProp is null || positionProp.GetGetMethod() is not { } getMethod || positionProp.GetSetMethod() is not { } setMethod) + return; + + gizmo = new PositionGizmo( + () => (Vec3)getMethod.Invoke(def, [])!, + newValue => + { + setMethod.Invoke(def, [newValue]); + def.Dirty = true; + }, + scale: 1.0f); + }; + } +} diff --git a/Source/Mod/Editor/Tool/PlaceActorTool.cs b/Source/Mod/Editor/Tool/PlaceActorTool.cs new file mode 100644 index 00000000..1cc4537b --- /dev/null +++ b/Source/Mod/Editor/Tool/PlaceActorTool.cs @@ -0,0 +1,86 @@ +using System.Reflection; + +namespace Celeste64.Mod.Editor; + +public class PlaceActorTool : Tool +{ + public override string Name => "Place Actor"; + public override Gizmo[] Gizmos => []; + public override bool EnableSelection => false; + + // TODO: Detect these definitions somehow + internal readonly List Definitions = [typeof(SpikeBlock.Definition), typeof(Solid.Definition)]; + + private Type currentDefinition = null!; // Indirectly initialized in constructor + internal Type CurrentDefinition + { + get => currentDefinition; + set + { + if (currentDefinition == value) + return; + + currentDefinition = value; + definitionToPlace = (ActorDefinition)Activator.CreateInstance(value)!; + + var prop = value + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(prop => + !prop.HasAttr() && + prop.GetCustomAttribute() is { Value: SpecialPropertyType.PositionXYZ }); + + if (prop is null || prop.GetGetMethod() is not { } getMethod || prop.GetSetMethod() is not { } setMethod) + positionProp = null; + else + positionProp = prop; + } + } + + private Actor[] actorsToPlace = []; + private ActorDefinition? definitionToPlace = null; + private PropertyInfo? positionProp; + + public PlaceActorTool() + { + CurrentDefinition = Definitions[0]; + } + + public override void OnDeselectTool(EditorWorld editor) + { + foreach (var actor in actorsToPlace) + { + editor.Destroy(actor); + } + actorsToPlace = []; + } + + public override void Update(EditorWorld editor) + { + if (positionProp is null || definitionToPlace is null) + return; + + // TODO: Choose the placement position more intelligently: configurable distance, place along geometry, etc. + positionProp.SetValue(definitionToPlace, editor.Camera.Position + editor.MouseRay * 250.0f); + + foreach (var actor in actorsToPlace) + { + editor.Destroy(actor); + } + + if (!ImGuiManager.WantCaptureMouse && Input.Mouse.LeftPressed) + { + // Place the definition. + editor.AddDefinition(definitionToPlace); + // Generate a new definition + actors + definitionToPlace = (ActorDefinition)Activator.CreateInstance(currentDefinition)!; + actorsToPlace = []; + return; + } + + actorsToPlace = definitionToPlace.Load(World.WorldType.Editor); + foreach (var actor in actorsToPlace) + { + editor.Add(actor); + } + } +} diff --git a/Source/Mod/Editor/Tool/Tool.cs b/Source/Mod/Editor/Tool/Tool.cs new file mode 100644 index 00000000..6223d856 --- /dev/null +++ b/Source/Mod/Editor/Tool/Tool.cs @@ -0,0 +1,16 @@ +namespace Celeste64.Mod.Editor; + +public abstract class Tool +{ + public abstract string Name { get; } + public abstract Gizmo[] Gizmos { get; } + public abstract bool EnableSelection { get; } + + public virtual void Awake(EditorWorld editor) { } + + public virtual void OnSelectTool(EditorWorld editor) { } + public virtual void OnDeselectTool(EditorWorld editor) { } + + public virtual void Update(EditorWorld editor) { } + public virtual void Render(EditorWorld editor) { } // TODO: Implement in EditorWorld +} diff --git a/Source/Mod/Helpers/Batcher3D.cs b/Source/Mod/Helpers/Batcher3D.cs new file mode 100644 index 00000000..91743e04 --- /dev/null +++ b/Source/Mod/Helpers/Batcher3D.cs @@ -0,0 +1,653 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Celeste64.Mod; + +/// +/// A version of the in 3D instead of 2D. +/// +public class Batcher3D +{ + /// + /// Vertex Format of Batcher.Vertex + /// + private static readonly VertexFormat VertexFormat = VertexFormat.Create( + new VertexFormat.Element(0, VertexType.Float3, false), + new VertexFormat.Element(1, VertexType.Float2, false), + new VertexFormat.Element(2, VertexType.UByte4, true) + ); + + /// + /// The Vertex Layout used for Sprite Batching + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct Vertex(Vec3 position, Vec2 texcoord, Color color) : IVertex + { + public Vec3 Pos = position; + public Vec2 Tex = texcoord; + public Color Col = color; + + public readonly VertexFormat Format => VertexFormat; + } + + public enum Direction { X, Y, Z } + + ~Batcher3D() + { + if (vertexPtr != IntPtr.Zero) + Marshal.FreeHGlobal(vertexPtr); + if (indexPtr != IntPtr.Zero) + Marshal.FreeHGlobal(indexPtr); + } + + private IntPtr vertexPtr = IntPtr.Zero; + private int vertexCount = 0; + private int vertexCapacity = 0; + + private IntPtr indexPtr = IntPtr.Zero; + private int indexCount = 0; + private int indexCapacity = 0; + + private readonly Mesh mesh = new(); + private readonly Material material = new(Assets.Shaders["Sprite"]); + private bool dirty = false; + + public void Square(Vec3 center, Vec3 normal, Color color, float size = 0.1f) => Square(center, normal, color, Matrix.Identity, size); + public void Square(Vec3 center, Vec3 normal, Color color, Matrix transform, float size = 0.1f) + { + var (tangent, bitangent) = GetTangentVectors(normal); + + tangent *= size; + bitangent *= size; + + Quad(center - tangent - bitangent, + center + tangent - bitangent, + center - tangent + bitangent, + center + tangent + bitangent, + color, transform); + } + + public void Line(Vec3 from, Vec3 to, Color color, float thickness = 0.1f) => Line(from, to, color, Matrix.Identity, thickness); + public void Line(Vec3 from, Vec3 to, Color color, Matrix transform, float thickness = 0.1f) + { + var normal = (to - from).Normalized(); + var (tangent, bitangent) = GetTangentVectors(normal); + + tangent *= thickness; + bitangent *= thickness; + + Box(from - tangent - bitangent, from - tangent + bitangent, from + tangent - bitangent, from + tangent + bitangent, + to - tangent - bitangent, to - tangent + bitangent, to + tangent - bitangent, to + tangent + bitangent, + color, transform); + } + + public void Cube(Vec3 center, Color color, float size = 0.1f) => Cube(center, color, Matrix.Identity, size); + public void Cube(Vec3 center, Color color, Matrix transform, float size = 0.1f) + { + Box(center + new Vec3(-size, -size, -size), + center + new Vec3(size, -size, -size), + center + new Vec3(-size, size, -size), + center + new Vec3(size, size, -size), + center + new Vec3(-size, -size, size), + center + new Vec3(size, -size, size), + center + new Vec3(-size, size, size), + center + new Vec3(size, size, size), + color, transform + ); + } + + public void Torus(Vec3 center, float radius, int resolution, Color color, float thickness = 0.1f) => Torus(center, radius, resolution, color, Matrix.Identity, thickness); + public void Torus(Vec3 center, float radius, int resolution, Color color, Matrix transform, float thickness = 0.1f) + { + var points = new Vec3[resolution]; + + float angleStep = Calc.TAU / resolution; + for (int i = 0; i < resolution; i++) + { + points[i] = new Vec3(Calc.AngleToVector(i * angleStep, radius), 0.0f); + } + + int vtxCount = resolution * 4; // 4 vertices each + int idxCount = resolution * 4 * 2 * 3; // 4 faces * 2 triangles * 3 vertices each + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + for (int i = 0; i < resolution; i++) + { + var normal = points[i].Normalized() * thickness; + var up = new Vec3(0.0f, 0.0f, thickness); + + vertices[i * 4 + 0].Pos = Vec3.Transform(center + points[i] + normal - up, transform); + vertices[i * 4 + 1].Pos = Vec3.Transform(center + points[i] - normal - up, transform); + vertices[i * 4 + 2].Pos = Vec3.Transform(center + points[i] + normal + up, transform); + vertices[i * 4 + 3].Pos = Vec3.Transform(center + points[i] - normal + up, transform); + vertices[i * 4 + 0].Col = color; + vertices[i * 4 + 1].Col = color; + vertices[i * 4 + 2].Col = color; + vertices[i * 4 + 3].Col = color; + } + + for (int i = 0; i < resolution; i++) + { + int curr = i; + int prev = i == 0 ? resolution - 1 : i - 1; // Wrap around to the end + + // Bottom + indices[i * (4 * 2 * 3) + 0] = vertexCount + prev * 4 + 0; + indices[i * (4 * 2 * 3) + 1] = vertexCount + curr * 4 + 0; + indices[i * (4 * 2 * 3) + 2] = vertexCount + prev * 4 + 1; + indices[i * (4 * 2 * 3) + 3] = vertexCount + prev * 4 + 0; + indices[i * (4 * 2 * 3) + 4] = vertexCount + curr * 4 + 0; + indices[i * (4 * 2 * 3) + 5] = vertexCount + curr * 4 + 1; + // Top + indices[i * (4 * 2 * 3) + 6] = vertexCount + prev * 4 + 2; + indices[i * (4 * 2 * 3) + 7] = vertexCount + prev * 4 + 3; + indices[i * (4 * 2 * 3) + 8] = vertexCount + curr * 4 + 3; + indices[i * (4 * 2 * 3) + 9] = vertexCount + prev * 4 + 2; + indices[i * (4 * 2 * 3) + 10] = vertexCount + curr * 4 + 3; + indices[i * (4 * 2 * 3) + 11] = vertexCount + curr * 4 + 2; + // Outer + indices[i * (4 * 2 * 3) + 12] = vertexCount + prev * 4 + 0; + indices[i * (4 * 2 * 3) + 13] = vertexCount + curr * 4 + 2; + indices[i * (4 * 2 * 3) + 14] = vertexCount + prev * 4 + 2; + indices[i * (4 * 2 * 3) + 15] = vertexCount + prev * 4 + 0; + indices[i * (4 * 2 * 3) + 16] = vertexCount + curr * 4 + 0; + indices[i * (4 * 2 * 3) + 17] = vertexCount + curr * 4 + 2; + // Inner + indices[i * (4 * 2 * 3) + 18] = vertexCount + prev * 4 + 1; + indices[i * (4 * 2 * 3) + 19] = vertexCount + prev * 4 + 3; + indices[i * (4 * 2 * 3) + 20] = vertexCount + curr * 4 + 3; + indices[i * (4 * 2 * 3) + 21] = vertexCount + prev * 4 + 1; + indices[i * (4 * 2 * 3) + 22] = vertexCount + curr * 4 + 3; + indices[i * (4 * 2 * 3) + 23] = vertexCount + curr * 4 + 1; + } + } + + vertexCount += vtxCount; + indexCount += idxCount; + dirty = true; + } + + public void Disk(Vec3 center, float radius, int resolution, Color color, float thickness = 0.1f) => Disk(center, radius, resolution, color, Matrix.Identity, thickness); + public void Disk(Vec3 center, float radius, int resolution, Color color, Matrix transform, float thickness = 0.1f) + { + var points = new Vec3[resolution]; + + float angleStep = Calc.TAU / resolution; + for (int i = 0; i < resolution; i++) + { + points[i] = new Vec3(Calc.AngleToVector(i * angleStep, radius), 0.0f); + } + + int vtxCount = resolution * 2 + 2; // 2 vertices each + 2 in the center + int idxCount = resolution * 4 * 3; // 1 faces for outside + 2 triangles on top/bottom = 4 triangles * 3 vertices each + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + var up = new Vec3(0.0f, 0.0f, thickness); + vertices[0].Pos = Vec3.Transform(center - up, transform); + vertices[1].Pos = Vec3.Transform(center + up, transform); + vertices[0].Col = color; + vertices[1].Col = color; + + for (int i = 0; i < resolution; i++) + { + vertices[(i * 2 + 2) + 0].Pos = Vec3.Transform(center + points[i] - up, transform); + vertices[(i * 2 + 2) + 1].Pos = Vec3.Transform(center + points[i] + up, transform); + vertices[(i * 2 + 2) + 0].Col = color; + vertices[(i * 2 + 2) + 1].Col = color; + } + + for (int i = 0; i < resolution; i++) + { + int curr = i; + int prev = i == 0 ? resolution - 1 : i - 1; // Wrap around to the end + + // Bottom + indices[i * (4 * 3) + 0] = vertexCount + (prev * 2 + 2) + 0; + indices[i * (4 * 3) + 1] = vertexCount + (curr * 2 + 2) + 0; + indices[i * (4 * 3) + 2] = vertexCount + 0; + // Top + indices[i * (4 * 3) + 3] = vertexCount + (curr * 2 + 2) + 1; + indices[i * (4 * 3) + 4] = vertexCount + (prev * 2 + 2) + 1; + indices[i * (4 * 3) + 5] = vertexCount + 1; + // Outer + indices[i * (4 * 3) + 6] = vertexCount + (curr * 2 + 2) + 1; + indices[i * (4 * 3) + 7] = vertexCount + (curr * 2 + 2) + 0; + indices[i * (4 * 3) + 8] = vertexCount + (prev * 2 + 2) + 0; + indices[i * (4 * 3) + 9] = vertexCount + (curr * 2 + 2) + 1; + indices[i * (4 * 3) + 10] = vertexCount + (prev * 2 + 2) + 0; + indices[i * (4 * 3) + 11] = vertexCount + (prev * 2 + 2) + 1; + } + } + + vertexCount += vtxCount; + indexCount += idxCount; + dirty = true; + } + + public void Cone(Vec3 position, Direction direction, float length, float radius, int resolution, Color color) => Cone(position, direction, length, radius, resolution, color, Matrix.Identity); + public void Cone(Vec3 position, Direction direction, float length, float radius, int resolution, Color color, Matrix transform) + { + var points = new Vec3[resolution]; + + float angleStep = Calc.TAU / resolution; + + for (int i = 0; i < resolution; i++) + { + var vec = Calc.AngleToVector(i * angleStep, radius); + points[i] = direction switch + { + Direction.X => new Vec3(0.0f, vec.X, vec.Y), + Direction.Y => new Vec3(vec.X, 0.0f, vec.Y), + Direction.Z => new Vec3(vec.X, vec.Y, 0.0f), + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null) + }; + } + + int vtxCount = resolution * 2 + 1 + 1; // 2 vertices each + 1 in the base center + 1 at the tip + int idxCount = resolution * 2 * 3; // 2 triangles on top/bottom * 3 vertices each + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + var dir = direction switch + { + Direction.X => Vec3.UnitX, + Direction.Y => Vec3.UnitY, + Direction.Z => Vec3.UnitZ, + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null) + }; + + // Base center + vertices[0].Pos = Vec3.Transform(position, transform); + vertices[0].Col = color; + // Tip + vertices[1].Pos = Vec3.Transform(position + dir * length, transform); + vertices[1].Col = color; + + for (int i = 0; i < resolution; i++) + { + vertices[i + 2].Pos = Vec3.Transform(position + points[i], transform); + vertices[i + 2].Col = color; + } + + for (int i = 0; i < resolution; i++) + { + int curr = i; + int prev = i == 0 ? resolution - 1 : i - 1; // Wrap around to the end + + // Bottom + indices[i * (2 * 3) + 0] = vertexCount + (prev + 2); + indices[i * (2 * 3) + 1] = vertexCount + (curr + 2); + indices[i * (2 * 3) + 2] = vertexCount + 0; + // Top + indices[i * (2 * 3) + 3] = vertexCount + (curr + 2); + indices[i * (2 * 3) + 4] = vertexCount + (prev + 2); + indices[i * (2 * 3) + 5] = vertexCount + 1; + } + } + + vertexCount += vtxCount; + indexCount += idxCount; + dirty = true; + } + + public void Sphere(Vec3 center, float radius, int resolution, Color color) => Sphere(center, radius, resolution, color, Matrix.Identity); + public void Sphere(Vec3 center, float radius, int resolution, Color color, Matrix transform) + { + // Taken and adapted from Utils.CreateSphere() + int stackCount = resolution; + int sliceCount = resolution; + + int vtxCount = 2 + (stackCount - 1) * sliceCount; + int idxCount = sliceCount * 6 + (stackCount - 2) * sliceCount * 6; + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + int vtx = 0; + int idx = 0; + + // Add top vertex + int v0 = vertexCount + vtx; + vertices[vtx++].Pos = center + new Vec3(0.0f, 0.0f, radius); + + // Generate vertices per stack / slice + for (int i = 0; i < stackCount - 1; i++) + { + float phi = MathF.PI * (i + 1) / (float)(stackCount); + for (int j = 0; j < sliceCount; j++) + { + float theta = 2.0f * MathF.PI * (j) / (float)(sliceCount); + float x = radius * MathF.Sin(phi) * MathF.Cos(theta); + float y = radius * MathF.Sin(phi) * MathF.Sin(theta); + float z = radius * MathF.Cos(phi); + vertices[vtx++].Pos = center + new Vec3(x, y, z); + } + } + + // Add bottom vertex + int v1 = vertexCount + vtx; + vertices[vtx++].Pos = center + new Vec3(0.0f, 0.0f, -radius); + + // Fill-in color + for (int i = 0; i < vtx; i++) + vertices[i].Col = color; + + // Add top / bottom triangles + for (int i = 0; i < sliceCount; ++i) + { + int i0 = i + 1; + int i1 = (i + 1) % sliceCount + 1; + indices[idx++] = v0; + indices[idx++] = vertexCount + i1; + indices[idx++] = vertexCount + i0; + + i0 = i + sliceCount * (stackCount - 2) + 1; + i1 = (i + 1) % sliceCount + sliceCount * (stackCount - 2) + 1; + indices[idx++] = v1; + indices[idx++] = vertexCount + i0; + indices[idx++] = vertexCount + i1; + } + + // Add quads per stack / slice + for (int j = 0; j < stackCount - 2; j++) + { + int j0 = j * sliceCount + 1; + int j1 = (j + 1) * sliceCount + 1; + for (int i = 0; i < sliceCount; i++) + { + int i0 = j0 + i; + int i1 = j0 + (i + 1) % sliceCount; + int i2 = j1 + (i + 1) % sliceCount; + int i3 = j1 + i; + indices[idx++] = vertexCount + i0; + indices[idx++] = vertexCount + i1; + indices[idx++] = vertexCount + i2; + indices[idx++] = vertexCount + i0; + indices[idx++] = vertexCount + i2; + indices[idx++] = vertexCount + i3; + } + } + + vertexCount += vtx; + indexCount += idx; + dirty = true; + } + } + + /// + /// Renders a quad of a solid color. + /// + /// Top Left + /// Top Right + /// Bottom Left + /// Bottom Right + /// Box color + public void Quad(Vec3 v0, Vec3 v1, Vec3 v2, Vec3 v3, + Color color, Matrix transform) + { + const int vtxCount = 4; + const int idxCount = 2 * 3; // 2 triangles * 3 vertices + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + vertices[0].Pos = Vec3.Transform(v0, transform); + vertices[1].Pos = Vec3.Transform(v1, transform); + vertices[2].Pos = Vec3.Transform(v2, transform); + vertices[3].Pos = Vec3.Transform(v3, transform); + vertices[0].Col = color; + vertices[1].Col = color; + vertices[2].Col = color; + vertices[3].Col = color; + + indices[0] = vertexCount + 0; + indices[1] = vertexCount + 2; + indices[2] = vertexCount + 1; + indices[3] = vertexCount + 2; + indices[4] = vertexCount + 3; + indices[5] = vertexCount + 1; + } + + vertexCount += vtxCount; + indexCount += idxCount; + dirty = true; + } + + public void Box(Vec3 min, Vec3 max, Color color) => Box(min, max, color, Matrix.Identity); + public void Box(Vec3 min, Vec3 max, Color color, Matrix transform) => + Box(min with { Z = max.Z }, + min with { X = max.X, Z = max.Z }, + min, + min with { X = max.X }, + + max with { X = min.X }, + max, + max with { X = min.X, Z = min.Z }, + max with { Z = min.Z }, + color, transform); + + /// + /// Renders a box of a solid color. + /// + /// Front Top Left + /// Front Top Right + /// Front Bottom Left + /// Front Bottom Right + /// Back Top Left + /// Back Top Right + /// Back Bottom Left + /// Back Bottom Right + /// Box color + public void Box(Vec3 v0, Vec3 v1, Vec3 v2, Vec3 v3, + Vec3 v4, Vec3 v5, Vec3 v6, Vec3 v7, + Color color, Matrix transform) + { + const int vtxCount = 8; + const int idxCount = 6 * 2 * 3; // 6 faces * 2 triangles * 3 vertices + + EnsureVertexCapacity(vertexCount + vtxCount); + EnsureIndexCapacity(indexCount + idxCount); + + unsafe + { + var vertices = new Span((Vertex*)vertexPtr + vertexCount, vtxCount); + var indices = new Span((int*)indexPtr + indexCount, idxCount); + + vertices[0].Pos = Vec3.Transform(v0, transform); + vertices[1].Pos = Vec3.Transform(v1, transform); + vertices[2].Pos = Vec3.Transform(v2, transform); + vertices[3].Pos = Vec3.Transform(v3, transform); + vertices[4].Pos = Vec3.Transform(v4, transform); + vertices[5].Pos = Vec3.Transform(v5, transform); + vertices[6].Pos = Vec3.Transform(v6, transform); + vertices[7].Pos = Vec3.Transform(v7, transform); + vertices[0].Col = color; + vertices[1].Col = color; + vertices[2].Col = color; + vertices[3].Col = color; + vertices[4].Col = color; + vertices[5].Col = color; + vertices[6].Col = color; + vertices[7].Col = color; + + // Front + indices[0] = vertexCount + 0; + indices[1] = vertexCount + 2; + indices[2] = vertexCount + 1; + indices[3] = vertexCount + 2; + indices[4] = vertexCount + 3; + indices[5] = vertexCount + 1; + // Back + indices[6] = vertexCount + 4; + indices[7] = vertexCount + 5; + indices[8] = vertexCount + 6; + indices[9] = vertexCount + 5; + indices[10] = vertexCount + 7; + indices[11] = vertexCount + 6; + // Left + indices[12] = vertexCount + 0; + indices[13] = vertexCount + 6; + indices[14] = vertexCount + 2; + indices[15] = vertexCount + 0; + indices[16] = vertexCount + 4; + indices[17] = vertexCount + 6; + // Right + indices[18] = vertexCount + 1; + indices[19] = vertexCount + 3; + indices[20] = vertexCount + 7; + indices[21] = vertexCount + 1; + indices[22] = vertexCount + 7; + indices[23] = vertexCount + 5; + // Top + indices[24] = vertexCount + 0; + indices[25] = vertexCount + 1; + indices[26] = vertexCount + 5; + indices[27] = vertexCount + 0; + indices[28] = vertexCount + 5; + indices[29] = vertexCount + 4; + // Bottom + indices[30] = vertexCount + 2; + indices[31] = vertexCount + 7; + indices[32] = vertexCount + 6; + indices[33] = vertexCount + 2; + indices[34] = vertexCount + 3; + indices[35] = vertexCount + 7; + } + + vertexCount += vtxCount; + indexCount += idxCount; + dirty = true; + } + + /// + /// Draws the Batcher3D to the given Target with the given RenderState + /// + public void Render(ref RenderState state) + { + if (indexPtr == IntPtr.Zero || vertexPtr == IntPtr.Zero) + return; + + // Upload our data if we've been modified since the last time we rendered + if (dirty) + { + mesh.SetIndices(indexPtr, indexCount, IndexFormat.ThirtyTwo); + mesh.SetVertices(vertexPtr, vertexCount, VertexFormat); + dirty = false; + } + + if (material.Shader?.Has("u_matrix") ?? false) + material.Set("u_matrix", state.Camera.ViewProjection); + if (material.Shader?.Has("u_far") ?? false) + material.Set("u_far", state.Camera.FarPlane); + if (material.Shader?.Has("u_near") ?? false) + material.Set("u_near", state.Camera.NearPlane); + if (material.Shader?.Has("u_texture") ?? false) + material.Set("u_texture", Assets.Textures["white"]); + + var call = new DrawCommand(state.Camera.Target, mesh, material) + { + // BlendMode = BlendMode.Screen, + DepthCompare = state.DepthCompare, + DepthMask = state.DepthMask, + // DepthMask = false, + // DepthCompare = DepthCompare.Less, + CullMode = CullMode.None, + MeshIndexStart = 0, + MeshIndexCount = indexCount, + }; + call.Submit(); + state.Calls++; + state.Triangles += indexCount / 3; + } + + /// + /// Clears the Batcher3D. + /// + public void Clear() + { + vertexCount = 0; + indexCount = 0; + } + + private (Vec3 Tangent, Vec3 Bitangent) GetTangentVectors(Vec3 normal) + { + // The other vector for the cross product can't be parallel to the normal + var tangent = Math.Abs(Vec3.Dot(normal, Vec3.UnitX)) < 0.5f ? Vec3.Cross(normal, Vec3.UnitX) : Vec3.Cross(normal, Vec3.UnitY); + var bitangent = Vec3.Cross(normal, tangent); + return (tangent, bitangent); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private unsafe void EnsureVertexCapacity(int capacity) + { + if (capacity < vertexCapacity) return; + + if (vertexCapacity == 0) + vertexCapacity = 32; + + while (capacity >= vertexCapacity) + vertexCapacity *= 2; + + IntPtr newPtr = Marshal.AllocHGlobal(sizeof(Vertex) * vertexCapacity); + + if (vertexCount > 0) + Buffer.MemoryCopy((void*)vertexPtr, (void*)newPtr, vertexCapacity * sizeof(Vertex), vertexCount * sizeof(Vertex)); + + if (vertexPtr != IntPtr.Zero) + Marshal.FreeHGlobal(vertexPtr); + + vertexPtr = newPtr; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private unsafe void EnsureIndexCapacity(int capacity) + { + if (capacity < indexCapacity) return; + + if (indexCapacity == 0) + indexCapacity = 32; + + while (capacity >= indexCapacity) + indexCapacity *= 2; + + IntPtr newPtr = Marshal.AllocHGlobal(sizeof(int) * indexCapacity); + + if (indexCount > 0) + Buffer.MemoryCopy((void*)indexPtr, (void*)newPtr, indexCapacity * sizeof(int), indexCount * sizeof(int)); + + if (indexPtr != IntPtr.Zero) + Marshal.FreeHGlobal(indexPtr); + + indexPtr = newPtr; + } +} diff --git a/Source/Mod/Helpers/BinaryExtensions.cs b/Source/Mod/Helpers/BinaryExtensions.cs new file mode 100644 index 00000000..30583d3f --- /dev/null +++ b/Source/Mod/Helpers/BinaryExtensions.cs @@ -0,0 +1,27 @@ +namespace Celeste64.Mod; + +public static class BinaryExtensions +{ + public static void Write(this BinaryWriter writer, Vec2 value) + { + writer.Write(value.X); + writer.Write(value.Y); + } + public static void Write(this BinaryWriter writer, Vec3 value) + { + writer.Write(value.X); + writer.Write(value.Y); + writer.Write(value.Z); + } + public static void Write(this BinaryWriter writer, Color value) + { + writer.Write(value.R); + writer.Write(value.G); + writer.Write(value.B); + writer.Write(value.A); + } + + public static Vec2 ReadVec2(this BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle()); + public static Vec3 ReadVec3(this BinaryReader reader) => new(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + public static Color ReadColor(this BinaryReader reader) => new(reader.ReadByte(), reader.ReadByte(), reader.ReadByte(), reader.ReadByte()); +} diff --git a/Source/Mod/Helpers/InputHelper.cs b/Source/Mod/Helpers/InputHelper.cs new file mode 100644 index 00000000..2af57f65 --- /dev/null +++ b/Source/Mod/Helpers/InputHelper.cs @@ -0,0 +1,8 @@ +namespace Celeste64.Mod.Helpers; + +public static class InputHelper +{ + public static Vec2 MouseDelta => ImGuiManager.WantCaptureMouse + ? Vec2.Zero + : Input.State.Mouse.Position - Input.LastState.Mouse.Position; +} diff --git a/Source/Mod/Helpers/ModUtils.cs b/Source/Mod/Helpers/ModUtils.cs new file mode 100644 index 00000000..b81139c0 --- /dev/null +++ b/Source/Mod/Helpers/ModUtils.cs @@ -0,0 +1,110 @@ +namespace Celeste64.Mod; + +public static class ModUtils +{ + // Based on Unity's implementation + // See: https://github.com/Unity-Technologies/Graphics/blob/17c1d4655ea4685128801d617bdc8d624e586bc6/Packages/com.unity.render-pipelines.core/ShaderLibrary/GeometricTools.hlsl#L48-L75 + public static bool RayIntersectsBox(Vec3 origin, Vec3 direction, BoundingBox box, out float tEnter, out float tExit) + { + // Could be precomputed. Clamp to avoid INF. clamp() is a single ALU on GCN. + // rcp(FLT_EPS) = 16,777,216, which is large enough for our purposes, + // yet doesn't cause a lot of numerical issues associated with FLT_MAX. + // float3 rayDirInv = clamp(rcp(rayDirection), -rcp(FLT_EPS), rcp(FLT_EPS)); + var dirInv = Vec3.Clamp(Vec3.One / direction, new Vec3(-1.0f / float.Epsilon), new Vec3(1.0f / float.Epsilon)); + + // Perform ray-slab intersection (component-wise). + // float3 t0 = boxMin * rayDirInv - (rayOrigin * rayDirInv); + // float3 t1 = boxMax * rayDirInv - (rayOrigin * rayDirInv); + var t0 = box.Min * dirInv - (origin * dirInv); + var t1 = box.Max * dirInv - (origin * dirInv); + + // Find the closest/farthest distance (component-wise). + // float3 tSlabEntr = min(t0, t1); + // float3 tSlabExit = max(t0, t1); + var tSlabEnter = Vec3.Min(t0, t1); + var tSlabExit = Vec3.Max(t0, t1); + + // Find the farthest entry and the nearest exit. + // tEntr = Max3(tSlabEntr.x, tSlabEntr.y, tSlabEntr.z); + // tExit = Min3(tSlabExit.x, tSlabExit.y, tSlabExit.z); + tEnter = Math.Max(tSlabEnter.X, Math.Max(tSlabEnter.Y, tSlabEnter.Z)); + tExit = Math.Min(tSlabExit.X, Math.Min(tSlabExit.Y, tSlabExit.Z)); + + // Clamp to the range. + // tEntr = max(tEntr, tMin); + // tExit = min(tExit, tMax); + + return tEnter < tExit; + } + + // "Box Intersection Generic" from https://iquilezles.org/articles/boxfunctions + public static bool RayIntersectOBB(Vec3 origin, Vec3 direction, BoundingBox box, Matrix transform, out float t) + { + t = 0.0f; + if (!Matrix.Invert(transform, out var inverse)) + return false; + + // The center of the bounding box needs to be at <0,0,0> + inverse *= Matrix.CreateTranslation(-box.Center); + + // convert from world to box space + var ro = Vec3.Transform(origin, inverse); + var rd = Vec3.TransformNormal(direction, inverse); + + var rad = box.Size / 2.0f; + + // ray-box intersection in box space + var m = Vec3.One / rd; + var s = new Vec3( + (rd.X < 0.0f) ? 1.0f : -1.0f, + (rd.Y < 0.0f) ? 1.0f : -1.0f, + (rd.Z < 0.0f) ? 1.0f : -1.0f); + var t1 = m * (-ro + s * rad); + var t2 = m * (-ro - s * rad); + + float tN = Math.Max(Math.Max(t1.X, t1.Y), t1.Z); + float tF = Math.Min(Math.Min(t2.X, t2.Y), t2.Z); + + if (tN > tF || tF < 0.0) + return false; + + // compute normal (in world space), face and UV + // currently not required + // if( t1.x>t1.y && t1.x>t1.z ) { oN=txi[0].xyz*s.x; oU=ro.yz+rd.yz*t1.x; oF=([1+int(s.x))/[2]; + // else if( t1.y>t1.z ) { oN=txi[1].xyz*s.y; oU=ro.zx+rd.zx*t1.y; oF=([5+int(s.y))/[2]; + // else { oN=txi[2].xyz*s.z; oU=ro.xy+rd.xy*t1.z; oF=([9+int(s.z))/[2]; + + // exit point currently not required + // oT = vec2(tN,tF); + t = tN; + + return true; + } + + // From "GamePhysicsCookbook" + // See: https://github.com/gszauer/GamePhysicsCookbook/blob/a0b8ee0c39fed6d4b90bb6d2195004dfcf5a1115/Code/Geometry3D.cpp#L769-L796 + public static bool RayIntersectsPlane(Vec3 origin, Vec3 direction, Plane plane, out Vec3 hitPoint) + { + hitPoint = default; + + float nd = Vec3.Dot(direction, plane.Normal); + float pn = Vec3.Dot(origin, plane.Normal); + + // nd must be negative, and not 0 + // if nd is positive, the ray and plane normals + // point in the same direction. No intersection. + if (nd >= 0.0f) + return false; + + float t = (plane.D - pn) / nd; + + // t must be positive + if (t >= 0.0f) + { + hitPoint = origin + direction * t; + return true; + } + + return false; + } +} diff --git a/Source/Mod/ImGui/DebugActorMenu.cs b/Source/Mod/ImGui/DebugActorMenu.cs index 0ec43d82..cb39463b 100644 --- a/Source/Mod/ImGui/DebugActorMenu.cs +++ b/Source/Mod/ImGui/DebugActorMenu.cs @@ -26,7 +26,7 @@ public override void Update() public override void Render() { - if (Visible && Game.Instance.Scene is World world && world.Get() is { } player) + if (Visible && Game.Scene is World world && world.Get() is { } player) { if (actorPropertiesWindowVisible) { diff --git a/Source/Mod/ImGui/DemoWindowHandler.cs b/Source/Mod/ImGui/DemoWindowHandler.cs new file mode 100644 index 00000000..2d9d28b7 --- /dev/null +++ b/Source/Mod/ImGui/DemoWindowHandler.cs @@ -0,0 +1,11 @@ +using ImGuiNET; + +namespace Celeste64.Mod; + +public class DemoWindowHandler : ImGuiHandler +{ + public override void Render() + { + ImGui.ShowDemoWindow(); + } +} diff --git a/Source/Mod/ImGui/FujiDebugMenu.cs b/Source/Mod/ImGui/FujiDebugMenu.cs index b59b8581..60522fd0 100644 --- a/Source/Mod/ImGui/FujiDebugMenu.cs +++ b/Source/Mod/ImGui/FujiDebugMenu.cs @@ -1,5 +1,5 @@ - -using ImGuiNET; +using ImGuiNET; + namespace Celeste64.Mod; internal class FujiDebugMenu : ImGuiHandler @@ -23,6 +23,9 @@ public override void Update() public override void Render() { + if (Game.Scene is not World world) + return; + ImGui.SetNextWindowSizeConstraints(new Vec2(400, 640), new Vec2(float.PositiveInfinity, float.PositiveInfinity)); ImGui.Begin("Celeste 64 ~ Debug Menu"); @@ -30,83 +33,82 @@ public override void Render() if (debugActorMenu.Visible) { debugActorMenu.Render(); + return; } - else + + if (ModManager.Instance.CurrentLevelMod != null) { - if (Game.Instance.Scene is World && ModManager.Instance.CurrentLevelMod != null) + if (ImGui.BeginMenu("Open Map")) { - if (ImGui.BeginMenu("Open Map")) + foreach (var kvp in ModManager.Instance.CurrentLevelMod.Maps) { - foreach (var kvp in ModManager.Instance.CurrentLevelMod.Maps) + if (ImGui.MenuItem(kvp.Key)) { - if (ImGui.MenuItem(kvp.Key)) + Game.Instance.Goto(new Transition() { - Game.Instance.Goto(new Transition() - { - Mode = Transition.Modes.Replace, - Scene = () => new World(new(kvp.Key, Save.CurrentRecord.Checkpoint, false, World.EntryReasons.Entered)), - ToBlack = new SpotlightWipe(), - FromBlack = new SpotlightWipe(), - StopMusic = true, - HoldOnBlackFor = 0 - }); - } + Mode = Transition.Modes.Replace, + Scene = () => new World(new(kvp.Key, Save.CurrentRecord.Checkpoint, false, World.EntryReasons.Entered)), + ToBlack = new SpotlightWipe(), + FromBlack = new SpotlightWipe(), + StopMusic = true, + HoldOnBlackFor = 0 + }); } - ImGui.EndMenu(); } + ImGui.EndMenu(); } + } - if (Game.Instance.Scene is World world && world.Get() is { } player) + if (world.Get() is { } player) + { + if (world.All().Any() && ImGui.BeginMenu("Go to Checkpoint")) { - if (world.All().Any() && ImGui.BeginMenu("Go to Checkpoint")) + int i = 0; + foreach (var actor in world.All()) { - int i = 0; - foreach (var actor in world.All()) + if (actor is Checkpoint checkpoint) { - if (actor is Checkpoint checkpoint) + string checkpointName = string.IsNullOrEmpty(checkpoint.CheckpointName) ? $"Checkpoint {i}" : checkpoint.CheckpointName; + if (ImGui.MenuItem(checkpointName)) { - string checkpointName = string.IsNullOrEmpty(checkpoint.CheckpointName) ? $"Checkpoint {i}" : checkpoint.CheckpointName; - if (ImGui.MenuItem(checkpointName)) - { - player.Position = checkpoint.Position; - } - i++; + player.Position = checkpoint.Position; } + i++; } - ImGui.EndMenu(); } + ImGui.EndMenu(); + } - if (ImGui.BeginMenu("Player Actions")) + if (ImGui.BeginMenu("Player Actions")) + { + if (ImGui.MenuItem("Kill Player")) { - if (ImGui.MenuItem("Kill Player")) - { - player.Kill(); - } - if (ImGui.MenuItem("Give Double Dash")) + player.Kill(); + } + if (ImGui.MenuItem("Give Double Dash")) + { + player.DashesLocal = 2; + } + if (ImGui.MenuItem("Toggle Debug Fly")) + { + if (player.StateMachine.State != Player.States.DebugFly) { - player.DashesLocal = 2; + player.StateMachine.State = Player.States.DebugFly; } - if (ImGui.MenuItem("Toggle Debug Fly")) + else { - if (player.StateMachine.State != Player.States.DebugFly) - { - player.StateMachine.State = Player.States.DebugFly; - } - else - { - player.StateMachine.State = Player.States.Normal; - } + player.StateMachine.State = Player.States.Normal; } - ImGui.EndMenu(); } + ImGui.EndMenu(); + } - if (ImGui.Button("Actor Properties")) - { - debugActorMenu.Visible = true; - } + if (ImGui.Button("Actor Properties")) + { + debugActorMenu.Visible = true; } } - + ImGui.End(); } } diff --git a/Source/Mod/ImGui/ImGuiManager.cs b/Source/Mod/ImGui/ImGuiManager.cs index 4a2289a7..eb26115a 100644 --- a/Source/Mod/ImGui/ImGuiManager.cs +++ b/Source/Mod/ImGui/ImGuiManager.cs @@ -1,3 +1,4 @@ +using Celeste64.Mod.Editor; using ImGuiNET; namespace Celeste64.Mod; @@ -16,6 +17,7 @@ public class ImGuiManager private readonly ImGuiRenderer renderer; private static FujiDebugMenu debugMenu = new FujiDebugMenu(); + private static DemoWindowHandler demoWindow = new DemoWindowHandler() { Visible = false }; private static IEnumerable Handlers => ModManager.Instance.EnabledMods.SelectMany(mod => mod.ImGuiHandlers); internal ImGuiManager() @@ -26,32 +28,58 @@ internal ImGuiManager() internal void UpdateHandlers() { + // Reset so that ImGui itself actually receives the inputs + WantCaptureKeyboard = false; + WantCaptureMouse = false; + renderer.Update(); if (debugMenu.Active) debugMenu.Update(); + + if (Input.Keyboard.Pressed(Keys.F2)) + demoWindow.Visible = !demoWindow.Visible; + + if (Game.Scene is EditorWorld editor) + { + foreach (var handler in editor.Handlers) + { + if (handler.Active) handler.Update(); + } + } foreach (var handler in Handlers) { if (handler.Active) handler.Update(); } + + var io = ImGui.GetIO(); + WantCaptureKeyboard = io.WantCaptureKeyboard; + WantCaptureMouse = io.WantCaptureMouse; } internal void RenderHandlers() { renderer.BeforeRender(); + if (debugMenu.Visible) debugMenu.Render(); + if (demoWindow.Visible) + demoWindow.Render(); + + if (Game.Scene is EditorWorld editor) + { + foreach (var handler in editor.Handlers) + { + if (handler.Visible) handler.Render(); + } + } foreach (var handler in Handlers) { if (handler.Visible) handler.Render(); } renderer.AfterRender(); - - var io = ImGui.GetIO(); - WantCaptureKeyboard = io.WantCaptureKeyboard; - WantCaptureMouse = io.WantCaptureMouse; } internal void RenderTexture(Batcher batch) diff --git a/Source/Mod/ImGui/ImGuiRenderer.cs b/Source/Mod/ImGui/ImGuiRenderer.cs index 6e8ddfe7..e88c8e10 100644 --- a/Source/Mod/ImGui/ImGuiRenderer.cs +++ b/Source/Mod/ImGui/ImGuiRenderer.cs @@ -1,5 +1,4 @@ using ImGuiNET; -using System.Diagnostics; using System.Runtime.InteropServices; using Color = Foster.Framework.Color; using Material = Foster.Framework.Material; @@ -31,8 +30,8 @@ public ImGuiRenderer() io.ConfigDockingAlwaysTabBar = true; io.ConfigDockingTransparentPayload = true; - io.Fonts.AddFontFromFileTTF(Path.GetFullPath(Path.Join(Assets.ContentPath, Assets.FontsFolder, "RenogareTrue.ttf")), 11); - io.FontGlobalScale = 1.5f; + io.Fonts.AddFontFromFileTTF(Path.GetFullPath(Path.Join(Assets.ContentPath, Assets.FontsFolder, "RenogareTrue.ttf")), 14); + io.FontGlobalScale = 1.0f; Input.OnTextEvent += chr => io.AddInputCharacter(chr); } diff --git a/Source/Scenes/World.cs b/Source/Scenes/World.cs index 9a238731..55486805 100644 --- a/Source/Scenes/World.cs +++ b/Source/Scenes/World.cs @@ -1,4 +1,5 @@ using Celeste64.Mod; +using Celeste64.Mod.Editor; using System.Diagnostics; using ModelEntry = (Celeste64.Actor Actor, Celeste64.Model Model); @@ -9,6 +10,9 @@ public class World : Scene public enum EntryReasons { Entered, Returned, Respawned } public readonly record struct EntryInfo(string Map, string CheckPoint, bool Submap, EntryReasons Reason); + public enum WorldType { Game, Editor } + public readonly WorldType Type; + public Camera Camera = new(); public Rng Rng = new(0); public float HitStun = 0; @@ -25,14 +29,14 @@ public enum EntryReasons { Entered, Returned, Respawned } private readonly Dictionary> recycled = []; private readonly List trackedTypes = []; - private readonly List models = []; - private readonly List sprites = []; + protected readonly List models = []; + protected readonly List sprites = []; - private Target? postTarget; - private readonly Material postMaterial = new(); - private readonly Batcher batch = new(); - private readonly List skyboxes = []; - private readonly SpriteRenderer spriteRenderer = new(); + protected Target? postTarget; + protected readonly Material postMaterial = new(); + protected readonly Batcher batch = new(); + protected readonly List skyboxes = []; + protected readonly SpriteRenderer spriteRenderer = new(); // Pause Menu, only drawn when actually paused private Menu pauseMenu = new(); @@ -59,12 +63,13 @@ private bool IsPauseEnabled } } - private readonly Stopwatch debugUpdTimer = new(); - private readonly Stopwatch debugRndTimer = new(); - private readonly Stopwatch debugFpsTimer = new(); - private TimeSpan lastDebugRndTime; - private int debugUpdateCount; - public static bool DebugDraw { get; private set; } = false; + protected readonly Stopwatch debugUpdTimer = new(); + protected readonly Stopwatch debugRndTimer = new(); + protected readonly Stopwatch debugFpsTimer = new(); + protected TimeSpan lastDebugRndTime; + protected int debugUpdateCount; + + public static bool DebugDraw { get; protected set; } = false; public Map? Map { get; private set; } @@ -100,6 +105,7 @@ public World(EntryInfo entry) }))); Entry = entry; + Type = this is EditorWorld ? WorldType.Editor : WorldType.Game; var stopwatch = Stopwatch.StartNew(); @@ -130,6 +136,7 @@ public World(EntryInfo entry) strawbCounterWiggle = 0; // setup pause menu + if (Type == WorldType.Game) // Don't create pause menu in the editor { Menu optionsMenu = new GameOptionsMenu(pauseMenu); @@ -173,25 +180,11 @@ public World(EntryInfo entry) } // environment + if (Type == WorldType.Game) // Editor will create environment effects by itself { if (map.SnowAmount > 0) Add(new Snow(map.SnowAmount, map.SnowWind)); - if (!string.IsNullOrEmpty(map.Skybox)) - { - // single skybox - if (Assets.Textures.TryGetValue($"skyboxes/{map.Skybox}", out var skybox)) - { - skyboxes.Add(new(skybox)); - } - // group - else - { - while (Assets.Textures.TryGetValue($"skyboxes/{map.Skybox}_{skyboxes.Count}", out var nextSkybox)) - skyboxes.Add(new(nextSkybox)); - } - } - // Fuji Custom: Allows playing music and ambience from wav files if available. // Otherwise, uses fmod events like normal. if (map.Music != null && Assets.Music.ContainsKey(map.Music)) @@ -215,14 +208,30 @@ public World(EntryInfo entry) AmbienceWav = ""; Ambience = $"event:/sfx/ambience/{map.Ambience}"; } - } - ModManager.Instance.OnPreMapLoaded(this, map); - - // load content - map.Load(this); + if (!string.IsNullOrEmpty(map.Skybox)) + { + // single skybox + if (Assets.Textures.TryGetValue($"skyboxes/{map.Skybox}", out var skybox)) + { + skyboxes.Add(new(skybox)); + } + // group + else + { + while (Assets.Textures.TryGetValue($"skyboxes/{map.Skybox}_{skyboxes.Count}", out var nextSkybox)) + skyboxes.Add(new(nextSkybox)); + } + } + } - ModManager.Instance.OnWorldLoaded(this); + if (Type == WorldType.Game) // The editor handles loading itself + { + ModManager.Instance.OnPreMapLoaded(this, map); + // load content + map.Load(this); + ModManager.Instance.OnWorldLoaded(this); + } if (Entry.Reason == EntryReasons.Entered) { @@ -316,7 +325,7 @@ private List GetTypesOf() return list; } - private void ResolveChanges() + protected void ResolveChanges() { // resolve adding/removing actors while (adding.Count > 0 || destroying.Count > 0) @@ -396,6 +405,16 @@ public override void Update() } } + // Toggle to editor + if (Input.Keyboard.Pressed(Keys.F3)) + { + Game.Scene!.Exited(); + Game.Instance.scenes.Pop(); + Game.Instance.scenes.Push(new EditorWorld(Entry)); + Game.Scene!.Entered(); + return; + } + if (Panicked) { return; @@ -984,7 +1003,7 @@ public override void Render(Target target) debugRndTimer.Stop(); } - private void ApplyPostEffects() + protected void ApplyPostEffects() { // perform post processing effects if (Camera.Target != null) @@ -1018,7 +1037,7 @@ private void ApplyPostEffects() } } - private void RenderModels(ref RenderState state, List models, ModelFlags flags) + protected void RenderModels(ref RenderState state, List models, ModelFlags flags) { foreach (var it in models) {