game_2d 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. data/.gitignore +15 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +84 -0
  5. data/Rakefile +1 -0
  6. data/bin/game_2d_client.rb +17 -0
  7. data/bin/game_2d_server.rb +21 -0
  8. data/game_2d.gemspec +32 -0
  9. data/lib/game_2d/client_connection.rb +127 -0
  10. data/lib/game_2d/client_engine.rb +227 -0
  11. data/lib/game_2d/complex_move.rb +45 -0
  12. data/lib/game_2d/entity.rb +371 -0
  13. data/lib/game_2d/entity/block.rb +73 -0
  14. data/lib/game_2d/entity/owned_entity.rb +29 -0
  15. data/lib/game_2d/entity/pellet.rb +27 -0
  16. data/lib/game_2d/entity/titanium.rb +11 -0
  17. data/lib/game_2d/entity_constants.rb +14 -0
  18. data/lib/game_2d/game.rb +213 -0
  19. data/lib/game_2d/game_space.rb +462 -0
  20. data/lib/game_2d/game_window.rb +260 -0
  21. data/lib/game_2d/hash.rb +11 -0
  22. data/lib/game_2d/menu.rb +82 -0
  23. data/lib/game_2d/move/rise_up.rb +77 -0
  24. data/lib/game_2d/player.rb +251 -0
  25. data/lib/game_2d/registerable.rb +25 -0
  26. data/lib/game_2d/serializable.rb +69 -0
  27. data/lib/game_2d/server_connection.rb +104 -0
  28. data/lib/game_2d/server_port.rb +74 -0
  29. data/lib/game_2d/storage.rb +42 -0
  30. data/lib/game_2d/version.rb +3 -0
  31. data/lib/game_2d/wall.rb +21 -0
  32. data/lib/game_2d/zorder.rb +3 -0
  33. data/media/Beep.wav +0 -0
  34. data/media/Space.png +0 -0
  35. data/media/Star.png +0 -0
  36. data/media/Starfighter.bmp +0 -0
  37. data/media/brick.gif +0 -0
  38. data/media/cement.gif +0 -0
  39. data/media/crosshair.gif +0 -0
  40. data/media/dirt.gif +0 -0
  41. data/media/pellet.png +0 -0
  42. data/media/pellet.xcf +0 -0
  43. data/media/player.png +0 -0
  44. data/media/player.xcf +0 -0
  45. data/media/rock.png +0 -0
  46. data/media/rock.xcf +0 -0
  47. data/media/steel.gif +0 -0
  48. data/media/tele.gif +0 -0
  49. data/media/titanium.gif +0 -0
  50. data/media/unlikelium.gif +0 -0
  51. data/spec/client_engine_spec.rb +235 -0
  52. data/spec/game_space_spec.rb +347 -0
  53. metadata +246 -0
@@ -0,0 +1,29 @@
1
+ require 'facets/kernel/try'
2
+
3
+ class Entity
4
+
5
+ class OwnedEntity < Entity
6
+ attr_reader :owner_id
7
+
8
+ def owner_id=(id)
9
+ @owner_id = id.try(:to_sym)
10
+ end
11
+
12
+ def owner
13
+ fail "Can't look up owner when not in a space" unless @space
14
+ @space[@owner_id]
15
+ end
16
+
17
+ def owner=(new_owner)
18
+ self.owner_id = new_owner.nullsafe_registry_id
19
+ end
20
+
21
+ def all_state; super.push(owner_id); end
22
+ def as_json; super.merge! :owner => owner_id; end
23
+ def update_from_json(json)
24
+ self.owner_id = json[:owner]
25
+ super
26
+ end
27
+ end
28
+
29
+ end
@@ -0,0 +1,27 @@
1
+ require 'game_2d/entity/block'
2
+ require 'game_2d/entity/owned_entity'
3
+
4
+ class Entity
5
+
6
+ class Pellet < OwnedEntity
7
+ def should_fall?; true end
8
+ def sleep_now?; false end
9
+
10
+ # Pellets don't hit the originating player, or other
11
+ # pellets fired by the same player
12
+ def transparent_to_me?(other)
13
+ super ||
14
+ other.registry_id == self.owner_id ||
15
+ ((other.is_a?(Pellet) || other.is_a?(Block)) && other.owner_id == self.owner_id)
16
+ end
17
+
18
+ def i_hit(others)
19
+ puts "#{self}: hit #{others.inspect}. That's all for me."
20
+ others.each {|other| other.harmed_by(self)}
21
+ @space.doom(self)
22
+ end
23
+
24
+ def image_filename; "pellet.png" end
25
+ end
26
+
27
+ end
@@ -0,0 +1,11 @@
1
+ class Entity
2
+
3
+ class Titanium < Entity
4
+ def update; end
5
+
6
+ def sleep_now?; true; end
7
+
8
+ def image_filename; "titanium.gif"; end
9
+ end
10
+
11
+ end
@@ -0,0 +1,14 @@
1
+ module EntityConstants
2
+ # All our drawings are 40x40
3
+ CELL_WIDTH_IN_PIXELS = 40
4
+
5
+ # We track entities at a resolution higher than pixels, called "subpixels"
6
+ # This is the smallest detectable motion, 1 / PIXEL_WIDTH of a pixel
7
+ PIXEL_WIDTH = 10
8
+
9
+ # The dimensions of a cell, equals the dimensions of an entity
10
+ WIDTH = HEIGHT = CELL_WIDTH_IN_PIXELS * PIXEL_WIDTH
11
+
12
+ # Maximum velocity is a full cell per tick, which is a lot
13
+ MAX_VELOCITY = WIDTH
14
+ end
@@ -0,0 +1,213 @@
1
+ ## Author: Greg Meyers
2
+ ## License: Same as for Gosu (MIT)
3
+
4
+ require 'rubygems'
5
+ require 'gosu'
6
+
7
+ require 'game_2d/storage'
8
+ require 'game_2d/server_port'
9
+ require 'game_2d/game_space'
10
+ require 'game_2d/serializable'
11
+ require 'game_2d/entity'
12
+ require 'game_2d/player'
13
+
14
+ WORLD_WIDTH = 100 # in cells
15
+ WORLD_HEIGHT = 70 # in cells
16
+
17
+ DEFAULT_PORT = 4321
18
+ DEFAULT_STORAGE = '.game_2d'
19
+ MAX_CLIENTS = 32
20
+
21
+ # By default, Gosu calls update() 60 times per second.
22
+ # We aim to match that.
23
+ TICKS_PER_SECOND = 60
24
+
25
+ # How many ticks between broadcasts of the registry
26
+ DEFAULT_REGISTRY_BROADCAST_EVERY = TICKS_PER_SECOND / 4
27
+
28
+ class Game
29
+ def initialize(args)
30
+ @storage = Storage.in_home_dir(args[:storage] || DEFAULT_STORAGE).dir('server')
31
+ level_storage = @storage[args[:level]]
32
+
33
+ if level_storage.empty?
34
+ @space = GameSpace.new(self).establish_world(
35
+ args[:level],
36
+ nil, # level ID
37
+ args[:width] || WORLD_WIDTH,
38
+ args[:height] || WORLD_HEIGHT)
39
+ @space.storage = level_storage
40
+ else
41
+ @space = GameSpace.load(self, level_storage)
42
+ end
43
+
44
+ @tick = -1
45
+ @player_actions = Hash.new {|h,tick| h[tick] = Array.new}
46
+
47
+ @self_check, @profile, @registry_broadcast_every = args.values_at(
48
+ :self_check, :profile, :registry_broadcast_every)
49
+ @registry_broadcast_every ||= DEFAULT_REGISTRY_BROADCAST_EVERY
50
+
51
+ # This should never happen. It can only happen client-side because a
52
+ # registry update may create an entity before we get around to it in,
53
+ # say, add_npc
54
+ def @space.fire_duplicate_id(old_entity, new_entity)
55
+ raise "#{old_entity} and #{new_entity} have same ID!"
56
+ end
57
+
58
+ # This should never happen. It can only happen client-side because a
59
+ # registry update may delete an entity before we get around to it in
60
+ # purge_doomed_entities
61
+ def @space.fire_entity_not_found(entity)
62
+ raise "Object #{entity} not in registry"
63
+ end
64
+
65
+ @port = _create_server_port(self,
66
+ args[:port] || DEFAULT_PORT,
67
+ args[:max_clients] || MAX_CLIENTS)
68
+ end
69
+
70
+ def _create_server_port(*args)
71
+ ServerPort.new *args
72
+ end
73
+
74
+ attr_reader :tick
75
+
76
+ def world_name; @space.world_name; end
77
+ def world_id; @space.world_id; end
78
+ def world_highest_id; @space.highest_id; end
79
+ def world_cell_width; @space.cell_width; end
80
+ def world_cell_height; @space.cell_height; end
81
+
82
+ def save
83
+ @space.save
84
+ end
85
+
86
+ def add_player(player_name)
87
+ player = Player.new(player_name)
88
+ player.x = (@space.width - Entity::WIDTH) / 2
89
+ player.y = (@space.height - Entity::HEIGHT) / 2
90
+ other_players = @space.players.dup
91
+ @space << player
92
+ other_players.each {|p| player_connection(p).add_player(player, @tick) }
93
+ player
94
+ end
95
+
96
+ def player_id_connection(player_id)
97
+ @port.player_connection(player_id)
98
+ end
99
+
100
+ def player_connection(player)
101
+ player_id_connection(player.registry_id)
102
+ end
103
+
104
+ def delete_entity(entity)
105
+ puts "Deleting #{entity}"
106
+ @space.doom entity
107
+ @space.purge_doomed_entities
108
+ @space.players.each {|player| player_connection(player).delete_entity entity, @tick }
109
+ end
110
+
111
+ # Answering request from client
112
+ def create_npc(json)
113
+ add_npc(Serializable.from_json(json, :GENERATE_ID))
114
+ end
115
+
116
+ def add_npc(npc)
117
+ @space << npc or return
118
+ puts "Created #{npc}"
119
+ @space.players.each {|p| player_connection(p).add_npc npc, @tick }
120
+ end
121
+
122
+ def send_updated_entities(*entities)
123
+ @space.players.each {|p| player_connection(p).update_entities entities, @tick }
124
+ end
125
+
126
+ def [](id)
127
+ @space[id]
128
+ end
129
+
130
+ def get_all_players
131
+ @space.players
132
+ end
133
+
134
+ def get_all_npcs
135
+ @space.npcs
136
+ end
137
+
138
+ def add_player_action(player_id, action)
139
+ at_tick = action[:at_tick]
140
+ unless at_tick
141
+ $stderr.puts "Received update from #{player_id} without at_tick!"
142
+ at_tick = @tick + 1
143
+ end
144
+ if at_tick <= @tick
145
+ $stderr.puts "Received update from #{player_id} #{@tick + 1 - at_tick} ticks late"
146
+ at_tick = @tick + 1
147
+ end
148
+ @player_actions[at_tick] << [player_id, action]
149
+ end
150
+
151
+ def process_player_actions
152
+ if actions = @player_actions.delete(@tick)
153
+ actions.each do |player_id, action|
154
+ player = @space[player_id]
155
+ unless player
156
+ $stderr.puts "No such player #{player_id} -- dropping move"
157
+ next
158
+ end
159
+ if (move = action[:move])
160
+ player.add_move move
161
+ elsif (npc = action[:create_npc])
162
+ create_npc npc
163
+ else
164
+ $stderr.puts "IGNORING BAD DATA from #{player}: #{action.inspect}"
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ def update
171
+ @tick += 1
172
+
173
+ # This will:
174
+ # 1) Queue up player actions for existing players
175
+ # (create_npc included)
176
+ # 2) Add new players in response to handshake messages
177
+ # 3) Remove players in response to disconnections
178
+ @port.update
179
+
180
+ # This will execute player moves, and create NPCs
181
+ process_player_actions
182
+
183
+ # Objects that exist by now will be updated
184
+ # Objects created during this tick won't be updated this tick
185
+ @space.update
186
+
187
+ @port.broadcast(
188
+ :registry => @space.all_registered,
189
+ :highest_id => @space.highest_id,
190
+ :at_tick => @tick
191
+ ) if @registry_broadcast_every > 0 && (@tick % @registry_broadcast_every == 0)
192
+
193
+ if @self_check
194
+ @space.check_for_grid_corruption
195
+ @space.check_for_registry_leaks
196
+ end
197
+ end
198
+
199
+ def run
200
+ run_start = Time.now.to_r
201
+ loop do
202
+ TICKS_PER_SECOND.times do |n|
203
+ update
204
+
205
+ # This results in something approaching TICKS_PER_SECOND
206
+ @port.update_until(run_start + Rational(@tick, TICKS_PER_SECOND))
207
+
208
+ $stderr.puts "Updates per second: #{@tick / (Time.now.to_r - run_start)}" if @profile
209
+ end # times
210
+ end # infinite loop
211
+ end # run
212
+
213
+ end
@@ -0,0 +1,462 @@
1
+ require 'securerandom'
2
+ require 'delegate'
3
+ require 'set'
4
+ require 'game_2d/wall'
5
+ require 'game_2d/player'
6
+ require 'game_2d/serializable'
7
+
8
+ # Common code between the server and client for maintaining the world.
9
+ # This is a bounded space (walls on all sides).
10
+ #
11
+ # Maintains a registry of entities. All game entities must have a registry_id
12
+ # set before they will be accepted.
13
+ #
14
+ # Also maintains a list of entities due to be deleted, to avoid removing them
15
+ # at the wrong time (during collision processing).
16
+
17
+ # Cell is a portion of the game space, the exact size of one entity.
18
+ # The cell (0,0) contains subpixel coordinates (0,0) through (399,399).
19
+ #
20
+ # The behavior I want from Cells is to consider them all unique objects.
21
+ # I want to be able to say "Subtract this set of cells from that set". Treating
22
+ # Cells as equal if their contents are equal defeats this purpose.
23
+ #
24
+ # It's also handy if each Cell knows where it lives in the grid.
25
+ #
26
+ # Previously, I was using Set as the superclass. That seemed to make sense,
27
+ # since this is an unordered collection. But Set stores everything as hash
28
+ # keys, and hashes get very confused if their keys get mutated without going
29
+ # through the API.
30
+
31
+ class Cell < DelegateClass(Array)
32
+ attr_reader :x, :y
33
+
34
+ def ==(other)
35
+ other.class.equal?(self.class) &&
36
+ other.x == self.x &&
37
+ other.y == self.y &&
38
+ other.instance_variable_get(:@a) == @a
39
+ end
40
+
41
+ def initialize(cell_x, cell_y)
42
+ @a = []
43
+ @x, @y = cell_x, cell_y
44
+ super(@a)
45
+ end
46
+
47
+ def to_s; "(#{x}, #{y}) [#{@a.join(', ')}]"; end
48
+ def inspect; "Cell(#{x}, #{y}) #{@a}"; end
49
+ end
50
+
51
+
52
+ class GameSpace
53
+ attr_reader :world_name, :world_id, :players, :npcs, :cell_width, :cell_height, :game
54
+ attr_accessor :storage, :highest_id
55
+
56
+ def initialize(game=nil)
57
+ @game = game
58
+ @grid = @storage = nil
59
+ @highest_id = 0
60
+
61
+ @registry = {}
62
+
63
+ # I create a @doomed array so we can remove entities after all collisions
64
+ # have been processed, to avoid confusion
65
+ @doomed = []
66
+
67
+ @players = []
68
+ @npcs = []
69
+ end
70
+
71
+ # Width and height, measured in cells
72
+ def establish_world(name, id, cell_width, cell_height)
73
+ @world_name = name
74
+ @world_id = (id || SecureRandom.uuid).to_sym
75
+ @cell_width, @cell_height = cell_width, cell_height
76
+
77
+ # Outer array is X-indexed; inner arrays are Y-indexed
78
+ # Therefore you can look up @grid[cell_x][cell_y] ...
79
+ # However, for convenience, we make the grid two cells wider, two cells
80
+ # taller. Then we can populate the edge with Wall instances, and treat (0,
81
+ # 0) as a usable coordinate. (-1, -1) contains a Wall, for example. The
82
+ # at(), put(), and cut() methods do the translation, so only they should
83
+ # access @grid directly
84
+ @grid = Array.new(cell_width + 2) do |cx|
85
+ Array.new(cell_height + 2) do |cy|
86
+ Cell.new(cx-1, cy-1)
87
+ end.freeze
88
+ end.freeze
89
+
90
+ # Top and bottom, including corners
91
+ (-1 .. cell_width).each do |cell_x|
92
+ put(cell_x, -1, Wall.new(self, cell_x, -1)) # top
93
+ put(cell_x, cell_height, Wall.new(self, cell_x, cell_height)) # bottom
94
+ end
95
+
96
+ # Left and right, skipping corners
97
+ (0 .. cell_height - 1).each do |cell_y|
98
+ put(-1, cell_y, Wall.new(self, -1, cell_y)) # left
99
+ put(cell_width, cell_y, Wall.new(self, cell_width, cell_y)) # right
100
+ end
101
+
102
+ self
103
+ end
104
+
105
+ def copy_from(original)
106
+ establish_world(original.world_name, original.world_id, original.cell_width, original.cell_height)
107
+ @highest_id = original.highest_id
108
+
109
+ # @game and @storage should point to the same object (no clone)
110
+ @game, @storage = original.game, original.storage
111
+
112
+ # Registry should contain all objects - clone those
113
+ original.all_registered.each {|ent| self << ent.clone }
114
+
115
+ self
116
+ end
117
+
118
+ def self.load(game, storage)
119
+ name, id, cell_width, cell_height =
120
+ storage[:world_name], storage[:world_id],
121
+ storage[:cell_width], storage[:cell_height]
122
+ space = GameSpace.new(game).establish_world(name, id, cell_width, cell_height)
123
+ space.storage = storage
124
+ space.load
125
+ end
126
+
127
+ def save
128
+ @storage[:world_name] = @world_name
129
+ @storage[:world_id] = @world_id
130
+ @storage[:cell_width], @storage[:cell_height] = @cell_width, @cell_height
131
+ @storage[:highest_id] = @highest_id
132
+ @storage[:npcs] = @npcs
133
+ @storage.save
134
+ end
135
+
136
+ # TODO: Handle this while server is running and players are connected
137
+ # TODO: Handle resizing the space
138
+ def load
139
+ @highest_id = @storage[:highest_id]
140
+ @storage[:npcs].each do |json|
141
+ puts "Loading #{json.inspect}"
142
+ self << Serializable.from_json(json)
143
+ end
144
+ self
145
+ end
146
+
147
+ def pixel_width; @cell_width * Entity::CELL_WIDTH_IN_PIXELS; end
148
+ def pixel_height; @cell_height * Entity::CELL_WIDTH_IN_PIXELS; end
149
+ def width; @cell_width * Entity::WIDTH; end
150
+ def height; @cell_height * Entity::HEIGHT; end
151
+
152
+ def next_id
153
+ "R#{@highest_id += 1}".to_sym
154
+ end
155
+
156
+ # Retrieve entity by ID
157
+ def [](registry_id)
158
+ return nil unless registry_id
159
+ @registry[registry_id.to_sym]
160
+ end
161
+
162
+ def all_registered
163
+ @registry.values
164
+ end
165
+
166
+ # List of entities by type matching the specified entity
167
+ def entity_list(entity)
168
+ case entity
169
+ when Player then @players
170
+ else @npcs
171
+ end
172
+ end
173
+
174
+ # Override to be informed when trying to add an entity that
175
+ # we already have (registry ID clash)
176
+ def fire_duplicate_id(old_entity, new_entity); end
177
+
178
+ # Returns nil if registration worked, or the exact same object
179
+ # was already registered
180
+ # If another object was registered, calls fire_duplicate_id and
181
+ # then returns the previously-registered object
182
+ def register(entity)
183
+ reg_id = entity.registry_id
184
+ old = @registry[reg_id]
185
+ return nil if old.equal? entity
186
+ if old
187
+ fire_duplicate_id(old, entity)
188
+ return old
189
+ end
190
+ @registry[reg_id] = entity
191
+ entity_list(entity) << entity
192
+ nil
193
+ end
194
+
195
+ def registered?(entity)
196
+ return false unless old = @registry[entity.registry_id]
197
+ return true if old.equal? entity
198
+ fail("Registered entity #{old} has ID #{old.object_id}; " +
199
+ "passed entity #{entity} has ID #{entity.object_id}")
200
+ end
201
+
202
+ def deregister(entity)
203
+ fail "#{entity} not registered" unless registered?(entity)
204
+ entity_list(entity).delete entity
205
+ @registry.delete entity.registry_id
206
+ end
207
+
208
+ # We can safely look up cell_x == -1, cell_x == @cell_width, cell_y == -1,
209
+ # and/or cell_y == @cell_height -- any of these returns a Wall instance
210
+ def assert_ok_coords(cell_x, cell_y)
211
+ raise "Illegal coordinate #{cell_x}x#{cell_y}" if (
212
+ cell_x < -1 ||
213
+ cell_y < -1 ||
214
+ cell_x > @cell_width ||
215
+ cell_y > @cell_height
216
+ )
217
+ end
218
+
219
+ # Retrieve set of entities falling (partly) within cell coordinates,
220
+ # zero-based
221
+ def at(cell_x, cell_y)
222
+ assert_ok_coords(cell_x, cell_y)
223
+ @grid[cell_x + 1][cell_y + 1]
224
+ end
225
+
226
+ # Low-level adder
227
+ def put(cell_x, cell_y, entity)
228
+ at(cell_x, cell_y) << entity
229
+ end
230
+
231
+ # Low-level remover
232
+ def cut(cell_x, cell_y, entity)
233
+ at(cell_x, cell_y).delete entity
234
+ end
235
+
236
+ # Translate a subpixel point (X, Y) to a cell coordinate (cell_x, cell_y)
237
+ def cell_at_point(x, y)
238
+ [x / Entity::WIDTH, y / Entity::HEIGHT ]
239
+ end
240
+
241
+ # Translate multiple subpixel points (X, Y) to a set of cell coordinates
242
+ # (cell_x, cell_y)
243
+ def cells_at_points(coords)
244
+ coords.collect {|x, y| cell_at_point(x, y) }.to_set
245
+ end
246
+
247
+ # Given the (X, Y) position of a theoretical entity, return the list of all
248
+ # the coordinates of its corners
249
+ def corner_points_of_entity(x, y)
250
+ [
251
+ [x, y],
252
+ [x + Entity::WIDTH - 1, y],
253
+ [x, y + Entity::HEIGHT - 1],
254
+ [x + Entity::WIDTH - 1, y + Entity::HEIGHT - 1],
255
+ ]
256
+ end
257
+
258
+ # Return a list of the entities (if any) at a subpixel point (X, Y)
259
+ def entities_at_point(x, y)
260
+ at(*cell_at_point(x, y)).find_all do |e|
261
+ e.x <= x && e.x > (x - Entity::WIDTH) &&
262
+ e.y <= y && e.y > (y - Entity::HEIGHT)
263
+ end
264
+ end
265
+
266
+ # Accepts a collection of (x, y)
267
+ # Returns a Set of entities
268
+ def entities_at_points(coords)
269
+ coords.collect {|x, y| entities_at_point(x, y) }.flatten.to_set
270
+ end
271
+
272
+ # The set of entities that may be affected by an entity moving to (or from)
273
+ # the specified (x, y) coordinates
274
+ # This includes the coordinates of eight points just beyond the entity's
275
+ # borders
276
+ def entities_bordering_entity_at(x, y)
277
+ r = x + Entity::WIDTH - 1
278
+ b = y + Entity::HEIGHT - 1
279
+ entities_at_points([
280
+ [x - 1, y], [x, y - 1], # upper-left corner
281
+ [r + 1, y], [r, y - 1], # upper-right corner
282
+ [x - 1, b], [x, b + 1], # lower-left corner
283
+ [r + 1, b], [r, b + 1], # lower-right corner
284
+ ])
285
+ end
286
+
287
+ # Retrieve set of entities that overlap with a theoretical entity created at
288
+ # position [x, y] (in subpixels)
289
+ def entities_overlapping(x, y)
290
+ entities_at_points(corner_points_of_entity(x, y))
291
+ end
292
+
293
+ # Retrieve list of cells that overlap with a theoretical entity
294
+ # at position [x, y] (in subpixels).
295
+ def cells_overlapping(x, y)
296
+ cells_at_points(corner_points_of_entity(x, y)).collect {|cx, cy| at(cx, cy) }
297
+ end
298
+
299
+ # Add the entity to the grid
300
+ def add_entity_to_grid(entity)
301
+ cells_overlapping(entity.x, entity.y).each {|s| s << entity }
302
+ end
303
+
304
+ # Remove the entity from the grid
305
+ def remove_entity_from_grid(entity)
306
+ cells_overlapping(entity.x, entity.y).each do |s|
307
+ raise "#{entity} not where expected" unless s.delete entity
308
+ end
309
+ end
310
+
311
+ # Update grid after an entity moves
312
+ def update_grid_for_moved_entity(entity, old_x, old_y)
313
+ cells_before = cells_overlapping(old_x, old_y)
314
+ cells_after = cells_overlapping(entity.x, entity.y)
315
+
316
+ (cells_before - cells_after).each do |s|
317
+ raise "#{entity} not where expected" unless s.delete entity
318
+ end
319
+ (cells_after - cells_before).each {|s| s << entity }
320
+ end
321
+
322
+ # Execute a block during which an entity may move
323
+ # If it did, we will update the grid appropriately, and wake nearby entities
324
+ #
325
+ # All entity motion should be passed through this method
326
+ def process_moving_entity(entity)
327
+ unless registered?(entity)
328
+ puts "#{entity} not in registry yet, no move to process"
329
+ yield
330
+ return
331
+ end
332
+
333
+ before_x, before_y = entity.x, entity.y
334
+
335
+ yield
336
+
337
+ if moved = (entity.x != before_x || entity.y != before_y)
338
+ update_grid_for_moved_entity(entity, before_x, before_y)
339
+ # Note: Maybe we should only wake entities in either set
340
+ # and not both. For now we'll wake them all
341
+ (
342
+ entities_bordering_entity_at(before_x, before_y) +
343
+ entities_bordering_entity_at(entity.x, entity.y)
344
+ ).each(&:wake!)
345
+ end
346
+
347
+ moved
348
+ end
349
+
350
+ # Add an entity. Will wake neighboring entities
351
+ def <<(entity)
352
+ entity.registry_id = next_id unless entity.registry_id?
353
+
354
+ fail "Already registered: #{entity}" if registered?(entity)
355
+
356
+ # Need to assign the space before entities_obstructing()
357
+ entity.space = self
358
+ conflicts = entity.entities_obstructing(entity.x, entity.y)
359
+ if conflicts.empty?
360
+ register(entity)
361
+ add_entity_to_grid(entity)
362
+ entities_bordering_entity_at(entity.x, entity.y).each(&:wake!)
363
+ entity
364
+ else
365
+ entity.space = nil
366
+ # TODO: Convey error to user somehow
367
+ $stderr.puts "Can't create #{entity}, occupied by #{conflicts.inspect}"
368
+ end
369
+ end
370
+
371
+ # Doom an entity (mark it to be deleted but don't remove it yet)
372
+ def doom(entity); @doomed << entity; end
373
+
374
+ def doomed?(entity); @doomed.include?(entity); end
375
+
376
+ # Override to be informed when trying to purge an entity that
377
+ # turns out not to exist
378
+ def fire_entity_not_found(entity); end
379
+
380
+ # Actually remove all previously-marked entities. Wakes neighbors
381
+ def purge_doomed_entities
382
+ @doomed.each do |entity|
383
+ if registered?(entity)
384
+ entity.destroy!
385
+ deregister(entity)
386
+ entities_bordering_entity_at(entity.x, entity.y).each(&:wake!)
387
+ remove_entity_from_grid(entity)
388
+ else
389
+ fire_entity_not_found(entity)
390
+ end
391
+ end
392
+ @doomed.clear
393
+ end
394
+
395
+ def update
396
+ @registry.values.find_all(&:moving?).each(&:update)
397
+ purge_doomed_entities
398
+ end
399
+
400
+ # Assertion
401
+ def check_for_grid_corruption
402
+ 0.upto(@cell_height - 1) do |cell_y|
403
+ 0.upto(@cell_width - 1) do |cell_x|
404
+ cell = at(cell_x, cell_y)
405
+ cell.each do |entity|
406
+ ok = cells_overlapping(entity.x, entity.y)
407
+ unless ok.include? cell
408
+ raise "#{entity} shouldn't be in cell #{cell}"
409
+ end
410
+ end
411
+ end
412
+ end
413
+ @registry.values.each do |entity|
414
+ cells_overlapping(entity.x, entity.y).each do |cell|
415
+ unless cell.include? entity
416
+ raise "Expected #{entity} to be in cell #{cell}"
417
+ end
418
+ end
419
+ end
420
+ end
421
+
422
+ # Assertion. Useful server-side only
423
+ def check_for_registry_leaks
424
+ expected = @players.size + @npcs.size
425
+ actual = @registry.size
426
+ if expected != actual
427
+ raise "We have #{expected} game entities, #{actual} in registry (delta: #{actual - expected})"
428
+ end
429
+ end
430
+
431
+ # Used client-side only. Determine an appropriate camera position,
432
+ # given the specified window size, and preferring that the specified entity
433
+ # be in the center. Inputs and outputs are in pixels
434
+ def good_camera_position_for(entity, screen_width, screen_height)
435
+ # Given plenty of room, put the entity in the middle of the screen
436
+ # If doing so would expose the area outside the world, move the camera just enough
437
+ # to avoid that
438
+ # If the world is smaller than the window, center it
439
+
440
+ # puts "Screen in pixels is #{screen_width}x#{screen_height}; world in pixels is #{pixel_width}x#{pixel_height}"
441
+ camera_x = if screen_width > pixel_width
442
+ (pixel_width - screen_width) / 2 # negative
443
+ else
444
+ [[entity.pixel_x - screen_width/2, pixel_width - screen_width].min, 0].max
445
+ end
446
+ camera_y = if screen_height > pixel_height
447
+ (pixel_height - screen_height) / 2 # negative
448
+ else
449
+ [[entity.pixel_y - screen_height/2, pixel_height - screen_height].min, 0].max
450
+ end
451
+
452
+ # puts "Camera at #{camera_x}x#{camera_y}"
453
+ [ camera_x, camera_y ]
454
+ end
455
+
456
+ def ==(other)
457
+ other.class.equal?(self.class) && other.all_state == self.all_state
458
+ end
459
+ def all_state
460
+ [@world_name, @world_id, @registry, @grid, @highest_id]
461
+ end
462
+ end