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