doom 0.8.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 +134 -24
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +239 -153
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +22 -39
- data/lib/doom/game/item_pickup.rb +87 -75
- data/lib/doom/game/menu.rb +90 -85
- data/lib/doom/game/monster_ai.rb +150 -136
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +38 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +169 -93
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +33 -58
- 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 +604 -832
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +5 -3
- 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 +466 -440
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +7 -7
- 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 +9 -10
- 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 +12 -15
- data/lib/doom/wad/sound.rb +14 -6
- data/lib/doom/wad/sprite.rb +36 -35
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +197 -16
- metadata +94 -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,38 +27,36 @@ 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
34
|
SIGHT_RANGE = 768.0 # Max distance for sight check
|
|
29
35
|
MELEE_RANGE = 64.0
|
|
30
36
|
MISSILE_RANGE = 768.0
|
|
31
|
-
KEEP_DISTANCE = 196.0
|
|
32
|
-
|
|
33
|
-
# Direction to angle (for sprite facing)
|
|
34
|
-
DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
|
|
37
|
+
KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
|
|
35
38
|
|
|
36
39
|
# Monster attack definitions (from mobjinfo / A_Chase)
|
|
37
40
|
# Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4)
|
|
38
41
|
# In DOOM, monsters only attempt attacks when movecount reaches 0,
|
|
39
42
|
# then play full attack animation before returning to chase.
|
|
40
43
|
MONSTER_ATTACK = {
|
|
41
|
-
3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 },
|
|
42
|
-
9
|
|
43
|
-
3001 => { type: :projectile, cooldown: 52 },
|
|
44
|
-
3002 => { type: :melee,
|
|
45
|
-
58
|
|
46
|
-
3003 => { type: :projectile, cooldown: 54 },
|
|
47
|
-
69
|
|
48
|
-
3005 => { type: :projectile, cooldown: 56 },
|
|
49
|
-
65
|
|
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
|
|
50
53
|
}.freeze
|
|
51
54
|
|
|
52
|
-
REACTIONTIME = 8
|
|
55
|
+
REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
|
|
53
56
|
|
|
54
57
|
# Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread)
|
|
55
|
-
#
|
|
58
|
+
# The falloff is HITSCAN_ACCURACY * (1 - dist / (MISSILE_RANGE * 2)):
|
|
59
|
+
# close (dist 0) = ~85%, mid (dist 384) = ~64%, far (dist 768) = ~42%.
|
|
56
60
|
HITSCAN_ACCURACY = 0.85
|
|
57
61
|
|
|
58
62
|
# Attack animation frames per sprite prefix (E, F, G typically)
|
|
@@ -64,10 +68,10 @@ module Doom
|
|
|
64
68
|
'HEAD' => %w[E F], # Cacodemon
|
|
65
69
|
'BOSS' => %w[E F G], # Baron
|
|
66
70
|
'BOS2' => %w[E F G], # Hell Knight
|
|
67
|
-
'CPOS' => %w[E F]
|
|
71
|
+
'CPOS' => %w[E F] # Heavy Weapon Dude
|
|
68
72
|
}.freeze
|
|
69
73
|
|
|
70
|
-
ATTACK_FRAME_TICS = 8
|
|
74
|
+
ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
|
|
71
75
|
|
|
72
76
|
# Which frame index the actual attack happens on (matching Chocolate Doom)
|
|
73
77
|
# Zombieman: A_PosAttack on frame F (index 1)
|
|
@@ -81,34 +85,36 @@ module Doom
|
|
|
81
85
|
'HEAD' => 1, # Cacodemon: E=charge, F=fire
|
|
82
86
|
'BOSS' => 1, # Baron: E=raise, F=throw, G=recover
|
|
83
87
|
'BOS2' => 1, # Hell Knight
|
|
84
|
-
'CPOS' => 1
|
|
88
|
+
'CPOS' => 1 # Heavy Weapon Dude
|
|
85
89
|
}.freeze
|
|
86
90
|
|
|
87
91
|
MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
|
|
88
92
|
:active, :chase_timer, :type, :attack_cooldown,
|
|
89
|
-
:reactiontime, :
|
|
90
|
-
:attacking, :attack_frame_tic, :fired)
|
|
93
|
+
:reactiontime, :last_saw_target,
|
|
94
|
+
:attacking, :attack_frame_tic, :fired, :target)
|
|
91
95
|
|
|
92
|
-
def initialize(map, combat,
|
|
96
|
+
def initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil,
|
|
97
|
+
random: Random.new)
|
|
93
98
|
@map = map
|
|
94
99
|
@combat = combat
|
|
95
|
-
@
|
|
100
|
+
@random = random
|
|
96
101
|
@sprites_mgr = sprites_mgr
|
|
97
102
|
@monsters = []
|
|
98
|
-
@aggression = true
|
|
103
|
+
@aggression = true # Monsters fight back (toggle with C)
|
|
99
104
|
@damage_multiplier = 1.0
|
|
100
105
|
@tic_counter = 0
|
|
101
106
|
@sound = sound_engine
|
|
102
107
|
@monster_by_thing_idx = {}
|
|
103
108
|
|
|
104
109
|
map.things.each_with_index do |thing, idx|
|
|
105
|
-
next if hidden_things[idx]
|
|
110
|
+
next if hidden_things[idx] # Filtered by difficulty
|
|
106
111
|
next unless Combat::MONSTER_HP[thing.type]
|
|
107
112
|
next if thing.type == Combat::BARREL_TYPE
|
|
113
|
+
|
|
108
114
|
mon = MonsterState.new(
|
|
109
115
|
idx, thing.x.to_f, thing.y.to_f,
|
|
110
116
|
DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
|
|
111
|
-
false, 0, false
|
|
117
|
+
false, 0, false, nil
|
|
112
118
|
)
|
|
113
119
|
@monsters << mon
|
|
114
120
|
@monster_by_thing_idx[idx] = mon
|
|
@@ -118,10 +124,16 @@ module Doom
|
|
|
118
124
|
attr_reader :monsters, :monster_by_thing_idx
|
|
119
125
|
attr_accessor :aggression, :damage_multiplier
|
|
120
126
|
|
|
121
|
-
# Called each game tic
|
|
122
|
-
|
|
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)
|
|
123
133
|
@tic_counter += 1
|
|
124
134
|
@monsters.each do |mon|
|
|
135
|
+
target = select_target(mon, players)
|
|
136
|
+
next unless target
|
|
125
137
|
next if @combat.dead?(mon.thing_idx)
|
|
126
138
|
|
|
127
139
|
# Pain state: monster is stunned, skip movement and attacks
|
|
@@ -139,7 +151,7 @@ module Doom
|
|
|
139
151
|
fire_idx = FIRE_FRAME_INDEX[prefix] || 1
|
|
140
152
|
fire_tic = fire_idx * ATTACK_FRAME_TICS
|
|
141
153
|
if !mon.fired && mon.attack_frame_tic >= fire_tic
|
|
142
|
-
execute_attack(mon,
|
|
154
|
+
execute_attack(mon, target)
|
|
143
155
|
mon.fired = true
|
|
144
156
|
end
|
|
145
157
|
|
|
@@ -154,54 +166,71 @@ module Doom
|
|
|
154
166
|
mon.chase_timer -= 1
|
|
155
167
|
if mon.chase_timer <= 0
|
|
156
168
|
mon.chase_timer = CHASE_TICS
|
|
157
|
-
chase(mon,
|
|
169
|
+
chase(mon, target)
|
|
158
170
|
end
|
|
159
171
|
else
|
|
160
|
-
look(mon,
|
|
172
|
+
look(mon, target)
|
|
161
173
|
end
|
|
162
174
|
end
|
|
163
175
|
end
|
|
164
176
|
|
|
165
177
|
private
|
|
166
178
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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))
|
|
171
198
|
return if dist > SIGHT_RANGE
|
|
172
199
|
|
|
173
200
|
# DOOM A_Look: monster only sees in ~180-degree forward arc
|
|
174
|
-
# unless
|
|
201
|
+
# unless the target is very close (melee range)
|
|
175
202
|
if dist > MELEE_RANGE
|
|
176
203
|
thing = @map.things[mon.thing_idx]
|
|
177
204
|
face_angle = thing.angle * Math::PI / 180.0
|
|
178
|
-
|
|
179
|
-
angle_diff = ((
|
|
180
|
-
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
|
|
181
208
|
end
|
|
182
209
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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)
|
|
188
215
|
end
|
|
189
216
|
|
|
190
|
-
def chase(mon,
|
|
217
|
+
def chase(mon, target)
|
|
218
|
+
target_x = target.x
|
|
219
|
+
target_y = target.y
|
|
191
220
|
speed = MONSTER_SPEED[mon.type] || 8
|
|
192
221
|
|
|
193
222
|
# Tick down attack cooldown
|
|
194
223
|
mon.attack_cooldown -= CHASE_TICS if mon.attack_cooldown > 0
|
|
195
224
|
|
|
196
|
-
dx =
|
|
197
|
-
dy =
|
|
198
|
-
dist = Math.sqrt(dx * dx + dy * dy)
|
|
225
|
+
dx = target_x - mon.x
|
|
226
|
+
dy = target_y - mon.y
|
|
227
|
+
dist = Math.sqrt((dx * dx) + (dy * dy))
|
|
199
228
|
|
|
200
|
-
# Track if monster can see the
|
|
201
|
-
can_see = dist < SIGHT_RANGE && has_line_of_sight?(mon.x, mon.y,
|
|
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)
|
|
202
231
|
if can_see
|
|
203
|
-
mon.
|
|
204
|
-
elsif @tic_counter - (mon.
|
|
232
|
+
mon.last_saw_target = @tic_counter
|
|
233
|
+
elsif @tic_counter - (mon.last_saw_target || 0) > 105 # ~3 seconds without LOS
|
|
205
234
|
# Monster gives up and goes idle (like DOOM's A_Chase returning to spawnstate)
|
|
206
235
|
mon.active = false
|
|
207
236
|
mon.reactiontime = REACTIONTIME
|
|
@@ -209,15 +238,15 @@ module Doom
|
|
|
209
238
|
end
|
|
210
239
|
|
|
211
240
|
# Only attempt attacks when: movecount == 0, has LOS, and in range
|
|
212
|
-
if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see &&
|
|
213
|
-
attacked = try_attack(mon,
|
|
241
|
+
if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !target.state.dead
|
|
242
|
+
attacked = try_attack(mon, target, dist)
|
|
214
243
|
end
|
|
215
244
|
|
|
216
245
|
# Move -- but ranged monsters stop advancing when they have LOS and are close enough
|
|
217
246
|
# In DOOM, A_Chase skips movement when P_CheckMissileRange succeeds
|
|
218
247
|
unless attacked
|
|
219
248
|
atk = MONSTER_ATTACK[mon.type]
|
|
220
|
-
ranged = atk &&
|
|
249
|
+
ranged = atk && %i[hitscan projectile].include?(atk[:type])
|
|
221
250
|
skip_move = ranged && can_see && dist < KEEP_DISTANCE
|
|
222
251
|
|
|
223
252
|
if skip_move
|
|
@@ -225,9 +254,7 @@ module Doom
|
|
|
225
254
|
mon.movecount -= 1 if mon.movecount > 0
|
|
226
255
|
else
|
|
227
256
|
mon.movecount -= 1
|
|
228
|
-
if mon.movecount < 0 || !try_move(mon, speed)
|
|
229
|
-
new_chase_dir(mon, player_x, player_y)
|
|
230
|
-
end
|
|
257
|
+
new_chase_dir(mon, target) if mon.movecount < 0 || !try_move(mon, speed)
|
|
231
258
|
end
|
|
232
259
|
end
|
|
233
260
|
|
|
@@ -236,14 +263,16 @@ module Doom
|
|
|
236
263
|
thing.x = mon.x.to_i
|
|
237
264
|
thing.y = mon.y.to_i
|
|
238
265
|
|
|
239
|
-
# Face toward the
|
|
240
|
-
|
|
241
|
-
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
|
|
242
269
|
end
|
|
243
270
|
|
|
244
271
|
# Decide whether to start an attack (does NOT apply damage yet)
|
|
245
272
|
# Matches Chocolate Doom's P_CheckMissileRange from p_enemy.c
|
|
246
|
-
def try_attack(mon,
|
|
273
|
+
def try_attack(mon, target, dist)
|
|
274
|
+
target_x = target.x
|
|
275
|
+
target_y = target.y
|
|
247
276
|
if mon.reactiontime > 0
|
|
248
277
|
mon.reactiontime -= 1
|
|
249
278
|
return false
|
|
@@ -257,14 +286,14 @@ module Doom
|
|
|
257
286
|
return false if dist > MELEE_RANGE + (Combat::MONSTER_RADIUS[mon.type] || 20)
|
|
258
287
|
when :hitscan, :projectile
|
|
259
288
|
return false if dist > MISSILE_RANGE
|
|
260
|
-
return false unless has_line_of_sight?(mon.x, mon.y,
|
|
289
|
+
return false unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
|
|
261
290
|
|
|
262
291
|
# P_CheckMissileRange: subtract grace distance, cap at 200
|
|
263
|
-
check_dist = dist - 64
|
|
264
|
-
check_dist -= 128 if atk[:type] == :projectile
|
|
292
|
+
check_dist = dist - 64 # 64 unit grace distance
|
|
293
|
+
check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
|
|
265
294
|
check_dist = [check_dist, 0].max
|
|
266
|
-
check_dist = [check_dist, 200].min
|
|
267
|
-
return false if rand(256) < check_dist
|
|
295
|
+
check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
|
|
296
|
+
return false if @random.rand(256) < check_dist
|
|
268
297
|
end
|
|
269
298
|
|
|
270
299
|
# Start attack animation (damage applied later on fire frame)
|
|
@@ -276,44 +305,53 @@ module Doom
|
|
|
276
305
|
end
|
|
277
306
|
|
|
278
307
|
# Called on the fire frame of the attack animation
|
|
279
|
-
def execute_attack(mon,
|
|
308
|
+
def execute_attack(mon, target)
|
|
309
|
+
target_x = target.x
|
|
310
|
+
target_y = target.y
|
|
280
311
|
atk = MONSTER_ATTACK[mon.type]
|
|
281
312
|
return unless atk
|
|
282
313
|
|
|
283
314
|
@sound&.monster_attack(mon.type)
|
|
284
315
|
|
|
285
|
-
dx =
|
|
286
|
-
dy =
|
|
287
|
-
dist = Math.sqrt(dx * dx + dy * dy)
|
|
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)
|
|
288
326
|
|
|
289
327
|
case atk[:type]
|
|
290
328
|
when :melee
|
|
291
329
|
min_dmg, max_dmg = atk[:damage]
|
|
292
|
-
damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
293
|
-
|
|
330
|
+
damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
331
|
+
target.state.take_damage(damage) if damage > 0
|
|
294
332
|
|
|
295
333
|
when :hitscan
|
|
296
|
-
hit_chance = HITSCAN_ACCURACY * (1.0 - dist / (MISSILE_RANGE * 2))
|
|
334
|
+
hit_chance = HITSCAN_ACCURACY * (1.0 - (dist / (MISSILE_RANGE * 2)))
|
|
297
335
|
hit_chance = [hit_chance, 0.15].max
|
|
298
|
-
if rand < hit_chance
|
|
336
|
+
if @random.rand < hit_chance
|
|
299
337
|
min_dmg, max_dmg = atk[:damage]
|
|
300
|
-
damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
301
|
-
|
|
338
|
+
damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
|
|
339
|
+
target.state.take_damage(damage) if damage > 0
|
|
302
340
|
end
|
|
303
341
|
|
|
304
342
|
when :projectile
|
|
305
343
|
# P_SpawnMissile: z = source->z + 32 (chest height)
|
|
306
344
|
sector = @map.sector_at(mon.x, mon.y)
|
|
307
345
|
spawn_z = (sector ? sector.floor_height : 0) + 32
|
|
308
|
-
@combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier)
|
|
346
|
+
@combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier, target)
|
|
309
347
|
end
|
|
310
348
|
end
|
|
311
349
|
|
|
312
350
|
def try_move(mon, speed)
|
|
313
351
|
return false if mon.movedir == DI_NODIR
|
|
314
352
|
|
|
315
|
-
new_x = mon.x + speed * XSPEED[mon.movedir]
|
|
316
|
-
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])
|
|
317
355
|
|
|
318
356
|
# Check if the position is valid (inside a sector, not blocked by walls)
|
|
319
357
|
sector = @map.sector_at(new_x, new_y)
|
|
@@ -327,7 +365,7 @@ module Doom
|
|
|
327
365
|
|
|
328
366
|
# Simple line-circle intersection
|
|
329
367
|
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)
|
|
368
|
+
next unless Geometry.line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
|
|
331
369
|
|
|
332
370
|
# One-sided walls always block
|
|
333
371
|
if ld.sidedef_left == 0xFFFF
|
|
@@ -336,16 +374,16 @@ module Doom
|
|
|
336
374
|
end
|
|
337
375
|
|
|
338
376
|
# Two-sided: check step height and headroom
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
349
387
|
end
|
|
350
388
|
end
|
|
351
389
|
return false if blocked
|
|
@@ -355,9 +393,11 @@ module Doom
|
|
|
355
393
|
true
|
|
356
394
|
end
|
|
357
395
|
|
|
358
|
-
def new_chase_dir(mon,
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
361
401
|
old_dir = mon.movedir
|
|
362
402
|
|
|
363
403
|
# Determine preferred directions
|
|
@@ -376,16 +416,12 @@ module Doom
|
|
|
376
416
|
diag = diagonal_dir(dir_x, dir_y)
|
|
377
417
|
if diag != OPPOSITE[old_dir]
|
|
378
418
|
mon.movedir = diag
|
|
379
|
-
if try_walk(mon)
|
|
380
|
-
return
|
|
381
|
-
end
|
|
419
|
+
return if try_walk(mon)
|
|
382
420
|
end
|
|
383
421
|
end
|
|
384
422
|
|
|
385
423
|
# 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
|
|
424
|
+
dir_x, dir_y = dir_y, dir_x if @random.rand > 0.22 || deltay.abs > deltax.abs
|
|
389
425
|
|
|
390
426
|
# Try primary direction
|
|
391
427
|
if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
|
|
@@ -406,10 +442,11 @@ module Doom
|
|
|
406
442
|
end
|
|
407
443
|
|
|
408
444
|
# Try all other directions
|
|
409
|
-
start = rand(8)
|
|
445
|
+
start = @random.rand(8)
|
|
410
446
|
8.times do |i|
|
|
411
447
|
d = (start + i) % 8
|
|
412
448
|
next if d == OPPOSITE[old_dir]
|
|
449
|
+
|
|
413
450
|
mon.movedir = d
|
|
414
451
|
return if try_walk(mon)
|
|
415
452
|
end
|
|
@@ -426,7 +463,7 @@ module Doom
|
|
|
426
463
|
def try_walk(mon)
|
|
427
464
|
speed = MONSTER_SPEED[mon.type] || 8
|
|
428
465
|
if try_move(mon, speed)
|
|
429
|
-
mon.movecount = rand(16)
|
|
466
|
+
mon.movecount = @random.rand(16)
|
|
430
467
|
true
|
|
431
468
|
else
|
|
432
469
|
false
|
|
@@ -449,46 +486,23 @@ module Doom
|
|
|
449
486
|
v1 = @map.vertices[ld.v1]
|
|
450
487
|
v2 = @map.vertices[ld.v2]
|
|
451
488
|
|
|
452
|
-
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)
|
|
453
490
|
|
|
454
491
|
# One-sided walls always block
|
|
455
492
|
return false if ld.sidedef_left == 0xFFFF
|
|
456
493
|
|
|
457
494
|
# Two-sided: check if opening is big enough to see through
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
466
503
|
end
|
|
467
504
|
true
|
|
468
505
|
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
506
|
end
|
|
493
507
|
end
|
|
494
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
|