doom 0.3.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.
@@ -7,33 +7,140 @@ module Doom
7
7
  class GosuWindow < Gosu::Window
8
8
  SCALE = 3
9
9
 
10
- # Movement constants
11
- MOVE_SPEED = 8.0 # Units per frame
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
- def initialize(renderer, palette, map)
22
+ USE_DISTANCE = 64.0 # Max distance to use a linedef
23
+
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)
17
52
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, false)
18
53
  self.caption = 'Doom Ruby'
19
54
 
20
55
  @renderer = renderer
21
56
  @palette = palette
22
57
  @map = map
58
+ @player_state = player_state
59
+ @status_bar = status_bar
60
+ @weapon_renderer = weapon_renderer
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
23
71
  @screen_image = nil
24
72
  @mouse_captured = false
25
73
  @last_mouse_x = nil
74
+ @last_update_time = Time.now
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
26
82
 
27
83
  # Pre-build palette lookup for speed
28
84
  @palette_rgba = palette.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
29
85
  end
30
86
 
31
87
  def update
32
- handle_input
88
+ # Calculate delta time for smooth animations
89
+ now = Time.now
90
+ delta_time = now - @last_update_time
91
+ @last_update_time = now
92
+
93
+ handle_input(delta_time)
94
+
95
+ # Update player state (per-frame for smooth bob)
96
+ if @player_state
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
110
+ end
111
+ @animations&.update(@leveltime)
112
+
113
+ # Update HUD animations
114
+ @status_bar&.update
115
+
116
+ # Update sector actions (doors, lifts, etc.)
117
+ if @sector_actions
118
+ @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
119
+ @sector_actions.update
120
+ end
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
+
131
+ # Render the 3D world
33
132
  @renderer.render_frame
133
+
134
+ # Render HUD on top
135
+ if @weapon_renderer
136
+ @weapon_renderer.render(@renderer.framebuffer)
137
+ end
138
+ if @status_bar
139
+ @status_bar.render(@renderer.framebuffer)
140
+ end
34
141
  end
35
142
 
36
- def handle_input
143
+ def handle_input(delta_time)
37
144
  # Mouse look
38
145
  handle_mouse_look
39
146
 
@@ -45,83 +152,391 @@ module Doom
45
152
  @renderer.turn(-TURN_SPEED)
46
153
  end
47
154
 
48
- # Forward/backward movement
49
- move_x = 0.0
50
- move_y = 0.0
155
+ # Apply thrust from input (P_Thrust: additive, scaled by delta_time)
156
+ thrust = MOVE_THRUST_RATE * delta_time
157
+ has_input = false
51
158
 
52
159
  if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
53
- move_x += @renderer.cos_angle * MOVE_SPEED
54
- move_y += @renderer.sin_angle * MOVE_SPEED
160
+ @move_momx += @renderer.cos_angle * thrust
161
+ @move_momy += @renderer.sin_angle * thrust
162
+ has_input = true
55
163
  end
56
164
  if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
57
- move_x -= @renderer.cos_angle * MOVE_SPEED
58
- move_y -= @renderer.sin_angle * MOVE_SPEED
165
+ @move_momx -= @renderer.cos_angle * thrust
166
+ @move_momy -= @renderer.sin_angle * thrust
167
+ has_input = true
59
168
  end
60
-
61
- # Strafe
62
169
  if Gosu.button_down?(Gosu::KB_A)
63
- move_x += @renderer.sin_angle * MOVE_SPEED
64
- move_y -= @renderer.cos_angle * MOVE_SPEED
170
+ @move_momx -= @renderer.sin_angle * thrust
171
+ @move_momy += @renderer.cos_angle * thrust
172
+ has_input = true
65
173
  end
66
174
  if Gosu.button_down?(Gosu::KB_D)
67
- move_x -= @renderer.sin_angle * MOVE_SPEED
68
- move_y += @renderer.cos_angle * MOVE_SPEED
175
+ @move_momx += @renderer.sin_angle * thrust
176
+ @move_momy -= @renderer.cos_angle * thrust
177
+ has_input = true
178
+ end
179
+
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
195
+
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)
69
199
  end
70
200
 
71
- # Apply movement with collision detection
72
- if move_x != 0.0 || move_y != 0.0
73
- try_move(move_x, move_y)
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
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
212
+ end
213
+
214
+ # Handle weapon switching with number keys
215
+ handle_weapon_switch if @player_state
216
+
217
+ # Handle use key (spacebar or E)
218
+ handle_use_key if @sector_actions
219
+ end
220
+
221
+ def handle_use_key
222
+ use_down = Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)
223
+
224
+ if use_down && !@use_pressed
225
+ @use_pressed = true
226
+ try_use_linedef
227
+ elsif !use_down
228
+ @use_pressed = false
229
+ end
230
+ end
231
+
232
+ def try_use_linedef
233
+ # Cast a ray forward to find a usable linedef
234
+ player_x = @renderer.player_x
235
+ player_y = @renderer.player_y
236
+ cos_angle = @renderer.cos_angle
237
+ sin_angle = @renderer.sin_angle
238
+
239
+ # Check point in front of player
240
+ use_x = player_x + cos_angle * USE_DISTANCE
241
+ use_y = player_y + sin_angle * USE_DISTANCE
242
+
243
+ # Find the closest linedef the player is facing
244
+ best_linedef = nil
245
+ best_idx = nil
246
+ best_dist = Float::INFINITY
247
+
248
+ @map.linedefs.each_with_index do |linedef, idx|
249
+ next if linedef.special == 0 # Skip non-special linedefs
250
+
251
+ v1 = @map.vertices[linedef.v1]
252
+ v2 = @map.vertices[linedef.v2]
253
+
254
+ # Check if player is close enough to the linedef
255
+ dist = point_to_line_distance(player_x, player_y, v1.x, v1.y, v2.x, v2.y)
256
+ next if dist > USE_DISTANCE
257
+ next if dist >= best_dist
258
+
259
+ # Check if player is facing the linedef (on the front side)
260
+ next unless facing_linedef?(player_x, player_y, cos_angle, sin_angle, v1, v2)
261
+
262
+ best_linedef = linedef
263
+ best_idx = idx
264
+ best_dist = dist
265
+ end
266
+
267
+ if best_linedef
268
+ @sector_actions.use_linedef(best_linedef, best_idx)
269
+ end
270
+ end
271
+
272
+ def point_to_line_distance(px, py, x1, y1, x2, y2)
273
+ # Vector from line start to point
274
+ dx = px - x1
275
+ dy = py - y1
276
+
277
+ # Line direction vector
278
+ line_dx = x2 - x1
279
+ line_dy = y2 - y1
280
+ line_len_sq = line_dx * line_dx + line_dy * line_dy
281
+
282
+ return Math.sqrt(dx * dx + dy * dy) if line_len_sq == 0
283
+
284
+ # Project point onto line, clamped to segment
285
+ t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
286
+ t = [[t, 0.0].max, 1.0].min
287
+
288
+ # Closest point on line segment
289
+ closest_x = x1 + t * line_dx
290
+ closest_y = y1 + t * line_dy
291
+
292
+ # Distance from point to closest point on segment
293
+ dist_x = px - closest_x
294
+ dist_y = py - closest_y
295
+ Math.sqrt(dist_x * dist_x + dist_y * dist_y)
296
+ end
297
+
298
+ def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
299
+ # Calculate linedef normal (perpendicular to line, pointing to front side)
300
+ line_dx = v2.x - v1.x
301
+ line_dy = v2.y - v1.y
302
+
303
+ # Normal points to the right of the line direction
304
+ normal_x = -line_dy
305
+ normal_y = line_dx
306
+
307
+ # Normalize
308
+ len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
309
+ return false if len == 0
310
+
311
+ normal_x /= len
312
+ normal_y /= len
313
+
314
+ # Check if player is on the front side (normal side) of the line
315
+ to_player_x = px - v1.x
316
+ to_player_y = py - v1.y
317
+ dot_player = to_player_x * normal_x + to_player_y * normal_y
318
+
319
+ # Player must be on front side
320
+ return false if dot_player < 0
321
+
322
+ # Check if player is facing toward the line
323
+ dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
324
+ dot_facing > 0.5 # Must be roughly facing the line
325
+ end
326
+
327
+ def handle_weapon_switch
328
+ if Gosu.button_down?(Gosu::KB_1)
329
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_FIST)
330
+ elsif Gosu.button_down?(Gosu::KB_2)
331
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_PISTOL)
332
+ elsif Gosu.button_down?(Gosu::KB_3)
333
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_SHOTGUN)
334
+ elsif Gosu.button_down?(Gosu::KB_4)
335
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_CHAINGUN)
336
+ elsif Gosu.button_down?(Gosu::KB_5)
337
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_ROCKET)
338
+ elsif Gosu.button_down?(Gosu::KB_6)
339
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_PLASMA)
340
+ elsif Gosu.button_down?(Gosu::KB_7)
341
+ @player_state.switch_weapon(Game::PlayerState::WEAPON_BFG)
74
342
  end
75
343
  end
76
344
 
77
345
  def try_move(dx, dy)
78
- new_x = @renderer.player_x + dx
79
- new_y = @renderer.player_y + dy
346
+ old_x = @renderer.player_x
347
+ old_y = @renderer.player_y
348
+ new_x = old_x + dx
349
+ new_y = old_y + dy
80
350
 
81
- # Check if new position is valid (simple collision detection)
82
- if valid_position?(new_x, new_y)
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)
83
353
  @renderer.move_to(new_x, new_y)
84
354
  update_player_height(new_x, new_y)
85
355
  else
86
- # Try sliding along walls - try X movement only
87
- if valid_position?(new_x, @renderer.player_y)
88
- @renderer.move_to(new_x, @renderer.player_y)
89
- update_player_height(new_x, @renderer.player_y)
90
- # Try Y movement only
91
- elsif valid_position?(@renderer.player_x, new_y)
92
- @renderer.move_to(@renderer.player_x, new_y)
93
- update_player_height(@renderer.player_x, new_y)
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
94
384
  end
95
385
  end
96
386
  end
97
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
+
98
430
  def update_player_height(x, y)
99
431
  sector = @map.sector_at(x, y)
100
432
  return unless sector
101
433
 
102
- # Player view height is 41 units above floor
103
- target_z = sector.floor_height + 41
104
- @renderer.set_z(target_z)
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
105
449
  end
106
450
 
107
- def valid_position?(x, y)
108
- # Check if position is inside a valid sector
109
- sector = @map.sector_at(x, y)
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)
110
454
  return false unless sector
111
455
 
112
- # Check floor height - can't walk into walls
456
+ # Check floor height - can't step up too high
113
457
  floor_height = sector.floor_height
114
458
  return false if floor_height > @renderer.player_z + 24 # Max step height
115
459
 
116
- # Check against blocking linedefs
460
+ # Check against blocking linedefs: both circle intersection and path crossing
117
461
  @map.linedefs.each do |linedef|
118
- next unless linedef_blocks?(linedef, x, y)
119
- return false
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
120
485
  end
121
486
 
122
487
  true
123
488
  end
124
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
+
125
540
  def linedef_blocks?(linedef, x, y)
126
541
  v1 = @map.vertices[linedef.v1]
127
542
  v2 = @map.vertices[linedef.v2]
@@ -132,23 +547,22 @@ module Doom
132
547
  # One-sided linedef (wall) always blocks
133
548
  return true if linedef.sidedef_left == 0xFFFF
134
549
 
135
- # Two-sided: check if passable
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)
136
554
  front_side = @map.sidedefs[linedef.sidedef_right]
137
555
  back_side = @map.sidedefs[linedef.sidedef_left]
138
556
 
139
557
  front_sector = @map.sectors[front_side.sector]
140
558
  back_sector = @map.sectors[back_side.sector]
141
559
 
142
- # Check step height
143
- step = back_sector.floor_height - front_sector.floor_height
144
- return true if step.abs > 24
145
-
146
- # Check ceiling clearance
560
+ step = (back_sector.floor_height - front_sector.floor_height).abs
147
561
  min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
148
562
  max_floor = [front_sector.floor_height, back_sector.floor_height].max
149
- return true if min_ceiling - max_floor < 56 # Player height
150
563
 
151
- false
564
+ # Block if step too high OR not enough headroom
565
+ step > 24 || (min_ceiling - max_floor) < 56
152
566
  end
153
567
 
154
568
  def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
@@ -199,16 +613,46 @@ module Doom
199
613
  end
200
614
 
201
615
  def draw
202
- # Fast RGBA conversion using pre-built palette
203
- rgba = @renderer.framebuffer.map { |idx| @palette_rgba[idx] }.join
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
621
+
622
+ @screen_image = Gosu::Image.from_blob(
623
+ Render::SCREEN_WIDTH,
624
+ Render::SCREEN_HEIGHT,
625
+ rgba
626
+ )
204
627
 
205
- @screen_image = Gosu::Image.from_blob(
206
- Render::SCREEN_WIDTH,
207
- Render::SCREEN_HEIGHT,
208
- rgba
209
- )
628
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
210
629
 
211
- @screen_image.draw(0, 0, 0, SCALE, SCALE)
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
212
656
  end
213
657
 
214
658
  def button_down(id)
@@ -226,12 +670,215 @@ module Doom
226
670
  @mouse_captured = true
227
671
  @last_mouse_x = mouse_x
228
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
229
679
  end
230
680
  end
231
681
 
232
682
  def needs_cursor?
233
683
  !@mouse_captured
234
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
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)
875
+ end
876
+
877
+ # --- End Automap ---
878
+
879
+ def needs_cursor?
880
+ !@mouse_captured
881
+ end
235
882
  end
236
883
  end
237
884
  end