Compare commits

..

2 Commits

Author SHA1 Message Date
CHatingPython 6639127bcf feat(ecs): introduce component view
Closes: #1
2026-09-07 23:05:39 +02:00
CHatingPython 320eb29957 chore: add hooks from furlang 2026-09-07 23:05:13 +02:00
6 changed files with 328 additions and 2 deletions
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -e
BUILD_DIR="build"
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(cpp|hpp)$' | grep -v 'deps/' || true)
if [ -z "$FILES" ]; then
echo "No C/C++ files to check"
exit 0
fi
echo "Running clang-format..."
git diff -U0 --cached -- $FILES | \
python3 <(curl -s https://raw.githubusercontent.com/llvm/llvm-project/refs/heads/main/clang/tools/clang-format/clang-format-diff.py) -p1 -i
echo "Running clang-tidy..."
for file in $FILES; do
clang-tidy \
"$file" \
--header-filter="^(?!.*deps/).*" \
-p "$BUILD_DIR"
done
echo "pre-commit checks passed"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
BUILD_DIR="build"
echo "Building..."
cmake --build "$BUILD_DIR"
echo "Running tests..."
ctest --test-dir "$BUILD_DIR" --output-on-failure
echo "pre-push checks passed"
+124
View File
@@ -0,0 +1,124 @@
#ifndef LIBCATBOY_ECS_COMPONENT_VIEW_HPP
#define LIBCATBOY_ECS_COMPONENT_VIEW_HPP
#include "sparseSet.hpp"
#include <array>
#include <concepts>
#include <cstddef>
#include <iterator>
#include <tuple>
#include <utility>
namespace libcatboy {
namespace ecs {
template <std::integral Entity, typename... Sum>
requires(sizeof...(Sum) >= 1)
class component_view {
friend class iterator;
public:
class iterator {
friend class component_view;
private:
iterator(component_view* view, std::size_t index)
: m_view(view), m_denseMap(view->m_minimal->dense_map()), m_index(index) {
find_valid();
}
public:
using value_type = std::tuple<Entity, Sum...>;
using difference_type = std::ptrdiff_t;
using reference = std::tuple<Entity, Sum&...>;
using iterator_category = std::forward_iterator_tag;
public:
reference operator*() { return std::tuple_cat(std::make_tuple(index()), current_tuple()); }
iterator& operator++() {
++m_index;
find_valid();
return *this;
}
iterator operator++(int) {
iterator ret = *this;
++(*this);
return ret;
}
public:
bool operator==(const iterator& rhs) const noexcept { return m_view == rhs.m_view && m_index == rhs.m_index; }
bool operator!=(const iterator& rhs) const noexcept { return m_view != rhs.m_view || m_index != rhs.m_index; }
public:
void swap(iterator& other) noexcept {
std::swap(m_view, other.m_view);
std::swap(m_denseMap, other.m_denseMap);
std::swap(m_index, other.m_index);
}
private:
void find_valid() {
while (m_index < m_denseMap.size() && !m_view->valid(index()))
++m_index;
}
private:
using component_types = std::tuple<Sum...>;
private:
auto current_tuple() { return current_tuple(std::make_index_sequence<sizeof...(Sum)>{}); }
template <std::size_t... Is>
auto current_tuple(std::index_sequence<Is...>) {
return std::forward_as_tuple(
(reinterpret_cast<sparse_set<std::tuple_element_t<Is, component_types>>&>(*m_view->m_sets[Is])
.at(index()))...);
}
auto current_tuple() const { return current_tuple(std::make_index_sequence<sizeof...(Sum)>{}); }
template <std::size_t... Is>
auto current_tuple(std::index_sequence<Is...>) const {
return std::forward_as_tuple(
(reinterpret_cast<const sparse_set<std::tuple_element_t<Is, component_types>>&>(*m_view->m_sets[Is])
.at(index()))...);
}
private:
std::size_t index() const noexcept { return m_denseMap[m_index]; }
private:
component_view* m_view;
std::span<std::size_t> m_denseMap;
std::size_t m_index = 0;
};
public:
component_view(std::array<isparse_set*, sizeof...(Sum)> sets)
: m_sets(sets), m_minimal(sets.front()) {
for (std::size_t i = 1; i < sets.size(); ++i) {
if (m_minimal->size() > sets[i]->size())
m_minimal = sets[i];
}
}
public:
std::size_t size() const noexcept {
std::size_t count = 0;
for (auto idx : m_minimal->dense_map()) {
if (valid(idx)) ++count;
}
return count;
}
public:
iterator begin() { return { this, 0 }; }
iterator end() { return { this, m_minimal->size() }; }
private:
bool valid(Entity entity) const noexcept {
for (isparse_set* set : m_sets) {
if (!set->contains(entity)) return false;
}
return true;
}
private:
std::array<isparse_set*, sizeof...(Sum)> m_sets;
isparse_set* m_minimal = nullptr;
};
} // namespace ecs
} // namespace libcatboy
#endif // LIBCATBOY_ECS_COMPONENT_VIEW_HPP
+7
View File
@@ -1,6 +1,7 @@
#ifndef LIBCATBOY_ECS_ECS_HPP
#define LIBCATBOY_ECS_ECS_HPP
#include "component_view.hpp"
#include "fwd.hpp"
#include "sparseSet.hpp"
@@ -83,6 +84,12 @@ public:
if (m_alive.find(entity) == m_alive.end()) throw std::runtime_error("entity is dead");
get_set<T>().erase(entity);
}
public:
template <typename... Sum>
requires(sizeof...(Sum) >= 1)
component_view<Entity, Sum...> view() {
return { { (&get_set<Sum>())... } };
}
private:
template <typename T>
sparse_set<T>& get_set() {
+12
View File
@@ -1,6 +1,7 @@
#ifndef LIBCATBOY_ECS_SPARSE_SET_HPP
#define LIBCATBOY_ECS_SPARSE_SET_HPP
#include <span>
#include <type_traits>
#include <utility>
#include <vector>
@@ -20,6 +21,9 @@ public:
public:
virtual bool contains(std::size_t key) const noexcept = 0;
virtual void erase(std::size_t key) noexcept = 0;
virtual std::size_t size() const noexcept = 0;
virtual std::size_t sparse_size() const noexcept = 0;
virtual std::span<std::size_t> dense_map() noexcept = 0;
};
template <typename T>
@@ -70,6 +74,14 @@ public:
m_reverseMap.pop_back();
set_index(key, TOMBSTONE);
}
public:
std::size_t size() const noexcept override { return m_dense.size(); }
std::size_t sparse_size() const noexcept override { return m_map.size(); }
std::span<std::size_t> dense_map() noexcept override {
return m_reverseMap;
}
private:
void set_index(std::size_t key, std::size_t dense) {
if (key >= m_map.size()) m_map.resize(key + 1, TOMBSTONE);
+140
View File
@@ -8,6 +8,7 @@
#include <type_traits>
#include <utility>
#include <vector>
#include <algorithm>
namespace libcatboy::ecs::test {
@@ -939,4 +940,143 @@ TEST_F(RegistryTest, RegistryIsDefaultConstructible) {
SUCCEED();
}
// =============================================================================
// COMPONENT VIEWS
// =============================================================================
TEST_F(RegistryTest, ViewReturnsEntitiesWithAllRequestedComponents) {
const auto e1 = create();
const auto e2 = create();
const auto e3 = create();
reg.emplace<position>(e1, 1, 2);
reg.emplace<velocity>(e1, 10, 20);
reg.emplace<position>(e2, 3, 4);
reg.emplace<position>(e3, 5, 6);
reg.emplace<velocity>(e3, 50, 60);
std::vector<entity> result;
for (auto [e, p, v] : reg.view<position, velocity>()) {
result.push_back(e);
EXPECT_EQ(p, (position{ e == e1 ? 1 : 5, e == e1 ? 2 : 6 }));
EXPECT_EQ(v, (velocity{ e == e1 ? 10 : 50, e == e1 ? 20 : 60 }));
}
ASSERT_EQ(result.size(), 2u);
EXPECT_NE(std::find(result.begin(), result.end(), e1), result.end());
EXPECT_NE(std::find(result.begin(), result.end(), e3), result.end());
EXPECT_EQ(std::find(result.begin(), result.end(), e2), result.end());
}
TEST_F(RegistryTest, ViewIsEmptyWhenNoEntityHasAllComponents) {
const auto e1 = create();
const auto e2 = create();
reg.emplace<position>(e1, 1, 2);
reg.emplace<velocity>(e2, 3, 4);
auto view = reg.view<position, velocity>();
EXPECT_EQ(view.begin(), view.end());
}
TEST_F(RegistryTest, ViewReturnsMutableComponentReferences) {
const auto e = create();
reg.emplace<position>(e, 1, 2);
reg.emplace<velocity>(e, 3, 4);
for (auto [entity, p, v] : reg.view<position, velocity>()) {
EXPECT_EQ(entity, e);
p.x = 100;
v.y = 200;
}
EXPECT_EQ(reg.at<position>(e), (position{ 100, 2 }));
EXPECT_EQ(reg.at<velocity>(e), (velocity{ 3, 200 }));
}
TEST_F(RegistryTest, ViewHandlesSparseEntityIDs) {
const auto e0 = create();
const auto e1 = create();
const auto e2 = create();
const auto e3 = create();
reg.emplace<position>(e0, 0, 0);
reg.emplace<velocity>(e0, 10, 10);
reg.emplace<position>(e2, 2, 20);
reg.emplace<velocity>(e2, 20, 20);
reg.emplace<position>(e3, 3, 30);
std::vector<entity> result;
for (auto [e, p, v] : reg.view<position, velocity>()) {
result.push_back(e);
}
ASSERT_EQ(result.size(), 2u);
EXPECT_NE(std::find(result.begin(), result.end(), e0), result.end());
EXPECT_NE(std::find(result.begin(), result.end(), e2), result.end());
EXPECT_EQ(std::find(result.begin(), result.end(), e1), result.end());
EXPECT_EQ(std::find(result.begin(), result.end(), e3), result.end());
}
TEST_F(RegistryTest, ViewUsesSmallestComponentSetAsDriver) {
// Position exists on many entities while velocity exists on only two.
// The result should still contain exactly the intersection.
//
// This primarily exercises the optimization in component_view that picks
// the smallest sparse set as the iteration source.
for (int i = 0; i < 100; ++i) {
const auto e = create();
reg.emplace<position>(e, i, i);
}
const auto e1 = create();
const auto e2 = create();
reg.emplace<position>(e1, 100, 100);
reg.emplace<velocity>(e1, 1, 1);
reg.emplace<position>(e2, 200, 200);
reg.emplace<velocity>(e2, 2, 2);
std::vector<entity> result;
for (auto [e, p, v] : reg.view<position, velocity>())
result.push_back(e);
ASSERT_EQ(result.size(), 2u);
EXPECT_NE(std::find(result.begin(), result.end(), e1), result.end());
EXPECT_NE(std::find(result.begin(), result.end(), e2), result.end());
}
TEST_F(RegistryTest, ViewStopsReturningEntityAfterComponentIsErased) {
const auto e1 = create();
const auto e2 = create();
reg.emplace<position>(e1, 1, 2);
reg.emplace<velocity>(e1, 3, 4);
reg.emplace<position>(e2, 5, 6);
reg.emplace<velocity>(e2, 7, 8);
reg.erase<velocity>(e1);
std::vector<entity> result;
for (auto [e, p, v] : reg.view<position, velocity>())
result.push_back(e);
ASSERT_EQ(result.size(), 1u);
EXPECT_EQ(result.front(), e2);
}
} // namespace libcatboy::ecs::test