game_ecs 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ececc23f2d9b4a5f22dd86c23315acc3aec9e1d7
4
+ data.tar.gz: d1c43248bcf8feed51969521b8b1b0e08917eb86
5
+ SHA512:
6
+ metadata.gz: 182736984659f36cb814ff787560e0e18f210f7399f8cce7f29a981191ed926fac45487d2caba78f923b9e2c6ec7e54ce16f1e85a3a90987e5587fcb69faa959
7
+ data.tar.gz: f42a0e425250cc83998c11027b7b6cdc3d28987ac51dc460fe1b7acdded220e4570708a959b744d4877938ebd839cd75c2bc5b182abd27eb62fe28c58452620e
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ Gemfile.lock
11
+
12
+ # rspec failure tracking
13
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.1
5
+ before_install: gem install bundler -v 1.16.1
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in game_ecs.gemspec
6
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Shawn Anderson
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.
@@ -0,0 +1,254 @@
1
+ # GameEcs
2
+
3
+ An easy to use Entity Component System library for use in game development. Learn more about ECS here:
4
+ * [Evolve Your Heirachy](http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/)
5
+ * [Wikipedia](https://en.wikipedia.org/wiki/Entity%E2%80%93component%E2%80%93system)
6
+
7
+ Getting Started
8
+
9
+ * [Installation](#installation)
10
+ * [Usage](#usage)
11
+ * [Components](#components)
12
+ * [Creating Entities](#creating-entities)
13
+ * [Add/Remove Components](#adding-and-removing-components)
14
+ * [Finding Entities](#finding-entities)
15
+ * [Updating Components](#updating-components)
16
+ * [Advanced Querying](#advanced-querying)
17
+ * [Big Picture](#big-picture)
18
+ * [Notes](#notes)
19
+
20
+ ### Adding and Removing Components
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem 'game_ecs'
28
+ ```
29
+
30
+ And then execute:
31
+
32
+ $ bundle
33
+
34
+ Or install it yourself as:
35
+
36
+ $ gem install game_ecs
37
+
38
+ ## Usage
39
+
40
+ ### Components
41
+
42
+ Components in GameEcs are ordinary Ruby classes. In most cases, they should be struct-like classes with `attr_accessor` properties only. Adding default values in the constructor is as advanced as these objects should get.
43
+
44
+ ```ruby
45
+ # Example components
46
+ class Position
47
+ attr_accessor :x, :y
48
+ def initialize(x:0,y:0)
49
+ @x = x
50
+ @y = y
51
+ end
52
+ end
53
+
54
+ class Tag
55
+ attr_accessor :name
56
+ def initialize(name:)
57
+ @name = name
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### Creating Entities
63
+
64
+ An Entity is simply a collection of Components joined together by an id. To create one, simply call the `add_entity` method with a list of the Components you want the Entity to initially have:
65
+
66
+ ```ruby
67
+ # Create and save your store in your higher level Game / State class
68
+ store = GameEcs::EntityStore.new
69
+
70
+ # Creating an entity returns its id.
71
+ # In most cases, you will not need to keep this id around.
72
+ ent_id = store.add_entity(Position.new(x:1,y:3), Tag.new(name:"Player"))
73
+ ```
74
+
75
+ I recommend creating an `EntityFactory` or `Prefab` class to store factory methods that know how to build each kind of entity:
76
+
77
+ ```ruby
78
+ class Prefab
79
+ def self.player_at(store:, x:,y:)
80
+ store.add_entity(Position.new(x:x,y:y), Tag.new(name:"Player"))
81
+ end
82
+
83
+ def self.tank(*args)
84
+ # ...
85
+ end
86
+
87
+ # etc
88
+ end
89
+ ```
90
+
91
+ ### Adding and Removing Components
92
+
93
+ The great thing about ECS is the ability to add/remove components at runtime. Here's how to do it:
94
+
95
+ ```ruby
96
+ # add a Color component
97
+ store.add_component(id: ent_id, component: Color.new(red: 255, green: 255, blue: 0))
98
+
99
+ # we remove by class
100
+ store.remove_component(id: ent_id, klass: Color)
101
+
102
+ # remove an entire entity
103
+ store.remove_entity(id: ent_id)
104
+
105
+ # remove many entities
106
+ store.remove_entities(ids: list_of_ids)
107
+ ```
108
+
109
+ ### Finding Entities
110
+
111
+ There are two main ways of finding the entities you want. You can ask for them directly by id or you can search for them by Components.
112
+
113
+ #### By Id
114
+ Finding by id is nice if you are looking for a single entity. You merely specify the id and the components you want available for modification. If the id does not exist or the entity does not have one of the specified components, `nil` is returned.
115
+
116
+ ```ruby
117
+ ent = store.find_by_id(ent_id, Position, Color)
118
+ id = ent.id
119
+ pos, color = ent.components
120
+ ```
121
+
122
+ #### Querying by Components
123
+
124
+ GameEcs has a `Query` class that can be used for more advanced queries, but the most common case is that you want all enitities that have all the components you're interested in. `musts` is short had for building these types of queries:
125
+
126
+ ```ruby
127
+ ents_that_need_move = store.musts(Position, Velocity)
128
+ ents_that_need_move.each do |ent|
129
+ pos,vel = ent.components
130
+ # modify pos based on vel
131
+ end
132
+ ```
133
+
134
+ This pattern of find the ents and loop over them is so common there is a helper that does just that called `each_entity`:
135
+
136
+ ```ruby
137
+ store.each_entity(Position, Velocity) do |ent|
138
+ pos,vel = ent.components
139
+ # modify pos based on vel
140
+ end
141
+ ```
142
+
143
+ ### Updating Components
144
+
145
+ Once you've got hold of an "entity" from the store. You can access the components you queried for via the `components` method on the entity. Once you have it, you can directly modify its values.
146
+
147
+ ```ruby
148
+ store.each_entity(Position, Velocity) do |ent|
149
+ pos,vel = ent.components
150
+ pos.x += vel.x * time_scale
151
+ pos.y += vel.y * time_scale
152
+ end
153
+ ```
154
+
155
+ ### Advanced Querying
156
+
157
+ `each_entity` and `musts` are really shorthand for creating `GameEcs::Query` objects and passing it to the `query` method. Let's look at the longhand version; the following two lines are synonymous:
158
+
159
+ ```ruby
160
+ ents = store.query(Query.must(Position).must(Color))
161
+ ents = store.musts(Position, Color)
162
+ ```
163
+
164
+ By using the `Query` directly, we can add in `maybe` cases. A Maybe will still match if the entity does not have the desired component, but will return nil for that component.
165
+
166
+
167
+ ```ruby
168
+ store.query(Query.must(Position).maybe(Color)).each do |ent|
169
+ # color may be nil
170
+ pos,color = ent.components
171
+ end
172
+ ```
173
+
174
+ #### Experimental!
175
+ We can also query based on components' values using `with`:
176
+
177
+ ```ruby
178
+ # Only entities with a Position component with x val == 12 will be returned
179
+ store.query(Query.must(Position).with(x: 12).must(Color)).each do |ent|
180
+ pos,color = ent.components
181
+ end
182
+ ```
183
+
184
+ We can also use lambdas to determine if a value matches:
185
+ ```ruby
186
+ # Only entities with a Position less than 12 will be returned
187
+ store.query(Query.must(Position).with(x: ->(x){ x < 12 }).must(Color)).each do |ent|
188
+ pos,color = ent.components
189
+ end
190
+ ```
191
+
192
+ *_!! DANGER !!_*
193
+
194
+ Currently the caching mechanism in GameEcs does not know if the value of a component has changed since it was cached. Only use this for component values that do not change often, or clear your cache to get the results to update. The rough plan here is to eventually change components to be more of a DSL and have them notify the store on value changes of interest (If any queries care about the change)
195
+
196
+ ### Big Picture
197
+
198
+ `EntityStore` is meant to be constructed and passed into a list of processing systems. This gem is entirely agnostic to how you implement your Game and Systems. A quick example _could_ look like:
199
+
200
+ ```ruby
201
+ class Game
202
+ def intialize
203
+ @store = GameEcs::EntityStore.new
204
+ @render_system = RenderSystem.new(@store)
205
+ @systems = [
206
+ MovementSystem.new(@store),
207
+ # .. other systems
208
+ @render_system
209
+ ]
210
+ end
211
+
212
+ def update(time_delta, inputs)
213
+ @systems.each{|sys| sys.update(time_delta, inputs) }
214
+ end
215
+
216
+ def draw
217
+ @render_system.render
218
+ end
219
+ end
220
+
221
+ class MovementSystem
222
+ def initialize(store)
223
+ @store = store
224
+ end
225
+
226
+ def update(dt, inputs)
227
+ @store.each_entity(Position, Velocity) do |ent|
228
+ pos,vel = ent.components
229
+ pos.x += vel.x * dt
230
+ pos.y += vel.y * dt
231
+ end
232
+ end
233
+ end
234
+
235
+
236
+ ```
237
+ For a more fully fleshed out game using ECS in this way, checkout [Pixel Monster](https://github.com/shawn42/pixel_monster)
238
+
239
+
240
+ ### Notes
241
+
242
+ * entities can only have one instance of each component type
243
+ * adding/removing entities and components is delayed until all iterating code has finished (calls to `each_entity`).
244
+ * all queries are cached by default calling `clear_cache!` will reset the cache
245
+ * to dump all entities and components from the store, use `clear!`
246
+
247
+
248
+ ## Contributing
249
+
250
+ Bug reports and pull requests are welcome on GitHub at https://github.com/shawn42/game_ecs.
251
+
252
+ ## License
253
+
254
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,56 @@
1
+ # TODO docs here
2
+ class TimerSystem
3
+ def update(store, inputs)
4
+ current_time_ms = inputs[:total_time]
5
+ store.each_entity Timer do |rec|
6
+ timer = rec.get(Timer)
7
+ ent_id = rec.id
8
+
9
+ if timer.expires_at
10
+ if timer.expires_at < current_time_ms
11
+ if timer.event
12
+ event_comp = timer.event.is_a?(Class) ? timer.event.new : timer.event
13
+ store.add_component component: event_comp, id: ent_id
14
+ end
15
+ if timer.repeat
16
+ timer.expires_at = current_time_ms + timer.total
17
+ else
18
+ store.remove_component(klass: timer.class, id: ent_id)
19
+ end
20
+ end
21
+ else
22
+ timer.expires_at = current_time_ms + timer.total
23
+ end
24
+ end
25
+ end
26
+ end
27
+ class Timer
28
+ attr_accessor :ttl, :repeat, :total, :event, :name, :expires_at
29
+ def initialize(name, ttl, repeat, event = nil)
30
+ @name = name
31
+ @total = ttl
32
+ @ttl = ttl
33
+ @repeat = repeat
34
+ @event = event
35
+ end
36
+ end
37
+
38
+ class SoundSystem
39
+ def preload(asset)
40
+ @assets ||= {}
41
+ @assets[asset] = Gosu::Sample.new(asset)
42
+ end
43
+ def update(store, inputs)
44
+ store.each_entity SoundEffectEvent do |rec|
45
+ ent_id = rec.id
46
+ effect = rec.get(SoundEffectEvent)
47
+ store.remove_component klass: effect.class, id: ent_id
48
+ if @assets[effect.sound_to_play]
49
+ @assets[effect.sound_to_play].play
50
+ else
51
+ puts "Warning: #{effect.sound_to_play} is missing!"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ SoundEffectEvent = Struct.new :sound_to_play
@@ -0,0 +1,53 @@
1
+ module GameEcs
2
+ class SimpleSystem
3
+ def self.from_query(query, &block)
4
+ new(query, &block)
5
+ end
6
+
7
+ def initialize(query=Query.none, &block)
8
+ @query = query
9
+ @system_block = block
10
+ end
11
+
12
+ def update(entity_store, *args)
13
+ before_update(entity_store, *args)
14
+ entity_store.query.each do |ent|
15
+ each(ent, *args)
16
+ end
17
+ after_update(entity_store, *args)
18
+ end
19
+
20
+ def before_update(entity_store, *args)
21
+ end
22
+ def after_update(entity_store, *args)
23
+ end
24
+ def update_each(entity_store, ent, *args)
25
+ # handle a single update of an ent
26
+ @system_block.call(entity_store, ent, *args)
27
+ end
28
+ end
29
+
30
+ end
31
+
32
+ if $0 == __FILE__
33
+ include GameEcs
34
+ MovementSystem = SimpleSystem.from_query( Q.musts(Position, Velocity) ) do |store, ent, dt, input|
35
+ pos,vel = ent.components
36
+ pos.x += vel.x * dt
37
+ pos.y += vel.y * dt
38
+ end
39
+
40
+ # long hand via subclassing, allows for before/after overrides
41
+ class MovementSystem < SimpleSystem
42
+ def after_update(store, *args)
43
+ log "MovementSystem finished: #{Time.now}"
44
+ end
45
+ def update_each(store, ent, dt, input)
46
+ Q.each_entity(Position, Velocity) do
47
+ pos,vel = ent.components
48
+ pos.x += vel.x * dt
49
+ pos.y += vel.y * dt
50
+ end
51
+ end
52
+ end
53
+ end
Binary file
@@ -0,0 +1,215 @@
1
+ $: << '../lib'
2
+ $: << '../contrib'
3
+ require_relative '../lib/game_ecs'
4
+ require_relative '../contrib/sample_systems_components'
5
+
6
+ require 'gosu'
7
+ include Gosu
8
+ Q = GameEcs::Query
9
+
10
+ class CoinGetterGame < Window
11
+ def initialize
12
+ super(800,600)
13
+ @entity_store = GameEcs::EntityStore.new
14
+ @render_system = RenderSystem.new
15
+ @sound_system = SoundSystem.new
16
+ @sound_system.preload('coin.wav')
17
+ @downs = []
18
+
19
+ @systems = [
20
+ ControlSystem.new,
21
+ MotionSystem.new,
22
+ TimerSystem.new,
23
+ CoinSystem.new,
24
+ @sound_system,
25
+ @render_system
26
+ ]
27
+
28
+ Prefab.player(@entity_store, 400, 300)
29
+ 10.times do
30
+ Prefab.coin(@entity_store, rand(50..750), rand(50..550))
31
+ end
32
+ Prefab.coin_gen_timer(@entity_store)
33
+ end
34
+
35
+ def button_down(id)
36
+ close if id == KbEscape
37
+ @downs << id
38
+ end
39
+ def button_up(id)
40
+ @downs.delete id
41
+ end
42
+
43
+ def update
44
+ @systems.each do |sys|
45
+ sys.update(@entity_store, {dt: relative_delta, down_ids: @downs, total_time: Gosu::milliseconds})
46
+ end
47
+ self.caption = "FPS: #{Gosu.fps}"
48
+ end
49
+
50
+ def draw
51
+ @render_system.draw(@entity_store, self)
52
+ end
53
+
54
+
55
+ private
56
+
57
+ MAX_UPDATE_SIZE_IN_MILLIS = 500
58
+ def relative_delta
59
+ total_millis = Gosu::milliseconds.to_f
60
+ @last_millis ||= total_millis
61
+ delta = total_millis
62
+ delta -= @last_millis if total_millis > @last_millis
63
+ @last_millis = total_millis
64
+ delta = MAX_UPDATE_SIZE_IN_MILLIS if delta > MAX_UPDATE_SIZE_IN_MILLIS
65
+ delta
66
+ end
67
+ end
68
+
69
+ class Prefab
70
+ def self.coin(store, x, y)
71
+ store.add_entity(Position.new(x,y,1), Velocity.new(0.5-rand,0.5-rand), Color.new(:green), Size.new(10), Tag.new("coin") )
72
+ end
73
+
74
+ def self.player(store, x, y)
75
+ store.add_entity(Position.new(400,40,3), Tag.new("p1"), Score.new(0))
76
+ store.add_entity(Position.new(x,y,2), Velocity.new(0,0), Color.new(:red), Tag.new("p1"), Input.new, Size.new(20))
77
+ end
78
+
79
+ def self.coin_gen_timer(store)
80
+ store.add_entity(Timer.new(:coin_gen, 2000, true, GenerateNewCoinEvent))
81
+ end
82
+ end
83
+
84
+
85
+ # Systems
86
+ class ControlSystem
87
+ def update(store, inputs)
88
+ downs = inputs[:down_ids]
89
+ player_one = store.query(Q.must(Input).must(Tag).with(name: "p1")).first.get(Input)
90
+ player_one.left = downs.include?(KbLeft) || downs.include?(GpLeft)
91
+ player_one.right = downs.include?(KbRight) || downs.include?(GpRight)
92
+ player_one.up = downs.include?(KbUp) || downs.include?(GpUp)
93
+ player_one.down = downs.include?(KbDown) || downs.include?(GpDown)
94
+ end
95
+ end
96
+ class MotionSystem
97
+ MAX_VELOCITY = 2
98
+ ACCEL = 0.03
99
+ FRICTION = 0.96
100
+ def update(store, inputs)
101
+ time_scale = inputs[:dt] * 0.01
102
+ store.each_entity(Input, Velocity) do |ent|
103
+ input, vel = ent.components
104
+ vel.dx += ACCEL*time_scale if input.right
105
+ vel.dx -= ACCEL*time_scale if input.left
106
+ vel.dy += ACCEL*time_scale if input.down
107
+ vel.dy -= ACCEL*time_scale if input.up
108
+
109
+ vel.dx *= FRICTION
110
+ vel.dy *= FRICTION
111
+
112
+ mag = vec_mag(vel.dx, vel.dy)
113
+ if mag > MAX_VELOCITY
114
+ vel.dx, vel.dy = vec_clip_to_mag(vel.dx, vel.dy, MAX_VELOCITY)
115
+ end
116
+ end
117
+
118
+ store.each_entity(Position, Velocity) do |ent|
119
+ pos,vel = ent.components
120
+ pos.x += vel.dx*time_scale
121
+ pos.y += vel.dy*time_scale
122
+ pos.x %= 800
123
+ pos.y %= 600
124
+ end
125
+ end
126
+ def vec_mag(x,y)
127
+ Math.sqrt(x*x + y*y)
128
+ end
129
+ def vec_clip_to_mag(x,y,max_mag)
130
+ mag = vec_mag(x,y)
131
+ [x.to_f/mag*max_mag, y.to_f/mag*max_mag]
132
+ end
133
+ end
134
+
135
+ class RenderSystem
136
+ def initialize
137
+ @colors = {
138
+ red: Gosu::Color::RED,
139
+ green: Gosu::Color::GREEN
140
+ }
141
+ @font = Gosu::Font.new(40)
142
+ end
143
+ def update(store,inputs); end
144
+ def draw(store, target)
145
+ store.each_entity(Position, Score) do |ent|
146
+ pos, score = ent.components
147
+ @font.draw("#{score.points}", pos.x, pos.y, pos.z, 1.0, 1.0, Gosu::Color::WHITE)
148
+ end
149
+ store.each_entity(Position, Color, Size) do |ent|
150
+ pos, col, size = ent.components
151
+ w = size.width
152
+ c1 = c2 = c3 = c4 = @colors[col.name]
153
+ x1 = pos.x
154
+ x2 = pos.x + w
155
+ x3 = x2
156
+ x4 = x1
157
+
158
+ y1 = pos.y
159
+ y2 = y1
160
+ y3 = pos.y + w
161
+ y4 = y3
162
+
163
+ target.draw_quad(x1, y1, c1, x2, y2, c2, x3, y3, c3, x4, y4, c4, pos.z)
164
+ end
165
+ end
166
+ end
167
+
168
+ class CoinSystem
169
+ def update(store, inputs)
170
+ p1_score = store.query(Q.must(Score).must(Tag).with(name: "p1")).first
171
+ p1_score, _ = p1_score.components
172
+ p1 = store.query(Q.must(Position).must(Size).must(Tag).with(name: "p1")).first
173
+ p1_pos, p1_size, _ = p1.components
174
+
175
+ store.query(Q.must(Position).must(Size).must(Tag).with(name:"coin")).each do |coin|
176
+ coin_pos, coin_size, _ = coin.components
177
+ if (coin_pos.x >= p1_pos.x &&
178
+ coin_pos.x <= p1_pos.x + p1_size.width) ||
179
+ (coin_pos.x + coin_size.width >= p1_pos.x &&
180
+ coin_pos.x + coin_size.width <= p1_pos.x + p1_size.width)
181
+
182
+ if (coin_pos.y >= p1_pos.y &&
183
+ coin_pos.y <= p1_pos.y + p1_size.width) ||
184
+ (coin_pos.y + coin_size.width >= p1_pos.y &&
185
+ coin_pos.y + coin_size.width <= p1_pos.y + p1_size.width)
186
+
187
+ store.remove_entity(id: coin.id)
188
+ p1_score.points += 1
189
+ store.add_entity SoundEffectEvent.new('coin.wav')
190
+ end
191
+ end
192
+ end
193
+ store.each_entity(GenerateNewCoinEvent) do |ent|
194
+ Prefab.coin(store, rand(50..750), rand(50..550))
195
+ store.remove_component(klass: GenerateNewCoinEvent, id: ent.id)
196
+ end
197
+ end
198
+ end
199
+
200
+ # Components
201
+ class Input
202
+ attr_accessor :left, :right, :up, :down
203
+ end
204
+ GenerateNewCoinEvent = Class.new
205
+ Tag = Struct.new :name
206
+ Position = Struct.new :x, :y, :z
207
+ Size = Struct.new :width
208
+ Velocity = Struct.new :dx, :dy
209
+ Color = Struct.new :name
210
+ Score = Struct.new :points
211
+
212
+ if $0 == __FILE__
213
+ game = CoinGetterGame.new
214
+ game.show
215
+ end
@@ -0,0 +1,28 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "game_ecs/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "game_ecs"
8
+ spec.version = GameEcs::VERSION
9
+ spec.authors = ["Shawn Anderson"]
10
+ spec.email = ["shawn42@gmail.com"]
11
+
12
+ spec.summary = %q{Entity Component System architecture in Ruby}
13
+ spec.description = %q{Entity Component System architecture in Ruby}
14
+ spec.homepage = "https://github.com/shawn42/game_ecs"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.16"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+ spec.add_development_dependency "pry", "~> 0.12.0"
28
+ end
@@ -0,0 +1,5 @@
1
+ require_relative "./game_ecs/version"
2
+ require_relative "./game_ecs/entity_store"
3
+
4
+ module GameEcs
5
+ end
@@ -0,0 +1,469 @@
1
+ require 'forwardable'
2
+ require 'set'
3
+
4
+ module GameEcs
5
+ class EntityStore
6
+ attr_reader :entity_count, :id_to_comp
7
+ def initialize
8
+ clear!
9
+ end
10
+
11
+ def deep_clone
12
+ # NOTE! does not work for Hashes with default procs
13
+ if _iterating?
14
+ raise "AHH! EM is still iterating!!"
15
+ else
16
+ _apply_updates
17
+ clear_cache!
18
+ em = Marshal.load( Marshal.dump(self) )
19
+ em
20
+ end
21
+ end
22
+
23
+ def num_entities
24
+ @id_to_comp.keys.size
25
+ end
26
+
27
+ def clear!
28
+ @comp_to_id = {}
29
+ @id_to_comp = {}
30
+ @cache = {}
31
+ @entity_count = 0
32
+
33
+ @iterator_count = 0
34
+ @ents_to_add_later = []
35
+ @comps_to_add_later = []
36
+ @comps_to_remove_later = []
37
+ @ents_to_remove_later = []
38
+ clear_cache!
39
+ end
40
+
41
+ def clear_cache!
42
+ @cache = {}
43
+ end
44
+
45
+ def find_by_id(id, *klasses)
46
+ return nil unless @id_to_comp.key? id
47
+ ent_record = @id_to_comp[id]
48
+ components = ent_record.values_at(*klasses)
49
+ rec = build_record(id, @id_to_comp[id], klasses) unless components.any?(&:nil?)
50
+ if block_given?
51
+ yield rec
52
+ else
53
+ rec
54
+ end
55
+ end
56
+
57
+ def musts(*klasses)
58
+ raise "specify at least one component" if klasses.empty?
59
+ q = Q
60
+ klasses.each{|k| q = q.must(k)}
61
+ query(q)
62
+ end
63
+ alias find musts
64
+
65
+ def query(q)
66
+ # TODO cache results as q with content based cache
67
+ # invalidate cache based on queried_comps
68
+ cache_hit = @cache[q]
69
+ return cache_hit if cache_hit
70
+
71
+ queried_comps = q.components
72
+ required_comps = q.required_components
73
+
74
+ required_comps.each do |k|
75
+ @comp_to_id[k] ||= Set.new
76
+ end
77
+
78
+ intersecting_ids = []
79
+ unless required_comps.empty?
80
+ id_collection = @comp_to_id.values_at(*required_comps)
81
+ intersecting_ids = id_collection.sort_by(&:size).inject &:&
82
+ end
83
+
84
+ recs = intersecting_ids.
85
+ select{|eid| q.matches?(eid, @id_to_comp[eid]) }.
86
+ map do |eid|
87
+ build_record eid, @id_to_comp[eid], queried_comps
88
+ end
89
+ result = QueryResultSet.new(records: recs, ids: recs.map(&:id))
90
+
91
+ @cache[q] = result if q.cacheable?
92
+ result
93
+ end
94
+
95
+ def first(*klasses)
96
+ find(*klasses).first
97
+ end
98
+
99
+ def each_entity(*klasses, &blk)
100
+ ents = find(*klasses)
101
+ if block_given?
102
+ _iterating do
103
+ ents.each &blk
104
+ end
105
+ end
106
+ ents
107
+ end
108
+
109
+ def remove_component(klass:, id:)
110
+ if _iterating?
111
+ _remove_component_later klass: klass, id: id
112
+ else
113
+ _remove_component klass: klass, id: id
114
+ end
115
+ end
116
+
117
+ def add_component(component:,id:)
118
+ if _iterating?
119
+ _add_component_later component: component, id: id
120
+ else
121
+ _add_component component: component, id: id
122
+ end
123
+ end
124
+
125
+ def remove_entites(ids:)
126
+ if _iterating?
127
+ _remove_entities_later(ids: ids)
128
+ else
129
+ _remove_entites(ids: ids)
130
+ end
131
+ end
132
+
133
+ def remove_entity(id:)
134
+ if _iterating?
135
+ _remove_entity_later(id: id)
136
+ else
137
+ _remove_entity(id: id)
138
+ end
139
+ end
140
+
141
+ def add_entity(*components)
142
+ id = generate_id
143
+ if _iterating?
144
+ _add_entity_later(id:id, components: components)
145
+ else
146
+ _add_entity(id: id, components: components)
147
+ end
148
+ id
149
+ end
150
+
151
+ private
152
+ def _add_entity_later(id:,components:)
153
+ @ents_to_add_later << {components: components, id: id}
154
+ end
155
+ def _remove_entities_later(ids:)
156
+ ids.each do |id|
157
+ @ents_to_remove_later << id
158
+ end
159
+ end
160
+ def _remove_entity_later(id:)
161
+ @ents_to_remove_later << id
162
+ end
163
+
164
+ def _remove_component_later(klass:,id:)
165
+ @comps_to_remove_later << {klass: klass, id: id}
166
+ end
167
+ def _add_component_later(component:,id:)
168
+ @comps_to_add_later << {component: component, id: id}
169
+ end
170
+
171
+ def _apply_updates
172
+ _remove_entites ids: @ents_to_remove_later
173
+ @ents_to_remove_later.clear
174
+
175
+ @comps_to_remove_later.each do |opts|
176
+ _remove_component klass: opts[:klass], id: opts[:id]
177
+ end
178
+ @comps_to_remove_later.clear
179
+
180
+ @comps_to_add_later.each do |opts|
181
+ _add_component component: opts[:component], id: opts[:id]
182
+ end
183
+ @comps_to_add_later.clear
184
+
185
+ @ents_to_add_later.each do |opts|
186
+ _add_entity id: opts[:id], components: opts[:components]
187
+ end
188
+ @ents_to_add_later.clear
189
+ end
190
+
191
+ def _iterating
192
+ @iterator_count += 1
193
+ yield
194
+ @iterator_count -= 1
195
+ _apply_updates unless _iterating?
196
+ end
197
+
198
+ def _iterating?
199
+ @iterator_count > 0
200
+ end
201
+
202
+ def _add_component(component:,id:)
203
+ raise "Cannot add nil component" if component.nil?
204
+
205
+ @comp_to_id[component.class] ||= Set.new
206
+ @comp_to_id[component.class] << id
207
+ @id_to_comp[id] ||= {}
208
+ ent_record = @id_to_comp[id]
209
+ klass = component.class
210
+
211
+ raise "Cannot add component twice! #{component} -> #{id}" if ent_record.has_key? klass
212
+ ent_record[klass] = component
213
+
214
+ @cache.each do |q, results|
215
+ # TODO make results a smart result set that knows about ids to avoid the linear scan
216
+ # will musts vs maybes help here?
217
+ comp_klasses = q.components
218
+ if comp_klasses.include?(klass)
219
+ if results.has_id?(id)
220
+ results.add_component(id: id, component: component)
221
+ else
222
+ results << build_record(id, ent_record, comp_klasses) if q.matches?(id, ent_record)
223
+ end
224
+ end
225
+ end
226
+ nil
227
+ end
228
+
229
+ def _remove_component(klass:, id:)
230
+ @comp_to_id[klass] ||= Set.new
231
+ @comp_to_id[klass].delete id
232
+ @id_to_comp[id] ||= {}
233
+ @id_to_comp[id].delete klass
234
+
235
+ @cache.each do |q, results|
236
+ comp_klasses = q.components
237
+ if comp_klasses.include?(klass)
238
+ results.delete(id: id) unless q.matches?(id, @id_to_comp[id])
239
+ end
240
+ end
241
+ nil
242
+ end
243
+
244
+ def _remove_entites(ids:)
245
+ return if ids.empty?
246
+
247
+ ids.each do |id|
248
+ @id_to_comp.delete(id)
249
+ end
250
+
251
+ @comp_to_id.each do |_klass, ents|
252
+ ents.delete_if{|ent_id| ids.include? ent_id}
253
+ end
254
+
255
+ @cache.each do |comp_klasses, results|
256
+ results.delete ids: ids
257
+ end
258
+ end
259
+
260
+ def _remove_entity(id:)
261
+ comp_map = @id_to_comp[id]
262
+ if @id_to_comp.delete(id)
263
+ ent_comps = comp_map.keys
264
+ ent_comps.each do |klass|
265
+ @comp_to_id[klass].delete id
266
+ end
267
+ @cache.each do |_query, results|
268
+ results.delete id: id
269
+ end
270
+ end
271
+ end
272
+
273
+ def _add_entity(id:, components:)
274
+ components.each do |comp|
275
+ _add_component component: comp, id: id
276
+ end
277
+ id
278
+ end
279
+
280
+ def generate_id
281
+ @entity_count += 1
282
+ @ent_counter ||= 0
283
+ @ent_counter += 1
284
+ end
285
+
286
+ def build_record(*args)
287
+ EntityQueryResult.new(*args)
288
+ end
289
+
290
+ class QueryResultSet
291
+ def initialize(records:, ids:)
292
+ @records = records
293
+ @ids = Set.new(ids)
294
+ end
295
+ def <<(rec)
296
+ @ids << rec.id
297
+ @records << rec
298
+ end
299
+ def has_id?(id)
300
+ @ids.include? id
301
+ end
302
+ def add_component(id:, component:)
303
+ index = @records.index{ |rec| id == rec&.id }
304
+ @records[index].update_component(component) if index >= 0
305
+ end
306
+ def delete(id:nil, ids:nil)
307
+ if id
308
+ @records.delete_at(@records.index{ |rec| id == rec&.id } || @records.size) if @ids.include? id
309
+ @ids.delete id
310
+ else
311
+ unless (@ids & ids).empty?
312
+ # ids.each do |id|
313
+ # @ids.delete id
314
+ # end
315
+ @ids = @ids - ids
316
+ @records.delete_if{|res| ids.include? res.id}
317
+ end
318
+ end
319
+ end
320
+ def each
321
+ @records.each do |rec|
322
+ yield rec
323
+ end
324
+ end
325
+ extend ::Forwardable
326
+ def_delegators :@records, :first, :any?, :size, :select, :find, :empty?, :first, :map, :[]
327
+
328
+ end
329
+
330
+ class EntityQueryResult
331
+ attr_reader :id
332
+ def initialize(id, components, queried_components)
333
+ @id = id
334
+ @components = components
335
+ @queried_components = queried_components
336
+ end
337
+
338
+ def get(klass)
339
+ @components[klass]
340
+ end
341
+
342
+ def update_component(component)
343
+ @components[component.class] = component
344
+ @comp_cache = comp_cache
345
+ end
346
+
347
+ def components
348
+ @comp_cache ||= comp_cache
349
+ end
350
+
351
+ private
352
+ def comp_cache
353
+ @queried_components.map{|qc| @components[qc]}
354
+ end
355
+ end
356
+ end
357
+
358
+ class Condition
359
+ attr_reader :k, :attr_conditions
360
+ def initialize(k)
361
+ @attr_conditions = {}
362
+ @k = k
363
+ end
364
+
365
+ def ==(other)
366
+ @k == other.k &&
367
+ @attr_conditions.size == other.attr_conditions.size &&
368
+ @attr_conditions.all?{|ac,v| other.attr_conditions[ac] == v}
369
+ end
370
+ alias eql? ==
371
+ def hash
372
+ @_hash ||= @k.hash ^ @attr_conditions.hash
373
+ end
374
+
375
+ def components
376
+ @k
377
+ end
378
+
379
+ def attrs_match?(id, comps)
380
+ comp = comps[@k]
381
+ @attr_conditions.all? do |name, cond|
382
+ val = comp.send(name)
383
+ if cond.respond_to? :call
384
+ cond.call val
385
+ else
386
+ val == cond
387
+ end
388
+ end
389
+ end
390
+
391
+ def merge_conditions(attrs)
392
+ @attr_conditions ||= {}
393
+ @attr_conditions.merge! attrs
394
+ end
395
+ end
396
+
397
+ class Must < Condition
398
+ def matches?(id, comps)
399
+ comps.keys.include?(@k) && attrs_match?(id, comps)
400
+ end
401
+ end
402
+
403
+ class Maybe < Condition
404
+ def matches?(id, comps)
405
+ attrs_match?(id, comps)
406
+ end
407
+
408
+ end
409
+
410
+ class Query
411
+ attr_reader :components, :musts, :maybes
412
+ def self.none
413
+ Query.new
414
+ end
415
+ def self.must(*args)
416
+ Query.new.must(*args)
417
+ end
418
+ def self.maybe(*args)
419
+ Query.new.maybe(*args)
420
+ end
421
+
422
+ def initialize
423
+ @components = []
424
+ @musts = []
425
+ end
426
+
427
+ def must(k)
428
+ @last_condition = Must.new(k)
429
+ @musts << @last_condition
430
+ @components << k
431
+ self
432
+ end
433
+
434
+ def required_components
435
+ @musts.flat_map(&:components).uniq
436
+ end
437
+
438
+ def with(attr_map)
439
+ @last_condition.merge_conditions(attr_map)
440
+ self
441
+ end
442
+
443
+ def maybe(k)
444
+ @maybes ||= []
445
+ @last_condition = Maybe.new(k)
446
+ @maybes << @last_condition
447
+ @components << k
448
+ self
449
+ end
450
+
451
+ def matches?(eid, comps)
452
+ @musts.all?{|m| m.matches?(eid, comps)} # ignore maybes ;)
453
+ end
454
+
455
+ def ==(other)
456
+ self.musts == other.musts && self.maybes == other.maybes
457
+ end
458
+
459
+ def cacheable?
460
+ @cacheable ||= @musts.all?{|m| m.attr_conditions.values.all?{|ac| !ac.respond_to?(:call) } }
461
+ end
462
+
463
+ alias eql? ==
464
+ def hash
465
+ @_hash ||= self.musts.hash ^ self.maybes.hash
466
+ end
467
+ end
468
+ Q = Query
469
+ end
@@ -0,0 +1,3 @@
1
+ module GameEcs
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: game_ecs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shawn Anderson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-02-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.12.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.12.0
69
+ description: Entity Component System architecture in Ruby
70
+ email:
71
+ - shawn42@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".rspec"
78
+ - ".travis.yml"
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - contrib/sample_systems_components.rb
84
+ - contrib/simple_system.rb
85
+ - examples/coin.wav
86
+ - examples/coin_getter_game.rb
87
+ - game_ecs.gemspec
88
+ - lib/game_ecs.rb
89
+ - lib/game_ecs/entity_store.rb
90
+ - lib/game_ecs/version.rb
91
+ homepage: https://github.com/shawn42/game_ecs
92
+ licenses:
93
+ - MIT
94
+ metadata: {}
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubyforge_project:
111
+ rubygems_version: 2.6.11
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: Entity Component System architecture in Ruby
115
+ test_files: []