doom 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +33 -58
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +604 -832
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +5 -3
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +9 -10
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -7
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spinel_native/kernel"
4
+
5
+ module Doom
6
+ module Render
7
+ # Runs the classic software renderer's hot path (BSP -> textured walls ->
8
+ # floors/ceilings -> sky) through spinel_native. The pixel-producing code
9
+ # lives in Doom::Render::SpinelNative::Kernel as native methods that mirror
10
+ # lib/doom/render/renderer.rb one-to-one; this class only flattens the map,
11
+ # textures and flats into the plain Integer/Float arrays the kernel boundary
12
+ # accepts, then calls it once per frame and hands back the framebuffer.
13
+ #
14
+ # The same kernel methods run as plain Ruby when SPINEL_NATIVE=off, so the
15
+ # port can be verified against the reference renderer before compiling.
16
+ class SpinelNativeRenderer
17
+ SCREEN_WIDTH = 320
18
+ SCREEN_HEIGHT = 240
19
+ EYE_HEIGHT = 41
20
+
21
+ attr_reader :palette
22
+
23
+ def initialize(map, palette, colormap, textures, flats)
24
+ @map = map
25
+ @palette = palette
26
+ flatten_all(map, colormap, textures, flats)
27
+ upload
28
+ end
29
+
30
+ # Upload the flattened map/textures into the kernel's state once.
31
+ def upload
32
+ SpinelNative::Kernel.load_map(
33
+ @nodes_f, @verts, @segs, @subs, @lines, @sides, @secs,
34
+ @wpx, @wtab, @fpx, @cmap, @node_count, @sky_tex
35
+ )
36
+ end
37
+
38
+ # Render one frame from the given eye pose. Returns a 76800-int framebuffer
39
+ # of palette indices (row-major, 320x240).
40
+ def render(px, py, angle_rad)
41
+ pz = floor_height_at(px, py) + EYE_HEIGHT
42
+ SpinelNative::Kernel.render(px.round, py.round, pz, angle_rad)
43
+ end
44
+
45
+ # Render with an explicit eye height (the game supplies player_z), for
46
+ # integration into the real Renderer.
47
+ def render_at(px, py, pz, angle_rad)
48
+ SpinelNative::Kernel.render(px.round, py.round, pz.round, angle_rad)
49
+ end
50
+
51
+ # Wall-pass outputs from the last render, for the Ruby sprite pass.
52
+ def ceiling_clip = SpinelNative::Kernel.get_cclip
53
+ def floor_clip = SpinelNative::Kernel.get_fclip
54
+ def wall_depth = SpinelNative::Kernel.get_wdepth
55
+
56
+ # Eye height follows the floor under the player (classic pz = floorz + 41).
57
+ def floor_height_at(x, y)
58
+ ni = @node_count - 1
59
+ while (ni & 0x8000).zero?
60
+ b = ni * 6
61
+ side = (x.to_f - @nodes_f[b]) * @nodes_f[b + 3] >= (y.to_f - @nodes_f[b + 1]) * @nodes_f[b + 2] ? 0 : 1
62
+ ni = (side.zero? ? @nodes_f[b + 4] : @nodes_f[b + 5]).to_i
63
+ end
64
+ first = @subs[(ni & 0x7FFF) * 2]
65
+ sdi = @lines[@segs[first * 5 + 2] * 3 + (@segs[first * 5 + 3].zero? ? 0 : 1)]
66
+ @secs[@sides[sdi * 6] * 6]
67
+ end
68
+
69
+ private
70
+
71
+ def flatten_all(map, colormap, textures, flats)
72
+ # --- wall textures (composited, column-major) + name->index ------------
73
+ @wall_index = {}
74
+ @wall_names = []
75
+ map.sidedefs.each do |sd|
76
+ add_wall(sd.upper_texture); add_wall(sd.lower_texture); add_wall(sd.middle_texture)
77
+ end
78
+ @sky_tex = add_wall("SKY1")
79
+
80
+ @wpx = []
81
+ @wtab = []
82
+ @wall_names.each do |name|
83
+ comp = textures[name]
84
+ if comp.nil?
85
+ @wtab.push(@wpx.length, 1, 1); @wpx.push(-1); next
86
+ end
87
+ @wtab.push(@wpx.length, comp.width, comp.height)
88
+ tx = 0
89
+ while tx < comp.width
90
+ col = comp.column_pixels(tx)
91
+ ty = 0
92
+ while ty < comp.height
93
+ c = col[ty]
94
+ @wpx.push(c.nil? ? -1 : c) # -1 = transparent texel (skipped)
95
+ ty += 1
96
+ end
97
+ tx += 1
98
+ end
99
+ end
100
+
101
+ # --- flats (64x64 row-major) + name->index ----------------------------
102
+ @flat_index = {}
103
+ @fpx = []
104
+ idx = 0
105
+ flats.each do |fl|
106
+ @flat_index[fl.name] = idx
107
+ @fpx.concat(fl.pixels)
108
+ idx += 1
109
+ end
110
+
111
+ # --- colormap (flat 34*256) ------------------------------------------
112
+ @cmap = colormap.respond_to?(:maps) ? flatten_colormap(colormap.maps) : colormap
113
+
114
+ # --- geometry ---------------------------------------------------------
115
+ @nodes_f = []
116
+ map.nodes.each { |n| @nodes_f.push(n.x.to_f, n.y.to_f, n.dx.to_f, n.dy.to_f, n.child_right.to_f, n.child_left.to_f) }
117
+ @verts = []
118
+ map.vertices.each { |v| @verts.push(v.x, v.y) }
119
+ @segs = []
120
+ map.segs.each { |s| @segs.push(s.v1, s.v2, s.linedef, s.direction, s.offset) }
121
+ @subs = []
122
+ map.subsectors.each { |s| @subs.push(s.first_seg, s.seg_count) }
123
+ @lines = []
124
+ map.linedefs.each { |l| @lines.push(l.sidedef_right, l.sidedef_left, l.flags) }
125
+ @sides = []
126
+ map.sidedefs.each do |sd|
127
+ @sides.push(sd.sector, wall_idx(sd.upper_texture), wall_idx(sd.lower_texture),
128
+ wall_idx(sd.middle_texture), sd.x_offset, sd.y_offset)
129
+ end
130
+ @secs = []
131
+ map.sectors.each do |s|
132
+ @secs.push(s.floor_height, s.ceiling_height, s.light_level,
133
+ flat_idx(s.floor_texture), flat_idx(s.ceiling_texture),
134
+ s.ceiling_texture == "F_SKY1" ? 1 : 0,
135
+ s.floor_texture == "F_SKY1" ? 1 : 0)
136
+ end
137
+ @node_count = map.nodes.length
138
+ end
139
+
140
+ def flatten_colormap(maps)
141
+ return maps if maps.first.is_a?(Integer) # already flat
142
+
143
+ maps.flatten
144
+ end
145
+
146
+ def add_wall(name)
147
+ return -1 if name.nil? || name.empty? || name == "-"
148
+
149
+ key = name.upcase
150
+ @wall_index[key] ||= begin
151
+ @wall_names << key
152
+ @wall_names.length - 1
153
+ end
154
+ end
155
+ alias wall_idx add_wall
156
+
157
+ def flat_idx(name)
158
+ @flat_index[name] || @flat_index[name.to_s.upcase] || 0
159
+ end
160
+ end
161
+ end
162
+ end
@@ -28,11 +28,14 @@ module Doom
28
28
  # Small ammo counts (right side of status bar)
29
29
  SMALL_AMMO_X = 288 # Current ammo X
30
30
  SMALL_MAX_X = 314 # Max ammo X
31
- SMALL_AMMO_Y = [5, 11, 23, 17] # Bullets, Shells, Cells, Rockets (relative to bar)
31
+ SMALL_AMMO_Y = [5, 11, 23, 17] # Bullets, Shells, Cells, Rockets (relative to bar)
32
32
 
33
33
  NUM_WIDTH = 14 # Width of large digit
34
34
  SMALL_NUM_WIDTH = 4 # Width of small digit
35
35
 
36
+ attr_reader :gfx
37
+ attr_writer :player # Re-pointed on world rebind (map change, netgame resync)
38
+
36
39
  def initialize(hud_graphics, player_state)
37
40
  @gfx = hud_graphics
38
41
  @player = player_state
@@ -77,10 +80,10 @@ module Doom
77
80
  def update
78
81
  # Cycle face animation
79
82
  @face_timer += 1
80
- if @face_timer > 15 # Change face every ~0.5 seconds
81
- @face_timer = 0
82
- @face_index = (@face_index + 1) % 3
83
- end
83
+ return unless @face_timer > 15 # Change face every ~0.5 seconds
84
+
85
+ @face_timer = 0
86
+ @face_index = (@face_index + 1) % 3
84
87
  end
85
88
 
86
89
  private
@@ -101,7 +104,7 @@ module Doom
101
104
  draw_y = y + sy
102
105
  next if draw_y < 0 || draw_y >= SCREEN_HEIGHT
103
106
 
104
- framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
107
+ framebuffer[(draw_y * SCREEN_WIDTH) + draw_x] = color
105
108
  end
106
109
  end
107
110
  end
@@ -137,13 +140,13 @@ module Doom
137
140
  def draw_arms(framebuffer)
138
141
  # Weapon numbers 2-7 in a 3x2 grid
139
142
  6.times do |i|
140
- weapon_num = i + 2 # weapons 2-7
143
+ weapon_num = i + 2 # weapons 2-7
141
144
  owned = @player.has_weapons[weapon_num]
142
145
  digit = owned ? @gfx.yellow_numbers[weapon_num] : @gfx.grey_numbers[weapon_num]
143
146
  next unless digit
144
147
 
145
- x = ARMS_X + (i % 3) * ARMS_XSPACE
146
- y = STATUS_BAR_Y + ARMS_Y + (i / 3) * ARMS_YSPACE
148
+ x = ARMS_X + ((i % 3) * ARMS_XSPACE)
149
+ y = STATUS_BAR_Y + ARMS_Y + ((i / 3) * ARMS_YSPACE)
147
150
  draw_sprite(framebuffer, digit, x, y)
148
151
  end
149
152
  end
@@ -161,6 +164,7 @@ module Doom
161
164
  end
162
165
 
163
166
  return unless face
167
+
164
168
  draw_sprite(framebuffer, face, FACE_X, STATUS_BAR_Y + FACE_Y)
165
169
  end
166
170
 
@@ -177,6 +181,7 @@ module Doom
177
181
 
178
182
  def draw_small_number_right(framebuffer, value, right_x, y)
179
183
  return unless value
184
+
180
185
  str = value.to_i.to_s
181
186
  current_x = right_x
182
187
  str.reverse.each_char do |char|
@@ -190,7 +195,6 @@ module Doom
190
195
 
191
196
  def draw_keys(framebuffer)
192
197
  key_x = KEYS_X
193
- key_spacing = 10
194
198
 
195
199
  # Blue keys (top row)
196
200
  if @player.keys[:blue_card]
@@ -7,12 +7,11 @@ module Doom
7
7
  # Weapon is rendered above the status bar
8
8
  WEAPON_AREA_HEIGHT = SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT
9
9
 
10
- # Chocolate Doom R_DrawPSprite weapon positioning:
11
- # centery = viewheight/2 (view area excluding status bar)
12
- # texturemid = centery - (WEAPONTOP - spritetopoffset)
13
- # dc_yl = centery - texturemid (first visible row)
10
+ # Chocolate Doom R_DrawPSprite weapon top: dc_yl = WEAPONTOP - spritetopoffset
14
11
  WEAPONTOP = 32
15
- VIEW_CENTERY = (SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT) / 2 # 104
12
+
13
+ attr_reader :gfx
14
+ attr_writer :player # Re-pointed on world rebind (map change, netgame resync)
16
15
 
17
16
  def initialize(hud_graphics, player_state)
18
17
  @gfx = hud_graphics
@@ -47,9 +46,9 @@ module Doom
47
46
  draw_weapon_sprite(framebuffer, sprite, x, y)
48
47
 
49
48
  # Draw muzzle flash only on the first fire frame (the actual shot)
50
- if @player.attacking && @player.attack_frame == 0
51
- draw_muzzle_flash(framebuffer, weapon_name)
52
- end
49
+ return unless @player.attacking && @player.attack_frame == 0
50
+
51
+ draw_muzzle_flash(framebuffer, weapon_name)
53
52
  end
54
53
 
55
54
  private
@@ -68,12 +67,12 @@ module Doom
68
67
  next if draw_x < 0 || draw_x >= SCREEN_WIDTH
69
68
 
70
69
  column.each_with_index do |color, sy|
71
- next unless color # Skip transparent pixels
70
+ next unless color # Skip transparent pixels
72
71
 
73
72
  draw_y = base_y + sy
74
73
  next if draw_y < 0 || draw_y > max_y
75
74
 
76
- framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
75
+ framebuffer[(draw_y * SCREEN_WIDTH) + draw_x] = color
77
76
  end
78
77
  end
79
78
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Converts Doom's sector/subsector representation into ordinary triangles.
6
+ # Subsector polygons are convex by construction, so a fan is sufficient.
7
+ class WorldMesh
8
+ Triangle = Struct.new(:vertices, :uvs, :normal, :light, :material, :masked)
9
+
10
+ attr_reader :triangles
11
+
12
+ def initialize(map, textures = nil)
13
+ @map = map
14
+ @textures = textures
15
+ @triangles = []
16
+ build_planes
17
+ build_walls
18
+ end
19
+
20
+ private
21
+
22
+ def build_planes
23
+ bsp_leaf_polygons.each do |subsector_index, points|
24
+ subsector = @map.subsectors[subsector_index]
25
+ segs = @map.segs[subsector.first_seg, subsector.seg_count]
26
+ next if segs.nil? || segs.empty?
27
+
28
+ # BSP leaves partition the entire map bounding box, including solid
29
+ # void behind one-sided walls. Segs are directed with their front
30
+ # sector on the right; clipping by each one trims the leaf to the
31
+ # actual playable subsector surface.
32
+ points = clip_to_segs(points, segs)
33
+
34
+ sector = segs.lazy.map { |seg| sector_for_seg(seg) }.find(&:itself)
35
+ next unless sector
36
+
37
+ next if points.size < 3
38
+
39
+ (1...(points.size - 1)).each do |i|
40
+ add_triangle(points[0], points[i], points[i + 1], sector.floor_height,
41
+ [0.0, 0.0, 1.0], sector.light_level, sector.floor_texture)
42
+ unless sector.ceiling_texture == 'F_SKY1'
43
+ add_triangle(points[0], points[i + 1], points[i], sector.ceiling_height,
44
+ [0.0, 0.0, -1.0], sector.light_level, sector.ceiling_texture)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ def clip_to_segs(points, segs)
51
+ segs.reduce(points) do |polygon, seg|
52
+ break [] if polygon.size < 3
53
+
54
+ a = @map.vertices[seg.v1]
55
+ b = @map.vertices[seg.v2]
56
+ dx = b.x - a.x
57
+ dy = b.y - a.y
58
+ clip_by_values(polygon) { |point| ((point.x - a.x) * dy) - ((point.y - a.y) * dx) }
59
+ end
60
+ end
61
+
62
+ def clip_by_values(polygon)
63
+ output = []
64
+ polygon.each_with_index do |current, index|
65
+ previous = polygon[index - 1]
66
+ current_value = yield(current)
67
+ previous_value = yield(previous)
68
+ current_inside = current_value >= -1e-7
69
+ previous_inside = previous_value >= -1e-7
70
+ if current_inside != previous_inside
71
+ t = previous_value / (previous_value - current_value).to_f
72
+ output << Map::Vertex.new(
73
+ previous.x + ((current.x - previous.x) * t),
74
+ previous.y + ((current.y - previous.y) * t)
75
+ )
76
+ end
77
+ output << current if current_inside
78
+ end
79
+ output
80
+ end
81
+
82
+ # Reconstruct exact convex subsector cells by carrying the map bounding
83
+ # polygon through the BSP and clipping it at every partition. Seg
84
+ # endpoints alone do not contain the artificial BSP split edges.
85
+ def bsp_leaf_polygons
86
+ xs = @map.vertices.map(&:x)
87
+ ys = @map.vertices.map(&:y)
88
+ padding = 1.0
89
+ root_polygon = [
90
+ [xs.min - padding, ys.min - padding],
91
+ [xs.max + padding, ys.min - padding],
92
+ [xs.max + padding, ys.max + padding],
93
+ [xs.min - padding, ys.max + padding]
94
+ ]
95
+ leaves = {}
96
+ visit_bsp(@map.nodes.size - 1, root_polygon, leaves)
97
+ leaves
98
+ end
99
+
100
+ def visit_bsp(index, polygon, leaves)
101
+ return if polygon.size < 3
102
+
103
+ if (index & Map::Node::SUBSECTOR_FLAG) != 0
104
+ leaves[index & ~Map::Node::SUBSECTOR_FLAG] = polygon.map { |x, y| Map::Vertex.new(x, y) }
105
+ return
106
+ end
107
+
108
+ node = @map.nodes[index]
109
+ visit_bsp(node.child_right, clip_polygon(polygon, node, true), leaves)
110
+ visit_bsp(node.child_left, clip_polygon(polygon, node, false), leaves)
111
+ end
112
+
113
+ def clip_polygon(polygon, node, keep_right)
114
+ output = []
115
+ polygon.each_with_index do |current, index|
116
+ previous = polygon[index - 1]
117
+ current_side = partition_value(current, node)
118
+ previous_side = partition_value(previous, node)
119
+ current_inside = keep_right ? current_side >= -1e-7 : current_side <= 1e-7
120
+ previous_inside = keep_right ? previous_side >= -1e-7 : previous_side <= 1e-7
121
+
122
+ if current_inside != previous_inside
123
+ t = previous_side / (previous_side - current_side).to_f
124
+ output << [previous[0] + ((current[0] - previous[0]) * t),
125
+ previous[1] + ((current[1] - previous[1]) * t)]
126
+ end
127
+ output << current if current_inside
128
+ end
129
+ output
130
+ end
131
+
132
+ def partition_value(point, node)
133
+ ((point[0] - node.x) * node.dy) - ((point[1] - node.y) * node.dx)
134
+ end
135
+
136
+ def build_walls
137
+ @map.linedefs.each do |line|
138
+ right = sidedef(line.sidedef_right)
139
+ left = sidedef(line.sidedef_left)
140
+ next unless right || left
141
+
142
+ front = right && @map.sectors[right.sector]
143
+ back = left && @map.sectors[left.sector]
144
+ a = @map.vertices[line.v1]
145
+ b = @map.vertices[line.v2]
146
+
147
+ if front && back
148
+ # Doom's sky hack: adjacent sky ceilings are one continuous
149
+ # infinite sky even when their numeric heights differ. Emitting an
150
+ # upper wall here creates the conspicuous tower at outdoor edges.
151
+ both_sky = front.ceiling_texture == 'F_SKY1' && back.ceiling_texture == 'F_SKY1'
152
+ if !both_sky && front.ceiling_height > back.ceiling_height
153
+ v_top = right.y_offset
154
+ v_top += back.ceiling_height - front.ceiling_height unless line.upper_unpegged?
155
+ wall_quad(a, b, back.ceiling_height, front.ceiling_height,
156
+ front.light_level, right.upper_texture,
157
+ u_offset: right.x_offset, v_top: v_top)
158
+ elsif !both_sky && back.ceiling_height > front.ceiling_height
159
+ v_top = left.y_offset
160
+ v_top += front.ceiling_height - back.ceiling_height unless line.upper_unpegged?
161
+ wall_quad(a, b, front.ceiling_height, back.ceiling_height,
162
+ back.light_level, left.upper_texture, flip: true,
163
+ u_offset: left.x_offset, v_top: v_top)
164
+ end
165
+ if back.floor_height > front.floor_height
166
+ v_top = right.y_offset
167
+ v_top += front.ceiling_height - back.floor_height if line.lower_unpegged?
168
+ wall_quad(a, b, front.floor_height, back.floor_height,
169
+ front.light_level, right.lower_texture,
170
+ u_offset: right.x_offset, v_top: v_top)
171
+ elsif front.floor_height > back.floor_height
172
+ v_top = left.y_offset
173
+ v_top += back.ceiling_height - front.floor_height if line.lower_unpegged?
174
+ wall_quad(a, b, back.floor_height, front.floor_height,
175
+ back.light_level, left.lower_texture, flip: true,
176
+ u_offset: left.x_offset, v_top: v_top)
177
+ end
178
+ opening_bottom = [front.floor_height, back.floor_height].max
179
+ opening_top = [front.ceiling_height, back.ceiling_height].min
180
+ if middle_material?(right)
181
+ masked_middle(a, b, opening_bottom, opening_top, front, right, line)
182
+ elsif middle_material?(left)
183
+ masked_middle(a, b, opening_bottom, opening_top, back, left, line, flip: true)
184
+ end
185
+ else
186
+ sector = front || back
187
+ side = right || left
188
+ v_top = side.y_offset
189
+ v_top -= sector.ceiling_height - sector.floor_height if line.lower_unpegged?
190
+ wall_quad(a, b, sector.floor_height, sector.ceiling_height,
191
+ sector.light_level, side.middle_texture, flip: !right,
192
+ u_offset: side.x_offset, v_top: v_top)
193
+ end
194
+ end
195
+ end
196
+
197
+ def middle_material?(side)
198
+ side && !side.middle_texture.nil? && !side.middle_texture.empty? && side.middle_texture != '-'
199
+ end
200
+
201
+ def masked_middle(a, b, opening_bottom, opening_top, sector, side, line, flip: false)
202
+ material = side.middle_texture
203
+ return if material.nil? || material.empty? || material == '-'
204
+
205
+ texture = @textures&.[](material)
206
+ texture_height = texture&.height || (opening_top - opening_bottom)
207
+ texture_top = if line.lower_unpegged?
208
+ opening_bottom + texture_height + side.y_offset
209
+ else
210
+ opening_top + side.y_offset
211
+ end
212
+ bottom = [opening_bottom, texture_top - texture_height].max
213
+ top = [opening_top, texture_top].min
214
+ return unless top > bottom
215
+
216
+ wall_quad(a, b, bottom, top, sector.light_level, material, flip: flip,
217
+ u_offset: side.x_offset, v_top: texture_top - top, masked: true)
218
+ end
219
+
220
+ def wall_quad(a, b, bottom, top, light, material, flip: false, u_offset: 0, v_top: 0, masked: false)
221
+ return unless top > bottom
222
+
223
+ a, b = b, a if flip
224
+
225
+ dx = b.x - a.x
226
+ dy = b.y - a.y
227
+ length = Math.hypot(dx, dy)
228
+ return if length.zero?
229
+
230
+ normal = [dy / length, -dx / length, 0.0]
231
+ v0 = [a.x.to_f, a.y.to_f, bottom.to_f]
232
+ v1 = [b.x.to_f, b.y.to_f, bottom.to_f]
233
+ v2 = [b.x.to_f, b.y.to_f, top.to_f]
234
+ v3 = [a.x.to_f, a.y.to_f, top.to_f]
235
+ # UVs remain in Doom world/texel units. The GPU renderer normalizes
236
+ # them by the actual material dimensions (not every wall is 64 px).
237
+ u0 = u_offset.to_f
238
+ u1 = u0 + length
239
+ v0_tex = v_top.to_f + (top - bottom)
240
+ v1_tex = v_top.to_f
241
+ @triangles << Triangle.new([v0, v1, v2], [[u0, v0_tex], [u1, v0_tex], [u1, v1_tex]], normal, light, material,
242
+ masked)
243
+ @triangles << Triangle.new([v0, v2, v3], [[u0, v0_tex], [u1, v1_tex], [u0, v1_tex]], normal, light, material,
244
+ masked)
245
+ end
246
+
247
+ def add_triangle(a, b, c, z, normal, light, material)
248
+ area = ((b.x - a.x) * (c.y - a.y)) - ((b.y - a.y) * (c.x - a.x))
249
+ return if area.abs < 1e-6
250
+
251
+ vertices = [a, b, c].map { |point| [point.x.to_f, point.y.to_f, z.to_f] }
252
+ uvs = [a, b, c].map { |point| [point.x.to_f, -point.y.to_f] }
253
+ @triangles << Triangle.new(vertices, uvs, normal, light, material)
254
+ end
255
+
256
+ def sector_for_seg(seg)
257
+ line = @map.linedefs[seg.linedef]
258
+ index = seg.direction.zero? ? line.sidedef_right : line.sidedef_left
259
+ side = sidedef(index)
260
+ side && @map.sectors[side.sector]
261
+ end
262
+
263
+ def sidedef(index)
264
+ return nil unless index && index >= 0 && index < @map.sidedefs.size
265
+
266
+ @map.sidedefs[index]
267
+ end
268
+ end
269
+ end
270
+ end