quake-rb 0.1.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 +7 -0
- data/bin/quake +143 -0
- data/bin/quake-debug +83 -0
- data/lib/quake/bsp/face_vertices.rb +63 -0
- data/lib/quake/bsp/reader.rb +264 -0
- data/lib/quake/bsp/types.rb +30 -0
- data/lib/quake/bsp/vis.rb +246 -0
- data/lib/quake/camera.rb +99 -0
- data/lib/quake/debug/png_writer.rb +58 -0
- data/lib/quake/debug/screenshot.rb +26 -0
- data/lib/quake/debug/script.rb +179 -0
- data/lib/quake/entity.rb +116 -0
- data/lib/quake/game/brush_entities.rb +361 -0
- data/lib/quake/game/engine.rb +300 -0
- data/lib/quake/game/item_pickups.rb +137 -0
- data/lib/quake/game/player_state.rb +158 -0
- data/lib/quake/math/vec3.rb +35 -0
- data/lib/quake/mdl/reader.rb +176 -0
- data/lib/quake/mdl/types.rb +30 -0
- data/lib/quake/pak/reader.rb +57 -0
- data/lib/quake/pak_downloader.rb +145 -0
- data/lib/quake/palette.rb +32 -0
- data/lib/quake/physics/hull_trace.rb +193 -0
- data/lib/quake/physics/player.rb +357 -0
- data/lib/quake/renderer/gl_alias_model.rb +122 -0
- data/lib/quake/renderer/gl_brush_model.rb +162 -0
- data/lib/quake/renderer/gl_hud.rb +226 -0
- data/lib/quake/renderer/gl_lightmap.rb +261 -0
- data/lib/quake/renderer/gl_particles.rb +173 -0
- data/lib/quake/renderer/gl_sky.rb +166 -0
- data/lib/quake/renderer/gl_texture_manager.rb +54 -0
- data/lib/quake/renderer/gl_textured.rb +224 -0
- data/lib/quake/renderer/gl_viewmodel.rb +109 -0
- data/lib/quake/renderer/gl_water.rb +200 -0
- data/lib/quake/renderer/gl_wireframe.rb +36 -0
- data/lib/quake/sound/events.rb +58 -0
- data/lib/quake/sound/mixer.rb +105 -0
- data/lib/quake/version.rb +5 -0
- data/lib/quake/wad/reader.rb +69 -0
- data/lib/quake/window.rb +74 -0
- data/lib/quake.rb +19 -0
- metadata +140 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders Quake's status bar HUD using graphics from gfx.wad.
|
|
8
|
+
# Uses orthographic projection for 2D rendering on top of the 3D scene.
|
|
9
|
+
#
|
|
10
|
+
# Layout matches original Quake sbar.c (320x200 virtual resolution):
|
|
11
|
+
# Bottom bar (sbar): [armor_icon][armor] [face] [health] [ammo_icon][ammo]
|
|
12
|
+
# Above that (ibar): weapon slots and ammo counts
|
|
13
|
+
class GLHud
|
|
14
|
+
VIRTUAL_WIDTH = 320
|
|
15
|
+
VIRTUAL_HEIGHT = 200
|
|
16
|
+
SBAR_HEIGHT = 24 # status bar height
|
|
17
|
+
DIGIT_WIDTH = 24 # big number digit width
|
|
18
|
+
|
|
19
|
+
def initialize(wad, palette, screen_width, screen_height)
|
|
20
|
+
@wad = wad
|
|
21
|
+
@palette = palette
|
|
22
|
+
@screen_width = screen_width
|
|
23
|
+
@screen_height = screen_height
|
|
24
|
+
@textures = {} # name -> { id:, width:, height: }
|
|
25
|
+
|
|
26
|
+
upload_hud_graphics
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render(player_state)
|
|
30
|
+
setup_ortho
|
|
31
|
+
|
|
32
|
+
GL.Enable(GL::TEXTURE_2D)
|
|
33
|
+
GL.Enable(GL::BLEND)
|
|
34
|
+
GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
|
|
35
|
+
GL.Disable(GL::DEPTH_TEST)
|
|
36
|
+
GL.Color4f(1.0, 1.0, 1.0, 1.0)
|
|
37
|
+
|
|
38
|
+
draw_sbar(player_state)
|
|
39
|
+
|
|
40
|
+
GL.Enable(GL::DEPTH_TEST)
|
|
41
|
+
GL.Disable(GL::BLEND)
|
|
42
|
+
|
|
43
|
+
restore_projection
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def setup_ortho
|
|
49
|
+
GL.MatrixMode(GL::PROJECTION)
|
|
50
|
+
GL.PushMatrix
|
|
51
|
+
GL.LoadIdentity
|
|
52
|
+
# Y=0 at bottom, Y=200 at top (standard GL orientation)
|
|
53
|
+
GL.Ortho(0, VIRTUAL_WIDTH, 0, VIRTUAL_HEIGHT, -1, 1)
|
|
54
|
+
|
|
55
|
+
GL.MatrixMode(GL::MODELVIEW)
|
|
56
|
+
GL.PushMatrix
|
|
57
|
+
GL.LoadIdentity
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def restore_projection
|
|
61
|
+
GL.MatrixMode(GL::MODELVIEW)
|
|
62
|
+
GL.PopMatrix
|
|
63
|
+
GL.MatrixMode(GL::PROJECTION)
|
|
64
|
+
GL.PopMatrix
|
|
65
|
+
GL.MatrixMode(GL::MODELVIEW)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Draw a QPic texture at position (x, y) where y is measured from
|
|
69
|
+
# the bottom of the screen (0 = bottom edge).
|
|
70
|
+
def draw_pic(x, y, name)
|
|
71
|
+
tex = @textures[name]
|
|
72
|
+
return unless tex
|
|
73
|
+
|
|
74
|
+
GL.BindTexture(GL::TEXTURE_2D, tex[:id])
|
|
75
|
+
|
|
76
|
+
w = tex[:width]
|
|
77
|
+
h = tex[:height]
|
|
78
|
+
|
|
79
|
+
GL.Begin(GL::QUADS)
|
|
80
|
+
GL.TexCoord2f(0.0, 0.0); GL.Vertex2f(x, y + h)
|
|
81
|
+
GL.TexCoord2f(1.0, 0.0); GL.Vertex2f(x + w, y + h)
|
|
82
|
+
GL.TexCoord2f(1.0, 1.0); GL.Vertex2f(x + w, y)
|
|
83
|
+
GL.TexCoord2f(0.0, 1.0); GL.Vertex2f(x, y)
|
|
84
|
+
GL.End
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def draw_sbar(ps)
|
|
88
|
+
# Sbar sits at the very bottom of the screen
|
|
89
|
+
sbar_y = 0
|
|
90
|
+
|
|
91
|
+
# Status bar background (320x24)
|
|
92
|
+
draw_pic(0, sbar_y, "sbar")
|
|
93
|
+
|
|
94
|
+
# Armor number (x=24, 3 digits)
|
|
95
|
+
draw_num(24, sbar_y, ps.armor, 3, ps.armor <= 25)
|
|
96
|
+
|
|
97
|
+
# Armor type icon (x=0)
|
|
98
|
+
if ps.armor > 0
|
|
99
|
+
armor_icon = case ps.armor_type
|
|
100
|
+
when 1 then "sb_armor1"
|
|
101
|
+
when 2 then "sb_armor2"
|
|
102
|
+
else "sb_armor3"
|
|
103
|
+
end
|
|
104
|
+
draw_pic(0, sbar_y, armor_icon) if @textures[armor_icon]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Face (x=112)
|
|
108
|
+
draw_pic(112, sbar_y, health_face(ps.health))
|
|
109
|
+
|
|
110
|
+
# Health (x=136, 3 digits)
|
|
111
|
+
draw_num(136, sbar_y, ps.health, 3, ps.health <= 25)
|
|
112
|
+
|
|
113
|
+
# Ammo icon (x=224)
|
|
114
|
+
ammo_icon = ammo_type_icon(ps.current_ammo_type)
|
|
115
|
+
draw_pic(224, sbar_y, ammo_icon) if ammo_icon && @textures[ammo_icon]
|
|
116
|
+
|
|
117
|
+
# Ammo count (x=248, 3 digits)
|
|
118
|
+
ammo = ps.current_ammo_count
|
|
119
|
+
draw_num(248, sbar_y, ammo, 3, ammo && ammo <= 10) if ammo
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Draw a right-justified number using big digit graphics.
|
|
123
|
+
# x is the LEFT edge of the digit field.
|
|
124
|
+
# Matches Quake's Sbar_DrawNum exactly.
|
|
125
|
+
def draw_num(x, y, value, digits, red = false)
|
|
126
|
+
return unless value
|
|
127
|
+
|
|
128
|
+
value = value.to_i
|
|
129
|
+
negative = value < 0
|
|
130
|
+
value = value.abs
|
|
131
|
+
|
|
132
|
+
str = value.to_s
|
|
133
|
+
str = "-#{str}" if negative
|
|
134
|
+
|
|
135
|
+
# Right-justify: pad left with empty space
|
|
136
|
+
if str.length < digits
|
|
137
|
+
x += (digits - str.length) * DIGIT_WIDTH
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Truncate from left if too many digits
|
|
141
|
+
ptr = str.length > digits ? str[str.length - digits..] : str
|
|
142
|
+
|
|
143
|
+
ptr.each_char do |ch|
|
|
144
|
+
if ch == "-"
|
|
145
|
+
draw_pic(x, y, "num_minus")
|
|
146
|
+
else
|
|
147
|
+
prefix = red ? "anum_" : "num_"
|
|
148
|
+
draw_pic(x, y, "#{prefix}#{ch}")
|
|
149
|
+
end
|
|
150
|
+
x += DIGIT_WIDTH
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def health_face(health)
|
|
155
|
+
# Quake face mapping: face1 = best (health 100+), face5 = worst (near death)
|
|
156
|
+
# sb_faces[4] = face1, sb_faces[0] = face5
|
|
157
|
+
# f = health >= 100 ? 4 : health / 20
|
|
158
|
+
f = health >= 100 ? 4 : [health / 20, 0].max
|
|
159
|
+
"face#{5 - f}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ammo_type_icon(ammo_type)
|
|
163
|
+
case ammo_type
|
|
164
|
+
when :shells then "sb_shells"
|
|
165
|
+
when :nails then "sb_nails"
|
|
166
|
+
when :rockets then "sb_rocket"
|
|
167
|
+
when :cells then "sb_cells"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def upload_hud_graphics
|
|
172
|
+
# Status bar background
|
|
173
|
+
upload_wad_pic("sbar")
|
|
174
|
+
|
|
175
|
+
# Big numbers (0-9) and alternate (red) numbers
|
|
176
|
+
10.times do |i|
|
|
177
|
+
upload_wad_pic("num_#{i}")
|
|
178
|
+
upload_wad_pic("anum_#{i}")
|
|
179
|
+
end
|
|
180
|
+
upload_wad_pic("num_minus")
|
|
181
|
+
upload_wad_pic("num_colon")
|
|
182
|
+
upload_wad_pic("num_slash")
|
|
183
|
+
|
|
184
|
+
# Face graphics
|
|
185
|
+
(1..5).each { |i| upload_wad_pic("face#{i}") }
|
|
186
|
+
(1..5).each { |i| upload_wad_pic("face_p#{i}") }
|
|
187
|
+
|
|
188
|
+
# Armor icons
|
|
189
|
+
upload_wad_pic("sb_armor1")
|
|
190
|
+
upload_wad_pic("sb_armor2")
|
|
191
|
+
upload_wad_pic("sb_armor3")
|
|
192
|
+
|
|
193
|
+
# Ammo type icons
|
|
194
|
+
upload_wad_pic("sb_shells")
|
|
195
|
+
upload_wad_pic("sb_nails")
|
|
196
|
+
upload_wad_pic("sb_rocket")
|
|
197
|
+
upload_wad_pic("sb_cells")
|
|
198
|
+
|
|
199
|
+
count = @textures.size
|
|
200
|
+
puts "Loaded #{count} HUD graphics from WAD"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def upload_wad_pic(name)
|
|
204
|
+
qpic = @wad.read_qpic(name)
|
|
205
|
+
return unless qpic
|
|
206
|
+
|
|
207
|
+
rgba = @palette.indexed_to_rgba(qpic.pixels)
|
|
208
|
+
|
|
209
|
+
buf = "\0" * 4
|
|
210
|
+
GL.GenTextures(1, buf)
|
|
211
|
+
tex_id = buf.unpack1("V")
|
|
212
|
+
|
|
213
|
+
GL.BindTexture(GL::TEXTURE_2D, tex_id)
|
|
214
|
+
GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
|
|
215
|
+
qpic.width, qpic.height, 0,
|
|
216
|
+
GL::RGBA, GL::UNSIGNED_BYTE, rgba)
|
|
217
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::NEAREST)
|
|
218
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::NEAREST)
|
|
219
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::CLAMP_TO_EDGE)
|
|
220
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::CLAMP_TO_EDGE)
|
|
221
|
+
|
|
222
|
+
@textures[name] = { id: tex_id, width: qpic.width, height: qpic.height }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
# Computes and uploads lightmaps for BSP faces.
|
|
8
|
+
# Each face's lightmap is a small texture (typically 4x4 to 18x18)
|
|
9
|
+
# derived from the lighting lump. Lightmap UVs are computed from
|
|
10
|
+
# the face's texture-space extents, quantized to a 16-unit grid.
|
|
11
|
+
class GLLightmap
|
|
12
|
+
LIGHTMAP_BLOCK_WIDTH = 128
|
|
13
|
+
LIGHTMAP_BLOCK_HEIGHT = 128
|
|
14
|
+
|
|
15
|
+
LightmapInfo = Data.define(:gl_texture, :s_offset, :t_offset, :width, :height)
|
|
16
|
+
|
|
17
|
+
attr_reader :face_lightmaps # face_index -> LightmapInfo
|
|
18
|
+
|
|
19
|
+
def initialize(level, palette)
|
|
20
|
+
@level = level
|
|
21
|
+
@palette = palette
|
|
22
|
+
@face_lightmaps = {}
|
|
23
|
+
@block_textures = [] # GL texture ids for each block
|
|
24
|
+
@block_allocated = [] # row allocation per block column
|
|
25
|
+
@block_pixels = [] # RGBA pixel data per block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_all
|
|
29
|
+
allocate_block
|
|
30
|
+
|
|
31
|
+
@level.faces.each_with_index do |face, face_index|
|
|
32
|
+
texinfo = @level.texinfo[face.texinfo_index]
|
|
33
|
+
next if texinfo.nil?
|
|
34
|
+
next if texinfo.flags & 1 != 0 # TEX_SPECIAL (sky/turb)
|
|
35
|
+
next if face.light_offset < 0
|
|
36
|
+
|
|
37
|
+
build_face_lightmap(face, face_index)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
upload_blocks
|
|
41
|
+
count = @face_lightmaps.size
|
|
42
|
+
puts "Built #{count} lightmaps in #{@block_textures.size} blocks"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bind(face_index)
|
|
46
|
+
info = @face_lightmaps[face_index]
|
|
47
|
+
return unless info
|
|
48
|
+
GL.BindTexture(GL::TEXTURE_2D, info.gl_texture)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Compute lightmap UVs for a vertex given the face's lightmap info.
|
|
52
|
+
def lightmap_texcoords(face_index, vertex, texinfo)
|
|
53
|
+
info = @face_lightmaps[face_index]
|
|
54
|
+
return [0.0, 0.0] unless info
|
|
55
|
+
|
|
56
|
+
# Compute texture-space coordinate
|
|
57
|
+
s = vertex.dot(texinfo.s_vec) + texinfo.s_offset
|
|
58
|
+
t = vertex.dot(texinfo.t_vec) + texinfo.t_offset
|
|
59
|
+
|
|
60
|
+
# Convert to lightmap UV: subtract texture mins, scale by 1/16, offset by 0.5
|
|
61
|
+
# Then convert to atlas UV
|
|
62
|
+
extents = face_extents(face_index)
|
|
63
|
+
return [0.0, 0.0] unless extents
|
|
64
|
+
|
|
65
|
+
tex_mins_s, tex_mins_t, _, _ = extents
|
|
66
|
+
|
|
67
|
+
ls = (s - tex_mins_s + 8.0) / (info.width * 16.0)
|
|
68
|
+
lt = (t - tex_mins_t + 8.0) / (info.height * 16.0)
|
|
69
|
+
|
|
70
|
+
# Map into atlas block coordinates
|
|
71
|
+
atlas_s = (info.s_offset + ls * info.width + 0.5) / LIGHTMAP_BLOCK_WIDTH
|
|
72
|
+
atlas_t = (info.t_offset + lt * info.height + 0.5) / LIGHTMAP_BLOCK_HEIGHT
|
|
73
|
+
|
|
74
|
+
[atlas_s, atlas_t]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def allocate_block
|
|
80
|
+
@block_allocated << Array.new(LIGHTMAP_BLOCK_WIDTH, 0)
|
|
81
|
+
@block_pixels << ("\0" * (LIGHTMAP_BLOCK_WIDTH * LIGHTMAP_BLOCK_HEIGHT * 4))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def find_space(width, height)
|
|
85
|
+
@block_allocated.each_with_index do |allocated, block_idx|
|
|
86
|
+
best_y = LIGHTMAP_BLOCK_HEIGHT
|
|
87
|
+
|
|
88
|
+
# Simple first-fit allocation: scan columns
|
|
89
|
+
x = 0
|
|
90
|
+
while x <= LIGHTMAP_BLOCK_WIDTH - width
|
|
91
|
+
max_y = 0
|
|
92
|
+
fits = true
|
|
93
|
+
width.times do |dx|
|
|
94
|
+
if allocated[x + dx] + height > LIGHTMAP_BLOCK_HEIGHT
|
|
95
|
+
# Skip ahead
|
|
96
|
+
x = x + dx + 1
|
|
97
|
+
fits = false
|
|
98
|
+
break
|
|
99
|
+
end
|
|
100
|
+
max_y = allocated[x + dx] if allocated[x + dx] > max_y
|
|
101
|
+
end
|
|
102
|
+
next unless fits
|
|
103
|
+
|
|
104
|
+
if max_y < best_y
|
|
105
|
+
best_y = max_y
|
|
106
|
+
best_x = x
|
|
107
|
+
end
|
|
108
|
+
x += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if best_y + height <= LIGHTMAP_BLOCK_HEIGHT
|
|
112
|
+
# Mark columns as used
|
|
113
|
+
width.times { |dx| allocated[best_x + dx] = best_y + height }
|
|
114
|
+
return [block_idx, best_x, best_y]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# No space in existing blocks, allocate a new one
|
|
119
|
+
allocate_block
|
|
120
|
+
block_idx = @block_allocated.size - 1
|
|
121
|
+
allocated = @block_allocated[block_idx]
|
|
122
|
+
width.times { |dx| allocated[dx] = height }
|
|
123
|
+
[block_idx, 0, 0]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def calc_extents(face)
|
|
127
|
+
texinfo = @level.texinfo[face.texinfo_index]
|
|
128
|
+
return nil unless texinfo
|
|
129
|
+
|
|
130
|
+
min_s = Float::INFINITY
|
|
131
|
+
max_s = -Float::INFINITY
|
|
132
|
+
min_t = Float::INFINITY
|
|
133
|
+
max_t = -Float::INFINITY
|
|
134
|
+
|
|
135
|
+
face.num_edges.times do |i|
|
|
136
|
+
surfedge = @level.surfedges[face.first_edge + i]
|
|
137
|
+
if surfedge >= 0
|
|
138
|
+
edge = @level.edges[surfedge]
|
|
139
|
+
v = @level.vertices[edge.v0]
|
|
140
|
+
else
|
|
141
|
+
edge = @level.edges[-surfedge]
|
|
142
|
+
v = @level.vertices[edge.v1]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
s = v.dot(texinfo.s_vec) + texinfo.s_offset
|
|
146
|
+
t = v.dot(texinfo.t_vec) + texinfo.t_offset
|
|
147
|
+
|
|
148
|
+
min_s = s if s < min_s
|
|
149
|
+
max_s = s if s > max_s
|
|
150
|
+
min_t = t if t < min_t
|
|
151
|
+
max_t = t if t > max_t
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
tex_min_s = (min_s / 16.0).floor * 16
|
|
155
|
+
tex_min_t = (min_t / 16.0).floor * 16
|
|
156
|
+
extent_s = ((max_s / 16.0).ceil - (min_s / 16.0).floor) * 16
|
|
157
|
+
extent_t = ((max_t / 16.0).ceil - (min_t / 16.0).floor) * 16
|
|
158
|
+
|
|
159
|
+
[tex_min_s, tex_min_t, extent_s.to_i, extent_t.to_i]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def face_extents(face_index)
|
|
163
|
+
@extents_cache ||= {}
|
|
164
|
+
@extents_cache[face_index]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_face_lightmap(face, face_index)
|
|
168
|
+
extents = calc_extents(face)
|
|
169
|
+
return unless extents
|
|
170
|
+
|
|
171
|
+
tex_min_s, tex_min_t, extent_s, extent_t = extents
|
|
172
|
+
lm_width = (extent_s / 16) + 1
|
|
173
|
+
lm_height = (extent_t / 16) + 1
|
|
174
|
+
|
|
175
|
+
return if lm_width <= 0 || lm_height <= 0
|
|
176
|
+
return if lm_width > 18 || lm_height > 18
|
|
177
|
+
|
|
178
|
+
@extents_cache ||= {}
|
|
179
|
+
@extents_cache[face_index] = extents
|
|
180
|
+
|
|
181
|
+
# Read lightmap data from lighting lump
|
|
182
|
+
lm_size = lm_width * lm_height
|
|
183
|
+
lighting = @level.lighting
|
|
184
|
+
offset = face.light_offset
|
|
185
|
+
|
|
186
|
+
# Accumulate light from all styles
|
|
187
|
+
blocklights = Array.new(lm_size, 0)
|
|
188
|
+
|
|
189
|
+
4.times do |style_idx|
|
|
190
|
+
style = face.styles[style_idx]
|
|
191
|
+
break if style == 255 # end of styles
|
|
192
|
+
|
|
193
|
+
# Scale factor: normal light (style 0) = 264 (Quake default)
|
|
194
|
+
# Other styles animated, but default to full brightness
|
|
195
|
+
scale = 256
|
|
196
|
+
|
|
197
|
+
lm_size.times do |j|
|
|
198
|
+
byte = lighting.getbyte(offset + j)
|
|
199
|
+
blocklights[j] += byte * scale if byte
|
|
200
|
+
end
|
|
201
|
+
offset += lm_size
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Find space in lightmap atlas
|
|
205
|
+
block_idx, s_off, t_off = find_space(lm_width, lm_height)
|
|
206
|
+
|
|
207
|
+
# Write pixels into atlas block
|
|
208
|
+
pixels = @block_pixels[block_idx]
|
|
209
|
+
lm_height.times do |row|
|
|
210
|
+
lm_width.times do |col|
|
|
211
|
+
src_idx = row * lm_width + col
|
|
212
|
+
# Shift right 7 (overbright by 2x) to match Quake's gamma
|
|
213
|
+
val = (blocklights[src_idx] >> 7).clamp(0, 255)
|
|
214
|
+
|
|
215
|
+
dst_x = s_off + col
|
|
216
|
+
dst_y = t_off + row
|
|
217
|
+
dst_idx = (dst_y * LIGHTMAP_BLOCK_WIDTH + dst_x) * 4
|
|
218
|
+
pixels.setbyte(dst_idx, val)
|
|
219
|
+
pixels.setbyte(dst_idx + 1, val)
|
|
220
|
+
pixels.setbyte(dst_idx + 2, val)
|
|
221
|
+
pixels.setbyte(dst_idx + 3, 255)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
@face_lightmaps[face_index] = LightmapInfo.new(
|
|
226
|
+
gl_texture: block_idx, # Will be replaced with actual GL id after upload
|
|
227
|
+
s_offset: s_off, t_offset: t_off,
|
|
228
|
+
width: lm_width, height: lm_height
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def upload_blocks
|
|
233
|
+
gl_ids = []
|
|
234
|
+
@block_pixels.each_with_index do |pixels, idx|
|
|
235
|
+
buf = "\0" * 4
|
|
236
|
+
GL.GenTextures(1, buf)
|
|
237
|
+
tex_id = buf.unpack1("V")
|
|
238
|
+
gl_ids << tex_id
|
|
239
|
+
|
|
240
|
+
GL.BindTexture(GL::TEXTURE_2D, tex_id)
|
|
241
|
+
GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
|
|
242
|
+
LIGHTMAP_BLOCK_WIDTH, LIGHTMAP_BLOCK_HEIGHT, 0,
|
|
243
|
+
GL::RGBA, GL::UNSIGNED_BYTE, pixels)
|
|
244
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR)
|
|
245
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
|
|
246
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::CLAMP_TO_EDGE)
|
|
247
|
+
GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::CLAMP_TO_EDGE)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Update face lightmap infos with actual GL texture ids
|
|
251
|
+
@face_lightmaps.each do |face_index, info|
|
|
252
|
+
@face_lightmaps[face_index] = LightmapInfo.new(
|
|
253
|
+
gl_texture: gl_ids[info.gl_texture],
|
|
254
|
+
s_offset: info.s_offset, t_offset: info.t_offset,
|
|
255
|
+
width: info.width, height: info.height
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Renderer
|
|
7
|
+
# Simple particle system for visual effects.
|
|
8
|
+
# Particles are rendered as GL_POINTS with depth test enabled.
|
|
9
|
+
# Matches Quake's R_DrawParticles (gl_rpart.c).
|
|
10
|
+
class GLParticles
|
|
11
|
+
GRAVITY = 800.0
|
|
12
|
+
MAX_PARTICLES = 2048
|
|
13
|
+
|
|
14
|
+
# Quake ramp tables for color animation (palette indices)
|
|
15
|
+
RAMP1 = [0x6f, 0x6d, 0x6b, 0x69, 0x67, 0x65, 0x63, 0x61].freeze
|
|
16
|
+
RAMP2 = [0x6f, 0x6e, 0x6d, 0x6c, 0x6b, 0x6a, 0x68, 0x66].freeze
|
|
17
|
+
RAMP3 = [0x6d, 0x6b, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01].freeze # explosion
|
|
18
|
+
|
|
19
|
+
Particle = Struct.new(:x, :y, :z, :vx, :vy, :vz,
|
|
20
|
+
:r, :g, :b, :a,
|
|
21
|
+
:life, :ramp, :ramp_type, :gravity_scale)
|
|
22
|
+
|
|
23
|
+
def initialize(palette)
|
|
24
|
+
@palette = palette
|
|
25
|
+
@particles = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update(dt)
|
|
29
|
+
@particles.reject! do |p|
|
|
30
|
+
p.life -= dt
|
|
31
|
+
next true if p.life <= 0
|
|
32
|
+
|
|
33
|
+
# Apply velocity
|
|
34
|
+
p.x += p.vx * dt
|
|
35
|
+
p.y += p.vy * dt
|
|
36
|
+
p.z += p.vz * dt
|
|
37
|
+
|
|
38
|
+
# Apply gravity
|
|
39
|
+
p.vz -= GRAVITY * p.gravity_scale * dt
|
|
40
|
+
|
|
41
|
+
# Color ramp animation
|
|
42
|
+
if p.ramp_type
|
|
43
|
+
p.ramp += dt * 10.0
|
|
44
|
+
idx = p.ramp.to_i
|
|
45
|
+
ramp = case p.ramp_type
|
|
46
|
+
when :explosion then RAMP3
|
|
47
|
+
when :fire then RAMP1
|
|
48
|
+
else RAMP2
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if idx >= ramp.size
|
|
52
|
+
next true # particle expired
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
color = @palette.rgb(ramp[idx])
|
|
56
|
+
p.r = color[0] / 255.0
|
|
57
|
+
p.g = color[1] / 255.0
|
|
58
|
+
p.b = color[2] / 255.0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fade alpha
|
|
62
|
+
p.a = (p.life * 2.0).clamp(0.0, 1.0)
|
|
63
|
+
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render
|
|
69
|
+
return if @particles.empty?
|
|
70
|
+
|
|
71
|
+
GL.Disable(GL::TEXTURE_2D)
|
|
72
|
+
GL.Enable(GL::BLEND)
|
|
73
|
+
GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
|
|
74
|
+
GL.PointSize(2.0)
|
|
75
|
+
|
|
76
|
+
GL.Begin(GL::POINTS)
|
|
77
|
+
@particles.each do |p|
|
|
78
|
+
GL.Color4f(p.r, p.g, p.b, p.a)
|
|
79
|
+
GL.Vertex3f(p.x, p.y, p.z)
|
|
80
|
+
end
|
|
81
|
+
GL.End
|
|
82
|
+
|
|
83
|
+
GL.PointSize(1.0)
|
|
84
|
+
GL.Disable(GL::BLEND)
|
|
85
|
+
GL.Color4f(1.0, 1.0, 1.0, 1.0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Teleporter sparkle effect at a position
|
|
89
|
+
def teleport_splash(pos)
|
|
90
|
+
80.times do
|
|
91
|
+
dx = (rand * 64.0) - 32.0
|
|
92
|
+
dy = (rand * 64.0) - 32.0
|
|
93
|
+
dz = (rand * 64.0) - 32.0
|
|
94
|
+
color_idx = 7 + (rand * 8).to_i
|
|
95
|
+
color = @palette.rgb(color_idx)
|
|
96
|
+
emit(
|
|
97
|
+
x: pos.x + dx, y: pos.y + dy, z: pos.z + dz,
|
|
98
|
+
vx: dx * 2.0, vy: dy * 2.0, vz: dz * 2.0 + 80.0,
|
|
99
|
+
r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
|
|
100
|
+
life: 0.5 + rand * 0.3,
|
|
101
|
+
gravity_scale: 0.2
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Item pickup sparkle
|
|
107
|
+
def pickup_effect(pos)
|
|
108
|
+
20.times do
|
|
109
|
+
angle = rand * ::Math::PI * 2.0
|
|
110
|
+
speed = 50.0 + rand * 50.0
|
|
111
|
+
color = @palette.rgb(0x6f) # orange/yellow
|
|
112
|
+
emit(
|
|
113
|
+
x: pos.x, y: pos.y, z: pos.z + 16.0,
|
|
114
|
+
vx: ::Math.cos(angle) * speed,
|
|
115
|
+
vy: ::Math.sin(angle) * speed,
|
|
116
|
+
vz: 80.0 + rand * 60.0,
|
|
117
|
+
r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
|
|
118
|
+
life: 0.3 + rand * 0.3,
|
|
119
|
+
gravity_scale: 0.5,
|
|
120
|
+
ramp_type: :fire
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Explosion burst
|
|
126
|
+
def explosion(pos)
|
|
127
|
+
128.times do
|
|
128
|
+
color = @palette.rgb(0x6d)
|
|
129
|
+
emit(
|
|
130
|
+
x: pos.x + rand * 16 - 8, y: pos.y + rand * 16 - 8, z: pos.z + rand * 16 - 8,
|
|
131
|
+
vx: (rand * 512) - 256, vy: (rand * 512) - 256, vz: (rand * 512) - 256,
|
|
132
|
+
r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
|
|
133
|
+
life: 0.5 + rand * 0.5,
|
|
134
|
+
gravity_scale: 0.05,
|
|
135
|
+
ramp_type: :explosion
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Blood spurt
|
|
141
|
+
def blood(pos, count: 20)
|
|
142
|
+
count.times do
|
|
143
|
+
color_idx = 67 + (rand * 4).to_i # dark red palette range
|
|
144
|
+
color = @palette.rgb(color_idx)
|
|
145
|
+
emit(
|
|
146
|
+
x: pos.x, y: pos.y, z: pos.z,
|
|
147
|
+
vx: (rand * 128) - 64, vy: (rand * 128) - 64, vz: (rand * 128) - 64,
|
|
148
|
+
r: color[0] / 255.0, g: color[1] / 255.0, b: color[2] / 255.0,
|
|
149
|
+
life: 0.5 + rand * 0.3,
|
|
150
|
+
gravity_scale: 1.0
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def particle_count
|
|
156
|
+
@particles.size
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def emit(x:, y:, z:, vx:, vy:, vz:, r:, g:, b:, life:,
|
|
162
|
+
gravity_scale: 0.0, ramp_type: nil)
|
|
163
|
+
return if @particles.size >= MAX_PARTICLES
|
|
164
|
+
|
|
165
|
+
@particles << Particle.new(
|
|
166
|
+
x, y, z, vx, vy, vz,
|
|
167
|
+
r, g, b, 1.0,
|
|
168
|
+
life, 0.0, ramp_type, gravity_scale
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|