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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/bin/quake +143 -0
  3. data/bin/quake-debug +83 -0
  4. data/lib/quake/bsp/face_vertices.rb +63 -0
  5. data/lib/quake/bsp/reader.rb +264 -0
  6. data/lib/quake/bsp/types.rb +30 -0
  7. data/lib/quake/bsp/vis.rb +246 -0
  8. data/lib/quake/camera.rb +99 -0
  9. data/lib/quake/debug/png_writer.rb +58 -0
  10. data/lib/quake/debug/screenshot.rb +26 -0
  11. data/lib/quake/debug/script.rb +179 -0
  12. data/lib/quake/entity.rb +116 -0
  13. data/lib/quake/game/brush_entities.rb +361 -0
  14. data/lib/quake/game/engine.rb +300 -0
  15. data/lib/quake/game/item_pickups.rb +137 -0
  16. data/lib/quake/game/player_state.rb +158 -0
  17. data/lib/quake/math/vec3.rb +35 -0
  18. data/lib/quake/mdl/reader.rb +176 -0
  19. data/lib/quake/mdl/types.rb +30 -0
  20. data/lib/quake/pak/reader.rb +57 -0
  21. data/lib/quake/pak_downloader.rb +145 -0
  22. data/lib/quake/palette.rb +32 -0
  23. data/lib/quake/physics/hull_trace.rb +193 -0
  24. data/lib/quake/physics/player.rb +357 -0
  25. data/lib/quake/renderer/gl_alias_model.rb +122 -0
  26. data/lib/quake/renderer/gl_brush_model.rb +162 -0
  27. data/lib/quake/renderer/gl_hud.rb +226 -0
  28. data/lib/quake/renderer/gl_lightmap.rb +261 -0
  29. data/lib/quake/renderer/gl_particles.rb +173 -0
  30. data/lib/quake/renderer/gl_sky.rb +166 -0
  31. data/lib/quake/renderer/gl_texture_manager.rb +54 -0
  32. data/lib/quake/renderer/gl_textured.rb +224 -0
  33. data/lib/quake/renderer/gl_viewmodel.rb +109 -0
  34. data/lib/quake/renderer/gl_water.rb +200 -0
  35. data/lib/quake/renderer/gl_wireframe.rb +36 -0
  36. data/lib/quake/sound/events.rb +58 -0
  37. data/lib/quake/sound/mixer.rb +105 -0
  38. data/lib/quake/version.rb +5 -0
  39. data/lib/quake/wad/reader.rb +69 -0
  40. data/lib/quake/window.rb +74 -0
  41. data/lib/quake.rb +19 -0
  42. 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