doom 0.6.0 → 0.11.0

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Shared framebuffer blitting for the full-screen menu and intermission
6
+ # screens. Both draw palette-index sprites into a flat 320x240 framebuffer,
7
+ # so the clipping loop lived duplicated in each. It lives here once and uses
8
+ # the renderer's screen constants instead of hardcoded 320/240.
9
+ #
10
+ # A "sprite" here is anything exposing #width and #column_pixels(x), where a
11
+ # column is an array of palette indices (nil = transparent).
12
+ module FramebufferBlitter
13
+ # Blit a sprite's opaque pixels at (x, y), clipping to the screen.
14
+ def blit_sprite(framebuffer, sprite, x, y)
15
+ return unless sprite
16
+
17
+ w = Render::SCREEN_WIDTH
18
+ h = Render::SCREEN_HEIGHT
19
+ sprite.width.times do |col_x|
20
+ sx = x + col_x
21
+ next if sx < 0 || sx >= w
22
+
23
+ col = sprite.column_pixels(col_x)
24
+ next unless col
25
+
26
+ col.each_with_index do |color, col_y|
27
+ next unless color
28
+
29
+ sy = y + col_y
30
+ next if sy < 0 || sy >= h
31
+
32
+ framebuffer[(sy * w) + sx] = color
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Shared 2D geometry for the simulation. These three helpers were copy-pasted
6
+ # across combat, monster AI, player physics, world and sector actions (the
7
+ # last under the name point_line_dist); consolidated here so line-of-sight,
8
+ # collision and use-line checks share one definition.
9
+ #
10
+ # Pure functions of their arguments -- no state, no RNG, no wall clock -- so
11
+ # they stay deterministic and every peer computes the same result.
12
+ module Geometry
13
+ module_function
14
+
15
+ # Do the open segments A (ax1,ay1)->(ax2,ay2) and B (bx1,by1)->(bx2,by2)
16
+ # cross? Used for line-of-sight and hitscan against walls. Parallel or
17
+ # barely-crossing segments (tiny determinant) count as no intersection.
18
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
19
+ d1x = ax2 - ax1
20
+ d1y = ay2 - ay1
21
+ d2x = bx2 - bx1
22
+ d2y = by2 - by1
23
+ denom = (d1x * d2y) - (d1y * d2x)
24
+ return false if denom.abs < 0.001
25
+
26
+ dx = bx1 - ax1
27
+ dy = by1 - ay1
28
+ t = ((dx * d2y) - (dy * d2x)).to_f / denom
29
+ u = ((dx * d1y) - (dy * d1x)).to_f / denom
30
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
31
+ end
32
+
33
+ # Does the segment (x1,y1)->(x2,y2) pass within `radius` of the circle
34
+ # centred at (cx,cy)? The collision test for a body of that radius sliding
35
+ # against a wall.
36
+ def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
37
+ dx = cx - x1
38
+ dy = cy - y1
39
+ line_dx = x2 - x1
40
+ line_dy = y2 - y1
41
+ line_len_sq = (line_dx * line_dx) + (line_dy * line_dy)
42
+ return false if line_len_sq.zero?
43
+
44
+ t = (((dx * line_dx) + (dy * line_dy)).to_f / line_len_sq).clamp(0.0, 1.0)
45
+ closest_x = x1 + (t * line_dx)
46
+ closest_y = y1 + (t * line_dy)
47
+ dist_sq = ((cx - closest_x)**2) + ((cy - closest_y)**2)
48
+ dist_sq < radius * radius
49
+ end
50
+
51
+ # Shortest distance from point (px,py) to the segment (x1,y1)->(x2,y2),
52
+ # clamped to the segment ends. Used for use-line and trigger proximity.
53
+ def point_to_segment_distance(px, py, x1, y1, x2, y2)
54
+ dx = px - x1
55
+ dy = py - y1
56
+ line_dx = x2 - x1
57
+ line_dy = y2 - y1
58
+ line_len_sq = (line_dx * line_dx) + (line_dy * line_dy)
59
+ return Math.sqrt((dx * dx) + (dy * dy)) if line_len_sq.zero?
60
+
61
+ t = (((dx * line_dx) + (dy * line_dy)).to_f / line_len_sq).clamp(0.0, 1.0)
62
+ closest_x = x1 + (t * line_dx)
63
+ closest_y = y1 + (t * line_dy)
64
+ Math.sqrt(((px - closest_x)**2) + ((py - closest_y)**2))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'framebuffer_blitter'
4
+
5
+ module Doom
6
+ module Game
7
+ # Intermission screen shown between levels.
8
+ # Displays kill%, item%, secret%, time, and par time.
9
+ class Intermission
10
+ include FramebufferBlitter
11
+
12
+ # Episode 1 par times in seconds (from Chocolate Doom)
13
+ PAR_TIMES = {
14
+ 'E1M1' => 30, 'E1M2' => 75, 'E1M3' => 120, 'E1M4' => 90,
15
+ 'E1M5' => 165, 'E1M6' => 180, 'E1M7' => 180, 'E1M8' => 30, 'E1M9' => 165
16
+ }.freeze
17
+
18
+ # Next map progression
19
+ NEXT_MAP = {
20
+ 'E1M1' => 'E1M2', 'E1M2' => 'E1M3', 'E1M3' => 'E1M4', 'E1M4' => 'E1M5',
21
+ 'E1M5' => 'E1M6', 'E1M6' => 'E1M7', 'E1M7' => 'E1M8', 'E1M8' => nil,
22
+ 'E1M9' => 'E1M4'
23
+ }.freeze
24
+
25
+ # Counter animation speed (percentage points per tic)
26
+ COUNT_SPEED = 2
27
+ TICS_PER_COUNT = 1
28
+
29
+ attr_reader :finished, :next_map
30
+
31
+ def initialize(_wad, hud_graphics, stats)
32
+ @gfx = hud_graphics
33
+ @stats = stats # { map:, kills:, total_kills:, items:, total_items:, secrets:, total_secrets:, time_tics: }
34
+ @finished = false
35
+ @next_map = NEXT_MAP[stats[:map]]
36
+ @tic = 0
37
+
38
+ # Animated counters (count up from 0 to actual value)
39
+ @kill_count = 0
40
+ @item_count = 0
41
+ @secret_count = 0
42
+ @time_count = 0
43
+ @counting_done = false
44
+
45
+ # Target percentages
46
+ @kill_pct = @stats[:total_kills] > 0 ? (@stats[:kills] * 100 / @stats[:total_kills]) : 100
47
+ @item_pct = @stats[:total_items] > 0 ? (@stats[:items] * 100 / @stats[:total_items]) : 100
48
+ @secret_pct = @stats[:total_secrets] > 0 ? (@stats[:secrets] * 100 / @stats[:total_secrets]) : 100
49
+ @time_secs = @stats[:time_tics] / 35
50
+
51
+ @par_time = PAR_TIMES[stats[:map]] || 0
52
+
53
+ load_graphics
54
+ end
55
+
56
+ def update
57
+ @tic += 1
58
+ return if @counting_done
59
+
60
+ # Animate counters
61
+ if @kill_count < @kill_pct
62
+ @kill_count = [@kill_count + COUNT_SPEED, @kill_pct].min
63
+ elsif @item_count < @item_pct
64
+ @item_count = [@item_count + COUNT_SPEED, @item_pct].min
65
+ elsif @secret_count < @secret_pct
66
+ @secret_count = [@secret_count + COUNT_SPEED, @secret_pct].min
67
+ elsif @time_count < @time_secs
68
+ @time_count = [@time_count + 3, @time_secs].min
69
+ else
70
+ @counting_done = true
71
+ end
72
+ end
73
+
74
+ def render(framebuffer)
75
+ # Background
76
+ draw_background(framebuffer)
77
+
78
+ # "Finished" text + level name
79
+ draw_sprite(framebuffer, @wifinish, 64, 4) if @wifinish
80
+ level_idx = map_to_level_index(@stats[:map])
81
+ lv = @level_names[level_idx]
82
+ draw_sprite(framebuffer, lv, (320 - (lv&.width || 0)) / 2, 24) if lv
83
+
84
+ # Kill, Item, Secret percentages
85
+ y = 60
86
+ draw_sprite(framebuffer, @wiostk, 50, y) if @wiostk
87
+ draw_percent(framebuffer, 260, y, @kill_count)
88
+
89
+ y += 24
90
+ draw_sprite(framebuffer, @wiosti, 50, y) if @wiosti
91
+ draw_percent(framebuffer, 260, y, @item_count)
92
+
93
+ y += 24
94
+ draw_sprite(framebuffer, @wiosts, 50, y) if @wiosts
95
+ draw_percent(framebuffer, 260, y, @secret_count)
96
+
97
+ # Time
98
+ y += 30
99
+ draw_sprite(framebuffer, @witime, 16, y) if @witime
100
+ draw_time(framebuffer, 160, y, @time_count)
101
+
102
+ # Par time
103
+ draw_sprite(framebuffer, @wipar, 176, y) if @wipar
104
+ draw_time(framebuffer, 292, y, @par_time)
105
+
106
+ # "Entering" next level (after counting done)
107
+ if @counting_done && @next_map
108
+ y += 30
109
+ draw_sprite(framebuffer, @wienter, 64, y) if @wienter
110
+ next_idx = map_to_level_index(@next_map)
111
+ nlv = @level_names[next_idx]
112
+ draw_sprite(framebuffer, nlv, (320 - (nlv&.width || 0)) / 2, y + 18) if nlv
113
+ end
114
+
115
+ # "Press any key" hint after counting
116
+ return unless @counting_done && (@tic / 17).even?
117
+
118
+ # Blink hint via skull
119
+ skull = @skulls[@tic / 8 % 2]
120
+ draw_sprite(framebuffer, skull, 144, 210) if skull
121
+ end
122
+
123
+ def handle_key
124
+ if @counting_done
125
+ @finished = true
126
+ else
127
+ # Skip counting animation
128
+ @kill_count = @kill_pct
129
+ @item_count = @item_pct
130
+ @secret_count = @secret_pct
131
+ @time_count = @time_secs
132
+ @counting_done = true
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def map_to_level_index(map_name)
139
+ return 0 unless map_name
140
+
141
+ map_name[3].to_i - 1 # E1M1 -> 0, E1M2 -> 1, etc.
142
+ end
143
+
144
+ def load_graphics
145
+ # Intermission number digits
146
+ @nums = (0..9).map { |n| load_patch("WINUM#{n}") }
147
+ @percent = load_patch('WIPCNT')
148
+ @colon = load_patch('WICOLON')
149
+ @minus = load_patch('WIMINUS')
150
+
151
+ # Labels
152
+ @wiostk = load_patch('WIOSTK') # "Kills"
153
+ @wiosti = load_patch('WIOSTI') # "Items"
154
+ @wiosts = load_patch('WIOSTS') # "Scrt" (Secrets)
155
+ @witime = load_patch('WITIME') # "Time"
156
+ @wipar = load_patch('WIPAR') # "Par"
157
+ @wifinish = load_patch('WIF') # "Finished"
158
+ @wienter = load_patch('WIENTER') # "Entering"
159
+
160
+ # Map background
161
+ @wimap = load_patch('WIMAP0')
162
+
163
+ # Level names (WILV00-WILV08)
164
+ @level_names = (0..8).map { |n| load_patch("WILV0#{n}") }
165
+
166
+ # Skull cursor
167
+ @skulls = [load_patch('M_SKULL1'), load_patch('M_SKULL2')]
168
+ end
169
+
170
+ def load_patch(name)
171
+ @gfx.load_graphic(name)
172
+ end
173
+
174
+ def draw_background(framebuffer)
175
+ return unless @wimap
176
+
177
+ draw_fullscreen(framebuffer, @wimap)
178
+ end
179
+
180
+ def draw_fullscreen(framebuffer, sprite)
181
+ return unless sprite
182
+
183
+ y_offset = [(Render::SCREEN_HEIGHT - sprite.height) / 2, 0].max
184
+ blit_sprite(framebuffer, sprite, 0, y_offset)
185
+ end
186
+
187
+ def draw_percent(framebuffer, right_x, y, value)
188
+ # Draw percent sign
189
+ draw_sprite(framebuffer, @percent, right_x, y) if @percent
190
+
191
+ # Draw number right-aligned before percent
192
+ draw_num_right(framebuffer, right_x - 2, y, value)
193
+ end
194
+
195
+ def draw_time(framebuffer, right_x, y, seconds)
196
+ mins = seconds / 60
197
+ secs = seconds % 60
198
+
199
+ # Draw seconds (2 digits, zero-padded)
200
+ draw_num_right(framebuffer, right_x, y, secs, pad: 2)
201
+
202
+ # Colon
203
+ colon_x = right_x - (num_width * 2) - 4
204
+ draw_sprite(framebuffer, @colon, colon_x, y) if @colon
205
+
206
+ # Minutes
207
+ draw_num_right(framebuffer, colon_x - 2, y, mins)
208
+ end
209
+
210
+ def num_width
211
+ @nums[0]&.width || 14
212
+ end
213
+
214
+ def draw_num_right(framebuffer, right_x, y, value, pad: 0)
215
+ w = num_width
216
+ str = value.to_i.to_s
217
+ str = str.rjust(pad, '0') if pad > 0
218
+ x = right_x
219
+ str.reverse.each_char do |ch|
220
+ x -= w
221
+ digit = @nums[ch.to_i]
222
+ draw_sprite(framebuffer, digit, x, y) if digit
223
+ end
224
+ end
225
+
226
+ def draw_sprite(framebuffer, sprite, x, y)
227
+ blit_sprite(framebuffer, sprite, x, y)
228
+ end
229
+ end
230
+ end
231
+ end
@@ -7,7 +7,7 @@ module Doom
7
7
  class ItemPickup
8
8
  PLAYER_RADIUS = 16.0
9
9
  THING_RADIUS = 20.0
10
- PICKUP_DIST = PLAYER_RADIUS + THING_RADIUS # 36 units (bounding box overlap)
10
+ PICKUP_DIST = PLAYER_RADIUS + THING_RADIUS # 36 units (bounding box overlap)
11
11
 
12
12
  # Item definitions: type => { category, value, ... }
13
13
  ITEMS = {
@@ -26,9 +26,9 @@ module Doom
26
26
  2049 => { cat: :ammo, ammo: :shells, amount: 20 }, # Box of shells
27
27
  2010 => { cat: :ammo, ammo: :rockets, amount: 1 }, # Rocket
28
28
  2046 => { cat: :ammo, ammo: :rockets, amount: 5 }, # Box of rockets
29
- 17 => { cat: :ammo, ammo: :cells, amount: 20 }, # Cell charge
30
- 2047 => { cat: :ammo, ammo: :cells, amount: 100 }, # Cell pack
31
- 8 => { cat: :backpack }, # Backpack (doubles max ammo + some ammo)
29
+ 17 => { cat: :ammo, ammo: :cells, amount: 20 }, # Cell charge
30
+ 2047 => { cat: :ammo, ammo: :cells, amount: 100 }, # Cell pack
31
+ 8 => { cat: :backpack }, # Backpack (doubles max ammo + some ammo)
32
32
 
33
33
  # Health
34
34
  2014 => { cat: :health, amount: 1, max: 200 }, # Health bonus (+1, up to 200)
@@ -37,132 +37,180 @@ module Doom
37
37
  2013 => { cat: :health, amount: 100, max: 200 }, # Soul sphere
38
38
 
39
39
  # Armor
40
- 2015 => { cat: :armor, amount: 1, max: 200 }, # Armor bonus (+1, up to 200)
40
+ 2015 => { cat: :armor, amount: 1, max: 200 }, # Armor bonus (+1, up to 200)
41
41
  2018 => { cat: :armor, amount: 100, armor_type: 1 }, # Green armor (100%, absorbs 1/3)
42
42
  2019 => { cat: :armor, amount: 200, armor_type: 2 }, # Blue armor (200%, absorbs 1/2)
43
43
 
44
44
  # Keys
45
- 5 => { cat: :key, key: :blue_card },
46
- 6 => { cat: :key, key: :yellow_card },
45
+ 5 => { cat: :key, key: :blue_card },
46
+ 6 => { cat: :key, key: :yellow_card },
47
47
  13 => { cat: :key, key: :red_card },
48
48
  40 => { cat: :key, key: :blue_skull },
49
49
  39 => { cat: :key, key: :yellow_skull },
50
- 38 => { cat: :key, key: :red_skull },
50
+ 38 => { cat: :key, key: :red_skull }
51
51
  }.freeze
52
52
 
53
- attr_reader :picked_up
53
+ MESSAGETICS = 140 # 4 * TICRATE (4 seconds, matching Chocolate Doom)
54
+ FLASH_TICS = 8 # Yellow palette flash duration
54
55
 
55
- def initialize(map, player_state)
56
+ attr_reader :picked_up, :pickup_message, :pickup_flash, :message_tics
57
+ attr_accessor :ammo_multiplier, :hidden_things
58
+
59
+ def initialize(map, hidden_things = {})
56
60
  @map = map
57
- @player = player_state
58
- @picked_up = {} # thing index => true (to avoid re-picking)
61
+ @picked_up = {}
62
+ @hidden_things = hidden_things
63
+ @ammo_multiplier = 1
64
+ @pickup_message = nil
65
+ @pickup_flash = 0 # Yellow screen flash (short)
66
+ @message_tics = 0 # Message display timer (long)
67
+ end
68
+
69
+ # Decay timers each tic
70
+ def update_flash
71
+ @pickup_flash -= 1 if @pickup_flash > 0
72
+ @message_tics -= 1 if @message_tics > 0
59
73
  end
60
74
 
61
- def update(player_x, player_y)
75
+ # Pick up for one player. `picked_up` stays global on purpose: in coop an
76
+ # item taken by anyone is gone for everyone, as in DOOM. Deathmatch
77
+ # respawning is a separate concern.
78
+ def update(player)
79
+ state = player.state
62
80
  @map.things.each_with_index do |thing, idx|
81
+ next if @hidden_things[idx]
63
82
  next if @picked_up[idx]
83
+
64
84
  item = ITEMS[thing.type]
65
85
  next unless item
66
86
 
67
- # DOOM uses bounding box overlap: abs(dx) < sum_of_radii
68
- dx = (player_x - thing.x).abs
69
- dy = (player_y - thing.y).abs
87
+ dx = (player.x - thing.x).abs
88
+ dy = (player.y - thing.y).abs
70
89
  next if dx >= PICKUP_DIST || dy >= PICKUP_DIST
71
90
 
72
- if try_pickup(item)
73
- @picked_up[idx] = true
74
- end
91
+ next unless try_pickup(item, state)
92
+
93
+ @picked_up[idx] = true
94
+ @pickup_message = PICKUP_MESSAGES[thing.type]
95
+ @pickup_flash = FLASH_TICS
96
+ @message_tics = MESSAGETICS
75
97
  end
76
98
  end
77
99
 
100
+ PICKUP_MESSAGES = {
101
+ 2001 => 'A SHOTGUN!', 2002 => 'A CHAINGUN!', 2003 => 'A ROCKET LAUNCHER!',
102
+ 2004 => 'A PLASMA RIFLE!', 2005 => 'A CHAINSAW!', 2006 => 'A BFG9000!',
103
+ 2007 => 'PICKED UP A CLIP.', 2048 => 'PICKED UP A BOX OF BULLETS.',
104
+ 2008 => 'PICKED UP 4 SHOTGUN SHELLS.', 2049 => 'PICKED UP A BOX OF SHELLS.',
105
+ 2010 => 'PICKED UP A ROCKET.', 2046 => 'PICKED UP A BOX OF ROCKETS.',
106
+ 17 => 'PICKED UP AN ENERGY CELL.', 2047 => 'PICKED UP AN ENERGY CELL PACK.',
107
+ 8 => 'PICKED UP A BACKPACK FULL OF AMMO!',
108
+ 2011 => 'PICKED UP A STIMPACK.', 2012 => 'PICKED UP A MEDIKIT.',
109
+ 2014 => 'PICKED UP A HEALTH BONUS.', 2015 => 'PICKED UP AN ARMOR BONUS.',
110
+ 2018 => 'PICKED UP THE ARMOR.', 2019 => 'PICKED UP THE MEGAARMOR!',
111
+ 2013 => 'SUPERCHARGE!',
112
+ 5 => 'PICKED UP A BLUE KEYCARD.', 6 => 'PICKED UP A YELLOW KEYCARD.',
113
+ 13 => 'PICKED UP A RED KEYCARD.', 40 => 'PICKED UP A BLUE SKULL KEY.',
114
+ 39 => 'PICKED UP A YELLOW SKULL KEY.', 38 => 'PICKED UP A RED SKULL KEY.'
115
+ }.freeze
116
+
78
117
  private
79
118
 
80
- def try_pickup(item)
119
+ def try_pickup(item, state)
81
120
  case item[:cat]
82
121
  when :weapon
83
- give_weapon(item)
122
+ give_weapon(item, state)
84
123
  when :ammo
85
- give_ammo(item[:ammo], item[:amount])
124
+ give_ammo(item[:ammo], item[:amount], state)
86
125
  when :backpack
87
- give_backpack
126
+ give_backpack(state)
88
127
  when :health
89
- give_health(item[:amount], item[:max])
128
+ give_health(item[:amount], item[:max], state)
90
129
  when :armor
91
- give_armor(item)
130
+ give_armor(item, state)
92
131
  when :key
93
- give_key(item[:key])
132
+ give_key(item[:key], state)
94
133
  else
95
134
  false
96
135
  end
97
136
  end
98
137
 
99
- def give_weapon(item)
138
+ def give_weapon(item, state)
100
139
  weapon_idx = item[:weapon]
101
- had_weapon = @player.has_weapons[weapon_idx]
140
+ had_weapon = state.has_weapons[weapon_idx]
102
141
 
103
- @player.has_weapons[weapon_idx] = true
104
- ammo_given = item[:ammo] ? give_ammo(item[:ammo], item[:amount]) : false
142
+ state.has_weapons[weapon_idx] = true
143
+ ammo_given = item[:ammo] ? give_ammo(item[:ammo], item[:amount], state) : false
105
144
 
106
145
  unless had_weapon
107
- @player.switch_weapon(weapon_idx) unless @player.attacking
146
+ state.switch_weapon(weapon_idx) unless state.attacking
108
147
  return true
109
148
  end
110
149
 
111
150
  ammo_given
112
151
  end
113
152
 
114
- def give_ammo(type, amount)
153
+ def give_ammo(type, amount, state)
154
+ amount *= @ammo_multiplier
115
155
  case type
116
156
  when :bullets
117
- return false if @player.ammo_bullets >= @player.max_bullets
118
- @player.ammo_bullets = [@player.ammo_bullets + amount, @player.max_bullets].min
157
+ return false if state.ammo_bullets >= state.max_bullets
158
+
159
+ state.ammo_bullets = [state.ammo_bullets + amount, state.max_bullets].min
119
160
  when :shells
120
- return false if @player.ammo_shells >= @player.max_shells
121
- @player.ammo_shells = [@player.ammo_shells + amount, @player.max_shells].min
161
+ return false if state.ammo_shells >= state.max_shells
162
+
163
+ state.ammo_shells = [state.ammo_shells + amount, state.max_shells].min
122
164
  when :rockets
123
- return false if @player.ammo_rockets >= @player.max_rockets
124
- @player.ammo_rockets = [@player.ammo_rockets + amount, @player.max_rockets].min
165
+ return false if state.ammo_rockets >= state.max_rockets
166
+
167
+ state.ammo_rockets = [state.ammo_rockets + amount, state.max_rockets].min
125
168
  when :cells
126
- return false if @player.ammo_cells >= @player.max_cells
127
- @player.ammo_cells = [@player.ammo_cells + amount, @player.max_cells].min
169
+ return false if state.ammo_cells >= state.max_cells
170
+
171
+ state.ammo_cells = [state.ammo_cells + amount, state.max_cells].min
128
172
  end
129
173
  true
130
174
  end
131
175
 
132
- def give_backpack
133
- @player.max_bullets = 400
134
- @player.max_shells = 100
135
- @player.max_rockets = 100
136
- @player.max_cells = 600
137
- give_ammo(:bullets, 10)
138
- give_ammo(:shells, 4)
139
- give_ammo(:rockets, 1)
140
- give_ammo(:cells, 20)
176
+ def give_backpack(state)
177
+ state.max_bullets = 400
178
+ state.max_shells = 100
179
+ state.max_rockets = 100
180
+ state.max_cells = 600
181
+ give_ammo(:bullets, 10, state)
182
+ give_ammo(:shells, 4, state)
183
+ give_ammo(:rockets, 1, state)
184
+ give_ammo(:cells, 20, state)
141
185
  true
142
186
  end
143
187
 
144
- def give_health(amount, max)
145
- return false if @player.health >= max
146
- @player.health = [@player.health + amount, max].min
188
+ def give_health(amount, max, state)
189
+ return false if state.health >= max
190
+
191
+ state.health = [state.health + amount, max].min
147
192
  true
148
193
  end
149
194
 
150
- def give_armor(item)
195
+ def give_armor(item, state)
151
196
  if item[:armor_type]
152
197
  # Green/blue armor: only pick up if better than current
153
- return false if @player.armor >= item[:amount]
154
- @player.armor = item[:amount]
198
+ return false if state.armor >= item[:amount]
199
+
200
+ state.armor = item[:amount]
155
201
  else
156
202
  # Armor bonus: +1, up to max
157
- return false if @player.armor >= item[:max]
158
- @player.armor = [@player.armor + item[:amount], item[:max]].min
203
+ return false if state.armor >= item[:max]
204
+
205
+ state.armor = [state.armor + item[:amount], item[:max]].min
159
206
  end
160
207
  true
161
208
  end
162
209
 
163
- def give_key(key)
164
- return false if @player.keys[key]
165
- @player.keys[key] = true
210
+ def give_key(key, state)
211
+ return false if state.keys[key]
212
+
213
+ state.keys[key] = true
166
214
  true
167
215
  end
168
216
  end