Class: Doom::Platform::GosuWindow

Inherits:
Gosu::Window
  • Object
show all
Defined in:
lib/doom/platform/gosu_window.rb

Defined Under Namespace

Modules: SDLKeyboardGrab

Constant Summary collapse

SCALE =
3
MOVE_THRUST_RATE =

Movement constants (matching Chocolate Doom P_Thrust / P_XYMovement) DOOM: terminal walk speed = 7.55 units/tic = 264 units/sec Continuous-time: v_terminal = thrust_rate / decay_rate decay_rate = -ln(0.90625) * 35 = 3.44/sec thrust_rate = 264 * 3.44 = 908 units/sec^2

264.0 * 3.44
FRICTION_DECAY_RATE =

Thrust rate (units/sec^2)

3.44
STOPSPEED =

Friction decay (1/sec)

0.5
TURN_SPEED =

Snap-to-zero threshold (units/sec)

3.0
MOUSE_SENSITIVITY =

Degrees per frame

0.15
PLAYER_RADIUS =

Mouse look sensitivity

16.0
USE_DISTANCE =

Collision radius

64.0
SOLID_THING_RADIUS =

Solid thing types with their collision radii (from mobjinfo[] MF_SOLID) Monsters, barrels, pillars, lamps, torches, trees block player movement

{
  9 => 20, 65 => 20, 66 => 20, 67 => 20, 68 => 20, # Shotgun Guy variants
  3004 => 20, 84 => 20,                               # Zombieman
  3001 => 20,                                          # Imp
  3002 => 30, 58 => 30,                                # Demon, Spectre
  3003 => 24, 69 => 24,                                # Baron, Hell Knight
  3006 => 16,                                          # Lost Soul
  3005 => 31,                                          # Cacodemon
  16 => 40,                                            # Cyberdemon
  7 => 128,                                            # Spider Mastermind
  64 => 20,                                            # Archvile
  71 => 31,                                            # Pain Elemental
  2035 => 10,                                          # Barrel
  2028 => 16,                                          # Tall lamp
  48 => 16, 30 => 16, 32 => 16,                       # Tech column, green/red pillars
  31 => 16, 33 => 16, 36 => 16,                       # Short pillars
  41 => 16, 43 => 16,                                  # Evil eye, burnt tree
  54 => 32,                                            # Brown tree
  44 => 16, 45 => 16, 46 => 16,                       # Tall torches
  55 => 16, 56 => 16, 57 => 16,                       # Short torches
  47 => 16, 70 => 16,                                  # Stubs
  85 => 16, 86 => 16,                                  # Tall tech lamps
  2046 => 16,                                          # Burning barrel
}.freeze
SECTOR_DAMAGE =

Sector damage types from DOOM (p_spec.c) Type 5: 10 damage, Type 7: 5 damage, Type 4/16: 20 damage

{ 5 => 10, 7 => 5, 4 => 20, 16 => 20, 11 => 20 }.freeze
MAP_MARGIN =

— Automap —

20

Instance Method Summary collapse

Constructor Details

#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, monster_ai = nil, menu = nil, sound_engine = nil) ⇒ GosuWindow

Returns a new instance of GosuWindow.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/doom/platform/gosu_window.rb', line 82

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, monster_ai = nil, menu = nil, sound_engine = nil)
  fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
  self.caption = 'Doom Ruby'
  self.update_interval = 0  # Uncap framerate (default 16.67ms = 60 FPS cap)
  SDLKeyboardGrab.setup

  @renderer = renderer
  @palette = palette
  @map = map
  @player_state = player_state
  @status_bar = status_bar
  @weapon_renderer = weapon_renderer
  @sector_actions = sector_actions
  @animations = animations
  @sector_effects = sector_effects
  @item_pickup = item_pickup
  @combat = combat
  @monster_ai = monster_ai
  @menu = menu
  @doom_font = menu&.font
  @sound = sound_engine
  @damage_multiplier = 1.0
  @skill = Game::Menu::SKILL_MEDIUM
  @skill_hidden = {}  # Thing indices hidden by difficulty
  @last_floor_height = nil
  @move_momx = 0.0
  @move_momy = 0.0
  @leveltime = 0
  @tic_accumulator = 0.0
  @screen_image = nil
  @mouse_captured = false
  @last_mouse_x = nil
  @last_update_time = Time.now
  @use_pressed = false
  @show_debug = false
  @show_map = false
  @screen_melt = nil
  @intermission = nil
  @current_map = 'E1M1'
  @debug_font = Gosu::Font.new(24)
  @fps_frames = 0
  @fps_time = Time.now
  @fps_display = 0.0

  # Precompute sector colors for automap
  @sector_colors = build_sector_colors

  # Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
  @all_palette_rgba = []
  wad = renderer.instance_variable_get(:@wad)
  14.times do |pal_idx|
    pal = Wad::Palette.load(wad, pal_idx)
    @all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
  end
  @palette_rgba = @all_palette_rgba[0]
end

Instance Method Details

#apply_death_tint(framebuffer) ⇒ Object



993
994
995
996
# File 'lib/doom/platform/gosu_window.rb', line 993

def apply_death_tint(framebuffer)
  # Death keeps damage_count at max so the pain palette stays red
  @player_state.damage_count = 8 if @player_state&.dead
end

#apply_difficulty(skill) ⇒ Object



1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
# File 'lib/doom/platform/gosu_window.rb', line 1163

def apply_difficulty(skill)
  @skill = skill
  @damage_multiplier = case skill
                       when Game::Menu::SKILL_BABY then 0.5
                       when Game::Menu::SKILL_EASY then 0.75
                       when Game::Menu::SKILL_MEDIUM then 1.0
                       when Game::Menu::SKILL_HARD then 1.0
                       when Game::Menu::SKILL_NIGHTMARE then 1.5
                       else 1.0
                       end

  # Compute which things are hidden by this skill level
  @skill_hidden = compute_skill_hidden(skill)

  # Baby mode: start with some armor
  if skill == Game::Menu::SKILL_BABY
    @player_state.armor = 50
  end

  if @monster_ai
    @monster_ai.aggression = true
    @monster_ai.damage_multiplier = @damage_multiplier
  end

  # Baby: double ammo from pickups (matching DOOM skill 1)
  if @item_pickup
    @item_pickup.ammo_multiplier = (skill == Game::Menu::SKILL_BABY) ? 2 : 1
  end

  respawn_player
end

#apply_rubykaigi_modeObject



1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
# File 'lib/doom/platform/gosu_window.rb', line 1055

def apply_rubykaigi_mode
  return unless @menu&.options&.[](:rubykaigi_mode)

  # God mode: invincible for stress-free demos
  @player_state.god_mode = true
  @player_state.health = 100

  # All weapons + full ammo
  handle_option_toggle(:all_weapons, true)
  @player_state.infinite_ammo = true

  # Monsters don't attack (peaceful exploration)
  @monster_ai.aggression = false if @monster_ai

  # Force debug overlay on (shows FPS + YJIT status)
  @show_debug = true
end

#build_sector_colorsObject



1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
# File 'lib/doom/platform/gosu_window.rb', line 1288

def build_sector_colors
  # Generate distinct colors for each sector using golden ratio hue spacing
  num_sectors = @map.sectors.size
  colors = Array.new(num_sectors)
  phi = (1 + Math.sqrt(5)) / 2.0

  num_sectors.times do |i|
    hue = (i * phi * 360) % 360
    colors[i] = hsv_to_gosu(hue, 0.6, 0.85)
  end
  colors
end

#button_down(id) ⇒ Object



872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
# File 'lib/doom/platform/gosu_window.rb', line 872

def button_down(id)
  # Intermission handles input
  if @intermission
    @intermission.handle_key
    if @intermission.finished
      next_map = @intermission.next_map
      @intermission = nil
      if next_map
        load_next_map(next_map)
      else
        # Episode complete - return to menu
        @menu&.show
      end
    end
    return
  end

  # Menu handles input when active
  if @menu&.active?
    key = case id
          when Gosu::KB_UP then :up
          when Gosu::KB_DOWN then :down
          when Gosu::KB_RETURN, Gosu::KB_SPACE then :enter
          when Gosu::KB_ESCAPE then :escape
          end
    if key
      # Play menu navigation sounds
      case key
      when :up, :down
        @sound&.menu_move
      when :escape
        @sound&.menu_back
      end

      # Capture old screen before menu state change (for melt effect)
      old_state = @menu.state
      result = @menu.handle_key(key)
      new_state = @menu.state

      # Trigger melt when transitioning from title to main menu
      if old_state == Game::Menu::STATE_TITLE && new_state == Game::Menu::STATE_MAIN && @last_menu_fb
        # Build the new screen (main menu with game background)
        @renderer.render_frame
        @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
        @status_bar&.render(@renderer.framebuffer)
        new_fb = @renderer.framebuffer.dup
        @menu.render(new_fb, nil)
        @screen_melt = Render::ScreenMelt.new(@last_menu_fb, new_fb)
      end

      # Play confirmation sound on select
      @sound&.menu_select if key == :enter

      case result
      when :start_game
        apply_difficulty(@menu.selected_skill)
      when :resume
        @mouse_captured = true
        SDLKeyboardGrab.grab!
      when :quit
        close
      when Hash
        handle_option_toggle(result[:option], result[:value]) if result[:action] == :toggle_option
      end
    end
    return
  end

  case id
  when Gosu::KB_ESCAPE
    SDLKeyboardGrab.release!
    @mouse_captured = false
    self.mouse_x = width / 2
    self.mouse_y = height / 2
    @menu&.show
  when Gosu::MS_LEFT, Gosu::KB_TAB
    unless @mouse_captured
      @mouse_captured = true
      @last_mouse_x = mouse_x
      SDLKeyboardGrab.grab!
    end
  when Gosu::KB_Z
    @show_debug = !@show_debug
  when Gosu::KB_Y
    if defined?(RubyVM::YJIT)
      setup_yjit_toggle
      if RubyVM::YJIT.enabled?
        RubyVM::YJIT.disable
        puts "YJIT disabled!"
      else
        RubyVM::YJIT.enable
        puts "YJIT enabled!"
      end
    end
  when Gosu::KB_C
    if @monster_ai
      @monster_ai.aggression = !@monster_ai.aggression
      puts "Monster aggression: #{@monster_ai.aggression ? 'ON' : 'OFF'}"
    end
  when Gosu::KB_M
    @show_map = !@show_map
  when Gosu::KB_F12
    capture_debug_snapshot
  end
end

#capture_debug_snapshotObject

— Debug Snapshot —



1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
# File 'lib/doom/platform/gosu_window.rb', line 1228

def capture_debug_snapshot
  dir = File.join(File.expand_path('../..', __dir__), '..', 'screenshots')
  FileUtils.mkdir_p(dir)

  ts = Time.now.strftime('%Y%m%d_%H%M%S_%L')
  prefix = File.join(dir, ts)

  # Save framebuffer as PNG
  require 'chunky_png' unless defined?(ChunkyPNG)
  w = Render::SCREEN_WIDTH
  h = Render::SCREEN_HEIGHT
  img = ChunkyPNG::Image.new(w, h)
  fb = @renderer.framebuffer
  colors = @palette.colors
  h.times do |y|
    row = y * w
    w.times do |x|
      r, g, b = colors[fb[row + x]]
      img[x, y] = ChunkyPNG::Color.rgb(r, g, b)
    end
  end
  img.save("#{prefix}.png")

  # Save player state and sector info
  sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
  sector_idx = sector ? @map.sectors.index(sector) : nil
  angle_deg = Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI

  # Sprite diagnostics
  sprites_info = @renderer.sprite_diagnostics
  nearby = sprites_info.select { |s| s[:dist] && s[:dist] < 1500 }
                       .sort_by { |s| s[:dist] }

  sprite_lines = nearby.map do |s|
    "  #{s[:prefix]} type=#{s[:type]} pos=(#{s[:x]},#{s[:y]}) dist=#{s[:dist]} " \
    "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
    "range=#{s[:screen_range]} status=#{s[:status]} " \
    "clip_segs=#{s[:clipping_segs]}" \
    "#{s[:clipping_detail]&.any? ? "\n    clips: #{s[:clipping_detail].map { |c| "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}" }.join(', ')}" : ''}"
  end

  File.write("#{prefix}.txt", <<~INFO)
    pos: #{@renderer.player_x.round(1)}, #{@renderer.player_y.round(1)}, #{@renderer.player_z.round(1)}
    angle: #{angle_deg.round(1)}
    sector: #{sector_idx}
    floor: #{sector&.floor_height} (#{sector&.floor_texture})
    ceil: #{sector&.ceiling_height} (#{sector&.ceiling_texture})
    light: #{sector&.light_level}

    nearby sprites (#{nearby.size}):
    #{sprite_lines.join("\n")}
  INFO

  puts "Snapshot saved: #{prefix}.png + .txt"
end

#check_sector_damageObject



982
983
984
985
986
987
988
989
990
991
# File 'lib/doom/platform/gosu_window.rb', line 982

def check_sector_damage
  sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
  return unless sector

  damage = SECTOR_DAMAGE[sector.special]
  if damage
    @player_state.take_damage((damage * @damage_multiplier).to_i)
    @sound&.player_pain
  end
end

#compute_skill_hidden(skill) ⇒ Object

DOOM thing flags: bit 0 = skill 1-2, bit 1 = skill 3, bit 2 = skill 4-5



1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
# File 'lib/doom/platform/gosu_window.rb', line 1074

def compute_skill_hidden(skill)
  flag_bit = case skill
             when Game::Menu::SKILL_BABY, Game::Menu::SKILL_EASY then 0x0001
             when Game::Menu::SKILL_MEDIUM then 0x0002
             when Game::Menu::SKILL_HARD, Game::Menu::SKILL_NIGHTMARE then 0x0004
             else 0x0007
             end
  hidden = {}
  @map.things.each_with_index do |thing, idx|
    # Multiplayer-only things (bit 4) are hidden in single player
    if (thing.flags & 0x0010) != 0 || (thing.flags & flag_bit) == 0
      hidden[idx] = true
    end
  end
  hidden
end

#compute_slide(px, py, dx, dy) ⇒ Object

Find the blocking linedef and project movement along it



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
# File 'lib/doom/platform/gosu_window.rb', line 531

def compute_slide(px, py, dx, dy)
  best_wall = nil
  best_dist = Float::INFINITY

  @map.linedefs.each do |linedef|
    v1 = @map.vertices[linedef.v1]
    v2 = @map.vertices[linedef.v2]

    # Only check linedefs near the player
    next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, px + dx, py + dy, PLAYER_RADIUS)

    # Check if this linedef actually blocks
    next unless linedef_blocks?(linedef, px + dx, py + dy) ||
                crosses_blocking_linedef?(px, py, px + dx, py + dy, linedef)

    # Distance from player to this linedef
    dist = point_to_line_distance(px, py, v1.x, v1.y, v2.x, v2.y)
    if dist < best_dist
      best_dist = dist
      best_wall = linedef
    end
  end

  return nil unless best_wall

  # Get wall direction vector
  v1 = @map.vertices[best_wall.v1]
  v2 = @map.vertices[best_wall.v2]
  wall_dx = (v2.x - v1.x).to_f
  wall_dy = (v2.y - v1.y).to_f
  wall_len = Math.sqrt(wall_dx * wall_dx + wall_dy * wall_dy)
  return nil if wall_len == 0

  wall_dx /= wall_len
  wall_dy /= wall_len

  # Project movement onto wall direction
  dot = dx * wall_dx + dy * wall_dy
  [dot * wall_dx, dot * wall_dy]
end

#crosses_blocking_linedef?(x1, y1, x2, y2, linedef) ⇒ Boolean

Check if movement from (x1,y1) to (x2,y2) crosses a blocking linedef

Returns:

  • (Boolean)


634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# File 'lib/doom/platform/gosu_window.rb', line 634

def crosses_blocking_linedef?(x1, y1, x2, y2, linedef)
  v1 = @map.vertices[linedef.v1]
  v2 = @map.vertices[linedef.v2]

  # One-sided linedef always blocks crossing
  if linedef.sidedef_left == 0xFFFF
    return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
  end

  # ML_BLOCKING (0x0001) blocks crossing for everything including player
  if (linedef.flags & 0x0001) != 0
    return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
  end

  # Two-sided: check if impassable (high step OR low ceiling)
  front_side = @map.sidedefs[linedef.sidedef_right]
  back_side = @map.sidedefs[linedef.sidedef_left]
  front_sector = @map.sectors[front_side.sector]
  back_sector = @map.sectors[back_side.sector]

  step = (back_sector.floor_height - front_sector.floor_height).abs
  min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
  max_floor = [front_sector.floor_height, back_sector.floor_height].max

  # Passable if step is small AND enough headroom
  return false if step <= 24 && (min_ceiling - max_floor) >= 56

  segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
end

#drawObject



759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
# File 'lib/doom/platform/gosu_window.rb', line 759

def draw
  # Intermission screen
  if @intermission
    fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
    @intermission.render(fb)
    active_pal = @all_palette_rgba[0]
    rgba = fb.map { |idx| active_pal[idx] }.join
    @screen_image = Gosu::Image.from_blob(
      Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
    )
    @screen_image.draw(0, 0, 0, SCALE, SCALE)
    return
  end

  # Screen melt effect in progress
  if @screen_melt && !@screen_melt.done?
    fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
    @screen_melt.update(fb)
    active_pal = @all_palette_rgba[0]
    rgba = fb.map { |idx| active_pal[idx] }.join
    @screen_image = Gosu::Image.from_blob(
      Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
    )
    @screen_image.draw(0, 0, 0, SCALE, SCALE)
    @screen_melt = nil if @screen_melt.done?
    return
  end

  if @menu&.active?
    if @menu.needs_background?
      # Render game view + HUD as background, then overlay menu on top
      @renderer.render_frame
      @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
      @status_bar&.render(@renderer.framebuffer)
      fb = @renderer.framebuffer.dup
    else
      # Title screen: black background
      fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
    end
    @menu.render(fb, nil)

    # Capture current menu frame for melt transitions
    @last_menu_fb = fb.dup

    active_pal = @all_palette_rgba[0]
    rgba = fb.map { |idx| active_pal[idx] }.join
    @screen_image = Gosu::Image.from_blob(
      Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
    )
    @screen_image.draw(0, 0, 0, SCALE, SCALE)
  elsif @show_map
    draw_automap
  else
    # Select palette: red tint when taking damage (palettes 1-8)
    # Pain palette (1-8 red), pickup palette (9 yellow)
    pal_idx = if @item_pickup && @item_pickup.pickup_flash > 0
                9  # Yellow flash for item pickup
              elsif @player_state
                @player_state.damage_count.clamp(0, 8)
              else
                0
              end
    active_pal = @all_palette_rgba[pal_idx]
    rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join

    @screen_image = Gosu::Image.from_blob(
      Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
    )
    @screen_image.draw(0, 0, 0, SCALE, SCALE)

    draw_debug_overlay if @show_debug
  end
end

#draw_automapObject



1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
# File 'lib/doom/platform/gosu_window.rb', line 1318

def draw_automap
  # Black background
  Gosu.draw_rect(0, 0, width, height, Gosu::Color::BLACK, 0)

  # Compute map bounds
  verts = @map.vertices
  min_x = min_y = Float::INFINITY
  max_x = max_y = -Float::INFINITY
  verts.each do |v|
    min_x = v.x if v.x < min_x
    max_x = v.x if v.x > max_x
    min_y = v.y if v.y < min_y
    max_y = v.y if v.y > max_y
  end

  map_w = max_x - min_x
  map_h = max_y - min_y
  return if map_w == 0 || map_h == 0

  # Scale to fit screen with margin
  draw_w = width - MAP_MARGIN * 2
  draw_h = height - MAP_MARGIN * 2
  scale = [draw_w.to_f / map_w, draw_h.to_f / map_h].min

  # Center the map
  offset_x = MAP_MARGIN + (draw_w - map_w * scale) / 2.0
  offset_y = MAP_MARGIN + (draw_h - map_h * scale) / 2.0

  # World to screen coordinate transform (Y flipped: world Y+ is up, screen Y+ is down)
  to_sx = ->(wx) { offset_x + (wx - min_x) * scale }
  to_sy = ->(wy) { offset_y + (max_y - wy) * scale }

  # Draw linedefs colored by front sector
  two_sided_color = Gosu::Color.new(100, 80, 80, 80)

  @map.linedefs.each do |linedef|
    v1 = verts[linedef.v1]
    v2 = verts[linedef.v2]
    sx1 = to_sx.call(v1.x)
    sy1 = to_sy.call(v1.y)
    sx2 = to_sx.call(v2.x)
    sy2 = to_sy.call(v2.y)

    if linedef.two_sided?
      # Two-sided: dim line, colored by front sector
      front_sd = @map.sidedefs[linedef.sidedef_right]
      color = @sector_colors[front_sd.sector]
      dim = Gosu::Color.new(100, color.red, color.green, color.blue)
      Gosu.draw_line(sx1, sy1, dim, sx2, sy2, dim, 1)
    else
      # One-sided: solid wall, bright sector color
      front_sd = @map.sidedefs[linedef.sidedef_right]
      color = @sector_colors[front_sd.sector]
      Gosu.draw_line(sx1, sy1, color, sx2, sy2, color, 1)
    end
  end

  # Draw player
  px = to_sx.call(@renderer.player_x)
  py = to_sy.call(@renderer.player_y)

  cos_a = @renderer.cos_angle
  sin_a = @renderer.sin_angle

  # FOV cone
  fov_len = 40.0
  half_fov = Math::PI / 4.0 # 45 deg half = 90 deg total

  # Cone edges (in world space, Y+ is up; on screen Y is flipped via to_sy)
  left_dx = Math.cos(half_fov) * cos_a - Math.sin(half_fov) * sin_a
  left_dy = Math.cos(half_fov) * sin_a + Math.sin(half_fov) * cos_a
  right_dx = Math.cos(-half_fov) * cos_a - Math.sin(-half_fov) * sin_a
  right_dy = Math.cos(-half_fov) * sin_a + Math.sin(-half_fov) * cos_a

  # Screen positions for cone tips
  lx = px + left_dx * fov_len
  ly = py - left_dy * fov_len  # negate because screen Y is flipped
  rx = px + right_dx * fov_len
  ry = py - right_dy * fov_len

  cone_color = Gosu::Color.new(60, 0, 255, 0)
  Gosu.draw_triangle(px, py, cone_color, lx, ly, cone_color, rx, ry, cone_color, 2)

  # Cone edge lines
  edge_color = Gosu::Color.new(180, 0, 255, 0)
  Gosu.draw_line(px, py, edge_color, lx, ly, edge_color, 3)
  Gosu.draw_line(px, py, edge_color, rx, ry, edge_color, 3)

  # Player dot
  dot_size = 4
  Gosu.draw_rect(px - dot_size, py - dot_size, dot_size * 2, dot_size * 2, Gosu::Color::GREEN, 3)

  # Direction line
  dir_len = 12.0
  dx = px + cos_a * dir_len
  dy = py - sin_a * dir_len
  Gosu.draw_line(px, py, Gosu::Color::WHITE, dx, dy, Gosu::Color::WHITE, 3)
end

#draw_debug_overlayObject



833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
# File 'lib/doom/platform/gosu_window.rb', line 833

def draw_debug_overlay
  @fps_frames += 1
  now = Time.now
  elapsed = now - @fps_time
  if elapsed >= 0.5
    @fps_display = (@fps_frames / elapsed).round(1)
    @fps_frames = 0
    @fps_time = now
  end

  yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'
  ang = (Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)

  lines = if @menu&.options&.[](:rubykaigi_mode)
            [
              "#{@fps_display} FPS",
              "YJIT: #{yjit_status}  (Y to toggle)",
              "Ruby #{RUBY_VERSION}",
              "Map: #{@current_map}",
              "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
              "Ang: #{ang}",
            ]
          else
            [
              "FPS: #{@fps_display}",
              "YJIT: #{yjit_status}",
              "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
              "Ang: #{ang}",
            ]
          end

  y = 4
  lines.each do |line|
    @debug_font.draw_text(line, 8, y + 2, 1, 1, 1, Gosu::Color::BLACK)
    @debug_font.draw_text(line, 6, y, 1, 1, 1, Gosu::Color::WHITE)
    y += 26
  end
end

#facing_linedef?(px, py, cos_angle, sin_angle, v1, v2) ⇒ Boolean

Returns:

  • (Boolean)


438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/doom/platform/gosu_window.rb', line 438

def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
  # Calculate linedef normal (perpendicular to line)
  line_dx = v2.x - v1.x
  line_dy = v2.y - v1.y

  # Normal points to the right of the line direction
  normal_x = -line_dy
  normal_y = line_dx

  len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
  return false if len == 0

  normal_x /= len
  normal_y /= len

  # Determine which side the player is on
  to_player_x = px - v1.x
  to_player_y = py - v1.y
  side = to_player_x * normal_x + to_player_y * normal_y

  # Flip normal if player is on the back side (so we check facing toward the line)
  if side < 0
    normal_x = -normal_x
    normal_y = -normal_y
  end

  # Check if player is facing toward the line (relaxed angle check)
  dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
  dot_facing > 0.2  # ~78 degree cone, matching DOOM's generous use check
end

#handle_input(delta_time) ⇒ Object



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/doom/platform/gosu_window.rb', line 270

def handle_input(delta_time)
  # Handle respawn when dead
  if @player_state&.dead
    if @player_state.death_tic > 35  # 1 second delay before respawn allowed
      if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
         Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT)
        respawn_player
      end
    end
    return  # No other input while dead
  end

  # Mouse look
  handle_mouse_look

  # Keyboard turning
  if Gosu.button_down?(Gosu::KB_LEFT)
    @renderer.turn(TURN_SPEED)
  end
  if Gosu.button_down?(Gosu::KB_RIGHT)
    @renderer.turn(-TURN_SPEED)
  end

  # Apply thrust from input (P_Thrust: additive, scaled by delta_time)
  thrust = MOVE_THRUST_RATE * delta_time
  has_input = false

  if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
    @move_momx += @renderer.cos_angle * thrust
    @move_momy += @renderer.sin_angle * thrust
    has_input = true
  end
  if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
    @move_momx -= @renderer.cos_angle * thrust
    @move_momy -= @renderer.sin_angle * thrust
    has_input = true
  end
  if Gosu.button_down?(Gosu::KB_A)
    @move_momx -= @renderer.sin_angle * thrust
    @move_momy += @renderer.cos_angle * thrust
    has_input = true
  end
  if Gosu.button_down?(Gosu::KB_D)
    @move_momx += @renderer.sin_angle * thrust
    @move_momy -= @renderer.cos_angle * thrust
    has_input = true
  end

  # Apply friction (continuous-time equivalent of *= 0.90625 per tic)
  decay = Math.exp(-FRICTION_DECAY_RATE * delta_time)
  if !has_input && @move_momx.abs < STOPSPEED && @move_momy.abs < STOPSPEED
    @move_momx = 0.0
    @move_momy = 0.0
  else
    @move_momx *= decay
    @move_momy *= decay
  end

  # Track movement state for weapon/view bob
  if @player_state
    @player_state.is_moving = has_input
    @player_state.set_movement_momentum(@move_momx, @move_momy)
  end

  # Apply momentum with collision detection (scale by delta_time for frame-rate independence)
  if @move_momx.abs > STOPSPEED || @move_momy.abs > STOPSPEED
    try_move(@move_momx * delta_time, @move_momy * delta_time)
  end

  # Handle firing (left click, Ctrl, X, or Shift)
  if @player_state && ((@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
      Gosu.button_down?(Gosu::KB_LEFT_CONTROL) || Gosu.button_down?(Gosu::KB_RIGHT_CONTROL) ||
      Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
      Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
    was_attacking = @player_state.attacking
    @player_state.start_attack
    # Fire hitscan on the first frame of the attack
    if @player_state.attacking && !was_attacking && @combat
      @combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
                   @renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
      @sound&.weapon_fire(@player_state.weapon)
    end
  end

  # Handle weapon switching with number keys
  handle_weapon_switch if @player_state

  # Handle use key (spacebar or E)
  handle_use_key if @sector_actions
end

#handle_mouse_lookObject



740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# File 'lib/doom/platform/gosu_window.rb', line 740

def handle_mouse_look
  return unless @mouse_captured

  current_x = mouse_x
  if @last_mouse_x
    delta_x = current_x - @last_mouse_x
    @renderer.turn(-delta_x * MOUSE_SENSITIVITY) if delta_x != 0
  end

  # Keep mouse centered
  center_x = width / 2
  if (current_x - center_x).abs > 50
    self.mouse_x = center_x
    @last_mouse_x = center_x
  else
    @last_mouse_x = current_x
  end
end

#handle_option_toggle(option, value) ⇒ Object



1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
# File 'lib/doom/platform/gosu_window.rb', line 1028

def handle_option_toggle(option, value)
  case option
  when :god_mode
    @player_state.god_mode = value
    @player_state.health = 100 if value
  when :infinite_ammo
    @player_state.infinite_ammo = value
  when :all_weapons
    if value
      # Give all weapons that have sprites loaded
      @gfx_weapons ||= @weapon_renderer&.instance_variable_get(:@gfx)&.weapons || {}
      (0..7).each do |w|
        name = Game::PlayerState::WEAPON_NAMES[w]
        @player_state.has_weapons[w] = true if @gfx_weapons[name]&.dig(:idle)
      end
      @player_state.ammo_bullets = @player_state.max_bullets
      @player_state.ammo_shells = @player_state.max_shells
      @player_state.ammo_rockets = @player_state.max_rockets
      @player_state.ammo_cells = @player_state.max_cells
    end
  when :fullscreen
    self.fullscreen = value if respond_to?(:fullscreen=)
  when :rubykaigi_mode
    apply_rubykaigi_mode if value
  end
end

#handle_use_keyObject



361
362
363
364
365
366
367
368
369
370
# File 'lib/doom/platform/gosu_window.rb', line 361

def handle_use_key
  use_down = Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)

  if use_down && !@use_pressed
    @use_pressed = true
    try_use_linedef
  elsif !use_down
    @use_pressed = false
  end
end

#handle_weapon_switchObject



469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/doom/platform/gosu_window.rb', line 469

def handle_weapon_switch
  if Gosu.button_down?(Gosu::KB_1)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_FIST)
  elsif Gosu.button_down?(Gosu::KB_2)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_PISTOL)
  elsif Gosu.button_down?(Gosu::KB_3)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_SHOTGUN)
  elsif Gosu.button_down?(Gosu::KB_4)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_CHAINGUN)
  elsif Gosu.button_down?(Gosu::KB_5)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_ROCKET)
  elsif Gosu.button_down?(Gosu::KB_6)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_PLASMA)
  elsif Gosu.button_down?(Gosu::KB_7)
    @player_state.switch_weapon(Game::PlayerState::WEAPON_BFG)
  end
end

#hsv_to_gosu(h, s, v) ⇒ Object



1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
# File 'lib/doom/platform/gosu_window.rb', line 1301

def hsv_to_gosu(h, s, v)
  c = v * s
  x = c * (1 - ((h / 60.0) % 2 - 1).abs)
  m = v - c

  r, g, b = case (h / 60).to_i % 6
            when 0 then [c, x, 0]
            when 1 then [x, c, 0]
            when 2 then [0, c, x]
            when 3 then [0, x, c]
            when 4 then [x, 0, c]
            when 5 then [c, 0, x]
            end

  Gosu::Color.new(255, ((r + m) * 255).to_i, ((g + m) * 255).to_i, ((b + m) * 255).to_i)
end

#line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius) ⇒ Boolean

Returns:

  • (Boolean)


712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
# File 'lib/doom/platform/gosu_window.rb', line 712

def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
  # Vector from line start to circle center
  dx = cx - x1
  dy = cy - y1

  # Line direction vector
  line_dx = x2 - x1
  line_dy = y2 - y1
  line_len_sq = line_dx * line_dx + line_dy * line_dy

  return false if line_len_sq == 0

  # Project circle center onto line, clamped to segment
  t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
  t = [[t, 0.0].max, 1.0].min

  # Closest point on line segment
  closest_x = x1 + t * line_dx
  closest_y = y1 + t * line_dy

  # Distance from circle center to closest point
  dist_x = cx - closest_x
  dist_y = cy - closest_y
  dist_sq = dist_x * dist_x + dist_y * dist_y

  dist_sq < radius * radius
end

#linedef_blocks?(linedef, x, y) ⇒ Boolean

Returns:

  • (Boolean)


683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'lib/doom/platform/gosu_window.rb', line 683

def linedef_blocks?(linedef, x, y)
  v1 = @map.vertices[linedef.v1]
  v2 = @map.vertices[linedef.v2]

  # Check if player circle intersects this line
  return false unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, x, y, PLAYER_RADIUS)

  # One-sided linedef (wall) always blocks
  return true if linedef.sidedef_left == 0xFFFF

  # ML_BLOCKING on two-sided: handled by crosses_blocking_linedef? (crossing check)
  # Don't check here -- linedef_blocks? is a proximity check and would
  # block the player when standing near the line, not just crossing it

  # Two-sided: check if impassable (high step OR low ceiling)
  front_side = @map.sidedefs[linedef.sidedef_right]
  back_side = @map.sidedefs[linedef.sidedef_left]

  front_sector = @map.sectors[front_side.sector]
  back_sector = @map.sectors[back_side.sector]

  step = (back_sector.floor_height - front_sector.floor_height).abs
  min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
  max_floor = [front_sector.floor_height, back_sector.floor_height].max

  # Block if step too high OR not enough headroom
  step > 24 || (min_ceiling - max_floor) < 56
end

#load_next_map(map_name) ⇒ Object



1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
# File 'lib/doom/platform/gosu_window.rb', line 1118

def load_next_map(map_name)
  return unless map_name

  wad = @renderer.instance_variable_get(:@wad)
  @current_map = map_name

  # Load new map data
  map = Map::MapData.load(wad, map_name)
  @map = map

  # Rebuild all systems for new map
  palette = @palette
  colormap = @renderer.instance_variable_get(:@colormap)
  textures = @renderer.instance_variable_get(:@textures)
  flats = @renderer.instance_variable_get(:@flats)
  sprites = @renderer.instance_variable_get(:@sprites)
  animations = @animations

  @renderer = Render::Renderer.new(wad, map, textures, palette, colormap,
                                    flats.values, sprites, animations)
  ps = map.player_start
  @renderer.set_player(ps.x, ps.y, 41, ps.angle)

  @player_state.reset
  @sector_actions = Game::SectorActions.new(map, @sound)
  @sector_effects = Game::SectorEffects.new(map)

  @skill_hidden = compute_skill_hidden(@skill || Game::Menu::SKILL_MEDIUM)
  @item_pickup = Game::ItemPickup.new(map, @player_state, @skill_hidden)
  @item_pickup.ammo_multiplier = (@skill == Game::Menu::SKILL_BABY) ? 2 : 1

  combat_sprites = sprites
  @combat = Game::Combat.new(map, @player_state, combat_sprites, @skill_hidden, @sound)
  @monster_ai = Game::MonsterAI.new(map, @combat, @player_state, combat_sprites, @skill_hidden, @sound)
  @monster_ai.aggression = true
  @monster_ai.damage_multiplier = @damage_multiplier

  @last_floor_height = nil
  @move_momx = 0.0
  @move_momy = 0.0
  @leveltime = 0

  update_player_height(ps.x, ps.y)
end

#needs_cursor?Boolean

— End Automap —

Returns:

  • (Boolean)


1419
1420
1421
# File 'lib/doom/platform/gosu_window.rb', line 1419

def needs_cursor?
  !@mouse_captured
end

#point_to_line_distance(px, py, x1, y1, x2, y2) ⇒ Object



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/doom/platform/gosu_window.rb', line 412

def point_to_line_distance(px, py, x1, y1, x2, y2)
  # Vector from line start to point
  dx = px - x1
  dy = py - y1

  # Line direction vector
  line_dx = x2 - x1
  line_dy = y2 - y1
  line_len_sq = line_dx * line_dx + line_dy * line_dy

  return Math.sqrt(dx * dx + dy * dy) if line_len_sq == 0

  # Project point onto line, clamped to segment
  t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
  t = [[t, 0.0].max, 1.0].min

  # Closest point on line segment
  closest_x = x1 + t * line_dx
  closest_y = y1 + t * line_dy

  # Distance from point to closest point on segment
  dist_x = px - closest_x
  dist_y = py - closest_y
  Math.sqrt(dist_x * dist_x + dist_y * dist_y)
end

#respawn_playerObject



998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
# File 'lib/doom/platform/gosu_window.rb', line 998

def respawn_player
  @player_state.reset
  @last_floor_height = nil
  @move_momx = 0.0
  @move_momy = 0.0

  # Reset item pickup, combat, and monster AI state
  sprites = @combat&.instance_variable_get(:@sprites)
  @item_pickup = Game::ItemPickup.new(@map, @player_state, @skill_hidden) if @item_pickup
  @combat = Game::Combat.new(@map, @player_state, sprites, @skill_hidden, @sound) if @combat && sprites
  sprites_mgr = @combat&.instance_variable_get(:@sprites)
  @monster_ai = Game::MonsterAI.new(@map, @combat, @player_state, sprites_mgr, @skill_hidden, @sound) if @monster_ai && @combat

  # Re-apply active cheats from menu options
  if @menu
    opts = @menu.options
    @player_state.god_mode = opts[:god_mode]
    @player_state.infinite_ammo = opts[:infinite_ammo]
    handle_option_toggle(:all_weapons, true) if opts[:all_weapons]
    apply_rubykaigi_mode if opts[:rubykaigi_mode]
  end

  # Move player to start position
  ps = @map.player_start
  if ps
    @renderer.set_player(ps.x, ps.y, 41, ps.angle)
    update_player_height(ps.x, ps.y)
  end
end

#segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) ⇒ Boolean

Test if line segment (ax1,ay1)-(ax2,ay2) intersects (bx1,by1)-(bx2,by2)

Returns:

  • (Boolean)


665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
# File 'lib/doom/platform/gosu_window.rb', line 665

def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
  d1x = ax2 - ax1
  d1y = ay2 - ay1
  d2x = bx2 - bx1
  d2y = by2 - by1

  denom = d1x * d2y - d1y * d2x
  return false if denom.abs < 0.001  # Parallel

  dx = bx1 - ax1
  dy = by1 - ay1

  t = (dx * d2y - dy * d2x).to_f / denom
  u = (dx * d1y - dy * d1x).to_f / denom

  t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
end

#setup_yjit_toggleObject



1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
# File 'lib/doom/platform/gosu_window.rb', line 1195

def setup_yjit_toggle
  return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
  require "fiddle"

  address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
  enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)

  RubyVM::YJIT.singleton_class.prepend(Module.new do
    define_method(:enable) do |**kwargs|
      return false if enabled?
      return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
      enabled_ptr[0] = 1
      true
    end

    define_method(:disable) do
      return false unless enabled?
      enabled_ptr[0] = 0
      true
    end
  end)

  @yjit_toggle_ready = true
rescue => e
  puts "YJIT toggle setup failed: #{e.message}"
end

#trigger_level_exit(exit_type) ⇒ Object



1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
# File 'lib/doom/platform/gosu_window.rb', line 1091

def trigger_level_exit(exit_type)
  # Gather stats
  total_monsters = @monster_ai ? @monster_ai.monsters.size : 0
  killed = @combat ? @combat.dead_things.size : 0

  total_items = Game::ItemPickup::ITEMS.keys.count { |t|
    @map.things.any? { |th| th.type == t }
  }
  picked = @item_pickup ? @item_pickup.picked_up.size : 0

  # Secret sectors (type 9) tracked by SectorActions
  total_secrets = @map.sectors.count { |s| s.special == 9 }
  found_secrets = @sector_actions ? @sector_actions.secrets_found.size : 0

  stats = {
    map: @current_map,
    kills: killed, total_kills: total_monsters,
    items: picked, total_items: total_items,
    secrets: found_secrets, total_secrets: total_secrets,
    time_tics: @leveltime,
    exit_type: exit_type,
  }

  wad = @renderer.instance_variable_get(:@wad)
  @intermission = Game::Intermission.new(wad, @status_bar.instance_variable_get(:@gfx), stats)
end

#try_move(dx, dy) ⇒ Object



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/doom/platform/gosu_window.rb', line 487

def try_move(dx, dy)
  old_x = @renderer.player_x
  old_y = @renderer.player_y
  new_x = old_x + dx
  new_y = old_y + dy

  # Check if new position is valid and path doesn't cross blocking linedefs
  if valid_move?(old_x, old_y, new_x, new_y)
    @renderer.move_to(new_x, new_y)
    update_player_height(new_x, new_y)
  else
    # Wall sliding: project movement along the blocking wall
    slide_x, slide_y = compute_slide(old_x, old_y, dx, dy)
    if slide_x && (slide_x != 0.0 || slide_y != 0.0)
      sx = old_x + slide_x
      sy = old_y + slide_y
      if valid_move?(old_x, old_y, sx, sy)
        @renderer.move_to(sx, sy)
        update_player_height(sx, sy)
        # Redirect momentum along the wall
        @move_momx = slide_x / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momx.abs
        @move_momy = slide_y / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momy.abs
        return
      end
    end

    # Fallback: try axis-aligned sliding
    if dx != 0.0 && valid_move?(old_x, old_y, new_x, old_y)
      @renderer.move_to(new_x, old_y)
      update_player_height(new_x, old_y)
      @move_momy *= 0.0
    elsif dy != 0.0 && valid_move?(old_x, old_y, old_x, new_y)
      @renderer.move_to(old_x, new_y)
      update_player_height(old_x, new_y)
      @move_momx *= 0.0
    else
      # Fully blocked - kill momentum
      @move_momx = 0.0
      @move_momy = 0.0
    end
  end
end

#try_use_linedefObject



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/doom/platform/gosu_window.rb', line 372

def try_use_linedef
  # Cast a ray forward to find a usable linedef
  player_x = @renderer.player_x
  player_y = @renderer.player_y
  cos_angle = @renderer.cos_angle
  sin_angle = @renderer.sin_angle

  # Check point in front of player
  use_x = player_x + cos_angle * USE_DISTANCE
  use_y = player_y + sin_angle * USE_DISTANCE

  # Find the closest linedef the player is facing
  best_linedef = nil
  best_idx = nil
  best_dist = Float::INFINITY

  @map.linedefs.each_with_index do |linedef, idx|
    next if linedef.special == 0  # Skip non-special linedefs

    v1 = @map.vertices[linedef.v1]
    v2 = @map.vertices[linedef.v2]

    # Check if player is close enough to the linedef
    dist = point_to_line_distance(player_x, player_y, v1.x, v1.y, v2.x, v2.y)
    next if dist > USE_DISTANCE
    next if dist >= best_dist

    # Check if player is facing the linedef (on the front side)
    next unless facing_linedef?(player_x, player_y, cos_angle, sin_angle, v1, v2)

    best_linedef = linedef
    best_idx = idx
    best_dist = dist
  end

  if best_linedef
    @sector_actions.use_linedef(best_linedef, best_idx)
  end
end

#updateObject



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/doom/platform/gosu_window.rb', line 140

def update
  # Calculate delta time for smooth animations
  now = Time.now
  delta_time = now - @last_update_time
  @last_update_time = now

  # Menu is active -- only update menu animation, skip game logic
  if @menu&.active?
    @menu.update
    return
  end

  # Intermission screen active
  if @intermission
    @intermission.update
    return
  end

  handle_input(delta_time)

  # Update player state (per-frame for smooth bob)
  if @player_state
    @player_state.update_bob(delta_time)
    @player_state.update_view_bob(delta_time)
  end

  # Advance game tics at 35/sec (DOOM's tic rate)
  @tic_accumulator += delta_time * 35.0
  while @tic_accumulator >= 1.0
    @leveltime += 1
    @tic_accumulator -= 1.0
    @sector_effects&.update
    @player_state&.update_viewheight
    @player_state&.update_attack  # Attack timing at 35fps like DOOM
    health_before = @player_state&.health || 100

    @combat&.update_player_pos(@renderer.player_x, @renderer.player_y, @renderer.player_z)
    @combat&.update
    @monster_ai&.update(@renderer.player_x, @renderer.player_y)

    # Sound effects for player damage/death
    if @sound && @player_state
      health_now = @player_state.health
      if health_now < health_before
        if @player_state.dead
          @sound.player_death
        else
          @sound.player_pain
        end
      end
    end

    @player_state&.update_damage_count
    @item_pickup&.update_flash

    # Sector damage (nukage, lava, etc.) every 32 tics
    if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
      check_sector_damage
    end

    # Track death tic for death animation
    if @player_state&.dead
      @player_state.death_tic += 1
    end
  end
  @animations&.update(@leveltime)

  # Update HUD animations
  @status_bar&.update

  # Update sector actions (doors, lifts, etc.)
  if @sector_actions
    @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
    @sector_actions.update

    # Check for level exit
    if @sector_actions.exit_triggered && !@intermission
      trigger_level_exit(@sector_actions.exit_triggered)
    end

    # Check for teleport
    if (dest = @sector_actions.pop_teleport)
      @renderer.set_player(dest[:x], dest[:y], @renderer.player_z, dest[:angle])
      update_player_height(dest[:x], dest[:y])
    end
  end

  # Check item pickups
  if @item_pickup
    picked_before = @item_pickup.picked_up.size
    @item_pickup.update(@renderer.player_x, @renderer.player_y)
    @renderer.hidden_things = @skill_hidden.merge(@item_pickup.picked_up)
    if @sound && @item_pickup.picked_up.size > picked_before
      # Check if it was a weapon pickup (has :weapon key in ITEMS)
      msg = @item_pickup.pickup_message
      if msg && msg.include?('!')  # Weapon pickups end with !
        @sound.weapon_pickup
      else
        @sound.item_pickup
      end
    end
  end

  # Pass combat state to renderer for death frame rendering
  @renderer.combat = @combat
  @renderer.monster_ai = @monster_ai
  @renderer.leveltime = @leveltime

  # Render the 3D world
  @renderer.render_frame

  # Render HUD on top
  if @weapon_renderer && !@player_state&.dead
    @weapon_renderer.render(@renderer.framebuffer)
  end
  if @status_bar
    @status_bar.render(@renderer.framebuffer)
  end

  # Pickup message (drawn into framebuffer with DOOM font, 4 seconds like Chocolate Doom)
  if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics > 0
    @doom_font.draw_text(@renderer.framebuffer, @item_pickup.pickup_message, 2, 2)
  end

  # Red tint when dead
  if @player_state&.dead
    apply_death_tint(@renderer.framebuffer)
  end
end

#update_player_height(x, y) ⇒ Object



572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
# File 'lib/doom/platform/gosu_window.rb', line 572

def update_player_height(x, y)
  sector = @map.sector_at(x, y)
  return unless sector

  new_floor = sector.floor_height

  if @player_state
    # Detect step: floor height changed since last move
    if @last_floor_height && @last_floor_height != new_floor
      step = new_floor - @last_floor_height
      @player_state.notify_step(step) if step.abs <= 24
    end
    @last_floor_height = new_floor

    view_bob = @player_state.view_bob_offset
    @renderer.set_z(new_floor + @player_state.viewheight + view_bob)
  else
    @renderer.set_z(new_floor + 41)
  end
end

#valid_move?(old_x, old_y, new_x, new_y) ⇒ Boolean

Returns:

  • (Boolean)


593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/doom/platform/gosu_window.rb', line 593

def valid_move?(old_x, old_y, new_x, new_y)
  # Check if destination is inside a valid sector
  sector = @map.sector_at(new_x, new_y)
  return false unless sector

  # Check floor height - can't step up too high
  floor_height = sector.floor_height
  return false if floor_height > @renderer.player_z + 24  # Max step height

  # Check against blocking linedefs: both circle intersection and path crossing
  @map.linedefs.each do |linedef|
    if linedef_blocks?(linedef, new_x, new_y)
      return false
    end
    if crosses_blocking_linedef?(old_x, old_y, new_x, new_y, linedef)
      return false
    end
  end

  # Check against solid things (monsters, barrels, pillars, etc.)
  combined_radius = PLAYER_RADIUS
  picked = @item_pickup&.picked_up
  @map.things.each_with_index do |thing, idx|
    next if @skill_hidden[idx]
    next if picked && picked[idx]
    next if @combat && @combat.dead?(idx)
    thing_radius = SOLID_THING_RADIUS[thing.type]
    next unless thing_radius

    dx = new_x - thing.x
    dy = new_y - thing.y
    min_dist = combined_radius + thing_radius
    if dx * dx + dy * dy < min_dist * min_dist
      return false
    end
  end

  true
end