diff --git a/README.md b/README.md index a336ddb..e813dd9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ build engineering, and honest performance evidence in a codebase a reviewer can ## What it demonstrates +The collision system supports **bidirectional categories and inclusion masks**. +Give each body one category bit and the categories it accepts; an overlap is +reported only when both bodies include the other. A zero mask disables matching +without hiding, deleting or stopping the entity. Sprite rendering layers remain +independent. The strict scene-v2 grammar stores the filters, with explicit v1 +default migration and filter-inclusive replay fingerprints. + +Run `forge2d_arena assets/collision-layers.scene out/layers` to see a fictional +player/character/pickup-zone example. The actual CLI writes initial/final matched +pair CSV, canonical v2 scene, replay and rendered PPM. See [format/API](docs/asset-format.md) +and [validation/limits](docs/reports/practical-workflows-20261109.md). These are +overlap queries; physical object collision response and trigger callbacks remain +application responsibilities. + - C++20 engine core with strict ownership and no runtime dependency. - C17 linear arena as a narrow, tested cross-language/platform boundary. - Generation-safe entity handles and sparse-set component storage with expected O(1) access, diff --git a/apps/forge2d_arena.cpp b/apps/forge2d_arena.cpp index 5cd25f9..8728b6e 100644 --- a/apps/forge2d_arena.cpp +++ b/apps/forge2d_arena.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -65,13 +66,37 @@ int main(int argc, char** argv) { // NOLINT(bugprone-exception-escape) argc > 2 ? std::filesystem::path{argv[2]} : std::filesystem::path{"out/demo"}; const auto scene = forge2d::parse_scene(read_text(scene_path)); auto world = forge2d::World::from_scene(scene); + const auto initial_pairs = world.collision_pairs(); const auto replay = build_autopilot(360U); for (const auto frame : replay.frames) { world.step(frame); } + const auto final_pairs = world.collision_pairs(); forge2d::Image image{640U, 360U}; forge2d::render_world(image, world); std::filesystem::create_directories(output_directory); + std::ofstream contacts{output_directory / "collision-pairs.csv"}; + contacts.exceptions(std::ios::badbit | std::ios::failbit); + contacts << "snapshot,tick,first_index,first_generation,second_index,second_generation," + "first_category,first_mask,second_category,second_mask\n"; + std::map filters; + for (const auto& entity : world.snapshot()) + filters.emplace(entity.entity, entity.body.filter); + const auto write_pairs = [&](const char* snapshot, std::uint64_t tick, const auto& pairs) { + for (const auto& pair : pairs) { + const auto& first = filters.at(pair.first); + const auto& second = filters.at(pair.second); + contacts << snapshot << ',' << tick << ',' << pair.first.index << ',' + << pair.first.generation << ',' << pair.second.index << ',' + << pair.second.generation << ',' << first.category << ',' << first.mask + << ',' << second.category << ',' << second.mask << '\n'; + } + }; + write_pairs("initial", 0U, initial_pairs); + write_pairs("final", world.tick(), final_pairs); + std::ofstream canonical{output_directory / "scene-v2.scene"}; + canonical.exceptions(std::ios::badbit | std::ios::failbit); + canonical << forge2d::serialize_scene(scene); image.write_ppm(output_directory / "arena-final.ppm"); std::ofstream{output_directory / "autopilot.replay"} << forge2d::serialize_replay(replay); @@ -83,7 +108,9 @@ int main(int argc, char** argv) { // NOLINT(bugprone-exception-escape) << " \"engine_version\": \"" << FORGE2D_VERSION << "\",\n" << " \"ticks\": " << world.tick() << ",\n" << " \"entities\": " << world.entity_count() << ",\n" - << " \"collisions\": " << world.collision_pairs().size() << ",\n" + << " \"collisions\": " << final_pairs.size() << ",\n" + << " \"initial_filtered_pairs\": " << initial_pairs.size() << ",\n" + << " \"canonical_scene_schema\": 2,\n" << " \"state_hash\": " << world.state_hash() << ",\n" << " \"pixel_hash\": " << image.pixel_hash() << ",\n" << " \"arena_peak_bytes\": " << arena.peak << "\n" diff --git a/assets/collision-layers.scene b/assets/collision-layers.scene new file mode 100644 index 0000000..3c5b12e --- /dev/null +++ b/assets/collision-layers.scene @@ -0,0 +1,8 @@ +FORGE2D_SCENE 2 +bounds 0 0 10000 10000 +entities 5 +entity player 750 750 0 0 220 220 16711935 10 1 1 6 +entity character 1000 750 0 0 200 200 4278190335 5 0 2 1 +entity pickup-zone 1000 750 0 0 300 300 65535 4 0 4 1 +entity decoration 1000 750 0 0 300 300 2493757695 2 0 8 0 +entity isolated-character 1000 750 0 0 200 200 4294902015 3 0 2 0 diff --git a/docs/api.md b/docs/api.md index 621ded8..8693ad2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,6 +37,19 @@ remaining fraction for presentation interpolation; interpolation does not mutate ## Collision +`CollisionFilter {category, mask}` belongs to each `KinematicBody` and raw +`CollisionItem`. `category` is exactly one of32 bits, including `0x80000000`; +zero/multi-bit categories are rejected before query/spawn/serialization. `mask` +may contain any32-bit combination; zero matches nothing. Both directions must +allow the other's category before geometry is tested. Defaults(category1, all +mask bits) preserve legacy inclusion behavior. Sprite.layer affects drawing only. +Raw queries operate on supplied handles; only the World owns generation validity. + +Filters do not apply forces, block movement, remove entities or infer callbacks. +World passes each body's filter to the existing spatial query. State fingerprints +now include category/mask, so historic numeric fingerprints change even when +v1 scenes retain equivalent default behavior. Replay input grammar remains v1. + `SpatialHash(cell_size)::query(items)` performs broadphase candidate generation and exact AABB filtering. `brute_force_collisions(items)` is the O(n²) reference oracle. Edge contact alone is not penetration. Cell size and item half extents must be positive. Queries accept at most 100,000 items, diff --git a/docs/asset-format.md b/docs/asset-format.md index 166595f..69b1803 100644 --- a/docs/asset-format.md +++ b/docs/asset-format.md @@ -24,6 +24,22 @@ entity The repository-authored [arena scene](../assets/arena.scene) contains only primitive/color data and has no external or copyrighted art dependency. +## Scene version 2 + +```text +FORGE2D_SCENE 2 +bounds +entities +entity +``` + +All v1 constraints remain. Category/mask are unsigned32-bit decimal fields; +category must be a nonzero single bit, mask may be zero. Negative/overflowing, +missing or extra fields fail closed. Version1 inputs remain readable with +category1/all mask bits. Scene writers now emit canonical version2. The +[layer example](../assets/collision-layers.scene) demonstrates two-way inclusion +and an intentionally disabled mask. This affects reported AABB pairs only. + ## Replay version 1 ```text @@ -40,6 +56,9 @@ random number generator. ## Compatibility -Writers always emit version one. A changed grammar or semantic interpretation requires a new +Scene writers emit version2 and replay writers version1. Legacy scene readers migrate only the +documented defaults, with no field loss. State hashing includes the two filter words, so old +numeric state hashes are intentionally not compatible with this new fingerprint layout. +A changed grammar or semantic interpretation requires a new header version and migration test; silently accepting new fields would make replay evidence ambiguous. Unknown future versions fail closed. diff --git a/docs/code-index.md b/docs/code-index.md index 0b88a70..276dfec 100644 --- a/docs/code-index.md +++ b/docs/code-index.md @@ -5,21 +5,29 @@ declaration locations. Regenerate after authored source changes. ## `apps/forge2d_arena.cpp` -- Line 24: `read_text` -- Line 28: `input` -- Line 35: `build_autopilot` -- Line 36: `grid` -- Line 42: `path` -- Line 49: `segment` -- Line 50: `next` -- Line 60: `main` -- Line 62: `scene_path` -- Line 64: `output_directory` -- Line 66: `scene` -- Line 67: `world` -- Line 68: `replay` -- Line 72: `image` -- Line 79: `arena` +- Line 25: `read_text` +- Line 29: `input` +- Line 36: `build_autopilot` +- Line 37: `grid` +- Line 43: `path` +- Line 50: `segment` +- Line 51: `next` +- Line 61: `main` +- Line 63: `scene_path` +- Line 65: `output_directory` +- Line 67: `scene` +- Line 68: `world` +- Line 69: `initial_pairs` +- Line 70: `replay` +- Line 74: `final_pairs` +- Line 75: `image` +- Line 78: `contacts` +- Line 85: `write_pairs` +- Line 87: `first` +- Line 88: `second` +- Line 97: `canonical` +- Line 99: `forge2d::serialize_scene` +- Line 104: `arena` ## `apps/forge2d_arena_sdl.cpp` @@ -57,38 +65,45 @@ declaration locations. Regenerate after authored source changes. ## `include/forge2d/collision.hpp` -- Line 16: `CollisionItem` -- Line 17: `entity` -- Line 18: `bounds` -- Line 21: `CollisionPair` -- Line 22: `first` -- Line 23: `second` -- Line 25: `operator<=>` -- Line 28: `SpatialHash` +- Line 17: `CollisionItem` +- Line 18: `entity` +- Line 19: `bounds` +- Line 20: `filter` +- Line 23: `CollisionPair` +- Line 24: `first` +- Line 25: `second` +- Line 27: `operator<=>` - Line 30: `SpatialHash` -- Line 32: `query` -- Line 33: `cell_size` -- Line 38: `cell_size_` -- Line 42: `brute_force_collisions` +- Line 32: `SpatialHash` +- Line 34: `query` +- Line 35: `cell_size` +- Line 40: `cell_size_` +- Line 44: `brute_force_collisions` ## `include/forge2d/components.hpp` -- Line 15: `Transform` -- Line 16: `position` -- Line 17: `operator<=>` -- Line 20: `KinematicBody` -- Line 21: `velocity` -- Line 22: `half_extent` +- Line 17: `CollisionFilter` +- Line 18: `category` +- Line 19: `mask` +- Line 20: `valid` - Line 23: `operator<=>` -- Line 26: `Sprite` -- Line 27: `rgba` -- Line 28: `layer` -- Line 29: `operator<=>` -- Line 32: `InputFrame` -- Line 33: `move_x` -- Line 34: `move_y` -- Line 35: `action` -- Line 37: `operator<=>` +- Line 26: `Transform` +- Line 27: `position` +- Line 28: `operator<=>` +- Line 31: `KinematicBody` +- Line 32: `velocity` +- Line 33: `half_extent` +- Line 34: `filter` +- Line 35: `operator<=>` +- Line 38: `Sprite` +- Line 39: `rgba` +- Line 40: `layer` +- Line 41: `operator<=>` +- Line 44: `InputFrame` +- Line 45: `move_x` +- Line 46: `move_y` +- Line 47: `action` +- Line 49: `operator<=>` ## `include/forge2d/entity.hpp` @@ -381,15 +396,17 @@ declaration locations. Regenerate after authored source changes. - Line 35: `SpatialHash::SpatialHash` - Line 41: `SpatialHash::query` - Line 47: `bounds` -- Line 51: `min_x` -- Line 52: `max_x` -- Line 53: `min_y` -- Line 54: `max_y` -- Line 55: `span_x` -- Line 56: `span_y` -- Line 74: `left` -- Line 75: `right` -- Line 85: `brute_force_collisions` +- Line 54: `min_x` +- Line 55: `max_x` +- Line 56: `min_y` +- Line 57: `max_y` +- Line 58: `span_x` +- Line 59: `span_y` +- Line 77: `left` +- Line 78: `right` +- Line 90: `brute_force_collisions` +- Line 99: `left` +- Line 100: `right` ## `src/pathfinding.cpp` @@ -442,20 +459,20 @@ declaration locations. Regenerate after authored source changes. ## `src/scene.cpp` -- Line 17: `maximum_entities` -- Line 18: `maximum_coordinate` -- Line 20: `valid_name` -- Line 27: `require_line_end` -- Line 34: `validate_scene` -- Line 42: `players` -- Line 63: `serialize_scene` -- Line 80: `parse_scene` -- Line 92: `line` -- Line 100: `entity_count` -- Line 105: `line` -- Line 118: `player` -- Line 119: `layer` -- Line 120: `line` +- Line 18: `maximum_entities` +- Line 19: `maximum_coordinate` +- Line 21: `valid_name` +- Line 28: `require_line_end` +- Line 35: `validate_scene` +- Line 43: `players` +- Line 67: `serialize_scene` +- Line 85: `parse_scene` +- Line 99: `line` +- Line 107: `entity_count` +- Line 112: `line` +- Line 125: `player` +- Line 126: `layer` +- Line 127: `line` ## `src/sdl_renderer.cpp` @@ -519,77 +536,88 @@ declaration locations. Regenerate after authored source changes. - Line 90: `World::World` - Line 91: `minimum_` - Line 97: `World::spawn` -- Line 104: `low_x` -- Line 105: `high_x` -- Line 106: `low_y` -- Line 107: `high_y` -- Line 113: `entity` -- Line 131: `World::destroy` -- Line 144: `World::step` -- Line 149: `player_body` -- Line 153: `body_entities` -- Line 154: `body_components` -- Line 156: `body` -- Line 157: `transform` -- Line 158: `low_x` -- Line 159: `high_x` -- Line 160: `low_y` -- Line 161: `high_y` -- Line 162: `next_x` -- Line 163: `next_y` -- Line 176: `World::snapshot` -- Line 194: `World::collision_items` -- Line 206: `World::collision_pairs` -- Line 210: `World::state_hash` -- Line 211: `hash` -- Line 228: `World::from_scene` -- Line 229: `world` -- Line 236: `run_replay` +- Line 107: `low_x` +- Line 108: `high_x` +- Line 109: `low_y` +- Line 110: `high_y` +- Line 116: `entity` +- Line 134: `World::destroy` +- Line 147: `World::step` +- Line 152: `player_body` +- Line 156: `body_entities` +- Line 157: `body_components` +- Line 159: `body` +- Line 160: `transform` +- Line 161: `low_x` +- Line 162: `high_x` +- Line 163: `low_y` +- Line 164: `high_y` +- Line 165: `next_x` +- Line 166: `next_y` +- Line 179: `World::snapshot` +- Line 197: `World::collision_items` +- Line 210: `World::collision_pairs` +- Line 214: `World::state_hash` +- Line 215: `hash` +- Line 234: `World::from_scene` +- Line 235: `world` +- Line 242: `run_replay` ## `tests/test_main.cpp` -- Line 38: `require` -- Line 44: `require_throws` -- Line 53: `sample_scene` -- Line 60: `test_math_and_fixed_step` -- Line 83: `test_entity_pool_and_sparse_set_properties` -- Line 92: `random` -- Line 93: `entity_distribution` -- Line 94: `value_distribution` -- Line 96: `entity` -- Line 98: `erased` -- Line 99: `expected` -- Line 102: `value` -- Line 107: `iterator` -- Line 116: `stale` -- Line 118: `replacement` -- Line 124: `test_c_arena_alignment_and_overflow` -- Line 126: `arena` -- Line 128: `first` -- Line 129: `second` -- Line 133: `offset` -- Line 143: `test_spatial_hash_matches_brute_force` -- Line 144: `spatial_hash` -- Line 145: `random` -- Line 146: `coordinate` -- Line 147: `extent` -- Line 170: `test_astar_returns_valid_shortest_path` -- Line 171: `grid` -- Line 177: `path` -- Line 182: `delta_x` -- Line 183: `delta_y` -- Line 195: `test_scene_and_replay_round_trip` -- Line 196: `scene` -- Line 212: `test_world_replay_and_render_are_deterministic` -- Line 213: `scene` -- Line 220: `first` -- Line 221: `second` -- Line 236: `first_image` -- Line 237: `second_image` -- Line 242: `player` -- Line 247: `player_entity` -- Line 253: `test_profiler_summary_is_bounded` -- Line 254: `profiler` -- Line 259: `summary` -- Line 268: `main` -- Line 279: `failures` +- Line 39: `require` +- Line 45: `require_throws` +- Line 54: `sample_scene` +- Line 61: `test_math_and_fixed_step` +- Line 84: `test_entity_pool_and_sparse_set_properties` +- Line 93: `random` +- Line 94: `entity_distribution` +- Line 95: `value_distribution` +- Line 97: `entity` +- Line 99: `erased` +- Line 100: `expected` +- Line 103: `value` +- Line 108: `iterator` +- Line 117: `stale` +- Line 119: `replacement` +- Line 125: `test_c_arena_alignment_and_overflow` +- Line 127: `arena` +- Line 129: `first` +- Line 130: `second` +- Line 134: `offset` +- Line 144: `test_spatial_hash_matches_brute_force` +- Line 145: `spatial_hash` +- Line 146: `random` +- Line 147: `coordinate` +- Line 148: `extent` +- Line 172: `test_collision_category_masks` +- Line 175: `grid` +- Line 204: `test_collision_scene_migration_and_hash` +- Line 207: `scene` +- Line 210: `serialized` +- Line 214: `original` +- Line 218: `filtered` +- Line 221: `before` +- Line 227: `paired` +- Line 231: `enabled` +- Line 235: `disabled` +- Line 250: `test_astar_returns_valid_shortest_path` +- Line 251: `grid` +- Line 257: `path` +- Line 262: `delta_x` +- Line 263: `delta_y` +- Line 275: `test_scene_and_replay_round_trip` +- Line 276: `scene` +- Line 292: `test_world_replay_and_render_are_deterministic` +- Line 293: `scene` +- Line 300: `first` +- Line 301: `second` +- Line 316: `first_image` +- Line 317: `second_image` +- Line 322: `player` +- Line 327: `player_entity` +- Line 333: `test_profiler_summary_is_bounded` +- Line 334: `profiler` +- Line 339: `summary` +- Line 348: `main` +- Line 361: `failures` diff --git a/docs/complexity.md b/docs/complexity.md index 59585b7..861ddcb 100644 --- a/docs/complexity.md +++ b/docs/complexity.md @@ -3,6 +3,11 @@ Let `n` be live entities, `c` entities with one component type, `p` collision candidate pairs, `k` occupied spatial cells, `V/E` pathfinding vertices/edges, `f` replay frames, and `w×h` pixels. +Category/mask checks add O(1) work per candidate before the existing exact geometry +test; they do not change broadphase worst-case complexity. The headless demo +exports only initial/final matched-pair snapshots, not unbounded per-tick history. +CSV lookup uses O(n) filter storage and O(log n) per matched endpoint. + | Operation | Time | Additional space | Notes | |---|---:|---:|---| | Entity create/destroy/valid | amortized O(1) / O(1) / O(1) | O(n) total | intrusive free-index list plus generation/alive arrays | diff --git a/docs/file-catalog.md b/docs/file-catalog.md index fc63de2..a82751e 100644 --- a/docs/file-catalog.md +++ b/docs/file-catalog.md @@ -50,7 +50,7 @@ For declaration-level locations, see the generated [code index](code-index.md). | `src/collision.cpp` | Builds bounded spatial cells, de-duplicates candidates, and exact-tests AABBs deterministically. | | `src/pathfinding.cpp` | Implements deterministic A* with stable tie-breaking and bounded storage. | | `src/replay.cpp` | Parses and serializes capped, version-one input replays. | -| `src/scene.cpp` | Parses, validates, and serializes capped, version-one scene descriptions. | +| `src/scene.cpp` | Parses bounded v1/v2 scenes, migrates legacy filter defaults and serializes canonical v2. | | `src/sdl_renderer.cpp` | Maps SDL3 events and accelerated rectangles onto the platform-neutral engine API. | | `src/software_renderer.cpp` | Rasterizes clipped rectangles into deterministic RGBA memory and PPM evidence. | | `src/world.cpp` | Implements entity lifetime, safe movement, bounds response, snapshots, collision input, and stable hashes. | @@ -64,6 +64,9 @@ For declaration-level locations, see the generated [code index](code-index.md). | `apps/forge2d_arena.cpp` | Runs the display-free autopilot scenario and emits replay, screenshot, and JSON evidence. | | `apps/forge2d_arena_sdl.cpp` | Runs the optional interactive fixed-step SDL3 arena. | | `assets/arena.scene` | Supplies the original, text-only demonstration arena and entities. | +| `assets/collision-layers.scene` | Fictional player/character/pickup/decor category-mask inclusion example. | +| `fuzz/corpus/collision-filter.scene` | Valid v2 parser seed with highest category bit and disabled mask. | +| `docs/reports/practical-workflows-20261109.md` | Local category/mask verification, API behavior and compatibility limits. | | `benchmarks/benchmark.cpp` | Measures component lifecycle, collision broadphase, and fixed-tick throughput against regression budgets. | | `tests/test_main.cpp` | Runs deterministic unit, randomized reference, negative, integration, and replay tests without a test dependency. | | `fuzz/scene_parser_fuzz.cpp` | Exposes the scene parser to LLVM libFuzzer and asserts successful parse/serialize stability. | diff --git a/docs/reports/practical-workflows-20261109.md b/docs/reports/practical-workflows-20261109.md new file mode 100644 index 0000000..916dea2 --- /dev/null +++ b/docs/reports/practical-workflows-20261109.md @@ -0,0 +1,49 @@ +# Collision category/mask workflow validation — 2026-11-09 + +Jason Stys: deterministic game-engine collision selection and migration evidence. + +Bodies and raw collision items now carry one category bit and an inclusion mask. +Both actors must include the other's category. Zero masks intentionally disable +matching. The optimized spatial query and independent quadratic reference apply +their own inclusion predicates; World propagates body metadata unchanged. Drawing, +position integration and boundary response stay independent of filtering. + +Scene writers use v2; v1 remains readable with category1/all mask bits. Both filter +words belong to state hashing, intentionally changing old numeric fingerprints. +The replay input format is unchanged. Unknown versions and bad/missing/negative/ +overflowing filter fields remain errors. A v2 fuzz seed exercises the new grammar. + +## Observed local evidence + +- MSVC warning-as-error release build and10/10 native test groups passed. + 150 fixed-seed50-item scenes with varied masks/categories exactly match the + independent quadratic oracle. A hand matrix requires exactly player/character + and player/zone matches; reverse ordering, bit31, zero masks, invalid categories, + real World propagation, invalid-spawn atomicity and v1/v2 round trips are tested. +- Both actual CLI demonstrations completed360ticks. The five-entity filtered + example yielded exactly two initial pairs and zero final pairs; CSV rows list + the correct category/mask values. Canonical scene, replay and PPM were produced. + Initial/final are two snapshots, not collision episode/trajectory evidence. +- Existing20,000-entity benchmark passed unchanged budgets: storage1.267ms, + broadphase7.181ms, simulation4.651ms on this PC. Timings are not real-time claims. +- Formatting and generated source-index/repository checks passed. A native + header/library/CMake-target install into a fresh project-local prefix passed. + Optional SDL3.4.16 frontend rebuilt from the existing pinned local source. + Warning-denying Clang-tidy passed on all five changed implementation/test units; + existing vendor-header warnings remained outside the configured user-code scope. + Its first attempt lacked standard-library include paths; the identical policy + passed under the installed Visual Studio developer environment. Generated PPM + pixels were viewed through a lossless PNG companion: the player moves while + stationary overlapping actors remain drawn, independently of query filtering. + Linux sanitizer/fuzz/coverage and compiler matrix remain mandatory publication + gates; no acceptance limit or independent reference was removed. + +## Limits + +This is a fictional integer2D game-engine query feature, with no real-world +tracking, targeting, flight, physics calibration or weapon control. Filtering +does not prevent bodies from passing through each other, apply forces or supply +enter/exit trigger callbacks. Consumers own gameplay reactions and must pass +currently valid handles to raw queries. State hashes are deterministic fingerprints, +not cryptographic attestation. No user scenes, existing outputs or services were +deleted. Parent owns remote CI/publication; no Actions triggered here. diff --git a/fuzz/corpus/collision-filter.scene b/fuzz/corpus/collision-filter.scene new file mode 100644 index 0000000..7c42693 --- /dev/null +++ b/fuzz/corpus/collision-filter.scene @@ -0,0 +1,4 @@ +FORGE2D_SCENE 2 +bounds 0 0 1000 1000 +entities 1 +entity item 500 500 0 0 10 10 1234 0 0 2147483648 0 diff --git a/include/forge2d/collision.hpp b/include/forge2d/collision.hpp index f84ecb6..f1d8564 100644 --- a/include/forge2d/collision.hpp +++ b/include/forge2d/collision.hpp @@ -5,6 +5,7 @@ */ #pragma once +#include "forge2d/components.hpp" #include "forge2d/entity.hpp" #include "forge2d/math.hpp" @@ -16,6 +17,7 @@ namespace forge2d { struct CollisionItem { Entity entity{}; Aabb bounds{}; + CollisionFilter filter{}; }; struct CollisionPair { diff --git a/include/forge2d/components.hpp b/include/forge2d/components.hpp index 360efdc..0df2468 100644 --- a/include/forge2d/components.hpp +++ b/include/forge2d/components.hpp @@ -7,11 +7,22 @@ #include "forge2d/math.hpp" +#include #include #include namespace forge2d { +/** Jason Stys: one category bit and a 32-bit inclusion mask; zero mask disables matching. */ +struct CollisionFilter { + std::uint32_t category{1U}; + std::uint32_t mask{0xFFFFFFFFU}; + [[nodiscard]] bool valid() const noexcept { + return std::has_single_bit(category); + } + auto operator<=>(const CollisionFilter&) const = default; +}; + struct Transform { Vec2i position{}; auto operator<=>(const Transform&) const = default; @@ -20,6 +31,7 @@ struct Transform { struct KinematicBody { Vec2i velocity{}; Vec2i half_extent{250, 250}; + CollisionFilter filter{}; auto operator<=>(const KinematicBody&) const = default; }; diff --git a/src/collision.cpp b/src/collision.cpp index b500457..6ca3ea9 100644 --- a/src/collision.cpp +++ b/src/collision.cpp @@ -45,6 +45,9 @@ std::vector SpatialHash::query(const std::vector& std::map, std::vector> cells; for (std::size_t item_index = 0; item_index < items.size(); ++item_index) { const auto& bounds = items[item_index].bounds; + if (!items[item_index].filter.valid()) { + throw std::invalid_argument("collision category must contain exactly one bit"); + } if (bounds.half_extent.x <= 0 || bounds.half_extent.y <= 0) { throw std::invalid_argument("collision half extents must be positive"); } @@ -73,7 +76,9 @@ std::vector SpatialHash::query(const std::vector& ++right_index) { const auto& left = items[members[left_index]]; const auto& right = items[members[right_index]]; - if (intersects(left.bounds, right.bounds)) { + if ((left.filter.category & right.filter.mask) != 0U && + (right.filter.category & left.filter.mask) != 0U && + intersects(left.bounds, right.bounds)) { pairs.insert(normalized_pair(left.entity, right.entity)); } } @@ -83,10 +88,23 @@ std::vector SpatialHash::query(const std::vector& } std::vector brute_force_collisions(const std::vector& items) { + for (const auto& item : items) { + if (!item.filter.valid()) { + throw std::invalid_argument("collision category must contain exactly one bit"); + } + } std::vector pairs; for (std::size_t left_index = 0; left_index < items.size(); ++left_index) { for (std::size_t right_index = left_index + 1U; right_index < items.size(); ++right_index) { - if (intersects(items[left_index].bounds, items[right_index].bounds)) { + const auto& left = items[left_index]; + const auto& right = items[right_index]; + // Independently apply both caller inclusion decisions before the quadratic geometry + // oracle. + if ((left.filter.mask & right.filter.category) == 0U || + (right.filter.mask & left.filter.category) == 0U) { + continue; + } + if (intersects(left.bounds, right.bounds)) { pairs.push_back( normalized_pair(items[left_index].entity, items[right_index].entity)); } diff --git a/src/scene.cpp b/src/scene.cpp index 8a5751b..ead6713 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -41,6 +42,9 @@ void validate_scene(const Scene& scene) { std::set names; std::size_t players{}; for (const auto& entity : scene.entities) { + if (!entity.body.filter.valid()) { + throw std::invalid_argument("scene collision category must contain exactly one bit"); + } if (!valid_name(entity.name) || !names.insert(entity.name).second) { throw std::invalid_argument("scene entity names must be unique safe identifiers"); } @@ -63,7 +67,7 @@ void validate_scene(const Scene& scene) { std::string serialize_scene(const Scene& scene) { validate_scene(scene); std::ostringstream output; - output << "FORGE2D_SCENE 1\n"; + output << "FORGE2D_SCENE 2\n"; output << "bounds " << scene.minimum.x << ' ' << scene.minimum.y << ' ' << scene.maximum.x << ' ' << scene.maximum.y << '\n'; output << "entities " << scene.entities.size() << '\n'; @@ -72,7 +76,8 @@ std::string serialize_scene(const Scene& scene) { << entity.transform.position.y << ' ' << entity.body.velocity.x << ' ' << entity.body.velocity.y << ' ' << entity.body.half_extent.x << ' ' << entity.body.half_extent.y << ' ' << entity.sprite.rgba << ' ' - << entity.sprite.layer << ' ' << (entity.player ? 1 : 0) << '\n'; + << entity.sprite.layer << ' ' << (entity.player ? 1 : 0) << ' ' + << entity.body.filter.category << ' ' << entity.body.filter.mask << '\n'; } return output.str(); } @@ -80,9 +85,11 @@ std::string serialize_scene(const Scene& scene) { Scene parse_scene(std::string_view text) { std::istringstream input{std::string{text}}; std::string line_text; - if (!std::getline(input, line_text) || line_text != "FORGE2D_SCENE 1") { + if (!std::getline(input, line_text) || + (line_text != "FORGE2D_SCENE 1" && line_text != "FORGE2D_SCENE 2")) { throw std::invalid_argument("unsupported scene header"); } + const bool filtered = line_text == "FORGE2D_SCENE 2"; Scene scene; std::string key; if (!std::getline(input, line_text)) { @@ -125,6 +132,16 @@ Scene parse_scene(std::string_view text) { key != "entity" || layer < -32'768 || layer > 32'767 || (player != 0 && player != 1)) { throw std::invalid_argument("invalid scene entity"); } + if (filtered) { + std::uint64_t category{}, mask{}; + if (!(line >> category >> mask) || + category > std::numeric_limits::max() || + mask > std::numeric_limits::max()) { + throw std::invalid_argument("invalid scene collision filter"); + } + entity.body.filter = {static_cast(category), + static_cast(mask)}; + } require_line_end(line); entity.sprite.layer = static_cast(layer); entity.player = player == 1; diff --git a/src/world.cpp b/src/world.cpp index a179988..a5934a5 100644 --- a/src/world.cpp +++ b/src/world.cpp @@ -95,6 +95,9 @@ World::World(Vec2i minimum, Vec2i maximum, std::int32_t collision_cell_size) } Entity World::spawn(Transform transform, KinematicBody body, Sprite sprite, bool player) { + if (!body.filter.valid()) { + throw std::invalid_argument("body collision category must contain exactly one bit"); + } if (body.half_extent.x <= 0 || body.half_extent.y <= 0) { throw std::invalid_argument("body half extents must be positive"); } @@ -196,8 +199,9 @@ std::vector World::collision_items() const { result.reserve(bodies_.size()); for (const auto entity : bodies_.entities()) { if (transforms_.contains(entity)) { - result.push_back( - {entity, {transforms_.get(entity).position, bodies_.get(entity).half_extent}}); + result.push_back({entity, + {transforms_.get(entity).position, bodies_.get(entity).half_extent}, + bodies_.get(entity).filter}); } } return result; @@ -218,6 +222,8 @@ std::uint64_t World::state_hash() const { hash_u64(hash, signed_bits(value.body.velocity.y)); hash_u64(hash, signed_bits(value.body.half_extent.x)); hash_u64(hash, signed_bits(value.body.half_extent.y)); + hash_u64(hash, value.body.filter.category); + hash_u64(hash, value.body.filter.mask); hash_u64(hash, value.sprite.rgba); hash_u64(hash, static_cast(value.sprite.layer)); hash_u64(hash, value.player ? 1U : 0U); diff --git a/tests/test_main.cpp b/tests/test_main.cpp index ae32c7a..fdb0e6e 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -15,6 +15,7 @@ #include "arena.h" +#include #include #include #include @@ -152,6 +153,7 @@ void test_spatial_hash_matches_brute_force() { items.push_back( {{index, 0U}, {{coordinate(random), coordinate(random)}, {extent(random), extent(random)}}}); + items.back().filter = {1U << (index % 32U), static_cast(random())}; } require(spatial_hash.query(items) == forge2d::brute_force_collisions(items), "spatial hash must match quadratic reference exactly"); @@ -167,6 +169,84 @@ void test_spatial_hash_matches_brute_force() { "a single item must not allocate an unbounded number of cells"); } +void test_collision_category_masks() { + // Independent hand matrix: the player includes characters/zones, but not decorations; + // both participants must opt in, and a zero mask deliberately disables matching. + forge2d::SpatialHash grid{10}; + std::vector items{ + {{0U, 0U}, {{0, 0}, {10, 10}}, {1U, 6U}}, {{1U, 0U}, {{0, 0}, {10, 10}}, {2U, 1U}}, + {{2U, 0U}, {{0, 0}, {10, 10}}, {4U, 1U}}, {{3U, 0U}, {{0, 0}, {10, 10}}, {8U, 0xFFFFFFFFU}}, + {{4U, 0U}, {{0, 0}, {10, 10}}, {2U, 0U}}, + }; + const std::vector expected{{{0U, 0U}, {1U, 0U}}, {{0U, 0U}, {2U, 0U}}}; + require(grid.query(items) == expected, + "hand inclusion matrix must yield exactly player/character and player/zone"); + require(forge2d::brute_force_collisions(items) == expected, + "independent reference must honor both masks"); + std::reverse(items.begin(), items.end()); + require(grid.query(items) == expected, "filter results must keep canonical pair order"); + const std::vector high{ + {{0U, 0U}, {{0, 0}, {10, 10}}, {0x80000000U, 1U}}, + {{1U, 0U}, {{0, 0}, {10, 10}}, {1U, 0x80000000U}}, + }; + require(grid.query(high).size() == 1U, + "highest category bit must not become signed or overflow"); + for (const auto invalid : {0U, 3U}) { + const std::vector bad{ + {{0U, 0U}, {{0, 0}, {10, 10}}, {invalid, 1U}}}; + require_throws([&] { static_cast(grid.query(bad)); }, + "invalid query category must fail"); + require_throws([&] { static_cast(forge2d::brute_force_collisions(bad)); }, + "invalid reference category must fail"); + } +} + +void test_collision_scene_migration_and_hash() { + const std::string legacy = "FORGE2D_SCENE 1\nbounds 0 0 1000 1000\nentities 1\nentity item 500 " + "500 0 0 10 10 1234 0 0\n"; + auto scene = forge2d::parse_scene(legacy); + require(scene.entities[0].body.filter == forge2d::CollisionFilter{}, + "version1 scenes retain all-category inclusion defaults"); + const auto serialized = forge2d::serialize_scene(scene); + require(serialized.starts_with("FORGE2D_SCENE 2\n") && + forge2d::parse_scene(serialized) == scene, + "legacy scenes migrate to canonical version2 without losing any field"); + auto original = forge2d::World::from_scene(scene); + scene.entities[0].body.filter = {0x80000000U, 0U}; + require(forge2d::parse_scene(forge2d::serialize_scene(scene)) == scene, + "bit31 and zero mask round-trip"); + auto filtered = forge2d::World::from_scene(scene); + require(original.state_hash() != filtered.state_hash(), + "collision configuration belongs in replay state fingerprint"); + const auto before = original.state_hash(); + require_throws( + [&] { static_cast(original.spawn({{500, 500}}, {{0, 0}, {10, 10}, {3U, 1U}}, {})); }, + "spawn must reject ambiguous category"); + require(original.state_hash() == before && original.entity_count() == 1U, + "invalid spawn is atomic"); + auto paired = scene; + paired.entities[0].body.filter = {}; + paired.entities.push_back(paired.entities[0]); + paired.entities[1].name = "second"; + const auto enabled = forge2d::World::from_scene(paired); + require(enabled.collision_pairs().size() == 1U, + "World must preserve legacy all-inclusive matching"); + paired.entities[1].body.filter.mask = 0U; + auto disabled = forge2d::World::from_scene(paired); + disabled.step({}); + require(disabled.collision_pairs().empty(), + "World collision items must carry per-body filter configuration"); + require(disabled.snapshot().size() == 2U, + "query filtering must not hide or delete rendered entities"); + const std::string prefix = "FORGE2D_SCENE 2\nbounds 0 0 1000 1000\nentities 1\nentity item 500 " + "500 0 0 10 10 1234 0 0 "; + for (const auto* suffix : + {"0 1", "3 1", "4294967296 1", "1 -1", "1 4294967296", "1", "1 1 extra"}) { + require_throws([&] { static_cast(forge2d::parse_scene(prefix + suffix + "\n")); }, + "invalid version2 filter fields fail closed"); + } +} + void test_astar_returns_valid_shortest_path() { forge2d::Grid grid{12, 10}; for (std::int32_t y = 0; y < grid.height(); ++y) { @@ -271,6 +351,8 @@ int main() { // NOLINT(bugprone-exception-escape) {"entity pool and sparse-set properties", test_entity_pool_and_sparse_set_properties}, {"C arena alignment and overflow", test_c_arena_alignment_and_overflow}, {"spatial hash reference equivalence", test_spatial_hash_matches_brute_force}, + {"collision categories and masks", test_collision_category_masks}, + {"collision scene migration and fingerprint", test_collision_scene_migration_and_hash}, {"A-star shortest path", test_astar_returns_valid_shortest_path}, {"scene and replay round trip", test_scene_and_replay_round_trip}, {"world replay and render determinism", test_world_replay_and_render_are_deterministic},