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.
- checksums.yaml +4 -4
- data/README.md +75 -115
- data/bin/doom +47 -58
- data/lib/doom/map/data.rb +280 -0
- data/lib/doom/platform/gosu_window.rb +237 -0
- data/lib/doom/render/renderer.rb +1218 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/colormap.rb +38 -0
- data/lib/doom/wad/flat.rb +61 -0
- data/lib/doom/wad/palette.rb +37 -0
- data/lib/doom/wad/patch.rb +61 -0
- data/lib/doom/wad/reader.rb +79 -0
- data/lib/doom/wad/sprite.rb +205 -0
- data/lib/doom/wad/texture.rb +163 -0
- data/lib/doom/wad_downloader.rb +143 -0
- data/lib/doom.rb +56 -37
- metadata +32 -35
- data/LICENSE.txt +0 -21
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/bin/wad +0 -152
- data/lib/doom/bsp_renderer.rb +0 -90
- data/lib/doom/game.rb +0 -84
- data/lib/doom/hud.rb +0 -80
- data/lib/doom/map_loader.rb +0 -255
- data/lib/doom/renderer.rb +0 -32
- data/lib/doom/sprite_loader.rb +0 -88
- data/lib/doom/sprite_renderer.rb +0 -56
- data/lib/doom/texture_loader.rb +0 -138
- data/lib/doom/texture_mapper.rb +0 -57
- data/lib/doom/wad_loader.rb +0 -106
- data/lib/doom/window.rb +0 -41
|
@@ -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
|