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.
- data/.gitignore +15 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +84 -0
- data/Rakefile +1 -0
- data/bin/game_2d_client.rb +17 -0
- data/bin/game_2d_server.rb +21 -0
- data/game_2d.gemspec +32 -0
- data/lib/game_2d/client_connection.rb +127 -0
- data/lib/game_2d/client_engine.rb +227 -0
- data/lib/game_2d/complex_move.rb +45 -0
- data/lib/game_2d/entity.rb +371 -0
- data/lib/game_2d/entity/block.rb +73 -0
- data/lib/game_2d/entity/owned_entity.rb +29 -0
- data/lib/game_2d/entity/pellet.rb +27 -0
- data/lib/game_2d/entity/titanium.rb +11 -0
- data/lib/game_2d/entity_constants.rb +14 -0
- data/lib/game_2d/game.rb +213 -0
- data/lib/game_2d/game_space.rb +462 -0
- data/lib/game_2d/game_window.rb +260 -0
- data/lib/game_2d/hash.rb +11 -0
- data/lib/game_2d/menu.rb +82 -0
- data/lib/game_2d/move/rise_up.rb +77 -0
- data/lib/game_2d/player.rb +251 -0
- data/lib/game_2d/registerable.rb +25 -0
- data/lib/game_2d/serializable.rb +69 -0
- data/lib/game_2d/server_connection.rb +104 -0
- data/lib/game_2d/server_port.rb +74 -0
- data/lib/game_2d/storage.rb +42 -0
- data/lib/game_2d/version.rb +3 -0
- data/lib/game_2d/wall.rb +21 -0
- data/lib/game_2d/zorder.rb +3 -0
- data/media/Beep.wav +0 -0
- data/media/Space.png +0 -0
- data/media/Star.png +0 -0
- data/media/Starfighter.bmp +0 -0
- data/media/brick.gif +0 -0
- data/media/cement.gif +0 -0
- data/media/crosshair.gif +0 -0
- data/media/dirt.gif +0 -0
- data/media/pellet.png +0 -0
- data/media/pellet.xcf +0 -0
- data/media/player.png +0 -0
- data/media/player.xcf +0 -0
- data/media/rock.png +0 -0
- data/media/rock.xcf +0 -0
- data/media/steel.gif +0 -0
- data/media/tele.gif +0 -0
- data/media/titanium.gif +0 -0
- data/media/unlikelium.gif +0 -0
- data/spec/client_engine_spec.rb +235 -0
- data/spec/game_space_spec.rb +347 -0
- 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,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
|
data/lib/game_2d/game.rb
ADDED
@@ -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
|