From c081aa868f870320775c648f10488096f08af12f Mon Sep 17 00:00:00 2001 From: Connor Date: Fri, 20 Feb 2026 14:53:28 +0900 Subject: [PATCH] support --- CMakeLists.txt | 1 + include/Components/Support.h | 35 ++++-- src/Components/Support.cpp | 186 ++++++++++++++++++++++++++++++ src/Components/components.cpp | 0 src/Core/WorldInstance.cpp | 2 + tests/Components/test_Support.cpp | 122 ++++++++++++++++++++ 6 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 src/Components/Support.cpp create mode 100644 src/Components/components.cpp create mode 100644 tests/Components/test_Support.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fb0acf..3459336 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ FetchContent_MakeAvailable(flecs doctest) # Only compile sources needed for the core library set(SOURCES src/Components/Config/WorldConfig.cpp + src/Components/Support.cpp src/Core/WorldInstance.cpp ) diff --git a/include/Components/Support.h b/include/Components/Support.h index bacd574..4a74bff 100644 --- a/include/Components/Support.h +++ b/include/Components/Support.h @@ -2,18 +2,33 @@ #include +#include "flecs.h" + +#include "Components/Misc.hpp" +#include "Util/Span.h" + struct Support { - uint8_t MaxSupport : 5; - bool SupportsUp : 1; - bool SupportsRight : 1; - bool SupportsLeft : 1; - uint8_t SupportsAvailable : 5; - bool SupportedByBottom : 1; - bool SupportedByRight : 1; - bool SupportedByLeft : 1; + uint8_t MaxSupport{}; + uint8_t SupportsAvailable{}; }; -struct RequiresSupport +struct GroundedSupport {}; +struct RequiresSupport {}; + +flecs::entity GetSupport(flecs::world& world, Vector2 pos); + +void RecalculateSupport(flecs::world& world, + tcb::span skip = {}, + tcb::span unground = {}); + +bool CanRemove(flecs::world& world, tcb::span positions); + +void Flecs_Support(flecs::world& world); + +inline void Support_Helper(const flecs::entity& entity, Vector2 pos, uint8_t maxSupport, bool grounded = false) { -}; \ No newline at end of file + entity.set({ pos }); + if (grounded) entity.add(); // before set so OnAdd fires with grounded state visible + entity.set({ maxSupport }); +} diff --git a/src/Components/Support.cpp b/src/Components/Support.cpp new file mode 100644 index 0000000..8d22999 --- /dev/null +++ b/src/Components/Support.cpp @@ -0,0 +1,186 @@ +#include "Components/Support.h" + +#include +#include + +namespace +{ + +struct SupportNode +{ + flecs::entity Entity; + Vector2 Pos; +}; + +bool InSpan(tcb::span span, Vector2 pos) +{ + return std::any_of(span.begin(), span.end(), [&](const Vector2& p) { + return p.X == pos.X && p.Y == pos.Y; + }); +} + +} // namespace + +flecs::entity GetSupport(flecs::world& world, Vector2 pos) +{ + flecs::entity result{}; + world.each([&](flecs::entity e, const TilePosition& tp, const Support&) { + if (tp.Position.X == pos.X && tp.Position.Y == pos.Y) + result = e; + }); + return result; +} + +void RecalculateSupport(flecs::world& world, + tcb::span skip, + tcb::span unground) +{ + // Collect all support nodes first (avoid nested world.each() re-entrancy) + std::vector nodes; + world.each([&](flecs::entity e, const TilePosition& tp, const Support&) { + if (!InSpan(skip, tp.Position)) + nodes.push_back({ e, tp.Position }); + }); + + // Reset all SupportsAvailable to 0 + for (auto& node : nodes) + node.Entity.ensure().SupportsAvailable = 0; + + // Seed queue with grounded supports + std::queue queue; + for (auto& node : nodes) + { + if (node.Entity.has() && !InSpan(unground, node.Pos)) + { + uint8_t maxSupport = node.Entity.get().MaxSupport; + node.Entity.ensure().SupportsAvailable = maxSupport; + queue.push(node); + } + } + + // BFS: propagate support outward from grounded anchors + const Vector2 offsets[] = { {0, 1}, {0, -1}, {1, 0}, {-1, 0} }; + while (!queue.empty()) + { + SupportNode current = queue.front(); + queue.pop(); + + uint8_t currentAvailable = current.Entity.get().SupportsAvailable; + + for (const auto& offset : offsets) + { + uint8_t cost = (offset.X != 0) ? 2 : 1; + if (currentAvailable < cost) continue; + + Vector2 neighborPos = current.Pos + offset; + + auto it = std::find_if(nodes.begin(), nodes.end(), [&](const SupportNode& n) { + return n.Pos.X == neighborPos.X && n.Pos.Y == neighborPos.Y; + }); + if (it == nodes.end()) continue; + + uint8_t neighborMax = it->Entity.get().MaxSupport; + uint8_t candidate = std::min(static_cast(currentAvailable - cost), neighborMax); + uint8_t neighborCurrent = it->Entity.get().SupportsAvailable; + + if (candidate > neighborCurrent) + { + it->Entity.ensure().SupportsAvailable = candidate; + queue.push(*it); + } + } + } +} + +bool CanRemove(flecs::world& world, tcb::span positions) +{ + RecalculateSupport(world, positions); + + bool valid = true; + + // Collect remaining positions with active support; flag any that lost all support + std::vector supportedPositions; + world.each([&](const TilePosition& tp, const Support& s) { + if (InSpan(positions, tp.Position)) return; + if (s.SupportsAvailable == 0) + valid = false; + else + supportedPositions.push_back(tp.Position); + }); + + // Check every RequiresSupport entity has an active support one tile below it + world.each([&](flecs::entity e, const TilePosition& tp) { + if (e.has()) + { + Vector2 supportPos = { tp.Position.X, tp.Position.Y - 1 }; + tcb::span suppSpan(supportedPositions.data(), supportedPositions.size()); + if (!InSpan(suppSpan, supportPos)) + valid = false; + } + }); + + RecalculateSupport(world); + return valid; +} + +void Flecs_Support(flecs::world& world) +{ + world.component() + .member("MaxSupport") + .member("SupportsAvailable"); + + world.component(); + world.component(); + + // OnSet fires after the value is written (unlike OnAdd which fires during archetype move, + // before the actual struct values are copied in — MaxSupport would still be 0 at that point). + world.observer("On Support Set") + .event(flecs::OnSet) + .each([](flecs::entity e, Support&) { + auto w = e.world(); + RecalculateSupport(w); + }); + + world.observer("On Support Remove") + .event(flecs::OnRemove) + .each([](flecs::entity e, Support&) { + auto w = e.world(); + if (e.has()) + { + Vector2 pos = e.get().Position; + RecalculateSupport(w, tcb::span(&pos, 1)); + } + else + { + RecalculateSupport(w); + } + }); + + // GroundedSupport is a tag (no data) — use .with<>() filter so the lambda + // takes only flecs::entity, avoiding the empty-type column issue. + world.observer("On GroundedSupport Add") + .with() + .event(flecs::OnAdd) + .each([](flecs::entity e) { + auto w = e.world(); + RecalculateSupport(w); + }); + + // OnRemove fires before actual removal, so entity still has GroundedSupport. + // Pass position to unground so the entity is not treated as a grounded seed. + world.observer("On GroundedSupport Remove") + .with() + .event(flecs::OnRemove) + .each([](flecs::entity e) { + auto w = e.world(); + if (e.has()) + { + Vector2 pos = e.get().Position; + RecalculateSupport(w, {}, tcb::span(&pos, 1)); + } + else + { + RecalculateSupport(w); + } + }); +} diff --git a/src/Components/components.cpp b/src/Components/components.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Core/WorldInstance.cpp b/src/Core/WorldInstance.cpp index 40d3ec5..222067b 100644 --- a/src/Core/WorldInstance.cpp +++ b/src/Core/WorldInstance.cpp @@ -6,6 +6,7 @@ #include "Components/Inventory.hpp" #include "Components/Tick.hpp" #include "Components/Chute.hpp" +#include "Components/Support.h" WorldInstance::WorldInstance(const WorldConfig& worldConfig) { @@ -26,6 +27,7 @@ void WorldInstance::RegisterTypes(flecs::world &world) Flecs_Inventory(world); Flecs_Resource(world); Flecs_Chute(world); + Flecs_Support(world); } void WorldInstance::ProcessFrame() diff --git a/tests/Components/test_Support.cpp b/tests/Components/test_Support.cpp new file mode 100644 index 0000000..57c4db2 --- /dev/null +++ b/tests/Components/test_Support.cpp @@ -0,0 +1,122 @@ +#include +#include "Components/Configs/WorldConfig.hpp" +#include "Core/WorldInstance.h" +#include "Components/Support.h" + +TEST_SUITE("Support") { + TEST_CASE("grounded support has SupportsAvailable equal to MaxSupport") { + WorldInstance world{ WorldConfig{} }; + + auto e = world.GetEcsWorld().entity(); + Support_Helper(e, { 0, 0 }, 5, true); + + auto s = e.get(); + CHECK(s.SupportsAvailable == 5); + } + + TEST_CASE("vertical neighbor costs 1 point") { + WorldInstance world{ WorldConfig{} }; + + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto above = world.GetEcsWorld().entity(); + Support_Helper(above, { 0, 1 }, 5); + + auto s = above.get(); + CHECK(s.SupportsAvailable == 4); + } + + TEST_CASE("horizontal neighbor costs 2 points") { + WorldInstance world{ WorldConfig{} }; + + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto beside = world.GetEcsWorld().entity(); + Support_Helper(beside, { 1, 0 }, 5); + + auto s = beside.get(); + CHECK(s.SupportsAvailable == 3); + } + + TEST_CASE("floating support has SupportsAvailable zero") { + WorldInstance world{ WorldConfig{} }; + + auto e = world.GetEcsWorld().entity(); + Support_Helper(e, { 5, 5 }, 5); + + auto s = e.get(); + CHECK(s.SupportsAvailable == 0); + } + + TEST_CASE("CanRemove returns false when removal disconnects a support") { + WorldInstance world{ WorldConfig{} }; + + // chain: ground(0,0) -> (0,1) -> (0,2) + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto mid = world.GetEcsWorld().entity(); + Support_Helper(mid, { 0, 1 }, 5); + + auto top = world.GetEcsWorld().entity(); + Support_Helper(top, { 0, 2 }, 5); + + // removing (0,1) disconnects (0,2) + std::vector toRemove = { { 0, 1 } }; + CHECK_FALSE(CanRemove(world.GetEcsWorld(), toRemove)); + } + + TEST_CASE("CanRemove returns true when removing a leaf node") { + WorldInstance world{ WorldConfig{} }; + + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto leaf = world.GetEcsWorld().entity(); + Support_Helper(leaf, { 0, 1 }, 5); + + // removing the leaf leaves only the grounded anchor — valid + std::vector toRemove = { { 0, 1 } }; + CHECK(CanRemove(world.GetEcsWorld(), toRemove)); + } + + TEST_CASE("CanRemove returns false when removal would unsupport a RequiresSupport entity") { + WorldInstance world{ WorldConfig{} }; + + // grounded support at (0,0); machine one tile above at (0,1) + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto machine = world.GetEcsWorld().entity(); + machine.set({ { 0, 1 } }); + machine.add(); + + // removing the only support at (0,0) leaves the machine at (0,1) unsupported + std::vector toRemove = { { 0, 0 } }; + CHECK_FALSE(CanRemove(world.GetEcsWorld(), toRemove)); + } + + TEST_CASE("CanRemove returns true when RequiresSupport entity still has support") { + WorldInstance world{ WorldConfig{} }; + + // chain: ground(0,0) -> leaf(0,1); machine one tile above leaf at (0,2) + auto ground = world.GetEcsWorld().entity(); + Support_Helper(ground, { 0, 0 }, 5, true); + + auto leaf = world.GetEcsWorld().entity(); + Support_Helper(leaf, { 0, 1 }, 5); + + auto machine = world.GetEcsWorld().entity(); + machine.set({ { 0, 2 } }); + machine.add(); + + // sibling at (1,0) is a horizontal leaf; removing it doesn't affect (0,1) + auto sibling = world.GetEcsWorld().entity(); + Support_Helper(sibling, { 1, 0 }, 5); + + std::vector toRemove = { { 1, 0 } }; + CHECK(CanRemove(world.GetEcsWorld(), toRemove)); + } +}