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.
- checksums.yaml +4 -4
- data/bin/doom +5 -2
- data/lib/doom/game/combat.rb +226 -40
- data/lib/doom/game/intermission.rb +248 -0
- data/lib/doom/game/item_pickup.rb +40 -4
- data/lib/doom/game/menu.rb +342 -0
- data/lib/doom/game/monster_ai.rb +210 -11
- data/lib/doom/game/player_state.rb +10 -1
- data/lib/doom/game/sector_actions.rb +376 -15
- data/lib/doom/game/sound_engine.rb +201 -0
- data/lib/doom/platform/gosu_window.rb +460 -52
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +361 -33
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/weapon_renderer.rb +11 -12
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/reader.rb +52 -0
- data/lib/doom/wad/sound.rb +85 -0
- data/lib/doom/wad/sprite.rb +29 -4
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom.rb +47 -5
- metadata +7 -1
|
@@ -50,31 +50,66 @@ module Doom
|
|
|
50
50
|
38 => { cat: :key, key: :red_skull },
|
|
51
51
|
}.freeze
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
MESSAGETICS = 140 # 4 * TICRATE (4 seconds, matching Chocolate Doom)
|
|
54
|
+
FLASH_TICS = 8 # Yellow palette flash duration
|
|
54
55
|
|
|
55
|
-
|
|
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 = {}
|
|
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
|
data/lib/doom/game/monster_ai.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
98
|
-
mon.
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
|