doom 0.6.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.
@@ -50,31 +50,66 @@ module Doom
50
50
  38 => { cat: :key, key: :red_skull },
51
51
  }.freeze
52
52
 
53
- attr_reader :picked_up
53
+ MESSAGETICS = 140 # 4 * TICRATE (4 seconds, matching Chocolate Doom)
54
+ FLASH_TICS = 8 # Yellow palette flash duration
54
55
 
55
- def initialize(map, player_state)
56
+ attr_reader :picked_up, :pickup_message, :pickup_flash, :message_tics
57
+ attr_accessor :ammo_multiplier, :hidden_things
58
+
59
+ def initialize(map, player_state, hidden_things = {})
56
60
  @map = map
57
61
  @player = player_state
58
- @picked_up = {} # thing index => true (to avoid re-picking)
62
+ @picked_up = {}
63
+ @hidden_things = hidden_things
64
+ @ammo_multiplier = 1
65
+ @pickup_message = nil
66
+ @pickup_flash = 0 # Yellow screen flash (short)
67
+ @message_tics = 0 # Message display timer (long)
68
+ end
69
+
70
+ # Decay timers each tic
71
+ def update_flash
72
+ @pickup_flash -= 1 if @pickup_flash > 0
73
+ @message_tics -= 1 if @message_tics > 0
59
74
  end
60
75
 
61
76
  def update(player_x, player_y)
62
77
  @map.things.each_with_index do |thing, idx|
78
+ next if @hidden_things[idx]
63
79
  next if @picked_up[idx]
64
80
  item = ITEMS[thing.type]
65
81
  next unless item
66
82
 
67
- # DOOM uses bounding box overlap: abs(dx) < sum_of_radii
68
83
  dx = (player_x - thing.x).abs
69
84
  dy = (player_y - thing.y).abs
70
85
  next if dx >= PICKUP_DIST || dy >= PICKUP_DIST
71
86
 
72
87
  if try_pickup(item)
73
88
  @picked_up[idx] = true
89
+ @pickup_message = PICKUP_MESSAGES[thing.type]
90
+ @pickup_flash = FLASH_TICS
91
+ @message_tics = MESSAGETICS
74
92
  end
75
93
  end
76
94
  end
77
95
 
96
+ PICKUP_MESSAGES = {
97
+ 2001 => "A SHOTGUN!", 2002 => "A CHAINGUN!", 2003 => "A ROCKET LAUNCHER!",
98
+ 2004 => "A PLASMA RIFLE!", 2005 => "A CHAINSAW!", 2006 => "A BFG9000!",
99
+ 2007 => "PICKED UP A CLIP.", 2048 => "PICKED UP A BOX OF BULLETS.",
100
+ 2008 => "PICKED UP 4 SHOTGUN SHELLS.", 2049 => "PICKED UP A BOX OF SHELLS.",
101
+ 2010 => "PICKED UP A ROCKET.", 2046 => "PICKED UP A BOX OF ROCKETS.",
102
+ 17 => "PICKED UP AN ENERGY CELL.", 2047 => "PICKED UP AN ENERGY CELL PACK.",
103
+ 8 => "PICKED UP A BACKPACK FULL OF AMMO!",
104
+ 2011 => "PICKED UP A STIMPACK.", 2012 => "PICKED UP A MEDIKIT.",
105
+ 2014 => "PICKED UP A HEALTH BONUS.", 2015 => "PICKED UP AN ARMOR BONUS.",
106
+ 2018 => "PICKED UP THE ARMOR.", 2019 => "PICKED UP THE MEGAARMOR!",
107
+ 2013 => "SUPERCHARGE!",
108
+ 5 => "PICKED UP A BLUE KEYCARD.", 6 => "PICKED UP A YELLOW KEYCARD.",
109
+ 13 => "PICKED UP A RED KEYCARD.", 40 => "PICKED UP A BLUE SKULL KEY.",
110
+ 39 => "PICKED UP A YELLOW SKULL KEY.", 38 => "PICKED UP A RED SKULL KEY.",
111
+ }.freeze
112
+
78
113
  private
79
114
 
80
115
  def try_pickup(item)
@@ -112,6 +147,7 @@ module Doom
112
147
  end
113
148
 
114
149
  def give_ammo(type, amount)
150
+ amount = amount * @ammo_multiplier
115
151
  case type
116
152
  when :bullets
117
153
  return false if @player.ammo_bullets >= @player.max_bullets
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # DOOM main menu system with title screen, new game, and difficulty selection.
6
+ class Menu
7
+ SKULL_ANIM_TICS = 8 # Skull cursor blink rate
8
+
9
+ # Difficulty levels matching DOOM's skill levels
10
+ SKILL_BABY = 0 # I'm too young to die
11
+ SKILL_EASY = 1 # Hey, not too rough
12
+ SKILL_MEDIUM = 2 # Hurt me plenty
13
+ SKILL_HARD = 3 # Ultra-Violence
14
+ SKILL_NIGHTMARE = 4 # Nightmare!
15
+
16
+ # Menu states
17
+ STATE_TITLE = :title
18
+ STATE_MAIN = :main
19
+ STATE_SKILL = :skill
20
+ STATE_OPTIONS = :options
21
+ STATE_NONE = :none # In-game, no menu
22
+
23
+ # Main menu items
24
+ MAIN_ITEMS = %i[new_game options quit].freeze
25
+
26
+ # Options menu items
27
+ OPTIONS_ITEMS = %i[god_mode infinite_ammo all_weapons fullscreen rubykaigi_mode].freeze
28
+ OPTIONS_LABELS = {
29
+ god_mode: "GOD MODE",
30
+ infinite_ammo: "INFINITE AMMO",
31
+ all_weapons: "ALL WEAPONS",
32
+ fullscreen: "FULLSCREEN",
33
+ rubykaigi_mode: "RUBYKAIGI MODE",
34
+ }.freeze
35
+
36
+ OPTIONS_X = 48
37
+ OPTIONS_Y = 50
38
+ OPTIONS_SPACING = 18
39
+
40
+ # Skill menu items
41
+ SKILL_ITEMS = [SKILL_BABY, SKILL_EASY, SKILL_MEDIUM, SKILL_HARD, SKILL_NIGHTMARE].freeze
42
+
43
+ # Menu item Y positions (from Chocolate Doom m_menu.c)
44
+ MAIN_X = 97
45
+ MAIN_Y = 64
46
+ MAIN_SPACING = 16
47
+
48
+ SKILL_X = 48
49
+ SKILL_Y = 63
50
+ SKILL_SPACING = 16
51
+
52
+ attr_reader :state, :selected_skill, :options, :font
53
+
54
+ def initialize(wad, hud_graphics, font = nil)
55
+ @wad = wad
56
+ @gfx = hud_graphics
57
+ @font = font
58
+ @state = STATE_TITLE
59
+ @cursor = 0
60
+ @skull_frame = 0
61
+ @skull_tic = 0
62
+ @selected_skill = SKILL_MEDIUM # Default difficulty
63
+ @game_started = false
64
+
65
+ # Options toggles
66
+ @options = {
67
+ god_mode: false,
68
+ infinite_ammo: false,
69
+ all_weapons: false,
70
+ fullscreen: false,
71
+ rubykaigi_mode: false,
72
+ }
73
+
74
+ load_graphics
75
+ end
76
+
77
+ def active?
78
+ @state != STATE_NONE
79
+ end
80
+
81
+ def needs_background?
82
+ @state != STATE_TITLE
83
+ end
84
+
85
+ def update
86
+ @skull_tic += 1
87
+ if @skull_tic >= SKULL_ANIM_TICS
88
+ @skull_tic = 0
89
+ @skull_frame = 1 - @skull_frame
90
+ end
91
+ end
92
+
93
+ def render(framebuffer, palette_colors)
94
+ case @state
95
+ when STATE_TITLE
96
+ render_title(framebuffer)
97
+ when STATE_MAIN
98
+ render_main_menu(framebuffer)
99
+ when STATE_SKILL
100
+ render_skill_menu(framebuffer)
101
+ when STATE_OPTIONS
102
+ render_options_menu(framebuffer)
103
+ end
104
+ end
105
+
106
+ # Returns :start_game, :resume, :quit, or option action symbols
107
+ def handle_key(key)
108
+ case @state
109
+ when STATE_TITLE
110
+ @state = STATE_MAIN
111
+ @cursor = 0
112
+ when STATE_MAIN
113
+ handle_main_key(key)
114
+ when STATE_SKILL
115
+ handle_skill_key(key)
116
+ when STATE_OPTIONS
117
+ handle_options_key(key)
118
+ end
119
+ end
120
+
121
+ def dismiss
122
+ @state = STATE_NONE
123
+ @game_started = true
124
+ end
125
+
126
+ def show
127
+ @state = STATE_MAIN
128
+ @cursor = 0
129
+ end
130
+
131
+ private
132
+
133
+ def load_graphics
134
+ # Title screen
135
+ @title = load_patch('TITLEPIC')
136
+
137
+ # RubyKaigi title screen (pre-rendered palette indices)
138
+ kaigi_path = File.join(File.expand_path('../../..', __dir__), 'assets', 'kaigi_title.dat')
139
+ @kaigi_title = File.exist?(kaigi_path) ? Marshal.load(File.binread(kaigi_path)) : nil
140
+
141
+ # Main menu
142
+ @m_doom = load_patch('M_DOOM')
143
+ @m_newg = load_patch('M_NGAME')
144
+ @m_option = load_patch('M_OPTION')
145
+ @m_quitg = load_patch('M_QUITG')
146
+
147
+ # Skill menu
148
+ @m_skill = load_patch('M_SKILL')
149
+ @m_jkill = load_patch('M_JKILL')
150
+ @m_hurt = load_patch('M_HURT')
151
+ @m_rough = load_patch('M_ROUGH') # Not used, but loaded
152
+ @m_ultra = load_patch('M_ULTRA')
153
+ @m_nmare = load_patch('M_NMARE')
154
+
155
+ # Episode (shareware only has 1)
156
+ @m_episod = load_patch('M_EPISOD')
157
+ @m_epi1 = load_patch('M_EPI1')
158
+
159
+ # Skull cursor
160
+ @skulls = [load_patch('M_SKULL1'), load_patch('M_SKULL2')]
161
+ end
162
+
163
+ def load_patch(name)
164
+ @gfx.send(:load_graphic, name)
165
+ end
166
+
167
+ def render_title(framebuffer)
168
+ if @options[:rubykaigi_mode] && @kaigi_title
169
+ # Draw kaigi title (raw palette indices, 320x200, offset 20px down)
170
+ @kaigi_title.each_with_index do |color, i|
171
+ x = i % 320
172
+ y = (i / 320) + 20
173
+ framebuffer[y * 320 + x] = color if y < 240
174
+ end
175
+ elsif @title
176
+ draw_fullscreen(framebuffer, @title)
177
+ end
178
+ end
179
+
180
+ def render_main_menu(framebuffer)
181
+ # Draw title logo
182
+ draw_sprite(framebuffer, @m_doom, 94, 2) if @m_doom
183
+
184
+ # Draw menu items
185
+ items = [@m_newg, @m_option, @m_quitg]
186
+ items.each_with_index do |item, i|
187
+ next unless item
188
+ draw_sprite(framebuffer, item, MAIN_X, MAIN_Y + i * MAIN_SPACING)
189
+ end
190
+
191
+ # Draw skull cursor
192
+ skull = @skulls[@skull_frame]
193
+ if skull
194
+ skull_x = MAIN_X - 32
195
+ skull_y = MAIN_Y + @cursor * MAIN_SPACING - 5
196
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
197
+ end
198
+ end
199
+
200
+ def render_skill_menu(framebuffer)
201
+ # Draw skill title
202
+ draw_sprite(framebuffer, @m_skill, 38, 15) if @m_skill
203
+
204
+ # Draw skill items: baby, easy, medium, hard, nightmare
205
+ skill_items = [@m_jkill, @m_hurt, @m_rough, @m_ultra, @m_nmare]
206
+ skill_items.each_with_index do |item, i|
207
+ next unless item
208
+ draw_sprite(framebuffer, item, SKILL_X, SKILL_Y + i * SKILL_SPACING)
209
+ end
210
+
211
+ # Draw skull cursor
212
+ skull = @skulls[@skull_frame]
213
+ if skull
214
+ skull_x = SKILL_X - 32
215
+ skull_y = SKILL_Y + @cursor * SKILL_SPACING - 5
216
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
217
+ end
218
+ end
219
+
220
+ def handle_main_key(key)
221
+ case key
222
+ when :up
223
+ @cursor = (@cursor - 1) % MAIN_ITEMS.size
224
+ when :down
225
+ @cursor = (@cursor + 1) % MAIN_ITEMS.size
226
+ when :enter
227
+ case MAIN_ITEMS[@cursor]
228
+ when :new_game
229
+ @state = STATE_SKILL
230
+ @cursor = SKILL_MEDIUM # Default to "Hurt me plenty"
231
+ when :options
232
+ @state = STATE_OPTIONS
233
+ @cursor = 0
234
+ when :quit
235
+ return :quit
236
+ end
237
+ when :escape
238
+ if @game_started
239
+ # Resume game
240
+ @state = STATE_NONE
241
+ return :resume
242
+ else
243
+ @state = STATE_TITLE
244
+ end
245
+ end
246
+ nil
247
+ end
248
+
249
+ def handle_skill_key(key)
250
+ case key
251
+ when :up
252
+ @cursor = (@cursor - 1) % SKILL_ITEMS.size
253
+ when :down
254
+ @cursor = (@cursor + 1) % SKILL_ITEMS.size
255
+ when :enter
256
+ @selected_skill = SKILL_ITEMS[@cursor]
257
+ @state = STATE_NONE
258
+ @game_started = true
259
+ return :start_game
260
+ when :escape
261
+ @state = STATE_MAIN
262
+ @cursor = 0
263
+ end
264
+ nil
265
+ end
266
+
267
+ def render_options_menu(framebuffer)
268
+ # Draw title using font
269
+ @font&.draw_centered(framebuffer, "OPTIONS", 20)
270
+
271
+ # Draw each option with ON/OFF status
272
+ OPTIONS_ITEMS.each_with_index do |item, i|
273
+ y = OPTIONS_Y + i * OPTIONS_SPACING
274
+ label = OPTIONS_LABELS[item]
275
+ value = @options[item] ? "ON" : "OFF"
276
+ @font&.draw_text(framebuffer, label, OPTIONS_X, y)
277
+ @font&.draw_text(framebuffer, value, 260, y)
278
+ end
279
+
280
+ # Draw skull cursor
281
+ skull = @skulls[@skull_frame]
282
+ if skull
283
+ skull_x = OPTIONS_X - 32
284
+ skull_y = OPTIONS_Y + @cursor * OPTIONS_SPACING - 5
285
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
286
+ end
287
+ end
288
+
289
+ def handle_options_key(key)
290
+ case key
291
+ when :up
292
+ @cursor = (@cursor - 1) % OPTIONS_ITEMS.size
293
+ when :down
294
+ @cursor = (@cursor + 1) % OPTIONS_ITEMS.size
295
+ when :enter
296
+ item = OPTIONS_ITEMS[@cursor]
297
+ @options[item] = !@options[item]
298
+ return { action: :toggle_option, option: item, value: @options[item] }
299
+ when :escape
300
+ @state = STATE_MAIN
301
+ @cursor = 1 # Options is the second main menu item
302
+ end
303
+ nil
304
+ end
305
+
306
+ def draw_fullscreen(framebuffer, sprite)
307
+ return unless sprite
308
+ # TITLEPIC is 320x200, our screen is 320x240
309
+ # Draw it centered vertically (offset by 20 pixels)
310
+ y_offset = 20
311
+ sprite.width.times do |x|
312
+ col = sprite.column_pixels(x)
313
+ next unless col
314
+ col.each_with_index do |color, y|
315
+ next unless color
316
+ screen_y = y + y_offset
317
+ next if screen_y < 0 || screen_y >= 240
318
+ framebuffer[screen_y * 320 + x] = color
319
+ end
320
+ end
321
+ end
322
+
323
+ def draw_sprite(framebuffer, sprite, x, y)
324
+ return unless sprite
325
+ sprite.width.times do |col_x|
326
+ screen_x = x + col_x
327
+ next if screen_x < 0 || screen_x >= 320
328
+
329
+ col = sprite.column_pixels(col_x)
330
+ next unless col
331
+
332
+ col.each_with_index do |color, col_y|
333
+ next unless color
334
+ screen_y = y + col_y
335
+ next if screen_y < 0 || screen_y >= 240
336
+ framebuffer[screen_y * 320 + screen_x] = color
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end
@@ -25,37 +25,132 @@ module Doom
25
25
  }.freeze
26
26
 
27
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)
28
+ SIGHT_RANGE = 768.0 # Max distance for sight check
29
29
  MELEE_RANGE = 64.0
30
+ MISSILE_RANGE = 768.0
31
+ KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
30
32
 
31
33
  # Direction to angle (for sprite facing)
32
34
  DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
33
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
+
34
87
  MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
35
- :active, :chase_timer, :type)
88
+ :active, :chase_timer, :type, :attack_cooldown,
89
+ :reactiontime, :last_saw_player,
90
+ :attacking, :attack_frame_tic, :fired)
36
91
 
37
- def initialize(map, combat)
92
+ def initialize(map, combat, player_state, sprites_mgr = nil, hidden_things = {}, sound_engine = nil)
38
93
  @map = map
39
94
  @combat = combat
95
+ @player = player_state
96
+ @sprites_mgr = sprites_mgr
40
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 = {}
41
103
 
42
104
  map.things.each_with_index do |thing, idx|
105
+ next if hidden_things[idx] # Filtered by difficulty
43
106
  next unless Combat::MONSTER_HP[thing.type]
44
- @monsters << MonsterState.new(
107
+ next if thing.type == Combat::BARREL_TYPE
108
+ mon = MonsterState.new(
45
109
  idx, thing.x.to_f, thing.y.to_f,
46
- DI_NODIR, 0, false, 0, thing.type
110
+ DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
111
+ false, 0, false
47
112
  )
113
+ @monsters << mon
114
+ @monster_by_thing_idx[idx] = mon
48
115
  end
49
116
  end
50
117
 
51
- attr_reader :monsters
118
+ attr_reader :monsters, :monster_by_thing_idx
119
+ attr_accessor :aggression, :damage_multiplier
52
120
 
53
121
  # Called each game tic
54
122
  def update(player_x, player_y)
123
+ @tic_counter += 1
55
124
  @monsters.each do |mon|
56
125
  next if @combat.dead?(mon.thing_idx)
57
126
 
127
+ # Pain state: monster is stunned, skip movement and attacks
128
+ next if @combat.in_pain?(mon.thing_idx)
129
+
58
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
+
59
154
  mon.chase_timer -= 1
60
155
  if mon.chase_timer <= 0
61
156
  mon.chase_timer = CHASE_TICS
@@ -88,16 +183,52 @@ module Doom
88
183
  if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
89
184
  mon.active = true
90
185
  mon.chase_timer = CHASE_TICS
186
+ @sound&.monster_see(mon.type)
91
187
  end
92
188
  end
93
189
 
94
190
  def chase(mon, player_x, player_y)
95
191
  speed = MONSTER_SPEED[mon.type] || 8
96
192
 
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)
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
101
232
  end
102
233
 
103
234
  # Update the thing's position and facing angle in the map for rendering
@@ -105,11 +236,79 @@ module Doom
105
236
  thing.x = mon.x.to_i
106
237
  thing.y = mon.y.to_i
107
238
 
108
- # Face toward the player (smooth turning)
239
+ # Face toward the player
109
240
  target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
110
241
  thing.angle = target_angle.round.to_i
111
242
  end
112
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
+
113
312
  def try_move(mon, speed)
114
313
  return false if mon.movedir == DI_NODIR
115
314