doom 0.2.0 → 0.4.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.
@@ -0,0 +1,1272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ SCREEN_WIDTH = 320
6
+ SCREEN_HEIGHT = 240
7
+ HALF_WIDTH = SCREEN_WIDTH / 2
8
+ HALF_HEIGHT = SCREEN_HEIGHT / 2
9
+ FOV = 90.0
10
+
11
+ # Silhouette types for sprite clipping (matches Chocolate Doom r_defs.h)
12
+ SIL_NONE = 0
13
+ SIL_BOTTOM = 1
14
+ SIL_TOP = 2
15
+ SIL_BOTH = 3
16
+
17
+ # Drawseg stores wall segment info for sprite clipping
18
+ # Matches Chocolate Doom's drawseg_t structure
19
+ Drawseg = Struct.new(:x1, :x2, :scale1, :scale2,
20
+ :silhouette, :bsilheight, :tsilheight,
21
+ :sprtopclip, :sprbottomclip,
22
+ :curline) # seg for point-on-side test
23
+
24
+ # VisibleSprite stores sprite data for sorting and rendering
25
+ # Struct is faster than Hash for fixed-field data
26
+ VisibleSprite = Struct.new(:thing, :sprite, :view_x, :view_y, :dist, :screen_x)
27
+
28
+ # Visplane stores floor/ceiling rendering info for a sector
29
+ # Matches Chocolate Doom's visplane_t structure from r_plane.c
30
+ Visplane = Struct.new(:sector, :height, :texture, :light_level, :is_ceiling,
31
+ :top, :bottom, :minx, :maxx) do
32
+ def initialize(sector, height, texture, light_level, is_ceiling)
33
+ super(sector, height, texture, light_level, is_ceiling,
34
+ Array.new(SCREEN_WIDTH, SCREEN_HEIGHT), # top (initially invalid)
35
+ Array.new(SCREEN_WIDTH, -1), # bottom (initially invalid)
36
+ SCREEN_WIDTH, # minx (no columns marked yet)
37
+ -1) # maxx (no columns marked yet)
38
+ end
39
+
40
+ def mark(x, y1, y2)
41
+ return if y1 > y2
42
+ top[x] = [top[x], y1].min
43
+ bottom[x] = [bottom[x], y2].max
44
+ self.minx = [minx, x].min
45
+ self.maxx = [maxx, x].max
46
+ end
47
+
48
+ def valid?
49
+ minx <= maxx
50
+ end
51
+ end
52
+
53
+ class Renderer
54
+ attr_reader :framebuffer
55
+
56
+ def initialize(wad, map, textures, palette, colormap, flats, sprites = nil)
57
+ @wad = wad
58
+ @map = map
59
+ @textures = textures
60
+ @palette = palette
61
+ @colormap = colormap
62
+ @flats = flats.to_h { |f| [f.name, f] }
63
+ @sprites = sprites
64
+
65
+ @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
66
+
67
+ @player_x = 0.0
68
+ @player_y = 0.0
69
+ @player_z = 41.0
70
+ @player_angle = 0.0
71
+
72
+ # Projection constant - distance to projection plane
73
+ @projection = HALF_WIDTH / Math.tan(FOV * Math::PI / 360.0)
74
+
75
+ # Precomputed column data (cached based on player angle)
76
+ @column_cos = Array.new(SCREEN_WIDTH)
77
+ @column_sin = Array.new(SCREEN_WIDTH)
78
+ @cached_player_angle = nil
79
+
80
+ # Column distance scale is constant (doesn't depend on player angle)
81
+ @column_distscale = Array.new(SCREEN_WIDTH) do |x|
82
+ dx = x - HALF_WIDTH
83
+ Math.sqrt(dx * dx + @projection * @projection) / @projection
84
+ end
85
+
86
+ # Clipping arrays
87
+ @ceiling_clip = Array.new(SCREEN_WIDTH, -1)
88
+ @floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
89
+
90
+ # Sprite clip arrays (copy of wall clips for sprite clipping)
91
+ @sprite_ceiling_clip = Array.new(SCREEN_WIDTH, -1)
92
+ @sprite_floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
93
+
94
+ # Wall depth array - tracks distance to nearest wall at each column
95
+ @wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
96
+ @sprite_wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
97
+
98
+ # Preallocated y_slope arrays for floor/ceiling rendering (avoids per-frame allocation)
99
+ @y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
100
+ @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
101
+ end
102
+
103
+ attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle
104
+
105
+ def set_player(x, y, z, angle)
106
+ @player_x = x.to_f
107
+ @player_y = y.to_f
108
+ @player_z = z.to_f
109
+ @player_angle = angle * Math::PI / 180.0
110
+ end
111
+
112
+ def move_to(x, y)
113
+ @player_x = x.to_f
114
+ @player_y = y.to_f
115
+ end
116
+
117
+ def set_z(z)
118
+ @player_z = z.to_f
119
+ end
120
+
121
+ def turn(degrees)
122
+ @player_angle += degrees * Math::PI / 180.0
123
+ end
124
+
125
+ def render_frame
126
+ clear_framebuffer
127
+ reset_clipping
128
+
129
+ @sin_angle = Math.sin(@player_angle)
130
+ @cos_angle = Math.cos(@player_angle)
131
+
132
+ # Precompute column angles for floor/ceiling rendering
133
+ precompute_column_data
134
+
135
+ # Draw floor/ceiling background first (will be partially overwritten by walls)
136
+ draw_floor_ceiling_background
137
+
138
+ # Initialize visplanes for tracking visible floor/ceiling spans
139
+ @visplanes = []
140
+ @visplane_hash = {} # Hash for O(1) lookup by (height, texture, light_level, is_ceiling)
141
+
142
+ # Initialize drawsegs for sprite clipping
143
+ @drawsegs = []
144
+
145
+ # Render walls via BSP traversal
146
+ render_bsp_node(@map.nodes.size - 1)
147
+
148
+ # Draw visplanes for sectors different from background
149
+ draw_all_visplanes
150
+
151
+ # Save wall clip arrays for sprite clipping (reuse preallocated arrays)
152
+ @sprite_ceiling_clip.replace(@ceiling_clip)
153
+ @sprite_floor_clip.replace(@floor_clip)
154
+ @sprite_wall_depth.replace(@wall_depth)
155
+
156
+ # Render sprites
157
+ render_sprites if @sprites
158
+ end
159
+
160
+ # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
161
+ # Cached: only recomputes sin/cos when player angle changes
162
+ def precompute_column_data
163
+ return if @cached_player_angle == @player_angle
164
+
165
+ @cached_player_angle = @player_angle
166
+
167
+ SCREEN_WIDTH.times do |x|
168
+ column_angle = @player_angle - Math.atan2(x - HALF_WIDTH, @projection)
169
+ @column_cos[x] = Math.cos(column_angle)
170
+ @column_sin[x] = Math.sin(column_angle)
171
+ end
172
+ end
173
+
174
+ def draw_floor_ceiling_background
175
+ player_sector = @map.sector_at(@player_x, @player_y)
176
+ return unless player_sector
177
+
178
+ fill_uncovered_with_sector(player_sector)
179
+ end
180
+
181
+ # Render all visplanes after BSP traversal (R_DrawPlanes in Chocolate Doom)
182
+ def draw_all_visplanes
183
+ @visplanes.each do |plane|
184
+ next unless plane.valid?
185
+
186
+ if plane.texture == 'F_SKY1'
187
+ draw_sky_plane(plane)
188
+ else
189
+ render_visplane_spans(plane)
190
+ end
191
+ end
192
+ end
193
+
194
+ # Render visplane using horizontal spans (R_MakeSpans in Chocolate Doom)
195
+ # This processes columns left-to-right, building spans and rendering them
196
+ def render_visplane_spans(plane)
197
+ return if plane.minx > plane.maxx
198
+
199
+ spanstart = Array.new(SCREEN_HEIGHT) # Track where each row's span started
200
+
201
+ # Process columns left to right
202
+ ((plane.minx)..(plane.maxx + 1)).each do |x|
203
+ # Get current column bounds
204
+ if x <= plane.maxx
205
+ t2 = plane.top[x]
206
+ b2 = plane.bottom[x]
207
+ t2 = SCREEN_HEIGHT if t2 > b2 # Invalid = empty
208
+ else
209
+ t2, b2 = SCREEN_HEIGHT, -1 # Sentinel for final column
210
+ end
211
+
212
+ # Get previous column bounds
213
+ if x > plane.minx
214
+ t1 = plane.top[x - 1]
215
+ b1 = plane.bottom[x - 1]
216
+ t1 = SCREEN_HEIGHT if t1 > b1
217
+ else
218
+ t1, b1 = SCREEN_HEIGHT, -1
219
+ end
220
+
221
+ # Close spans that ended (visible in prev column, not in current)
222
+ if t1 < SCREEN_HEIGHT
223
+ # Rows visible in previous but not current (above current or below current)
224
+ (t1..[b1, t2 - 1].min).each do |y|
225
+ draw_span(plane, y, spanstart[y], x - 1) if spanstart[y]
226
+ spanstart[y] = nil
227
+ end
228
+ ([t1, b2 + 1].max..b1).each do |y|
229
+ draw_span(plane, y, spanstart[y], x - 1) if spanstart[y]
230
+ spanstart[y] = nil
231
+ end
232
+ end
233
+
234
+ # Open new spans (visible in current, not started yet)
235
+ if t2 < SCREEN_HEIGHT
236
+ (t2..b2).each do |y|
237
+ spanstart[y] ||= x
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # Render one horizontal span with texture mapping (R_MapPlane in Chocolate Doom)
244
+ def draw_span(plane, y, x1, x2)
245
+ return if x1.nil? || x1 > x2 || y < 0 || y >= SCREEN_HEIGHT
246
+
247
+ flat = @flats[plane.texture]
248
+ return unless flat
249
+
250
+ # Distance from horizon (y=100 for 200-high screen)
251
+ dy = y - HALF_HEIGHT
252
+ return if dy == 0
253
+
254
+ # Plane height relative to player eye level
255
+ plane_height = (plane.height - @player_z).abs
256
+ return if plane_height == 0
257
+
258
+ # Perpendicular distance to this row: distance = height * projection / dy
259
+ perp_dist = plane_height * @projection / dy.abs
260
+
261
+ # Calculate lighting for this distance
262
+ light = calculate_flat_light(plane.light_level, perp_dist)
263
+ cmap = @colormap.maps[light]
264
+
265
+ # Cache locals for inner loop
266
+ framebuffer = @framebuffer
267
+ column_distscale = @column_distscale
268
+ column_cos = @column_cos
269
+ column_sin = @column_sin
270
+ player_x = @player_x
271
+ neg_player_y = -@player_y
272
+ row_offset = y * SCREEN_WIDTH
273
+
274
+ # Clamp to screen bounds
275
+ x1 = 0 if x1 < 0
276
+ x2 = SCREEN_WIDTH - 1 if x2 >= SCREEN_WIDTH
277
+
278
+ # Draw each pixel in the span using while loop
279
+ x = x1
280
+ while x <= x2
281
+ ray_dist = perp_dist * column_distscale[x]
282
+ tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
283
+ tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
284
+ color = flat[tex_x, tex_y]
285
+ framebuffer[row_offset + x] = cmap[color]
286
+ x += 1
287
+ end
288
+ end
289
+
290
+ # Render sky ceiling as columns (column-based like walls, not spans)
291
+ def draw_sky_plane(plane)
292
+ sky_texture = @textures['SKY1']
293
+ return unless sky_texture
294
+
295
+ framebuffer = @framebuffer
296
+ player_angle = @player_angle
297
+ projection = @projection
298
+ sky_width_mask = sky_texture.width - 1 # Power-of-2 textures: & is faster than %
299
+ sky_height_mask = sky_texture.height - 1
300
+
301
+ # Clamp to screen bounds
302
+ minx = [plane.minx, 0].max
303
+ maxx = [plane.maxx, SCREEN_WIDTH - 1].min
304
+
305
+ (minx..maxx).each do |x|
306
+ y1 = plane.top[x]
307
+ y2 = plane.bottom[x]
308
+ next if y1 > y2
309
+
310
+ # Clamp y bounds
311
+ y1 = 0 if y1 < 0
312
+ y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
313
+
314
+ # Sky X based on view angle (wraps around 256 degrees)
315
+ column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
316
+ sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
317
+ column = sky_texture.column_pixels(sky_x)
318
+ next unless column
319
+
320
+ (y1..y2).each do |y|
321
+ color = column[y & sky_height_mask]
322
+ framebuffer[y * SCREEN_WIDTH + x] = color
323
+ end
324
+ end
325
+ end
326
+
327
+ def find_or_create_visplane(sector, height, texture, light_level, is_ceiling)
328
+ # O(1) hash lookup instead of O(n) linear search
329
+ key = [height, texture, light_level, is_ceiling]
330
+ plane = @visplane_hash[key]
331
+
332
+ unless plane
333
+ plane = Visplane.new(sector, height, texture, light_level, is_ceiling)
334
+ @visplanes << plane
335
+ @visplane_hash[key] = plane
336
+ end
337
+
338
+ plane
339
+ end
340
+
341
+ # R_CheckPlane equivalent - check if columns in range are already marked
342
+ # If overlap exists, create a new visplane; otherwise update minx/maxx
343
+ def check_plane(plane, start_x, stop_x)
344
+ return plane unless plane
345
+
346
+ # Calculate intersection and union of column ranges
347
+ if start_x < plane.minx
348
+ intrl = plane.minx
349
+ unionl = start_x
350
+ else
351
+ unionl = plane.minx
352
+ intrl = start_x
353
+ end
354
+
355
+ if stop_x > plane.maxx
356
+ intrh = plane.maxx
357
+ unionh = stop_x
358
+ else
359
+ unionh = plane.maxx
360
+ intrh = stop_x
361
+ end
362
+
363
+ # Check if any column in intersection range is already marked
364
+ # A column is marked if top[x] <= bottom[x] (valid range)
365
+ overlap = false
366
+ (intrl..intrh).each do |x|
367
+ next if x < 0 || x >= SCREEN_WIDTH
368
+ if plane.top[x] <= plane.bottom[x]
369
+ overlap = true
370
+ break
371
+ end
372
+ end
373
+
374
+ if !overlap
375
+ # No overlap - reuse same visplane with expanded range
376
+ plane.minx = unionl if unionl < plane.minx
377
+ plane.maxx = unionh if unionh > plane.maxx
378
+ return plane
379
+ end
380
+
381
+ # Overlap detected - create new visplane with same properties
382
+ new_plane = Visplane.new(
383
+ plane.sector,
384
+ plane.height,
385
+ plane.texture,
386
+ plane.light_level,
387
+ plane.is_ceiling
388
+ )
389
+ new_plane.minx = start_x
390
+ new_plane.maxx = stop_x
391
+ @visplanes << new_plane
392
+
393
+ # Update hash to point to the new plane (for subsequent lookups)
394
+ key = [plane.height, plane.texture, plane.light_level, plane.is_ceiling]
395
+ @visplane_hash[key] = new_plane
396
+
397
+ new_plane
398
+ end
399
+
400
+ def fill_uncovered_with_sector(default_sector)
401
+ # Cache all instance variables as locals for faster access
402
+ framebuffer = @framebuffer
403
+ column_cos = @column_cos
404
+ column_sin = @column_sin
405
+ column_distscale = @column_distscale
406
+ projection = @projection
407
+ player_angle = @player_angle
408
+ player_x = @player_x
409
+ neg_player_y = -@player_y
410
+
411
+ ceil_height = (default_sector.ceiling_height - @player_z).abs
412
+ floor_height = (default_sector.floor_height - @player_z).abs
413
+ ceil_flat = @flats[default_sector.ceiling_texture]
414
+ floor_flat = @flats[default_sector.floor_texture]
415
+ is_sky = default_sector.ceiling_texture == 'F_SKY1'
416
+ sky_texture = is_sky ? @textures['SKY1'] : nil
417
+ light_level = default_sector.light_level
418
+ colormap_maps = @colormap.maps
419
+
420
+ # Compute y_slope for each row (perpendicular distance) - reuse preallocated arrays
421
+ y_slope_ceil = @y_slope_ceil
422
+ y_slope_floor = @y_slope_floor
423
+ (1..HALF_HEIGHT).each do |dy|
424
+ y_slope_ceil[dy] = ceil_height * projection / dy.to_f
425
+ y_slope_floor[dy] = floor_height * projection / dy.to_f
426
+ end
427
+
428
+ # Draw ceiling (rows 0 to HALF_HEIGHT-1) using while loops for speed
429
+ y = 0
430
+ while y < HALF_HEIGHT
431
+ dy = HALF_HEIGHT - y
432
+ if dy > 0
433
+ perp_dist = y_slope_ceil[dy]
434
+ if perp_dist > 0
435
+ light = calculate_flat_light(light_level, perp_dist)
436
+ cmap = colormap_maps[light]
437
+ row_offset = y * SCREEN_WIDTH
438
+
439
+ if is_sky && sky_texture
440
+ sky_height_mask = sky_texture.height - 1
441
+ sky_width_mask = sky_texture.width - 1
442
+ sky_y = y & sky_height_mask
443
+ x = 0
444
+ while x < SCREEN_WIDTH
445
+ column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
446
+ sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
447
+ color = sky_texture.column_pixels(sky_x)[sky_y]
448
+ framebuffer[row_offset + x] = color
449
+ x += 1
450
+ end
451
+ elsif ceil_flat
452
+ x = 0
453
+ while x < SCREEN_WIDTH
454
+ ray_dist = perp_dist * column_distscale[x]
455
+ tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
456
+ tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
457
+ color = ceil_flat[tex_x, tex_y]
458
+ framebuffer[row_offset + x] = cmap[color]
459
+ x += 1
460
+ end
461
+ end
462
+ end
463
+ end
464
+ y += 1
465
+ end
466
+
467
+ # Draw floor (rows HALF_HEIGHT to SCREEN_HEIGHT-1)
468
+ y = HALF_HEIGHT
469
+ while y < SCREEN_HEIGHT
470
+ dy = y - HALF_HEIGHT
471
+ if dy > 0
472
+ perp_dist = y_slope_floor[dy]
473
+ if perp_dist > 0
474
+ light = calculate_flat_light(light_level, perp_dist)
475
+ cmap = colormap_maps[light]
476
+ row_offset = y * SCREEN_WIDTH
477
+
478
+ if floor_flat
479
+ x = 0
480
+ while x < SCREEN_WIDTH
481
+ ray_dist = perp_dist * column_distscale[x]
482
+ tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
483
+ tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
484
+ color = floor_flat[tex_x, tex_y]
485
+ framebuffer[row_offset + x] = cmap[color]
486
+ x += 1
487
+ end
488
+ end
489
+ end
490
+ end
491
+ y += 1
492
+ end
493
+ end
494
+
495
+ private
496
+
497
+ def clear_framebuffer
498
+ @framebuffer.fill(0)
499
+ end
500
+
501
+ def reset_clipping
502
+ @ceiling_clip.fill(-1)
503
+ @floor_clip.fill(SCREEN_HEIGHT)
504
+ @wall_depth.fill(Float::INFINITY)
505
+ end
506
+
507
+ def render_bsp_node(node_index)
508
+ if node_index & Map::Node::SUBSECTOR_FLAG != 0
509
+ render_subsector(node_index & ~Map::Node::SUBSECTOR_FLAG)
510
+ return
511
+ end
512
+
513
+ node = @map.nodes[node_index]
514
+ side = point_on_side(@player_x, @player_y, node)
515
+
516
+ if side == 0
517
+ render_bsp_node(node.child_right)
518
+ render_bsp_node(node.child_left)
519
+ else
520
+ render_bsp_node(node.child_left)
521
+ render_bsp_node(node.child_right)
522
+ end
523
+ end
524
+
525
+ def point_on_side(x, y, node)
526
+ dx = x - node.x
527
+ dy = y - node.y
528
+ left = dy * node.dx
529
+ right = dx * node.dy
530
+ right >= left ? 0 : 1
531
+ end
532
+
533
+ def render_subsector(index)
534
+ subsector = @map.subsectors[index]
535
+ return unless subsector
536
+
537
+ # Get the sector for this subsector (from first seg's linedef)
538
+ first_seg = @map.segs[subsector.first_seg]
539
+ linedef = @map.linedefs[first_seg.linedef]
540
+ sidedef_idx = first_seg.direction == 0 ? linedef.sidedef_right : linedef.sidedef_left
541
+ return if sidedef_idx < 0
542
+
543
+ sidedef = @map.sidedefs[sidedef_idx]
544
+ @current_sector = @map.sectors[sidedef.sector]
545
+
546
+ # Create floor visplane if floor is visible (below eye level)
547
+ # Matches Chocolate Doom: if (frontsector->floorheight < viewz)
548
+ if @current_sector.floor_height < @player_z
549
+ @current_floor_plane = find_or_create_visplane(
550
+ @current_sector,
551
+ @current_sector.floor_height,
552
+ @current_sector.floor_texture,
553
+ @current_sector.light_level,
554
+ false
555
+ )
556
+ else
557
+ @current_floor_plane = nil
558
+ end
559
+
560
+ # Create ceiling visplane if ceiling is visible (above eye level or sky)
561
+ # Matches Chocolate Doom: if (frontsector->ceilingheight > viewz || frontsector->ceilingpic == skyflatnum)
562
+ is_sky = @current_sector.ceiling_texture == 'F_SKY1'
563
+ if @current_sector.ceiling_height > @player_z || is_sky
564
+ @current_ceiling_plane = find_or_create_visplane(
565
+ @current_sector,
566
+ @current_sector.ceiling_height,
567
+ @current_sector.ceiling_texture,
568
+ @current_sector.light_level,
569
+ true
570
+ )
571
+ else
572
+ @current_ceiling_plane = nil
573
+ end
574
+
575
+ # Process all segs in this subsector
576
+ subsector.seg_count.times do |i|
577
+ seg = @map.segs[subsector.first_seg + i]
578
+ render_seg(seg)
579
+ end
580
+ end
581
+
582
+ def render_seg(seg)
583
+ v1 = @map.vertices[seg.v1]
584
+ v2 = @map.vertices[seg.v2]
585
+
586
+ # Transform vertices to view space
587
+ # View space: +Y is forward, +X is right
588
+ x1, y1 = transform_point(v1.x, v1.y)
589
+ x2, y2 = transform_point(v2.x, v2.y)
590
+
591
+ # Both behind player?
592
+ return if y1 <= 0 && y2 <= 0
593
+
594
+ # Clip to near plane
595
+ near = 1.0
596
+ if y1 < near || y2 < near
597
+ if y1 < near && y2 < near
598
+ return
599
+ elsif y1 < near
600
+ t = (near - y1) / (y2 - y1)
601
+ x1 = x1 + t * (x2 - x1)
602
+ y1 = near
603
+ elsif y2 < near
604
+ t = (near - y1) / (y2 - y1)
605
+ x2 = x1 + t * (x2 - x1)
606
+ y2 = near
607
+ end
608
+ end
609
+
610
+ # Project to screen X using Doom's tangent-based approach
611
+ # screenX = centerX + viewX * projection / viewY
612
+ # This is equivalent to Doom's: centerX - tan(angle) * focalLength
613
+ # because tan(angle) = -viewX / viewY in our coordinate convention
614
+ sx1 = HALF_WIDTH + (x1 * @projection / y1)
615
+ sx2 = HALF_WIDTH + (x2 * @projection / y2)
616
+
617
+ # Backface: seg spanning from right to left means we see the back
618
+ return if sx1 >= sx2
619
+
620
+ # Off screen?
621
+ return if sx2 < 0 || sx1 >= SCREEN_WIDTH
622
+
623
+ # Clamp to screen
624
+ x1i = [sx1.to_i, 0].max
625
+ x2i = [sx2.to_i, SCREEN_WIDTH - 1].min
626
+ return if x1i > x2i
627
+
628
+ # Get sector info
629
+ linedef = @map.linedefs[seg.linedef]
630
+ sidedef_idx = seg.direction == 0 ? linedef.sidedef_right : linedef.sidedef_left
631
+ return if sidedef_idx < 0
632
+
633
+ sidedef = @map.sidedefs[sidedef_idx]
634
+ sector = @map.sectors[sidedef.sector]
635
+
636
+ # Back sector for two-sided lines
637
+ back_sector = nil
638
+ if linedef.two_sided?
639
+ back_sidedef_idx = seg.direction == 0 ? linedef.sidedef_left : linedef.sidedef_right
640
+ if back_sidedef_idx >= 0
641
+ back_sidedef = @map.sidedefs[back_sidedef_idx]
642
+ back_sector = @map.sectors[back_sidedef.sector]
643
+ end
644
+ end
645
+
646
+ # Calculate seg length for texture mapping
647
+ seg_v1 = @map.vertices[seg.v1]
648
+ seg_v2 = @map.vertices[seg.v2]
649
+ seg_length = Math.sqrt((seg_v2.x - seg_v1.x)**2 + (seg_v2.y - seg_v1.y)**2)
650
+
651
+ draw_seg_range(x1i, x2i, sx1, sx2, y1, y2, sector, back_sector, sidedef, linedef, seg, seg_length)
652
+ end
653
+
654
+ def transform_point(wx, wy)
655
+ # Translate
656
+ dx = wx - @player_x
657
+ dy = wy - @player_y
658
+
659
+ # Rotate - transform world to view space
660
+ # View space: +Y forward (in direction of angle), +X is right
661
+ x = dx * @sin_angle - dy * @cos_angle
662
+ y = dx * @cos_angle + dy * @sin_angle
663
+
664
+ [x, y]
665
+ end
666
+
667
+ # Determine which side of a seg a point is on (R_PointOnSegSide from Chocolate Doom)
668
+ # Returns true if point is on back side, false if on front side
669
+ def point_on_seg_side(x, y, seg)
670
+ v1 = @map.vertices[seg.v1]
671
+ v2 = @map.vertices[seg.v2]
672
+
673
+ lx = v1.x
674
+ ly = v1.y
675
+ ldx = v2.x - lx
676
+ ldy = v2.y - ly
677
+
678
+ # Handle axis-aligned lines
679
+ if ldx == 0
680
+ return x <= lx ? ldy > 0 : ldy < 0
681
+ end
682
+ if ldy == 0
683
+ return y <= ly ? ldx < 0 : ldx > 0
684
+ end
685
+
686
+ dx = x - lx
687
+ dy = y - ly
688
+
689
+ # Cross product to determine side
690
+ left = ldy * dx
691
+ right = dy * ldx
692
+
693
+ right >= left
694
+ end
695
+
696
+ def draw_seg_range(x1, x2, sx1, sx2, dist1, dist2, sector, back_sector, sidedef, linedef, seg, seg_length)
697
+ # Get seg vertices in world space for texture coordinate calculation
698
+ seg_v1 = @map.vertices[seg.v1]
699
+ seg_v2 = @map.vertices[seg.v2]
700
+
701
+ # Calculate scales for drawseg (scale = projection / distance)
702
+ scale1 = dist1 > 0 ? @projection / dist1 : Float::INFINITY
703
+ scale2 = dist2 > 0 ? @projection / dist2 : Float::INFINITY
704
+
705
+ # Determine silhouette type for sprite clipping
706
+ silhouette = SIL_NONE
707
+ bsilheight = 0 # bottom silhouette world height
708
+ tsilheight = 0 # top silhouette world height
709
+
710
+ if back_sector
711
+ # Two-sided line - check for silhouettes
712
+ if sector.floor_height > back_sector.floor_height
713
+ silhouette |= SIL_BOTTOM
714
+ bsilheight = sector.floor_height
715
+ elsif back_sector.floor_height > @player_z
716
+ silhouette |= SIL_BOTTOM
717
+ bsilheight = Float::INFINITY
718
+ end
719
+
720
+ if sector.ceiling_height < back_sector.ceiling_height
721
+ silhouette |= SIL_TOP
722
+ tsilheight = sector.ceiling_height
723
+ elsif back_sector.ceiling_height < @player_z
724
+ silhouette |= SIL_TOP
725
+ tsilheight = -Float::INFINITY
726
+ end
727
+ else
728
+ # Solid wall - full silhouette
729
+ silhouette = SIL_BOTH
730
+ bsilheight = Float::INFINITY
731
+ tsilheight = -Float::INFINITY
732
+ end
733
+
734
+ # Check planes for this seg range (R_CheckPlane equivalent)
735
+ # This may create new visplanes if column ranges would overlap
736
+ if @current_floor_plane
737
+ @current_floor_plane = check_plane(@current_floor_plane, x1, x2)
738
+ end
739
+ if @current_ceiling_plane
740
+ @current_ceiling_plane = check_plane(@current_ceiling_plane, x1, x2)
741
+ end
742
+
743
+ (x1..x2).each do |x|
744
+ next if @ceiling_clip[x] >= @floor_clip[x] - 1
745
+
746
+ # Screen-space interpolation factor for distance only
747
+ t = sx2 != sx1 ? (x - sx1) / (sx2 - sx1) : 0
748
+ t = t.clamp(0.0, 1.0)
749
+
750
+ # Perspective-correct interpolation for distance
751
+ if dist1 > 0 && dist2 > 0
752
+ inv_dist = (1.0 - t) / dist1 + t / dist2
753
+ dist = 1.0 / inv_dist
754
+ else
755
+ dist = dist1 > 0 ? dist1 : dist2
756
+ end
757
+
758
+ # Texture column using perspective-correct interpolation
759
+ # tex_col_1 is texture coordinate at seg v1, tex_col_2 at v2
760
+ tex_col_1 = seg.offset + sidedef.x_offset
761
+ tex_col_2 = seg.offset + seg_length + sidedef.x_offset
762
+
763
+ # Perspective-correct interpolation: interpolate tex/z, then multiply by z
764
+ if dist1 > 0 && dist2 > 0
765
+ tex_col = ((tex_col_1 / dist1) * (1.0 - t) + (tex_col_2 / dist2) * t) / inv_dist
766
+ else
767
+ tex_col = tex_col_1 * (1.0 - t) + tex_col_2 * t
768
+ end
769
+ tex_col = tex_col.to_i
770
+
771
+ # Skip if too close
772
+ next if dist < 1
773
+
774
+ # Scale factor for this column
775
+ scale = @projection / dist
776
+
777
+ # World heights relative to player eye level
778
+ front_floor = sector.floor_height - @player_z
779
+ front_ceil = sector.ceiling_height - @player_z
780
+
781
+ # Project to screen Y (Y increases downward on screen)
782
+ front_ceil_y = (HALF_HEIGHT - front_ceil * scale).to_i
783
+ front_floor_y = (HALF_HEIGHT - front_floor * scale).to_i
784
+
785
+ # Clamp to current clip bounds
786
+ ceil_y = [front_ceil_y, @ceiling_clip[x] + 1].max
787
+ floor_y = [front_floor_y, @floor_clip[x] - 1].min
788
+
789
+ if back_sector
790
+ # Two-sided line
791
+ back_floor = back_sector.floor_height - @player_z
792
+ back_ceil = back_sector.ceiling_height - @player_z
793
+
794
+ back_ceil_y = (HALF_HEIGHT - back_ceil * scale).to_i
795
+ back_floor_y = (HALF_HEIGHT - back_floor * scale).to_i
796
+
797
+ # Determine visible ceiling/floor boundaries (the opening between sectors)
798
+ # high_ceil = top of the opening on screen (max Y = lower world ceiling)
799
+ # low_floor = bottom of the opening on screen (min Y = higher world floor)
800
+ high_ceil = [ceil_y, back_ceil_y].max
801
+ low_floor = [floor_y, back_floor_y].min
802
+
803
+ # Check for closed door (no opening between sectors)
804
+ # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
805
+ # || backsector->floorheight >= frontsector->ceilingheight
806
+ closed_door = back_sector.ceiling_height <= sector.floor_height ||
807
+ back_sector.floor_height >= sector.ceiling_height
808
+
809
+ # Mark ceiling visplane - mark front sector's ceiling for two-sided lines
810
+ # Matches Chocolate Doom: mark from ceilingclip+1 to yl-1 (front ceiling)
811
+ # (yl is clamped to ceilingclip+1, so we use ceil_y which is already clamped)
812
+ should_mark_ceiling = sector.ceiling_height != back_sector.ceiling_height ||
813
+ sector.ceiling_texture != back_sector.ceiling_texture ||
814
+ sector.light_level != back_sector.light_level
815
+ if @current_ceiling_plane && should_mark_ceiling
816
+ mark_top = @ceiling_clip[x] + 1
817
+ mark_bottom = ceil_y - 1
818
+ mark_bottom = [@floor_clip[x] - 1, mark_bottom].min
819
+ if mark_top <= mark_bottom
820
+ @current_ceiling_plane.mark(x, mark_top, mark_bottom)
821
+ end
822
+ end
823
+
824
+ # Mark floor visplane - mark front sector's floor for two-sided lines
825
+ # Matches Chocolate Doom: mark from yh+1 (front floor) to floorclip-1
826
+ # (yh is clamped to floorclip-1, so we use floor_y which is already clamped)
827
+ should_mark_floor = sector.floor_height != back_sector.floor_height ||
828
+ sector.floor_texture != back_sector.floor_texture ||
829
+ sector.light_level != back_sector.light_level
830
+ if @current_floor_plane && should_mark_floor
831
+ mark_top = floor_y + 1
832
+ mark_bottom = @floor_clip[x] - 1
833
+ mark_top = [@ceiling_clip[x] + 1, mark_top].max
834
+ if mark_top <= mark_bottom
835
+ @current_floor_plane.mark(x, mark_top, mark_bottom)
836
+ end
837
+ end
838
+
839
+ # Upper wall (ceiling step down)
840
+ if sector.ceiling_height > back_sector.ceiling_height
841
+ # Upper texture Y offset depends on DONTPEGTOP flag
842
+ # With DONTPEGTOP: texture top aligns with front ceiling
843
+ # Without: texture bottom aligns with back ceiling (for doors opening)
844
+ if linedef.upper_unpegged?
845
+ upper_tex_y = sidedef.y_offset
846
+ else
847
+ texture = @textures[sidedef.upper_texture]
848
+ tex_height = texture ? texture.height : 128
849
+ upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
850
+ end
851
+ draw_wall_column_ex(x, ceil_y, back_ceil_y - 1, sidedef.upper_texture, dist,
852
+ sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height, back_sector.ceiling_height)
853
+ # Note: Upper walls don't fully occlude - sprites can be visible through openings
854
+ end
855
+
856
+ # Lower wall (floor step up)
857
+ if sector.floor_height < back_sector.floor_height
858
+ # Lower texture Y offset depends on DONTPEGBOTTOM flag
859
+ # With DONTPEGBOTTOM: texture bottom aligns with lower floor
860
+ # Without: texture top aligns with higher floor
861
+ if linedef.lower_unpegged?
862
+ lower_tex_y = sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
863
+ else
864
+ lower_tex_y = sidedef.y_offset
865
+ end
866
+ draw_wall_column_ex(x, back_floor_y + 1, floor_y, sidedef.lower_texture, dist,
867
+ sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
868
+ # Note: Lower walls don't fully occlude - sprites can be visible through openings
869
+ end
870
+
871
+ # Update clip bounds after marking
872
+ if closed_door
873
+ # Closed door - fully occlude this column
874
+ @wall_depth[x] = [@wall_depth[x], dist].min
875
+ @ceiling_clip[x] = SCREEN_HEIGHT
876
+ @floor_clip[x] = -1
877
+ else
878
+ # Ceiling clip increases (moves down) as ceiling is marked
879
+ if sector.ceiling_height > back_sector.ceiling_height
880
+ # Upper wall drawn - clip ceiling to back ceiling
881
+ @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
882
+ elsif sector.ceiling_height < back_sector.ceiling_height
883
+ # Ceiling step up - clip to front ceiling
884
+ @ceiling_clip[x] = [ceil_y, @ceiling_clip[x]].max
885
+ elsif should_mark_ceiling
886
+ # Same height but different texture/light - still update clip
887
+ # Matches Chocolate Doom: if (markceiling) ceilingclip[rw_x] = yl-1;
888
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
889
+ end
890
+
891
+ # Floor clip decreases (moves up) as floor is marked
892
+ if sector.floor_height < back_sector.floor_height
893
+ # Lower wall drawn - clip floor to back floor
894
+ @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
895
+ elsif sector.floor_height > back_sector.floor_height
896
+ # Floor step down - clip to front floor to allow back sector to mark later
897
+ @floor_clip[x] = [floor_y, @floor_clip[x]].min
898
+ elsif should_mark_floor
899
+ # Same height but different texture/light - still update clip
900
+ # Matches Chocolate Doom: if (markfloor) floorclip[rw_x] = yh+1;
901
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
902
+ end
903
+ end
904
+ else
905
+ # One-sided (solid) wall
906
+ # Mark ceiling visplane (from previous clip to wall's ceiling)
907
+ if @current_ceiling_plane && @ceiling_clip[x] + 1 <= ceil_y - 1
908
+ @current_ceiling_plane.mark(x, @ceiling_clip[x] + 1, ceil_y - 1)
909
+ end
910
+
911
+ # Mark floor visplane (from wall's floor to previous floor clip)
912
+ # Floor is visible BELOW the wall (from floor_y+1 to floor_clip-1)
913
+ if @current_floor_plane && floor_y + 1 <= @floor_clip[x] - 1
914
+ @current_floor_plane.mark(x, floor_y + 1, @floor_clip[x] - 1)
915
+ end
916
+
917
+ # Draw wall (from clipped ceiling to clipped floor)
918
+ # Middle texture Y offset depends on DONTPEGBOTTOM flag
919
+ # With DONTPEGBOTTOM: texture bottom aligns with floor
920
+ # Without: texture top aligns with ceiling
921
+ if linedef.lower_unpegged?
922
+ texture = @textures[sidedef.middle_texture]
923
+ tex_height = texture ? texture.height : 128
924
+ mid_tex_y = sidedef.y_offset + tex_height - (sector.ceiling_height - sector.floor_height)
925
+ else
926
+ mid_tex_y = sidedef.y_offset
927
+ end
928
+ draw_wall_column_ex(x, ceil_y, floor_y, sidedef.middle_texture, dist,
929
+ sector.light_level, tex_col, mid_tex_y, scale, sector.ceiling_height, sector.floor_height)
930
+
931
+ # Track wall depth for sprite clipping (solid wall occludes this column)
932
+ @wall_depth[x] = [@wall_depth[x], dist].min
933
+
934
+ # Fully occluded
935
+ @ceiling_clip[x] = SCREEN_HEIGHT
936
+ @floor_clip[x] = -1
937
+ end
938
+ end
939
+
940
+ # Save drawseg for sprite clipping (after columns are rendered)
941
+ # Copy the clip arrays for the segment's screen range
942
+ if silhouette != SIL_NONE
943
+ sprtopclip = @ceiling_clip[x1..x2].dup
944
+ sprbottomclip = @floor_clip[x1..x2].dup
945
+
946
+ drawseg = Drawseg.new(
947
+ x1, x2,
948
+ scale1, scale2,
949
+ silhouette,
950
+ bsilheight, tsilheight,
951
+ sprtopclip, sprbottomclip,
952
+ seg
953
+ )
954
+ @drawsegs << drawseg
955
+ end
956
+ end
957
+
958
+ # Wall column drawing with proper texture mapping
959
+ # tex_col: texture column (X coordinate in texture)
960
+ # tex_y_start: starting Y coordinate in texture (accounts for pegging)
961
+ # scale: projection scale for this column (projection / distance)
962
+ # world_top, world_bottom: world heights of this wall section
963
+ def draw_wall_column_ex(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top, world_bottom)
964
+ return if y1 > y2
965
+ return if texture_name.nil? || texture_name.empty? || texture_name == '-'
966
+
967
+ # Clip to visible range (ceiling_clip/floor_clip)
968
+ clip_top = @ceiling_clip[x] + 1
969
+ clip_bottom = @floor_clip[x] - 1
970
+ y1 = [y1, clip_top].max
971
+ y2 = [y2, clip_bottom].min
972
+ return if y1 > y2
973
+
974
+ texture = @textures[texture_name]
975
+ return unless texture
976
+
977
+ light = calculate_light(light_level, dist)
978
+ cmap = @colormap.maps[light]
979
+ framebuffer = @framebuffer
980
+ tex_width_mask = texture.width - 1 # For power-of-2 textures: & mask is faster than %
981
+ tex_height_mask = texture.height - 1
982
+
983
+ # Texture X coordinate (wrap around texture width using bitwise AND)
984
+ tex_x = tex_col.to_i & tex_width_mask
985
+
986
+ # Get the column of pixels
987
+ column = texture.column_pixels(tex_x)
988
+ return unless column
989
+
990
+ # Texture step per screen pixel
991
+ tex_step = 1.0 / scale
992
+
993
+ # Calculate where the unclipped wall top would be on screen
994
+ unclipped_y1 = HALF_HEIGHT - (world_top - @player_z) * scale
995
+
996
+ # Adjust tex_y_start for any clipping at the top
997
+ tex_y_at_y1 = tex_y_start + (y1 - unclipped_y1) * tex_step
998
+
999
+ # Clamp to screen bounds
1000
+ y1 = 0 if y1 < 0
1001
+ y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1002
+
1003
+ # Draw wall column using while loop
1004
+ y = y1
1005
+ while y <= y2
1006
+ screen_offset = y - y1
1007
+ tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i & tex_height_mask
1008
+
1009
+ color = column[tex_y]
1010
+ framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1011
+ y += 1
1012
+ end
1013
+ end
1014
+
1015
+ # Calculate colormap index for lighting
1016
+ # Doom uses: walllights[scale >> LIGHTSCALESHIFT] where scale = projection/distance
1017
+ # LIGHTSCALESHIFT = 12, MAXLIGHTSCALE = 48, NUMCOLORMAPS = 32
1018
+ # Doom lighting constants from r_main.h
1019
+ LIGHTLEVELS = 16
1020
+ LIGHTSEGSHIFT = 4
1021
+ MAXLIGHTSCALE = 48
1022
+ LIGHTSCALESHIFT = 12
1023
+ MAXLIGHTZ = 128
1024
+ LIGHTZSHIFT = 20
1025
+ NUMCOLORMAPS = 32
1026
+ DISTMAP = 2
1027
+
1028
+ # Calculate light for walls using Doom's scalelight formula
1029
+ # In Doom: scalelight[lightnum][scale_index] where:
1030
+ # lightnum = sector_light >> LIGHTSEGSHIFT (0-15)
1031
+ # startmap = (LIGHTLEVELS-1-lightnum) * 2 * NUMCOLORMAPS / LIGHTLEVELS
1032
+ # level = startmap - scale_index * SCREENWIDTH / (viewwidth * DISTMAP)
1033
+ # scale_index = rw_scale >> LIGHTSCALESHIFT (0-47)
1034
+ def calculate_light(light_level, dist)
1035
+ # lightnum from sector light level (0-15)
1036
+ lightnum = (light_level >> LIGHTSEGSHIFT).clamp(0, LIGHTLEVELS - 1)
1037
+
1038
+ # startmap = (15 - lightnum) * 4 for LIGHTLEVELS=16, NUMCOLORMAPS=32
1039
+ startmap = ((LIGHTLEVELS - 1 - lightnum) * 2 * NUMCOLORMAPS) / LIGHTLEVELS
1040
+
1041
+ # scale_index from projection scale
1042
+ # rw_scale (fixed point) = projection * FRACUNIT / distance
1043
+ # scale_index = rw_scale >> LIGHTSCALESHIFT = projection * 16 / distance
1044
+ # With projection = 160: scale_index = 2560 / distance
1045
+ scale_index = dist > 0 ? (2560.0 / dist).to_i : MAXLIGHTSCALE
1046
+ scale_index = scale_index.clamp(0, MAXLIGHTSCALE - 1)
1047
+
1048
+ # level = startmap - scale_index * 320 / (320 * 2) = startmap - scale_index / 2
1049
+ level = startmap - (scale_index * SCREEN_WIDTH / (SCREEN_WIDTH * DISTMAP))
1050
+
1051
+ level.clamp(0, NUMCOLORMAPS - 1)
1052
+ end
1053
+
1054
+ # Calculate light for floor/ceiling using Doom's zlight formula
1055
+ # In Doom: zlight[lightnum][z_index] where:
1056
+ # z_index = distance >> LIGHTZSHIFT (0-127)
1057
+ # For each z_index, a scale is computed and used to find the level
1058
+ def calculate_flat_light(light_level, distance)
1059
+ # lightnum from sector light level (0-15)
1060
+ lightnum = (light_level >> LIGHTSEGSHIFT).clamp(0, LIGHTLEVELS - 1)
1061
+
1062
+ # startmap = (LIGHTLEVELS-1-lightnum)*2*NUMCOLORMAPS/LIGHTLEVELS
1063
+ startmap = ((LIGHTLEVELS - 1 - lightnum) * 2 * NUMCOLORMAPS) / LIGHTLEVELS
1064
+
1065
+ # z_index = distance (in fixed point) >> LIGHTZSHIFT
1066
+ # Our float distance * FRACUNIT >> LIGHTZSHIFT = distance * 65536 / 1048576 = distance / 16
1067
+ z_index = (distance / 16.0).to_i.clamp(0, MAXLIGHTZ - 1)
1068
+
1069
+ # From R_InitLightTables: scale = FixedDiv(160*FRACUNIT, (j+1)<<LIGHTZSHIFT)
1070
+ # = (160*65536*65536) / ((j+1)*1048576) = 655360 / (j+1)
1071
+ # level = startmap - scale/FRACUNIT = startmap - 655360/65536/(j+1) = startmap - 10/(j+1)
1072
+ diminish = 10.0 / (z_index + 1)
1073
+
1074
+ level = startmap - diminish
1075
+ level.to_i.clamp(0, NUMCOLORMAPS - 1)
1076
+ end
1077
+
1078
+ def render_sprites
1079
+ return unless @sprites
1080
+
1081
+ # Collect visible sprites with their distances
1082
+ visible_sprites = []
1083
+
1084
+ @map.things.each do |thing|
1085
+ # Check if we have a sprite for this thing type
1086
+ next unless @sprites.prefix_for(thing.type)
1087
+
1088
+ # Transform to view space
1089
+ view_x, view_y = transform_point(thing.x, thing.y)
1090
+
1091
+ # Skip if behind player
1092
+ next if view_y <= 0
1093
+
1094
+ # Calculate distance for sorting and scaling
1095
+ dist = view_y
1096
+
1097
+ # Calculate angle from player to thing (for rotation selection)
1098
+ dx = thing.x - @player_x
1099
+ dy = thing.y - @player_y
1100
+ angle_to_thing = Math.atan2(dy, dx)
1101
+
1102
+ # Get the correct rotated sprite
1103
+ sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1104
+ next unless sprite
1105
+
1106
+ # Project to screen X
1107
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1108
+
1109
+ # Skip if completely off screen (with margin for sprite width)
1110
+ sprite_half_width = (sprite.width * @projection / dist / 2).to_i
1111
+ next if screen_x + sprite_half_width < 0
1112
+ next if screen_x - sprite_half_width >= SCREEN_WIDTH
1113
+
1114
+ visible_sprites << VisibleSprite.new(thing, sprite, view_x, view_y, dist, screen_x)
1115
+ end
1116
+
1117
+ # Sort by distance (back to front for proper overdraw)
1118
+ visible_sprites.sort_by! { |s| -s.dist }
1119
+
1120
+ # Draw each sprite
1121
+ visible_sprites.each do |vs|
1122
+ draw_sprite(vs)
1123
+ end
1124
+ end
1125
+
1126
+ def draw_sprite(vs)
1127
+ sprite = vs.sprite
1128
+ dist = vs.dist
1129
+ screen_x = vs.screen_x
1130
+ thing = vs.thing
1131
+
1132
+ # Calculate scale (inverse of distance, used for depth comparison)
1133
+ sprite_scale = @projection / dist
1134
+
1135
+ # Sprite dimensions on screen
1136
+ sprite_screen_width = (sprite.width * sprite_scale).to_i
1137
+ sprite_screen_height = (sprite.height * sprite_scale).to_i
1138
+
1139
+ return if sprite_screen_width <= 0 || sprite_screen_height <= 0
1140
+
1141
+ # Get sector for lighting and Z position
1142
+ sector = @map.sector_at(thing.x, thing.y)
1143
+ light_level = sector ? sector.light_level : 160
1144
+ light = calculate_light(light_level, dist)
1145
+
1146
+ # Sprite screen bounds
1147
+ sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
1148
+ sprite_right = sprite_left + sprite_screen_width - 1
1149
+
1150
+ # Clamp to screen
1151
+ x1 = [sprite_left, 0].max
1152
+ x2 = [sprite_right, SCREEN_WIDTH - 1].min
1153
+ return if x1 > x2
1154
+
1155
+ # Calculate sprite world Z positions
1156
+ thing_floor = sector ? sector.floor_height : 0
1157
+ sprite_gz = thing_floor # bottom of sprite in world Z
1158
+ sprite_gzt = thing_floor + sprite.top_offset # top of sprite in world Z
1159
+
1160
+ # Initialize per-column clip arrays (-2 = not yet clipped)
1161
+ clipbot = Array.new(SCREEN_WIDTH, -2)
1162
+ cliptop = Array.new(SCREEN_WIDTH, -2)
1163
+
1164
+ # Scan drawsegs from back to front for obscuring segs
1165
+ @drawsegs.reverse_each do |ds|
1166
+ # Skip if drawseg doesn't overlap sprite horizontally
1167
+ next if ds.x1 > x2 || ds.x2 < x1
1168
+
1169
+ # Skip if drawseg has no silhouette
1170
+ next if ds.silhouette == SIL_NONE
1171
+
1172
+ # Determine overlap range
1173
+ r1 = [ds.x1, x1].max
1174
+ r2 = [ds.x2, x2].min
1175
+
1176
+ # Get drawseg's scale range for depth comparison
1177
+ lowscale = [ds.scale1, ds.scale2].min
1178
+ highscale = [ds.scale1, ds.scale2].max
1179
+
1180
+ # Chocolate Doom logic: skip if seg is behind sprite
1181
+ # If highscale < sprite_scale: wall entirely behind sprite
1182
+ # OR if lowscale < sprite_scale AND sprite is on front side of wall
1183
+ if highscale < sprite_scale
1184
+ next
1185
+ elsif lowscale < sprite_scale
1186
+ # Partial overlap - check if sprite is in front of the seg
1187
+ next unless point_on_seg_side(thing.x, thing.y, ds.curline)
1188
+ end
1189
+
1190
+ # Determine which silhouettes apply based on sprite Z
1191
+ silhouette = ds.silhouette
1192
+
1193
+ # If sprite bottom is at or above the bottom silhouette height, don't clip bottom
1194
+ if sprite_gz >= ds.bsilheight
1195
+ silhouette &= ~SIL_BOTTOM
1196
+ end
1197
+
1198
+ # If sprite top is at or below the top silhouette height, don't clip top
1199
+ if sprite_gzt <= ds.tsilheight
1200
+ silhouette &= ~SIL_TOP
1201
+ end
1202
+
1203
+ # Apply clipping for each column in the overlap
1204
+ (r1..r2).each do |x|
1205
+ # Index into drawseg's clip arrays (0-based from ds.x1)
1206
+ ds_idx = x - ds.x1
1207
+
1208
+ if (silhouette & SIL_BOTTOM) != 0 && clipbot[x] == -2
1209
+ clipbot[x] = ds.sprbottomclip[ds_idx] if ds.sprbottomclip && ds_idx < ds.sprbottomclip.length
1210
+ end
1211
+
1212
+ if (silhouette & SIL_TOP) != 0 && cliptop[x] == -2
1213
+ cliptop[x] = ds.sprtopclip[ds_idx] if ds.sprtopclip && ds_idx < ds.sprtopclip.length
1214
+ end
1215
+ end
1216
+ end
1217
+
1218
+ # Fill in default values for unclipped columns
1219
+ (x1..x2).each do |x|
1220
+ clipbot[x] = SCREEN_HEIGHT if clipbot[x] == -2
1221
+ cliptop[x] = -1 if cliptop[x] == -2
1222
+ end
1223
+
1224
+ # Calculate sprite screen Y positions
1225
+ sprite_top_world = sprite_gzt - @player_z
1226
+ sprite_bottom_world = sprite_gz - @player_z
1227
+ sprite_top_screen = (HALF_HEIGHT - sprite_top_world * sprite_scale).to_i
1228
+ sprite_bottom_screen = (HALF_HEIGHT - sprite_bottom_world * sprite_scale).to_i
1229
+
1230
+ # Cache for inner loop
1231
+ framebuffer = @framebuffer
1232
+ cmap = @colormap.maps[light]
1233
+ sprite_width = sprite.width
1234
+ sprite_height = sprite.height
1235
+
1236
+ # Draw each column of the sprite
1237
+ (x1..x2).each do |x|
1238
+ # Get clip bounds for this column
1239
+ top_clip = cliptop[x] + 1
1240
+ bottom_clip = clipbot[x] - 1
1241
+
1242
+ next if top_clip > bottom_clip
1243
+
1244
+ # Calculate which texture column to use
1245
+ tex_x = ((x - sprite_left) * sprite_width / sprite_screen_width).to_i
1246
+ tex_x = tex_x.clamp(0, sprite_width - 1)
1247
+
1248
+ # Get column pixels
1249
+ column = sprite.column_pixels(tex_x)
1250
+ next unless column
1251
+
1252
+ # Draw visible portion of this column
1253
+ y_start = [sprite_top_screen, top_clip].max
1254
+ y_end = [sprite_bottom_screen, bottom_clip].min
1255
+
1256
+ (y_start..y_end).each do |y|
1257
+ # Calculate texture Y
1258
+ tex_y = ((y - sprite_top_screen) * sprite_height / sprite_screen_height).to_i
1259
+ tex_y = tex_y.clamp(0, sprite_height - 1)
1260
+
1261
+ # Get pixel (nil = transparent)
1262
+ color = column[tex_y]
1263
+ next unless color
1264
+
1265
+ # Apply lighting and write directly to framebuffer
1266
+ framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1267
+ end
1268
+ end
1269
+ end
1270
+ end
1271
+ end
1272
+ end