doom 0.4.0 → 0.5.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 +1 -1
- data/lib/doom/game/animations.rb +97 -0
- data/lib/doom/game/combat.rb +244 -0
- data/lib/doom/game/item_pickup.rb +170 -0
- data/lib/doom/game/player_state.rb +101 -9
- data/lib/doom/game/sector_effects.rb +179 -0
- data/lib/doom/platform/gosu_window.rb +536 -66
- data/lib/doom/render/renderer.rb +297 -90
- data/lib/doom/render/status_bar.rb +74 -22
- data/lib/doom/render/weapon_renderer.rb +25 -28
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +70 -2
- data/lib/doom/wad/sprite.rb +95 -22
- data/lib/doom/wad/texture.rb +23 -12
- data/lib/doom.rb +13 -2
- metadata +7 -6
|
@@ -7,15 +7,48 @@ module Doom
|
|
|
7
7
|
class GosuWindow < Gosu::Window
|
|
8
8
|
SCALE = 3
|
|
9
9
|
|
|
10
|
-
# Movement constants
|
|
11
|
-
|
|
10
|
+
# Movement constants (matching Chocolate Doom P_Thrust / P_XYMovement)
|
|
11
|
+
# DOOM: terminal walk speed = 7.55 units/tic = 264 units/sec
|
|
12
|
+
# Continuous-time: v_terminal = thrust_rate / decay_rate
|
|
13
|
+
# decay_rate = -ln(0.90625) * 35 = 3.44/sec
|
|
14
|
+
# thrust_rate = 264 * 3.44 = 908 units/sec^2
|
|
15
|
+
MOVE_THRUST_RATE = 264.0 * 3.44 # Thrust rate (units/sec^2)
|
|
16
|
+
FRICTION_DECAY_RATE = 3.44 # Friction decay (1/sec)
|
|
17
|
+
STOPSPEED = 0.5 # Snap-to-zero threshold (units/sec)
|
|
12
18
|
TURN_SPEED = 3.0 # Degrees per frame
|
|
13
19
|
MOUSE_SENSITIVITY = 0.15 # Mouse look sensitivity
|
|
14
20
|
PLAYER_RADIUS = 16.0 # Collision radius
|
|
15
21
|
|
|
16
22
|
USE_DISTANCE = 64.0 # Max distance to use a linedef
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
# Solid thing types with their collision radii (from mobjinfo[] MF_SOLID)
|
|
25
|
+
# Monsters, barrels, pillars, lamps, torches, trees block player movement
|
|
26
|
+
SOLID_THING_RADIUS = {
|
|
27
|
+
9 => 20, 65 => 20, 66 => 20, 67 => 20, 68 => 20, # Shotgun Guy variants
|
|
28
|
+
3004 => 20, 84 => 20, # Zombieman
|
|
29
|
+
3001 => 20, # Imp
|
|
30
|
+
3002 => 30, 58 => 30, # Demon, Spectre
|
|
31
|
+
3003 => 24, 69 => 24, # Baron, Hell Knight
|
|
32
|
+
3006 => 16, # Lost Soul
|
|
33
|
+
3005 => 31, # Cacodemon
|
|
34
|
+
16 => 40, # Cyberdemon
|
|
35
|
+
7 => 128, # Spider Mastermind
|
|
36
|
+
64 => 20, # Archvile
|
|
37
|
+
71 => 31, # Pain Elemental
|
|
38
|
+
2035 => 10, # Barrel
|
|
39
|
+
2028 => 16, # Tall lamp
|
|
40
|
+
48 => 16, 30 => 16, 32 => 16, # Tech column, green/red pillars
|
|
41
|
+
31 => 16, 33 => 16, 36 => 16, # Short pillars
|
|
42
|
+
41 => 16, 43 => 16, # Evil eye, burnt tree
|
|
43
|
+
54 => 32, # Brown tree
|
|
44
|
+
44 => 16, 45 => 16, 46 => 16, # Tall torches
|
|
45
|
+
55 => 16, 56 => 16, 57 => 16, # Short torches
|
|
46
|
+
47 => 16, 70 => 16, # Stubs
|
|
47
|
+
85 => 16, 86 => 16, # Tall tech lamps
|
|
48
|
+
2046 => 16, # Burning barrel
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil, animations = nil, sector_effects = nil, item_pickup = nil, combat = nil)
|
|
19
52
|
super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, false)
|
|
20
53
|
self.caption = 'Doom Ruby'
|
|
21
54
|
|
|
@@ -26,11 +59,26 @@ module Doom
|
|
|
26
59
|
@status_bar = status_bar
|
|
27
60
|
@weapon_renderer = weapon_renderer
|
|
28
61
|
@sector_actions = sector_actions
|
|
62
|
+
@animations = animations
|
|
63
|
+
@sector_effects = sector_effects
|
|
64
|
+
@item_pickup = item_pickup
|
|
65
|
+
@combat = combat
|
|
66
|
+
@last_floor_height = nil
|
|
67
|
+
@move_momx = 0.0
|
|
68
|
+
@move_momy = 0.0
|
|
69
|
+
@leveltime = 0
|
|
70
|
+
@tic_accumulator = 0.0
|
|
29
71
|
@screen_image = nil
|
|
30
72
|
@mouse_captured = false
|
|
31
73
|
@last_mouse_x = nil
|
|
32
74
|
@last_update_time = Time.now
|
|
33
75
|
@use_pressed = false
|
|
76
|
+
@show_debug = false
|
|
77
|
+
@show_map = false
|
|
78
|
+
@debug_font = Gosu::Font.new(16)
|
|
79
|
+
|
|
80
|
+
# Precompute sector colors for automap
|
|
81
|
+
@sector_colors = build_sector_colors
|
|
34
82
|
|
|
35
83
|
# Pre-build palette lookup for speed
|
|
36
84
|
@palette_rgba = palette.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
|
|
@@ -42,13 +90,25 @@ module Doom
|
|
|
42
90
|
delta_time = now - @last_update_time
|
|
43
91
|
@last_update_time = now
|
|
44
92
|
|
|
45
|
-
handle_input
|
|
93
|
+
handle_input(delta_time)
|
|
46
94
|
|
|
47
|
-
# Update player state
|
|
95
|
+
# Update player state (per-frame for smooth bob)
|
|
48
96
|
if @player_state
|
|
49
|
-
@player_state.update_attack
|
|
50
97
|
@player_state.update_bob(delta_time)
|
|
98
|
+
@player_state.update_view_bob(delta_time)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Advance game tics at 35/sec (DOOM's tic rate)
|
|
102
|
+
@tic_accumulator += delta_time * 35.0
|
|
103
|
+
while @tic_accumulator >= 1.0
|
|
104
|
+
@leveltime += 1
|
|
105
|
+
@tic_accumulator -= 1.0
|
|
106
|
+
@sector_effects&.update
|
|
107
|
+
@player_state&.update_viewheight
|
|
108
|
+
@player_state&.update_attack # Attack timing at 35fps like DOOM
|
|
109
|
+
@combat&.update
|
|
51
110
|
end
|
|
111
|
+
@animations&.update(@leveltime)
|
|
52
112
|
|
|
53
113
|
# Update HUD animations
|
|
54
114
|
@status_bar&.update
|
|
@@ -59,6 +119,15 @@ module Doom
|
|
|
59
119
|
@sector_actions.update
|
|
60
120
|
end
|
|
61
121
|
|
|
122
|
+
# Check item pickups
|
|
123
|
+
if @item_pickup
|
|
124
|
+
@item_pickup.update(@renderer.player_x, @renderer.player_y)
|
|
125
|
+
@renderer.hidden_things = @item_pickup.picked_up
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Pass combat state to renderer for death frame rendering
|
|
129
|
+
@renderer.combat = @combat
|
|
130
|
+
|
|
62
131
|
# Render the 3D world
|
|
63
132
|
@renderer.render_frame
|
|
64
133
|
|
|
@@ -71,7 +140,7 @@ module Doom
|
|
|
71
140
|
end
|
|
72
141
|
end
|
|
73
142
|
|
|
74
|
-
def handle_input
|
|
143
|
+
def handle_input(delta_time)
|
|
75
144
|
# Mouse look
|
|
76
145
|
handle_mouse_look
|
|
77
146
|
|
|
@@ -83,41 +152,63 @@ module Doom
|
|
|
83
152
|
@renderer.turn(-TURN_SPEED)
|
|
84
153
|
end
|
|
85
154
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
155
|
+
# Apply thrust from input (P_Thrust: additive, scaled by delta_time)
|
|
156
|
+
thrust = MOVE_THRUST_RATE * delta_time
|
|
157
|
+
has_input = false
|
|
89
158
|
|
|
90
159
|
if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
|
|
91
|
-
|
|
92
|
-
|
|
160
|
+
@move_momx += @renderer.cos_angle * thrust
|
|
161
|
+
@move_momy += @renderer.sin_angle * thrust
|
|
162
|
+
has_input = true
|
|
93
163
|
end
|
|
94
164
|
if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
|
|
95
|
-
|
|
96
|
-
|
|
165
|
+
@move_momx -= @renderer.cos_angle * thrust
|
|
166
|
+
@move_momy -= @renderer.sin_angle * thrust
|
|
167
|
+
has_input = true
|
|
97
168
|
end
|
|
98
|
-
|
|
99
|
-
# Strafe
|
|
100
169
|
if Gosu.button_down?(Gosu::KB_A)
|
|
101
|
-
|
|
102
|
-
|
|
170
|
+
@move_momx -= @renderer.sin_angle * thrust
|
|
171
|
+
@move_momy += @renderer.cos_angle * thrust
|
|
172
|
+
has_input = true
|
|
103
173
|
end
|
|
104
174
|
if Gosu.button_down?(Gosu::KB_D)
|
|
105
|
-
|
|
106
|
-
|
|
175
|
+
@move_momx += @renderer.sin_angle * thrust
|
|
176
|
+
@move_momy -= @renderer.cos_angle * thrust
|
|
177
|
+
has_input = true
|
|
107
178
|
end
|
|
108
179
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
@
|
|
180
|
+
# Apply friction (continuous-time equivalent of *= 0.90625 per tic)
|
|
181
|
+
decay = Math.exp(-FRICTION_DECAY_RATE * delta_time)
|
|
182
|
+
if !has_input && @move_momx.abs < STOPSPEED && @move_momy.abs < STOPSPEED
|
|
183
|
+
@move_momx = 0.0
|
|
184
|
+
@move_momy = 0.0
|
|
185
|
+
else
|
|
186
|
+
@move_momx *= decay
|
|
187
|
+
@move_momy *= decay
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Track movement state for weapon/view bob
|
|
191
|
+
if @player_state
|
|
192
|
+
@player_state.is_moving = has_input
|
|
193
|
+
@player_state.set_movement_momentum(@move_momx, @move_momy)
|
|
194
|
+
end
|
|
112
195
|
|
|
113
|
-
# Apply
|
|
114
|
-
if
|
|
115
|
-
try_move(
|
|
196
|
+
# Apply momentum with collision detection (scale by delta_time for frame-rate independence)
|
|
197
|
+
if @move_momx.abs > STOPSPEED || @move_momy.abs > STOPSPEED
|
|
198
|
+
try_move(@move_momx * delta_time, @move_momy * delta_time)
|
|
116
199
|
end
|
|
117
200
|
|
|
118
|
-
# Handle firing
|
|
119
|
-
if @player_state && @mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)
|
|
201
|
+
# Handle firing (left click, Z, or Shift - Ctrl conflicts with macOS spaces)
|
|
202
|
+
if @player_state && ((@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
|
|
203
|
+
Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
|
|
204
|
+
Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
|
|
205
|
+
was_attacking = @player_state.attacking
|
|
120
206
|
@player_state.start_attack
|
|
207
|
+
# Fire hitscan on the first frame of the attack
|
|
208
|
+
if @player_state.attacking && !was_attacking && @combat
|
|
209
|
+
@combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
|
|
210
|
+
@renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
|
|
211
|
+
end
|
|
121
212
|
end
|
|
122
213
|
|
|
123
214
|
# Handle weapon switching with number keys
|
|
@@ -252,53 +343,200 @@ module Doom
|
|
|
252
343
|
end
|
|
253
344
|
|
|
254
345
|
def try_move(dx, dy)
|
|
255
|
-
|
|
256
|
-
|
|
346
|
+
old_x = @renderer.player_x
|
|
347
|
+
old_y = @renderer.player_y
|
|
348
|
+
new_x = old_x + dx
|
|
349
|
+
new_y = old_y + dy
|
|
257
350
|
|
|
258
|
-
# Check if new position is valid
|
|
259
|
-
if
|
|
351
|
+
# Check if new position is valid and path doesn't cross blocking linedefs
|
|
352
|
+
if valid_move?(old_x, old_y, new_x, new_y)
|
|
260
353
|
@renderer.move_to(new_x, new_y)
|
|
261
354
|
update_player_height(new_x, new_y)
|
|
262
355
|
else
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
356
|
+
# Wall sliding: project movement along the blocking wall
|
|
357
|
+
slide_x, slide_y = compute_slide(old_x, old_y, dx, dy)
|
|
358
|
+
if slide_x && (slide_x != 0.0 || slide_y != 0.0)
|
|
359
|
+
sx = old_x + slide_x
|
|
360
|
+
sy = old_y + slide_y
|
|
361
|
+
if valid_move?(old_x, old_y, sx, sy)
|
|
362
|
+
@renderer.move_to(sx, sy)
|
|
363
|
+
update_player_height(sx, sy)
|
|
364
|
+
# Redirect momentum along the wall
|
|
365
|
+
@move_momx = slide_x / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momx.abs
|
|
366
|
+
@move_momy = slide_y / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momy.abs
|
|
367
|
+
return
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Fallback: try axis-aligned sliding
|
|
372
|
+
if dx != 0.0 && valid_move?(old_x, old_y, new_x, old_y)
|
|
373
|
+
@renderer.move_to(new_x, old_y)
|
|
374
|
+
update_player_height(new_x, old_y)
|
|
375
|
+
@move_momy *= 0.0
|
|
376
|
+
elsif dy != 0.0 && valid_move?(old_x, old_y, old_x, new_y)
|
|
377
|
+
@renderer.move_to(old_x, new_y)
|
|
378
|
+
update_player_height(old_x, new_y)
|
|
379
|
+
@move_momx *= 0.0
|
|
380
|
+
else
|
|
381
|
+
# Fully blocked - kill momentum
|
|
382
|
+
@move_momx = 0.0
|
|
383
|
+
@move_momy = 0.0
|
|
271
384
|
end
|
|
272
385
|
end
|
|
273
386
|
end
|
|
274
387
|
|
|
388
|
+
# Find the blocking linedef and project movement along it
|
|
389
|
+
def compute_slide(px, py, dx, dy)
|
|
390
|
+
best_wall = nil
|
|
391
|
+
best_dist = Float::INFINITY
|
|
392
|
+
|
|
393
|
+
@map.linedefs.each do |linedef|
|
|
394
|
+
v1 = @map.vertices[linedef.v1]
|
|
395
|
+
v2 = @map.vertices[linedef.v2]
|
|
396
|
+
|
|
397
|
+
# Only check linedefs near the player
|
|
398
|
+
next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, px + dx, py + dy, PLAYER_RADIUS)
|
|
399
|
+
|
|
400
|
+
# Check if this linedef actually blocks
|
|
401
|
+
next unless linedef_blocks?(linedef, px + dx, py + dy) ||
|
|
402
|
+
crosses_blocking_linedef?(px, py, px + dx, py + dy, linedef)
|
|
403
|
+
|
|
404
|
+
# Distance from player to this linedef
|
|
405
|
+
dist = point_to_line_distance(px, py, v1.x, v1.y, v2.x, v2.y)
|
|
406
|
+
if dist < best_dist
|
|
407
|
+
best_dist = dist
|
|
408
|
+
best_wall = linedef
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
return nil unless best_wall
|
|
413
|
+
|
|
414
|
+
# Get wall direction vector
|
|
415
|
+
v1 = @map.vertices[best_wall.v1]
|
|
416
|
+
v2 = @map.vertices[best_wall.v2]
|
|
417
|
+
wall_dx = (v2.x - v1.x).to_f
|
|
418
|
+
wall_dy = (v2.y - v1.y).to_f
|
|
419
|
+
wall_len = Math.sqrt(wall_dx * wall_dx + wall_dy * wall_dy)
|
|
420
|
+
return nil if wall_len == 0
|
|
421
|
+
|
|
422
|
+
wall_dx /= wall_len
|
|
423
|
+
wall_dy /= wall_len
|
|
424
|
+
|
|
425
|
+
# Project movement onto wall direction
|
|
426
|
+
dot = dx * wall_dx + dy * wall_dy
|
|
427
|
+
[dot * wall_dx, dot * wall_dy]
|
|
428
|
+
end
|
|
429
|
+
|
|
275
430
|
def update_player_height(x, y)
|
|
276
431
|
sector = @map.sector_at(x, y)
|
|
277
432
|
return unless sector
|
|
278
433
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
@
|
|
434
|
+
new_floor = sector.floor_height
|
|
435
|
+
|
|
436
|
+
if @player_state
|
|
437
|
+
# Detect step: floor height changed since last move
|
|
438
|
+
if @last_floor_height && @last_floor_height != new_floor
|
|
439
|
+
step = new_floor - @last_floor_height
|
|
440
|
+
@player_state.notify_step(step) if step.abs <= 24
|
|
441
|
+
end
|
|
442
|
+
@last_floor_height = new_floor
|
|
443
|
+
|
|
444
|
+
view_bob = @player_state.view_bob_offset
|
|
445
|
+
@renderer.set_z(new_floor + @player_state.viewheight + view_bob)
|
|
446
|
+
else
|
|
447
|
+
@renderer.set_z(new_floor + 41)
|
|
448
|
+
end
|
|
282
449
|
end
|
|
283
450
|
|
|
284
|
-
def
|
|
285
|
-
# Check if
|
|
286
|
-
sector = @map.sector_at(
|
|
451
|
+
def valid_move?(old_x, old_y, new_x, new_y)
|
|
452
|
+
# Check if destination is inside a valid sector
|
|
453
|
+
sector = @map.sector_at(new_x, new_y)
|
|
287
454
|
return false unless sector
|
|
288
455
|
|
|
289
|
-
# Check floor height - can't
|
|
456
|
+
# Check floor height - can't step up too high
|
|
290
457
|
floor_height = sector.floor_height
|
|
291
458
|
return false if floor_height > @renderer.player_z + 24 # Max step height
|
|
292
459
|
|
|
293
|
-
# Check against blocking linedefs
|
|
460
|
+
# Check against blocking linedefs: both circle intersection and path crossing
|
|
294
461
|
@map.linedefs.each do |linedef|
|
|
295
|
-
|
|
296
|
-
|
|
462
|
+
if linedef_blocks?(linedef, new_x, new_y)
|
|
463
|
+
return false
|
|
464
|
+
end
|
|
465
|
+
if crosses_blocking_linedef?(old_x, old_y, new_x, new_y, linedef)
|
|
466
|
+
return false
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Check against solid things (monsters, barrels, pillars, etc.)
|
|
471
|
+
combined_radius = PLAYER_RADIUS
|
|
472
|
+
picked = @item_pickup&.picked_up
|
|
473
|
+
@map.things.each_with_index do |thing, idx|
|
|
474
|
+
next if picked && picked[idx]
|
|
475
|
+
next if @combat && @combat.dead?(idx)
|
|
476
|
+
thing_radius = SOLID_THING_RADIUS[thing.type]
|
|
477
|
+
next unless thing_radius
|
|
478
|
+
|
|
479
|
+
dx = new_x - thing.x
|
|
480
|
+
dy = new_y - thing.y
|
|
481
|
+
min_dist = combined_radius + thing_radius
|
|
482
|
+
if dx * dx + dy * dy < min_dist * min_dist
|
|
483
|
+
return false
|
|
484
|
+
end
|
|
297
485
|
end
|
|
298
486
|
|
|
299
487
|
true
|
|
300
488
|
end
|
|
301
489
|
|
|
490
|
+
# Check if movement from (x1,y1) to (x2,y2) crosses a blocking linedef
|
|
491
|
+
def crosses_blocking_linedef?(x1, y1, x2, y2, linedef)
|
|
492
|
+
v1 = @map.vertices[linedef.v1]
|
|
493
|
+
v2 = @map.vertices[linedef.v2]
|
|
494
|
+
|
|
495
|
+
# One-sided linedef always blocks crossing
|
|
496
|
+
if linedef.sidedef_left == 0xFFFF
|
|
497
|
+
return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# BLOCKING flag blocks crossing even on two-sided linedefs
|
|
501
|
+
if (linedef.flags & 0x0001) != 0
|
|
502
|
+
return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Two-sided: check if impassable (high step OR low ceiling)
|
|
506
|
+
front_side = @map.sidedefs[linedef.sidedef_right]
|
|
507
|
+
back_side = @map.sidedefs[linedef.sidedef_left]
|
|
508
|
+
front_sector = @map.sectors[front_side.sector]
|
|
509
|
+
back_sector = @map.sectors[back_side.sector]
|
|
510
|
+
|
|
511
|
+
step = (back_sector.floor_height - front_sector.floor_height).abs
|
|
512
|
+
min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
|
|
513
|
+
max_floor = [front_sector.floor_height, back_sector.floor_height].max
|
|
514
|
+
|
|
515
|
+
# Passable if step is small AND enough headroom
|
|
516
|
+
return false if step <= 24 && (min_ceiling - max_floor) >= 56
|
|
517
|
+
|
|
518
|
+
segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Test if line segment (ax1,ay1)-(ax2,ay2) intersects (bx1,by1)-(bx2,by2)
|
|
522
|
+
def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
|
|
523
|
+
d1x = ax2 - ax1
|
|
524
|
+
d1y = ay2 - ay1
|
|
525
|
+
d2x = bx2 - bx1
|
|
526
|
+
d2y = by2 - by1
|
|
527
|
+
|
|
528
|
+
denom = d1x * d2y - d1y * d2x
|
|
529
|
+
return false if denom.abs < 0.001 # Parallel
|
|
530
|
+
|
|
531
|
+
dx = bx1 - ax1
|
|
532
|
+
dy = by1 - ay1
|
|
533
|
+
|
|
534
|
+
t = (dx * d2y - dy * d2x).to_f / denom
|
|
535
|
+
u = (dx * d1y - dy * d1x).to_f / denom
|
|
536
|
+
|
|
537
|
+
t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
|
|
538
|
+
end
|
|
539
|
+
|
|
302
540
|
def linedef_blocks?(linedef, x, y)
|
|
303
541
|
v1 = @map.vertices[linedef.v1]
|
|
304
542
|
v2 = @map.vertices[linedef.v2]
|
|
@@ -309,23 +547,22 @@ module Doom
|
|
|
309
547
|
# One-sided linedef (wall) always blocks
|
|
310
548
|
return true if linedef.sidedef_left == 0xFFFF
|
|
311
549
|
|
|
312
|
-
#
|
|
550
|
+
# BLOCKING flag (0x0001) blocks even on two-sided linedefs (e.g., windows)
|
|
551
|
+
return true if (linedef.flags & 0x0001) != 0
|
|
552
|
+
|
|
553
|
+
# Two-sided: check if impassable (high step OR low ceiling)
|
|
313
554
|
front_side = @map.sidedefs[linedef.sidedef_right]
|
|
314
555
|
back_side = @map.sidedefs[linedef.sidedef_left]
|
|
315
556
|
|
|
316
557
|
front_sector = @map.sectors[front_side.sector]
|
|
317
558
|
back_sector = @map.sectors[back_side.sector]
|
|
318
559
|
|
|
319
|
-
|
|
320
|
-
step = back_sector.floor_height - front_sector.floor_height
|
|
321
|
-
return true if step.abs > 24
|
|
322
|
-
|
|
323
|
-
# Check ceiling clearance
|
|
560
|
+
step = (back_sector.floor_height - front_sector.floor_height).abs
|
|
324
561
|
min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
|
|
325
562
|
max_floor = [front_sector.floor_height, back_sector.floor_height].max
|
|
326
|
-
return true if min_ceiling - max_floor < 56 # Player height
|
|
327
563
|
|
|
328
|
-
|
|
564
|
+
# Block if step too high OR not enough headroom
|
|
565
|
+
step > 24 || (min_ceiling - max_floor) < 56
|
|
329
566
|
end
|
|
330
567
|
|
|
331
568
|
def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
|
|
@@ -376,16 +613,46 @@ module Doom
|
|
|
376
613
|
end
|
|
377
614
|
|
|
378
615
|
def draw
|
|
379
|
-
|
|
380
|
-
|
|
616
|
+
if @show_map
|
|
617
|
+
draw_automap
|
|
618
|
+
else
|
|
619
|
+
# Fast RGBA conversion using pre-built palette
|
|
620
|
+
rgba = @renderer.framebuffer.map { |idx| @palette_rgba[idx] }.join
|
|
381
621
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
622
|
+
@screen_image = Gosu::Image.from_blob(
|
|
623
|
+
Render::SCREEN_WIDTH,
|
|
624
|
+
Render::SCREEN_HEIGHT,
|
|
625
|
+
rgba
|
|
626
|
+
)
|
|
387
627
|
|
|
388
|
-
|
|
628
|
+
@screen_image.draw(0, 0, 0, SCALE, SCALE)
|
|
629
|
+
|
|
630
|
+
draw_debug_overlay if @show_debug
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def draw_debug_overlay
|
|
635
|
+
sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
|
|
636
|
+
return unless sector
|
|
637
|
+
|
|
638
|
+
# Find sector index
|
|
639
|
+
sector_idx = @map.sectors.index(sector)
|
|
640
|
+
|
|
641
|
+
lines = [
|
|
642
|
+
"Sector #{sector_idx}",
|
|
643
|
+
"Floor: #{sector.floor_height} (#{sector.floor_texture})",
|
|
644
|
+
"Ceil: #{sector.ceiling_height} (#{sector.ceiling_texture})",
|
|
645
|
+
"Light: #{sector.light_level}",
|
|
646
|
+
"Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
|
|
647
|
+
"Heading: #{(Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)}",
|
|
648
|
+
]
|
|
649
|
+
|
|
650
|
+
y = 4
|
|
651
|
+
lines.each do |line|
|
|
652
|
+
@debug_font.draw_text(line, 6, y + 1, 1, 1, 1, Gosu::Color::BLACK)
|
|
653
|
+
@debug_font.draw_text(line, 5, y, 1, 1, 1, Gosu::Color::WHITE)
|
|
654
|
+
y += 18
|
|
655
|
+
end
|
|
389
656
|
end
|
|
390
657
|
|
|
391
658
|
def button_down(id)
|
|
@@ -403,9 +670,212 @@ module Doom
|
|
|
403
670
|
@mouse_captured = true
|
|
404
671
|
@last_mouse_x = mouse_x
|
|
405
672
|
end
|
|
673
|
+
when Gosu::KB_Z
|
|
674
|
+
@show_debug = !@show_debug
|
|
675
|
+
when Gosu::KB_M
|
|
676
|
+
@show_map = !@show_map
|
|
677
|
+
when Gosu::KB_F12
|
|
678
|
+
capture_debug_snapshot
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def needs_cursor?
|
|
683
|
+
!@mouse_captured
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# --- Debug Snapshot ---
|
|
687
|
+
|
|
688
|
+
def capture_debug_snapshot
|
|
689
|
+
dir = File.join(File.expand_path('../..', __dir__), '..', 'screenshots')
|
|
690
|
+
FileUtils.mkdir_p(dir)
|
|
691
|
+
|
|
692
|
+
ts = Time.now.strftime('%Y%m%d_%H%M%S_%L')
|
|
693
|
+
prefix = File.join(dir, ts)
|
|
694
|
+
|
|
695
|
+
# Save framebuffer as PNG
|
|
696
|
+
require 'chunky_png' unless defined?(ChunkyPNG)
|
|
697
|
+
w = Render::SCREEN_WIDTH
|
|
698
|
+
h = Render::SCREEN_HEIGHT
|
|
699
|
+
img = ChunkyPNG::Image.new(w, h)
|
|
700
|
+
fb = @renderer.framebuffer
|
|
701
|
+
colors = @palette.colors
|
|
702
|
+
h.times do |y|
|
|
703
|
+
row = y * w
|
|
704
|
+
w.times do |x|
|
|
705
|
+
r, g, b = colors[fb[row + x]]
|
|
706
|
+
img[x, y] = ChunkyPNG::Color.rgb(r, g, b)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
img.save("#{prefix}.png")
|
|
710
|
+
|
|
711
|
+
# Save player state and sector info
|
|
712
|
+
sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
|
|
713
|
+
sector_idx = sector ? @map.sectors.index(sector) : nil
|
|
714
|
+
angle_deg = Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI
|
|
715
|
+
|
|
716
|
+
# Sprite diagnostics
|
|
717
|
+
sprites_info = @renderer.sprite_diagnostics
|
|
718
|
+
nearby = sprites_info.select { |s| s[:dist] && s[:dist] < 1500 }
|
|
719
|
+
.sort_by { |s| s[:dist] }
|
|
720
|
+
|
|
721
|
+
sprite_lines = nearby.map do |s|
|
|
722
|
+
" #{s[:prefix]} type=#{s[:type]} pos=(#{s[:x]},#{s[:y]}) dist=#{s[:dist]} " \
|
|
723
|
+
"screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
|
|
724
|
+
"range=#{s[:screen_range]} status=#{s[:status]} " \
|
|
725
|
+
"clip_segs=#{s[:clipping_segs]}" \
|
|
726
|
+
"#{s[:clipping_detail]&.any? ? "\n clips: #{s[:clipping_detail].map { |c| "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}" }.join(', ')}" : ''}"
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
File.write("#{prefix}.txt", <<~INFO)
|
|
730
|
+
pos: #{@renderer.player_x.round(1)}, #{@renderer.player_y.round(1)}, #{@renderer.player_z.round(1)}
|
|
731
|
+
angle: #{angle_deg.round(1)}
|
|
732
|
+
sector: #{sector_idx}
|
|
733
|
+
floor: #{sector&.floor_height} (#{sector&.floor_texture})
|
|
734
|
+
ceil: #{sector&.ceiling_height} (#{sector&.ceiling_texture})
|
|
735
|
+
light: #{sector&.light_level}
|
|
736
|
+
|
|
737
|
+
nearby sprites (#{nearby.size}):
|
|
738
|
+
#{sprite_lines.join("\n")}
|
|
739
|
+
INFO
|
|
740
|
+
|
|
741
|
+
puts "Snapshot saved: #{prefix}.png + .txt"
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# --- Automap ---
|
|
745
|
+
|
|
746
|
+
MAP_MARGIN = 20
|
|
747
|
+
|
|
748
|
+
def build_sector_colors
|
|
749
|
+
# Generate distinct colors for each sector using golden ratio hue spacing
|
|
750
|
+
num_sectors = @map.sectors.size
|
|
751
|
+
colors = Array.new(num_sectors)
|
|
752
|
+
phi = (1 + Math.sqrt(5)) / 2.0
|
|
753
|
+
|
|
754
|
+
num_sectors.times do |i|
|
|
755
|
+
hue = (i * phi * 360) % 360
|
|
756
|
+
colors[i] = hsv_to_gosu(hue, 0.6, 0.85)
|
|
757
|
+
end
|
|
758
|
+
colors
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def hsv_to_gosu(h, s, v)
|
|
762
|
+
c = v * s
|
|
763
|
+
x = c * (1 - ((h / 60.0) % 2 - 1).abs)
|
|
764
|
+
m = v - c
|
|
765
|
+
|
|
766
|
+
r, g, b = case (h / 60).to_i % 6
|
|
767
|
+
when 0 then [c, x, 0]
|
|
768
|
+
when 1 then [x, c, 0]
|
|
769
|
+
when 2 then [0, c, x]
|
|
770
|
+
when 3 then [0, x, c]
|
|
771
|
+
when 4 then [x, 0, c]
|
|
772
|
+
when 5 then [c, 0, x]
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
Gosu::Color.new(255, ((r + m) * 255).to_i, ((g + m) * 255).to_i, ((b + m) * 255).to_i)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def draw_automap
|
|
779
|
+
# Black background
|
|
780
|
+
Gosu.draw_rect(0, 0, width, height, Gosu::Color::BLACK, 0)
|
|
781
|
+
|
|
782
|
+
# Compute map bounds
|
|
783
|
+
verts = @map.vertices
|
|
784
|
+
min_x = min_y = Float::INFINITY
|
|
785
|
+
max_x = max_y = -Float::INFINITY
|
|
786
|
+
verts.each do |v|
|
|
787
|
+
min_x = v.x if v.x < min_x
|
|
788
|
+
max_x = v.x if v.x > max_x
|
|
789
|
+
min_y = v.y if v.y < min_y
|
|
790
|
+
max_y = v.y if v.y > max_y
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
map_w = max_x - min_x
|
|
794
|
+
map_h = max_y - min_y
|
|
795
|
+
return if map_w == 0 || map_h == 0
|
|
796
|
+
|
|
797
|
+
# Scale to fit screen with margin
|
|
798
|
+
draw_w = width - MAP_MARGIN * 2
|
|
799
|
+
draw_h = height - MAP_MARGIN * 2
|
|
800
|
+
scale = [draw_w.to_f / map_w, draw_h.to_f / map_h].min
|
|
801
|
+
|
|
802
|
+
# Center the map
|
|
803
|
+
offset_x = MAP_MARGIN + (draw_w - map_w * scale) / 2.0
|
|
804
|
+
offset_y = MAP_MARGIN + (draw_h - map_h * scale) / 2.0
|
|
805
|
+
|
|
806
|
+
# World to screen coordinate transform (Y flipped: world Y+ is up, screen Y+ is down)
|
|
807
|
+
to_sx = ->(wx) { offset_x + (wx - min_x) * scale }
|
|
808
|
+
to_sy = ->(wy) { offset_y + (max_y - wy) * scale }
|
|
809
|
+
|
|
810
|
+
# Draw linedefs colored by front sector
|
|
811
|
+
two_sided_color = Gosu::Color.new(100, 80, 80, 80)
|
|
812
|
+
|
|
813
|
+
@map.linedefs.each do |linedef|
|
|
814
|
+
v1 = verts[linedef.v1]
|
|
815
|
+
v2 = verts[linedef.v2]
|
|
816
|
+
sx1 = to_sx.call(v1.x)
|
|
817
|
+
sy1 = to_sy.call(v1.y)
|
|
818
|
+
sx2 = to_sx.call(v2.x)
|
|
819
|
+
sy2 = to_sy.call(v2.y)
|
|
820
|
+
|
|
821
|
+
if linedef.two_sided?
|
|
822
|
+
# Two-sided: dim line, colored by front sector
|
|
823
|
+
front_sd = @map.sidedefs[linedef.sidedef_right]
|
|
824
|
+
color = @sector_colors[front_sd.sector]
|
|
825
|
+
dim = Gosu::Color.new(100, color.red, color.green, color.blue)
|
|
826
|
+
Gosu.draw_line(sx1, sy1, dim, sx2, sy2, dim, 1)
|
|
827
|
+
else
|
|
828
|
+
# One-sided: solid wall, bright sector color
|
|
829
|
+
front_sd = @map.sidedefs[linedef.sidedef_right]
|
|
830
|
+
color = @sector_colors[front_sd.sector]
|
|
831
|
+
Gosu.draw_line(sx1, sy1, color, sx2, sy2, color, 1)
|
|
832
|
+
end
|
|
406
833
|
end
|
|
834
|
+
|
|
835
|
+
# Draw player
|
|
836
|
+
px = to_sx.call(@renderer.player_x)
|
|
837
|
+
py = to_sy.call(@renderer.player_y)
|
|
838
|
+
|
|
839
|
+
cos_a = @renderer.cos_angle
|
|
840
|
+
sin_a = @renderer.sin_angle
|
|
841
|
+
|
|
842
|
+
# FOV cone
|
|
843
|
+
fov_len = 40.0
|
|
844
|
+
half_fov = Math::PI / 4.0 # 45 deg half = 90 deg total
|
|
845
|
+
|
|
846
|
+
# Cone edges (in world space, Y+ is up; on screen Y is flipped via to_sy)
|
|
847
|
+
left_dx = Math.cos(half_fov) * cos_a - Math.sin(half_fov) * sin_a
|
|
848
|
+
left_dy = Math.cos(half_fov) * sin_a + Math.sin(half_fov) * cos_a
|
|
849
|
+
right_dx = Math.cos(-half_fov) * cos_a - Math.sin(-half_fov) * sin_a
|
|
850
|
+
right_dy = Math.cos(-half_fov) * sin_a + Math.sin(-half_fov) * cos_a
|
|
851
|
+
|
|
852
|
+
# Screen positions for cone tips
|
|
853
|
+
lx = px + left_dx * fov_len
|
|
854
|
+
ly = py - left_dy * fov_len # negate because screen Y is flipped
|
|
855
|
+
rx = px + right_dx * fov_len
|
|
856
|
+
ry = py - right_dy * fov_len
|
|
857
|
+
|
|
858
|
+
cone_color = Gosu::Color.new(60, 0, 255, 0)
|
|
859
|
+
Gosu.draw_triangle(px, py, cone_color, lx, ly, cone_color, rx, ry, cone_color, 2)
|
|
860
|
+
|
|
861
|
+
# Cone edge lines
|
|
862
|
+
edge_color = Gosu::Color.new(180, 0, 255, 0)
|
|
863
|
+
Gosu.draw_line(px, py, edge_color, lx, ly, edge_color, 3)
|
|
864
|
+
Gosu.draw_line(px, py, edge_color, rx, ry, edge_color, 3)
|
|
865
|
+
|
|
866
|
+
# Player dot
|
|
867
|
+
dot_size = 4
|
|
868
|
+
Gosu.draw_rect(px - dot_size, py - dot_size, dot_size * 2, dot_size * 2, Gosu::Color::GREEN, 3)
|
|
869
|
+
|
|
870
|
+
# Direction line
|
|
871
|
+
dir_len = 12.0
|
|
872
|
+
dx = px + cos_a * dir_len
|
|
873
|
+
dy = py - sin_a * dir_len
|
|
874
|
+
Gosu.draw_line(px, py, Gosu::Color::WHITE, dx, dy, Gosu::Color::WHITE, 3)
|
|
407
875
|
end
|
|
408
876
|
|
|
877
|
+
# --- End Automap ---
|
|
878
|
+
|
|
409
879
|
def needs_cursor?
|
|
410
880
|
!@mouse_captured
|
|
411
881
|
end
|