doom 0.4.0 → 0.6.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.
@@ -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
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Basic monster AI: idle until seeing player, then chase.
6
+ # Matches Chocolate Doom's A_Look / A_Chase / P_NewChaseDir from p_enemy.c.
7
+ class MonsterAI
8
+ # 8 movement directions + no direction
9
+ DI_EAST = 0; DI_NORTHEAST = 1; DI_NORTH = 2; DI_NORTHWEST = 3
10
+ DI_WEST = 4; DI_SOUTHWEST = 5; DI_SOUTH = 6; DI_SOUTHEAST = 7
11
+ DI_NODIR = 8
12
+
13
+ # Movement deltas per direction (map units, 1.0 = FRACUNIT)
14
+ XSPEED = [1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071, 0.0, 0.7071].freeze
15
+ YSPEED = [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071].freeze
16
+
17
+ OPPOSITE = [DI_WEST, DI_SOUTHWEST, DI_SOUTH, DI_SOUTHEAST,
18
+ DI_EAST, DI_NORTHEAST, DI_NORTH, DI_NORTHWEST, DI_NODIR].freeze
19
+
20
+ # Monster speeds (from mobjinfo)
21
+ MONSTER_SPEED = {
22
+ 3004 => 8, 9 => 8, 3001 => 8, 3002 => 10, 58 => 10,
23
+ 3003 => 8, 69 => 8, 3005 => 8, 3006 => 8, 16 => 16,
24
+ 7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8,
25
+ }.freeze
26
+
27
+ CHASE_TICS = 4 # Steps between A_Chase calls
28
+ SIGHT_RANGE = 768.0 # Max distance for sight check (DOOM uses sector sound propagation, we approximate)
29
+ MELEE_RANGE = 64.0
30
+
31
+ # Direction to angle (for sprite facing)
32
+ DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
33
+
34
+ MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
35
+ :active, :chase_timer, :type)
36
+
37
+ def initialize(map, combat)
38
+ @map = map
39
+ @combat = combat
40
+ @monsters = []
41
+
42
+ map.things.each_with_index do |thing, idx|
43
+ next unless Combat::MONSTER_HP[thing.type]
44
+ @monsters << MonsterState.new(
45
+ idx, thing.x.to_f, thing.y.to_f,
46
+ DI_NODIR, 0, false, 0, thing.type
47
+ )
48
+ end
49
+ end
50
+
51
+ attr_reader :monsters
52
+
53
+ # Called each game tic
54
+ def update(player_x, player_y)
55
+ @monsters.each do |mon|
56
+ next if @combat.dead?(mon.thing_idx)
57
+
58
+ if mon.active
59
+ mon.chase_timer -= 1
60
+ if mon.chase_timer <= 0
61
+ mon.chase_timer = CHASE_TICS
62
+ chase(mon, player_x, player_y)
63
+ end
64
+ else
65
+ look(mon, player_x, player_y)
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def look(mon, player_x, player_y)
73
+ dx = player_x - mon.x
74
+ dy = player_y - mon.y
75
+ dist = Math.sqrt(dx * dx + dy * dy)
76
+ return if dist > SIGHT_RANGE
77
+
78
+ # DOOM A_Look: monster only sees in ~180-degree forward arc
79
+ # unless player is very close (melee range)
80
+ if dist > MELEE_RANGE
81
+ thing = @map.things[mon.thing_idx]
82
+ face_angle = thing.angle * Math::PI / 180.0
83
+ to_player = Math.atan2(dy, dx)
84
+ angle_diff = ((to_player - face_angle + Math::PI) % (2 * Math::PI) - Math::PI).abs
85
+ return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
86
+ end
87
+
88
+ if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
89
+ mon.active = true
90
+ mon.chase_timer = CHASE_TICS
91
+ end
92
+ end
93
+
94
+ def chase(mon, player_x, player_y)
95
+ speed = MONSTER_SPEED[mon.type] || 8
96
+
97
+ # Decrement movecount; pick new direction when expired or blocked
98
+ mon.movecount -= 1
99
+ if mon.movecount < 0 || !try_move(mon, speed)
100
+ new_chase_dir(mon, player_x, player_y)
101
+ end
102
+
103
+ # Update the thing's position and facing angle in the map for rendering
104
+ thing = @map.things[mon.thing_idx]
105
+ thing.x = mon.x.to_i
106
+ thing.y = mon.y.to_i
107
+
108
+ # Face toward the player (smooth turning)
109
+ target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
110
+ thing.angle = target_angle.round.to_i
111
+ end
112
+
113
+ def try_move(mon, speed)
114
+ return false if mon.movedir == DI_NODIR
115
+
116
+ new_x = mon.x + speed * XSPEED[mon.movedir]
117
+ new_y = mon.y + speed * YSPEED[mon.movedir]
118
+
119
+ # Check if the position is valid (inside a sector, not blocked by walls)
120
+ sector = @map.sector_at(new_x, new_y)
121
+ return false unless sector
122
+
123
+ # Check wall collision
124
+ blocked = false
125
+ @map.linedefs.each do |ld|
126
+ v1 = @map.vertices[ld.v1]
127
+ v2 = @map.vertices[ld.v2]
128
+
129
+ # Simple line-circle intersection
130
+ radius = Combat::MONSTER_RADIUS[mon.type] || 20
131
+ next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
132
+
133
+ # One-sided walls always block
134
+ if ld.sidedef_left == 0xFFFF
135
+ blocked = true
136
+ break
137
+ end
138
+
139
+ # Two-sided: check step height and headroom
140
+ if ld.sidedef_left < 0xFFFF
141
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
142
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
143
+ step = (back.floor_height - front.floor_height).abs
144
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
145
+ max_floor = [front.floor_height, back.floor_height].max
146
+ if step > 24 || (min_ceil - max_floor) < 56
147
+ blocked = true
148
+ break
149
+ end
150
+ end
151
+ end
152
+ return false if blocked
153
+
154
+ mon.x = new_x
155
+ mon.y = new_y
156
+ true
157
+ end
158
+
159
+ def new_chase_dir(mon, player_x, player_y)
160
+ deltax = player_x - mon.x
161
+ deltay = player_y - mon.y
162
+ old_dir = mon.movedir
163
+
164
+ # Determine preferred directions
165
+ dir_x = if deltax > 10 then DI_EAST
166
+ elsif deltax < -10 then DI_WEST
167
+ else DI_NODIR
168
+ end
169
+
170
+ dir_y = if deltay > 10 then DI_NORTH
171
+ elsif deltay < -10 then DI_SOUTH
172
+ else DI_NODIR
173
+ end
174
+
175
+ # Try diagonal
176
+ if dir_x != DI_NODIR && dir_y != DI_NODIR
177
+ diag = diagonal_dir(dir_x, dir_y)
178
+ if diag != OPPOSITE[old_dir]
179
+ mon.movedir = diag
180
+ if try_walk(mon)
181
+ return
182
+ end
183
+ end
184
+ end
185
+
186
+ # Randomly swap X/Y priority
187
+ if rand > 0.22 || deltay.abs > deltax.abs
188
+ dir_x, dir_y = dir_y, dir_x
189
+ end
190
+
191
+ # Try primary direction
192
+ if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
193
+ mon.movedir = dir_x
194
+ return if try_walk(mon)
195
+ end
196
+
197
+ # Try secondary direction
198
+ if dir_y != DI_NODIR && dir_y != OPPOSITE[old_dir]
199
+ mon.movedir = dir_y
200
+ return if try_walk(mon)
201
+ end
202
+
203
+ # Try old direction
204
+ if old_dir != DI_NODIR
205
+ mon.movedir = old_dir
206
+ return if try_walk(mon)
207
+ end
208
+
209
+ # Try all other directions
210
+ start = rand(8)
211
+ 8.times do |i|
212
+ d = (start + i) % 8
213
+ next if d == OPPOSITE[old_dir]
214
+ mon.movedir = d
215
+ return if try_walk(mon)
216
+ end
217
+
218
+ # Last resort: turnaround
219
+ if old_dir != DI_NODIR
220
+ mon.movedir = OPPOSITE[old_dir]
221
+ return if try_walk(mon)
222
+ end
223
+
224
+ mon.movedir = DI_NODIR
225
+ end
226
+
227
+ def try_walk(mon)
228
+ speed = MONSTER_SPEED[mon.type] || 8
229
+ if try_move(mon, speed)
230
+ mon.movecount = rand(16)
231
+ true
232
+ else
233
+ false
234
+ end
235
+ end
236
+
237
+ def diagonal_dir(dx, dy)
238
+ case [dx, dy]
239
+ when [DI_EAST, DI_NORTH] then DI_NORTHEAST
240
+ when [DI_EAST, DI_SOUTH] then DI_SOUTHEAST
241
+ when [DI_WEST, DI_NORTH] then DI_NORTHWEST
242
+ when [DI_WEST, DI_SOUTH] then DI_SOUTHWEST
243
+ else DI_NODIR
244
+ end
245
+ end
246
+
247
+ def has_line_of_sight?(x1, y1, x2, y2)
248
+ # Check if any wall blocks the line of sight
249
+ @map.linedefs.each do |ld|
250
+ v1 = @map.vertices[ld.v1]
251
+ v2 = @map.vertices[ld.v2]
252
+
253
+ next unless segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
254
+
255
+ # One-sided walls always block
256
+ return false if ld.sidedef_left == 0xFFFF
257
+
258
+ # Two-sided: check if opening is big enough to see through
259
+ if ld.sidedef_left < 0xFFFF
260
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
261
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
262
+ max_floor = [front.floor_height, back.floor_height].max
263
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
264
+ # Block sight if the opening is too small
265
+ return false if (min_ceil - max_floor) < 1
266
+ end
267
+ end
268
+ true
269
+ end
270
+
271
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
272
+ d1x = ax2 - ax1; d1y = ay2 - ay1
273
+ d2x = bx2 - bx1; d2y = by2 - by1
274
+ denom = d1x * d2y - d1y * d2x
275
+ return false if denom.abs < 0.001
276
+ dx = bx1 - ax1; dy = by1 - ay1
277
+ t = (dx * d2y - dy * d2x).to_f / denom
278
+ u = (dx * d1y - dy * d1x).to_f / denom
279
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
280
+ end
281
+
282
+ def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
283
+ dx = cx - x1; dy = cy - y1
284
+ line_dx = x2 - x1; line_dy = y2 - y1
285
+ line_len_sq = line_dx * line_dx + line_dy * line_dy
286
+ return false if line_len_sq == 0
287
+ t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
288
+ t = [[t, 0.0].max, 1.0].min
289
+ closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
290
+ dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
291
+ dist_sq < radius * radius
292
+ end
293
+ end
294
+ end
295
+ 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
@@ -46,6 +46,28 @@ module Doom
46
46
  attr_accessor :attacking, :attack_frame, :attack_tics
47
47
  attr_accessor :bob_angle, :bob_amount
48
48
  attr_accessor :is_moving
49
+ attr_accessor :dead, :death_tic
50
+ attr_accessor :damage_count # Red flash intensity (0-8), decays each tic
51
+
52
+ # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
53
+ VIEWHEIGHT = 41.0
54
+ VIEWHEIGHT_HALF = VIEWHEIGHT / 2.0
55
+ DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
56
+ attr_reader :viewheight, :deltaviewheight
57
+
58
+ # View bob (camera bounce when walking, matching Chocolate Doom's
59
+ # P_CalcHeight + P_XYMovement + P_Thrust from p_user.c / p_mobj.c)
60
+ MAXBOB = 16.0 # Maximum bob amplitude (0x100000 in fixed-point = 16 map units)
61
+ STOPSPEED = 0.0625 # Snap-to-zero threshold (0x1000 in fixed-point)
62
+ # Continuous-time equivalents of DOOM's per-tic constants (35 fps tic rate):
63
+ # FRICTION = 0xE800/0x10000 = 0.90625 per tic
64
+ # decay_rate = -ln(0.90625) * 35 = 3.44/sec
65
+ # walk thrust = forwardmove(25) * 2048 / 65536 = 0.78 map units/tic = 27.3/sec
66
+ # terminal velocity = 27.3 / 3.44 = 7.56 -> bob = 7.56^2/4 = 14.3 (89% of MAXBOB)
67
+ BOB_DECAY_RATE = 3.44 # Friction as continuous decay rate (1/sec)
68
+ BOB_THRUST = 26.0 # Walk thrust (map units/sec), gives terminal ~7.5
69
+ BOB_FREQUENCY = 11.0 # Bob cycle frequency (rad/sec): FINEANGLES/20 * 35 / 8192 * 2*PI
70
+ attr_reader :view_bob_offset
49
71
 
50
72
  def initialize
51
73
  reset
@@ -87,10 +109,27 @@ module Doom
87
109
  @attack_frame = 0
88
110
  @attack_tics = 0
89
111
 
112
+ # Death state
113
+ @dead = false
114
+ @death_tic = 0
115
+ @damage_count = 0
116
+
90
117
  # Weapon bob
91
118
  @bob_angle = 0.0
92
119
  @bob_amount = 0.0
93
120
  @is_moving = false
121
+
122
+ # Smooth step height (P_CalcHeight viewheight/deltaviewheight)
123
+ @viewheight = VIEWHEIGHT
124
+ @deltaviewheight = 0.0
125
+
126
+ # View bob (camera bounce) - simulated momentum for P_CalcHeight
127
+ @view_bob_offset = 0.0
128
+ @momx = 0.0 # Simulated X momentum (map units/sec, not actual movement)
129
+ @momy = 0.0 # Simulated Y momentum
130
+ @thrust_x = 0.0 # Per-frame thrust input (raw, before normalization)
131
+ @thrust_y = 0.0
132
+ @view_bob_angle = 0.0
94
133
  end
95
134
 
96
135
  def weapon_name
@@ -190,6 +229,66 @@ module Doom
190
229
  end
191
230
  end
192
231
 
232
+ # Called when player moves onto a different floor height.
233
+ # Matches Chocolate Doom P_ZMovement: reduce viewheight by the step amount
234
+ # so the camera doesn't snap, then let P_CalcHeight recover it smoothly.
235
+ def notify_step(step_amount)
236
+ return if step_amount == 0
237
+ @viewheight -= step_amount
238
+ @deltaviewheight = (VIEWHEIGHT - @viewheight) / 8.0
239
+ end
240
+
241
+ # Gradually restore viewheight to VIEWHEIGHT (called each tic).
242
+ # Matches Chocolate Doom P_CalcHeight viewheight recovery loop.
243
+ # For step-up: viewheight < 41, delta > 0, accelerates upward.
244
+ # For step-down: viewheight > 41, delta < 0, decelerates then recovers.
245
+ def update_viewheight
246
+ @viewheight += @deltaviewheight
247
+
248
+ if @viewheight > VIEWHEIGHT && @deltaviewheight >= 0
249
+ @viewheight = VIEWHEIGHT
250
+ @deltaviewheight = 0.0
251
+ end
252
+
253
+ if @viewheight < VIEWHEIGHT_HALF
254
+ @viewheight = VIEWHEIGHT_HALF
255
+ @deltaviewheight = 1.0 if @deltaviewheight <= 0
256
+ end
257
+
258
+ if @deltaviewheight != 0
259
+ @deltaviewheight += DELTA_ACCEL
260
+ @deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
261
+ end
262
+ end
263
+
264
+ # Set movement momentum directly (called from GosuWindow with actual
265
+ # movement momentum, which already has thrust + friction applied).
266
+ def set_movement_momentum(momx, momy)
267
+ @momx = momx
268
+ @momy = momy
269
+ end
270
+
271
+ # Compute view bob from actual movement momentum.
272
+ # Matches Chocolate Doom P_CalcHeight:
273
+ # bob = (momx*momx + momy*momy) >> 2, capped at MAXBOB
274
+ # viewz += finesine[angle] * bob/2
275
+ # Momentum is in units/sec; DOOM's bob uses units/tic (divide by 35).
276
+ BOB_MOM_SCALE = 1.0 / (35.0 * 35.0 * 4.0) # (mom/35)^2 / 4
277
+
278
+ def update_view_bob(delta_time)
279
+ dt = delta_time.clamp(0.001, 0.05)
280
+
281
+ # P_CalcHeight: bob = (momx_per_tic^2 + momy_per_tic^2) / 4, capped at MAXBOB
282
+ bob = (@momx * @momx + @momy * @momy) * BOB_MOM_SCALE
283
+ bob = MAXBOB if bob > MAXBOB
284
+
285
+ # Advance bob sine wave (FINEANGLES/20 per tic = ~11 rad/sec)
286
+ @view_bob_angle += BOB_FREQUENCY * dt
287
+
288
+ # viewz offset: sin(angle) * bob/2
289
+ @view_bob_offset = Math.sin(@view_bob_angle) * bob / 2.0
290
+ end
291
+
193
292
  def weapon_bob_x
194
293
  Math.cos(@bob_angle) * @bob_amount
195
294
  end
@@ -216,6 +315,42 @@ module Doom
216
315
 
217
316
  @weapon = weapon_num
218
317
  end
318
+
319
+ # Apply damage (from environment or enemies). Armor absorbs some.
320
+ def take_damage(amount)
321
+ return if @dead
322
+
323
+ absorbed = 0
324
+ if @armor > 0
325
+ absorbed = amount / 3 # Green armor absorbs 1/3
326
+ absorbed = @armor if absorbed > @armor
327
+ @armor -= absorbed
328
+ end
329
+
330
+ actual = amount - absorbed
331
+ @health -= actual
332
+
333
+ # Red flash proportional to damage (capped at palette 8)
334
+ @damage_count = [(@damage_count + actual / 2.0).ceil, 8].min
335
+
336
+ if @health <= 0
337
+ @health = 0
338
+ @damage_count = 8
339
+ die
340
+ end
341
+ end
342
+
343
+ # Decay damage flash each tic
344
+ def update_damage_count
345
+ @damage_count -= 1 if @damage_count > 0
346
+ end
347
+
348
+ def die
349
+ @dead = true
350
+ @death_tic = 0
351
+ @attacking = false
352
+ @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
353
+ end
219
354
  end
220
355
  end
221
356
  end