doom 0.6.0 → 0.11.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/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +138 -25
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +377 -105
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +231 -0
- data/lib/doom/game/item_pickup.rb +108 -60
- data/lib/doom/game/menu.rb +347 -0
- data/lib/doom/game/monster_ai.rb +304 -91
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +47 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +466 -29
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +176 -0
- data/lib/doom/game/state_hash.rb +125 -0
- data/lib/doom/game/ticcmd.rb +61 -0
- data/lib/doom/game/world.rb +420 -0
- data/lib/doom/map/data.rb +95 -0
- data/lib/doom/net/client.rb +204 -0
- data/lib/doom/net/desync_monitor.rb +101 -0
- data/lib/doom/net/game_server.rb +232 -0
- data/lib/doom/net/lockstep.rb +170 -0
- data/lib/doom/net/protocol.rb +350 -0
- data/lib/doom/net/session.rb +282 -0
- data/lib/doom/net/transport.rb +110 -0
- data/lib/doom/platform/gosu_window.rb +820 -640
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +80 -0
- data/lib/doom/render/hardware_renderer.rb +547 -0
- data/lib/doom/render/ray_tracing/bvh.rb +103 -0
- data/lib/doom/render/ray_tracing/material_state.rb +66 -0
- data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
- data/lib/doom/render/ray_tracing_renderer.rb +940 -0
- data/lib/doom/render/renderer.rb +684 -330
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/spinel_native/kernel.rb +780 -0
- data/lib/doom/render/spinel_native_renderer.rb +162 -0
- data/lib/doom/render/status_bar.rb +14 -10
- data/lib/doom/render/weapon_renderer.rb +15 -17
- data/lib/doom/render/world_mesh.rb +270 -0
- data/lib/doom/render/zbuffer_renderer.rb +151 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/flat.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +7 -3
- data/lib/doom/wad/palette.rb +3 -3
- data/lib/doom/wad/patch.rb +1 -1
- data/lib/doom/wad/reader.rb +59 -10
- data/lib/doom/wad/sound.rb +93 -0
- data/lib/doom/wad/sprite.rb +60 -34
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +235 -12
- metadata +100 -7
data/lib/doom/game/monster_ai.rb
CHANGED
|
@@ -6,8 +6,14 @@ module Doom
|
|
|
6
6
|
# Matches Chocolate Doom's A_Look / A_Chase / P_NewChaseDir from p_enemy.c.
|
|
7
7
|
class MonsterAI
|
|
8
8
|
# 8 movement directions + no direction
|
|
9
|
-
DI_EAST = 0
|
|
10
|
-
|
|
9
|
+
DI_EAST = 0
|
|
10
|
+
DI_NORTHEAST = 1
|
|
11
|
+
DI_NORTH = 2
|
|
12
|
+
DI_NORTHWEST = 3
|
|
13
|
+
DI_WEST = 4
|
|
14
|
+
DI_SOUTHWEST = 5
|
|
15
|
+
DI_SOUTH = 6
|
|
16
|
+
DI_SOUTHEAST = 7
|
|
11
17
|
DI_NODIR = 8
|
|
12
18
|
|
|
13
19
|
# Movement deltas per direction (map units, 1.0 = FRACUNIT)
|
|
@@ -21,83 +27,235 @@ module Doom
|
|
|
21
27
|
MONSTER_SPEED = {
|
|
22
28
|
3004 => 8, 9 => 8, 3001 => 8, 3002 => 10, 58 => 10,
|
|
23
29
|
3003 => 8, 69 => 8, 3005 => 8, 3006 => 8, 16 => 16,
|
|
24
|
-
7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8
|
|
30
|
+
7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8
|
|
25
31
|
}.freeze
|
|
26
32
|
|
|
27
33
|
CHASE_TICS = 4 # Steps between A_Chase calls
|
|
28
|
-
SIGHT_RANGE = 768.0 # Max distance for sight check
|
|
34
|
+
SIGHT_RANGE = 768.0 # Max distance for sight check
|
|
29
35
|
MELEE_RANGE = 64.0
|
|
36
|
+
MISSILE_RANGE = 768.0
|
|
37
|
+
KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
|
|
38
|
+
|
|
39
|
+
# Monster attack definitions (from mobjinfo / A_Chase)
|
|
40
|
+
# Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4)
|
|
41
|
+
# In DOOM, monsters only attempt attacks when movecount reaches 0,
|
|
42
|
+
# then play full attack animation before returning to chase.
|
|
43
|
+
MONSTER_ATTACK = {
|
|
44
|
+
3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Zombieman
|
|
45
|
+
9 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Shotgun Guy
|
|
46
|
+
3001 => { type: :projectile, cooldown: 52 }, # Imp: fireball
|
|
47
|
+
3002 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Demon
|
|
48
|
+
58 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Spectre
|
|
49
|
+
3003 => { type: :projectile, cooldown: 54 }, # Baron: fireball
|
|
50
|
+
69 => { type: :projectile, cooldown: 54 }, # Hell Knight
|
|
51
|
+
3005 => { type: :projectile, cooldown: 56 }, # Cacodemon
|
|
52
|
+
65 => { type: :hitscan, damage: [3, 15], cooldown: 40 } # Heavy Weapon Dude
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
|
|
56
|
+
|
|
57
|
+
# Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread)
|
|
58
|
+
# The falloff is HITSCAN_ACCURACY * (1 - dist / (MISSILE_RANGE * 2)):
|
|
59
|
+
# close (dist 0) = ~85%, mid (dist 384) = ~64%, far (dist 768) = ~42%.
|
|
60
|
+
HITSCAN_ACCURACY = 0.85
|
|
61
|
+
|
|
62
|
+
# Attack animation frames per sprite prefix (E, F, G typically)
|
|
63
|
+
ATTACK_FRAMES = {
|
|
64
|
+
'POSS' => %w[E F], # Zombieman: raise, fire
|
|
65
|
+
'SPOS' => %w[E F], # Shotgun Guy
|
|
66
|
+
'TROO' => %w[E F G H], # Imp: raise, fireball, throw, recover
|
|
67
|
+
'SARG' => %w[E F G], # Demon: bite
|
|
68
|
+
'HEAD' => %w[E F], # Cacodemon
|
|
69
|
+
'BOSS' => %w[E F G], # Baron
|
|
70
|
+
'BOS2' => %w[E F G], # Hell Knight
|
|
71
|
+
'CPOS' => %w[E F] # Heavy Weapon Dude
|
|
72
|
+
}.freeze
|
|
30
73
|
|
|
31
|
-
#
|
|
32
|
-
|
|
74
|
+
ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
|
|
75
|
+
|
|
76
|
+
# Which frame index the actual attack happens on (matching Chocolate Doom)
|
|
77
|
+
# Zombieman: A_PosAttack on frame F (index 1)
|
|
78
|
+
# Imp: A_TroopAttack on frame G (index 2)
|
|
79
|
+
# Demon: A_SargAttack on frame F (index 1)
|
|
80
|
+
FIRE_FRAME_INDEX = {
|
|
81
|
+
'POSS' => 1, # Zombieman: E=raise, F=fire
|
|
82
|
+
'SPOS' => 1, # Shotgun Guy: E=raise, F=fire
|
|
83
|
+
'TROO' => 2, # Imp: E=raise, F=aim, G=throw, H=recover
|
|
84
|
+
'SARG' => 1, # Demon: E=open, F=bite, G=close
|
|
85
|
+
'HEAD' => 1, # Cacodemon: E=charge, F=fire
|
|
86
|
+
'BOSS' => 1, # Baron: E=raise, F=throw, G=recover
|
|
87
|
+
'BOS2' => 1, # Hell Knight
|
|
88
|
+
'CPOS' => 1 # Heavy Weapon Dude
|
|
89
|
+
}.freeze
|
|
33
90
|
|
|
34
91
|
MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
|
|
35
|
-
:active, :chase_timer, :type
|
|
92
|
+
:active, :chase_timer, :type, :attack_cooldown,
|
|
93
|
+
:reactiontime, :last_saw_target,
|
|
94
|
+
:attacking, :attack_frame_tic, :fired, :target)
|
|
36
95
|
|
|
37
|
-
def initialize(map, combat
|
|
96
|
+
def initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil,
|
|
97
|
+
random: Random.new)
|
|
38
98
|
@map = map
|
|
39
99
|
@combat = combat
|
|
100
|
+
@random = random
|
|
101
|
+
@sprites_mgr = sprites_mgr
|
|
40
102
|
@monsters = []
|
|
103
|
+
@aggression = true # Monsters fight back (toggle with C)
|
|
104
|
+
@damage_multiplier = 1.0
|
|
105
|
+
@tic_counter = 0
|
|
106
|
+
@sound = sound_engine
|
|
107
|
+
@monster_by_thing_idx = {}
|
|
41
108
|
|
|
42
109
|
map.things.each_with_index do |thing, idx|
|
|
110
|
+
next if hidden_things[idx] # Filtered by difficulty
|
|
43
111
|
next unless Combat::MONSTER_HP[thing.type]
|
|
44
|
-
|
|
112
|
+
next if thing.type == Combat::BARREL_TYPE
|
|
113
|
+
|
|
114
|
+
mon = MonsterState.new(
|
|
45
115
|
idx, thing.x.to_f, thing.y.to_f,
|
|
46
|
-
DI_NODIR, 0, false, 0, thing.type
|
|
116
|
+
DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
|
|
117
|
+
false, 0, false, nil
|
|
47
118
|
)
|
|
119
|
+
@monsters << mon
|
|
120
|
+
@monster_by_thing_idx[idx] = mon
|
|
48
121
|
end
|
|
49
122
|
end
|
|
50
123
|
|
|
51
|
-
attr_reader :monsters
|
|
124
|
+
attr_reader :monsters, :monster_by_thing_idx
|
|
125
|
+
attr_accessor :aggression, :damage_multiplier
|
|
52
126
|
|
|
53
|
-
# Called each game tic
|
|
54
|
-
|
|
127
|
+
# Called each game tic with every live player. Each monster keeps its own
|
|
128
|
+
# target so a second player walking past does not yank a monster off the
|
|
129
|
+
# one it is already fighting; it re-picks only when its target dies or
|
|
130
|
+
# leaves.
|
|
131
|
+
def update(players)
|
|
132
|
+
players = Array(players)
|
|
133
|
+
@tic_counter += 1
|
|
55
134
|
@monsters.each do |mon|
|
|
135
|
+
target = select_target(mon, players)
|
|
136
|
+
next unless target
|
|
56
137
|
next if @combat.dead?(mon.thing_idx)
|
|
57
138
|
|
|
139
|
+
# Pain state: monster is stunned, skip movement and attacks
|
|
140
|
+
next if @combat.in_pain?(mon.thing_idx)
|
|
141
|
+
|
|
58
142
|
if mon.active
|
|
143
|
+
# Attack animation in progress: freeze movement, tick animation
|
|
144
|
+
if mon.attacking
|
|
145
|
+
mon.attack_frame_tic += 1
|
|
146
|
+
prefix = @sprites_mgr&.prefix_for(mon.type)
|
|
147
|
+
frames = ATTACK_FRAMES[prefix]
|
|
148
|
+
total_tics = (frames&.size || 2) * ATTACK_FRAME_TICS
|
|
149
|
+
|
|
150
|
+
# Fire on the correct frame (matching Chocolate Doom)
|
|
151
|
+
fire_idx = FIRE_FRAME_INDEX[prefix] || 1
|
|
152
|
+
fire_tic = fire_idx * ATTACK_FRAME_TICS
|
|
153
|
+
if !mon.fired && mon.attack_frame_tic >= fire_tic
|
|
154
|
+
execute_attack(mon, target)
|
|
155
|
+
mon.fired = true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if mon.attack_frame_tic >= total_tics
|
|
159
|
+
mon.attacking = false
|
|
160
|
+
mon.attack_frame_tic = 0
|
|
161
|
+
mon.fired = false
|
|
162
|
+
end
|
|
163
|
+
next
|
|
164
|
+
end
|
|
165
|
+
|
|
59
166
|
mon.chase_timer -= 1
|
|
60
167
|
if mon.chase_timer <= 0
|
|
61
168
|
mon.chase_timer = CHASE_TICS
|
|
62
|
-
chase(mon,
|
|
169
|
+
chase(mon, target)
|
|
63
170
|
end
|
|
64
171
|
else
|
|
65
|
-
look(mon,
|
|
172
|
+
look(mon, target)
|
|
66
173
|
end
|
|
67
174
|
end
|
|
68
175
|
end
|
|
69
176
|
|
|
70
177
|
private
|
|
71
178
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
179
|
+
# Keep fighting the current target while it is alive and still present;
|
|
180
|
+
# otherwise take the nearest living player. Monsters that have not woken
|
|
181
|
+
# up yet always look at the nearest one.
|
|
182
|
+
def select_target(mon, players)
|
|
183
|
+
current = mon.target
|
|
184
|
+
return current if current && !current.state.dead && players.include?(current)
|
|
185
|
+
|
|
186
|
+
living = players.reject { |p| p.state.dead }
|
|
187
|
+
return nil if living.empty?
|
|
188
|
+
|
|
189
|
+
mon.target = living.min_by { |p| ((p.x - mon.x)**2) + ((p.y - mon.y)**2) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def look(mon, target)
|
|
193
|
+
target_x = target.x
|
|
194
|
+
target_y = target.y
|
|
195
|
+
dx = target_x - mon.x
|
|
196
|
+
dy = target_y - mon.y
|
|
197
|
+
dist = Math.sqrt((dx * dx) + (dy * dy))
|
|
76
198
|
return if dist > SIGHT_RANGE
|
|
77
199
|
|
|
78
200
|
# DOOM A_Look: monster only sees in ~180-degree forward arc
|
|
79
|
-
# unless
|
|
201
|
+
# unless the target is very close (melee range)
|
|
80
202
|
if dist > MELEE_RANGE
|
|
81
203
|
thing = @map.things[mon.thing_idx]
|
|
82
204
|
face_angle = thing.angle * Math::PI / 180.0
|
|
83
|
-
|
|
84
|
-
angle_diff = ((
|
|
85
|
-
return if angle_diff > Math::PI / 2
|
|
205
|
+
to_target = Math.atan2(dy, dx)
|
|
206
|
+
angle_diff = (((to_target - face_angle + Math::PI) % (2 * Math::PI)) - Math::PI).abs
|
|
207
|
+
return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
|
|
86
208
|
end
|
|
87
209
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
210
|
+
return unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
|
|
211
|
+
|
|
212
|
+
mon.active = true
|
|
213
|
+
mon.chase_timer = CHASE_TICS
|
|
214
|
+
@sound&.monster_see(mon.type)
|
|
92
215
|
end
|
|
93
216
|
|
|
94
|
-
def chase(mon,
|
|
217
|
+
def chase(mon, target)
|
|
218
|
+
target_x = target.x
|
|
219
|
+
target_y = target.y
|
|
95
220
|
speed = MONSTER_SPEED[mon.type] || 8
|
|
96
221
|
|
|
97
|
-
#
|
|
98
|
-
mon.
|
|
99
|
-
|
|
100
|
-
|
|
222
|
+
# Tick down attack cooldown
|
|
223
|
+
mon.attack_cooldown -= CHASE_TICS if mon.attack_cooldown > 0
|
|
224
|
+
|
|
225
|
+
dx = target_x - mon.x
|
|
226
|
+
dy = target_y - mon.y
|
|
227
|
+
dist = Math.sqrt((dx * dx) + (dy * dy))
|
|
228
|
+
|
|
229
|
+
# Track if monster can see the target
|
|
230
|
+
can_see = dist < SIGHT_RANGE && has_line_of_sight?(mon.x, mon.y, target_x, target_y)
|
|
231
|
+
if can_see
|
|
232
|
+
mon.last_saw_target = @tic_counter
|
|
233
|
+
elsif @tic_counter - (mon.last_saw_target || 0) > 105 # ~3 seconds without LOS
|
|
234
|
+
# Monster gives up and goes idle (like DOOM's A_Chase returning to spawnstate)
|
|
235
|
+
mon.active = false
|
|
236
|
+
mon.reactiontime = REACTIONTIME
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Only attempt attacks when: movecount == 0, has LOS, and in range
|
|
241
|
+
if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !target.state.dead
|
|
242
|
+
attacked = try_attack(mon, target, dist)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Move -- but ranged monsters stop advancing when they have LOS and are close enough
|
|
246
|
+
# In DOOM, A_Chase skips movement when P_CheckMissileRange succeeds
|
|
247
|
+
unless attacked
|
|
248
|
+
atk = MONSTER_ATTACK[mon.type]
|
|
249
|
+
ranged = atk && %i[hitscan projectile].include?(atk[:type])
|
|
250
|
+
skip_move = ranged && can_see && dist < KEEP_DISTANCE
|
|
251
|
+
|
|
252
|
+
if skip_move
|
|
253
|
+
# Still tick movecount down so attack condition can trigger
|
|
254
|
+
mon.movecount -= 1 if mon.movecount > 0
|
|
255
|
+
else
|
|
256
|
+
mon.movecount -= 1
|
|
257
|
+
new_chase_dir(mon, target) if mon.movecount < 0 || !try_move(mon, speed)
|
|
258
|
+
end
|
|
101
259
|
end
|
|
102
260
|
|
|
103
261
|
# Update the thing's position and facing angle in the map for rendering
|
|
@@ -105,16 +263,95 @@ module Doom
|
|
|
105
263
|
thing.x = mon.x.to_i
|
|
106
264
|
thing.y = mon.y.to_i
|
|
107
265
|
|
|
108
|
-
# Face toward the
|
|
109
|
-
|
|
110
|
-
thing.angle =
|
|
266
|
+
# Face toward the target
|
|
267
|
+
facing_angle = Math.atan2(target_y - mon.y, target_x - mon.x) * 180.0 / Math::PI
|
|
268
|
+
thing.angle = facing_angle.round.to_i
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Decide whether to start an attack (does NOT apply damage yet)
|
|
272
|
+
# Matches Chocolate Doom's P_CheckMissileRange from p_enemy.c
|
|
273
|
+
def try_attack(mon, target, dist)
|
|
274
|
+
target_x = target.x
|
|
275
|
+
target_y = target.y
|
|
276
|
+
if mon.reactiontime > 0
|
|
277
|
+
mon.reactiontime -= 1
|
|
278
|
+
return false
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
atk = MONSTER_ATTACK[mon.type]
|
|
282
|
+
return false unless atk
|
|
283
|
+
|
|
284
|
+
case atk[:type]
|
|
285
|
+
when :melee
|
|
286
|
+
return false if dist > MELEE_RANGE + (Combat::MONSTER_RADIUS[mon.type] || 20)
|
|
287
|
+
when :hitscan, :projectile
|
|
288
|
+
return false if dist > MISSILE_RANGE
|
|
289
|
+
return false unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
|
|
290
|
+
|
|
291
|
+
# P_CheckMissileRange: subtract grace distance, cap at 200
|
|
292
|
+
check_dist = dist - 64 # 64 unit grace distance
|
|
293
|
+
check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
|
|
294
|
+
check_dist = [check_dist, 0].max
|
|
295
|
+
check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
|
|
296
|
+
return false if @random.rand(256) < check_dist
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Start attack animation (damage applied later on fire frame)
|
|
300
|
+
mon.attacking = true
|
|
301
|
+
mon.attack_frame_tic = 0
|
|
302
|
+
mon.fired = false
|
|
303
|
+
mon.attack_cooldown = atk[:cooldown]
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Called on the fire frame of the attack animation
|
|
308
|
+
def execute_attack(mon, target)
|
|
309
|
+
target_x = target.x
|
|
310
|
+
target_y = target.y
|
|
311
|
+
atk = MONSTER_ATTACK[mon.type]
|
|
312
|
+
return unless atk
|
|
313
|
+
|
|
314
|
+
@sound&.monster_attack(mon.type)
|
|
315
|
+
|
|
316
|
+
dx = target_x - mon.x
|
|
317
|
+
dy = target_y - mon.y
|
|
318
|
+
dist = Math.sqrt((dx * dx) + (dy * dy))
|
|
319
|
+
|
|
320
|
+
# Re-check line of sight at fire time. The player may have moved
|
|
321
|
+
# behind cover or a door may have closed during the attack animation
|
|
322
|
+
# (which spans several tics between try_attack and the fire frame).
|
|
323
|
+
# Melee always lands on contact; ranged skips silently if LOS broke.
|
|
324
|
+
ranged = %i[hitscan projectile].include?(atk[:type])
|
|
325
|
+
return if ranged && !has_line_of_sight?(mon.x, mon.y, target_x, target_y)
|
|
326
|
+
|
|
327
|
+
case atk[:type]
|
|
328
|
+
when :melee
|
|
329
|
+
min_dmg, max_dmg = atk[:damage]
|
|
330
|
+
damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
331
|
+
target.state.take_damage(damage) if damage > 0
|
|
332
|
+
|
|
333
|
+
when :hitscan
|
|
334
|
+
hit_chance = HITSCAN_ACCURACY * (1.0 - (dist / (MISSILE_RANGE * 2)))
|
|
335
|
+
hit_chance = [hit_chance, 0.15].max
|
|
336
|
+
if @random.rand < hit_chance
|
|
337
|
+
min_dmg, max_dmg = atk[:damage]
|
|
338
|
+
damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
339
|
+
target.state.take_damage(damage) if damage > 0
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
when :projectile
|
|
343
|
+
# P_SpawnMissile: z = source->z + 32 (chest height)
|
|
344
|
+
sector = @map.sector_at(mon.x, mon.y)
|
|
345
|
+
spawn_z = (sector ? sector.floor_height : 0) + 32
|
|
346
|
+
@combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier, target)
|
|
347
|
+
end
|
|
111
348
|
end
|
|
112
349
|
|
|
113
350
|
def try_move(mon, speed)
|
|
114
351
|
return false if mon.movedir == DI_NODIR
|
|
115
352
|
|
|
116
|
-
new_x = mon.x + speed * XSPEED[mon.movedir]
|
|
117
|
-
new_y = mon.y + speed * YSPEED[mon.movedir]
|
|
353
|
+
new_x = mon.x + (speed * XSPEED[mon.movedir])
|
|
354
|
+
new_y = mon.y + (speed * YSPEED[mon.movedir])
|
|
118
355
|
|
|
119
356
|
# Check if the position is valid (inside a sector, not blocked by walls)
|
|
120
357
|
sector = @map.sector_at(new_x, new_y)
|
|
@@ -128,7 +365,7 @@ module Doom
|
|
|
128
365
|
|
|
129
366
|
# Simple line-circle intersection
|
|
130
367
|
radius = Combat::MONSTER_RADIUS[mon.type] || 20
|
|
131
|
-
next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
|
|
368
|
+
next unless Geometry.line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
|
|
132
369
|
|
|
133
370
|
# One-sided walls always block
|
|
134
371
|
if ld.sidedef_left == 0xFFFF
|
|
@@ -137,16 +374,16 @@ module Doom
|
|
|
137
374
|
end
|
|
138
375
|
|
|
139
376
|
# Two-sided: check step height and headroom
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
377
|
+
next unless ld.sidedef_left < 0xFFFF
|
|
378
|
+
|
|
379
|
+
front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
|
|
380
|
+
back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
|
|
381
|
+
step = (back.floor_height - front.floor_height).abs
|
|
382
|
+
min_ceil = [front.ceiling_height, back.ceiling_height].min
|
|
383
|
+
max_floor = [front.floor_height, back.floor_height].max
|
|
384
|
+
if step > 24 || (min_ceil - max_floor) < 56
|
|
385
|
+
blocked = true
|
|
386
|
+
break
|
|
150
387
|
end
|
|
151
388
|
end
|
|
152
389
|
return false if blocked
|
|
@@ -156,9 +393,11 @@ module Doom
|
|
|
156
393
|
true
|
|
157
394
|
end
|
|
158
395
|
|
|
159
|
-
def new_chase_dir(mon,
|
|
160
|
-
|
|
161
|
-
|
|
396
|
+
def new_chase_dir(mon, target)
|
|
397
|
+
target_x = target.x
|
|
398
|
+
target_y = target.y
|
|
399
|
+
deltax = target_x - mon.x
|
|
400
|
+
deltay = target_y - mon.y
|
|
162
401
|
old_dir = mon.movedir
|
|
163
402
|
|
|
164
403
|
# Determine preferred directions
|
|
@@ -177,16 +416,12 @@ module Doom
|
|
|
177
416
|
diag = diagonal_dir(dir_x, dir_y)
|
|
178
417
|
if diag != OPPOSITE[old_dir]
|
|
179
418
|
mon.movedir = diag
|
|
180
|
-
if try_walk(mon)
|
|
181
|
-
return
|
|
182
|
-
end
|
|
419
|
+
return if try_walk(mon)
|
|
183
420
|
end
|
|
184
421
|
end
|
|
185
422
|
|
|
186
423
|
# Randomly swap X/Y priority
|
|
187
|
-
if rand > 0.22 || deltay.abs > deltax.abs
|
|
188
|
-
dir_x, dir_y = dir_y, dir_x
|
|
189
|
-
end
|
|
424
|
+
dir_x, dir_y = dir_y, dir_x if @random.rand > 0.22 || deltay.abs > deltax.abs
|
|
190
425
|
|
|
191
426
|
# Try primary direction
|
|
192
427
|
if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
|
|
@@ -207,10 +442,11 @@ module Doom
|
|
|
207
442
|
end
|
|
208
443
|
|
|
209
444
|
# Try all other directions
|
|
210
|
-
start = rand(8)
|
|
445
|
+
start = @random.rand(8)
|
|
211
446
|
8.times do |i|
|
|
212
447
|
d = (start + i) % 8
|
|
213
448
|
next if d == OPPOSITE[old_dir]
|
|
449
|
+
|
|
214
450
|
mon.movedir = d
|
|
215
451
|
return if try_walk(mon)
|
|
216
452
|
end
|
|
@@ -227,7 +463,7 @@ module Doom
|
|
|
227
463
|
def try_walk(mon)
|
|
228
464
|
speed = MONSTER_SPEED[mon.type] || 8
|
|
229
465
|
if try_move(mon, speed)
|
|
230
|
-
mon.movecount = rand(16)
|
|
466
|
+
mon.movecount = @random.rand(16)
|
|
231
467
|
true
|
|
232
468
|
else
|
|
233
469
|
false
|
|
@@ -250,46 +486,23 @@ module Doom
|
|
|
250
486
|
v1 = @map.vertices[ld.v1]
|
|
251
487
|
v2 = @map.vertices[ld.v2]
|
|
252
488
|
|
|
253
|
-
next unless segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
489
|
+
next unless Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
254
490
|
|
|
255
491
|
# One-sided walls always block
|
|
256
492
|
return false if ld.sidedef_left == 0xFFFF
|
|
257
493
|
|
|
258
494
|
# Two-sided: check if opening is big enough to see through
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
495
|
+
next unless ld.sidedef_left < 0xFFFF
|
|
496
|
+
|
|
497
|
+
front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
|
|
498
|
+
back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
|
|
499
|
+
max_floor = [front.floor_height, back.floor_height].max
|
|
500
|
+
min_ceil = [front.ceiling_height, back.ceiling_height].min
|
|
501
|
+
# Block sight if the opening is too small
|
|
502
|
+
return false if (min_ceil - max_floor) < 1
|
|
267
503
|
end
|
|
268
504
|
true
|
|
269
505
|
end
|
|
270
|
-
|
|
271
|
-
def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
|
|
272
|
-
d1x = ax2 - ax1; d1y = ay2 - ay1
|
|
273
|
-
d2x = bx2 - bx1; d2y = by2 - by1
|
|
274
|
-
denom = d1x * d2y - d1y * d2x
|
|
275
|
-
return false if denom.abs < 0.001
|
|
276
|
-
dx = bx1 - ax1; dy = by1 - ay1
|
|
277
|
-
t = (dx * d2y - dy * d2x).to_f / denom
|
|
278
|
-
u = (dx * d1y - dy * d1x).to_f / denom
|
|
279
|
-
t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
|
|
283
|
-
dx = cx - x1; dy = cy - y1
|
|
284
|
-
line_dx = x2 - x1; line_dy = y2 - y1
|
|
285
|
-
line_len_sq = line_dx * line_dx + line_dy * line_dy
|
|
286
|
-
return false if line_len_sq == 0
|
|
287
|
-
t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
|
|
288
|
-
t = [[t, 0.0].max, 1.0].min
|
|
289
|
-
closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
|
|
290
|
-
dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
|
|
291
|
-
dist_sq < radius * radius
|
|
292
|
-
end
|
|
293
506
|
end
|
|
294
507
|
end
|
|
295
508
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# A player as a first-class entity.
|
|
6
|
+
#
|
|
7
|
+
# Before this existed the "player" was scattered across three objects:
|
|
8
|
+
# position and angle lived in Renderer, horizontal momentum lived in
|
|
9
|
+
# GosuWindow, and floor height lived in PlayerPhysics -- which made more
|
|
10
|
+
# than one of them impossible. Everything that identifies a player now
|
|
11
|
+
# lives here; Renderer is reduced to a camera that gets pointed at a pose.
|
|
12
|
+
#
|
|
13
|
+
# Angles are radians internally (Renderer's old setters took degrees).
|
|
14
|
+
class Player
|
|
15
|
+
attr_accessor :id, :x, :y, :z, :angle, :momx, :momy
|
|
16
|
+
# Edge-trigger latch for the use key, per player: holding the key must
|
|
17
|
+
# not spam doors, and each player needs its own latch.
|
|
18
|
+
attr_accessor :use_held
|
|
19
|
+
# Deathmatch score. It lives on the player rather than in a side table so
|
|
20
|
+
# it survives respawning, which throws away the whole PlayerState.
|
|
21
|
+
attr_accessor :frags
|
|
22
|
+
attr_reader :state, :sin_angle, :cos_angle
|
|
23
|
+
|
|
24
|
+
# Previous tic's pose, kept so the renderer can interpolate between tics
|
|
25
|
+
# and stay smooth above 35 FPS.
|
|
26
|
+
attr_reader :prev_x, :prev_y, :prev_z, :prev_angle
|
|
27
|
+
|
|
28
|
+
def initialize(id: 0, state: PlayerState.new)
|
|
29
|
+
@id = id
|
|
30
|
+
@state = state
|
|
31
|
+
@x = 0.0
|
|
32
|
+
@y = 0.0
|
|
33
|
+
@z = 41.0 # eye height
|
|
34
|
+
@momx = 0.0
|
|
35
|
+
@momy = 0.0
|
|
36
|
+
@use_held = false
|
|
37
|
+
@frags = 0
|
|
38
|
+
self.angle = 0.0
|
|
39
|
+
snapshot!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Keeps the sin/cos cache in step with the angle -- every read path
|
|
43
|
+
# (thrust direction, firing direction, view transform) depends on it.
|
|
44
|
+
def angle=(radians)
|
|
45
|
+
@angle = radians
|
|
46
|
+
@sin_angle = Math.sin(radians)
|
|
47
|
+
@cos_angle = Math.cos(radians)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def angle_degrees=(degrees)
|
|
51
|
+
self.angle = degrees * Math::PI / 180.0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def turn(degrees)
|
|
55
|
+
self.angle = @angle + (degrees * Math::PI / 180.0)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def move_to(x, y)
|
|
59
|
+
@x = x.to_f
|
|
60
|
+
@y = y.to_f
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Place the player outright (spawn, teleport, respawn) and collapse the
|
|
64
|
+
# interpolation history so the camera doesn't sweep across the map.
|
|
65
|
+
def place(x, y, z, angle_degrees)
|
|
66
|
+
@x = x.to_f
|
|
67
|
+
@y = y.to_f
|
|
68
|
+
@z = z.to_f
|
|
69
|
+
self.angle_degrees = angle_degrees
|
|
70
|
+
@momx = 0.0
|
|
71
|
+
@momy = 0.0
|
|
72
|
+
snapshot!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Called at the start of each tic, before the simulation moves the player.
|
|
76
|
+
def snapshot!
|
|
77
|
+
@prev_x = @x
|
|
78
|
+
@prev_y = @y
|
|
79
|
+
@prev_z = @z
|
|
80
|
+
@prev_angle = @angle
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Pose to render at, `alpha` of the way from the previous tic to this one.
|
|
84
|
+
# Angle is interpolated along the shortest arc so wrapping past +/-PI
|
|
85
|
+
# doesn't spin the view the long way round.
|
|
86
|
+
def view_pose(alpha)
|
|
87
|
+
a = alpha.clamp(0.0, 1.0)
|
|
88
|
+
delta = @angle - @prev_angle
|
|
89
|
+
delta -= 2 * Math::PI while delta > Math::PI
|
|
90
|
+
delta += 2 * Math::PI while delta < -Math::PI
|
|
91
|
+
|
|
92
|
+
[
|
|
93
|
+
@prev_x + ((@x - @prev_x) * a),
|
|
94
|
+
@prev_y + ((@y - @prev_y) * a),
|
|
95
|
+
@prev_z + ((@z - @prev_z) * a),
|
|
96
|
+
@prev_angle + (delta * a)
|
|
97
|
+
]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def dead?
|
|
101
|
+
@state.dead
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|