doom 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70d72175c1f115b3363e188d7075047bc32cece4f3a3dd1feda17dc6f965b07f
4
- data.tar.gz: 22eb9172267c578a6fbaa3dd526ec19e8d4587dba9a349080ad1626d3e04fd92
3
+ metadata.gz: cb48bcb7558c2e4f4ab959b16dbd857d2a1b6a3b9a9cc76c19a08ec91bdeb93e
4
+ data.tar.gz: 610f42cf954c60048a0c4030d96a8e8ceb2872aba90e90a141b2179329867c05
5
5
  SHA512:
6
- metadata.gz: 6f5fdd281119ba4bec5a29b8f9cd32f8d0aa37d1674125c3095835ba24942e66e6356037c3e424f320578cac5b1ed94dc7738388d3ced296f8de22ce78e9e79e
7
- data.tar.gz: 35de5ff2f711aa4084a54d811d26b919cf8f1b00be1d9b06597b7e7aac09eab3ee8ff597b1ece2ac451d175dad2faf3c16bc73f3dbf56b50867407509fc4f234
6
+ metadata.gz: 1ff09fd05c29a5c49b7d41285424d58b8b46dbf995e1f18106d571f18866762e8b20ecb47d472dc2cb2be8f2fc8cdea5eb0b3a2dedae55d5b1881bbcb796088b
7
+ data.tar.gz: 41d4fcb793f3b25fb57f2d50c02f5eee1a56cb40a40baf2a9f7e0831634f43aa4777abf80968130c44593055dbaea23bd3257e7d4b509a068af2c92e1e16698d
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A faithful ruby port of the DOOM (1993) rendering engine to Ruby.
4
4
 
5
- ![DOOM Ruby Screenshot](https://raw.githubusercontent.com/khasinski/doom-rb/main/e1m1_spawn.png)
5
+ ![DOOM Ruby Screenshot](screenshot.png)
6
6
 
7
7
  ## Features
8
8
 
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Tracks player state for HUD display and weapon rendering
6
+ class PlayerState
7
+ # Weapons
8
+ WEAPON_FIST = 0
9
+ WEAPON_PISTOL = 1
10
+ WEAPON_SHOTGUN = 2
11
+ WEAPON_CHAINGUN = 3
12
+ WEAPON_ROCKET = 4
13
+ WEAPON_PLASMA = 5
14
+ WEAPON_BFG = 6
15
+ WEAPON_CHAINSAW = 7
16
+
17
+ # Weapon symbols for graphics lookup
18
+ WEAPON_NAMES = {
19
+ WEAPON_FIST => :fist,
20
+ WEAPON_PISTOL => :pistol,
21
+ WEAPON_SHOTGUN => :shotgun,
22
+ WEAPON_CHAINGUN => :chaingun,
23
+ WEAPON_ROCKET => :rocket,
24
+ WEAPON_PLASMA => :plasma,
25
+ WEAPON_BFG => :bfg,
26
+ WEAPON_CHAINSAW => :chainsaw
27
+ }.freeze
28
+
29
+ # Attack durations (in frames at 35fps)
30
+ ATTACK_DURATIONS = {
31
+ WEAPON_FIST => 12,
32
+ WEAPON_PISTOL => 8,
33
+ WEAPON_SHOTGUN => 20,
34
+ WEAPON_CHAINGUN => 4,
35
+ WEAPON_ROCKET => 16,
36
+ WEAPON_PLASMA => 6,
37
+ WEAPON_BFG => 40,
38
+ WEAPON_CHAINSAW => 4
39
+ }.freeze
40
+
41
+ attr_accessor :health, :armor, :max_health, :max_armor
42
+ attr_accessor :ammo_bullets, :ammo_shells, :ammo_rockets, :ammo_cells
43
+ attr_accessor :max_bullets, :max_shells, :max_rockets, :max_cells
44
+ attr_accessor :weapon, :has_weapons
45
+ attr_accessor :keys
46
+ attr_accessor :attacking, :attack_frame, :attack_tics
47
+ attr_accessor :bob_angle, :bob_amount
48
+ attr_accessor :is_moving
49
+
50
+ def initialize
51
+ reset
52
+ end
53
+
54
+ def reset
55
+ @health = 100
56
+ @armor = 0
57
+ @max_health = 100
58
+ @max_armor = 200
59
+
60
+ # Ammo
61
+ @ammo_bullets = 50
62
+ @ammo_shells = 0
63
+ @ammo_rockets = 0
64
+ @ammo_cells = 0
65
+
66
+ @max_bullets = 200
67
+ @max_shells = 50
68
+ @max_rockets = 50
69
+ @max_cells = 300
70
+
71
+ # Start with fist and pistol
72
+ @weapon = WEAPON_PISTOL
73
+ @has_weapons = [true, true, false, false, false, false, false, false]
74
+
75
+ # No keys
76
+ @keys = {
77
+ blue_card: false,
78
+ yellow_card: false,
79
+ red_card: false,
80
+ blue_skull: false,
81
+ yellow_skull: false,
82
+ red_skull: false
83
+ }
84
+
85
+ # Attack state
86
+ @attacking = false
87
+ @attack_frame = 0
88
+ @attack_tics = 0
89
+
90
+ # Weapon bob
91
+ @bob_angle = 0.0
92
+ @bob_amount = 0.0
93
+ @is_moving = false
94
+ end
95
+
96
+ def weapon_name
97
+ WEAPON_NAMES[@weapon]
98
+ end
99
+
100
+ def current_ammo
101
+ case @weapon
102
+ when WEAPON_PISTOL, WEAPON_CHAINGUN
103
+ @ammo_bullets
104
+ when WEAPON_SHOTGUN
105
+ @ammo_shells
106
+ when WEAPON_ROCKET
107
+ @ammo_rockets
108
+ when WEAPON_PLASMA, WEAPON_BFG
109
+ @ammo_cells
110
+ else
111
+ nil # Fist/chainsaw don't use ammo
112
+ end
113
+ end
114
+
115
+ def max_ammo_for_weapon
116
+ case @weapon
117
+ when WEAPON_PISTOL, WEAPON_CHAINGUN
118
+ @max_bullets
119
+ when WEAPON_SHOTGUN
120
+ @max_shells
121
+ when WEAPON_ROCKET
122
+ @max_rockets
123
+ when WEAPON_PLASMA, WEAPON_BFG
124
+ @max_cells
125
+ else
126
+ nil
127
+ end
128
+ end
129
+
130
+ def can_attack?
131
+ return true if @weapon == WEAPON_FIST || @weapon == WEAPON_CHAINSAW
132
+
133
+ ammo = current_ammo
134
+ ammo && ammo > 0
135
+ end
136
+
137
+ def start_attack
138
+ return unless can_attack?
139
+ return if @attacking
140
+
141
+ @attacking = true
142
+ @attack_frame = 0
143
+ @attack_tics = 0
144
+
145
+ # Consume ammo
146
+ case @weapon
147
+ when WEAPON_PISTOL
148
+ @ammo_bullets -= 1 if @ammo_bullets > 0
149
+ when WEAPON_SHOTGUN
150
+ @ammo_shells -= 1 if @ammo_shells > 0
151
+ when WEAPON_CHAINGUN
152
+ @ammo_bullets -= 1 if @ammo_bullets > 0
153
+ when WEAPON_ROCKET
154
+ @ammo_rockets -= 1 if @ammo_rockets > 0
155
+ when WEAPON_PLASMA
156
+ @ammo_cells -= 1 if @ammo_cells > 0
157
+ when WEAPON_BFG
158
+ @ammo_cells -= 40 if @ammo_cells >= 40
159
+ end
160
+ end
161
+
162
+ def update_attack
163
+ return unless @attacking
164
+
165
+ @attack_tics += 1
166
+
167
+ # Calculate which frame we're on based on tics
168
+ duration = ATTACK_DURATIONS[@weapon] || 8
169
+ frame_count = @weapon == WEAPON_FIST ? 3 : 4
170
+
171
+ tics_per_frame = duration / frame_count
172
+ @attack_frame = (@attack_tics / tics_per_frame).to_i
173
+
174
+ # Attack finished?
175
+ if @attack_tics >= duration
176
+ @attacking = false
177
+ @attack_frame = 0
178
+ @attack_tics = 0
179
+ end
180
+ end
181
+
182
+ def update_bob(delta_time)
183
+ if @is_moving
184
+ # Increase bob while moving
185
+ @bob_angle += delta_time * 10.0
186
+ @bob_amount = [@bob_amount + delta_time * 16.0, 6.0].min
187
+ else
188
+ # Decay bob when stopped
189
+ @bob_amount = [@bob_amount - delta_time * 12.0, 0.0].max
190
+ end
191
+ end
192
+
193
+ def weapon_bob_x
194
+ Math.cos(@bob_angle) * @bob_amount
195
+ end
196
+
197
+ def weapon_bob_y
198
+ Math.sin(@bob_angle * 2) * @bob_amount * 0.5
199
+ end
200
+
201
+ def health_level
202
+ # 0 = dying, 4 = full health
203
+ case @health
204
+ when 80..200 then 4
205
+ when 60..79 then 3
206
+ when 40..59 then 2
207
+ when 20..39 then 1
208
+ else 0
209
+ end
210
+ end
211
+
212
+ def switch_weapon(weapon_num)
213
+ return unless weapon_num >= 0 && weapon_num < 8
214
+ return unless @has_weapons[weapon_num]
215
+ return if @attacking
216
+
217
+ @weapon = weapon_num
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Manages animated sector actions (doors, lifts, etc.)
6
+ class SectorActions
7
+ # Door states
8
+ DOOR_CLOSED = 0
9
+ DOOR_OPENING = 1
10
+ DOOR_OPEN = 2
11
+ DOOR_CLOSING = 3
12
+
13
+ # Door speeds (units per tic, 35 tics/sec)
14
+ DOOR_SPEED = 2
15
+ DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
16
+ PLAYER_HEIGHT = 56 # Player height for door collision
17
+
18
+ def initialize(map)
19
+ @map = map
20
+ @active_doors = {} # sector_index => door_state
21
+ @player_x = 0
22
+ @player_y = 0
23
+ end
24
+
25
+ def update_player_position(x, y)
26
+ @player_x = x
27
+ @player_y = y
28
+ end
29
+
30
+ def update
31
+ update_doors
32
+ end
33
+
34
+ # Try to use a linedef (called when player presses use key)
35
+ def use_linedef(linedef, linedef_idx)
36
+ return false if linedef.special == 0
37
+
38
+ case linedef.special
39
+ when 1 # DR Door Open Wait Close
40
+ activate_door(linedef)
41
+ true
42
+ when 31 # D1 Door Open Stay
43
+ activate_door(linedef, stay_open: true)
44
+ true
45
+ when 26 # DR Blue Door
46
+ activate_door(linedef, key: :blue_card)
47
+ true
48
+ when 27 # DR Yellow Door
49
+ activate_door(linedef, key: :yellow_card)
50
+ true
51
+ when 28 # DR Red Door
52
+ activate_door(linedef, key: :red_card)
53
+ true
54
+ else
55
+ false
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def activate_door(linedef, stay_open: false, key: nil)
62
+ # Find the sector on the back side of the linedef
63
+ return unless linedef.two_sided?
64
+
65
+ back_sidedef_idx = linedef.sidedef_left
66
+ return if back_sidedef_idx == 0xFFFF || back_sidedef_idx < 0
67
+
68
+ back_sidedef = @map.sidedefs[back_sidedef_idx]
69
+ sector_idx = back_sidedef.sector
70
+ sector = @map.sectors[sector_idx]
71
+ return unless sector
72
+
73
+ # Check if door is already active
74
+ if @active_doors[sector_idx]
75
+ door = @active_doors[sector_idx]
76
+ # If closing, reverse direction
77
+ if door[:state] == DOOR_CLOSING
78
+ door[:state] = DOOR_OPENING
79
+ end
80
+ return
81
+ end
82
+
83
+ # Calculate target height (find lowest adjacent ceiling)
84
+ target_height = find_lowest_ceiling_around(sector_idx) - 4
85
+
86
+ # Start the door
87
+ @active_doors[sector_idx] = {
88
+ sector: sector,
89
+ state: DOOR_OPENING,
90
+ target_height: target_height,
91
+ original_height: sector.ceiling_height,
92
+ wait_tics: 0,
93
+ stay_open: stay_open
94
+ }
95
+ end
96
+
97
+ def update_doors
98
+ @active_doors.each do |sector_idx, door|
99
+ case door[:state]
100
+ when DOOR_OPENING
101
+ door[:sector].ceiling_height += DOOR_SPEED
102
+ if door[:sector].ceiling_height >= door[:target_height]
103
+ door[:sector].ceiling_height = door[:target_height]
104
+ if door[:stay_open]
105
+ @active_doors.delete(sector_idx)
106
+ else
107
+ door[:state] = DOOR_OPEN
108
+ door[:wait_tics] = DOOR_WAIT
109
+ end
110
+ end
111
+
112
+ when DOOR_OPEN
113
+ door[:wait_tics] -= 1
114
+ if door[:wait_tics] <= 0
115
+ door[:state] = DOOR_CLOSING
116
+ end
117
+
118
+ when DOOR_CLOSING
119
+ # Check if player is in the door sector
120
+ player_sector = @map.sector_at(@player_x, @player_y)
121
+ if player_sector == door[:sector]
122
+ # Player is in door - reopen it
123
+ door[:state] = DOOR_OPENING
124
+ next
125
+ end
126
+
127
+ door[:sector].ceiling_height -= DOOR_SPEED
128
+ if door[:sector].ceiling_height <= door[:original_height]
129
+ door[:sector].ceiling_height = door[:original_height]
130
+ @active_doors.delete(sector_idx)
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ def find_lowest_ceiling_around(sector_idx)
137
+ lowest = Float::INFINITY
138
+
139
+ @map.linedefs.each do |linedef|
140
+ next unless linedef.two_sided?
141
+
142
+ # Check if this linedef touches our sector
143
+ right_sidedef = @map.sidedefs[linedef.sidedef_right]
144
+ left_sidedef = @map.sidedefs[linedef.sidedef_left] if linedef.sidedef_left != 0xFFFF
145
+
146
+ adjacent_sector = nil
147
+ if right_sidedef&.sector == sector_idx && left_sidedef
148
+ adjacent_sector = @map.sectors[left_sidedef.sector]
149
+ elsif left_sidedef&.sector == sector_idx
150
+ adjacent_sector = @map.sectors[right_sidedef.sector]
151
+ end
152
+
153
+ if adjacent_sector
154
+ lowest = [lowest, adjacent_sector.ceiling_height].min
155
+ end
156
+ end
157
+
158
+ lowest == Float::INFINITY ? 128 : lowest
159
+ end
160
+ end
161
+ end
162
+ end
@@ -13,24 +13,62 @@ module Doom
13
13
  MOUSE_SENSITIVITY = 0.15 # Mouse look sensitivity
14
14
  PLAYER_RADIUS = 16.0 # Collision radius
15
15
 
16
- def initialize(renderer, palette, map)
16
+ USE_DISTANCE = 64.0 # Max distance to use a linedef
17
+
18
+ def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil)
17
19
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, false)
18
20
  self.caption = 'Doom Ruby'
19
21
 
20
22
  @renderer = renderer
21
23
  @palette = palette
22
24
  @map = map
25
+ @player_state = player_state
26
+ @status_bar = status_bar
27
+ @weapon_renderer = weapon_renderer
28
+ @sector_actions = sector_actions
23
29
  @screen_image = nil
24
30
  @mouse_captured = false
25
31
  @last_mouse_x = nil
32
+ @last_update_time = Time.now
33
+ @use_pressed = false
26
34
 
27
35
  # Pre-build palette lookup for speed
28
36
  @palette_rgba = palette.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
29
37
  end
30
38
 
31
39
  def update
40
+ # Calculate delta time for smooth animations
41
+ now = Time.now
42
+ delta_time = now - @last_update_time
43
+ @last_update_time = now
44
+
32
45
  handle_input
46
+
47
+ # Update player state
48
+ if @player_state
49
+ @player_state.update_attack
50
+ @player_state.update_bob(delta_time)
51
+ end
52
+
53
+ # Update HUD animations
54
+ @status_bar&.update
55
+
56
+ # Update sector actions (doors, lifts, etc.)
57
+ if @sector_actions
58
+ @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
59
+ @sector_actions.update
60
+ end
61
+
62
+ # Render the 3D world
33
63
  @renderer.render_frame
64
+
65
+ # Render HUD on top
66
+ if @weapon_renderer
67
+ @weapon_renderer.render(@renderer.framebuffer)
68
+ end
69
+ if @status_bar
70
+ @status_bar.render(@renderer.framebuffer)
71
+ end
34
72
  end
35
73
 
36
74
  def handle_input
@@ -68,10 +106,149 @@ module Doom
68
106
  move_y += @renderer.cos_angle * MOVE_SPEED
69
107
  end
70
108
 
109
+ # Track if player is moving (for weapon bob)
110
+ is_moving = move_x != 0.0 || move_y != 0.0
111
+ @player_state.is_moving = is_moving if @player_state
112
+
71
113
  # Apply movement with collision detection
72
- if move_x != 0.0 || move_y != 0.0
114
+ if is_moving
73
115
  try_move(move_x, move_y)
74
116
  end
117
+
118
+ # Handle firing
119
+ if @player_state && @mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)
120
+ @player_state.start_attack
121
+ end
122
+
123
+ # Handle weapon switching with number keys
124
+ handle_weapon_switch if @player_state
125
+
126
+ # Handle use key (spacebar or E)
127
+ handle_use_key if @sector_actions
128
+ end
129
+
130
+ def handle_use_key
131
+ use_down = Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)
132
+
133
+ if use_down && !@use_pressed
134
+ @use_pressed = true
135
+ try_use_linedef
136
+ elsif !use_down
137
+ @use_pressed = false
138
+ end
139
+ end
140
+
141
+ def try_use_linedef
142
+ # Cast a ray forward to find a usable linedef
143
+ player_x = @renderer.player_x
144
+ player_y = @renderer.player_y
145
+ cos_angle = @renderer.cos_angle
146
+ sin_angle = @renderer.sin_angle
147
+
148
+ # Check point in front of player
149
+ use_x = player_x + cos_angle * USE_DISTANCE
150
+ use_y = player_y + sin_angle * USE_DISTANCE
151
+
152
+ # Find the closest linedef the player is facing
153
+ best_linedef = nil
154
+ best_idx = nil
155
+ best_dist = Float::INFINITY
156
+
157
+ @map.linedefs.each_with_index do |linedef, idx|
158
+ next if linedef.special == 0 # Skip non-special linedefs
159
+
160
+ v1 = @map.vertices[linedef.v1]
161
+ v2 = @map.vertices[linedef.v2]
162
+
163
+ # Check if player is close enough to the linedef
164
+ dist = point_to_line_distance(player_x, player_y, v1.x, v1.y, v2.x, v2.y)
165
+ next if dist > USE_DISTANCE
166
+ next if dist >= best_dist
167
+
168
+ # Check if player is facing the linedef (on the front side)
169
+ next unless facing_linedef?(player_x, player_y, cos_angle, sin_angle, v1, v2)
170
+
171
+ best_linedef = linedef
172
+ best_idx = idx
173
+ best_dist = dist
174
+ end
175
+
176
+ if best_linedef
177
+ @sector_actions.use_linedef(best_linedef, best_idx)
178
+ end
179
+ end
180
+
181
+ def point_to_line_distance(px, py, x1, y1, x2, y2)
182
+ # Vector from line start to point
183
+ dx = px - x1
184
+ dy = py - y1
185
+
186
+ # Line direction vector
187
+ line_dx = x2 - x1
188
+ line_dy = y2 - y1
189
+ line_len_sq = line_dx * line_dx + line_dy * line_dy
190
+
191
+ return Math.sqrt(dx * dx + dy * dy) if line_len_sq == 0
192
+
193
+ # Project point onto line, clamped to segment
194
+ t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
195
+ t = [[t, 0.0].max, 1.0].min
196
+
197
+ # Closest point on line segment
198
+ closest_x = x1 + t * line_dx
199
+ closest_y = y1 + t * line_dy
200
+
201
+ # Distance from point to closest point on segment
202
+ dist_x = px - closest_x
203
+ dist_y = py - closest_y
204
+ Math.sqrt(dist_x * dist_x + dist_y * dist_y)
205
+ end
206
+
207
+ def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
208
+ # Calculate linedef normal (perpendicular to line, pointing to front side)
209
+ line_dx = v2.x - v1.x
210
+ line_dy = v2.y - v1.y
211
+
212
+ # Normal points to the right of the line direction
213
+ normal_x = -line_dy
214
+ normal_y = line_dx
215
+
216
+ # Normalize
217
+ len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
218
+ return false if len == 0
219
+
220
+ normal_x /= len
221
+ normal_y /= len
222
+
223
+ # Check if player is on the front side (normal side) of the line
224
+ to_player_x = px - v1.x
225
+ to_player_y = py - v1.y
226
+ dot_player = to_player_x * normal_x + to_player_y * normal_y
227
+
228
+ # Player must be on front side
229
+ return false if dot_player < 0
230
+
231
+ # Check if player is facing toward the line
232
+ dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
233
+ dot_facing > 0.5 # Must be roughly facing the line
234
+ end
235
+
236
+ def handle_weapon_switch
237
+ if Gosu.button_down?(Gosu::KB_1)
238
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_FIST)
239
+ elsif Gosu.button_down?(Gosu::KB_2)
240
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_PISTOL)
241
+ elsif Gosu.button_down?(Gosu::KB_3)
242
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_SHOTGUN)
243
+ elsif Gosu.button_down?(Gosu::KB_4)
244
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_CHAINGUN)
245
+ elsif Gosu.button_down?(Gosu::KB_5)
246
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_ROCKET)
247
+ elsif Gosu.button_down?(Gosu::KB_6)
248
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_PLASMA)
249
+ elsif Gosu.button_down?(Gosu::KB_7)
250
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_BFG)
251
+ end
75
252
  end
76
253
 
77
254
  def try_move(dx, dy)