doom 0.4.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: cb48bcb7558c2e4f4ab959b16dbd857d2a1b6a3b9a9cc76c19a08ec91bdeb93e
4
- data.tar.gz: 610f42cf954c60048a0c4030d96a8e8ceb2872aba90e90a141b2179329867c05
3
+ metadata.gz: c4672b03d393f17c1cd9ece97869d7239a7af3e91d3ffa47b911b0ba3058f673
4
+ data.tar.gz: 56772f44ced59386c92f5319c0d25dc8612e96263d68ae7e9a240d26508571cf
5
5
  SHA512:
6
- metadata.gz: 1ff09fd05c29a5c49b7d41285424d58b8b46dbf995e1f18106d571f18866762e8b20ecb47d472dc2cb2be8f2fc8cdea5eb0b3a2dedae55d5b1881bbcb796088b
7
- data.tar.gz: 41d4fcb793f3b25fb57f2d50c02f5eee1a56cb40a40baf2a9f7e0831634f43aa4777abf80968130c44593055dbaea23bd3257e7d4b509a068af2c92e1e16698d
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](screenshot.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
@@ -26,16 +26,16 @@ module Doom
26
26
  WEAPON_CHAINSAW => :chainsaw
27
27
  }.freeze
28
28
 
29
- # Attack durations (in frames at 35fps)
29
+ # Attack durations in tics (at 35fps), matching DOOM's weapon state sequences
30
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
31
+ WEAPON_FIST => 14, # Punch windup + swing
32
+ WEAPON_PISTOL => 16, # S_PISTOL: 6+4+5+1 tics
33
+ WEAPON_SHOTGUN => 40, # Pump action cycle
34
+ WEAPON_CHAINGUN => 8, # Rapid fire (2 shots per cycle)
35
+ WEAPON_ROCKET => 20, # Rocket launch + recovery
36
+ WEAPON_PLASMA => 8, # Fast energy weapon
37
+ WEAPON_BFG => 60, # Long charge + fire
38
+ WEAPON_CHAINSAW => 6 # Fast melee
39
39
  }.freeze
40
40
 
41
41
  attr_accessor :health, :armor, :max_health, :max_armor
@@ -47,6 +47,26 @@ module Doom
47
47
  attr_accessor :bob_angle, :bob_amount
48
48
  attr_accessor :is_moving
49
49
 
50
+ # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
51
+ VIEWHEIGHT = 41.0
52
+ VIEWHEIGHT_HALF = VIEWHEIGHT / 2.0
53
+ DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
54
+ attr_reader :viewheight, :deltaviewheight
55
+
56
+ # View bob (camera bounce when walking, matching Chocolate Doom's
57
+ # P_CalcHeight + P_XYMovement + P_Thrust from p_user.c / p_mobj.c)
58
+ MAXBOB = 16.0 # Maximum bob amplitude (0x100000 in fixed-point = 16 map units)
59
+ STOPSPEED = 0.0625 # Snap-to-zero threshold (0x1000 in fixed-point)
60
+ # Continuous-time equivalents of DOOM's per-tic constants (35 fps tic rate):
61
+ # FRICTION = 0xE800/0x10000 = 0.90625 per tic
62
+ # decay_rate = -ln(0.90625) * 35 = 3.44/sec
63
+ # walk thrust = forwardmove(25) * 2048 / 65536 = 0.78 map units/tic = 27.3/sec
64
+ # terminal velocity = 27.3 / 3.44 = 7.56 -> bob = 7.56^2/4 = 14.3 (89% of MAXBOB)
65
+ BOB_DECAY_RATE = 3.44 # Friction as continuous decay rate (1/sec)
66
+ BOB_THRUST = 26.0 # Walk thrust (map units/sec), gives terminal ~7.5
67
+ BOB_FREQUENCY = 11.0 # Bob cycle frequency (rad/sec): FINEANGLES/20 * 35 / 8192 * 2*PI
68
+ attr_reader :view_bob_offset
69
+
50
70
  def initialize
51
71
  reset
52
72
  end
@@ -91,6 +111,18 @@ module Doom
91
111
  @bob_angle = 0.0
92
112
  @bob_amount = 0.0
93
113
  @is_moving = false
114
+
115
+ # Smooth step height (P_CalcHeight viewheight/deltaviewheight)
116
+ @viewheight = VIEWHEIGHT
117
+ @deltaviewheight = 0.0
118
+
119
+ # View bob (camera bounce) - simulated momentum for P_CalcHeight
120
+ @view_bob_offset = 0.0
121
+ @momx = 0.0 # Simulated X momentum (map units/sec, not actual movement)
122
+ @momy = 0.0 # Simulated Y momentum
123
+ @thrust_x = 0.0 # Per-frame thrust input (raw, before normalization)
124
+ @thrust_y = 0.0
125
+ @view_bob_angle = 0.0
94
126
  end
95
127
 
96
128
  def weapon_name
@@ -190,6 +222,66 @@ module Doom
190
222
  end
191
223
  end
192
224
 
225
+ # Called when player moves onto a different floor height.
226
+ # Matches Chocolate Doom P_ZMovement: reduce viewheight by the step amount
227
+ # so the camera doesn't snap, then let P_CalcHeight recover it smoothly.
228
+ def notify_step(step_amount)
229
+ return if step_amount == 0
230
+ @viewheight -= step_amount
231
+ @deltaviewheight = (VIEWHEIGHT - @viewheight) / 8.0
232
+ end
233
+
234
+ # Gradually restore viewheight to VIEWHEIGHT (called each tic).
235
+ # Matches Chocolate Doom P_CalcHeight viewheight recovery loop.
236
+ # For step-up: viewheight < 41, delta > 0, accelerates upward.
237
+ # For step-down: viewheight > 41, delta < 0, decelerates then recovers.
238
+ def update_viewheight
239
+ @viewheight += @deltaviewheight
240
+
241
+ if @viewheight > VIEWHEIGHT && @deltaviewheight >= 0
242
+ @viewheight = VIEWHEIGHT
243
+ @deltaviewheight = 0.0
244
+ end
245
+
246
+ if @viewheight < VIEWHEIGHT_HALF
247
+ @viewheight = VIEWHEIGHT_HALF
248
+ @deltaviewheight = 1.0 if @deltaviewheight <= 0
249
+ end
250
+
251
+ if @deltaviewheight != 0
252
+ @deltaviewheight += DELTA_ACCEL
253
+ @deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
254
+ end
255
+ end
256
+
257
+ # Set movement momentum directly (called from GosuWindow with actual
258
+ # movement momentum, which already has thrust + friction applied).
259
+ def set_movement_momentum(momx, momy)
260
+ @momx = momx
261
+ @momy = momy
262
+ end
263
+
264
+ # Compute view bob from actual movement momentum.
265
+ # Matches Chocolate Doom P_CalcHeight:
266
+ # bob = (momx*momx + momy*momy) >> 2, capped at MAXBOB
267
+ # viewz += finesine[angle] * bob/2
268
+ # Momentum is in units/sec; DOOM's bob uses units/tic (divide by 35).
269
+ BOB_MOM_SCALE = 1.0 / (35.0 * 35.0 * 4.0) # (mom/35)^2 / 4
270
+
271
+ def update_view_bob(delta_time)
272
+ dt = delta_time.clamp(0.001, 0.05)
273
+
274
+ # P_CalcHeight: bob = (momx_per_tic^2 + momy_per_tic^2) / 4, capped at MAXBOB
275
+ bob = (@momx * @momx + @momy * @momy) * BOB_MOM_SCALE
276
+ bob = MAXBOB if bob > MAXBOB
277
+
278
+ # Advance bob sine wave (FINEANGLES/20 per tic = ~11 rad/sec)
279
+ @view_bob_angle += BOB_FREQUENCY * dt
280
+
281
+ # viewz offset: sin(angle) * bob/2
282
+ @view_bob_offset = Math.sin(@view_bob_angle) * bob / 2.0
283
+ end
284
+
193
285
  def weapon_bob_x
194
286
  Math.cos(@bob_angle) * @bob_amount
195
287
  end