doom 0.3.0 → 0.5.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: c4672b03d393f17c1cd9ece97869d7239a7af3e91d3ffa47b911b0ba3058f673
4
+ data.tar.gz: 56772f44ced59386c92f5319c0d25dc8612e96263d68ae7e9a240d26508571cf
5
5
  SHA512:
6
- metadata.gz: 6f5fdd281119ba4bec5a29b8f9cd32f8d0aa37d1674125c3095835ba24942e66e6356037c3e424f320578cac5b1ed94dc7738388d3ced296f8de22ce78e9e79e
7
- data.tar.gz: 35de5ff2f711aa4084a54d811d26b919cf8f1b00be1d9b06597b7e7aac09eab3ee8ff597b1ece2ac451d175dad2faf3c16bc73f3dbf56b50867407509fc4f234
6
+ metadata.gz: b11b52ed631217b002aae05a4f87b7ff5fcb208485b75dd9077ebcc222c3823c6ca74167e9bc6b7c3605c0481472abbc818e6445c8fa130c744fe8c54da53099
7
+ data.tar.gz: 808176138162ef4e21796dd995a6bd794eee89e4c631032134349746afd468d446b610a0939daf19c26a2706abac22635306d6e3fb1c1c93e9a70282a2114f7d
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](demo.gif)
6
6
 
7
7
  ## Features
8
8
 
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Animated texture/flat cycling, matching Chocolate Doom's P_InitPicAnims
6
+ # and P_UpdateSpecials from p_spec.c.
7
+ #
8
+ # All animations run at 8 tics per frame (8/35 sec ≈ 0.23s).
9
+ # Frames must be consecutive entries in the WAD; the engine uses
10
+ # start/end names to find the range.
11
+ class Animations
12
+ TICS_PER_FRAME = 8
13
+
14
+ # [is_texture, start_name, end_name]
15
+ # From Chocolate Doom animdefs[] in p_spec.c
16
+ ANIMDEFS = [
17
+ # Animated flats
18
+ [false, 'NUKAGE1', 'NUKAGE3'],
19
+ [false, 'FWATER1', 'FWATER4'],
20
+ [false, 'SWATER1', 'SWATER4'],
21
+ [false, 'LAVA1', 'LAVA4'],
22
+ [false, 'BLOOD1', 'BLOOD3'],
23
+ [false, 'RROCK05', 'RROCK08'],
24
+ [false, 'SLIME01', 'SLIME04'],
25
+ [false, 'SLIME05', 'SLIME08'],
26
+ [false, 'SLIME09', 'SLIME12'],
27
+ # Animated wall textures
28
+ [true, 'BLODGR1', 'BLODGR4'],
29
+ [true, 'SLADRIP1', 'SLADRIP3'],
30
+ [true, 'BLODRIP1', 'BLODRIP4'],
31
+ [true, 'FIREWALA', 'FIREWALL'],
32
+ [true, 'GSTFONT1', 'GSTFONT3'],
33
+ [true, 'FIRELAV3', 'FIRELAVA'],
34
+ [true, 'FIREMAG1', 'FIREMAG3'],
35
+ [true, 'FIREBLU1', 'FIREBLU2'],
36
+ [true, 'ROCKRED1', 'ROCKRED3'],
37
+ [true, 'BFALL1', 'BFALL4'],
38
+ [true, 'SFALL1', 'SFALL4'],
39
+ [true, 'WFALL1', 'WFALL4'],
40
+ [true, 'DBRAIN1', 'DBRAIN4'],
41
+ ].freeze
42
+
43
+ attr_reader :flat_translation, :texture_translation
44
+
45
+ def initialize(texture_names, flat_names)
46
+ @flat_translation = {} # flat_name -> current_frame_name
47
+ @texture_translation = {} # texture_name -> current_frame_name
48
+ @anims = []
49
+
50
+ ANIMDEFS.each do |is_texture, start_name, end_name|
51
+ names = is_texture ? texture_names : flat_names
52
+
53
+ start_idx = names.index(start_name)
54
+ end_idx = names.index(end_name)
55
+ next unless start_idx && end_idx
56
+ next if end_idx <= start_idx
57
+
58
+ frames = names[start_idx..end_idx]
59
+ next if frames.size < 2
60
+
61
+ @anims << {
62
+ is_texture: is_texture,
63
+ frames: frames,
64
+ speed: TICS_PER_FRAME,
65
+ }
66
+ end
67
+ end
68
+
69
+ # Call every game tic (or approximate with leveltime).
70
+ # Matches Chocolate Doom P_UpdateSpecials:
71
+ # pic = basepic + ((leveltime / speed + i) % numpics)
72
+ def update(leveltime)
73
+ @anims.each do |anim|
74
+ frames = anim[:frames]
75
+ numpics = frames.size
76
+ phase = leveltime / anim[:speed]
77
+ translation = anim[:is_texture] ? @texture_translation : @flat_translation
78
+
79
+ numpics.times do |i|
80
+ current_frame = frames[(phase + i) % numpics]
81
+ translation[frames[i]] = current_frame
82
+ end
83
+ end
84
+ end
85
+
86
+ # Translate a flat name to its current animation frame
87
+ def translate_flat(name)
88
+ @flat_translation[name] || name
89
+ end
90
+
91
+ # Translate a texture name to its current animation frame
92
+ def translate_texture(name)
93
+ @texture_translation[name] || name
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Hitscan weapon firing and monster state tracking.
6
+ # Matches Chocolate Doom's P_LineAttack / P_AimLineAttack from p_map.c.
7
+ class Combat
8
+ # Monster starting HP (from mobjinfo[] in info.c)
9
+ MONSTER_HP = {
10
+ 3004 => 20, # Zombieman
11
+ 9 => 30, # Shotgun Guy
12
+ 3001 => 60, # Imp
13
+ 3002 => 150, # Demon
14
+ 58 => 150, # Spectre
15
+ 3003 => 1000, # Baron of Hell
16
+ 69 => 500, # Hell Knight
17
+ 3005 => 400, # Cacodemon
18
+ 3006 => 100, # Lost Soul
19
+ 16 => 4000, # Cyberdemon
20
+ 7 => 3000, # Spider Mastermind
21
+ 65 => 70, # Heavy Weapon Dude
22
+ 64 => 700, # Archvile
23
+ 71 => 400, # Pain Elemental
24
+ 84 => 20, # Wolfenstein SS
25
+ }.freeze
26
+
27
+ MONSTER_RADIUS = {
28
+ 3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
29
+ 3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
30
+ 7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
31
+ }.freeze
32
+
33
+ # Normal death frame sequences per sprite prefix (rotation 0 only)
34
+ # Identified by sprite heights: frames go from standing height to flat on ground
35
+ DEATH_FRAMES = {
36
+ 'POSS' => %w[H I J K L], # Zombieman: 55→46→34→27→17
37
+ 'SPOS' => %w[H I J K L], # Shotgun Guy: 60→50→35→27→17
38
+ 'TROO' => %w[I J K L M], # Imp: 62→59→54→46→22
39
+ 'SARG' => %w[I J K L M N], # Demon/Spectre: 56→56→53→57→46→32
40
+ 'BOSS' => %w[H I J K L M N], # Baron
41
+ 'BOS2' => %w[H I J K L M N], # Hell Knight
42
+ 'HEAD' => %w[G H I J K L], # Cacodemon
43
+ 'SKUL' => %w[G H I J K], # Lost Soul
44
+ 'CYBR' => %w[I J], # Cyberdemon
45
+ 'SPID' => %w[I J K], # Spider Mastermind
46
+ 'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
47
+ 'PAIN' => %w[H I J K L M], # Pain Elemental
48
+ 'SSWV' => %w[I J K L M], # Wolfenstein SS
49
+ }.freeze
50
+
51
+ DEATH_ANIM_TICS = 6 # Tics per death frame
52
+
53
+ # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
54
+ # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
55
+ # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
56
+ # Fist/chainsaw: 1*2..3*2 = 2-10
57
+
58
+ def initialize(map, player_state, sprites)
59
+ @map = map
60
+ @player = player_state
61
+ @sprites = sprites
62
+ @monster_hp = {} # thing_idx => current HP
63
+ @dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
64
+ @tic = 0
65
+ end
66
+
67
+ attr_reader :dead_things
68
+
69
+ def dead?(thing_idx)
70
+ @dead_things.key?(thing_idx)
71
+ end
72
+
73
+ # Get the current death frame sprite for a dead monster
74
+ def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
75
+ info = @dead_things[thing_idx]
76
+ return nil unless info
77
+
78
+ frames = DEATH_FRAMES[info[:prefix]]
79
+ return nil unless frames
80
+
81
+ elapsed = @tic - info[:tic]
82
+ frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
83
+ frame_letter = frames[frame_idx]
84
+
85
+ @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
86
+ end
87
+
88
+ # Called each game tic
89
+ def update
90
+ @tic += 1
91
+ end
92
+
93
+ # Fire the current weapon
94
+ def fire(px, py, pz, cos_a, sin_a, weapon)
95
+ case weapon
96
+ when PlayerState::WEAPON_PISTOL, PlayerState::WEAPON_CHAINGUN
97
+ hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
98
+ when PlayerState::WEAPON_SHOTGUN
99
+ hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
100
+ when PlayerState::WEAPON_FIST
101
+ melee(px, py, cos_a, sin_a, 2, 64)
102
+ when PlayerState::WEAPON_CHAINSAW
103
+ melee(px, py, cos_a, sin_a, 2, 64)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
110
+ pellets.times do
111
+ # Add random spread
112
+ if spread > 0
113
+ angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * spread * 2
114
+ ca = Math.cos(angle)
115
+ sa = Math.sin(angle)
116
+ else
117
+ # Slight pistol/chaingun spread
118
+ angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * 0.04
119
+ ca = Math.cos(angle)
120
+ sa = Math.sin(angle)
121
+ end
122
+
123
+ wall_dist = trace_wall(px, py, ca, sa)
124
+
125
+ best_idx = nil
126
+ best_dist = wall_dist
127
+
128
+ @map.things.each_with_index do |thing, idx|
129
+ next unless MONSTER_HP[thing.type]
130
+ next if @dead_things[idx]
131
+
132
+ radius = MONSTER_RADIUS[thing.type] || 20
133
+ hit_dist = ray_circle_hit(px, py, ca, sa, thing.x, thing.y, radius)
134
+ if hit_dist && hit_dist > 0 && hit_dist < best_dist
135
+ best_dist = hit_dist
136
+ best_idx = idx
137
+ end
138
+ end
139
+
140
+ if best_idx
141
+ damage = (rand(3) + 1) * multiplier
142
+ apply_damage(best_idx, damage)
143
+ end
144
+ end
145
+ end
146
+
147
+ def melee(px, py, cos_a, sin_a, multiplier, range)
148
+ best_idx = nil
149
+ best_dist = range.to_f
150
+
151
+ @map.things.each_with_index do |thing, idx|
152
+ next unless MONSTER_HP[thing.type]
153
+ next if @dead_things[idx]
154
+
155
+ dx = thing.x - px
156
+ dy = thing.y - py
157
+ dist = Math.sqrt(dx * dx + dy * dy)
158
+ next if dist > range + (MONSTER_RADIUS[thing.type] || 20)
159
+
160
+ # Check if roughly facing the monster
161
+ dot = dx * cos_a + dy * sin_a
162
+ next if dot < 0
163
+
164
+ if dist < best_dist
165
+ best_dist = dist
166
+ best_idx = idx
167
+ end
168
+ end
169
+
170
+ if best_idx
171
+ damage = (rand(3) + 1) * multiplier
172
+ apply_damage(best_idx, damage)
173
+ end
174
+ end
175
+
176
+ def apply_damage(thing_idx, damage)
177
+ thing = @map.things[thing_idx]
178
+ @monster_hp[thing_idx] ||= MONSTER_HP[thing.type] || 20
179
+
180
+ @monster_hp[thing_idx] -= damage
181
+
182
+ if @monster_hp[thing_idx] <= 0
183
+ prefix = @sprites.prefix_for(thing.type)
184
+ @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
185
+ end
186
+ end
187
+
188
+ def trace_wall(px, py, cos_a, sin_a)
189
+ best_t = 4096.0 # Max hitscan range
190
+
191
+ @map.linedefs.each do |ld|
192
+ v1 = @map.vertices[ld.v1]
193
+ v2 = @map.vertices[ld.v2]
194
+
195
+ # One-sided always blocks; two-sided only if impassable
196
+ blocks = (ld.sidedef_left == 0xFFFF) || (ld.flags & 0x0001 != 0)
197
+ unless blocks
198
+ next unless ld.sidedef_left < 0xFFFF
199
+ front = @map.sidedefs[ld.sidedef_right]
200
+ back = @map.sidedefs[ld.sidedef_left]
201
+ fs = @map.sectors[front.sector]
202
+ bs = @map.sectors[back.sector]
203
+ # Blocks if opening is too small (step or low ceiling)
204
+ max_floor = [fs.floor_height, bs.floor_height].max
205
+ min_ceil = [fs.ceiling_height, bs.ceiling_height].min
206
+ blocks = (min_ceil - max_floor) < 56
207
+ end
208
+ next unless blocks
209
+
210
+ t = ray_segment_intersect(px, py, cos_a, sin_a,
211
+ v1.x, v1.y, v2.x, v2.y)
212
+ best_t = t if t && t > 0 && t < best_t
213
+ end
214
+
215
+ best_t
216
+ end
217
+
218
+ def ray_segment_intersect(px, py, dx, dy, x1, y1, x2, y2)
219
+ sx = x2 - x1
220
+ sy = y2 - y1
221
+ denom = dx * sy - dy * sx
222
+ return nil if denom.abs < 0.001
223
+
224
+ t = ((x1 - px) * sy - (y1 - py) * sx) / denom
225
+ u = ((x1 - px) * dy - (y1 - py) * dx) / denom
226
+
227
+ (t > 0 && u >= 0.0 && u <= 1.0) ? t : nil
228
+ end
229
+
230
+ def ray_circle_hit(px, py, cos_a, sin_a, cx, cy, radius)
231
+ dx = cx - px
232
+ dy = cy - py
233
+ proj = dx * cos_a + dy * sin_a
234
+ return nil if proj < 0
235
+
236
+ perp_sq = dx * dx + dy * dy - proj * proj
237
+ return nil if perp_sq > radius * radius
238
+
239
+ chord_half = Math.sqrt([radius * radius - perp_sq, 0].max)
240
+ proj - chord_half
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Handles item pickup when player touches things.
6
+ # Matches Chocolate Doom's P_TouchSpecialThing from p_inter.c.
7
+ class ItemPickup
8
+ PLAYER_RADIUS = 16.0
9
+ THING_RADIUS = 20.0
10
+ PICKUP_DIST = PLAYER_RADIUS + THING_RADIUS # 36 units (bounding box overlap)
11
+
12
+ # Item definitions: type => { category, value, ... }
13
+ ITEMS = {
14
+ # Weapons (give weapon + some ammo)
15
+ 2001 => { cat: :weapon, weapon: 2, ammo: :shells, amount: 8 }, # Shotgun
16
+ 2002 => { cat: :weapon, weapon: 3, ammo: :bullets, amount: 20 }, # Chaingun
17
+ 2003 => { cat: :weapon, weapon: 4, ammo: :rockets, amount: 2 }, # Rocket launcher
18
+ 2004 => { cat: :weapon, weapon: 5, ammo: :cells, amount: 40 }, # Plasma rifle
19
+ 2006 => { cat: :weapon, weapon: 6, ammo: :cells, amount: 40 }, # BFG9000
20
+ 2005 => { cat: :weapon, weapon: 7, ammo: :bullets, amount: 0 }, # Chainsaw
21
+
22
+ # Ammo
23
+ 2007 => { cat: :ammo, ammo: :bullets, amount: 10 }, # Clip
24
+ 2048 => { cat: :ammo, ammo: :bullets, amount: 50 }, # Box of bullets
25
+ 2008 => { cat: :ammo, ammo: :shells, amount: 4 }, # 4 shells
26
+ 2049 => { cat: :ammo, ammo: :shells, amount: 20 }, # Box of shells
27
+ 2010 => { cat: :ammo, ammo: :rockets, amount: 1 }, # Rocket
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)
32
+
33
+ # Health
34
+ 2014 => { cat: :health, amount: 1, max: 200 }, # Health bonus (+1, up to 200)
35
+ 2011 => { cat: :health, amount: 10, max: 100 }, # Stimpack
36
+ 2012 => { cat: :health, amount: 25, max: 100 }, # Medikit
37
+ 2013 => { cat: :health, amount: 100, max: 200 }, # Soul sphere
38
+
39
+ # Armor
40
+ 2015 => { cat: :armor, amount: 1, max: 200 }, # Armor bonus (+1, up to 200)
41
+ 2018 => { cat: :armor, amount: 100, armor_type: 1 }, # Green armor (100%, absorbs 1/3)
42
+ 2019 => { cat: :armor, amount: 200, armor_type: 2 }, # Blue armor (200%, absorbs 1/2)
43
+
44
+ # Keys
45
+ 5 => { cat: :key, key: :blue_card },
46
+ 6 => { cat: :key, key: :yellow_card },
47
+ 13 => { cat: :key, key: :red_card },
48
+ 40 => { cat: :key, key: :blue_skull },
49
+ 39 => { cat: :key, key: :yellow_skull },
50
+ 38 => { cat: :key, key: :red_skull },
51
+ }.freeze
52
+
53
+ attr_reader :picked_up
54
+
55
+ def initialize(map, player_state)
56
+ @map = map
57
+ @player = player_state
58
+ @picked_up = {} # thing index => true (to avoid re-picking)
59
+ end
60
+
61
+ def update(player_x, player_y)
62
+ @map.things.each_with_index do |thing, idx|
63
+ next if @picked_up[idx]
64
+ item = ITEMS[thing.type]
65
+ next unless item
66
+
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
70
+ next if dx >= PICKUP_DIST || dy >= PICKUP_DIST
71
+
72
+ if try_pickup(item)
73
+ @picked_up[idx] = true
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def try_pickup(item)
81
+ case item[:cat]
82
+ when :weapon
83
+ give_weapon(item)
84
+ when :ammo
85
+ give_ammo(item[:ammo], item[:amount])
86
+ when :backpack
87
+ give_backpack
88
+ when :health
89
+ give_health(item[:amount], item[:max])
90
+ when :armor
91
+ give_armor(item)
92
+ when :key
93
+ give_key(item[:key])
94
+ else
95
+ false
96
+ end
97
+ end
98
+
99
+ def give_weapon(item)
100
+ weapon_idx = item[:weapon]
101
+ had_weapon = @player.has_weapons[weapon_idx]
102
+
103
+ @player.has_weapons[weapon_idx] = true
104
+ ammo_given = item[:ammo] ? give_ammo(item[:ammo], item[:amount]) : false
105
+
106
+ unless had_weapon
107
+ @player.switch_weapon(weapon_idx) unless @player.attacking
108
+ return true
109
+ end
110
+
111
+ ammo_given
112
+ end
113
+
114
+ def give_ammo(type, amount)
115
+ case type
116
+ when :bullets
117
+ return false if @player.ammo_bullets >= @player.max_bullets
118
+ @player.ammo_bullets = [@player.ammo_bullets + amount, @player.max_bullets].min
119
+ when :shells
120
+ return false if @player.ammo_shells >= @player.max_shells
121
+ @player.ammo_shells = [@player.ammo_shells + amount, @player.max_shells].min
122
+ when :rockets
123
+ return false if @player.ammo_rockets >= @player.max_rockets
124
+ @player.ammo_rockets = [@player.ammo_rockets + amount, @player.max_rockets].min
125
+ when :cells
126
+ return false if @player.ammo_cells >= @player.max_cells
127
+ @player.ammo_cells = [@player.ammo_cells + amount, @player.max_cells].min
128
+ end
129
+ true
130
+ end
131
+
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)
141
+ true
142
+ end
143
+
144
+ def give_health(amount, max)
145
+ return false if @player.health >= max
146
+ @player.health = [@player.health + amount, max].min
147
+ true
148
+ end
149
+
150
+ def give_armor(item)
151
+ if item[:armor_type]
152
+ # Green/blue armor: only pick up if better than current
153
+ return false if @player.armor >= item[:amount]
154
+ @player.armor = item[:amount]
155
+ else
156
+ # Armor bonus: +1, up to max
157
+ return false if @player.armor >= item[:max]
158
+ @player.armor = [@player.armor + item[:amount], item[:max]].min
159
+ end
160
+ true
161
+ end
162
+
163
+ def give_key(key)
164
+ return false if @player.keys[key]
165
+ @player.keys[key] = true
166
+ true
167
+ end
168
+ end
169
+ end
170
+ end