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.
- checksums.yaml +4 -4
- data/README.md +43 -21
- data/bin/doom +10 -5
- data/lib/doom/game/combat.rb +325 -13
- 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 +494 -0
- data/lib/doom/game/player_state.rb +53 -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 +589 -49
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +395 -18
- 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 +34 -8
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +48 -4
- metadata +10 -3
|
@@ -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
|