Class: Doom::Platform::GosuWindow

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

Constant Summary collapse

SCALE =
3
TURN_SPEED =

Movement now lives in Game::PlayerPhysics and runs per tic, so the old continuous-time thrust/friction constants moved there with it.

3.0
DEFAULT_REFRESH_HZ =

Frame generation is decoupled from presentation. Gosu's main loop is limited by the buffer swap, which blocks on vblank -- so drawing every iteration pins the whole engine to the monitor's refresh rate. Instead we render every iteration (in update) but only ask Gosu to present when a refresh interval has elapsed; needs_redraw? => false skips both the blit and the swap, letting the loop spin freely in between.

Gosu 1.4 exposes no refresh-rate API, so we ask SDL (see SDLDisplayMode) and fall back to 60 Hz if it will not say.

60.0
PRESENT_INTERVAL_SLACK =

Aim slightly early so we don't miss a vblank

0.85
MOUSE_SENSITIVITY =

Mouse look sensitivity

0.15
CLIENT_INPUT_LEAD =

Networked: input is sampled on the local clock but tics only run once every player's command for them has arrived. A stall is normal -- it means someone else's packet is late -- so we keep drawing and say who we are waiting for rather than freezing silently. Authoritative-server client: no local simulation at all. Poll the server's frame stream (Net::Client applies it), send this frame's input tagged a few tics ahead so it lands before that tic is finalized, and follow the world the client holds -- which it rebuilds from a fresh snapshot on a resync, so re-point at it when the object changes.

3
NET_SEND_INTERVAL =

Outbound packets are paced to the tic rate, not the uncapped render loop: drawing runs at hundreds of FPS, and sending one packet per frame would flood the server (and, in P2P, every peer) from every phone in the room.

1.0 / 35.0
CLIENT_INPUT_REDUNDANCY =

Each input packet repeats the last few tics of commands, so pacing the send rate down does not make a single dropped packet lose that input.

4
MAP_MARGIN =

--- Automap ---

20

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(renderer, palette, world, status_bar = nil, weapon_renderer = nil, animations = nil, menu = nil, sound_engine = nil, session: nil, client: nil) ⇒ GosuWindow

The simulation now lives in Game::World; this class is input and output only. The world's subsystems are cached in ivars because the automap, debug overlay and HUD read them constantly -- bind_world keeps those caches honest whenever the world is rebuilt.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/doom/platform/gosu_window.rb', line 33

def initialize(renderer, palette, world, status_bar = nil, weapon_renderer = nil,
               animations = nil, menu = nil, sound_engine = nil, session: nil, client: 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
  @status_bar = status_bar
  @weapon_renderer = weapon_renderer
  @animations = animations
  @menu = menu
  @doom_font = menu&.font
  @sound = sound_engine
  @skill = Game::Menu::SKILL_MEDIUM
  @session = session
  @client = client # authoritative-server client, if we joined one
  @local_player_id = session&.local_id || client&.local_id || 0
  @last_tic_at = Time.now  # for interpolating between server frames
  @last_net_send_at = nil  # throttles outbound packets to the tic rate
  @client_input_window = [] # recent [tic, ticcmd] resent against loss
  menu.netgame = true if menu && (session || client)

  bind_world(world)
  @pending_turn = 0.0 # Mouse turn accumulated between tics
  @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

  # Uncapped frame generation. Presented frames are counted separately so
  # the overlay can show both the render rate and what the display got.
  @uncapped_fps = !ARGV.include?('--vsync')
  menu.options[:uncapped_fps] = @uncapped_fps if menu
  @present_frames = 0
  @present_fps_display = 0.0
  @last_present_ms = 0
  @refresh_hz = (SDLDisplayMode.refresh_rate || DEFAULT_REFRESH_HZ).to_f
  @present_interval_ms = 1000.0 / @refresh_hz

  # 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.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 Attribute Details

#refresh_hzObject (readonly)

Returns the value of attribute refresh_hz.



416
417
418
# File 'lib/doom/platform/gosu_window.rb', line 416

def refresh_hz
  @refresh_hz
end

Instance Method Details

#active_palette_indexObject

Palette for the live game view: red pain flash while taking damage (1-8), yellow flash on item pickup (9), otherwise the normal palette.



579
580
581
582
583
584
585
586
587
# File 'lib/doom/platform/gosu_window.rb', line 579

def active_palette_index
  if @item_pickup && @item_pickup.pickup_flash > 0
    9
  elsif @player_state
    @player_state.damage_count.clamp(0, 8)
  else
    0
  end
end

#advance_localObject



99
100
101
102
103
104
105
# File 'lib/doom/platform/gosu_window.rb', line 99

def advance_local
  while @tic_accumulator >= 1.0
    @tic_accumulator -= 1.0
    @leveltime = @world.run_tic(@player.id => build_ticcmd)
    @item_pickup.update_flash
  end
end

#advance_networkedObject



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/doom/platform/gosu_window.rb', line 141

def advance_networked
  @session.poll

  while @tic_accumulator >= 1.0
    @tic_accumulator -= 1.0
    @session.submit(build_ticcmd)
  end
  # Lockstep already resends a redundancy window from each peer's ack, so
  # pacing transmit to the tic rate loses nothing but the flood.
  @session.transmit if net_send_due?

  ran = @session.run(@world, limit: 10)
  ran.times { @item_pickup.update_flash }
  @leveltime = @world.leveltime
end

#advance_server_clientObject



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/doom/platform/gosu_window.rb', line 125

def advance_server_client
  @client.poll
  bind_world(@client.world) unless @world.equal?(@client.world)

  send_client_input

  if @client.synced_tic != @leveltime
    @leveltime = @client.synced_tic
    @last_tic_at = Time.now
    @item_pickup.update_flash
  end
  # Fraction into the current tic, so rendering stays smooth above the
  # server's 35 Hz frame rate.
  @tic_accumulator = [(Time.now - @last_tic_at) * 35.0, 1.0].min
end

#apply_debug_poseObject



207
208
209
210
211
212
213
214
# File 'lib/doom/platform/gosu_window.rb', line 207

def apply_debug_pose
  x, y, angle = ENV.fetch('DOOM_DEBUG_POSE').split(',').map { |value| Float(value) }
  sector = @map.sector_at(x, y)
  @player.place(x, y, (sector&.floor_height || 0) + Game::PlayerState::VIEWHEIGHT, angle)
  @debug_pose_applied = true
rescue ArgumentError
  warn 'DOOM_DEBUG_POSE must be x,y,angle_degrees'
end

#apply_difficulty(skill) ⇒ Object



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
# File 'lib/doom/platform/gosu_window.rb', line 941

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

  # Push difficulty into the world before respawning: respawn rebuilds the
  # actor subsystems from the world's settings, so setting them here only
  # would be discarded.
  @world.skill_hidden = compute_skill_hidden(skill)
  @world.damage_multiplier = @damage_multiplier
  @physics.skill_hidden = @world.skill_hidden

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

  respawn_player

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

#apply_rubykaigi_modeObject



862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
# File 'lib/doom/platform/gosu_window.rb', line 862

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

#bind_world(world) ⇒ Object

Point this window at a world, refreshing every cached reference. Called on construction and whenever the world is rebuilt (new map, respawn).



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/doom/platform/gosu_window.rb', line 186

def bind_world(world)
  @world = world
  @map = world.map
  @random = world.random
  @player = world.player(@local_player_id) || world.add_player(id: @local_player_id)
  apply_debug_pose if ENV['DOOM_DEBUG_POSE'] && !@debug_pose_applied
  @player_state = @player.state
  # The HUD holds the player state directly; re-point it, or a map change
  # or a netgame resync (which builds a fresh world) leaves it on a stale
  # player showing frozen health and ammo.
  @status_bar.player = @player_state if @status_bar
  @weapon_renderer.player = @player_state if @weapon_renderer
  @physics = world.physics_for(@player)
  @combat = world.combat
  @monster_ai = world.monster_ai
  @item_pickup = world.item_pickup
  @sector_actions = world.sector_actions
  @damage_multiplier = world.damage_multiplier
  @leveltime = world.leveltime
end

#blit_hud_overlay(overlay, pal_idx, tint) ⇒ Object

Blit an indexed overlay (transparent = -1) over the GL world, coloured by palette pal_idx; tint multiplies the whole image (nil = untinted).



544
545
546
547
548
549
550
551
552
553
# File 'lib/doom/platform/gosu_window.rb', line 544

def blit_hud_overlay(overlay, pal_idx, tint)
  palette = @all_palette_rgba[pal_idx]
  rgba = overlay.map { |index| index == -1 ? "\0\0\0\0" : palette[index] }.join
  image = Gosu::Image.from_blob(Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba)
  if tint
    image.draw(0, 0, 10, SCALE, SCALE, tint)
  else
    image.draw(0, 0, 10, SCALE, SCALE)
  end
end

#build_sector_colorsObject



1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
# File 'lib/doom/platform/gosu_window.rb', line 1068

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

#build_ticcmdObject

Collect this tic's intent. Everything that moves the player must come through here, so that replacing it with a ticcmd off the network is the only change multiplayer needs.



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
# File 'lib/doom/platform/gosu_window.rb', line 320

def build_ticcmd
  return Game::Ticcmd.none if @menu&.active?
  return Game::Ticcmd.none if @player_state&.dead

  forward = 0.0
  side = 0.0
  forward += 1.0 if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
  forward -= 1.0 if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
  side += 1.0 if Gosu.button_down?(Gosu::KB_D)
  side -= 1.0 if Gosu.button_down?(Gosu::KB_A)

  turn = @pending_turn
  @pending_turn = 0.0
  turn += TURN_SPEED if Gosu.button_down?(Gosu::KB_LEFT)
  turn -= TURN_SPEED if Gosu.button_down?(Gosu::KB_RIGHT)

  buttons = 0
  if (@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)
    buttons |= Game::Ticcmd::BTN_FIRE
  end
  buttons |= Game::Ticcmd::BTN_USE if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)

  Game::Ticcmd.new(forward, side, turn, buttons)
end

#button_down(id) ⇒ Object



666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
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
711
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
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
# File 'lib/doom/platform/gosu_window.rb', line 666

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
        if @renderer.respond_to?(:hardware?) && @renderer.hardware?
          # The OpenGL scene has no palette-indexed snapshot for
          # ScreenMelt. Draw it live on the next frame instead of
          # melting toward HardwareRenderer's intentionally black
          # compatibility framebuffer.
          @screen_melt = nil
        else
          # 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
      end

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

      case result
      when :start_game
        # Restarting the level is local, so it would desync a netgame --
        # for a lockstep peer or an authoritative-server client alike.
        apply_difficulty(@menu.selected_skill) unless @session || @client
      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_R
    switch_renderer
  when Gosu::KB_B
    @renderer.skip_background_fill = !@renderer.skip_background_fill
  when Gosu::KB_Y
    if defined?(RubyVM::YJIT)
      setup_yjit_toggle
      if RubyVM::YJIT.enabled?
        RubyVM::YJIT.disable
      else
        RubyVM::YJIT.enable
      end
    end
  when Gosu::KB_C
    @monster_ai.aggression = !@monster_ai.aggression if @monster_ai
  when Gosu::KB_M
    @show_map = !@show_map
  when Gosu::KB_F12
    capture_debug_snapshot
  end
end

#capture_debug_snapshotObject

--- Debug Snapshot ---



1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
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
1054
1055
1056
1057
1058
1059
1060
1061
1062
# File 'lib/doom/platform/gosu_window.rb', line 1006

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(@player.x, @player.y)
  sector_idx = sector ? @map.sectors.index(sector) : nil
  angle_deg = Math.atan2(@player.sin_angle, @player.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]}" \
    "#{if s[:clipping_detail]&.any?
         "\n    clips: #{s[:clipping_detail].map do |c|
           "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}"
         end.join(', ')}"
       end}"
  end

  File.write("#{prefix}.txt", <<~INFO)
    pos: #{@player.x.round(1)}, #{@player.y.round(1)}, #{@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
end

#compute_skill_hidden(skill) ⇒ Object



880
881
882
# File 'lib/doom/platform/gosu_window.rb', line 880

def compute_skill_hidden(skill)
  WindowLogic.skill_hidden(skill, @map.things)
end

#drawObject



433
434
435
436
437
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
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/doom/platform/gosu_window.rb', line 433

def draw
  @present_frames += 1
  @last_present_ms = Gosu.milliseconds

  # Aim the camera at the local player. The simulation runs at 35 Hz but
  # we draw as fast as we can, so interpolate between the previous tic's
  # pose and the current one by however far into the tic we are.
  @renderer.apply_view(*@player.view_pose(@tic_accumulator))

  # Intermission screen
  if @intermission
    fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
    @intermission.render(fb)
    present(fb)
    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)
    present(fb)
    @screen_melt = nil if @screen_melt.done?
    return
  end

  if @menu&.active?
    hardware = @renderer.respond_to?(:hardware?) && @renderer.hardware?
    if hardware && @menu.needs_background?
      sync_renderer_visual_options
      @renderer.draw_hardware(width, height)
      draw_hardware_hud(menu: true)
      return
    elsif @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

    present(fb)
  elsif @show_map
    draw_automap
  else
    if @renderer.respond_to?(:hardware?) && @renderer.hardware?
      sync_renderer_visual_options
      @renderer.draw_hardware(width, height)
      draw_hardware_hud
    else
      present(@renderer.framebuffer, active_palette_index)
    end

    draw_debug_overlay if @show_debug
    draw_net_status if @session
    draw_match_status if @world.deathmatch?
  end
end

#draw_automapObject



1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
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
1162
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
# File 'lib/doom/platform/gosu_window.rb', line 1098

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

  bounds = map_bounds
  return unless bounds

  verts = @map.vertices
  min_x = bounds[:min_x]
  max_x = bounds[:max_x]
  min_y = bounds[:min_y]
  max_y = bounds[:max_y]
  map_w = max_x - min_x
  map_h = max_y - min_y

  # 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
  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(@player.x)
  py = to_sy.call(@player.y)

  cos_a = @player.cos_angle
  sin_a = @player.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



626
627
628
629
630
631
632
633
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
663
664
# File 'lib/doom/platform/gosu_window.rb', line 626

def draw_debug_overlay
  yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'
  renderer_name = Render::RendererFactory.type_of(@renderer).to_s
  ang = (Math.atan2(@player.sin_angle, @player.cos_angle) * 180.0 / Math::PI).round(1)

  # Shown vs generated: only worth spelling out when they differ.
  shown = if @uncapped_fps
            "shown #{@present_fps_display} / #{@refresh_hz.round} Hz"
          else
            'vsync'
          end

  lines = if @menu&.options&.[](:rubykaigi_mode)
            [
              "#{@fps_display} FPS  (#{shown})",
              "YJIT: #{yjit_status}  (Y to toggle)",
              "Ruby #{RUBY_VERSION}",
              "Renderer: #{renderer_name}",
              "Map: #{@current_map}",
              "Pos: #{@player.x.round}, #{@player.y.round}",
              "Ang: #{ang}"
            ]
          else
            [
              "FPS: #{@fps_display}  (#{shown})",
              "YJIT: #{yjit_status}",
              "Renderer: #{renderer_name}",
              "Pos: #{@player.x.round}, #{@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

#draw_hardware_hud(menu: false) ⇒ Object

Hardware world rendering bypasses the indexed framebuffer. Build a transparent indexed overlay for the existing weapon/status renderers, preserving the gameplay UI while the world is drawn by OpenGL.



515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/doom/platform/gosu_window.rb', line 515

def draw_hardware_hud(menu: false)
  # The weapon hand is a held object, so the world's light colours it: tint
  # it by the light where the player stands (lamps, the flashlight). Drawn
  # on its own so only it is lit -- never while a menu is up or the player
  # is dead.
  unless menu || @player_state&.dead
    weapon = new_hud_overlay
    @weapon_renderer&.render(weapon)
    blit_hud_overlay(weapon, active_palette_index, weapon_light_tint)
  end

  # HUD, pickup message and menu: readable UI, never lit by the world. The
  # menu also drops the pain/pickup tint (normal palette), as the classic
  # present(fb) path does.
  hud = new_hud_overlay
  @status_bar&.render(hud)
  if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics.positive?
    @doom_font.draw_text(hud, @item_pickup.pickup_message, 2, 2)
  end
  @menu.render(hud, nil) if menu
  blit_hud_overlay(hud, menu ? 0 : active_palette_index, nil)
end

#draw_match_statusObject

Deathmatch scoreboard. One line of frags, and the result once someone has hit the limit -- the world decides who won, this only reports it.



591
592
593
594
595
596
597
598
599
600
601
# File 'lib/doom/platform/gosu_window.rb', line 591

def draw_match_status
  score = @world.frags.map { |id, frags| "P#{id + 1} #{frags}" }.join('  ')
  @debug_font.draw_text(score, 20, 20, 2, 1, 1, Gosu::Color::YELLOW)

  winner = @world.match_winner
  return unless winner

  line = "PLAYER #{winner.id + 1} WINS -- #{winner.frags} FRAGS"
  @debug_font.draw_text(line, 22, (height / 3) + 2, 2, 1, 1, Gosu::Color::BLACK)
  @debug_font.draw_text(line, 20, height / 3, 2, 1, 1, Gosu::Color::YELLOW)
end

#draw_net_statusObject

A lockstep stall looks exactly like a freeze unless we say otherwise, and a desync means everything on screen is already wrong -- both are worth interrupting the player for.



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# File 'lib/doom/platform/gosu_window.rb', line 606

def draw_net_status
  lines = WindowLogic.net_status_lines(
    started: @session.started?,
    host: @session.host?,
    waiting: @session.waiting_on,
    stalled_seconds: @session.stalled_seconds,
    stall_threshold: Net::Session::STALL_WARNING_SECONDS,
    desync: @session.desyncs.first
  )

  return if lines.empty?

  y = height / 3
  lines.each do |line|
    @debug_font.draw_text(line, 22, y + 2, 2, 1, 1, Gosu::Color::BLACK)
    @debug_font.draw_text(line, 20, y, 2, 1, 1, Gosu::Color::YELLOW)
    y += 30
  end
end

#handle_input(_delta_time) ⇒ Object

Per-frame input sampling. Produces nothing but intent: the simulation itself runs per-tic in Game::World (fed the ticcmd from build_ticcmd). Mouse motion is accumulated here because frames are more frequent than tics and dropping the surplus would lose part of every flick.



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/doom/platform/gosu_window.rb', line 301

def handle_input(_delta_time)
  # Handle respawn when dead. Local play only: respawn rebuilds the level,
  # which in a networked game would desync us from everyone else, so there
  # it is the server's job (not yet wired -- dead players stay down).
  if @player_state&.dead
    if !@session && !@client && @player_state.death_tic > 35 && (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
    return # No other input while dead
  end

  handle_mouse_look # accumulates into @pending_turn
  handle_weapon_switch if @player_state
end

#handle_mouse_lookObject



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/doom/platform/gosu_window.rb', line 385

def handle_mouse_look
  return unless @mouse_captured

  current_x = mouse_x
  if @last_mouse_x
    delta_x = current_x - @last_mouse_x
    # Accumulate rather than turn: the turn is applied by the next ticcmd,
    # so fast mouse motion between tics is summed instead of dropped.
    @pending_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



822
823
824
825
826
827
828
829
830
831
832
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
# File 'lib/doom/platform/gosu_window.rb', line 822

def handle_option_toggle(option, value)
  # Belt and braces: the menu already hides these in a netgame, but a
  # cheat applied on one machine only is a real desync, so refuse it
  # here as well rather than trusting the menu to have filtered.
  return if (@session || @client) && Game::Menu::NETGAME_UNSAFE_OPTIONS.include?(option)

  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&.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 :uncapped_fps
    @uncapped_fps = value
    # Re-read on re-enable: the window may have moved to a display with a
    # different refresh rate.
    if value
      @refresh_hz = (SDLDisplayMode.refresh_rate || DEFAULT_REFRESH_HZ).to_f
      @present_interval_ms = 1000.0 / @refresh_hz
    end
  when :fullscreen
    self.fullscreen = value if respond_to?(:fullscreen=)
  when :rubykaigi_mode
    apply_rubykaigi_mode if value
  end
end

#handle_weapon_switchObject

Weapon selection from the number keys. Read per-frame like the rest of input; the switch itself only changes which weapon the HUD/firing use.



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/doom/platform/gosu_window.rb', line 350

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

#hold_pain_paletteObject

Death keeps damage_count pinned at max so the draw loop selects the red pain palette every frame while dead. The tint itself is applied by palette selection in #draw, not here.



795
796
797
# File 'lib/doom/platform/gosu_window.rb', line 795

def hold_pain_palette
  @player_state.damage_count = 8 if @player_state&.dead
end

#hsv_to_gosu(h, s, v) ⇒ Object



1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
# File 'lib/doom/platform/gosu_window.rb', line 1081

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

#hud_tint_byte(value) ⇒ Object



565
566
567
# File 'lib/doom/platform/gosu_window.rb', line 565

def hud_tint_byte(value)
  (value * 255.0).clamp(0.0, 255.0).to_i
end

#load_next_map(map_name) ⇒ Object



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
# File 'lib/doom/platform/gosu_window.rb', line 910

def load_next_map(map_name)
  return unless map_name

  wad = @renderer.wad
  @current_map = map_name

  # Load new map data
  map = Map::MapData.load(wad, map_name)
  @map = map
  @map_bounds = nil # Recompute on next automap draw
  @sector_colors = build_sector_colors

  # Rebuild all systems for new map
  @renderer = Render::RendererFactory.build(
    Render::RendererFactory.type_of(@renderer), wad, map, @renderer.textures,
    @palette, @renderer.colormap, @renderer.flats.values, @renderer.sprites, @animations
  )
  # A new map means a new world; the RNG carries over so the run stays
  # one continuous deterministic sequence across level changes.
  skill_hidden = compute_skill_hidden(@skill || Game::Menu::SKILL_MEDIUM)
  world = Game::World.new(map, sprites: @renderer.sprites, sound: @sound,
                               random: @random, skill_hidden: skill_hidden)
  world.damage_multiplier = @damage_multiplier
  world.item_pickup.ammo_multiplier = @skill == Game::Menu::SKILL_BABY ? 2 : 1
  world.monster_ai.aggression = true
  world.monster_ai.damage_multiplier = @damage_multiplier
  world.add_player

  bind_world(world)
end

#map_boundsObject

Lazily computed and cached on first access; cleared by load_next_map.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/doom/platform/gosu_window.rb', line 369

def map_bounds
  return @map_bounds if defined?(@map_bounds) && @map_bounds

  min_x = min_y = Float::INFINITY
  max_x = max_y = -Float::INFINITY
  @map.vertices.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
  return nil if max_x == min_x || max_y == min_y

  @map_bounds = { min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y }
end

#needs_cursor?Boolean

Returns:

  • (Boolean)


1000
1001
1002
# File 'lib/doom/platform/gosu_window.rb', line 1000

def needs_cursor?
  !@mouse_captured
end

#needs_redraw?Boolean

Gosu calls this before draw; false skips both the blit and the buffer swap, so the main loop keeps generating frames without waiting on the display. Returning true unconditionally restores plain vsync behaviour.

Returns:

  • (Boolean)


409
410
411
412
413
414
# File 'lib/doom/platform/gosu_window.rb', line 409

def needs_redraw?
  return true unless @uncapped_fps

  WindowLogic.present_due?(Gosu.milliseconds, @last_present_ms,
                           @present_interval_ms, PRESENT_INTERVAL_SLACK)
end

#net_send_due?Boolean

True at most once per tic interval, so outbound packets track the 35 Hz simulation rather than the uncapped frame rate. Advances the clock as a side effect when it fires.

Returns:

  • (Boolean)


176
177
178
179
180
181
182
# File 'lib/doom/platform/gosu_window.rb', line 176

def net_send_due?
  now = Time.now
  return false if @last_net_send_at && (now - @last_net_send_at) < NET_SEND_INTERVAL

  @last_net_send_at = now
  true
end

#new_hud_overlayObject



538
539
540
# File 'lib/doom/platform/gosu_window.rb', line 538

def new_hud_overlay
  Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, -1)
end

#present(framebuffer, pal_idx = 0) ⇒ Object

Blit one palette-indexed framebuffer to the window. pal_idx selects one of the 14 prebuilt RGBA palettes (0 = normal, 1-8 = pain red, 9 = pickup yellow). All four draw paths funnel through here so the blob->image->draw sequence lives in exactly one place.



503
504
505
506
507
508
509
510
# File 'lib/doom/platform/gosu_window.rb', line 503

def present(framebuffer, pal_idx = 0)
  active_pal = @all_palette_rgba[pal_idx]
  rgba = 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)
end

#respawn_playerObject



799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
# File 'lib/doom/platform/gosu_window.rb', line 799

def respawn_player
  if @world.deathmatch?
    # Deathmatch death is personal: everyone else is still playing, so
    # only this player comes back. restart_level would revive the monsters
    # and restore the items under them.
    @world.respawn(@player)
  else
    # Single player: death restarts the level, so the world rebuilds its
    # actor subsystems and the cached references must be refreshed.
    @world.restart_level(@player)
  end
  bind_world(@world)

  # Re-apply active cheats from menu options
  return unless @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

#send_client_inputObject

Sample this frame's input for a near-future tic and send it, paced to the tic rate, with a short redundancy window against packet loss. Only the newest sample for a given target tic is kept.



160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/doom/platform/gosu_window.rb', line 160

def send_client_input
  return unless net_send_due?

  tic = @client.synced_tic + CLIENT_INPUT_LEAD
  if @client_input_window.last&.first == tic
    @client_input_window[-1] = [tic, build_ticcmd]
  else
    @client_input_window << [tic, build_ticcmd]
  end
  @client_input_window = @client_input_window.last(CLIENT_INPUT_REDUNDANCY)
  @client.send_input(@client_input_window)
end

#setup_yjit_toggleObject



970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
# File 'lib/doom/platform/gosu_window.rb', line 970

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 StandardError => e
  warn "YJIT toggle setup failed: #{e.class}: #{e.message}"
end

#switch_rendererObject



781
782
783
784
785
786
787
788
789
790
# File 'lib/doom/platform/gosu_window.rb', line 781

def switch_renderer
  Render::RendererFactory.type_of(@renderer)
  target = Render::RendererFactory.next_type(@renderer)
  replacement = Render::RendererFactory.build_like(@renderer, target)
  replacement.apply_view(@renderer.player_x, @renderer.player_y,
                         @renderer.player_z, @renderer.player_angle)
  replacement.skip_background_fill = @renderer.skip_background_fill
  @renderer = replacement
  puts "Renderer: #{target}"
end

#sync_renderer_visual_optionsObject



569
570
571
572
573
574
575
# File 'lib/doom/platform/gosu_window.rb', line 569

def sync_renderer_visual_options
  return unless @menu

  @renderer.fog_enabled = @menu.options[:fog] if @renderer.respond_to?(:fog_enabled=)
  @renderer.flashlight_enabled = @menu.options[:flashlight] if @renderer.respond_to?(:flashlight_enabled=)
  @renderer.bounces_enabled = @menu.options[:rt_bounces] if @renderer.respond_to?(:bounces_enabled=)
end

#track_frame_ratesObject

Frames generated vs frames actually shown. With uncapped rendering the first number can run well above the second, which is the whole point.



420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/doom/platform/gosu_window.rb', line 420

def track_frame_rates
  @fps_frames += 1
  now = Time.now
  elapsed = now - @fps_time
  return if elapsed < 0.5

  @fps_display = (@fps_frames / elapsed).round(1)
  @present_fps_display = (@present_frames / elapsed).round(1)
  @fps_frames = 0
  @present_frames = 0
  @fps_time = now
end

#trigger_level_exit(exit_type) ⇒ Object



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
# File 'lib/doom/platform/gosu_window.rb', line 884

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 do |t|
    @map.things.any? { |th| th.type == t }
  end
  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
  }

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

#updateObject



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
269
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
# File 'lib/doom/platform/gosu_window.rb', line 216

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.
  #
  # Except in a networked game, which must never stop simulating. Peers
  # are waiting on our ticcmds and lockstep cannot skip a tic, so a
  # paused peer stops the whole session; if the pause outlasts the
  # commands the others still hold, it wedges permanently. DOOM does not
  # pause netgames either. Input is neutral while the menu has focus, so
  # navigating it does not also drive the player.
  if @menu&.active?
    @menu.update

    if @session
      @tic_accumulator += delta_time * 35.0
      advance_networked
    elsif @client
      advance_server_client
    end
    return
  end

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

  handle_input(delta_time)

  # Advance the simulation at 35/sec (DOOM's tic rate). Everything that
  # decides what happens now lives in Game::World; this loop only decides
  # how many tics are owed and hands over the input for each.
  @tic_accumulator += delta_time * 35.0
  if @session
    advance_networked
  elsif @client
    advance_server_client
  else
    advance_local
  end

  @animations&.update(@leveltime)
  @status_bar&.update
  @renderer.hidden_things = @world.hidden_things

  trigger_level_exit(@world.exit_triggered) if @world.exit_triggered && !@intermission

  # Other players are drawn as sprites; the local one never is.
  @renderer.players = @world.players
  @renderer.view_player = @player

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

  # Render the 3D world. This is the generated-frame rate: it runs every
  # loop iteration, whether or not the result gets presented.
  @renderer.render_frame
  track_frame_rates

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

  # 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
  return unless @player_state&.dead

  hold_pain_palette
end

#weapon_light_tintObject

The world light reaching the player, as a Gosu colour to multiply the weapon by. Only the hardware/ray-traced renderer computes this; the rasterizer leaves the weapon at full brightness.



558
559
560
561
562
563
# File 'lib/doom/platform/gosu_window.rb', line 558

def weapon_light_tint
  return nil unless @renderer.respond_to?(:light_color_at)

  r, g, b = @renderer.light_color_at(@player.x, @player.y, @player.z)
  Gosu::Color.rgba(hud_tint_byte(r), hud_tint_byte(g), hud_tint_byte(b), 255)
end