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.
@@ -0,0 +1,494 @@
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
29
+ MELEE_RANGE = 64.0
30
+ MISSILE_RANGE = 768.0
31
+ KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
32
+
33
+ # Direction to angle (for sprite facing)
34
+ DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
35
+
36
+ # Monster attack definitions (from mobjinfo / A_Chase)
37
+ # Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4)
38
+ # In DOOM, monsters only attempt attacks when movecount reaches 0,
39
+ # then play full attack animation before returning to chase.
40
+ MONSTER_ATTACK = {
41
+ 3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Zombieman
42
+ 9 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Shotgun Guy
43
+ 3001 => { type: :projectile, cooldown: 52 }, # Imp: fireball
44
+ 3002 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Demon
45
+ 58 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Spectre
46
+ 3003 => { type: :projectile, cooldown: 54 }, # Baron: fireball
47
+ 69 => { type: :projectile, cooldown: 54 }, # Hell Knight
48
+ 3005 => { type: :projectile, cooldown: 56 }, # Cacodemon
49
+ 65 => { type: :hitscan, damage: [3, 15], cooldown: 40 }, # Heavy Weapon Dude
50
+ }.freeze
51
+
52
+ REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
53
+
54
+ # Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread)
55
+ # Close = ~85%, mid = ~60%, far = ~35%
56
+ HITSCAN_ACCURACY = 0.85
57
+
58
+ # Attack animation frames per sprite prefix (E, F, G typically)
59
+ ATTACK_FRAMES = {
60
+ 'POSS' => %w[E F], # Zombieman: raise, fire
61
+ 'SPOS' => %w[E F], # Shotgun Guy
62
+ 'TROO' => %w[E F G H], # Imp: raise, fireball, throw, recover
63
+ 'SARG' => %w[E F G], # Demon: bite
64
+ 'HEAD' => %w[E F], # Cacodemon
65
+ 'BOSS' => %w[E F G], # Baron
66
+ 'BOS2' => %w[E F G], # Hell Knight
67
+ 'CPOS' => %w[E F], # Heavy Weapon Dude
68
+ }.freeze
69
+
70
+ ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
71
+
72
+ # Which frame index the actual attack happens on (matching Chocolate Doom)
73
+ # Zombieman: A_PosAttack on frame F (index 1)
74
+ # Imp: A_TroopAttack on frame G (index 2)
75
+ # Demon: A_SargAttack on frame F (index 1)
76
+ FIRE_FRAME_INDEX = {
77
+ 'POSS' => 1, # Zombieman: E=raise, F=fire
78
+ 'SPOS' => 1, # Shotgun Guy: E=raise, F=fire
79
+ 'TROO' => 2, # Imp: E=raise, F=aim, G=throw, H=recover
80
+ 'SARG' => 1, # Demon: E=open, F=bite, G=close
81
+ 'HEAD' => 1, # Cacodemon: E=charge, F=fire
82
+ 'BOSS' => 1, # Baron: E=raise, F=throw, G=recover
83
+ 'BOS2' => 1, # Hell Knight
84
+ 'CPOS' => 1, # Heavy Weapon Dude
85
+ }.freeze
86
+
87
+ MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
88
+ :active, :chase_timer, :type, :attack_cooldown,
89
+ :reactiontime, :last_saw_player,
90
+ :attacking, :attack_frame_tic, :fired)
91
+
92
+ def initialize(map, combat, player_state, sprites_mgr = nil, hidden_things = {}, sound_engine = nil)
93
+ @map = map
94
+ @combat = combat
95
+ @player = player_state
96
+ @sprites_mgr = sprites_mgr
97
+ @monsters = []
98
+ @aggression = true # Monsters fight back (toggle with C)
99
+ @damage_multiplier = 1.0
100
+ @tic_counter = 0
101
+ @sound = sound_engine
102
+ @monster_by_thing_idx = {}
103
+
104
+ map.things.each_with_index do |thing, idx|
105
+ next if hidden_things[idx] # Filtered by difficulty
106
+ next unless Combat::MONSTER_HP[thing.type]
107
+ next if thing.type == Combat::BARREL_TYPE
108
+ mon = MonsterState.new(
109
+ idx, thing.x.to_f, thing.y.to_f,
110
+ DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
111
+ false, 0, false
112
+ )
113
+ @monsters << mon
114
+ @monster_by_thing_idx[idx] = mon
115
+ end
116
+ end
117
+
118
+ attr_reader :monsters, :monster_by_thing_idx
119
+ attr_accessor :aggression, :damage_multiplier
120
+
121
+ # Called each game tic
122
+ def update(player_x, player_y)
123
+ @tic_counter += 1
124
+ @monsters.each do |mon|
125
+ next if @combat.dead?(mon.thing_idx)
126
+
127
+ # Pain state: monster is stunned, skip movement and attacks
128
+ next if @combat.in_pain?(mon.thing_idx)
129
+
130
+ if mon.active
131
+ # Attack animation in progress: freeze movement, tick animation
132
+ if mon.attacking
133
+ mon.attack_frame_tic += 1
134
+ prefix = @sprites_mgr&.prefix_for(mon.type)
135
+ frames = ATTACK_FRAMES[prefix]
136
+ total_tics = (frames&.size || 2) * ATTACK_FRAME_TICS
137
+
138
+ # Fire on the correct frame (matching Chocolate Doom)
139
+ fire_idx = FIRE_FRAME_INDEX[prefix] || 1
140
+ fire_tic = fire_idx * ATTACK_FRAME_TICS
141
+ if !mon.fired && mon.attack_frame_tic >= fire_tic
142
+ execute_attack(mon, player_x, player_y)
143
+ mon.fired = true
144
+ end
145
+
146
+ if mon.attack_frame_tic >= total_tics
147
+ mon.attacking = false
148
+ mon.attack_frame_tic = 0
149
+ mon.fired = false
150
+ end
151
+ next
152
+ end
153
+
154
+ mon.chase_timer -= 1
155
+ if mon.chase_timer <= 0
156
+ mon.chase_timer = CHASE_TICS
157
+ chase(mon, player_x, player_y)
158
+ end
159
+ else
160
+ look(mon, player_x, player_y)
161
+ end
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ def look(mon, player_x, player_y)
168
+ dx = player_x - mon.x
169
+ dy = player_y - mon.y
170
+ dist = Math.sqrt(dx * dx + dy * dy)
171
+ return if dist > SIGHT_RANGE
172
+
173
+ # DOOM A_Look: monster only sees in ~180-degree forward arc
174
+ # unless player is very close (melee range)
175
+ if dist > MELEE_RANGE
176
+ thing = @map.things[mon.thing_idx]
177
+ face_angle = thing.angle * Math::PI / 180.0
178
+ to_player = Math.atan2(dy, dx)
179
+ angle_diff = ((to_player - face_angle + Math::PI) % (2 * Math::PI) - Math::PI).abs
180
+ return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
181
+ end
182
+
183
+ if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
184
+ mon.active = true
185
+ mon.chase_timer = CHASE_TICS
186
+ @sound&.monster_see(mon.type)
187
+ end
188
+ end
189
+
190
+ def chase(mon, player_x, player_y)
191
+ speed = MONSTER_SPEED[mon.type] || 8
192
+
193
+ # Tick down attack cooldown
194
+ mon.attack_cooldown -= CHASE_TICS if mon.attack_cooldown > 0
195
+
196
+ dx = player_x - mon.x
197
+ dy = player_y - mon.y
198
+ dist = Math.sqrt(dx * dx + dy * dy)
199
+
200
+ # Track if monster can see the player
201
+ can_see = dist < SIGHT_RANGE && has_line_of_sight?(mon.x, mon.y, player_x, player_y)
202
+ if can_see
203
+ mon.last_saw_player = @tic_counter
204
+ elsif @tic_counter - (mon.last_saw_player || 0) > 105 # ~3 seconds without LOS
205
+ # Monster gives up and goes idle (like DOOM's A_Chase returning to spawnstate)
206
+ mon.active = false
207
+ mon.reactiontime = REACTIONTIME
208
+ return
209
+ end
210
+
211
+ # Only attempt attacks when: movecount == 0, has LOS, and in range
212
+ if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !@player.dead
213
+ attacked = try_attack(mon, player_x, player_y, dist)
214
+ end
215
+
216
+ # Move -- but ranged monsters stop advancing when they have LOS and are close enough
217
+ # In DOOM, A_Chase skips movement when P_CheckMissileRange succeeds
218
+ unless attacked
219
+ atk = MONSTER_ATTACK[mon.type]
220
+ ranged = atk && (atk[:type] == :hitscan || atk[:type] == :projectile)
221
+ skip_move = ranged && can_see && dist < KEEP_DISTANCE
222
+
223
+ if skip_move
224
+ # Still tick movecount down so attack condition can trigger
225
+ mon.movecount -= 1 if mon.movecount > 0
226
+ else
227
+ mon.movecount -= 1
228
+ if mon.movecount < 0 || !try_move(mon, speed)
229
+ new_chase_dir(mon, player_x, player_y)
230
+ end
231
+ end
232
+ end
233
+
234
+ # Update the thing's position and facing angle in the map for rendering
235
+ thing = @map.things[mon.thing_idx]
236
+ thing.x = mon.x.to_i
237
+ thing.y = mon.y.to_i
238
+
239
+ # Face toward the player
240
+ target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
241
+ thing.angle = target_angle.round.to_i
242
+ end
243
+
244
+ # Decide whether to start an attack (does NOT apply damage yet)
245
+ # Matches Chocolate Doom's P_CheckMissileRange from p_enemy.c
246
+ def try_attack(mon, player_x, player_y, dist)
247
+ if mon.reactiontime > 0
248
+ mon.reactiontime -= 1
249
+ return false
250
+ end
251
+
252
+ atk = MONSTER_ATTACK[mon.type]
253
+ return false unless atk
254
+
255
+ case atk[:type]
256
+ when :melee
257
+ return false if dist > MELEE_RANGE + (Combat::MONSTER_RADIUS[mon.type] || 20)
258
+ when :hitscan, :projectile
259
+ return false if dist > MISSILE_RANGE
260
+ return false unless has_line_of_sight?(mon.x, mon.y, player_x, player_y)
261
+
262
+ # P_CheckMissileRange: subtract grace distance, cap at 200
263
+ check_dist = dist - 64 # 64 unit grace distance
264
+ check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
265
+ check_dist = [check_dist, 0].max
266
+ check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
267
+ return false if rand(256) < check_dist
268
+ end
269
+
270
+ # Start attack animation (damage applied later on fire frame)
271
+ mon.attacking = true
272
+ mon.attack_frame_tic = 0
273
+ mon.fired = false
274
+ mon.attack_cooldown = atk[:cooldown]
275
+ true
276
+ end
277
+
278
+ # Called on the fire frame of the attack animation
279
+ def execute_attack(mon, player_x, player_y)
280
+ atk = MONSTER_ATTACK[mon.type]
281
+ return unless atk
282
+
283
+ @sound&.monster_attack(mon.type)
284
+
285
+ dx = player_x - mon.x
286
+ dy = player_y - mon.y
287
+ dist = Math.sqrt(dx * dx + dy * dy)
288
+
289
+ case atk[:type]
290
+ when :melee
291
+ min_dmg, max_dmg = atk[:damage]
292
+ damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
293
+ @player.take_damage(damage) if damage > 0
294
+
295
+ when :hitscan
296
+ hit_chance = HITSCAN_ACCURACY * (1.0 - dist / (MISSILE_RANGE * 2))
297
+ hit_chance = [hit_chance, 0.15].max
298
+ if rand < hit_chance
299
+ min_dmg, max_dmg = atk[:damage]
300
+ damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
301
+ @player.take_damage(damage) if damage > 0
302
+ end
303
+
304
+ when :projectile
305
+ # P_SpawnMissile: z = source->z + 32 (chest height)
306
+ sector = @map.sector_at(mon.x, mon.y)
307
+ spawn_z = (sector ? sector.floor_height : 0) + 32
308
+ @combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier)
309
+ end
310
+ end
311
+
312
+ def try_move(mon, speed)
313
+ return false if mon.movedir == DI_NODIR
314
+
315
+ new_x = mon.x + speed * XSPEED[mon.movedir]
316
+ new_y = mon.y + speed * YSPEED[mon.movedir]
317
+
318
+ # Check if the position is valid (inside a sector, not blocked by walls)
319
+ sector = @map.sector_at(new_x, new_y)
320
+ return false unless sector
321
+
322
+ # Check wall collision
323
+ blocked = false
324
+ @map.linedefs.each do |ld|
325
+ v1 = @map.vertices[ld.v1]
326
+ v2 = @map.vertices[ld.v2]
327
+
328
+ # Simple line-circle intersection
329
+ radius = Combat::MONSTER_RADIUS[mon.type] || 20
330
+ next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
331
+
332
+ # One-sided walls always block
333
+ if ld.sidedef_left == 0xFFFF
334
+ blocked = true
335
+ break
336
+ end
337
+
338
+ # Two-sided: check step height and headroom
339
+ if ld.sidedef_left < 0xFFFF
340
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
341
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
342
+ step = (back.floor_height - front.floor_height).abs
343
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
344
+ max_floor = [front.floor_height, back.floor_height].max
345
+ if step > 24 || (min_ceil - max_floor) < 56
346
+ blocked = true
347
+ break
348
+ end
349
+ end
350
+ end
351
+ return false if blocked
352
+
353
+ mon.x = new_x
354
+ mon.y = new_y
355
+ true
356
+ end
357
+
358
+ def new_chase_dir(mon, player_x, player_y)
359
+ deltax = player_x - mon.x
360
+ deltay = player_y - mon.y
361
+ old_dir = mon.movedir
362
+
363
+ # Determine preferred directions
364
+ dir_x = if deltax > 10 then DI_EAST
365
+ elsif deltax < -10 then DI_WEST
366
+ else DI_NODIR
367
+ end
368
+
369
+ dir_y = if deltay > 10 then DI_NORTH
370
+ elsif deltay < -10 then DI_SOUTH
371
+ else DI_NODIR
372
+ end
373
+
374
+ # Try diagonal
375
+ if dir_x != DI_NODIR && dir_y != DI_NODIR
376
+ diag = diagonal_dir(dir_x, dir_y)
377
+ if diag != OPPOSITE[old_dir]
378
+ mon.movedir = diag
379
+ if try_walk(mon)
380
+ return
381
+ end
382
+ end
383
+ end
384
+
385
+ # Randomly swap X/Y priority
386
+ if rand > 0.22 || deltay.abs > deltax.abs
387
+ dir_x, dir_y = dir_y, dir_x
388
+ end
389
+
390
+ # Try primary direction
391
+ if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
392
+ mon.movedir = dir_x
393
+ return if try_walk(mon)
394
+ end
395
+
396
+ # Try secondary direction
397
+ if dir_y != DI_NODIR && dir_y != OPPOSITE[old_dir]
398
+ mon.movedir = dir_y
399
+ return if try_walk(mon)
400
+ end
401
+
402
+ # Try old direction
403
+ if old_dir != DI_NODIR
404
+ mon.movedir = old_dir
405
+ return if try_walk(mon)
406
+ end
407
+
408
+ # Try all other directions
409
+ start = rand(8)
410
+ 8.times do |i|
411
+ d = (start + i) % 8
412
+ next if d == OPPOSITE[old_dir]
413
+ mon.movedir = d
414
+ return if try_walk(mon)
415
+ end
416
+
417
+ # Last resort: turnaround
418
+ if old_dir != DI_NODIR
419
+ mon.movedir = OPPOSITE[old_dir]
420
+ return if try_walk(mon)
421
+ end
422
+
423
+ mon.movedir = DI_NODIR
424
+ end
425
+
426
+ def try_walk(mon)
427
+ speed = MONSTER_SPEED[mon.type] || 8
428
+ if try_move(mon, speed)
429
+ mon.movecount = rand(16)
430
+ true
431
+ else
432
+ false
433
+ end
434
+ end
435
+
436
+ def diagonal_dir(dx, dy)
437
+ case [dx, dy]
438
+ when [DI_EAST, DI_NORTH] then DI_NORTHEAST
439
+ when [DI_EAST, DI_SOUTH] then DI_SOUTHEAST
440
+ when [DI_WEST, DI_NORTH] then DI_NORTHWEST
441
+ when [DI_WEST, DI_SOUTH] then DI_SOUTHWEST
442
+ else DI_NODIR
443
+ end
444
+ end
445
+
446
+ def has_line_of_sight?(x1, y1, x2, y2)
447
+ # Check if any wall blocks the line of sight
448
+ @map.linedefs.each do |ld|
449
+ v1 = @map.vertices[ld.v1]
450
+ v2 = @map.vertices[ld.v2]
451
+
452
+ next unless segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
453
+
454
+ # One-sided walls always block
455
+ return false if ld.sidedef_left == 0xFFFF
456
+
457
+ # Two-sided: check if opening is big enough to see through
458
+ if ld.sidedef_left < 0xFFFF
459
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
460
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
461
+ max_floor = [front.floor_height, back.floor_height].max
462
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
463
+ # Block sight if the opening is too small
464
+ return false if (min_ceil - max_floor) < 1
465
+ end
466
+ end
467
+ true
468
+ end
469
+
470
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
471
+ d1x = ax2 - ax1; d1y = ay2 - ay1
472
+ d2x = bx2 - bx1; d2y = by2 - by1
473
+ denom = d1x * d2y - d1y * d2x
474
+ return false if denom.abs < 0.001
475
+ dx = bx1 - ax1; dy = by1 - ay1
476
+ t = (dx * d2y - dy * d2x).to_f / denom
477
+ u = (dx * d1y - dy * d1x).to_f / denom
478
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
479
+ end
480
+
481
+ def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
482
+ dx = cx - x1; dy = cy - y1
483
+ line_dx = x2 - x1; line_dy = y2 - y1
484
+ line_len_sq = line_dx * line_dx + line_dy * line_dy
485
+ return false if line_len_sq == 0
486
+ t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
487
+ t = [[t, 0.0].max, 1.0].min
488
+ closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
489
+ dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
490
+ dist_sq < radius * radius
491
+ end
492
+ end
493
+ end
494
+ end
@@ -46,6 +46,9 @@ 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
+ attr_accessor :god_mode, :infinite_ammo
49
52
 
50
53
  # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
51
54
  VIEWHEIGHT = 41.0
@@ -107,6 +110,15 @@ module Doom
107
110
  @attack_frame = 0
108
111
  @attack_tics = 0
109
112
 
113
+ # Death state
114
+ @dead = false
115
+ @death_tic = 0
116
+ @damage_count = 0
117
+
118
+ # Cheats
119
+ @god_mode = false
120
+ @infinite_ammo = false
121
+
110
122
  # Weapon bob
111
123
  @bob_angle = 0.0
112
124
  @bob_amount = 0.0
@@ -161,6 +173,7 @@ module Doom
161
173
 
162
174
  def can_attack?
163
175
  return true if @weapon == WEAPON_FIST || @weapon == WEAPON_CHAINSAW
176
+ return true if @infinite_ammo
164
177
 
165
178
  ammo = current_ammo
166
179
  ammo && ammo > 0
@@ -174,7 +187,9 @@ module Doom
174
187
  @attack_frame = 0
175
188
  @attack_tics = 0
176
189
 
177
- # Consume ammo
190
+ # Consume ammo (skipped with infinite ammo)
191
+ return if @infinite_ammo
192
+
178
193
  case @weapon
179
194
  when WEAPON_PISTOL
180
195
  @ammo_bullets -= 1 if @ammo_bullets > 0
@@ -308,6 +323,43 @@ module Doom
308
323
 
309
324
  @weapon = weapon_num
310
325
  end
326
+
327
+ # Apply damage (from environment or enemies). Armor absorbs some.
328
+ def take_damage(amount)
329
+ return if @dead
330
+ return if @god_mode
331
+
332
+ absorbed = 0
333
+ if @armor > 0
334
+ absorbed = amount / 3 # Green armor absorbs 1/3
335
+ absorbed = @armor if absorbed > @armor
336
+ @armor -= absorbed
337
+ end
338
+
339
+ actual = amount - absorbed
340
+ @health -= actual
341
+
342
+ # Red flash proportional to damage (capped at palette 8)
343
+ @damage_count = [(@damage_count + actual / 2.0).ceil, 8].min
344
+
345
+ if @health <= 0
346
+ @health = 0
347
+ @damage_count = 8
348
+ die
349
+ end
350
+ end
351
+
352
+ # Decay damage flash each tic
353
+ def update_damage_count
354
+ @damage_count -= 1 if @damage_count > 0
355
+ end
356
+
357
+ def die
358
+ @dead = true
359
+ @death_tic = 0
360
+ @attacking = false
361
+ @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
362
+ end
311
363
  end
312
364
  end
313
365
  end