doom 0.5.0 → 0.8.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.
@@ -7,13 +7,12 @@ module Doom
7
7
  # Weapon is rendered above the status bar
8
8
  WEAPON_AREA_HEIGHT = SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT
9
9
 
10
- # DOOM positions weapon sprites using their built-in offsets:
11
- # x = SCREENWIDTH/2 - sprite.left_offset
12
- # y = WEAPONTOP + SCREENHEIGHT - 200 - sprite.top_offset
13
- # WEAPONTOP = 32 in fixed-point = 32 pixels above the default position
14
- # We scale for our 240px screen vs DOOM's 200px.
10
+ # Chocolate Doom R_DrawPSprite weapon positioning:
11
+ # centery = viewheight/2 (view area excluding status bar)
12
+ # texturemid = centery - (WEAPONTOP - spritetopoffset)
13
+ # dc_yl = centery - texturemid (first visible row)
15
14
  WEAPONTOP = 32
16
- SCREEN_Y_OFFSET = SCREEN_HEIGHT - 200 # 40px offset for 240px screen
15
+ VIEW_CENTERY = (SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT) / 2 # 104
17
16
 
18
17
  def initialize(hud_graphics, player_state)
19
18
  @gfx = hud_graphics
@@ -26,7 +25,7 @@ module Doom
26
25
  return unless weapon_data
27
26
 
28
27
  # Get the appropriate frame
29
- sprite = if @player.attacking && weapon_data[:fire]
28
+ sprite = if @player.attacking && weapon_data[:fire]&.any?
30
29
  frame = @player.attack_frame.clamp(0, weapon_data[:fire].length - 1)
31
30
  weapon_data[:fire][frame]
32
31
  else
@@ -39,11 +38,11 @@ module Doom
39
38
  bob_x = @player.attacking ? 0 : @player.weapon_bob_x.to_i
40
39
  bob_y = @player.attacking ? 0 : @player.weapon_bob_y.to_i
41
40
 
42
- # DOOM's R_DrawPSprite: x1 = centerx + (psp->sx - centerx - spriteoffset)
43
- # With psp->sx = 1 (default): x = 1 - left_offset
44
- # Uses sprite's built-in offsets for both weapon and flash alignment
41
+ # Chocolate Doom on 200px: dc_yl = WEAPONTOP - topoffset
42
+ # Our view area is 208px (240-32 status bar) vs DOOM's 168px (200-32)
43
+ # Offset by half the extra height to keep weapon centered in view
45
44
  x = 1 - sprite.left_offset + bob_x
46
- y = 1 - sprite.top_offset + SCREEN_Y_OFFSET + bob_y
45
+ y = WEAPONTOP - sprite.top_offset + 20 + bob_y
47
46
 
48
47
  draw_weapon_sprite(framebuffer, sprite, x, y)
49
48
 
@@ -90,7 +89,7 @@ module Doom
90
89
  # Flash uses same positioning as weapon sprite (built-in offsets)
91
90
  # Same positioning formula as weapon sprite
92
91
  flash_x = 1 - flash_sprite.left_offset
93
- flash_y = 1 - flash_sprite.top_offset + SCREEN_Y_OFFSET
92
+ flash_y = WEAPONTOP - flash_sprite.top_offset + 20
94
93
 
95
94
  draw_weapon_sprite(framebuffer, flash_sprite, flash_x, flash_y)
96
95
  end
data/lib/doom/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Doom
4
- VERSION = '0.5.0'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -45,8 +45,60 @@ module Doom
45
45
  @directory[start_idx + 1...end_idx]
46
46
  end
47
47
 
48
+ def pwad?
49
+ @type == PWAD
50
+ end
51
+
52
+ def iwad?
53
+ @type == IWAD
54
+ end
55
+
56
+ # Merge a PWAD on top of this IWAD.
57
+ # PWAD lumps override IWAD lumps with the same name.
58
+ # Map lumps (between map markers) are replaced as a group.
59
+ def merge_pwad(pwad)
60
+ raise Error, "Can only merge a PWAD" unless pwad.pwad?
61
+ @lump_cache.clear
62
+
63
+ pwad.directory.each do |pwad_entry|
64
+ # Check if this lump already exists in the IWAD
65
+ existing_idx = @directory.index { |e| e.name == pwad_entry.name }
66
+ if existing_idx
67
+ # Replace it -- but we need to read from the PWAD file
68
+ @directory[existing_idx] = pwad_entry
69
+ else
70
+ # Append new lump
71
+ @directory << pwad_entry
72
+ end
73
+ end
74
+
75
+ # Keep the PWAD file open for reading its lumps
76
+ @pwad_files ||= []
77
+ @pwad_files << pwad
78
+ @num_lumps = @directory.size
79
+ end
80
+
81
+ # Override read_lump_at to check if entry belongs to a PWAD file
82
+ def read_lump_at(entry)
83
+ # Try PWAD files first (they may own this entry)
84
+ (@pwad_files || []).each do |pwad|
85
+ if pwad.directory.include?(entry)
86
+ return pwad.read_lump_own(entry)
87
+ end
88
+ end
89
+ @file.seek(entry.offset)
90
+ @file.read(entry.size)
91
+ end
92
+
93
+ # Read a lump that belongs to this WAD's own file
94
+ def read_lump_own(entry)
95
+ @file.seek(entry.offset)
96
+ @file.read(entry.size)
97
+ end
98
+
48
99
  def close
49
100
  @file.close
101
+ (@pwad_files || []).each(&:close)
50
102
  end
51
103
 
52
104
  private
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module Doom
6
+ module Wad
7
+ # Loads DOOM sound effects from WAD and converts to WAV for Gosu playback.
8
+ # DOOM sound format: 8-byte header (2 format, 2 sample_rate, 4 num_samples)
9
+ # followed by unsigned 8-bit PCM samples.
10
+ class SoundManager
11
+ def initialize(wad)
12
+ @wad = wad
13
+ @cache = {} # name => Gosu::Sample
14
+ @temp_dir = File.join(Dir.tmpdir, "doom_rb_sounds_#{Process.pid}")
15
+ Dir.mkdir(@temp_dir) unless Dir.exist?(@temp_dir)
16
+ end
17
+
18
+ # Get or load a sound effect. Returns a Gosu::Sample or nil.
19
+ def [](name)
20
+ return @cache[name] if @cache.key?(name)
21
+
22
+ lump_name = name.start_with?('DS') ? name : "DS#{name}"
23
+ entry = @wad.find_lump(lump_name)
24
+ return @cache[name] = nil unless entry
25
+
26
+ data = @wad.read_lump_at(entry)
27
+ return @cache[name] = nil if data.size < 8
28
+
29
+ # Parse DOOM sound header
30
+ _format = data[0, 2].unpack1('v')
31
+ sample_rate = data[2, 2].unpack1('v')
32
+ num_samples = data[4, 4].unpack1('V')
33
+
34
+ # PCM data starts at offset 8, skip 16 padding bytes at start and end
35
+ pcm_start = 8 + 16
36
+ pcm_end = 8 + num_samples - 16
37
+ pcm_data = data[pcm_start...pcm_end]
38
+ return @cache[name] = nil unless pcm_data && pcm_data.size > 0
39
+
40
+ # Convert to WAV file for Gosu
41
+ wav_path = File.join(@temp_dir, "#{lump_name}.wav")
42
+ write_wav(wav_path, pcm_data, sample_rate) unless File.exist?(wav_path)
43
+
44
+ @cache[name] = Gosu::Sample.new(wav_path)
45
+ rescue => e
46
+ @cache[name] = nil
47
+ end
48
+
49
+ def cleanup
50
+ FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
51
+ end
52
+
53
+ private
54
+
55
+ # Write unsigned 8-bit PCM data as a WAV file
56
+ def write_wav(path, pcm_data, sample_rate)
57
+ num_samples = pcm_data.size
58
+ data_size = num_samples
59
+ file_size = 36 + data_size
60
+
61
+ File.open(path, 'wb') do |f|
62
+ # RIFF header
63
+ f.write("RIFF")
64
+ f.write([file_size].pack('V'))
65
+ f.write("WAVE")
66
+
67
+ # fmt chunk
68
+ f.write("fmt ")
69
+ f.write([16].pack('V')) # chunk size
70
+ f.write([1].pack('v')) # PCM format
71
+ f.write([1].pack('v')) # mono
72
+ f.write([sample_rate].pack('V')) # sample rate
73
+ f.write([sample_rate].pack('V')) # byte rate (sample_rate * 1 * 1)
74
+ f.write([1].pack('v')) # block align
75
+ f.write([8].pack('v')) # bits per sample
76
+
77
+ # data chunk
78
+ f.write("data")
79
+ f.write([data_size].pack('V'))
80
+ f.write(pcm_data)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -120,6 +120,12 @@ module Doom
120
120
  48 => 'ELEC', # Tall tech column
121
121
  35 => 'CBRA', # Candelabra
122
122
 
123
+ # Dead bodies / gore decorations
124
+ 10 => 'PLAY', # Bloody mess
125
+ 12 => 'PLAY', # Bloody mess 2
126
+ 15 => 'PLAY', # Dead player
127
+ 24 => 'POL5', # Pool of blood and flesh
128
+
123
129
  # Barrels
124
130
  2035 => 'BAR1', # Exploding barrel
125
131
 
@@ -136,6 +142,17 @@ module Doom
136
142
  16 => 'CYBR', # Cyberdemon
137
143
  }.freeze
138
144
 
145
+ # Things that use a specific frame instead of 'A'
146
+ # From DOOM info.h mobjinfo spawnstate:
147
+ # MT_MISC10 (type 10) -> S_PLAY_XDIE9 = PLAY W (gibbed mess)
148
+ # MT_MISC12 (type 12) -> S_PLAY_DIE7 = PLAY N (dead body)
149
+ # MT_MISC15 (type 15) -> S_PLAY_DIE7 = PLAY N (dead body)
150
+ THING_DEFAULT_FRAME = {
151
+ 10 => 'W', # Bloody mess (gibbed)
152
+ 12 => 'N', # Bloody mess 2 (dead body flat)
153
+ 15 => 'N', # Dead player (dead body flat)
154
+ }.freeze
155
+
139
156
  def initialize(wad)
140
157
  @wad = wad
141
158
  @cache = {}
@@ -154,8 +171,9 @@ module Doom
154
171
  prefix = THING_SPRITES[thing_type]
155
172
  return nil unless prefix
156
173
 
157
- sprite = load_sprite_frame(prefix, 'A', 0) ||
158
- load_sprite_frame(prefix, 'A', 1)
174
+ frame = THING_DEFAULT_FRAME[thing_type] || 'A'
175
+ sprite = load_sprite_frame(prefix, frame, 0) ||
176
+ load_sprite_frame(prefix, frame, 1)
159
177
 
160
178
  @cache[thing_type] = sprite
161
179
  sprite
@@ -173,18 +191,21 @@ module Doom
173
191
  prefix = THING_SPRITES[thing_type]
174
192
  return nil unless prefix
175
193
 
194
+ frame = THING_DEFAULT_FRAME[thing_type] || 'A'
195
+
176
196
  # Check for rotation 0 (all angles) sprite first
177
- sprite = load_sprite_frame(prefix, 'A', 0)
197
+ sprite = load_sprite_frame(prefix, frame, 0)
178
198
  return sprite if sprite
179
199
 
180
200
  # Calculate rotation frame (1-8)
181
- # Doom rotations: 1=front, 2=front-right, 3=right, etc. (clockwise)
182
- angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0)
201
+ # DOOM: rot = (R_PointToAngle(thing) - thing->angle + ANG45/2*9) >> 29
202
+ # Rotation 1=front (viewer faces monster's front), 5=back
203
+ angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
183
204
  angle_diff = angle_diff % (2 * Math::PI)
184
205
  angle_diff += 2 * Math::PI if angle_diff < 0
185
206
  rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
186
207
 
187
- load_sprite_frame(prefix, 'A', rotation) || @cache[thing_type]
208
+ load_sprite_frame(prefix, frame, rotation) || @cache[thing_type]
188
209
  end
189
210
 
190
211
  # Get a specific frame (for death animations, etc.)
@@ -196,8 +217,8 @@ module Doom
196
217
  sprite = load_sprite_frame(prefix, frame_letter, 0)
197
218
  return sprite if sprite
198
219
 
199
- # Try with calculated rotation
200
- angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0)
220
+ # Try with calculated rotation (same formula as get_rotated)
221
+ angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
201
222
  angle_diff = angle_diff % (2 * Math::PI)
202
223
  angle_diff += 2 * Math::PI if angle_diff < 0
203
224
  rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
@@ -205,6 +226,11 @@ module Doom
205
226
  load_sprite_frame(prefix, frame_letter, rotation)
206
227
  end
207
228
 
229
+ # Get a frame by explicit prefix (for barrel explosions where prefix differs from thing type)
230
+ def get_frame_by_prefix(prefix, frame_letter)
231
+ load_sprite_frame(prefix, frame_letter, 0)
232
+ end
233
+
208
234
  private
209
235
 
210
236
  def build_sprite_index
@@ -148,7 +148,7 @@ module Doom
148
148
  def precompute_columns
149
149
  @width.times do |x|
150
150
  posts = @columns[x]
151
- pixels = Array.new(@height, 0)
151
+ pixels = Array.new(@height) # nil = transparent
152
152
  posts.each do |post|
153
153
  td = post.top_delta
154
154
  post.pixels.each_with_index do |color, i|
@@ -66,7 +66,16 @@ module Doom
66
66
  uri = URI.parse(SHAREWARE_URL)
67
67
  downloaded = 0
68
68
 
69
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
69
+ ssl_opts = { use_ssl: uri.scheme == 'https' }
70
+ # Workaround for SSL certificate issues on some systems
71
+ begin
72
+ Net::HTTP.start(uri.host, uri.port, **ssl_opts) { |h| h.head(uri) }
73
+ rescue OpenSSL::SSL::SSLError
74
+ puts "SSL certificate verification failed, retrying without verification..."
75
+ ssl_opts[:verify_mode] = OpenSSL::SSL::VERIFY_NONE
76
+ end
77
+
78
+ Net::HTTP.start(uri.host, uri.port, **ssl_opts) do |http|
70
79
  request = Net::HTTP::Get.new(uri)
71
80
 
72
81
  http.request(request) do |response|
data/lib/doom.rb CHANGED
@@ -17,7 +17,14 @@ require_relative 'doom/game/animations'
17
17
  require_relative 'doom/game/item_pickup'
18
18
  require_relative 'doom/game/combat'
19
19
  require_relative 'doom/game/sector_effects'
20
+ require_relative 'doom/game/monster_ai'
21
+ require_relative 'doom/game/menu'
22
+ require_relative 'doom/game/intermission'
23
+ require_relative 'doom/wad/sound'
24
+ require_relative 'doom/game/sound_engine'
25
+ require_relative 'doom/render/font'
20
26
  require_relative 'doom/render/renderer'
27
+ require_relative 'doom/render/screen_melt'
21
28
  require_relative 'doom/render/status_bar'
22
29
  require_relative 'doom/render/weapon_renderer'
23
30
  require_relative 'doom/platform/gosu_window'
@@ -26,11 +33,26 @@ module Doom
26
33
  class Error < StandardError; end
27
34
 
28
35
  class << self
29
- def run(wad_path, map_name: 'E1M1')
36
+ def run(wad_path, map_name: 'E1M1', rubykaigi: false)
30
37
  puts "Loading WAD: #{wad_path}"
31
38
  wad = Wad::Reader.new(wad_path)
32
39
  puts " #{wad.type}: #{wad.num_lumps} lumps"
33
40
 
41
+ # If it's a PWAD, we need a base IWAD for resources (palette, textures, etc.)
42
+ if wad.pwad?
43
+ iwad_path = find_iwad
44
+ raise Error, "PWAD requires a base IWAD (doom1.wad). Place it in the current directory or ~/.doom/" unless iwad_path
45
+ puts " PWAD detected, loading base IWAD: #{iwad_path}"
46
+ base_wad = Wad::Reader.new(iwad_path)
47
+ base_wad.merge_pwad(wad)
48
+ wad = base_wad
49
+
50
+ # Auto-detect map name from PWAD lumps
51
+ pwad_map = wad.directory.find { |e| e.name =~ /^(E\dM\d|MAP\d\d)$/ }
52
+ map_name = pwad_map.name if pwad_map
53
+ puts " Using map: #{map_name}"
54
+ end
55
+
34
56
  puts 'Loading palette...'
35
57
  palette = Wad::Palette.load(wad)
36
58
 
@@ -77,14 +99,36 @@ module Doom
77
99
  player_state = Game::PlayerState.new
78
100
  status_bar = Render::StatusBar.new(hud_graphics, player_state)
79
101
  weapon_renderer = Render::WeaponRenderer.new(hud_graphics, player_state)
80
- sector_actions = Game::SectorActions.new(map)
102
+ sound_mgr = Wad::SoundManager.new(wad)
103
+ sound_engine = Game::SoundEngine.new(sound_mgr)
104
+ sector_actions = Game::SectorActions.new(map, sound_engine)
81
105
  sector_effects = Game::SectorEffects.new(map)
82
106
  item_pickup = Game::ItemPickup.new(map, player_state)
83
- combat = Game::Combat.new(map, player_state, sprites)
107
+ combat = Game::Combat.new(map, player_state, sprites, {}, sound_engine)
108
+ monster_ai = Game::MonsterAI.new(map, combat, player_state, sprites, {}, sound_engine)
109
+ doom_font = Render::Font.new(wad, hud_graphics)
110
+ menu = Game::Menu.new(wad, hud_graphics, doom_font)
111
+
112
+ # RubyKaigi mode: god mode, no aggression, benchmark HUD
113
+ if rubykaigi
114
+ menu.options[:rubykaigi_mode] = true
115
+ menu.options[:god_mode] = true
116
+ end
84
117
 
85
118
  puts 'Starting game window...'
86
- window = Platform::GosuWindow.new(renderer, palette, map, player_state, status_bar, weapon_renderer, sector_actions, animations, sector_effects, item_pickup, combat)
119
+ window = Platform::GosuWindow.new(renderer, palette, map, player_state, status_bar, weapon_renderer, sector_actions, animations, sector_effects, item_pickup, combat, monster_ai, menu, sound_engine)
87
120
  window.show
88
121
  end
122
+
123
+ private
124
+
125
+ def find_iwad
126
+ candidates = [
127
+ File.join(Dir.pwd, 'doom1.wad'),
128
+ File.join(Dir.pwd, 'doom.wad'),
129
+ File.join(Dir.home, '.doom', 'doom1.wad'),
130
+ ]
131
+ candidates.find { |p| File.exist?(p) }
132
+ end
89
133
  end
90
134
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: doom
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Hasinski
@@ -65,13 +65,19 @@ files:
65
65
  - lib/doom.rb
66
66
  - lib/doom/game/animations.rb
67
67
  - lib/doom/game/combat.rb
68
+ - lib/doom/game/intermission.rb
68
69
  - lib/doom/game/item_pickup.rb
70
+ - lib/doom/game/menu.rb
71
+ - lib/doom/game/monster_ai.rb
69
72
  - lib/doom/game/player_state.rb
70
73
  - lib/doom/game/sector_actions.rb
71
74
  - lib/doom/game/sector_effects.rb
75
+ - lib/doom/game/sound_engine.rb
72
76
  - lib/doom/map/data.rb
73
77
  - lib/doom/platform/gosu_window.rb
78
+ - lib/doom/render/font.rb
74
79
  - lib/doom/render/renderer.rb
80
+ - lib/doom/render/screen_melt.rb
75
81
  - lib/doom/render/status_bar.rb
76
82
  - lib/doom/render/weapon_renderer.rb
77
83
  - lib/doom/version.rb
@@ -81,10 +87,11 @@ files:
81
87
  - lib/doom/wad/palette.rb
82
88
  - lib/doom/wad/patch.rb
83
89
  - lib/doom/wad/reader.rb
90
+ - lib/doom/wad/sound.rb
84
91
  - lib/doom/wad/sprite.rb
85
92
  - lib/doom/wad/texture.rb
86
93
  - lib/doom/wad_downloader.rb
87
- homepage: https://github.com/khasinski/doom-rb
94
+ homepage: https://github.com/khasinski/doom
88
95
  licenses:
89
96
  - GPL-2.0
90
97
  metadata: {}
@@ -102,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
109
  - !ruby/object:Gem::Version
103
110
  version: '0'
104
111
  requirements: []
105
- rubygems_version: 4.0.3
112
+ rubygems_version: 4.0.6
106
113
  specification_version: 4
107
114
  summary: Doom engine port in pure Ruby
108
115
  test_files: []