├── spec ├── spec_helper.cr ├── linkedlist_spec.cr ├── yaml_spec.cr └── myecs_spec.cr ├── .editorconfig ├── .gitignore ├── shard.yml ├── plans.md ├── .vscode └── tasks.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── benchmarks ├── bench_ecs3.cr ├── bench_ecs2.cr ├── bench_flecs.cr ├── bench_entitas.cr └── bench_ecs.cr ├── Benchmarks.md ├── src ├── yaml.cr └── myecs.cr └── README.md /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/myecs" 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | .crystal 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in applications that use them 10 | /shard.lock 11 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: myecs 2 | version: 0.2.0 3 | 4 | authors: 5 | - Andrey Konovod 6 | 7 | crystal: 1.6.0 8 | 9 | dependencies: 10 | cannon: 11 | github: konovod/cannon 12 | branch: master 13 | 14 | license: MIT 15 | -------------------------------------------------------------------------------- /plans.md: -------------------------------------------------------------------------------- 1 | ### Fast iteration 2 | - filters that contain entity ids 3 | - hash of ids 4 | - reactive deletion and adding 5 | - initial building on creation 6 | - owning filters 7 | - some magic to keep consistency 8 | - filters that contain all components 9 | - additional arrays 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "spec", 8 | "type": "shell", 9 | "command": "crystal spec --no-color", 10 | "problemMatcher": [] 11 | }, 12 | { 13 | "label": "bench", 14 | "type": "shell", 15 | "command": "crystal run --no-color --release benchmarks/bench_ecs.cr --error-trace", 16 | "problemMatcher": [] 17 | }, 18 | ] 19 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | schedule: 8 | - cron: "0 6 * * 6" 9 | jobs: 10 | build: 11 | name: Test 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - { os: ubuntu-latest, crystal: latest } 17 | - { os: ubuntu-latest, crystal: nightly } 18 | - { os: macos-latest } 19 | - { os: windows-latest } 20 | runs-on: ${{matrix.os}} 21 | steps: 22 | - uses: crystal-lang/install-crystal@v1 23 | with: 24 | crystal: ${{matrix.crystal}} 25 | - uses: actions/checkout@v2 26 | - run: shards install 27 | - run: crystal spec 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Andrey Konovod 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/linkedlist_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe ECS::EntitiesList do 4 | it "creates" do 5 | list = ECS::EntitiesList.new(32) 6 | 32.times do |i| 7 | list.next_item.should eq i 8 | end 9 | expect_raises(ECS::Exception) { list.next_item } 10 | end 11 | 12 | it "can release elements" do 13 | list = ECS::EntitiesList.new(32) 14 | 32.times do |i| 15 | list.next_item 16 | end 17 | list.release 12 18 | list.next_item.should eq 12 19 | list.release 11 20 | list.next_item.should eq 11 21 | end 22 | 23 | it "can release all elements" do 24 | list = ECS::EntitiesList.new(32) 25 | 32.times do |i| 26 | list.next_item 27 | end 28 | 32.times do |i| 29 | list.release(i) 30 | end 31 | 32.times do |i| 32 | list.next_item.should eq i 33 | end 34 | expect_raises(ECS::Exception) { list.next_item } 35 | 32.times do |i| 36 | list.release(31 - i) 37 | end 38 | 32.times do |i| 39 | list.next_item.should eq 31 - i 40 | end 41 | expect_raises(ECS::Exception) { list.next_item } 42 | end 43 | 44 | it "can resize" do 45 | list = ECS::EntitiesList.new(32) 46 | 32.times do |i| 47 | list.next_item 48 | end 49 | list.resize(64) 50 | 32.times do |i| 51 | list.next_item.should eq i + 32 52 | end 53 | 64.times do |i| 54 | list.release(i) 55 | end 56 | 64.times do |i| 57 | list.next_item.should eq i 58 | end 59 | list.resize(128) 60 | 64.times do |i| 61 | list.next_item.should eq i + 64 62 | end 63 | 128.times do |i| 64 | list.release(127 - i) 65 | end 66 | 128.times do |i| 67 | list.next_item.should eq 127 - i 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/yaml_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/yaml" 3 | 4 | record Unsupported < ECS::Component, x : Int32, y : Int32 5 | record Supported < ECS::YAMLComponent, x : Int32, y : Int32 6 | 7 | it "serialize world to yaml" do 8 | world = ECS::World.new 9 | world.new_entity.add(Supported.new(1, 2)) 10 | world.new_entity.add(Unsupported.new(3, 4)) 11 | YAML.parse(world.to_yaml).to_s.should eq %q[{"Entity0" => [{"type" => "Supported", "x" => 1, "y" => 2}], "Entity1" => []}] 12 | end 13 | 14 | it "load world from yaml" do 15 | world1 = ECS::World.new 16 | world1.new_entity.add(Supported.new(1, 2)) 17 | world1.new_entity.add(Unsupported.new(3, 4)) 18 | yaml = world1.to_yaml 19 | world2 = ECS::World.from_yaml(yaml) 20 | world2.query(Supported).first.getSupported.should eq Supported.new(1, 2) 21 | world2.query(Unsupported).should be_empty 22 | end 23 | 24 | it "add entities from yaml" do 25 | world1 = ECS::World.new 26 | world1.new_entity.add(Supported.new(1, 2)) 27 | world1.new_entity.add(Unsupported.new(3, 4)) 28 | yaml = world1.to_yaml 29 | world2 = ECS::World.new 30 | world2.new_entity.add(Supported.new(10, 20)) 31 | world2.add_yaml(yaml) 32 | world2.query(Supported).to_a.map(&.getSupported).should eq [Supported.new(10, 20), Supported.new(1, 2)] 33 | world2.query(Unsupported).should be_empty 34 | end 35 | 36 | record WithLink < ECS::YAMLComponent, link : ECS::Entity 37 | 38 | it "keep links to entities" do 39 | world1 = ECS::World.new 40 | ent1 = world1.new_entity 41 | ent2 = world1.new_entity 42 | ent3 = world1.new_entity 43 | ent2.add(Supported.new(1, 2)) 44 | ent1.add(WithLink.new(ent3)) 45 | ent3.add(WithLink.new(ent2)) 46 | yaml = world1.to_yaml 47 | world2 = ECS::World.new 48 | world2.new_entity.add(Supported.new(10, 20)) 49 | world2.add_yaml(yaml) 50 | world2.query(WithLink).first.getWithLink.link.getWithLink.link.getSupported.should eq Supported.new(1, 2) 51 | end 52 | 53 | it "load yaml from multiple sources" do 54 | world = ECS::World.new 55 | world = ECS::World.from_yaml do |yaml| 56 | yaml.read "{linked1: [{type: Supported, x: 1, y: 2}, {type: WithLink, link: data2}]}" 57 | yaml.read "{data1: [{type: Supported, x: 1, y: 2}], data2: [{type: Supported, x: 10, y: 20}],}" 58 | end 59 | world.query(Supported).size.should eq 3 60 | world.query(WithLink).first.getWithLink.link.getSupported.should eq Supported.new(10, 20) 61 | world.delete_all 62 | 63 | world.add_yaml do |yaml| 64 | yaml.read "{linked1: [{type: Supported, x: 1, y: 2}, {type: WithLink, link: data2}]}" 65 | yaml.read "{data1: [{type: Supported, x: 1, y: 2}], data2: [{type: Supported, x: 10, y: 20}],}" 66 | end 67 | world.query(Supported).size.should eq 3 68 | world.query(WithLink).first.getWithLink.link.getSupported.should eq Supported.new(10, 20) 69 | end 70 | -------------------------------------------------------------------------------- /benchmarks/bench_ecs3.cr: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "../src/myecs" 3 | 4 | record PositionComponent < ECS::Component, x : Float32, y : Float32 do 5 | def update_x(value) 6 | @x = value 7 | end 8 | 9 | def update_y(value) 10 | @y = value 11 | end 12 | end 13 | 14 | record DirectionComponent < ECS::Component, x : Float32, y : Float32 15 | 16 | record ComflabulationComponent < ECS::Component, thingy : Float32, dingy : Int32, mingy : Bool, stringy : String 17 | 18 | DT = 1.0 / 60 19 | 20 | class MovementSystem < ECS::System 21 | def filter(world) 22 | world.all_of([PositionComponent, DirectionComponent]) 23 | end 24 | 25 | def process(entity) 26 | pos = entity.getPositionComponent_ptr 27 | dir = entity.getDirectionComponent 28 | pos.value.update_x(pos.value.x + dir.x*DT) 29 | pos.value.update_y(pos.value.y + dir.y*DT) 30 | end 31 | end 32 | 33 | class ComflabSystem < ECS::System 34 | def filter(world) 35 | world.of(ComflabulationComponent) 36 | end 37 | 38 | def process(entity) 39 | comp = entity.getComflabulationComponent 40 | entity.update(ComflabulationComponent.new(comp.thingy*1.000001f32, comp.dingy + 1, !comp.mingy, comp.stringy)) 41 | end 42 | end 43 | 44 | N_10M = 10_000_000 45 | 46 | def report(x, &) 47 | puts x, Benchmark.measure { yield } 48 | end 49 | 50 | def benchmark1 51 | world = ECS::World.new 52 | report("Creating 10M entities") do 53 | N_10M.times do 54 | world.new_entity 55 | end 56 | end 57 | world.delete_all 58 | 59 | list = [] of ECS::Entity 60 | N_10M.times do 61 | list << world.new_entity 62 | end 63 | report("Destroying 10M entities") do 64 | list.each &.destroy 65 | end 66 | 67 | N_10M.times do 68 | world.new_entity 69 | end 70 | report("Destroying 10M entities at once") do 71 | world.delete_all 72 | end 73 | 74 | N_10M.times do 75 | ent = world.new_entity 76 | ent.add(PositionComponent.new(0, 0)) 77 | end 78 | report("Iterating over 10M entities, unpacking one component") do 79 | world.query(PositionComponent).each_entity do |ent| 80 | v = ent.getPositionComponent 81 | puts v.y if v.x < -0.5 82 | end 83 | end 84 | world.delete_all 85 | 86 | N_10M.times do 87 | ent = world.new_entity 88 | ent.add(PositionComponent.new(0, 0)) 89 | ent.add(DirectionComponent.new(0, 0)) 90 | end 91 | report("Iterating over 10M entities, unpacking two component") do 92 | world.query(PositionComponent).each_entity do |ent| 93 | v = ent.getPositionComponent 94 | w = ent.getDirectionComponent 95 | puts v.y if w.x < -0.5 96 | end 97 | end 98 | world.delete_all 99 | 100 | sys = ECS::Systems.new(world) 101 | sys.add MovementSystem 102 | sys.add ComflabSystem 103 | sys.init 104 | [1, 2, 5, 10, 20].each do |n| 105 | (n*1_000_000).times do |i| 106 | ent = world.new_entity 107 | ent.add(PositionComponent.new(0, 0)) 108 | ent.add(DirectionComponent.new(0, 0)) 109 | if i % 2 == 0 110 | ent.add(ComflabulationComponent.new(0, 0, false, "")) 111 | end 112 | end 113 | report("Update #{n}M entities with 2 Systems") do 114 | sys.execute 115 | end 116 | world.delete_all 117 | end 118 | end 119 | 120 | ECS.debug_stats 121 | benchmark1 122 | -------------------------------------------------------------------------------- /benchmarks/bench_ecs2.cr: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "../src/myecs" 3 | 4 | record Comp1 < ECS::Component, x : Int32 do 5 | def change_x(value) 6 | @x = value 7 | end 8 | end 9 | 10 | record Comp2 < ECS::Component, x : Int32 11 | record Comp3 < ECS::Component, x : Int32 12 | 13 | class SystemUpdateComp1 < ECS::System 14 | def filter(world) 15 | world.of(Comp1) 16 | end 17 | 18 | def process(entity) 19 | comp = entity.getComp1 20 | entity.update(Comp1.new(comp.x + 1)) 21 | end 22 | end 23 | 24 | class SystemUpdateComp1UsingPtr < ECS::System 25 | def filter(world) 26 | world.of(Comp1) 27 | end 28 | 29 | def process(entity) 30 | ptr_comp = entity.getComp1_ptr 31 | ptr_comp.value.change_x(ptr_comp.value.x + 1) 32 | end 33 | end 34 | 35 | class SystemUpdateComp3 < ECS::System 36 | def filter(world) 37 | world.all_of([Comp1, Comp2, Comp3]) 38 | end 39 | 40 | def process(entity) 41 | comp1 = entity.getComp1 42 | comp2 = entity.getComp2 43 | comp3 = entity.getComp3 44 | entity.update(Comp1.new(comp1.x + comp2.x + comp3.x)) 45 | end 46 | end 47 | 48 | class SystemUpdateComp3UsingPtr < ECS::System 49 | def filter(world) 50 | world.all_of([Comp1, Comp2, Comp3]) 51 | end 52 | 53 | def process(entity) 54 | comp1 = entity.getComp1_ptr 55 | comp2 = entity.getComp2_ptr 56 | comp3 = entity.getComp3_ptr 57 | comp1.value.change_x(comp1.value.x + comp2.value.x + comp3.value.x) 58 | end 59 | end 60 | 61 | def direct_increment(world, three) 62 | if three 63 | pool1 = world.pool_for(Comp1.new(0)).@raw 64 | pool2 = world.pool_for(Comp2.new(0)).@raw 65 | pool3 = world.pool_for(Comp3.new(0)).@raw 66 | # pp! pool1.size, pool2.size, pool3.size 67 | BENCH_N.times do |i| 68 | pool1[i].change_x(pool1[i].x + pool2[i].x + pool3[i].x) 69 | end 70 | else 71 | world.pool_for(Comp1.new(0)).@raw.map! { |x| Comp1.new(x.x + 1) } 72 | end 73 | end 74 | 75 | def init_benchmark_world(n, three, padding) 76 | world = ECS::World.new 77 | n *= 10 if padding 78 | if three 79 | n.times do |i| 80 | ent = world.new_entity 81 | if (!padding) || i % 10 == 0 82 | ent.add(Comp1.new(1)) 83 | ent.add(Comp2.new(1)) 84 | ent.add(Comp3.new(1)) 85 | else 86 | case i % 3 87 | when 0 88 | ent.add(Comp1.new(1)) 89 | when 1 90 | ent.add(Comp2.new(1)) 91 | when 2 92 | ent.add(Comp3.new(1)) 93 | end 94 | end 95 | end 96 | else 97 | n.times do |i| 98 | ent = world.new_entity 99 | if (!padding) || i % 10 == 0 100 | ent.add(Comp1.new(1)) 101 | else 102 | ent.add(Comp2.new(1)) 103 | end 104 | end 105 | end 106 | return world 107 | end 108 | 109 | BENCH_N = 100000 110 | BENCH_WARMUP = 1 111 | BENCH_TIME = 2 112 | 113 | macro benchmark_list(three, *list) 114 | puts "***********************************************" 115 | puts "Three: {{three}}" 116 | world = init_benchmark_world(BENCH_N, {{three}}, false) 117 | worldp = init_benchmark_world(BENCH_N, {{three}}, true) 118 | puts world.stats 119 | puts worldp.stats 120 | list = [] of {ECS::Systems, String} 121 | {% for cls in list %} 122 | %sys = ECS::Systems.new(world) 123 | %sys.add({{cls}}) 124 | list << { %sys, ""} 125 | %sys2 = ECS::Systems.new(worldp) 126 | %sys2.add({{cls}}) 127 | list << { %sys2, "padded"} 128 | {% end %} 129 | 130 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 131 | 132 | bm.report("direct") do 133 | direct_increment(world, {{three}}) 134 | end 135 | 136 | list.each do |(sys, prefix)| 137 | sys.init 138 | sys.execute 139 | bm.report("#{prefix} #{sys.children[0].class.name}") do 140 | sys.execute 141 | end 142 | # sys.teardown fails due to bm imlementation? 143 | end 144 | end 145 | end 146 | 147 | benchmark_list(false, 148 | SystemUpdateComp1, 149 | SystemUpdateComp1UsingPtr 150 | ) 151 | benchmark_list(true, 152 | SystemUpdateComp3, 153 | SystemUpdateComp3UsingPtr 154 | ) 155 | 156 | ECS.debug_stats 157 | -------------------------------------------------------------------------------- /Benchmarks.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | I'm comparing it with other ECS with some "realistic" scenario - creating world with 1_000_000 entities (with 100 component types), adding and removing components in it, iterating over components, replacing components with another etc. 3 | You can see I'm not actually beating them in all areas (I'm slower in access but much faster in creation), but my ECS looks fast enough for me. What is I'm proud - 0.0B/op for all operations (after initial growth of pools) 4 | 5 | ## my ECS: 6 | Compilation time (in release mode): 7 | ``` 8 | real 0m30.592s 9 | user 0m29.234s 10 | sys 0m1.703s 11 | ``` 12 | Results: 13 | ``` 14 | *********************************************** 15 | create empty world 8.34k (119.93µs) (± 5.61%) 484kB/op fastest 16 | create benchmark world 5.68 (176.13ms) (± 8.67%) 1.02GB/op 1468.61× slower 17 | create and clear benchmark world 4.66 (214.68ms) (± 5.40%) 1.02GB/op 1790.11× slower 18 | *********************************************** 19 | EmptySystem 182.15M ( 5.49ns) (± 2.51%) 0.0B/op fastest 20 | EmptyFilterSystem 40.55M ( 24.66ns) (± 0.81%) 0.0B/op 4.49× slower 21 | SystemAddDeleteSingleComponent 37.91M ( 26.38ns) (± 0.77%) 0.0B/op 4.81× slower 22 | SystemAddDeleteFourComponents 3.11M (321.07ns) (± 0.47%) 0.0B/op 58.48× slower 23 | SystemAskComponent(0) 143.23M ( 6.98ns) (± 1.20%) 0.0B/op 1.27× slower 24 | SystemAskComponent(1) 139.83M ( 7.15ns) (± 1.30%) 0.0B/op 1.30× slower 25 | SystemGetComponent(0) 121.21M ( 8.25ns) (± 1.28%) 0.0B/op 1.50× slower 26 | SystemGetComponent(1) 111.17M ( 9.00ns) (± 3.95%) 0.0B/op 1.64× slower 27 | SystemGetSingletonComponent 130.97M ( 7.64ns) (± 1.19%) 0.0B/op 1.39× slower 28 | IterateOverCustomFilterSystem 75.59M ( 13.23ns) (± 1.17%) 0.0B/op 2.41× slower 29 | *********************************************** 30 | SystemCountComp1 297.24 ( 3.36ms) (± 0.54%) 0.0B/op fastest 31 | SystemUpdateComp1 117.62 ( 8.50ms) (± 0.27%) 0.0B/op 2.53× slower 32 | SystemUpdateComp1UsingPtr 242.48 ( 4.12ms) (± 0.33%) 0.0B/op 1.23× slower 33 | SystemReplaceComps 41.23 ( 24.25ms) (± 0.14%) 0.0B/op 7.21× slower 34 | SystemPassEvents 32.59 ( 30.68ms) (± 0.37%) 0.0B/op 9.12× slower 35 | *********************************************** 36 | FullFilterSystem 169.08 ( 5.91ms) (± 0.21%) 0.0B/op 1.76× slower 37 | FullFilterAnyOfSystem 125.96 ( 7.94ms) (± 0.21%) 0.0B/op 2.36× slower 38 | SystemComplexFilter 297.23 ( 3.36ms) (± 2.22%) 0.0B/op fastest 39 | SystemComplexSelectFilter 286.01 ( 3.50ms) (± 0.81%) 0.0B/op 1.04× slower 40 | ``` 41 | 42 | ## Entitas 43 | https://github.com/spoved/entitas.cr 44 | 45 | It worked with 1kk entities, but once added 100 components to the mix it started to crash. So it is benchmarked with half count of entities. 46 | 47 | Fast components access (4ns vs 8ns), but slow in creation and updating. 48 | 49 | Compilation time: 50 | ``` 51 | real 0m37.578s 52 | user 0m39.594s 53 | sys 0m1.891s 54 | ``` 55 | Results: 56 | ``` 57 | *********************************************** 58 | create empty world 350.73k ( 2.85µs) (±17.52%) 13.0kB/op fastest 59 | create benchmark world 2.00 (498.99ms) (±13.01%) 1.68GB/op 175012.30× slower 60 | create and clear benchmark world 1.39 (721.14ms) (± 8.75%) 1.73GB/op 252925.92× slower 61 | *********************************************** 62 | EmptySystem 327.74M ( 3.05ns) (± 2.84%) 0.0B/op fastest 63 | EmptyFilterSystem 258.61M ( 3.87ns) (± 2.12%) 0.0B/op 1.27× slower 64 | SystemAddDeleteSingleComponent 259.18k ( 3.86µs) (±11.42%) 3.46kB/op 1264.51× slower 65 | SystemAddDeleteFourComponents 238.17k ( 4.20µs) (±17.98%) 3.47kB/op 1376.08× slower 66 | SystemAskComponent(0) 221.77M ( 4.51ns) (± 2.52%) 0.0B/op 1.48× slower 67 | SystemAskComponent(1) 222.47M ( 4.49ns) (± 2.07%) 0.0B/op 1.47× slower 68 | SystemGetComponent(0) 218.50M ( 4.58ns) (± 1.67%) 0.0B/op 1.50× slower 69 | SystemGetComponent(1) 221.06M ( 4.52ns) (± 2.79%) 0.0B/op 1.48× slower 70 | *********************************************** 71 | SystemCountComp1 17.91k ( 55.84µs) (± 0.18%) 0.0B/op fastest 72 | SystemUpdateComp1 28.80 ( 34.73ms) (± 0.82%) 0.0B/op 621.85× slower 73 | SystemUpdateComp1UsingPtr 13.29 ( 75.25ms) (± 1.46%) 0.0B/op 1347.53× slower 74 | SystemReplaceComp1 8.82 (113.43ms) (± 1.18%) 1.91MB/op 2031.25× slower 75 | *********************************************** 76 | FullFilterSystem 241.66M ( 4.14ns) (± 2.61%) 0.0B/op fastest 77 | FullFilterAnyOfSystem 241.46M ( 4.14ns) (± 2.73%) 0.0B/op 1.00× slower 78 | SystemComplexFilter 78.15k ( 12.80µs) (± 0.26%) 0.0B/op 3092.29× slower 79 | ``` 80 | 81 | ## Flecs 82 | https://github.com/jemc/crystal-flecs.git 83 | 84 | 10x faster at iteration, 5x faster at updating, but other operations are significantly slower as it is archetype-based ecs. 85 | 86 | Note that memory usage shows usage only on Crystal bindings side, not allocations inside Flecs itself. 87 | 88 | Compilation time: 89 | ``` 90 | real 0m20.507s 91 | user 0m19.906s 92 | sys 0m1.109s 93 | ``` 94 | Results: 95 | ``` 96 | create empty world 81.10 ( 12.33ms) (± 0.78%) 1.0kB/op fastest 97 | create benchmark world 287.02m ( 3.48s ) (± 0.00%) 10.2MB/op 282.56× slower 98 | create and clear benchmark world 264.29m ( 3.78s ) (± 0.00%) 10.2MB/op 306.87× slower 99 | *********************************************** 100 | EmptySystem 327.97k ( 3.05µs) (± 0.62%) 0.0B/op 1.01× slower 101 | EmptyFilterSystem 329.99k ( 3.03µs) (± 0.80%) 0.0B/op 1.00× slower 102 | SystemAddDeleteSingleComponent 136.50k ( 7.33µs) (± 0.40%) 0.0B/op 2.42× slower 103 | SystemAddDeleteFourComponents 78.04k ( 12.81µs) (± 0.33%) 0.0B/op 4.24× slower 104 | SystemCountComp1 8.84k (113.07µs) (± 0.56%) 0.0B/op 37.38× slower 105 | SystemUpdateComp1 1.15k (868.92µs) (± 0.44%) 0.0B/op 287.26× slower 106 | *********************************************** 107 | SystemReplaceComp1 372.83m ( 2.68s ) (± 0.00%) 0.0B/op fastest 108 | *********************************************** 109 | SystemGetComponent(0) 294.91k ( 3.39µs) (± 0.31%) 0.0B/op 1.01× slower 110 | SystemGetComponent(1) 298.76k ( 3.35µs) (± 0.44%) 0.0B/op fastest 111 | ``` 112 | -------------------------------------------------------------------------------- /src/yaml.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module ECS 4 | # hack to add entities table to the YAML parser 5 | private record FakeNode, anchor : String 6 | 7 | abstract struct YAMLComponent < Component 8 | include YAML::Serializable 9 | 10 | def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) 11 | {% begin %} 12 | ctx.read_alias(node, \{{@type}}) do |obj| 13 | return obj 14 | end 15 | unless node.is_a?(YAML::Nodes::Mapping) 16 | node.raise "expected YAML mapping, not #{node.class}" 17 | end 18 | 19 | node.each do |key, value| 20 | next unless key.is_a?(YAML::Nodes::Scalar) && value.is_a?(YAML::Nodes::Scalar) 21 | next unless key.value == "type" 22 | 23 | discriminator_value = value.value 24 | case discriminator_value 25 | {% for obj in YAMLComponent.all_subclasses %} 26 | when {{obj.id.stringify}} 27 | result = {{obj.id}}.new(ctx, node) 28 | result.after_initialize 29 | return result 30 | {% end %} 31 | else 32 | node.raise "Unknown 'type' discriminator value: #{discriminator_value.inspect}" 33 | end 34 | end 35 | node.raise "Missing YAML discriminator field 'type'" 36 | {% end %} 37 | end 38 | 39 | # this is a hack - it copies substancial part of logic from Crystal stdlib just for one thing - automatically serialize `type` field 40 | def to_yaml(yaml : ::YAML::Nodes::Builder) 41 | {% begin %} 42 | {% options = @type.annotation(::YAML::Serializable::Options) %} 43 | {% emit_nulls = options && options[:emit_nulls] %} 44 | 45 | {% properties = {} of Nil => Nil %} 46 | {% for ivar in @type.instance_vars %} 47 | {% ann = ivar.annotation(::YAML::Field) %} 48 | {% unless ann && (ann[:ignore] || ann[:ignore_serialize] == true) %} 49 | {% 50 | properties[ivar.id] = { 51 | key: ((ann && ann[:key]) || ivar).id.stringify, 52 | converter: ann && ann[:converter], 53 | emit_null: (ann && (ann[:emit_null] != nil) ? ann[:emit_null] : emit_nulls), 54 | ignore_serialize: ann && ann[:ignore_serialize], 55 | } 56 | %} 57 | {% end %} 58 | {% end %} 59 | 60 | yaml.mapping(reference: self) do 61 | # These are two strings that was added 62 | # ------------ 63 | "type".to_yaml(yaml) 64 | self.class.name.to_yaml(yaml) 65 | # ------------ 66 | {% for name, value in properties %} 67 | _{{name}} = @{{name}} 68 | 69 | {% if value[:ignore_serialize] %} 70 | unless {{value[:ignore_serialize]}} 71 | {% end %} 72 | 73 | {% unless value[:emit_null] %} 74 | unless _{{name}}.nil? 75 | {% end %} 76 | 77 | {{value[:key]}}.to_yaml(yaml) 78 | 79 | {% if value[:converter] %} 80 | if _{{name}} 81 | {{ value[:converter] }}.to_yaml(_{{name}}, yaml) 82 | else 83 | nil.to_yaml(yaml) 84 | end 85 | {% else %} 86 | _{{name}}.to_yaml(yaml) 87 | {% end %} 88 | 89 | {% unless value[:emit_null] %} 90 | end 91 | {% end %} 92 | {% if value[:ignore_serialize] %} 93 | end 94 | {% end %} 95 | {% end %} 96 | on_to_yaml(yaml) 97 | end 98 | {% end %} 99 | end 100 | 101 | protected def on_unknown_yaml_attribute(ctx, key, key_node, value_node) 102 | key_node.raise "Unknown yaml attribute: #{key}" unless key == "type" 103 | end 104 | 105 | def self.new 106 | end 107 | end 108 | 109 | class EntitiesHash 110 | @entities = Hash(String, Entity).new 111 | 112 | def initialize(world) 113 | @entities = Hash(String, Entity).new { |h, x| ent = world.new_entity; h[x] = ent; ent } 114 | end 115 | 116 | def storage 117 | @entities 118 | end 119 | 120 | def reset 121 | @entities = Hash(String, Entity).new 122 | end 123 | end 124 | 125 | struct Entity 126 | def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) 127 | name = String.new(ctx, node) 128 | # storage = ctx.read_alias(FakeNode.new("_ecs_storage"), EntitiesHash) 129 | object_id, _ = ctx.@anchors["_ecs_storage"] 130 | storage = Pointer(Void).new(object_id).as(EntitiesHash) 131 | storage.storage[name] 132 | end 133 | 134 | def to_yaml_id(yaml : YAML::Nodes::Builder) : Nil 135 | "Entity#{self.id}".to_yaml(yaml) 136 | end 137 | 138 | def to_yaml(yaml : YAML::Nodes::Builder) : Nil 139 | to_yaml_id(yaml) 140 | end 141 | 142 | def to_yaml_comps(yaml : YAML::Nodes::Builder) : Nil 143 | {% begin %} 144 | yaml.sequence(reference: self) do 145 | {% for obj in YAMLComponent.all_subclasses %} 146 | if x = self.get{{obj.id}}? 147 | x.to_yaml(yaml) 148 | end 149 | {% end %} 150 | end 151 | {% end %} 152 | end 153 | end 154 | 155 | class World 156 | def to_yaml(yaml : YAML::Nodes::Builder) : Nil 157 | yaml.mapping(reference: self) do 158 | self.each_entity do |ent| 159 | ent.to_yaml_id(yaml) 160 | ent.to_yaml_comps(yaml) 161 | end 162 | end 163 | end 164 | 165 | def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) 166 | world = self.new 167 | names = EntitiesHash.new(world) 168 | ctx.record_anchor(FakeNode.new("_ecs_storage"), names) 169 | stubs = Hash(String, Array(YAMLComponent)).new(ctx, node) 170 | stubs.each do |k, v| 171 | ent = names.storage[k] 172 | v.each do |comp| 173 | ent.add(comp) 174 | end 175 | end 176 | world 177 | end 178 | 179 | def add_yaml(io_or_string) 180 | YAMLReader.new(self).read(io_or_string) 181 | end 182 | 183 | def add_yaml(&) 184 | yield(YAMLReader.new(self)) 185 | self 186 | end 187 | 188 | def self.from_yaml(&) 189 | self.new.add_yaml { |yaml| yield(yaml) } 190 | end 191 | end 192 | 193 | struct YAMLReader 194 | @names : EntitiesHash 195 | 196 | def initialize(owner) 197 | @names = EntitiesHash.new(owner) 198 | end 199 | 200 | def read(io_or_string) 201 | ctx = YAML::ParseContext.new 202 | node = YAML::Nodes.parse(io_or_string).nodes.first 203 | ctx.record_anchor(FakeNode.new("_ecs_storage"), @names) 204 | stubs = Hash(String, Array(YAMLComponent)).new(ctx, node) 205 | stubs.each do |k, v| 206 | ent = @names.storage[k] 207 | v.each do |comp| 208 | ent.add(comp) 209 | end 210 | end 211 | self 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /benchmarks/bench_flecs.cr: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "flecs" 3 | 4 | {% for i in 1..100 %} 5 | ECS.component BenchComp{{i}} do 6 | property x : Int32 7 | property y : Int32 8 | 9 | def initialize(@x, @y) 10 | end 11 | end 12 | {% end %} 13 | 14 | ECS.component Comp1 do 15 | property x : Int32 16 | property y : Int32 17 | 18 | def initialize(@x, @y) 19 | end 20 | end 21 | ECS.component Comp2 do 22 | property name : String 23 | 24 | def initialize(@name) 25 | end 26 | end 27 | ECS.component Comp3 do 28 | {% for i in (1..64) %} 29 | property heavy{{i}} : Int32 = 0 30 | {% end %} 31 | end 32 | # ECS.component Comp4 do 33 | # end 34 | 35 | ECS.component Comp5 do 36 | property vx : Int32 37 | property vy : Int32 38 | 39 | def initialize(@vx, @vy) 40 | end 41 | end 42 | 43 | ECS.system EmptySystem do 44 | phase "EcsOnUpdate" 45 | 46 | def self.run(iter) 47 | end 48 | end 49 | 50 | ECS.system EmptyFilterSystem do 51 | phase "EcsOnUpdate" 52 | 53 | term x : Comp5 54 | @@i = 0 55 | 56 | def self.run(iter) 57 | @@i = 0 58 | iter.each do |row| 59 | @@i += 1 60 | end 61 | end 62 | end 63 | 64 | # ECS.system FullFilterSystem do 65 | # phase "EcsOnUpdate" 66 | 67 | # # TODO or filter 68 | # term x : Comp1 69 | # term y : Comp2 70 | # term z : Comp3 71 | 72 | # # term t : Comp4 73 | # @@i = 0 74 | 75 | # def self.run(iter) 76 | # @@i = 0 77 | # iter.each do |row| 78 | # @@i += 1 79 | # end 80 | # end 81 | # end 82 | 83 | ECS.system SystemAddDeleteSingleComponent do 84 | phase "EcsOnUpdate" 85 | 86 | def self.run(iter) 87 | ent = iter.world.entity_init 88 | iter.world.set(ent, Comp1[-1, -1]) 89 | iter.world.remove(ent, Comp1) 90 | end 91 | end 92 | 93 | ECS.system SystemAddDeleteFourComponents do 94 | phase "EcsOnUpdate" 95 | 96 | def self.run(iter) 97 | ent = iter.world.entity_init 98 | iter.world.set(ent, Comp1[-1, -1]) 99 | iter.world.set(ent, Comp2["-1"]) 100 | iter.world.set(ent, Comp3[]) 101 | ECS::LibECS.entity_delete(iter.world, ent) 102 | end 103 | end 104 | 105 | ECS.system SystemGetComponent do 106 | phase "EcsOnUpdate" 107 | 108 | @@ent : UInt64 = 0 109 | @@i = 0 110 | 111 | def self.register2(world, positive) 112 | register(world) 113 | @@ent = world.entity_init 114 | if positive 115 | world.set(@@ent, Comp1[-1, -1]) 116 | else 117 | world.set(@@ent, Comp5[-1, -1]) 118 | end 119 | end 120 | 121 | def self.run(iter) 122 | @@i += 1 if iter.world.get(@@ent, Comp1) 123 | end 124 | end 125 | 126 | # class SystemGetSingletonComponent < ECS::System 127 | # @count = 0 128 | 129 | # def execute 130 | # conf = @world.new_entity.getConfig 131 | # @count = conf.values.size 132 | # end 133 | # end 134 | 135 | ECS.system SystemCountComp1 do 136 | phase "EcsOnUpdate" 137 | 138 | term x : Comp1 139 | @@i = 0 140 | 141 | def self.run(iter) 142 | @@i = 0 143 | iter.each do |row| 144 | @@i += 1 145 | end 146 | end 147 | end 148 | 149 | ECS.system SystemUpdateComp1 do 150 | phase "EcsOnUpdate" 151 | 152 | term comp1 : Comp1, write: true 153 | 154 | def self.run(iter) 155 | iter.each do |row| 156 | row.update_comp1 { |comp1| 157 | comp1.x = -comp1.x 158 | comp1.y = -comp1.y 159 | comp1 160 | } 161 | row.update_comp1 { |comp1| 162 | comp1.x = -comp1.x 163 | comp1.y = -comp1.y 164 | comp1 165 | } 166 | end 167 | end 168 | end 169 | 170 | ECS.system SystemReplaceComp1 do 171 | phase "EcsOnUpdate" 172 | 173 | term comp1 : Comp1, write: true 174 | 175 | def self.run(iter) 176 | iter.each do |row| 177 | comp1 = row.comp1 178 | iter.world.set(row.id, Comp5[comp1.x, comp1.y]) 179 | iter.world.remove(row.id, Comp1) 180 | end 181 | end 182 | end 183 | 184 | ECS.system SystemReplaceComp5 do 185 | phase "EcsOnStore" 186 | 187 | term comp5 : Comp5, write: true 188 | 189 | def self.run(iter) 190 | iter.each do |row| 191 | comp5 = row.comp5 192 | iter.world.set(row.id, Comp1[comp5.vx, comp5.vy]) 193 | iter.world.remove(row.id, Comp5) 194 | end 195 | end 196 | end 197 | 198 | # class SystemReplaceComp1 < ECS::System 199 | # def filter(world) 200 | # world.of(Comp1) 201 | # end 202 | 203 | # def process(entity) 204 | # comp = entity.getComp1 205 | # entity.replace(Comp1, Comp5.new(-comp.x, -comp.y)) 206 | # comp5 = entity.getComp5 207 | # entity.replace(Comp5, Comp1.new(-comp.x, -comp.y)) 208 | # end 209 | # end 210 | 211 | # class SystemComplexFilter < ECS::System 212 | # def filter(world) 213 | # world.any_of([Comp1, Comp2]).all_of([Comp3]).exclude(Comp4) 214 | # end 215 | 216 | # @count = 0 217 | 218 | # def process(entity) 219 | # @count += 1 220 | # end 221 | # end 222 | 223 | # class SystemComplexSelectFilter < ECS::System 224 | # def filter(world) 225 | # world.any_of([Comp1, Comp2]).all_of([Comp3]).exclude(Comp4).select { |ent| ent.id % 10 > 5 } 226 | # end 227 | 228 | # @count = 0 229 | 230 | # def process(entity) 231 | # @count += 1 232 | # end 233 | # end 234 | 235 | # class SystemGenerateEvent(Event) < ECS::System 236 | # @fixed_filter : ECS::Filter? 237 | 238 | # def initialize(@world, @fixed_filter = nil) 239 | # super(@world) 240 | # end 241 | 242 | # def filter(world) 243 | # @fixed_filter.not_nil! 244 | # end 245 | 246 | # def process(entity) 247 | # entity.add(Event.new) 248 | # end 249 | # end 250 | 251 | # class CountAllOf(Event) < ECS::System 252 | # def filter(world) 253 | # world.of(Event) 254 | # end 255 | 256 | # property value = 0 257 | 258 | # def process(entity) 259 | # @value += 1 260 | # end 261 | 262 | # def execute 263 | # @value = 0 264 | # end 265 | # end 266 | 267 | # class SystemPassEvents < ECS::Systems 268 | # def initialize(@world) 269 | # super 270 | # add SystemGenerateEvent(TestEvent1).new(@world, @world.of(Comp1)) 271 | # add SystemGenerateEvent(TestEvent2).new(@world, @world.of(Comp2)) 272 | # add SystemGenerateEvent(TestEvent3).new(@world, @world.all_of([TestEvent1, TestEvent2])) 273 | # add ECS::RemoveAllOf(TestEvent1).new(@world) 274 | # add ECS::RemoveAllOf(TestEvent2).new(@world) 275 | # add CountAllOf(TestEvent3).new(@world) 276 | # add ECS::RemoveAllOf(TestEvent3).new(@world) 277 | # end 278 | # end 279 | 280 | def init_benchmark_world(world, n) 281 | {% for i in 1..100 %} 282 | BenchComp{{i}}.register(world) 283 | ent = world.entity_init 284 | world.set(ent, BenchComp{{i}}[0, 0]) 285 | {% end %} 286 | 287 | Comp1.register(world) 288 | Comp2.register(world) 289 | Comp3.register(world) 290 | # Comp4.register(world) 291 | Comp5.register(world) 292 | ent = world.entity_init 293 | world.set(ent, Comp5[0, 0]) 294 | world.remove(ent, Comp5) 295 | n.times do |i| 296 | ent = world.entity_init 297 | world.set(ent, Comp1[i, i]) if i % 2 == 0 298 | world.set(ent, Comp2[i.to_s]) if i % 3 == 0 299 | # p 6 300 | world.set(ent, Comp3[]) if i % 5 == 0 301 | # p 7 302 | # world.set(ent, Comp4[]) if i % 7 == 0 303 | end 304 | return world 305 | end 306 | 307 | BENCH_N = 1000000 308 | BENCH_WARMUP = 1 309 | BENCH_TIME = 2 310 | 311 | def benchmark_creation 312 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 313 | bm.report("create empty world") do 314 | world = ECS::World.init 315 | world.fini 316 | end 317 | bm.report("create benchmark world") do 318 | world = init_benchmark_world(ECS::World.init, BENCH_N) 319 | # world.fini 320 | end 321 | bm.report("create and clear benchmark world") do 322 | world = init_benchmark_world(ECS::World.init, BENCH_N) 323 | world.fini 324 | end 325 | end 326 | end 327 | 328 | def benchmark_list(list) 329 | puts "***********************************************" 330 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 331 | list.each do |cls| 332 | world = ECS::World.init 333 | cls.register(world) 334 | init_benchmark_world(world, BENCH_N) 335 | world.progress 336 | bm.report(cls.to_s) do 337 | world.progress 338 | end 339 | # world.fini 340 | # sys.teardown fails due to bm imlementation? 341 | end 342 | end 343 | end 344 | 345 | def benchmark_list2(list) 346 | puts "***********************************************" 347 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 348 | list.each do |cls| 349 | world = ECS::World.init 350 | init_benchmark_world(world, BENCH_N) 351 | cls.register2(world, false) 352 | world.progress 353 | bm.report(cls.to_s + "(0)") do 354 | world.progress 355 | end 356 | 357 | world = ECS::World.init 358 | init_benchmark_world(world, BENCH_N) 359 | cls.register2(world, true) 360 | world.progress 361 | bm.report(cls.to_s + "(1)") do 362 | world.progress 363 | end 364 | 365 | # world.fini 366 | # sys.teardown fails due to bm imlementation? 367 | end 368 | end 369 | end 370 | 371 | def benchmark_list3(list) 372 | puts "***********************************************" 373 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 374 | world = ECS::World.init 375 | list.each do |cls| 376 | cls.register(world) 377 | end 378 | init_benchmark_world(world, BENCH_N) 379 | world.progress 380 | bm.report("replace") do 381 | world.progress 382 | end 383 | # world.fini 384 | # sys.teardown fails due to bm imlementation? 385 | end 386 | end 387 | 388 | benchmark_list3 [ 389 | SystemReplaceComp1, 390 | SystemReplaceComp5, 391 | ] 392 | 393 | benchmark_creation 394 | 395 | benchmark_list [ 396 | EmptySystem, 397 | EmptyFilterSystem, 398 | SystemAddDeleteSingleComponent, 399 | SystemAddDeleteFourComponents, 400 | SystemCountComp1, 401 | SystemUpdateComp1, 402 | SystemReplaceComp1, 403 | # SystemGetSingletonComponent, 404 | # SystemPassEvents, 405 | # FullFilterSystem, 406 | # FullFilterAnyOfSystem, 407 | # SystemComplexFilter, 408 | # SystemComplexSelectFilter, 409 | ] 410 | 411 | benchmark_list2 [ 412 | SystemGetComponent, 413 | ] 414 | -------------------------------------------------------------------------------- /benchmarks/bench_entitas.cr: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "entitas" 3 | 4 | annotation SingleFrame; end 5 | 6 | {% for i in 1..100 %} 7 | @[Context(Game)] 8 | class BenchComp{{i}} < Entitas::Component 9 | prop :x, Int32 10 | prop :y, Int32 11 | end 12 | {% end %} 13 | 14 | @[Context(Game)] 15 | class Comp1 < Entitas::Component 16 | prop :x, Int32 17 | prop :y, Int32 18 | end 19 | 20 | @[Context(Game)] 21 | class Comp2 < Entitas::Component 22 | prop :name, String 23 | end 24 | 25 | @[Context(Game)] 26 | class Comp3 < Entitas::Component 27 | prop :heavy, StaticArray(Int32, 64) 28 | end 29 | 30 | @[Context(Game)] 31 | class Comp4 < Entitas::Component 32 | end 33 | 34 | @[Context(Game)] 35 | class Comp5 < Entitas::Component 36 | prop :vx, Int32 37 | prop :vy, Int32 38 | end 39 | 40 | @[Context(Game)] 41 | # @[SingleFrame] 42 | class TestEvent1 < Entitas::Component 43 | end 44 | 45 | @[Context(Game)] 46 | # @[SingleFrame] 47 | class TestEvent2 < Entitas::Component 48 | end 49 | 50 | @[Context(Game)] 51 | # @[SingleFrame] 52 | class TestEvent3 < Entitas::Component 53 | end 54 | 55 | class EmptySystem 56 | include Entitas::Systems::ExecuteSystem 57 | 58 | def initialize(@context : GameContext) 59 | end 60 | 61 | def execute 62 | end 63 | end 64 | 65 | class EmptyFilterSystem 66 | include Entitas::Systems::ExecuteSystem 67 | 68 | getter context : GameContext 69 | getter filter : Entitas::Group(GameEntity) 70 | 71 | def initialize(@context : GameContext) 72 | @filter = context.get_group(Entitas::Matcher.all_of(Comp5)) 73 | end 74 | 75 | def execute 76 | @filter.get_entities.each do |ent| 77 | end 78 | end 79 | end 80 | 81 | class FullFilterSystem 82 | include Entitas::Systems::ExecuteSystem 83 | 84 | getter context : GameContext 85 | getter filter : Entitas::Group(GameEntity) 86 | 87 | def initialize(@context : GameContext) 88 | @filter = context.get_group(Entitas::Matcher.none_of(Comp5)) 89 | end 90 | 91 | def execute 92 | @filter.get_entities.each do |ent| 93 | end 94 | end 95 | end 96 | 97 | class FullFilterAnyOfSystem 98 | include Entitas::Systems::ExecuteSystem 99 | 100 | getter context : GameContext 101 | getter filter : Entitas::Group(GameEntity) 102 | 103 | def initialize(@context : GameContext) 104 | @filter = context.get_group(Entitas::Matcher.any_of(Comp1, Comp2, Comp3, Comp4, Comp5)) 105 | end 106 | 107 | def execute 108 | @filter.get_entities.each do |ent| 109 | end 110 | end 111 | end 112 | 113 | class SystemAddDeleteSingleComponent 114 | include Entitas::Systems::ExecuteSystem 115 | 116 | def initialize(@context : GameContext) 117 | end 118 | 119 | def execute 120 | ent = @context.create_entity.add_comp1(x: -1, y: -1) 121 | ent.del_comp1 122 | end 123 | end 124 | 125 | class SystemAddDeleteFourComponents 126 | include Entitas::Systems::ExecuteSystem 127 | 128 | def initialize(@context : GameContext) 129 | end 130 | 131 | def execute 132 | ent = @context.create_entity.add_comp1(x: -1, y: -1).add_comp2(name: "-1").add_comp3(heavy: StaticArray(Int32, 64).new { |x| -x }).add_comp4 133 | ent.del_comp1 134 | ent.del_comp2 135 | ent.del_comp3 136 | ent.del_comp4 137 | end 138 | end 139 | 140 | class SystemAskComponent(Positive) 141 | include Entitas::Systems::ExecuteSystem 142 | 143 | @ent : Entitas::Entity 144 | 145 | def initialize(@context : GameContext) 146 | if Positive > 0 147 | @ent = @context.create_entity.add_comp1(x: -1, y: -1) 148 | else 149 | @ent = @context.create_entity.add_comp5(vx: -1, vy: -1) 150 | end 151 | end 152 | 153 | def execute 154 | @ent.has_comp1? 155 | end 156 | end 157 | 158 | class SystemGetComponent(Positive) 159 | include Entitas::Systems::ExecuteSystem 160 | 161 | @ent : Entitas::Entity 162 | 163 | def initialize(@context : GameContext) 164 | if Positive > 0 165 | @ent = @context.create_entity.add_comp1(x: -1, y: -1) 166 | else 167 | @ent = @context.create_entity.add_comp5(vx: -1, vy: -1) 168 | end 169 | end 170 | 171 | def execute 172 | @ent.comp1? 173 | end 174 | end 175 | 176 | # class SystemGetSingletonComponent < ECS::System 177 | # @count = 0 178 | 179 | # def execute 180 | # conf = @world.new_entity.getConfig 181 | # @count = conf.values.size 182 | # end 183 | # end 184 | 185 | class SystemCountComp1 186 | include Entitas::Systems::ExecuteSystem 187 | 188 | getter context : GameContext 189 | getter filter : Entitas::Group(GameEntity) 190 | 191 | @count = 0 192 | 193 | def initialize(@context : GameContext) 194 | @filter = context.get_group(Entitas::Matcher.all_of(Comp1)) 195 | end 196 | 197 | def execute 198 | v = 0 199 | @filter.get_entities.each do |ent| 200 | v += 1 201 | end 202 | @count = @count ^ v 203 | end 204 | end 205 | 206 | class SystemUpdateComp1 207 | include Entitas::Systems::ExecuteSystem 208 | 209 | getter context : GameContext 210 | getter filter : Entitas::Group(GameEntity) 211 | 212 | def initialize(@context : GameContext) 213 | @filter = context.get_group(Entitas::Matcher.all_of(Comp1)) 214 | end 215 | 216 | def execute 217 | @filter.get_entities.each do |ent| 218 | comp1 = ent.comp1 219 | comp1.x = -comp1.x 220 | comp1.y = -comp1.y 221 | comp1 = ent.comp1 222 | comp1.x = -comp1.x 223 | comp1.y = -comp1.y 224 | end 225 | end 226 | end 227 | 228 | class SystemUpdateComp1UsingPtr 229 | include Entitas::Systems::ExecuteSystem 230 | 231 | getter context : GameContext 232 | getter filter : Entitas::Group(GameEntity) 233 | 234 | def initialize(@context : GameContext) 235 | @filter = context.get_group(Entitas::Matcher.all_of(Comp1)) 236 | end 237 | 238 | def execute 239 | @filter.get_entities.each do |ent| 240 | comp1 = ent.comp1 241 | ent.replace_comp1(x: -comp1.x, y: -comp1.y) 242 | comp1 = ent.comp1 243 | ent.replace_comp1(x: -comp1.x, y: -comp1.y) 244 | end 245 | end 246 | end 247 | 248 | class SystemReplaceComp1 249 | include Entitas::Systems::ExecuteSystem 250 | 251 | getter context : GameContext 252 | getter filter : Entitas::Group(GameEntity) 253 | 254 | def initialize(@context : GameContext) 255 | @filter = context.get_group(Entitas::Matcher.all_of(Comp1)) 256 | end 257 | 258 | def execute 259 | @filter.get_entities.each do |ent| 260 | comp1 = ent.comp1 261 | ent.del_comp1 262 | ent.add_comp5(vx: comp1.x, vy: comp1.y) 263 | comp5 = ent.comp5 264 | ent.del_comp5 265 | ent.add_comp1(x: comp5.vx, y: comp5.vy) 266 | end 267 | end 268 | end 269 | 270 | class SystemComplexFilter 271 | include Entitas::Systems::ExecuteSystem 272 | 273 | getter context : GameContext 274 | getter filter : Entitas::Group(GameEntity) 275 | 276 | @count = 0 277 | 278 | def initialize(@context : GameContext) 279 | @filter = context.get_group(Entitas::Matcher.any_of(Comp1, Comp2).all_of(Comp3).none_of(Comp4)) 280 | end 281 | 282 | def execute 283 | v = 0 284 | @filter.get_entities.each do |ent| 285 | v += 1 286 | end 287 | @count = @count ^ v 288 | end 289 | end 290 | 291 | # class SystemComplexSelectFilter 292 | # include Entitas::Systems::ExecuteSystem 293 | 294 | # getter context : GameContext 295 | # getter filter : Entitas::Group(GameEntity) 296 | 297 | # @count = 0 298 | 299 | # def initialize(@context : GameContext) 300 | # @filter = context.get_group(Entitas::Matcher.any_of(Comp1, Comp2).all_of(Comp3).none_of(Comp4)) 301 | # end 302 | 303 | # def execute 304 | # @filter.get_entities.each do |ent| 305 | # next unless ent.id % 10 > 5 306 | # @count += 1 307 | # end 308 | # end 309 | # end 310 | 311 | # class SystemGenerateEvent(Event) < ECS::System 312 | # @fixed_filter : ECS::Filter? 313 | 314 | # def initialize(@world, @fixed_filter = nil) 315 | # super(@world) 316 | # end 317 | 318 | # def filter(world) 319 | # @fixed_filter.not_nil! 320 | # end 321 | 322 | # def process(entity) 323 | # entity.add(Event.new) 324 | # end 325 | # end 326 | 327 | # class SystemPassEvents < ECS::Systems 328 | # def initialize(@world) 329 | # super 330 | # add SystemGenerateEvent(TestEvent1).new(@world, @world.of(Comp1)) 331 | # add SystemGenerateEvent(TestEvent2).new(@world, @world.of(Comp2)) 332 | # add SystemGenerateEvent(TestEvent3).new(@world, @world.all_of([TestEvent1, TestEvent2])) 333 | # # add SystemGenerateEvent(TestEvent1).new(@world, @world.of(TestEvent3)) 334 | # end 335 | # end 336 | 337 | def init_benchmark_world(n) 338 | ctx = GameContext.new 339 | # config = Config.new(Hash(String, Int32).new) 340 | # config.values["value"] = 1 341 | # world.new_entity.add(config) 342 | ent = ctx.create_entity.add_comp5(vx: 0, vy: 0) 343 | ent.del_comp5 344 | 345 | {% for i in 1..100 %} 346 | ent = ctx.create_entity.add_bench_comp{{i}}(x: 0, y: 0) 347 | {% end %} 348 | 349 | n.times do |i| 350 | ent = ctx.create_entity 351 | ent.add_comp1(x: i, y: i) if i % 2 == 0 352 | ent.add_comp2(name: i.to_s) if i % 3 == 0 353 | ent.add_comp3(heavy: StaticArray(Int32, 64).new { |x| x + i }) if i % 5 == 0 354 | ent.add_comp4 if i % 7 == 0 355 | end 356 | return ctx 357 | end 358 | 359 | BENCH_N = 500000 360 | BENCH_WARMUP = 1 361 | BENCH_TIME = 2 362 | 363 | def benchmark_creation 364 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 365 | bm.report("create empty world") do 366 | ctx = GameContext.new 367 | end 368 | bm.report("create benchmark world") do 369 | init_benchmark_world(BENCH_N) 370 | end 371 | bm.report("create and clear benchmark world") do 372 | ctx = init_benchmark_world(BENCH_N) 373 | ctx.destroy_all_entities 374 | end 375 | end 376 | end 377 | 378 | def benchmark_list(list) 379 | puts "***********************************************" 380 | world = init_benchmark_world(BENCH_N) 381 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 382 | list.each do |cls| 383 | GC.collect 384 | ctx = init_benchmark_world(BENCH_N) 385 | sys = Entitas::Feature.new("all") 386 | sys.add(cls.new(ctx)) 387 | sys.init 388 | sys.execute 389 | bm.report(cls.to_s) do 390 | sys.execute 391 | end 392 | sys.tear_down 393 | end 394 | end 395 | end 396 | 397 | benchmark_creation 398 | 399 | benchmark_list [ 400 | EmptySystem, 401 | EmptyFilterSystem, 402 | SystemAddDeleteSingleComponent, 403 | SystemAddDeleteFourComponents, 404 | SystemAskComponent(0), 405 | SystemAskComponent(1), 406 | SystemGetComponent(0), 407 | SystemGetComponent(1), 408 | # SystemGetSingletonComponent, 409 | ] 410 | 411 | benchmark_list [ 412 | SystemCountComp1, 413 | SystemUpdateComp1, 414 | SystemUpdateComp1UsingPtr, 415 | SystemReplaceComp1, 416 | # SystemPassEvents, 417 | ] 418 | 419 | benchmark_list [ 420 | FullFilterSystem, 421 | FullFilterAnyOfSystem, 422 | SystemComplexFilter, 423 | # SystemComplexSelectFilter, 424 | ] 425 | -------------------------------------------------------------------------------- /benchmarks/bench_ecs.cr: -------------------------------------------------------------------------------- 1 | require "benchmark" 2 | require "../src/myecs" 3 | 4 | # # UNCOMMENT TO MEASURE RELATIVE CHANGES 5 | # 6 | # BASE_LINE = 7 | # { 8 | # "create empty world" => 131.20 * 1000, 9 | # "create benchmark world" => 164.93*1000_000, 10 | # "create and clear benchmark world" => 211.54*1000_000, 11 | # "serialize benchmark world" => 264.68*1000_000, 12 | # "serialize and deserialize benchmark world" => 373.09*1000_000, 13 | # "EmptySystem" => 5.11, 14 | # "EmptyFilterSystem" => 26.80, 15 | # "SystemAddDeleteSingleComponent" => 27.02, 16 | # "SystemAddDeleteFourComponents" => 345.95, 17 | # "SystemAskComponent(0)" => 7.08, 18 | # "SystemAskComponent(1)" => 7.04, 19 | # "SystemGetComponent(0)" => 7.70, 20 | # "SystemGetComponent(1)" => 8.93, 21 | # "SystemGetSingletonComponent" => 7.33, 22 | # "IterateOverCustomFilterSystem" => 11.98, 23 | # "SystemCountComp1" => 3.65 * 1000_000, 24 | # "SystemUpdateComp1" => 9.09 * 1000_000, 25 | # "SystemUpdateComp1UsingPtr" => 5.04 * 1000_000, 26 | # "SystemReplaceComps" => 26.44*1000_000, 27 | # "SystemPassEvents" => 33.40*1000_000, 28 | # "FullFilterSystem" => 6.23*1000_000, 29 | # "FullFilterAnyOfSystem" => 7.94*1000_000, 30 | # "SystemComplexFilter" => 2.93*1000_000, 31 | # "SystemComplexSelectFilter" => 2.95*1000_000, 32 | # } 33 | 34 | # module Benchmark 35 | # module IPS 36 | # class Entry 37 | # def human_compare 38 | # sprintf "%5.2f", BASE_LINE[label] * mean / 1e9 39 | # end 40 | # end 41 | # end 42 | # end 43 | 44 | BENCH_COMPONENTS = 100 45 | 46 | {% for i in 1..BENCH_COMPONENTS %} 47 | record BenchComp{{i}} < ECS::Component, vx : Int32, vy : Int32 48 | {% end %} 49 | 50 | record Comp1 < ECS::Component, x : Int32, y : Int32 do 51 | def change_x(value) 52 | @x = value 53 | end 54 | end 55 | record Comp2 < ECS::Component, name : String 56 | record Comp3 < ECS::Component, heavy : StaticArray(Int32, 64) 57 | record Comp4 < ECS::Component 58 | record Comp5 < ECS::Component, vx : Int32, vy : Int32 59 | 60 | @[ECS::SingleFrame] 61 | record TestEvent1 < ECS::Component 62 | @[ECS::SingleFrame] 63 | record TestEvent2 < ECS::Component 64 | @[ECS::SingleFrame] 65 | record TestEvent3 < ECS::Component 66 | @[ECS::Singleton] 67 | record Config < ECS::Component, values : Hash(String, Int32) 68 | 69 | class EmptySystem < ECS::System 70 | def execute 71 | end 72 | end 73 | 74 | class EmptyFilterSystem < ECS::System 75 | def filter(world) 76 | world.of(Comp5) 77 | end 78 | 79 | def process(entity) 80 | end 81 | end 82 | 83 | class IterateOverCustomFilterSystem < ECS::System 84 | @n = 0 85 | 86 | def execute 87 | @n = 0 88 | @world.query(Comp5).each do 89 | @n += 1 90 | end 91 | end 92 | end 93 | 94 | class FullFilterSystem < ECS::System 95 | def filter(world) 96 | world.exclude([Comp5]) 97 | end 98 | 99 | def process(entity) 100 | end 101 | end 102 | 103 | class FullFilterAnyOfSystem < ECS::System 104 | def filter(world) 105 | world.any_of([Comp1, Comp2, Comp3, Comp4]) 106 | end 107 | 108 | def process(entity) 109 | end 110 | end 111 | 112 | class SystemAddDeleteSingleComponent < ECS::System 113 | def execute 114 | ent = @world.new_entity.add(Comp1.new(-1, -1)) 115 | ent.remove(Comp1) 116 | end 117 | end 118 | 119 | class SystemAddDeleteFourComponents < ECS::System 120 | def execute 121 | ent = @world.new_entity 122 | ent.add(Comp1.new(-1, -1)) 123 | ent.add(Comp2.new("-1")) 124 | ent.add(Comp3.new(StaticArray(Int32, 64).new { |x| -x })) 125 | ent.add(Comp4.new) 126 | ent.destroy 127 | end 128 | end 129 | 130 | class SystemAskComponent(Positive) < ECS::System 131 | @ent : ECS::Entity 132 | 133 | def initialize(@world) 134 | @ent = uninitialized ECS::Entity 135 | end 136 | 137 | def init 138 | if Positive > 0 139 | @ent = @world.new_entity.add(Comp1.new(-1, -1)) 140 | else 141 | @ent = @world.new_entity.add(Comp5.new(-1, -1)) 142 | end 143 | end 144 | 145 | def execute 146 | @ent.has? Comp1 147 | end 148 | 149 | def teardown 150 | @ent.destroy 151 | end 152 | end 153 | 154 | class SystemGetComponent(Positive) < ECS::System 155 | @ent : ECS::Entity 156 | 157 | def initialize(@world) 158 | @ent = uninitialized ECS::Entity 159 | end 160 | 161 | def init 162 | if Positive > 0 163 | @ent = @world.new_entity.add(Comp1.new(-1, -1)) 164 | else 165 | @ent = @world.new_entity.add(Comp5.new(-1, -1)) 166 | end 167 | end 168 | 169 | def execute 170 | @ent.getComp1? 171 | end 172 | 173 | def teardown 174 | @ent.destroy 175 | end 176 | end 177 | 178 | class SystemGetSingletonComponent < ECS::System 179 | @count = 0 180 | 181 | def execute 182 | conf = @world.getConfig 183 | @count = conf.values.size 184 | end 185 | end 186 | 187 | class SystemCountComp1 < ECS::System 188 | def filter(world) 189 | world.of(Comp1) 190 | end 191 | 192 | @count = 0 193 | 194 | def process(entity) 195 | @count += 1 196 | end 197 | end 198 | 199 | class SystemUpdateComp1 < ECS::System 200 | def filter(world) 201 | world.of(Comp1) 202 | end 203 | 204 | def process(entity) 205 | comp = entity.getComp1 206 | entity.update(Comp1.new(-comp.x, -comp.y)) 207 | comp = entity.getComp1 208 | entity.update(Comp1.new(-comp.x, -comp.y)) 209 | end 210 | end 211 | 212 | class SystemUpdateComp1UsingPtr < ECS::System 213 | def filter(world) 214 | world.of(Comp1) 215 | end 216 | 217 | def process(entity) 218 | ptr_comp = entity.getComp1_ptr 219 | ptr_comp.value.change_x(ptr_comp.value.y) 220 | end 221 | end 222 | 223 | class SystemReplaceComp1 < ECS::System 224 | def filter(world) 225 | world.of(Comp1) 226 | end 227 | 228 | def process(entity) 229 | comp = entity.getComp1 230 | entity.replace(Comp1, Comp5.new(-comp.x, -comp.y)) 231 | end 232 | end 233 | 234 | class SystemReplaceComp5 < ECS::System 235 | def filter(world) 236 | world.of(Comp5) 237 | end 238 | 239 | def process(entity) 240 | comp = entity.getComp5 241 | entity.replace(Comp5, Comp1.new(-comp.vx, -comp.vy)) 242 | end 243 | end 244 | 245 | class SystemReplaceComps < ECS::Systems 246 | def initialize(@world) 247 | super 248 | add SystemReplaceComp1.new(@world) 249 | add SystemReplaceComp5.new(@world) 250 | end 251 | end 252 | 253 | class SystemComplexFilter < ECS::System 254 | def filter(world) 255 | world.any_of([Comp1, Comp2]).all_of([Comp3]).exclude(Comp4) 256 | end 257 | 258 | @count = 0 259 | 260 | def process(entity) 261 | @count += 1 262 | end 263 | end 264 | 265 | class SystemComplexSelectFilter < ECS::System 266 | def filter(world) 267 | world.any_of([Comp1, Comp2]).all_of([Comp3]).exclude(Comp4).filter { |ent| ent.id % 10 > 5 } 268 | end 269 | 270 | @count = 0 271 | 272 | def process(entity) 273 | @count += 1 274 | end 275 | end 276 | 277 | class SystemGenerateEvent(Event) < ECS::System 278 | @fixed_filter : ECS::Filter? 279 | 280 | def initialize(@world, @fixed_filter = nil) 281 | super(@world) 282 | end 283 | 284 | def filter(world) 285 | @fixed_filter.not_nil! 286 | end 287 | 288 | def process(entity) 289 | entity.add(Event.new) 290 | end 291 | end 292 | 293 | class CountAllOf(Event) < ECS::System 294 | def filter(world) 295 | world.of(Event) 296 | end 297 | 298 | property value = 0 299 | 300 | def process(entity) 301 | @value += 1 302 | end 303 | 304 | def execute 305 | @value = 0 306 | end 307 | end 308 | 309 | class SystemPassEvents < ECS::Systems 310 | def initialize(@world) 311 | super 312 | add SystemGenerateEvent(TestEvent1).new(@world, @world.of(Comp1)) 313 | add SystemGenerateEvent(TestEvent2).new(@world, @world.of(Comp2)) 314 | add SystemGenerateEvent(TestEvent3).new(@world, @world.all_of([TestEvent1, TestEvent2])) 315 | remove_singleframe(TestEvent1) 316 | remove_singleframe(TestEvent2) 317 | add CountAllOf(TestEvent3) 318 | remove_singleframe(TestEvent3) 319 | end 320 | end 321 | 322 | def init_benchmark_world(n) 323 | world = ECS::World.new 324 | config = Config.new(Hash(String, Int32).new) 325 | config.values["value"] = 1 326 | world.new_entity.add(config) 327 | world.new_entity.add(Comp5.new(0, 0)).remove(Comp5) # to init pool 328 | {% for i in 1..BENCH_COMPONENTS %} 329 | world.new_entity.add(BenchComp{{i}}.new({{i}},{{i}})) 330 | {% end %} 331 | 332 | n.times do |i| 333 | ent = world.new_entity 334 | ent.add(Comp1.new(i, i)) if i % 2 == 0 335 | ent.add(Comp2.new(i.to_s)) if i % 3 == 0 336 | ent.add(Comp3.new(StaticArray(Int32, 64).new { |x| x + i })) if i % 5 == 0 337 | ent.add(Comp4.new) if i % 7 == 0 338 | ent.destroy_if_empty 339 | end 340 | return world 341 | end 342 | 343 | BENCH_N = 1000000 344 | BENCH_WARMUP = 2 345 | BENCH_TIME = 5 346 | 347 | def benchmark_creation 348 | puts "***********************************************" 349 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 350 | bm.report("create empty world") do 351 | world = ECS::World.new 352 | end 353 | bm.report("create benchmark world") do 354 | world = init_benchmark_world(BENCH_N) 355 | end 356 | bm.report("create and clear benchmark world") do 357 | world = init_benchmark_world(BENCH_N) 358 | world.delete_all 359 | end 360 | # bm.report("serialize empty world") do 361 | # world = ECS::World.new 362 | # aio = IO::Memory.new 363 | # world.encode aio 364 | # aio.rewind 365 | # world2 = ECS::World.new 366 | # world2.decode aio 367 | # end 368 | first = true 369 | bm.report("serialize benchmark world") do 370 | world = init_benchmark_world(BENCH_N) 371 | aio = IO::Memory.new 372 | world.encode aio 373 | if first 374 | puts aio.pos 375 | first = false 376 | end 377 | end 378 | bm.report("serialize and deserialize benchmark world") do 379 | world = init_benchmark_world(BENCH_N) 380 | aio = IO::Memory.new 381 | world.encode aio 382 | n = aio.pos 383 | world2 = ECS::World.new 384 | aio.rewind 385 | world2.decode aio 386 | raise "#{aio.pos} vs #{n}" unless aio.pos == n 387 | end 388 | end 389 | end 390 | 391 | macro benchmark_list(*list) 392 | puts "***********************************************" 393 | world = init_benchmark_world(BENCH_N) 394 | list = [] of ECS::Systems 395 | {% for cls in list %} 396 | %sys = ECS::Systems.new(world) 397 | %sys.add({{cls}}) 398 | list << %sys 399 | {% end %} 400 | 401 | 402 | 403 | Benchmark.ips(warmup: BENCH_WARMUP, calculation: BENCH_TIME) do |bm| 404 | list.each do |sys| 405 | sys.init 406 | sys.execute 407 | bm.report(sys.children[0].class.name) do 408 | sys.execute 409 | end 410 | # sys.teardown fails due to bm imlementation? 411 | end 412 | end 413 | end 414 | 415 | puts init_benchmark_world(BENCH_N).stats 416 | 417 | benchmark_creation 418 | 419 | benchmark_list(EmptySystem, 420 | EmptyFilterSystem, 421 | SystemAddDeleteSingleComponent, 422 | SystemAddDeleteFourComponents, 423 | SystemAskComponent(0), 424 | SystemAskComponent(1), 425 | SystemGetComponent(0), 426 | SystemGetComponent(1), 427 | SystemGetSingletonComponent, 428 | IterateOverCustomFilterSystem, 429 | ) 430 | 431 | benchmark_list(SystemCountComp1, 432 | SystemUpdateComp1, 433 | SystemUpdateComp1UsingPtr, 434 | SystemReplaceComps, 435 | SystemPassEvents, 436 | ) 437 | 438 | benchmark_list(FullFilterSystem, 439 | FullFilterAnyOfSystem, 440 | SystemComplexFilter, 441 | SystemComplexSelectFilter, 442 | ) 443 | 444 | ECS.debug_stats 445 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Build Status 3 | 4 | 5 | # MyECS 6 | 7 | ##### Table of Contents 8 | * [Introduction](#introduction) 9 | * [Main parts of ecs](#main-parts-of-ecs) 10 | * [Entity](#entity) 11 | * [Component](#component) 12 | * [System](#system) 13 | * [Special components](#special-components) 14 | * [ECS::SingleFrame](#ecssingleframe) 15 | * [ECS::Multiple](#ecsmultiple) 16 | * [ECS::Singleton](#ecssingleton) 17 | * [Other classes](#other-classes) 18 | * [ECS::World](#ecsworld) 19 | * [ECS::Filter](#ecsfilter) 20 | * [ECS::Systems](#ecssystems) 21 | * [Engine integration](#engine-integration) 22 | * [Other features](#other-features) 23 | * [Statistics](#statistics) 24 | * [Iterating without filter](#iterating-without-filter) 25 | * [Callbacks](#callbacks) 26 | * [Benchmarks](#benchmarks) 27 | * [Serialization](#serialization) 28 | * [Binary](#binary) 29 | * [YAML](#yaml) 30 | * [Plans](#plans) 31 | * [Contributors](#contributors) 32 | ## Introduction 33 | 34 | You can add shard as a dependency to your application's `shard.yml`: 35 | ```yaml 36 | dependencies: 37 | myecs: 38 | github: konovod/myecs 39 | ``` 40 | Alternativale, you can just copy file `src/myecs.cr` to your sources as it's single-file library. 41 | 42 | Then do 43 | ```crystal 44 | require "myecs" 45 | ``` 46 | And then use it: 47 | ```crystal 48 | # declare components 49 | record Position < ECS::Component, x : Int32, y : Int32 50 | record Velocity < ECS::Component, vx : Int32, vy : Int32 51 | 52 | # declare systems 53 | class UpdatePositionSystem < ECS::System 54 | def filter(world) 55 | world.all_of([Position, Velocity]) 56 | end 57 | 58 | def process(entity) 59 | pos = entity.getPosition 60 | speed = entity.getVelocity 61 | entity.update(Position.new(pos.x + speed.x, pos.y + speed.y)) 62 | end 63 | end 64 | 65 | # create world 66 | world = ECS::World.new 67 | 68 | # create entities 69 | 5.times { world.new_entity.add(Position.new(10, 10)) } 70 | 10.times do 71 | ent = world.new_entity 72 | ent.add(Position.new(1, 1)) 73 | ent.add(Velocity.new(1, 1)) 74 | end 75 | 76 | # create systems 77 | systems = ECS::Systems.new(world) 78 | systems.add(UpdatePositionSystem) 79 | 80 | # run systems 81 | systems.init 82 | 10.times do 83 | systems.execute 84 | end 85 | systems.teardown 86 | ``` 87 | 88 | ## Main parts of ecs 89 | 90 | ### Entity 91 | Сontainer for components. Consists from UInt64 and pointer to `World`: 92 | ```crystal 93 | struct Entity 94 | getter id : EntityID 95 | getter world : World 96 | ... 97 | ``` 98 | 99 | ```crystal 100 | # Creates new entity in world context. 101 | # Basically just allocates a new identifier so it's fast. 102 | entity = world.new_entity 103 | 104 | # destroying entity marks entity id as free so it can be reused. It is also destroyed when last component removed from it. 105 | # If you need to hold an empty entity, suggested way is to add some component to it. 106 | entity.destroy 107 | ``` 108 | 109 | ### Component 110 | Container for user data without / with small logic inside. Based on Crystal struct's: 111 | ```crystal 112 | record Comp1 < ECS::Component, 113 | x : Int32, 114 | y : Int32, 115 | name : String 116 | ``` 117 | Components can be added, requested, removed: 118 | ```crystal 119 | entity = world.new_entity 120 | entity.add(Comp1.new(0, 0, "name")) 121 | # method is autogenerated from component class name. 122 | # Will raise if component isn't present 123 | comp1 = entity.getComp1 124 | comp2 = entity.getComp2? # will return nil if component isn't present 125 | entity.remove(Comp1) 126 | 127 | # basically shortcut for deleting one component and adding another. 128 | entity.replace(Comp1, Comp2.new) 129 | ``` 130 | 131 | They can be updated (changed) using several ways: 132 | ```crystal 133 | entity = world.new_entity 134 | entity.add(Comp1.new(0, 0, "name")) 135 | 136 | # Replace Comp1 with another instance of Comp1. 137 | # Will raise if component isn't present 138 | entity.update(Comp1.new(1, 1, "name1")) 139 | 140 | entity.set(Comp1.new(2, 2, "name2")) # Adds Comp1 or replace it if already present 141 | 142 | # autogenerated method, returns Pointer(Comp1), so you can access it directly 143 | # this is not a recommended way to work with Crystal structs 144 | # but can provide maximum performance 145 | ptr = entity.getComp1_ptr 146 | ptr.value.x = 5 147 | # important - after deleting component in a pool would be reused 148 | # so don't save a pointer if you are not sure that component won't be deleted 149 | ``` 150 | 151 | ### System 152 | Сontainer for logic for processing filtered entities. 153 | User class can implement `init`, `execute`, `teardown`, `filter`, `preprocess` and `process` (in any combination. Just skip methods you don't need). 154 | ```crystal 155 | class UserSystem < ECS::System 156 | # @world : ECS::World - world 157 | # @active : Bool - allows to temporary enable or disable system 158 | 159 | def initialize(@world : ECS::World) 160 | super(@world) # constructor should pass @world field 161 | end 162 | 163 | def init 164 | # Will be called once during ECS::Systems.init call 165 | end 166 | 167 | def preprocess 168 | # Will be called on each ECS::Systems.execute call, before `#process` and `#execute` 169 | end 170 | 171 | def filter(world) 172 | # Called once during ECS::Systems.init, after #init call. 173 | # If this method present, it should return a filter that will be applied to a world 174 | world.of(SomeComponent) 175 | end 176 | 177 | def process(entity) 178 | # will be called during each ECS::Systems.execute call, before #execute, 179 | # for each entity that match the #filter 180 | end 181 | 182 | def execute 183 | # Will be called on each ECS::Systems.execute call 184 | end 185 | 186 | def teardown 187 | # Will be called once during ECS::Systems.teardown call 188 | end 189 | 190 | end 191 | ``` 192 | 193 | ### Special components 194 | #### ECS::SingleFrame 195 | annotation `@[ECS::SingleFrame]` is for components that have to live 1 frame (usually - events). The main difference is that they are supposed to be deleted at once, so their storage can be simplified (no need to track free indexes). They should be deleted by adding `ECS::RemoveAllOf` system in a right place of systems list (or just using `.remove_singleframe(T)`). 196 | 197 | ```crystal 198 | require "./src/myecs" 199 | 200 | @[ECS::Multiple] 201 | @[ECS::SingleFrame] 202 | record SomeRequest < ECS::Component, data : String 203 | 204 | class ExecuteSomeRequestsSystem < ECS::System 205 | def filter(world) 206 | world.of(SomeRequest) 207 | end 208 | 209 | def process(ent) 210 | req = ent.getSomeRequest 211 | puts "request #{req.data} called for #{ent}" 212 | end 213 | end 214 | 215 | world = ECS::World.new 216 | systems = ECS::Systems.new(world) 217 | .add(ExecuteSomeRequestsSystem) 218 | .remove_singleframe(SomeRequest) # shortcut for `add(ECS::RemoveAllOf.new(@world, SomeRequest))` 219 | systems.init 220 | # now you can add SomeRequest to the entity 221 | world.new_entity.add(SomeRequest.new("First")).add(SomeRequest.new("Second")) 222 | systems.execute 223 | ``` 224 | 225 | In case you tend to forget about removing such single-frame components, ECS checks it for you - if a single-frame component is created, but no corresponding `RemoveAllOf` is present in systems list, runtime exception will be raised 226 | 227 | ```crystal 228 | @[ECS::SingleFrame] 229 | record SomeEvent < ECS::Component 230 | 231 | world = ECS::World.new 232 | systems = ECS::Systems.new(world) 233 | # ...some systems added 234 | systems.init 235 | systens.execute #this won't raise - maybe we don't need SomeEvent at all 236 | world.new_entity.add(SomeEvent.new) # raises 237 | ``` 238 | 239 | In a rare cases when you need to override this check, you can use `@[ECS::SingleFrame(check: false)]` form: 240 | ```crystal 241 | @[ECS::SingleFrame(check: false)] 242 | record SomeEvent < ECS::Component 243 | 244 | world = ECS::World.new 245 | systems = ECS::Systems.new(world) 246 | # ... 247 | world.new_entity.add(SomeEvent.new) # this won't raise 248 | ``` 249 | 250 | #### ECS::Multiple 251 | Note above example also shows the use of `@[ECS::Multiple]`. This is for components that can be added multiple times. They have some limitations though - filters can't iterate over more then one type of components with this annotation (as this would usually mean cartesian product, unlikely needed in practice), there is no way to get multiple components outside of filter (it is planned though, but it won't be efficient nor cache-friendly), `remove` deletes all of components on target entity and there is no way to delete only one. 252 | `ECS::Multiple` can be combined with `ECS::SingleFrame` but that's not a requirement - there are perfectly correct use cases for `ECS::Multiple` on persistent components - add several sprites to one renderable object or add several weapons to a tank. The only thing is that with current API you won't be able to remove single weapon in that case - only remove all of them. So if you need better control over components just use good old "add an entity with single component and link it to parent entity" approach. 253 | 254 | #### ECS::Singleton 255 | annotation `@[ECS::Singleton]` is for data sharing. It creates component that is considered present on every entity (iteration on it isn't possible though). So you can do 256 | 257 | ```crystal 258 | @[ECS::Singleton] 259 | record Config < ECS::Component, values : Hash(String, Int32) 260 | 261 | class InitConfigSystem < ECS::System 262 | def init 263 | config = ...some config initialization 264 | @world.new_entity.add(Config.new(config)) 265 | 266 | # another way 267 | @world.add(Config.new(config)) unless @world.component_exists?(Config) 268 | end 269 | end 270 | 271 | class SomeAnotherSystem < ECS::System 272 | def execute 273 | config = @world.new_entity.getConfig.values # gets the same values 274 | 275 | # another way 276 | config = @world.getConfig.values 277 | end 278 | end 279 | ``` 280 | 281 | ### Other classes 282 | 283 | #### ECS::World 284 | Root level container for all entities / components, is iterated with ECS::Systems: 285 | ```crystal 286 | world = ECS::World.new 287 | 288 | # you can delete all entities 289 | world.delete_all 290 | 291 | # also delete all entities, but calls `when_removed` callbacks (slower) 292 | world.delete_all(with_callbacks: true) 293 | 294 | # you can create entity 295 | entity = world.new_entity 296 | 297 | # you can iterate all entities 298 | world.each_entity do |entity| 299 | puts entity.id 300 | end 301 | 302 | # you can create filters 303 | world.any_of([Comp1, Comp2]).any_of([Comp3, Comp4]) 304 | ``` 305 | 306 | #### ECS::Filter 307 | Allows to iterate over entities with specified conditions. 308 | Created by call `world.new_filter` or just by adding any conditions to `world`. 309 | 310 | Filters that is possible: 311 | - `any_of([Comp1, Comp2])`: at least one of the components must be present on entity 312 | - `all_of([Comp1, Comp2])`: all of the components must be present on entity 313 | - `of(Comp1)`: alias for `all_of([Comp1])` 314 | - `exclude([Comp1])`: none of the components could be present on entity 315 | - `filter{|ent| some_check(ent) }`: user-provided filter procedure, that must return true for entity to be passed. 316 | 317 | All of them can be called 0, 1, or many times using method chaining. 318 | So `any_of([Comp1, Comp2]).any_of([Comp3, Comp4])` means that either Comp1 or Comp2 should be present AND either Comp3 or Comp4 should be present. 319 | 320 | `ECS::Filter` includes `Enumerable(Entity)`, so you can use methods like `#any?` or `#count`. 321 | Note that `#select` in `Enumerable` returns an array, not a `ECS::Filter`. To create a filter use `#filter` method. 322 | 323 | #### ECS::Systems 324 | Group of systems to process `EcsWorld` instance: 325 | ```crystal 326 | world = ECS::World.new 327 | systems = ECS::Systems.new(world) 328 | 329 | systems 330 | .add(MySystem1.new(world)) 331 | .add(MySystem2) # shortcut for add(MySystem2.new(systems.@world)) 332 | .add(MySystem3) 333 | 334 | systems.init 335 | loop do 336 | systems.execute 337 | end 338 | systems.teardown 339 | ``` 340 | You can add Systems to Systems to create hierarchy. 341 | You can inherit from `ECS::Systems` to add systems automatically: 342 | ```crystal 343 | class SampleSystem < ECS::Systems 344 | def initialize(@world) 345 | super 346 | add InitPlayerSystem 347 | # note that shortcut `add KeyReactSystem` isn't applicaple here because 348 | # system require other params in initialize 349 | add KeyReactSystem.new(@world, pressed: CONFIG_PRESSED, down: CONFIG_DOWN) 350 | add ReactPlayerSystem 351 | add MovePlayerSystem 352 | add RotatePlayerSystem 353 | add StopRotatePlayerSystem 354 | add SyncPositionWithPhysicsSystem 355 | add DrawDebugSystem 356 | end 357 | end 358 | ``` 359 | ### Engine integration 360 | huh, this is integration with my [nonoengine](https://gitlab.com/kipar/nonoengine): 361 | (full project is available at https://github.com/konovod/nonoecs-template) 362 | ```crystal 363 | # main app: 364 | require "./ecs" 365 | require "./basic_systems" 366 | require "./physics_systems" 367 | require "./demo_systems" 368 | 369 | @[ECS::SingleFrame(check: false)] 370 | struct QuitEvent < ECS::Component 371 | end 372 | 373 | world = ECS::World.new 374 | systems = ECS::Systems.new(world) 375 | .add(BasicSystems) 376 | .add(PhysicSystems) 377 | .add(SampleSystem) 378 | 379 | systems.init 380 | loop do 381 | systems.execute 382 | break if world.component_exists? QuitEvent 383 | end 384 | systems.teardown 385 | 386 | ... 387 | # basic_systems.cr: 388 | require "./libnonoengine.cr" 389 | require "./ecs" 390 | 391 | class BasicSystems < ECS::Systems 392 | def initialize(@world) 393 | super 394 | add EngineSystem.new(world) 395 | # add RenderSystem.new(world) 396 | add ShouldQuitSystem.new(world) 397 | end 398 | end 399 | 400 | class EngineSystem < ECS::System 401 | def init 402 | Engine[Params::Antialias] = 4 403 | Engine[Params::VSync] = 1 404 | 405 | Engine.init "resources" 406 | end 407 | 408 | def execute 409 | Engine.process 410 | end 411 | end 412 | 413 | class ShouldQuitSystem < ECS::System 414 | def execute 415 | @world.new_entity.add(QuitEvent.new) if !Engine::Keys[Key::Quit].up? 416 | end 417 | end 418 | 419 | ``` 420 | 421 | see `bench_ecs.cr` for some examples, and `spec` folder for some more. Proper documentation and examples are planned, but not soon. 422 | 423 | ## Other features 424 | ### Statistics 425 | You can add `ECS.debug_stats` at he end of program to get information about number of different systems and component classes during compile-time. Userful mostly just for fun :) 426 | 427 | you can get runtime statistics (how many components of each type is present) using `ECS::World#stats`. It returns with component name as a key and components count as value. 428 | There is also non-allocating version of `stats` that yields an info instead of creating a hash: 429 | ```crystal 430 | world = init_benchmark_world(1000000) 431 | 432 | puts world.stats 433 | # {"Comp1" => 500000, "Comp2" => 333334, "Comp3" => 200000, "Comp4" => 142858, "Config" => 1} 434 | 435 | # will print the same info 436 | world.stats do |comp_name, value| 437 | puts "#{comp_name}: #{value}" 438 | end 439 | ``` 440 | 441 | 442 | ### Iterating without filter 443 | Sometimes you just need to check if some component is present in a world. No need to create a filter for it - just use `world.component_exists?(SomeComponent)` 444 | 445 | You can also iterate over single component without creating Filter using `world.query`. 446 | It returns a lightweight `SimpleFilter` instance, that includes `Enumerable(Entity)`. 447 | This could be useful when iterating inside `System#process`: 448 | ```crystal 449 | class FindNearestTarget < ECS::System 450 | def filter(world) 451 | world.all_of([Pos, FindTarget]) 452 | end 453 | 454 | def process(entity) 455 | pos = entity.getPos 456 | nearest = Nil 457 | nearest_range = INFINITY 458 | # world.of(IsATarget) will allocate a Filter, so you should create it at `initialize` and store it somewhere 459 | # so here is an easier way: 460 | world.query(IsATarget).each do |target| 461 | range = distance(target.getPos, pos) 462 | if nearest_range > range 463 | nearest = target 464 | nearest_range = range 465 | end 466 | end 467 | # ... 468 | # You can also use Enumerable utility methods: 469 | nearest = world.query(IsATarget).min_by { |target| distance(target.getPos, pos) } 470 | end 471 | end 472 | ``` 473 | 474 | ### Callbacks 475 | If you define `when_added` method, it will be called every time after component was added to entity. 476 | If you define `when_removed` method, it will be called every time before component is removed from entity (or entity is destroyed). 477 | ```crystal 478 | record PhysicalBody < ECS::Component, raw : PhysEngine::Body do 479 | def when_removed(entity) 480 | raw.destroy 481 | end 482 | end 483 | ``` 484 | This correctly process SingleFrame, Multiple and Singleton components. 485 | Note that by default `world.delete_all` won't call `when_removed` for performance purposes (and because it doesn't make sense in many cases). 486 | Use `world.delete_all(with_callbacks: true)` if you need to still call `when_removed` for all components or use specialized filter to delete selected components before delete_all. 487 | 488 | ## Serialization 489 | 490 | ### Binary 491 | `ECS::World` can be serialized to binary blob using brilliant [Cannon](https://github.com/Papierkorb/cannon) library. This done using `World#encode` and `World#decode`: 492 | 493 | ```crystal 494 | # saves world state to a file. 495 | save = File.open("./save", "wb") 496 | world.encode save 497 | save.close 498 | 499 | # loads world from a file 500 | save = File.open("./save", "rb") 501 | world = ECS::World.new 502 | begin 503 | world.decode save 504 | rescue ex : Exception 505 | error("Savefile is corrupt") 506 | ensure 507 | save.close 508 | end 509 | ``` 510 | 511 | Of course this is not limited to files, you can use any `IO` to pass it over network etc. 512 | 513 | Note that `Cannon` library by design have no ways to check that data are correct, you have to implement it yourself. 514 | 515 | Most components can be serialized automatically, but if it doesn't work - define `#to_cannon_io(io)` and `def self.from_cannon_io(io) : self` for a component. 516 | 517 | ### YAML 518 | 519 | There is a also an experimental feature - serialize world to and from YAML format. 520 | 521 | Example use case - loading of pregenerated entities. 522 | 523 | ```crystal 524 | require "myecs" 525 | 526 | # not required by default because not every app needs YAML support 527 | require "myecs/yaml" 528 | 529 | # Only compoonents inherited from `ECS::YAMLComponent` are serialized. 530 | record ItemSlot < ECS::YAMLComponent, name : String 531 | record CraftItem < ECS::YAMLComponent, name : String, slots : Array(ECS::Entity) 532 | record CraftItemStats < ECS::YAMLComponent, cost : Int32, mass : Int32 533 | 534 | ... 535 | File.open(filename) do |file| 536 | world = ECS::World.from_yaml(file) 537 | end 538 | 539 | puts world.to_yaml 540 | 541 | # Another way is to add yaml data to existing world: 542 | world.add_yaml(file) 543 | ``` 544 | Argument passed to deserialization can be either `IO` or `String` 545 | 546 | Generated YAML will be a hash, each entry is a `ECS::Entity`. 547 | 548 | Keys of hash are used to link to the entities (in case of `to_yaml` keys looks like `Entity1234` but that is not required). 549 | 550 | Values are array of components on the entity, each component must have a `type` field that represents class of component. 551 | 552 | Example file: 553 | ```YAML 554 | --- 555 | slot_energy: [{type: ItemSlot, name: "Power source"}] 556 | slot_radio: [{type: ItemSlot, name: "Radio antenna"}] 557 | slot_life: [{type: ItemSlot, name: "Life support"}] 558 | # note that we can link to any entity by its key 559 | item1: [{type: CraftItem, name: "Near space antenna", slots: [slot_radio]}, {type: CraftItemStats, cost: 100, mass: 100}] 560 | item2: [{type: CraftItem, name: "Lunar antenna", slots: [slot_radio]}, {type: CraftItemStats, cost: 200, mass: 200}] 561 | ``` 562 | 563 | It is possible to load yaml from multiple sources (e.g. to split "slots" and "items" in a code before). All sources will share the same entity keys, so entities from one source can link to another 564 | 565 | ```crystal 566 | world = ECS::World.from_yaml do |yaml| 567 | yaml.read source1 568 | yaml.read source2 569 | end 570 | 571 | # or add to existing world: 572 | world.add_yaml |yaml| 573 | yaml.read file1 574 | yaml.read string2 575 | end 576 | ``` 577 | 578 | ### Difference 579 | Binary and YAML serialization has different use cases. This section briefly describes what is different. 580 | 581 | Binary: 582 | - as fast as possible 583 | - as small as possible. Just dump of data in memory 584 | - always serialize\deserialize entire world state. 585 | - if some components can't be serialized (contain pointers to external objects such as files or engine), compilation error will be issued. 586 | As a solution, you can redefine serialization for such components to no-op and remove them before serialization 587 | - don't contain any information about structure of data. Loading from data saved in another version of application (with added\removed\changed component types) will fail in unpredictable way. 588 | - intended to just save\restore everything. Use cases - savegames, transmitting over network(WIP). 589 | 590 | YAML: 591 | - not necessary fast or compact (still should be fast) 592 | - will link LibYAML to your binary (statically in case of Windows) 593 | - human-readable (and writable) format 594 | - only components inherited from ECS::YAMLComponent are saved 595 | - can add info to already non-empty world 596 | - intended to save\load only what is needed. Use cases - configs, loading of editable game data 597 | 598 | ## Benchmarks 599 | See [Benchmarks](./Benchmarks.md) 600 | ## Plans 601 | ### Short-term 602 | - [x] Reuse entity identifier, allows to replace `@sparse` hash with array 603 | - [ ] generations of EntityID to catch usage of deleted entities 604 | - [ ] better API for multiple components - iterating, array, deleting only one 605 | - [ ] optimally delete multiple components (linked list) 606 | - [x] bitmasks for entities. Could they improve performance? - no they don't 607 | - [x] check that all singleframe components are deleted somewhere 608 | - [x] benchmark comparison with flecs (https://github.com/jemc/crystal-flecs) 609 | - [ ] groups from EnTT - could be useful? 610 | - [x] Serialization 611 | - [ ] Flexible control of what components to skip 612 | - [ ] Different contexts to simplify usage of different worlds 613 | ### Future 614 | - [x] Callbacks on adding\deleting components 615 | - [x] Option to call deletion callbacks when clearing world 616 | - [ ] Work with arena allocator to minimize usage of GC 617 | ## Contributors 618 | - [Andrey Konovod](https://github.com/konovod) - creator and maintainer 619 | -------------------------------------------------------------------------------- /spec/myecs_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | record Pos < ECS::Component, x : Int32, y : Int32 do 4 | def change_x(value) 5 | @x = value 6 | end 7 | end 8 | record Speed < ECS::Component, vx : Int32, vy : Int32 9 | record Name < ECS::Component, name : String 10 | 11 | @[ECS::SingleFrame] 12 | record TestEvent1 < ECS::Component 13 | @[ECS::SingleFrame(check: true)] 14 | record TestEvent2 < ECS::Component 15 | @[ECS::SingleFrame] 16 | record TestEvent3 < ECS::Component 17 | 18 | @[ECS::SingleFrame(check: false)] 19 | record TestEventNotChecked < ECS::Component 20 | 21 | def count_entities(where) 22 | n = 0 23 | where.each_entity do |e| 24 | n += 1 25 | end 26 | n 27 | end 28 | 29 | describe ECS do 30 | it "adds component" do 31 | world = ECS::World.new 32 | ent = world.new_entity 33 | ent.add(Pos.new(1, 1)) 34 | ent.getPos.should eq Pos.new(1, 1) 35 | typeof(ent.getPos).should eq(Pos) 36 | typeof(ent.getPos?).should eq(Pos | Nil) 37 | ent.has?(Pos).should be_true 38 | ent.has?(Speed).should be_false 39 | ent.getSpeed?.should be_nil 40 | ent.add(Speed.new(2, 2)) 41 | ent.has?(Speed).should be_true 42 | ent.getPos.should eq Pos.new(1, 1) 43 | ent.getSpeed.should eq Speed.new(2, 2) 44 | ent.getSpeed?.should eq Speed.new(2, 2) 45 | # ent.components.size.should eq 2 46 | end 47 | 48 | it "world can iterate without entities" do 49 | world = ECS::World.new 50 | count_entities(world).should eq 0 51 | end 52 | 53 | it "entity can add and delete component repeatedly" do 54 | world = ECS::World.new 55 | ent = world.new_entity 56 | ent.add(Speed.new(1, 1)) 57 | pos = Pos.new(5, 6) 58 | 10.times do 59 | ent.add(pos) 60 | ent.remove(Pos) 61 | end 62 | end 63 | 64 | it "remove components" do 65 | world = ECS::World.new 66 | ent = world.new_entity 67 | ent.add(Pos.new(1, 1)) 68 | ent.add(Speed.new(2, 2)) 69 | ent.remove(Pos) 70 | ent.has?(Pos).should be_false 71 | ent.has?(Speed).should be_true 72 | ent.add(Pos.new(1, 1)) 73 | ent.has?(Pos).should be_true 74 | ent.remove(Speed) 75 | ent.has?(Speed).should be_false 76 | end 77 | 78 | it "replace components" do 79 | world = ECS::World.new 80 | ent = world.new_entity 81 | ent.add(Pos.new(1, 1)) 82 | ent.replace(Pos, Speed.new(1, 1)) 83 | ent.has?(Pos).should be_false 84 | ent.has?(Speed).should be_true 85 | ent.getSpeed.should eq Speed.new(1, 1) 86 | end 87 | 88 | it "update components with same type" do 89 | world = ECS::World.new 90 | ent = world.new_entity 91 | ent.add(Pos.new(1, 1)) 92 | ent.update(Pos.new(2, 2)) 93 | ent.has?(Pos).should be_true 94 | ent.getPos.should eq Pos.new(2, 2) 95 | end 96 | 97 | it "can't add components with same type twice" do 98 | world = ECS::World.new 99 | ent = world.new_entity 100 | ent.add(Pos.new(1, 1)) 101 | expect_raises(ECS::Exception) { ent.add(Pos.new(2, 2)) } 102 | end 103 | 104 | it "can set components with same type" do 105 | world = ECS::World.new 106 | ent = world.new_entity 107 | ent.set(Pos.new(1, 1)) 108 | ent.set(Pos.new(2, 2)) 109 | ent.has?(Pos).should be_true 110 | ent.getPos.should eq Pos.new(2, 2) 111 | world.of(Pos).size.should eq 1 112 | end 113 | 114 | it "receive component pointer" do 115 | world = ECS::World.new 116 | ent = world.new_entity 117 | ent.add(Pos.new(1, 1)) 118 | ptr = ent.getPos_ptr 119 | ptr.value.change_x(2) 120 | ent.getPos.should eq Pos.new(2, 1) 121 | end 122 | 123 | it "remove entities" do 124 | world = ECS::World.new 125 | ent = world.new_entity 126 | ent2 = world.new_entity 127 | ent.add(Pos.new(1, 1)) 128 | ent.add(Speed.new(1, 1)) 129 | ent2.add(Speed.new(1, 2)) 130 | count_entities(world).should eq 2 131 | ent.destroy 132 | count_entities(world).should eq 1 133 | ent2.destroy 134 | count_entities(world).should eq 0 135 | end 136 | 137 | it "remove entities by components" do 138 | world = ECS::World.new 139 | ent = world.new_entity 140 | ent2 = world.new_entity 141 | ent.add(Pos.new(1, 1)) 142 | ent.add(Speed.new(1, 1)) 143 | ent2.add(Speed.new(1, 2)) 144 | ent.remove(Speed) 145 | count_entities(world).should eq 2 146 | ent.remove(Pos) 147 | count_entities(world).should eq 1 148 | end 149 | 150 | it "can iterate using filters" do 151 | world = ECS::World.new 152 | ent = world.new_entity 153 | ent2 = world.new_entity 154 | ent.add(Pos.new(1, 1)) 155 | filter = world.of(Speed) 156 | filter.size.should eq 0 157 | ent2.add(Speed.new(1, 1)) 158 | filter.size.should eq 1 159 | ent2.add(Pos.new(1, 1)) 160 | ent2.remove(Speed) 161 | filter.size.should eq 0 162 | end 163 | 164 | it "can iterate using filters with all_of" do 165 | world = ECS::World.new 166 | ent = world.new_entity 167 | ent2 = world.new_entity 168 | ent.add(Pos.new(1, 1)) 169 | ent2.add(Pos.new(1, 1)) 170 | world.all_of([Pos, Speed]).size.should eq 0 171 | ent2.add(Speed.new(1, 1)) 172 | world.all_of([Pos, Speed]).size.should eq 1 173 | world.all_of([Pos]).size.should eq 2 174 | world.all_of([Pos, Name]).size.should eq 0 175 | world.all_of([Name]).size.should eq 0 176 | end 177 | it "can iterate using filters with any_of" do 178 | world = ECS::World.new 179 | world.new_entity.add(Pos.new(1, 1)) 180 | world.any_of([Name, Speed]).size.should eq 0 181 | world.new_entity.add(Pos.new(1, 1)).add(Speed.new(1, 1)) 182 | world.new_entity.add(Speed.new(1, 1)) 183 | world.any_of([Pos, Speed]).size.should eq 3 184 | world.any_of([Name, Pos]).size.should eq 2 185 | end 186 | 187 | it "any_of works with single item" do 188 | world = ECS::World.new 189 | world.new_entity.add(Pos.new(1, 1)) 190 | world.any_of([Pos]).size.should eq 1 191 | world.new_entity.add(Pos.new(1, 1)).add(Speed.new(1, 1)) 192 | world.new_entity.add(Speed.new(1, 1)) 193 | world.any_of([Pos]).size.should eq 2 194 | world.any_of([Name]).size.should eq 0 195 | end 196 | 197 | it "can filter using exclude" do 198 | world = ECS::World.new 199 | world.new_entity.add(Pos.new(1, 1)) 200 | world.new_entity.add(Pos.new(1, 1)).add(Speed.new(1, 1)) 201 | world.new_entity.add(Speed.new(1, 1)) 202 | world.of(Pos).exclude(Speed).size.should eq 1 203 | world.exclude([Speed]).size.should eq 1 204 | world.exclude(Name).size.should eq 3 205 | world.exclude([Name, Pos]).size.should eq 1 206 | end 207 | it "can filter using select" do 208 | world = ECS::World.new 209 | world.new_entity.add(Pos.new(1, 1)) 210 | world.new_entity.add(Pos.new(1, -1)).add(Speed.new(1, 1)) 211 | world.new_entity.add(Speed.new(1, 2)) 212 | world.of(Pos).select { |ent| ent.getPos.y > 0 }.size.should eq 1 213 | expect_raises(ECS::Exception) { world.new_filter.select { |ent| ent.getPos.y > 0 }.size } 214 | end 215 | 216 | it "can found single entity" do 217 | world = ECS::World.new 218 | world.of(Pos).first?.should eq nil 219 | ent = world.new_entity.add(Pos.new(1, 1)) 220 | world.of(Pos).first?.should eq ent 221 | ent.destroy 222 | world.of(Pos).first?.should eq nil 223 | end 224 | 225 | it "can add component of runtime type" do 226 | world = ECS::World.new 227 | ent = world.new_entity 228 | event = rand < 0.5 ? Pos.new(1, 1) : Speed.new(2, 2) 229 | ent.add(event) 230 | world.any_of([Pos, Speed]).first?.should eq ent 231 | end 232 | end 233 | 234 | class TestSystem < ECS::System 235 | getter init_called = 0 236 | getter execute_called = 0 237 | getter teardown_called = 0 238 | 239 | def init 240 | @init_called += 1 241 | end 242 | 243 | def execute 244 | @execute_called += 1 245 | end 246 | 247 | def teardown 248 | @teardown_called += 1 249 | end 250 | end 251 | 252 | class TestReactiveSystem < ECS::System 253 | getter execute_called = 0 254 | 255 | def execute 256 | @execute_called += 1 257 | end 258 | 259 | def filter(world) 260 | world.all_of([Pos, Speed]) 261 | end 262 | 263 | def process(entity) 264 | new_pos = Pos.new(entity.getPos.x + entity.getSpeed.vx, entity.getPos.y + entity.getSpeed.vy) 265 | entity.update(new_pos) 266 | end 267 | end 268 | 269 | describe ECS::Systems do 270 | it "can add, init, execute and teardown systems" do 271 | world = ECS::World.new 272 | world.new_entity.add(Pos.new(1, 1)) 273 | sys1 = TestSystem.new(world) 274 | sys2 = TestSystem.new(world) 275 | systems = ECS::Systems.new(world).add(sys1).add(sys2) 276 | sys1.init_called.should eq 0 277 | systems.init 278 | sys1.init_called.should eq 1 279 | sys2.init_called.should eq 1 280 | 281 | sys1.execute_called.should eq 0 282 | systems.execute 283 | sys1.execute_called.should eq 1 284 | 285 | sys2.teardown_called.should eq 0 286 | systems.teardown 287 | sys1.teardown_called.should eq 1 288 | end 289 | 290 | it "raises if wasn't initialized" do 291 | world = ECS::World.new 292 | systems = ECS::Systems.new(world) 293 | expect_raises(ECS::Exception, "initialized") { systems.execute } 294 | end 295 | 296 | it "can process entities" do 297 | world = ECS::World.new 298 | ent = world.new_entity.add(Pos.new(1, 1)) 299 | ent.add(Speed.new(10, 10)) 300 | sys1 = TestReactiveSystem.new(world) 301 | systems = ECS::Systems.new(world).add(sys1) 302 | systems.init 303 | sys1.execute_called.should eq 0 304 | systems.execute 305 | sys1.execute_called.should eq 1 306 | ent.getPos.x.should eq 10 + 1 307 | end 308 | 309 | it "honour active property" do 310 | world = ECS::World.new 311 | ent = world.new_entity.add(Pos.new(1, 1)) 312 | ent.add(Speed.new(10, 10)) 313 | systems = ECS::Systems.new(world) 314 | sys1 = TestReactiveSystem.new(world) 315 | sys2 = TestSystem.new(world) 316 | systems.add(sys1).add(sys2) 317 | systems.init 318 | sys1.execute_called.should eq 0 319 | sys2.execute_called.should eq 0 320 | systems.execute 321 | sys1.execute_called.should eq 1 322 | sys2.execute_called.should eq 1 323 | ent.getPos.x.should eq 10 + 1 324 | 325 | sys1.active = false 326 | systems.execute 327 | sys1.execute_called.should eq 1 328 | sys2.execute_called.should eq 2 329 | ent.getPos.x.should eq 10 + 1 330 | 331 | sys1.active = true 332 | sys2.active = false 333 | systems.execute 334 | sys1.execute_called.should eq 2 335 | sys2.execute_called.should eq 2 336 | ent.getPos.x.should eq 10 + 10 + 1 337 | end 338 | end 339 | 340 | class ReplaceEventsSystem(EventFrom, EventTo) < ECS::System 341 | def filter(world) 342 | world.of(EventFrom) 343 | end 344 | 345 | def process(entity) 346 | entity.add(EventTo.new) 347 | end 348 | end 349 | 350 | class CountAllOf(Event) < ECS::System 351 | def filter(world) 352 | world.of(Event) 353 | end 354 | 355 | property value = 0 356 | 357 | def process(entity) 358 | @value += 1 359 | end 360 | end 361 | 362 | class GenerateEventsSystem(Event) < ECS::System 363 | def execute 364 | @world.new_entity.add(Event.new) 365 | end 366 | end 367 | 368 | class GenerateEventsInInitSystem(Event) < ECS::System 369 | def init 370 | @world.new_entity.add(Event.new) 371 | end 372 | end 373 | 374 | describe ECS::SingleFrame do 375 | it "is deleted correctly" do 376 | world = ECS::World.new 377 | counter_before = CountAllOf(TestEvent3).new(world) 378 | counter_after = CountAllOf(TestEvent3).new(world) 379 | systems = ECS::Systems.new(world) 380 | .add(ReplaceEventsSystem(TestEvent2, TestEvent3).new(world)) 381 | .remove_singleframe(TestEvent2) 382 | .add(counter_before) 383 | .remove_singleframe(TestEvent3) 384 | .add(counter_before) 385 | .add(ReplaceEventsSystem(TestEvent1, TestEvent2).new(world)) 386 | .remove_singleframe(TestEvent1) 387 | .add(GenerateEventsSystem(TestEvent1).new(world)) 388 | systems.init 389 | systems.execute 390 | counter_before.value.should eq 0 391 | counter_after.value.should eq 0 392 | systems.execute 393 | counter_before.value.should eq 0 394 | counter_after.value.should eq 0 395 | systems.execute 396 | counter_before.value.should eq 1 397 | counter_after.value.should eq 0 398 | end 399 | 400 | it "is checked that they are deleted somewhere" do 401 | world = ECS::World.new 402 | systems = ECS::Systems.new(world) 403 | systems.init 404 | expect_raises(ECS::Exception) { world.new_entity.add(TestEvent1.new) } 405 | systems.remove_singleframe(TestEvent1) 406 | world.new_entity.add(TestEvent1.new) 407 | world.of(TestEvent1).size.should eq 1 408 | systems.execute 409 | world.of(TestEvent1).size.should eq 0 410 | end 411 | it "isn't checked that they are deleted somewhere if annotation specify it" do 412 | world = ECS::World.new 413 | systems = ECS::Systems.new(world) 414 | systems.init 415 | world.of(TestEventNotChecked).size.should eq 0 416 | world.new_entity.add(TestEventNotChecked.new) 417 | world.of(TestEventNotChecked).size.should eq 1 418 | systems.execute 419 | world.of(TestEventNotChecked).size.should eq 1 420 | world.new_entity.add(TestEventNotChecked.new) 421 | world.of(TestEventNotChecked).size.should eq 2 422 | end 423 | end 424 | 425 | @[ECS::Singleton] 426 | record Config < ECS::Component, value : Int32 427 | 428 | describe ECS::Singleton do 429 | it "exists on every entity" do 430 | world = ECS::World.new 431 | ent = world.new_entity.add(Pos.new(1, 1)) 432 | ent.add(Speed.new(10, 10)) 433 | world.new_entity.add(Config.new(100)) 434 | ent.getConfig.value.should eq 100 435 | end 436 | 437 | it "can be changed on every entity" do 438 | world = ECS::World.new 439 | ent = world.new_entity.add(Pos.new(1, 1)) 440 | ent.add(Speed.new(10, 10)) 441 | world.new_entity.add(Config.new(100)) 442 | ent.getConfig.value.should eq 100 443 | ent.update(Config.new(101)) 444 | ent.getConfig.value.should eq 101 445 | end 446 | 447 | it "shouldn't be iterated" do 448 | world = ECS::World.new 449 | ent = world.new_entity 450 | ent.add(Pos.new(1, 1)) 451 | ent.add(Speed.new(10, 10)) 452 | world.new_entity.add(Config.new(100)) 453 | count_entities(world).should eq 1 454 | world.of(Config).size.should eq 0 455 | end 456 | 457 | it "can be acquired from a world" do 458 | world = ECS::World.new 459 | expect_raises(ECS::Exception) { world.getConfig } 460 | world.getConfig?.should be_nil 461 | world.new_entity.add(Config.new(100)) 462 | world.getConfig?.should be_truthy 463 | world.getConfig.value.should eq 100 464 | world.getConfig_ptr.value.value.should eq 100 465 | end 466 | 467 | it "can be added and deleted from a world" do 468 | world = ECS::World.new 469 | expect_raises(ECS::Exception) { world.getConfig } 470 | world.getConfig?.should be_nil 471 | world.add(Config.new(100)) 472 | world.getConfig?.should be_truthy 473 | world.getConfig.value.should eq 100 474 | world.remove(Config) 475 | world.getConfig?.should be_nil 476 | end 477 | end 478 | 479 | class SystemGenerateEvent(Event) < ECS::System 480 | @fixed_filter : ECS::Filter? 481 | 482 | def initialize(@world, @fixed_filter = nil) 483 | super(@world) 484 | end 485 | 486 | def filter(world) 487 | @fixed_filter.not_nil! 488 | end 489 | 490 | def process(entity) 491 | entity.add(Event.new) 492 | end 493 | end 494 | 495 | describe ECS do 496 | it "don't trigger bug with iterating events" do 497 | world = ECS::World.new 498 | 1025.times { |i| 499 | ent = world.new_entity.add(Pos.new(0, 0)) 500 | ent = world.new_entity.add(Speed.new(0, 0)) 501 | ent = world.new_entity.add(Pos.new(0, 0)).add(Speed.new(0, 0)) 502 | } 503 | sys1 = SystemGenerateEvent(TestEvent1).new(world, world.of(Pos)) 504 | sys2 = SystemGenerateEvent(TestEvent2).new(world, world.of(Speed)) 505 | sys3 = SystemGenerateEvent(TestEvent3).new(world, world.all_of([TestEvent1, TestEvent2])) 506 | sys4 = CountAllOf(TestEvent3).new(world) 507 | systems = ECS::Systems.new(world).add(sys1).add(sys2).add(sys3).add(sys4) 508 | .remove_singleframe(TestEvent1) 509 | .remove_singleframe(TestEvent2) 510 | .remove_singleframe(TestEvent3) 511 | systems.init 512 | systems.execute 513 | systems.execute 514 | systems.execute 515 | end 516 | end 517 | 518 | @[ECS::Multiple] 519 | record Changer < ECS::Component, dx : Int32, dy : Int32 520 | 521 | @[ECS::Multiple] 522 | @[ECS::SingleFrame] 523 | record Request < ECS::Component, dx : Int32, dy : Int32 524 | 525 | class ProcessRequests < ECS::System 526 | def filter(world) 527 | world.all_of([Request, Pos]) 528 | end 529 | 530 | def process(entity) 531 | pos = entity.getPos 532 | req = entity.getRequest 533 | entity.set(Pos.new(pos.x + req.dx, pos.y + req.dy)) 534 | end 535 | end 536 | 537 | class ProcessChangers < ECS::System 538 | def filter(world) 539 | world.all_of([Changer, Pos]) 540 | end 541 | 542 | def process(entity) 543 | pos = entity.getPos 544 | req = entity.getChanger 545 | entity.set(Pos.new(pos.x + req.dx, pos.y + req.dy)) 546 | end 547 | end 548 | 549 | class GenerateRequests < ECS::System 550 | def initialize(@world, @list : Array(Int32)) 551 | end 552 | 553 | def filter(world) 554 | world.of(Pos) 555 | end 556 | 557 | def process(entity) 558 | @list.each { |x| entity.add(Request.new(x, 0)) } 559 | end 560 | end 561 | 562 | describe ECS::Multiple do 563 | it "can be added" do 564 | world = ECS::World.new 565 | ent = world.new_entity 566 | ent.has?(Changer).should be_false 567 | ent.add(Pos.new(0, 0)) 568 | ent.has?(Changer).should be_false 569 | ent.add(Changer.new(1, 1)) 570 | ent.has?(Changer).should be_true 571 | ent.add(Changer.new(2, 2)) 572 | ent.has?(Changer).should be_true 573 | end 574 | 575 | it "can be added and then all removed" do 576 | world = ECS::World.new 577 | ent = world.new_entity 578 | ent.add(Pos.new(0, 0)) 579 | ent.add(Changer.new(1, 1)) 580 | ent.add(Changer.new(2, 2)) 581 | ent.has?(Changer).should be_true 582 | ent.remove(Changer) 583 | ent.has?(Changer).should be_false 584 | expect_raises(ECS::Exception) { ent.remove(Changer) } 585 | end 586 | 587 | it "can be iterated" do 588 | world = ECS::World.new 589 | ent = world.new_entity 590 | ent.add(Pos.new(0, 0)) 591 | ent.add(Changer.new(1, 1)) 592 | ent.add(Changer.new(2, 2)) 593 | world.of(Changer).size.should eq 2 594 | world.of(Pos).size.should eq 1 595 | end 596 | 597 | it "can be iterated after removal" do 598 | world = ECS::World.new 599 | ent = world.new_entity 600 | ent.add(Pos.new(0, 0)) 601 | ent.add(Changer.new(1, 1)) 602 | ent.add(Changer.new(2, 2)) 603 | world.of(Changer).size.should eq 2 604 | ent.remove(Changer) 605 | world.of(Changer).size.should eq 0 606 | end 607 | 608 | it "can be iterated in combination with usual components" do 609 | world = ECS::World.new 610 | ent = world.new_entity 611 | ent.add(Pos.new(0, 0)) 612 | ent.add(Changer.new(1, 1)) 613 | ent.add(Changer.new(2, 2)) 614 | ent = world.new_entity 615 | ent.add(Changer.new(3, 3)) 616 | ent = world.new_entity 617 | ent.add(Changer.new(4, 4)) 618 | world.any_of([Changer, Pos]).size.should eq 4 619 | world.any_of([Pos, Changer]).size.should eq 4 620 | world.all_of([Changer, Pos]).size.should eq 2 621 | world.all_of([Pos, Changer]).size.should eq 2 622 | end 623 | 624 | it "can be iterated in combination with usual components #2" do 625 | world = ECS::World.new 626 | ent = world.new_entity 627 | ent.add(Pos.new(0, 0)) 628 | ent.add(Changer.new(1, 1)) 629 | ent.add(Changer.new(2, 2)) 630 | ent.add(Speed.new(0, 0)) 631 | ent = world.new_entity 632 | ent.add(Changer.new(3, 3)) 633 | ent.add(Speed.new(0, 0)) 634 | ent = world.new_entity 635 | ent.add(Changer.new(4, 4)) 636 | ent.add(Speed.new(0, 0)) 637 | world.of(Speed).any_of([Pos, Changer]).size.should eq 4 638 | end 639 | 640 | it "can't be iterated when several of them present in filter" do 641 | world = ECS::World.new 642 | expect_raises(ECS::Exception) { world.any_of([Changer, Request]) } 643 | expect_raises(ECS::Exception) { world.all_of([Changer, Request]) } 644 | expect_raises(ECS::Exception) { world.of(Changer).of(Request) } 645 | expect_raises(ECS::Exception) { world.any_of([Changer, Pos]).any_of([Request, Speed]) } 646 | world.of(Changer).exclude(Request).size.should eq 0 647 | end 648 | 649 | it "can be processed with systems" do 650 | world = ECS::World.new 651 | systems = ECS::Systems.new(world) 652 | .add(ProcessChangers.new(world)) 653 | systems.init 654 | ent = world.new_entity 655 | ent.add(Pos.new(0, 0)) 656 | ent.add(Changer.new(1, 0)) 657 | ent.add(Changer.new(10, 0)) 658 | systems.execute 659 | ent.getPos.x.should eq 11 660 | systems.execute 661 | ent.getPos.x.should eq 22 662 | end 663 | 664 | it "can be processed with systems (single-frame)" do 665 | world = ECS::World.new 666 | systems = ECS::Systems.new(world) 667 | .add(GenerateRequests.new(world, [10, 1])) 668 | .add(ProcessRequests.new(world)) 669 | .remove_singleframe(Request) 670 | systems.init 671 | ent = world.new_entity 672 | ent.add(Pos.new(0, 0)) 673 | ent.add(Changer.new(1, 0)) 674 | ent.add(Changer.new(10, 0)) 675 | systems.execute 676 | ent.getPos.x.should eq 11 677 | systems.execute 678 | ent.getPos.x.should eq 22 679 | systems.execute 680 | ent.getPos.x.should eq 33 681 | end 682 | 683 | it "can be processed with systems (single-frame) when requests come from different systems" do 684 | world = ECS::World.new 685 | systems = ECS::Systems.new(world) 686 | .add(GenerateRequests.new(world, [10])) 687 | .add(ProcessRequests.new(world)) 688 | .remove_singleframe(Request) 689 | .add(GenerateRequests.new(world, [1])) 690 | systems.init 691 | ent = world.new_entity 692 | ent.add(Pos.new(0, 0)) 693 | systems.execute 694 | ent.getPos.x.should eq 10 695 | systems.execute 696 | ent.getPos.x.should eq 21 697 | systems.execute 698 | ent.getPos.x.should eq 32 699 | end 700 | end 701 | 702 | describe ECS do 703 | it "don't trigger bug with iterating after removal" do 704 | world = ECS::World.new 705 | systems = ECS::Systems.new(world) 706 | .add(ECS::RemoveAllOf.new(world, Speed)) 707 | .add(ECS::RemoveAllOf.new(world, Pos)) 708 | systems.init 709 | 710 | ent1 = world.new_entity 711 | ent1.add(Pos.new(1, 1)) 712 | ent1.add(Speed.new(0, 0)) 713 | 714 | ent2 = world.new_entity 715 | ent2.add(Pos.new(2, 2)) 716 | ent2.add(Speed.new(0, 0)) 717 | 718 | ent3 = world.new_entity 719 | ent3.add(Pos.new(3, 3)) 720 | ent3.add(Speed.new(0, 0)) 721 | world.of(Speed).size.should eq 3 722 | ent1.remove(Pos) 723 | ent3.remove(Pos) 724 | world.of(Pos).first?.not_nil!.getPos.should eq Pos.new(2, 2) 725 | end 726 | end 727 | 728 | describe ECS::World do 729 | it "can found single entity without filter" do 730 | world = ECS::World.new 731 | world.component_exists?(Pos).should be_false 732 | ent = world.new_entity.add(Pos.new(1, 1)) 733 | world.component_exists?(Pos).should be_true 734 | ent.destroy 735 | world.component_exists?(Pos).should be_false 736 | end 737 | 738 | it "can iterate on a single component without filter" do 739 | world = ECS::World.new 740 | world.query(Pos).size.should eq 0 741 | ent = world.new_entity.add(Pos.new(1, 1)) 742 | world.new_entity.add(Pos.new(2, 2)) 743 | world.query(Pos).size.should eq 2 744 | ent.destroy 745 | world.query(Pos).size.should eq 1 746 | end 747 | 748 | it "can show stats" do 749 | world = ECS::World.new 750 | world.stats.should eq Hash(String, Int32).new 751 | ent = world.new_entity.add(Pos.new(1, 1)) 752 | ent.add(Speed.new(2, 2)) 753 | ent = world.new_entity.add(Pos.new(1, 1)) 754 | world.stats.should eq({"Speed" => 1, "Pos" => 2}) 755 | end 756 | 757 | it "raises on reuse of deleted entities" do 758 | world = ECS::World.new 759 | ent = world.new_entity.add(Pos.new(1, 1)) 760 | ent.remove(Pos) 761 | expect_raises(ECS::Exception) { ent.add(Speed.new(1, 1)) } 762 | end 763 | 764 | it "don't hangs if component is just added during iterating on it" do 765 | world = ECS::World.new 766 | world.new_entity.add(Pos.new(1, 1)) 767 | world.new_entity.add(Pos.new(2, 2)) 768 | iter = 0 769 | world.of(Pos).each do |ent| 770 | pos = ent.getPos 771 | world.new_entity.add(pos) if world.entities_count < 5 772 | iter += 1 773 | raise "hangs up" if iter > 10 774 | end 775 | end 776 | 777 | it "don't hangs if component is deleted and added during iterating on it" do 778 | world = ECS::World.new 779 | world.new_entity.add(Pos.new(1, 1)) 780 | world.new_entity.add(Pos.new(2, 2)) 781 | iter = 0 782 | world.of(Pos).each do |ent| 783 | pos = ent.getPos 784 | ent.remove(Pos) 785 | world.new_entity.add(pos) 786 | iter += 1 787 | raise "hangs up" if iter > 10 788 | end 789 | end 790 | 791 | it "don't hangs if component is replaced during iterating on it" do 792 | world = ECS::World.new 793 | world.new_entity.add(Pos.new(1, 1)) 794 | world.new_entity.add(Pos.new(2, 2)) 795 | iter = 0 796 | world.of(Pos).each do |ent| 797 | pos = ent.getPos 798 | ent.replace(Pos, Speed.new(pos.x, pos.y)) 799 | speed = ent.getSpeed 800 | ent.replace(Speed, Pos.new(speed.vx, speed.vy)) 801 | iter += 1 802 | raise "hangs up" if iter > 10 803 | end 804 | end 805 | end 806 | 807 | record TestCallbacks < ECS::Component, name : String do 808 | class_getter added = [] of String 809 | class_getter deleted = [] of String 810 | 811 | def when_added(entity) 812 | @@added << @name 813 | end 814 | 815 | def when_removed(entity) 816 | @@deleted << @name 817 | end 818 | end 819 | 820 | @[ECS::Multiple] 821 | record TestCallbacksMultiple < ECS::Component, name : String do 822 | class_getter added = [] of String 823 | class_getter deleted = [] of String 824 | 825 | def when_added(entity) 826 | @@added << @name 827 | end 828 | 829 | def when_removed(entity) 830 | @@deleted << @name 831 | end 832 | end 833 | 834 | @[ECS::SingleFrame] 835 | record TestCallbacksSingleFrame < ECS::Component, name : String do 836 | class_getter added = [] of String 837 | class_getter deleted = [] of String 838 | 839 | def when_added(entity) 840 | @@added << @name 841 | end 842 | 843 | def when_removed(entity) 844 | @@deleted << @name 845 | end 846 | end 847 | 848 | describe ECS do 849 | describe "callbacks" do 850 | it "when_added called when component is added" do 851 | world = ECS::World.new 852 | TestCallbacks.added.clear 853 | world.new_entity.add(Pos.new(1, 1)).add(TestCallbacks.new("first")) 854 | world.new_entity.add(TestCallbacks.new("second")) 855 | TestCallbacks.added.should eq ["first", "second"] 856 | end 857 | 858 | it "when_removed called when component is removed" do 859 | world = ECS::World.new 860 | TestCallbacks.deleted.clear 861 | ent2 = world.new_entity.add(TestCallbacks.new("second")) 862 | ent1 = world.new_entity.add(Pos.new(1, 1)).add(TestCallbacks.new("first")) 863 | ent1.destroy 864 | ent2.remove(TestCallbacks) 865 | TestCallbacks.deleted.should eq ["first", "second"] 866 | end 867 | 868 | it "when_removed called when world is cleared" do 869 | world = ECS::World.new 870 | TestCallbacks.deleted.clear 871 | ent1 = world.new_entity.add(Pos.new(1, 1)).add(TestCallbacks.new("first")) 872 | ent2 = world.new_entity.add(TestCallbacks.new("second")) 873 | world.delete_all(with_callbacks: true) 874 | TestCallbacks.deleted.should eq ["first", "second"] 875 | 876 | TestCallbacks.deleted.clear 877 | ent1 = world.new_entity.add(Pos.new(1, 1)).add(TestCallbacks.new("first")) 878 | ent2 = world.new_entity.add(TestCallbacks.new("second")) 879 | world.delete_all(with_callbacks: false) 880 | TestCallbacks.deleted.should be_empty 881 | end 882 | 883 | it "when_added and when_removed works with singleframe components" do 884 | world = ECS::World.new 885 | TestCallbacksSingleFrame.deleted.clear 886 | TestCallbacksSingleFrame.added.clear 887 | systems = ECS::Systems.new(world).remove_singleframe(TestCallbacksSingleFrame) 888 | systems.init 889 | ent1 = world.new_entity.add(Pos.new(1, 1)).add(TestCallbacksSingleFrame.new("first")) 890 | ent2 = world.new_entity.add(TestCallbacksSingleFrame.new("second")) 891 | systems.execute 892 | TestCallbacksSingleFrame.added.should eq ["first", "second"] 893 | TestCallbacksSingleFrame.deleted.should eq ["first", "second"] 894 | end 895 | 896 | it "when_added and when_removed works with multiple components" do 897 | world = ECS::World.new 898 | TestCallbacksMultiple.deleted.clear 899 | TestCallbacksMultiple.added.clear 900 | systems = ECS::Systems.new(world).remove_singleframe(TestCallbacksMultiple) 901 | systems.init 902 | ent2 = world.new_entity.add(Pos.new(1, 1)).add(TestCallbacksMultiple.new("second")) 903 | ent1 = world.new_entity.add(TestCallbacksMultiple.new("first")) 904 | ent2.add(TestCallbacksMultiple.new("third")) 905 | ent1.destroy 906 | ent2.remove(TestCallbacksMultiple) 907 | TestCallbacksMultiple.added.should eq ["second", "first", "third"] 908 | TestCallbacksMultiple.deleted.should eq ["first", "second", "third"] 909 | end 910 | end 911 | end 912 | 913 | describe ECS::World do 914 | it "can be serialized and deserialized when empty" do 915 | world = ECS::World.new 916 | io = IO::Memory.new 917 | world.encode io 918 | total_size = io.pos 919 | world2 = ECS::World.new 920 | io.rewind 921 | world2.decode io 922 | io.pos.should eq total_size 923 | puts "serialization of empty world: #{total_size}" 924 | end 925 | 926 | it "can be serialized and deserialized" do 927 | world = ECS::World.new 928 | io = IO::Memory.new 929 | 100.times do 930 | ent = world.new_entity.add(Pos.new(1, 1)) 931 | ent.add(Speed.new(3, 3)) if rand < 0.5 932 | ent.add(Name.new("Test")) if rand < 0.5 933 | end 934 | 50.times do 935 | world.of(Pos).sample.destroy 936 | end 937 | 100.times do 938 | ent = world.new_entity.add(Pos.new(1, 1)) 939 | ent.add(Speed.new(3, 3)) if rand < 0.5 940 | ent.add(Name.new("Test")) if rand < 0.5 941 | end 942 | 50.times do 943 | world.of(Pos).sample.destroy 944 | end 945 | world.encode io 946 | total_size = io.pos 947 | old_stats = world.stats 948 | old_values = world.query(Pos).to_a.map do |ent| 949 | {ent.getPos?, ent.getSpeed?, ent.getName?} 950 | end 951 | 952 | world2 = ECS::World.new 953 | io.rewind 954 | world2.decode io 955 | io.pos.should eq total_size 956 | world.stats.should eq old_stats 957 | values = world.query(Pos).to_a.map do |ent| 958 | {ent.getPos?, ent.getSpeed?, ent.getName?} 959 | end 960 | values.should eq old_values 961 | puts "serialization size: #{total_size}" 962 | end 963 | 964 | it "isn't broken after deserialization (minimal test)" do 965 | world = ECS::World.new 966 | io = IO::Memory.new 967 | 16.times { ent = world.new_entity.add(Pos.new(1, 1)) } 968 | world.encode io 969 | 970 | world2 = ECS::World.new 971 | io.rewind 972 | world2.decode io 973 | 974 | world2.new_entity.add(Pos.new(1, 1)) 975 | end 976 | 977 | it "isn't broken after deserialization" do 978 | world = ECS::World.new 979 | io = IO::Memory.new 980 | 100.times do 981 | ent = world.new_entity.add(Pos.new(1, 1)) 982 | ent.add(Speed.new(3, 3)) if rand < 0.5 983 | ent.add(Name.new("Test")) if rand < 0.5 984 | end 985 | 50.times do 986 | world.of(Pos).sample.destroy 987 | end 988 | 100.times do 989 | ent = world.new_entity.add(Pos.new(1, 1)) 990 | ent.add(Speed.new(3, 3)) if rand < 0.5 991 | ent.add(Name.new("Test")) if rand < 0.5 992 | end 993 | 50.times do 994 | world.of(Pos).sample.destroy 995 | end 996 | world.encode io 997 | 998 | world2 = ECS::World.new 999 | io.rewind 1000 | world2.decode io 1001 | 1002 | 100.times do 1003 | ent = world2.new_entity.add(Pos.new(1, 1)) 1004 | ent.add(Speed.new(3, 3)) if rand < 0.5 1005 | ent.add(Name.new("Test")) if rand < 0.5 1006 | end 1007 | 50.times do 1008 | world2.of(Pos).sample.destroy 1009 | end 1010 | 100.times do 1011 | ent = world2.new_entity.add(Pos.new(1, 1)) 1012 | ent.add(Speed.new(3, 3)) if rand < 0.5 1013 | ent.add(Name.new("Test")) if rand < 0.5 1014 | end 1015 | 50.times do 1016 | world2.of(Pos).sample.destroy 1017 | end 1018 | end 1019 | end 1020 | 1021 | it "singleton correctly serialized" do 1022 | world = ECS::World.new 1023 | io = IO::Memory.new 1024 | 16.times { ent = world.new_entity.add(Pos.new(1, 1)) } 1025 | world.new_entity.add(Config.new(101)) 1026 | world.encode io 1027 | 1028 | world2 = ECS::World.new 1029 | io.rewind 1030 | world2.decode io 1031 | world2.getConfig.value.should eq 101 1032 | end 1033 | 1034 | it "singleton correctly serialized when not present" do 1035 | world = ECS::World.new 1036 | io = IO::Memory.new 1037 | 16.times { ent = world.new_entity.add(Pos.new(1, 1)) } 1038 | world.new_entity.add(Config.new(101)) 1039 | world.new_entity.remove(Config) 1040 | world.encode io 1041 | 1042 | world2 = ECS::World.new 1043 | io.rewind 1044 | world2.decode io 1045 | world2.getConfig?.should be_nil 1046 | end 1047 | 1048 | class TestOrderSystem < ECS::System 1049 | getter list = [] of String 1050 | 1051 | def execute 1052 | list << "execute" 1053 | end 1054 | 1055 | def filter(world) 1056 | world.of(Name) 1057 | end 1058 | 1059 | def process(entity) 1060 | list << "process" 1061 | end 1062 | 1063 | def preprocess 1064 | list << "preprocess" 1065 | end 1066 | end 1067 | 1068 | it "preprocess is called in correct order" do 1069 | world = ECS::World.new 1070 | world.new_entity.add(Name.new("1")) 1071 | sys = TestOrderSystem.new(world) 1072 | systems = ECS::Systems.new(world).add(sys) 1073 | systems.init 1074 | systems.execute 1075 | sys.list.should eq ["preprocess", "process", "execute"] 1076 | end 1077 | 1078 | record WithEntity < ECS::Component, link : ECS::Entity 1079 | record WithEntities < ECS::Component, links : Array(ECS::Entity) 1080 | 1081 | it "can serialize components containing ECS::Entity" do 1082 | world1 = ECS::World.new 1083 | io = IO::Memory.new 1084 | 1085 | ent1 = world1.new_entity 1086 | ent2 = world1.new_entity 1087 | ent3 = world1.new_entity 1088 | ent2.add(Pos.new(1, 2)) 1089 | ent1.add(WithEntity.new(ent3)) 1090 | ent3.add(WithEntities.new([ent2])) 1091 | 1092 | world1.encode io 1093 | 1094 | world2 = ECS::World.new 1095 | io.rewind 1096 | world2.decode io 1097 | 1098 | world2.query(WithEntity).first.getWithEntity.link.getWithEntities.links[0].getPos.should eq Pos.new(1, 2) 1099 | end 1100 | 1101 | ECS.debug_stats 1102 | -------------------------------------------------------------------------------- /src/myecs.cr: -------------------------------------------------------------------------------- 1 | require "cannon" 2 | 3 | module ECS 4 | class Exception < ::Exception 5 | end 6 | 7 | # :nodoc: 8 | COMP_INDICES = {} of Component.class => Int32 9 | 10 | # :nodoc: 11 | WORLD_BEING_LOADED = {} of Fiber => ECS::World 12 | 13 | # Component - container for user data without / with small logic inside. 14 | # All components should be inherited from `ECS::Component` 15 | @[Packed] 16 | abstract struct Component 17 | include Cannon::Auto 18 | 19 | macro inherited 20 | {% ECS::COMP_INDICES[@type] = ECS::COMP_INDICES.size %} 21 | 22 | @[AlwaysInline] 23 | def self.component_index 24 | {{ECS::COMP_INDICES[@type]}} 25 | end 26 | end 27 | end 28 | 29 | # Represents component that should exist for one frame and be deleted after. 30 | annotation SingleFrame 31 | end 32 | 33 | # Represents component that doesn't belong to specific entity. Instead, it can be acquired from every entity. 34 | annotation Singleton 35 | end 36 | 37 | # Represents component that can be present on any entity more than once. 38 | annotation Multiple 39 | end 40 | 41 | private SMALL_COMPONENT_POOL_SIZE = 16 42 | private DEFAULT_ENTITY_POOL_SIZE = 1024 43 | 44 | # Entity Identifier 45 | alias EntityID = UInt32 46 | 47 | alias SparseIndex = Int32 48 | 49 | # Identifier that doesn't match any entity 50 | NO_ENTITY = 0xFFFFFFFFu32 51 | 52 | # Сontainer for components. Consists from UInt64 and pointer to `World` 53 | struct Entity 54 | # ID of entity 55 | getter id : EntityID 56 | # World that contains entity 57 | getter world : World 58 | 59 | protected def initialize(@world, @id) 60 | end 61 | 62 | # Adds component to the entity. 63 | # Will raise if component already exists (and doesn't have `Multiple` annotation) 64 | def add(comp : Component) 65 | @world.pool_for(comp).add_component(@id, comp) 66 | self 67 | end 68 | 69 | # Adds component to the entity or update existing component of same type 70 | def set(comp : Component) 71 | @world.pool_for(comp).add_or_update_component(@id, comp) 72 | self 73 | end 74 | 75 | # Returns true if component of type `typ` exists on the entity 76 | def has?(typ : ComponentType) 77 | @world.base_pool_for(typ).has_component?(@id) 78 | end 79 | 80 | # Removes component of type `typ` from the entity. Will raise if component isn't present on entity 81 | def remove(typ : ComponentType) 82 | @world.base_pool_for(typ).remove_component(@id) 83 | self 84 | end 85 | 86 | # Removes component of type `typ` from the entity if it exists. Otherwise, do nothing 87 | def remove_if_present(typ : ComponentType) 88 | @world.base_pool_for(typ).try_remove_component(@id) 89 | self 90 | end 91 | 92 | # Deletes component of type `typ` and add component `comp` to the entity 93 | def replace(typ : ComponentType, comp : Component) 94 | @world.base_pool_for(typ).remove_component(@id, dont_gc: true) 95 | add(comp) 96 | end 97 | 98 | def inspect(io) 99 | io << "Entity{" << id << "}[" 100 | @world.pools.each { |pool| io << pool.name << "," if pool.has_component?(@id) && !pool.is_singleton } 101 | io << "]" 102 | end 103 | 104 | # Update existing component of same type on the entity. Will raise if component of this type isn't present. 105 | def update(comp : Component) 106 | @world.pool_for(comp).update_component(@id, comp) 107 | end 108 | 109 | # Destroys entity removing all components from it. 110 | # Entity ID is marked as free and can be reused 111 | def destroy 112 | @world.pools.each do |pool| 113 | # break if @world.count_components[@id] <= World::ENTITY_EMPTY #seems to be slower 114 | pool.try_remove_component(@id, dont_gc: true) 115 | end 116 | @world.gc_entity @id 117 | end 118 | 119 | # Destroys entity if it is empty. It is done automatically when last component is removed 120 | # So the only use case is when you create entity then want to destroy if no components was added to it. 121 | def destroy_if_empty 122 | @world.check_gc_entity @id 123 | end 124 | 125 | def to_cannon_io(io) 126 | io.write_bytes self.id 127 | io 128 | end 129 | 130 | def self.from_cannon_io(io) : self 131 | id = io.read_bytes(EntityID) 132 | self.new(WORLD_BEING_LOADED[Fiber.current], id) 133 | end 134 | 135 | macro finished 136 | {% for obj in Component.all_subclasses %} 137 | {% obj_name = obj.id.split("::").last.id %} 138 | def get{{obj_name}} 139 | @world.pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component?(@id) || raise Exception.new("{{obj}} not present on entity #{self}") 140 | end 141 | 142 | def get{{obj_name}}? 143 | @world.pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component?(@id) 144 | end 145 | 146 | def get{{obj_name}}_ptr 147 | @world.pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component_ptr(@id) 148 | end 149 | {% end %} 150 | end 151 | end 152 | 153 | # type that represents type of any component 154 | alias ComponentType = Component.class 155 | 156 | private abstract class BasePool 157 | def total_count : Int32 158 | @used 159 | end 160 | 161 | @size : Int32 162 | @used : Int32 = 0 163 | @corresponding : Slice(EntityID) 164 | @sparse : PagedArray 165 | @cache_entity : EntityID = NO_ENTITY 166 | @cache_index : Int32 = -1 167 | property deleter_registered = false 168 | 169 | def initialize(@size, @world : World) 170 | @sparse = PagedArray.new(@world.entities_capacity) 171 | @corresponding = Pointer(EntityID).malloc(@size).to_slice(@size) 172 | end 173 | 174 | protected def resize_sparse(count) 175 | @sparse.resize(count) 176 | end 177 | 178 | protected def grow 179 | old_size = @size 180 | @size = case old_size 181 | when 0 182 | 1 183 | when 1 184 | SMALL_COMPONENT_POOL_SIZE 185 | else 186 | @size * 2 187 | end 188 | @raw = @raw.to_unsafe.realloc(@size).to_slice(@size) 189 | @corresponding = @corresponding.to_unsafe.realloc(@size).to_slice(@size) 190 | end 191 | 192 | private def release_index(index) 193 | unless index == @used - 1 194 | fix_entity = @corresponding[@used - 1] 195 | @sparse[fix_entity] = index 196 | @corresponding[index] = fix_entity 197 | end 198 | @used -= 1 199 | end 200 | 201 | protected def get_free_index : Int32 202 | @used += 1 203 | grow if @used >= @size 204 | @used - 1 205 | end 206 | 207 | def entity_to_id(ent : EntityID) : Int32 208 | return @cache_index if @cache_entity == ent 209 | @sparse[ent] 210 | end 211 | 212 | def has_component?(entity) : Bool 213 | return true if entity == @cache_entity 214 | @sparse[entity] >= 0 215 | end 216 | 217 | def remove_component(entity, *, dont_gc = false) 218 | raise Exception.new("can't remove component #{self.class} from #{Entity.new(@world, entity)}") unless has_component?(entity) 219 | remove_component_without_check(entity) 220 | @world.dec_count_components(entity, dont_gc) 221 | end 222 | 223 | def try_remove_component(entity, *, dont_gc = false) 224 | return unless has_component?(entity) 225 | remove_component_without_check(entity) 226 | @world.dec_count_components(entity, dont_gc) 227 | end 228 | 229 | def remove_component_without_check_single(entity) 230 | item = entity_to_id entity 231 | comp = @raw[item] 232 | if comp.responds_to?(:when_removed) 233 | comp.when_removed(Entity.new(@world, entity)) 234 | end 235 | @cache_entity = NO_ENTITY if @cache_index == item || @cache_index == @used - 1 236 | @sparse[entity] = -1 237 | release_index item 238 | end 239 | 240 | def remove_component_without_check_multiple(entity) 241 | (0...@used).each do |i| 242 | if @corresponding[i] == entity 243 | comp = @raw[i] 244 | if comp.responds_to?(:when_removed) 245 | comp.when_removed(Entity.new(@world, entity)) 246 | end 247 | end 248 | end 249 | @cache_entity = NO_ENTITY # because many entites are affected 250 | @sparse[entity] = -1 251 | # we just iterate over all array 252 | # TODO - faster method 253 | (@used - 1).downto(0) do |i| 254 | if @corresponding[i] == entity 255 | release_index i 256 | end 257 | end 258 | end 259 | 260 | def each_entity(& : EntityID ->) 261 | i = 0 262 | @used.times do |iter| 263 | break if i >= @used 264 | ent = @corresponding[i] 265 | @cache_index = i 266 | @cache_entity = ent 267 | yield(ent) 268 | i += 1 if @corresponding[i] == ent 269 | end 270 | end 271 | 272 | def clear(with_callbacks = false) 273 | @sparse.clear 274 | @used = 0 275 | @cache_entity = NO_ENTITY 276 | end 277 | end 278 | 279 | private abstract class Pool(T) < BasePool 280 | def name 281 | T.to_s 282 | end 283 | 284 | abstract def remove_component_without_check(entity) 285 | abstract def update_component(entity, comp) 286 | abstract def add_component(entity, comp) 287 | abstract def add_or_update_component(entity, comp) 288 | abstract def get_component_ptr(entity) 289 | abstract def get_component?(entity) 290 | end 291 | 292 | private class SingletonPool(T) < Pool(T) 293 | @raw = uninitialized T 294 | 295 | def initialize(@world : World) 296 | super(1, @world) 297 | end 298 | 299 | def is_singleton 300 | true 301 | end 302 | 303 | def has_component?(entity) : Bool 304 | @used > 0 305 | end 306 | 307 | def remove_component(entity, *, dont_gc = false) 308 | raise Exception.new("can't remove singleton #{self.class}") if @used == 0 309 | item = @raw 310 | if item.responds_to?(:when_removed) 311 | item.when_removed(Entity.new(@world, entity)) 312 | end 313 | @used = 0 314 | end 315 | 316 | def remove_component_without_check(entity) 317 | end 318 | 319 | def each_entity(& : EntityID ->) 320 | end 321 | 322 | def clear(with_callbacks = false) 323 | if @used > 0 && @raw.responds_to?(:when_removed) 324 | item = @raw 325 | if item.responds_to?(:when_removed) 326 | item.when_removed(Entity.new(@world, NO_ENTITY)) 327 | end 328 | end 329 | @used = 0 330 | end 331 | 332 | def update_component(entity, comp) 333 | @used = 1 334 | @raw = comp.as(Component).as(T) 335 | end 336 | 337 | def add_component(entity, comp) 338 | @used = 1 339 | @raw = comp.as(Component).as(T) 340 | item = @raw 341 | if item.responds_to?(:when_added) 342 | item.when_added(Entity.new(@world, entity)) 343 | end 344 | end 345 | 346 | def add_or_update_component(entity, comp) 347 | was_empty = @used == 0 348 | @used = 1 349 | @raw = comp.as(Component).as(T) 350 | if was_empty 351 | if comp.responds_to?(:when_added) 352 | comp.when_added(Entity.new(@world, entity)) 353 | end 354 | end 355 | end 356 | 357 | def get_component_ptr(entity) 358 | pointerof(@raw) 359 | end 360 | 361 | def get_component?(entity) 362 | return nil if @used == 0 363 | @raw 364 | end 365 | 366 | def encode(io) 367 | Cannon.encode(io, @used) 368 | Cannon.encode(io, @raw) if @used > 0 369 | end 370 | 371 | def decode(io) 372 | @used = Cannon.decode(io, typeof(@used)) 373 | @raw = Cannon.decode(io, typeof(@raw)) if @used > 0 374 | end 375 | end 376 | 377 | private class NormalPool(T) < Pool(T) 378 | @raw : Slice(T) 379 | 380 | def initialize(@world : World) 381 | size = 0 382 | super(size, @world) 383 | @raw = Pointer(T).malloc(@size).to_slice(@size) 384 | end 385 | 386 | def name 387 | T.to_s 388 | end 389 | 390 | def is_singleton 391 | false 392 | end 393 | 394 | private def release_index(index) 395 | unless index == @used - 1 396 | @raw[index] = @raw[@used - 1] 397 | end 398 | super 399 | end 400 | 401 | protected def grow 402 | super 403 | @raw = @raw.to_unsafe.realloc(@size).to_slice(@size) 404 | end 405 | 406 | def remove_component_without_check(entity) 407 | {% if T.annotation(ECS::Multiple) %} 408 | remove_component_without_check_multiple(entity) 409 | {% else %} 410 | remove_component_without_check_single(entity) 411 | {% end %} 412 | end 413 | 414 | def pointer 415 | @raw 416 | end 417 | 418 | def add_component_without_check(entity : EntityID, item) 419 | {% if T.annotation(ECS::Multiple) %} 420 | @world.inc_count_components(entity) unless has_component?(entity) 421 | {% else %} 422 | @world.inc_count_components(entity) 423 | {% end %} 424 | fresh = get_free_index 425 | pointer[fresh] = item.as(Component).as(T) 426 | @sparse[entity] = fresh 427 | @cache_entity = entity 428 | @cache_index = fresh 429 | @corresponding[fresh] = entity 430 | if item.responds_to?(:when_added) 431 | item.when_added(Entity.new(@world, entity)) 432 | end 433 | end 434 | 435 | def update_component(entity, comp) 436 | pointer[entity_to_id(entity)] = comp.as(Component).as(T) 437 | end 438 | 439 | def add_component(entity, comp) 440 | {% if !T.annotation(ECS::Multiple) %} 441 | raise Exception.new("#{T} already added to #{Entity.new(@world, entity)}") if has_component?(entity) 442 | {% end %} 443 | {% if T.annotation(ECS::SingleFrame) && (!T.annotation(ECS::SingleFrame).named_args.keys.includes?("check".id) || T.annotation(ECS::SingleFrame)[:check]) %} 444 | raise Exception.new("#{T} is created but never deleted") unless @deleter_registered 445 | {% end %} 446 | add_component_without_check(entity, comp) 447 | end 448 | 449 | def add_or_update_component(entity, comp) 450 | if has_component?(entity) 451 | update_component(entity, comp) 452 | else 453 | {% if T.annotation(ECS::SingleFrame) && (!T.annotation(ECS::SingleFrame).named_args.keys.includes?("check".id) || T.annotation(ECS::SingleFrame)[:check]) %} 454 | raise Exception.new("#{T} is created but never deleted") unless @deleter_registered 455 | {% end %} 456 | add_component_without_check(entity, comp) 457 | end 458 | end 459 | 460 | def get_component_ptr(entity) 461 | (pointer + entity_to_id entity).to_unsafe 462 | end 463 | 464 | def get_component?(entity) 465 | return nil unless has_component?(entity) 466 | pointer[entity_to_id entity] 467 | end 468 | 469 | def encode(io) 470 | Cannon.encode(io, @used) 471 | Cannon.encode(io, @size) 472 | @sparse.encode(io, @used) 473 | @used.times do |i| 474 | Cannon.encode(io, @corresponding[i]) 475 | end 476 | @used.times do |i| 477 | Cannon.encode(io, @raw[i]) 478 | end 479 | end 480 | 481 | def decode(io) 482 | @used = Cannon.decode(io, typeof(@used)) 483 | @size = Cannon.decode(io, typeof(@size)) 484 | @sparse.decode(io, @used) 485 | @corresponding = Pointer(EntityID).malloc(@size).to_slice(@size) 486 | @used.times do |i| 487 | @corresponding[i] = Cannon.decode(io, EntityID) 488 | end 489 | @raw = Pointer(T).malloc(@size).to_slice(@size) 490 | @used.times do |i| 491 | @raw[i] = Cannon.decode(io, T) 492 | end 493 | @cache_entity = NO_ENTITY 494 | @cache_index = -1 495 | end 496 | 497 | def clear(with_callbacks = false) 498 | if with_callbacks && @used > 0 && @raw[0].responds_to?(:when_removed) 499 | @used.times do |i| 500 | item = @raw[i] 501 | if item.responds_to?(:when_removed) 502 | item.when_removed(Entity.new(@world, @corresponding[i])) 503 | end 504 | end 505 | end 506 | super 507 | end 508 | end 509 | 510 | # Root level container for all entities / components, is iterated with `ECS::Systems` 511 | class World 512 | @free_entities = EntitiesList.new(DEFAULT_ENTITY_POOL_SIZE) 513 | protected getter count_components = Slice(UInt16).new(DEFAULT_ENTITY_POOL_SIZE) 514 | protected getter pools = Array(BasePool).new({{Component.all_subclasses.size}}) 515 | 516 | @@comp_can_be_multiple = Set(ComponentType).new 517 | 518 | protected def register_singleframe_deleter(typ) 519 | base_pool_for(typ).deleter_registered = true 520 | end 521 | 522 | # Creates new `Filter` and adds a condition to it 523 | delegate of, all_of, any_of, exclude, to: new_filter 524 | 525 | # Creates empty world 526 | def initialize 527 | init_pools 528 | end 529 | 530 | protected def can_be_multiple?(typ : ComponentType) 531 | @@comp_can_be_multiple.includes? typ 532 | end 533 | 534 | def inspect(io) 535 | io << "World{entities: " << entities_count << "}" 536 | end 537 | 538 | # total number of alive entities in a world 539 | def entities_count 540 | @free_entities.used_entities 541 | end 542 | 543 | # number of entities that could exist in a world before reallocation of pools 544 | def entities_capacity 545 | @free_entities.capacity 546 | end 547 | 548 | # Creates new entity in a world context. 549 | # Basically doesn't cost anything as it just increase entities counter. 550 | # Entity don't take up space without components. 551 | def new_entity 552 | if @free_entities.remaining <= 0 553 | n = @free_entities.capacity*2 554 | @free_entities.resize(n) 555 | @count_components = @count_components.to_unsafe.realloc(n).to_slice(n) 556 | @pools.each &.resize_sparse(n) 557 | end 558 | id = @free_entities.next_item 559 | @count_components[id] = ENTITY_EMPTY 560 | Entity.new(self, EntityID.new(id)) 561 | end 562 | 563 | # Creates new Filter. 564 | # This call can be skipped: 565 | # Instead of `world.new_filter.of(Comp1)` you can do `world.of(Comp1)` 566 | def new_filter 567 | Filter.new(self) 568 | end 569 | 570 | # Deletes all components and entities from the world 571 | def delete_all(with_callbacks = false) 572 | @pools.each &.clear(with_callbacks) 573 | @free_entities.clear 574 | @count_components.fill(ENTITY_DELETED) 575 | end 576 | 577 | # Iterates over all entities 578 | def each_entity(& : Entity ->) 579 | entities_capacity.times do |i| 580 | next if @count_components[i] <= ENTITY_EMPTY 581 | yield(Entity.new(self, EntityID.new(i))) 582 | end 583 | end 584 | 585 | private ENTITY_DELETED = 0u16 586 | private ENTITY_EMPTY = 1u16 587 | 588 | @[AlwaysInline] 589 | protected def inc_count_components(entity_id) 590 | raise Exception.new("adding component to deleted entity: #{entity_id}") if @count_components[entity_id] == ENTITY_DELETED 591 | @count_components[entity_id] &+= 1 592 | # raise "BUG: inc_count_components failed" if @count_components[entity_id] > pools.size 593 | end 594 | 595 | @[AlwaysInline] 596 | protected def dec_count_components(entity_id, dont_gc) 597 | # raise "BUG: dec_count_components failed" if @count_components[entity_id] <= ENTITY_EMPTY 598 | @count_components[entity_id] &-= 1 599 | if @count_components[entity_id] == ENTITY_EMPTY && !dont_gc 600 | @count_components[entity_id] = ENTITY_DELETED 601 | gc_entity(entity_id) 602 | end 603 | end 604 | 605 | # Returns true if at least one component of type `typ` exists in a world 606 | def component_exists?(typ) 607 | base_pool_for(typ).total_count > 0 608 | end 609 | 610 | # Returns SimpleFilter (stack-allocated) that can iterate over single component 611 | def query(typ) 612 | SimpleFilter.new(self, typ) 613 | end 614 | 615 | @[AlwaysInline] 616 | protected def check_gc_entity(entity) 617 | @free_entities.release(Int32.new(entity)) if @count_components[entity] == ENTITY_DELETED 618 | end 619 | 620 | @[AlwaysInline] 621 | protected def gc_entity(entity) 622 | @free_entities.release(Int32.new(entity)) 623 | end 624 | 625 | macro finished 626 | private def init_pools 627 | {% for index in 1..COMP_INDICES.size %} 628 | @pools << nil.unsafe_as(BasePool) 629 | {% end %} 630 | 631 | {% for obj, index in Component.all_subclasses %} 632 | {% if obj.annotation(ECS::Singleton) %} 633 | @pools[{{COMP_INDICES[obj]}}] = SingletonPool({{obj}}).new(self) 634 | {% else %} 635 | @pools[{{COMP_INDICES[obj]}}] = NormalPool({{obj}}).new(self) 636 | {% end %} 637 | 638 | 639 | {% if obj.annotation(ECS::Multiple) %} 640 | @@comp_can_be_multiple.add {{obj}} 641 | {% end %} 642 | {% end %} 643 | end 644 | 645 | {% for obj in Component.all_subclasses %} 646 | @[AlwaysInline] 647 | protected def pool_for(component : {{obj}}) : Pool({{obj}}) 648 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})) 649 | end 650 | 651 | {% if obj.annotation(ECS::Singleton) %} 652 | {% obj_name = obj.id.split("::").last.id %} 653 | def get{{obj_name}} 654 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component?(NO_ENTITY) || raise Exception.new("{{obj}} was not created") 655 | end 656 | 657 | def get{{obj_name}}? 658 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component?(NO_ENTITY) 659 | end 660 | 661 | def get{{obj_name}}_ptr 662 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).get_component_ptr(NO_ENTITY) 663 | end 664 | 665 | def add(comp : {{obj}}) 666 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).add_component(NO_ENTITY, comp) 667 | end 668 | 669 | def set(comp : {{obj}}) 670 | @pools[{{COMP_INDICES[obj]}}].as(Pool({{obj}})).add_or_update_component(NO_ENTITY, comp) 671 | end 672 | 673 | def remove(typ : {{obj}}.class) 674 | @pools[{{COMP_INDICES[obj]}}].remove_component(NO_ENTITY) 675 | end 676 | 677 | def remove_if_present(typ : {{obj}}.class) 678 | @pools[{{COMP_INDICES[obj]}}].try_remove_component(NO_ENTITY) 679 | end 680 | 681 | {% end %} 682 | 683 | {% end %} 684 | 685 | @[AlwaysInline] 686 | protected def base_pool_for(typ : ComponentType) 687 | @pools[typ.component_index] 688 | end 689 | 690 | # Non-allocating version of `stats`. Yields component names and count of corresponding components 691 | # ``` 692 | # world = init_benchmark_world(1000000) 693 | # world.stats do |comp_name, value| 694 | # puts "#{comp_name}: #{value}" 695 | # end 696 | # ``` 697 | def stats(& : String, Int32 ->) 698 | @pools.each do |pool| 699 | next if pool.total_count == 0 700 | yield(pool.name, pool.total_count) 701 | end 702 | end 703 | 704 | # Returns Hash containing count of components 705 | # ``` 706 | # world = init_benchmark_world(1000000) 707 | # puts world.stats # prints {"Comp1" => 500000, "Comp2" => 333334, "Comp3" => 200000, "Comp4" => 142858, "Config" => 1} 708 | # ``` 709 | def stats 710 | result = {} of String => Int32 711 | stats do |name, count| 712 | result[name] = count 713 | end 714 | result 715 | end 716 | end 717 | 718 | def encode(io) 719 | Cannon.encode(io, @free_entities) 720 | Cannon.encode(io, @count_components) 721 | @pools.each &.encode(io) 722 | end 723 | 724 | def decode(io) 725 | @free_entities = Cannon.decode(io, typeof(@free_entities)) 726 | @count_components = Cannon.decode(io, typeof(@count_components)) 727 | raise Exception.new("recurrent deserialization is not supported") if WORLD_BEING_LOADED.has_key?(Fiber.current) 728 | WORLD_BEING_LOADED[Fiber.current] = self 729 | begin 730 | @pools.each &.decode(io) 731 | ensure 732 | WORLD_BEING_LOADED.delete Fiber.current 733 | end 734 | end 735 | end 736 | 737 | # General filter class, contain methods existing both in `Filter` (fully functional filter) and `SimpleFilter` (simple stack-allocated filter) 738 | module AbstractFilter 739 | include Enumerable(Entity) 740 | 741 | # returns true if entity satisfy filter 742 | abstract def satisfy(entity : Entity) : Bool 743 | end 744 | 745 | # Stack allocated filter - can iterate over one component type. 746 | struct SimpleFilter 747 | include AbstractFilter 748 | @pool : BasePool 749 | 750 | # type of components that this filter iterate 751 | getter typ : ComponentType 752 | # world that owns this filtetr 753 | getter world : World 754 | 755 | # Creates SimpleFilter. An easier way is to do `world.query(typ)` 756 | def initialize(@world, @typ) 757 | @pool = @world.base_pool_for(@typ) 758 | end 759 | 760 | # returns true if entity satisfy filter (contain a component `typ`) 761 | def satisfy(entity : Entity) : Bool 762 | entity.has? @typ 763 | end 764 | 765 | # Returns number of entities that match the filter. (in fact - number of components `typ` in a world) 766 | def size 767 | @pool.total_count 768 | end 769 | 770 | # iterates over all entities containing component `typ` 771 | # Note that for `Multiple` same entity can be yielded multiple times, once for each component present on entity 772 | def each(& : Entity ->) 773 | @pool.each_entity do |entity| 774 | yield(Entity.new(@world, entity)) 775 | end 776 | end 777 | end 778 | 779 | # Allows to iterate over entities with specified conditions. 780 | # Created by call `world.new_filter` or just by adding any conditions to `world`. 781 | # Following conditions are possible: 782 | # - entity must have ALL listed components: `filter.all_of([Comp1, Comp2])`, `filter.of(Comp1)` 783 | # - entity must have AT LEAST ONE of listed components: `filter.any_of([Comp1, Comp2])` 784 | # - entity must have NONE of listed components: `filter.exclude([Comp1, Comp2])`, `filter.exclude(Comp1)` 785 | # - specified Proc must return true when called on entity: `filter.select { |ent| ent.getComp1.size > 1 }` 786 | # conditions can be specified in any order, multiple conditions of same type are allowed 787 | class Filter 788 | include AbstractFilter 789 | @all_of = [] of ComponentType 790 | @any_of = [] of Array(ComponentType) 791 | @exclude = [] of ComponentType 792 | @callbacks = [] of Proc(Entity, Bool) 793 | @all_multiple_component : ComponentType? 794 | @any_multiple_component_index : Int32? 795 | 796 | protected def initialize(@world : World) 797 | end 798 | 799 | # Adds a condition that entity must have ALL listed components. 800 | # Example: `filter.all_of([Comp1, Comp2])` 801 | def all_of(list) 802 | multiple = list.find { |typ| @world.can_be_multiple?(typ) } 803 | if multiple 804 | if list.count { |typ| @world.can_be_multiple?(typ) } > 1 805 | raise Exception.new("iterating over several Multiple isn't supported: #{list}") 806 | elsif old = @all_multiple_component 807 | raise Exception.new("iterating over several Multiple isn't supported: #{old} and #{multiple}") 808 | elsif old = @any_multiple_component_index 809 | raise Exception.new("iterating over several Multiple isn't supported: #{@any_of[old]} and #{multiple}") 810 | else 811 | @all_multiple_component = multiple 812 | end 813 | end 814 | @all_of.concat(list) 815 | self 816 | end 817 | 818 | # Adds a condition that entity must not have specified component. 819 | # Example: `filter.exclude(Comp1)` 820 | def exclude(item : ComponentType) 821 | @exclude << item 822 | self 823 | end 824 | 825 | # Adds a condition that entity must have NONE of listed components. 826 | # Example: `filter.exclude([Comp1, Comp2])` 827 | def exclude(list) 828 | @exclude.concat(list) 829 | self 830 | end 831 | 832 | # Adds a condition that entity must have specified component. 833 | # Example: `filter.of(Comp1)` 834 | def of(item : ComponentType) 835 | if @world.can_be_multiple?(item) 836 | if old = @all_multiple_component 837 | raise Exception.new("iterating over several Multiple isn't supported: #{old} and #{item}") 838 | elsif old = @any_multiple_component_index 839 | raise Exception.new("iterating over several Multiple isn't supported: #{@any_of[old]} and #{item}") 840 | else 841 | @all_multiple_component = item 842 | end 843 | end 844 | @all_of << item 845 | self 846 | end 847 | 848 | # Adds a condition that entity must have AT LEAST ONE of specified components. 849 | # Example: `filter.any_of([Comp1, Comp2])` 850 | def any_of(list) 851 | if list.size == 1 852 | return of(list.first) 853 | end 854 | raise Exception.new("any_of list can't be empty") if list.size == 0 855 | 856 | multiple = list.find { |typ| @world.can_be_multiple?(typ) } 857 | if multiple 858 | if list.count { |typ| @world.can_be_multiple?(typ) } > 1 859 | raise Exception.new("iterating over several Multiple isn't supported: #{list}") 860 | elsif old = @all_multiple_component 861 | raise Exception.new("iterating over several Multiple isn't supported: #{old} and #{multiple}") 862 | elsif old = @any_multiple_component_index 863 | raise Exception.new("iterating over several Multiple isn't supported: #{@any_of[old]} and #{multiple}") 864 | else 865 | @any_multiple_component_index = @any_of.size 866 | list = list.dup 867 | list.delete(multiple) 868 | list.unshift multiple 869 | end 870 | end 871 | 872 | @any_of << list.map { |x| x.as(ComponentType) } 873 | self 874 | end 875 | 876 | # Adds a condition that specified Proc must return true when called on entity. 877 | # Example: `filter.select { |ent| ent.getComp1.size > 1 }` 878 | def filter(&block : Entity -> Bool) 879 | @callbacks << block 880 | self 881 | end 882 | 883 | private def pass_any_of_filter(entity) : Bool 884 | @any_of.each do |list| 885 | return false if list.all? { |typ| !entity.has?(typ) } 886 | end 887 | true 888 | end 889 | 890 | private def pass_all_of_filter(entity) : Bool 891 | return false if @all_of.any? { |typ| !entity.has?(typ) } 892 | true 893 | end 894 | 895 | private def pass_exclude_and_select_filter(entity) : Bool 896 | return false if @exclude.any? { |typ| entity.has?(typ) } 897 | return false if @callbacks.any? { |cb| !cb.call(entity) } 898 | true 899 | end 900 | 901 | # Returns true if entity satisfy the filter 902 | def satisfy(entity : Entity) : Bool 903 | return pass_all_of_filter(entity) && pass_any_of_filter(entity) && pass_exclude_and_select_filter(entity) 904 | end 905 | 906 | private def already_processed_in_list(entity, list, index) 907 | index.times do |i| 908 | typ = list[i] 909 | return true if entity.has?(typ) 910 | end 911 | false 912 | end 913 | 914 | private def iterate_over_type(typ, & : Entity ->) 915 | @world.base_pool_for(typ).each_entity do |id| 916 | entity = Entity.new(@world, id) 917 | next unless satisfy(entity) 918 | yield(entity) 919 | end 920 | end 921 | 922 | private def iterate_over_list(list, & : Entity ->) 923 | list.each_with_index do |typ, index| 924 | @world.base_pool_for(typ).each_entity do |id| 925 | entity = Entity.new(@world, id) 926 | next if already_processed_in_list(entity, list, index) 927 | next unless satisfy(entity) 928 | yield(entity) 929 | end 930 | end 931 | end 932 | 933 | private def iterate_over_world(& : Entity ->) 934 | @world.each_entity do |entity| 935 | next unless satisfy(entity) 936 | yield(entity) 937 | end 938 | end 939 | 940 | # Calls a block once for each entity that match the filter. 941 | # Note that for `Multiple` same entity can be called multiple times, once for each component present on entity 942 | def each(& : Entity ->) 943 | smallest_all_count = 0 944 | smallest_any_count = 0 945 | smallest_all = nil 946 | smallest_any = nil 947 | 948 | if all = @all_multiple_component 949 | smallest_all = all 950 | smallest_any = nil 951 | elsif any = @any_multiple_component_index 952 | smallest_all = nil 953 | smallest_any = @any_of[any] 954 | else 955 | if @all_of.size > 0 956 | # we use all_of and find shortest pool 957 | smallest_all = @all_of.min_by { |typ| @world.base_pool_for(typ).total_count } 958 | smallest_all_count = @world.base_pool_for(smallest_all).total_count 959 | return if smallest_all_count == 0 960 | end 961 | if @any_of.size > 0 962 | smallest_any = @any_of.min_by do |list| 963 | list.sum(0) { |typ| @world.base_pool_for(typ).total_count } 964 | end 965 | smallest_any_count = smallest_any.sum(0) { |typ| @world.base_pool_for(typ).total_count } 966 | return if smallest_any_count == 0 967 | end 968 | end 969 | 970 | if smallest_all && (!smallest_any || smallest_all_count <= smallest_any_count) 971 | # iterate by smallest_all 972 | iterate_over_type(smallest_all) do |entity| 973 | yield(entity) 974 | end 975 | elsif smallest_any && (@any_multiple_component_index || smallest_any_count < @world.entities_count // 2) 976 | # iterate by smallest_any 977 | iterate_over_list(smallest_any) do |entity| 978 | yield(entity) 979 | end 980 | else 981 | # iterate everything 982 | iterate_over_world do |entity| 983 | yield(entity) 984 | end 985 | end 986 | end 987 | end 988 | 989 | # Сontainer for logic for processing filtered entities. 990 | # User systems should inherit from `ECS::System` 991 | # and implement `init`, `execute`, `teardown`, `filter`, `preprocess` and `process` (in any combination. Just skip methods you don't need). 992 | class System 993 | # Set `active` property to false to temporarily disable system 994 | property active = true 995 | 996 | # Constructor. Called before `init` 997 | def initialize(@world : ECS::World) 998 | end 999 | 1000 | # Will be called once during ECS::Systems.init call 1001 | def init 1002 | end 1003 | 1004 | # Will be called on each ECS::Systems.execute call 1005 | def execute 1006 | end 1007 | 1008 | # Will be called on each ECS::Systems.execute call, before `#process` and `#execute` 1009 | def preprocess 1010 | end 1011 | 1012 | # Will be called once during ECS::Systems.teardown call 1013 | def teardown 1014 | end 1015 | 1016 | # Called once during ECS::Systems.init, after #init call. 1017 | # If this method is present, it should return a filter that will be applied to a world 1018 | # It can also return `nil` that means that no filter is present and #process won't be called 1019 | # Example: 1020 | # ``` 1021 | # def filter(world : World) 1022 | # world.of(Component1) 1023 | # end 1024 | # ``` 1025 | def filter(world : World) : Filter? 1026 | nil 1027 | end 1028 | 1029 | # Called during each ECS::Systems.execute call, before #execute, for each entity that match the #filter 1030 | def process(entity : Entity) 1031 | end 1032 | end 1033 | 1034 | # This system deletes all components of specified type during execute. 1035 | # This is a recommended way of deleting `SingleFrame` components, 1036 | # as library can detect if such system exists and raise exception if it doesn't. 1037 | # Example: 1038 | # ``` 1039 | # systems.add(ECS::RemoveAllOf.new(@world, Component1))` 1040 | # ``` 1041 | # or use a shortcut: 1042 | # ``` 1043 | # systems.remove_singleframe(Component1) 1044 | # ``` 1045 | # 1046 | class RemoveAllOf < System 1047 | @typ : ComponentType 1048 | 1049 | # creates a system for a given `world` and components of type `typ` 1050 | def initialize(@world, @typ) 1051 | super(@world) 1052 | @world.register_singleframe_deleter(@typ) 1053 | end 1054 | 1055 | # :nodoc: 1056 | def filter(world) 1057 | @world.of(@typ) 1058 | end 1059 | 1060 | # :nodoc: 1061 | def process(entity) 1062 | entity.remove(@typ) 1063 | end 1064 | end 1065 | 1066 | # Group of systems to process `EcsWorld` instance. 1067 | # You can add Systems to Systems to create hierarchy. 1068 | # You can either create Systems directly or (preferred way) inherit from `ECS::Systems` to add systems in `initialize` 1069 | class Systems < System 1070 | # List of systems in a group. This list must not be modified directly. 1071 | # Instead, use `#add` to add systems to it and `ECS::System#active` to disable systems 1072 | getter children = [] of System 1073 | @filters = [] of Filter? 1074 | @cur_child : System? 1075 | 1076 | # creates empty `Systems` group. 1077 | # This method should be overriden in children to automatically add systems. 1078 | def initialize(@world : World) 1079 | @started = false 1080 | end 1081 | 1082 | # Adds system to a group 1083 | def add(sys : System) 1084 | children << sys 1085 | if @started 1086 | sys.init 1087 | @filters << sys.filter(@world).as(Filter | Nil) 1088 | end 1089 | self 1090 | end 1091 | 1092 | # Creates system of given class and adds it to a group 1093 | def add(sys : System.class) 1094 | add(sys.new(@world)) 1095 | end 1096 | 1097 | # Adds `RemoveAllOf` instance for specified component type 1098 | def remove_singleframe(typ) 1099 | add(ECS::RemoveAllOf.new(@world, typ)) 1100 | end 1101 | 1102 | # calls `init` for all children systems 1103 | # also initializes filters for children systems 1104 | def init 1105 | raise Exception.new("#{self.class} already initialized") if @started 1106 | @children.each do |child| 1107 | # puts "#{child.class.name}.init begin" 1108 | child.init 1109 | # puts "#{child.class.name}.init end" 1110 | end 1111 | @filters = @children.map { |x| x.filter(@world).as(Filter | Nil) } 1112 | @started = true 1113 | end 1114 | 1115 | # calls `preprocess`, `process` and `execute` for all active children 1116 | def execute 1117 | raise Exception.new("#{@children.map(&.class)} wasn't initialized") unless @started 1118 | @children.zip(@filters) do |sys, filter| 1119 | @cur_child = sys 1120 | next unless sys.active 1121 | sys.preprocess 1122 | if filter 1123 | filter.each { |ent| sys.process(ent) } 1124 | end 1125 | sys.execute 1126 | end 1127 | end 1128 | 1129 | # calls `teardown` for all children systems 1130 | def teardown 1131 | raise Exception.new("#{self.class} not initialized") unless @started 1132 | @children.each &.teardown 1133 | @started = false 1134 | end 1135 | end 1136 | 1137 | # prints total count of registered components and classes of systems. 1138 | macro debug_stats 1139 | {% puts "total components: #{Component.all_subclasses.size}" %} 1140 | {% puts " single frame: #{Component.all_subclasses.select { |x| x.annotation(SingleFrame) }.size}" %} 1141 | {% puts " multiple: #{Component.all_subclasses.select { |x| x.annotation(Multiple) }.size}" %} 1142 | {% puts " singleton: #{Component.all_subclasses.select { |x| x.annotation(Singleton) }.size}" %} 1143 | {% puts "total systems: #{System.all_subclasses.size}" %} 1144 | end 1145 | end 1146 | 1147 | # :nodoc: 1148 | class ECS::EntitiesList 1149 | include Cannon::Auto 1150 | @items : Array(Int32) 1151 | getter last_id = 0 1152 | getter capacity = 0 1153 | 1154 | protected def initialize(@items, @last_id, @capacity) 1155 | end 1156 | 1157 | def initialize(@capacity) 1158 | # initialize with each element pointing to next 1159 | @items = Array(Int32).new(capacity) 1160 | end 1161 | 1162 | def next_item 1163 | return @items.shift if @items.size > 0 1164 | raise Exception.new("out of capacity") if @last_id == @capacity 1165 | @last_id += 1 1166 | @last_id - 1 1167 | end 1168 | 1169 | def release(item : Int32) 1170 | @items.push(item) 1171 | end 1172 | 1173 | def resize(new_size) 1174 | raise Exception.new("shrinking list isn't supported") if new_size < @capacity 1175 | @capacity = new_size 1176 | end 1177 | 1178 | def clear 1179 | @last_id = 0 1180 | @items.clear 1181 | end 1182 | 1183 | def used_entities 1184 | @last_id - @items.size 1185 | end 1186 | 1187 | def remaining 1188 | @items.size + (@capacity - @last_id) 1189 | end 1190 | end 1191 | 1192 | class ECS::PagedArray 1193 | PAGE_SIZE = 8192 1194 | 1195 | @slices : Array(Pointer(SparseIndex)) 1196 | 1197 | def initialize(capacity) 1198 | n = capacity // PAGE_SIZE + 1 1199 | @slices = Array(Pointer(SparseIndex)).new(n, Pointer(SparseIndex).null) 1200 | end 1201 | 1202 | def resize(new_capacity) 1203 | n = new_capacity // PAGE_SIZE + 1 1204 | (n - @slices.size).times do 1205 | @slices << Pointer(SparseIndex).null 1206 | end 1207 | end 1208 | 1209 | def []=(index : Int, value : SparseIndex) 1210 | page, inpage = index.divmod(PAGE_SIZE) 1211 | ptr = @slices[page] 1212 | if ptr.null? 1213 | ptr = Pointer(SparseIndex).malloc(PAGE_SIZE) 1214 | ptr.to_slice(PAGE_SIZE).fill(-1) 1215 | @slices[page] = ptr 1216 | end 1217 | ptr[inpage] = value 1218 | end 1219 | 1220 | def [](index : Int) : SparseIndex 1221 | page, inpage = index.divmod(PAGE_SIZE) 1222 | ptr = @slices[page] 1223 | return -1 if ptr.null? 1224 | ptr[inpage] 1225 | end 1226 | 1227 | def clear 1228 | @slices.fill Pointer(SparseIndex).null 1229 | end 1230 | 1231 | def encode(io, total) 1232 | Cannon.encode(io, @slices.size) 1233 | cnt = 0 1234 | @slices.each_with_index do |ptr, page| 1235 | next if ptr.null? 1236 | ptr.to_slice(PAGE_SIZE).each_with_index do |v, inpage| 1237 | next if v == -1 1238 | i = page*PAGE_SIZE + inpage 1239 | Cannon.encode(io, i) 1240 | Cannon.encode(io, v) 1241 | cnt += 1 1242 | end 1243 | end 1244 | raise "BUG: failed to encode sparse" if cnt != total 1245 | Cannon.encode(io, -1) 1246 | end 1247 | 1248 | def decode(io, total) 1249 | clear 1250 | n = Cannon.decode(io, Int32) 1251 | @slices = Array(Pointer(SparseIndex)).new(n, Pointer(SparseIndex).null) 1252 | maxn = @slices.size * PAGE_SIZE 1253 | total.times do 1254 | i = Cannon.decode(io, Int32) 1255 | v = Cannon.decode(io, Int32) 1256 | raise Exception.new("failed to decode sparse") unless (0...maxn).includes? i 1257 | self[i] = v 1258 | end 1259 | raise Exception.new("incorrect sparse count") unless Cannon.decode(io, Int32) == -1 1260 | end 1261 | end 1262 | --------------------------------------------------------------------------------