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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ # Renders Quake sky using the two-layer scrolling technique.
8
+ # Sky textures are 256x128, split into:
9
+ # - Left half (128x128): solid background layer
10
+ # - Right half (128x128): alpha foreground layer (index 0 = transparent)
11
+ class GLSky
12
+ def initialize(level, palette, texture_manager)
13
+ @level = level
14
+ @palette = palette
15
+ @texture_manager = texture_manager
16
+ @sky_surfaces = []
17
+ @solid_tex = nil
18
+ @alpha_tex = nil
19
+ @time = 0.0
20
+
21
+ find_sky_texture
22
+ precompute_sky_surfaces
23
+ end
24
+
25
+ def update(dt)
26
+ @time += dt
27
+ end
28
+
29
+ def render(camera, visible_face_set: nil)
30
+ return if @sky_surfaces.empty? || @solid_tex.nil?
31
+
32
+ # Filter to PVS-visible sky surfaces only. Without this, distant
33
+ # sky polys (e.g. the level's outdoor exit area) draw even when the
34
+ # camera is enclosed in a cave - and underwater they alpha-blend
35
+ # through translucent water as bright cloud shapes.
36
+ visible_surfaces = if visible_face_set
37
+ @sky_surfaces.select { |s| visible_face_set.include?(s[:face_index]) }
38
+ else
39
+ @sky_surfaces
40
+ end
41
+ return if visible_surfaces.empty?
42
+
43
+ # Render solid background layer
44
+ GL.BindTexture(GL::TEXTURE_2D, @solid_tex)
45
+ speed_scale = @time * 8.0
46
+ speed_scale -= speed_scale.floor & ~127
47
+
48
+ visible_surfaces.each do |surf|
49
+ GL.Begin(GL::TRIANGLE_FAN)
50
+ surf[:vertices].each do |v|
51
+ s, t = sky_texcoord(v, camera.position, speed_scale)
52
+ GL.TexCoord2f(s, t)
53
+ GL.Vertex3f(v.x, v.y, v.z)
54
+ end
55
+ GL.End
56
+ end
57
+
58
+ # Render alpha foreground layer with blending
59
+ GL.Enable(GL::BLEND)
60
+ GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
61
+ GL.BindTexture(GL::TEXTURE_2D, @alpha_tex)
62
+ speed_scale2 = @time * 16.0
63
+ speed_scale2 -= speed_scale2.floor & ~127
64
+
65
+ visible_surfaces.each do |surf|
66
+ GL.Begin(GL::TRIANGLE_FAN)
67
+ surf[:vertices].each do |v|
68
+ s, t = sky_texcoord(v, camera.position, speed_scale2)
69
+ GL.TexCoord2f(s, t)
70
+ GL.Vertex3f(v.x, v.y, v.z)
71
+ end
72
+ GL.End
73
+ end
74
+
75
+ GL.Disable(GL::BLEND)
76
+ end
77
+
78
+ private
79
+
80
+ def sky_texcoord(vertex, origin, speed_scale)
81
+ # Direction from camera to vertex
82
+ dx = vertex.x - origin.x
83
+ dy = vertex.y - origin.y
84
+ dz = (vertex.z - origin.z) * 3.0 # flatten sphere
85
+
86
+ len = ::Math.sqrt(dx * dx + dy * dy + dz * dz)
87
+ len = 6.0 * 63.0 / len # 378 / len
88
+
89
+ s = (speed_scale + dx * len) / 128.0
90
+ t = (speed_scale + dy * len) / 128.0
91
+ [s, t]
92
+ end
93
+
94
+ def find_sky_texture
95
+ @level.textures.each_with_index do |tex, idx|
96
+ next if tex.nil?
97
+ next unless tex.name.start_with?("sky")
98
+
99
+ # Split 256x128 texture into two 128x128 halves
100
+ upload_sky_halves(tex)
101
+ break
102
+ end
103
+ end
104
+
105
+ def upload_sky_halves(miptex)
106
+ w = miptex.width # 256
107
+ h = miptex.height # 128
108
+ half_w = w / 2 # 128
109
+
110
+ # Extract left half (solid background)
111
+ solid_pixels = String.new(capacity: half_w * h)
112
+ h.times do |row|
113
+ src_offset = row * w
114
+ solid_pixels << miptex.pixels[src_offset, half_w]
115
+ end
116
+
117
+ # Extract right half (alpha foreground)
118
+ alpha_pixels = String.new(capacity: half_w * h)
119
+ h.times do |row|
120
+ src_offset = row * w + half_w
121
+ alpha_pixels << miptex.pixels[src_offset, half_w]
122
+ end
123
+
124
+ @solid_tex = upload_texture(solid_pixels, half_w, h, transparent: false)
125
+ @alpha_tex = upload_texture(alpha_pixels, half_w, h, transparent: true)
126
+ end
127
+
128
+ def upload_texture(pixels, width, height, transparent:)
129
+ rgba = String.new(capacity: width * height * 4)
130
+ pixels.each_byte do |idx|
131
+ r, g, b = @palette.rgb(idx)
132
+ a = (transparent && idx == 0) ? 0 : 255
133
+ rgba << r << g << b << a
134
+ end
135
+
136
+ buf = "\0" * 4
137
+ GL.GenTextures(1, buf)
138
+ tex_id = buf.unpack1("V")
139
+
140
+ GL.BindTexture(GL::TEXTURE_2D, tex_id)
141
+ GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA, width, height, 0,
142
+ GL::RGBA, GL::UNSIGNED_BYTE, rgba)
143
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR)
144
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
145
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT)
146
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT)
147
+ tex_id
148
+ end
149
+
150
+ def precompute_sky_surfaces
151
+ @level.faces.each_with_index do |face, face_index|
152
+ texinfo = @level.texinfo[face.texinfo_index]
153
+ next if texinfo.nil?
154
+
155
+ tex = @level.textures[texinfo.miptex_index]
156
+ next if tex.nil?
157
+ next unless tex.name.start_with?("sky")
158
+
159
+ verts = Bsp::FaceVertices.extract(@level, face)
160
+ @sky_surfaces << { vertices: verts, face_index: face_index }
161
+ end
162
+ puts "Found #{@sky_surfaces.size} sky surfaces"
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ class GLTextureManager
8
+ TEX_SPECIAL = 1
9
+
10
+ attr_reader :gl_textures
11
+
12
+ def initialize(level, palette)
13
+ @level = level
14
+ @palette = palette
15
+ @gl_textures = {} # miptex_index -> GL texture id
16
+ end
17
+
18
+ def upload_all
19
+ @level.textures.each_with_index do |miptex, index|
20
+ next if miptex.nil? || miptex.pixels.nil?
21
+ @gl_textures[index] = upload_texture(miptex)
22
+ end
23
+ puts "Uploaded #{@gl_textures.size} textures to GL"
24
+ end
25
+
26
+ def bind(miptex_index)
27
+ tex_id = @gl_textures[miptex_index]
28
+ GL.BindTexture(GL::TEXTURE_2D, tex_id || 0)
29
+ end
30
+
31
+ private
32
+
33
+ def upload_texture(miptex)
34
+ rgba = @palette.indexed_to_rgba(miptex.pixels)
35
+
36
+ buf = "\0" * 4
37
+ GL.GenTextures(1, buf)
38
+ tex_id = buf.unpack1("V")
39
+
40
+ GL.BindTexture(GL::TEXTURE_2D, tex_id)
41
+ GL.TexImage2D(GL::TEXTURE_2D, 0, GL::RGBA,
42
+ miptex.width, miptex.height, 0,
43
+ GL::RGBA, GL::UNSIGNED_BYTE, rgba)
44
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT)
45
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT)
46
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR_MIPMAP_LINEAR)
47
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
48
+ GL.GenerateMipmap(GL::TEXTURE_2D)
49
+
50
+ tex_id
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ class GLTextured
8
+ TEX_SPECIAL = 1
9
+
10
+ def initialize(level, texture_manager, lightmap: nil, sky: nil, water: nil)
11
+ @level = level
12
+ @texture_manager = texture_manager
13
+ @lightmap = lightmap
14
+ @sky = sky
15
+ @water = water
16
+ @last_leaf = -1
17
+ @visible_surface_cache = nil
18
+
19
+ # Precompute ALL surfaces grouped by texture (for non-VIS fallback
20
+ # and as source data for VIS filtering)
21
+ @all_surfaces_by_texture = precompute_surfaces
22
+ @face_to_surface = build_face_index
23
+
24
+ puts "Precomputed #{@all_surfaces_by_texture.values.sum(&:size)} surfaces across #{@all_surfaces_by_texture.size} textures"
25
+ end
26
+
27
+ def render(camera, aspect)
28
+ GL.Clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT)
29
+
30
+ camera.apply_projection_gl(aspect)
31
+ camera.apply_gl
32
+
33
+ # Determine visible surfaces via PVS
34
+ surfaces_by_texture = visible_surfaces(camera)
35
+
36
+ # Render opaque world geometry
37
+ GL.Enable(GL::TEXTURE_2D)
38
+
39
+ if @lightmap
40
+ render_with_lightmaps(surfaces_by_texture)
41
+ else
42
+ render_without_lightmaps(surfaces_by_texture)
43
+ end
44
+
45
+ # Sky drawn before translucent water so translucent water can
46
+ # alpha-blend over sky-tinted background. PVS-cull keeps distant
47
+ # outdoor sky polys out, and TEXTURE_2D must stay enabled or sky
48
+ # polys render as untextured white shapes.
49
+ @sky&.render(camera, visible_face_set: @last_visible_face_set)
50
+
51
+ # Translucent water last so it alpha-blends over everything else.
52
+ @water&.render(alpha: 0.5)
53
+
54
+ GL.Disable(GL::TEXTURE_2D)
55
+ end
56
+
57
+ def update(dt)
58
+ @sky&.update(dt)
59
+ @water&.update(dt)
60
+ end
61
+
62
+ private
63
+
64
+ def visible_surfaces(camera)
65
+ leaf_index = Bsp::Vis.point_in_leaf(@level, camera.position)
66
+ leaf = @level.leafs[leaf_index]
67
+ # FatPVS depends on point position, so recompute every frame when
68
+ # the camera leaf touches a liquid surface. Otherwise the cheap
69
+ # leaf-keyed cache is fine.
70
+ near_liquid = Bsp::Vis.near_liquid_portal?(@level, leaf)
71
+
72
+ if near_liquid
73
+ compute_visible_surfaces(leaf_index, point: camera.position)
74
+ else
75
+ if leaf_index != @last_leaf
76
+ @last_leaf = leaf_index
77
+ @visible_surface_cache = compute_visible_surfaces(leaf_index)
78
+ end
79
+ @visible_surface_cache || @all_surfaces_by_texture
80
+ end
81
+ end
82
+
83
+ def compute_visible_surfaces(leaf_index, point: nil)
84
+ visible_face_set = Bsp::Vis.visible_faces(@level, leaf_index, point: point)
85
+ # Stash so the sky pass can cull against the same set.
86
+ @last_visible_face_set = visible_face_set
87
+
88
+ filtered = Hash.new { |h, k| h[k] = [] }
89
+
90
+ visible_face_set.each do |face_idx|
91
+ entry = @face_to_surface[face_idx]
92
+ next unless entry
93
+
94
+ miptex_index, surf_data = entry
95
+ filtered[miptex_index] << surf_data
96
+ end
97
+
98
+ filtered
99
+ end
100
+
101
+ def render_without_lightmaps(surfaces_by_texture)
102
+ GL.Color3f(1.0, 1.0, 1.0)
103
+
104
+ surfaces_by_texture.each do |miptex_index, surfaces|
105
+ @texture_manager.bind(miptex_index)
106
+
107
+ surfaces.each do |surf|
108
+ GL.Begin(GL::TRIANGLE_FAN)
109
+ surf[:vertices].each_with_index do |v, i|
110
+ u, t = surf[:texcoords][i]
111
+ GL.TexCoord2f(u, t)
112
+ GL.Vertex3f(v.x, v.y, v.z)
113
+ end
114
+ GL.End
115
+ end
116
+ end
117
+ end
118
+
119
+ def render_with_lightmaps(surfaces_by_texture)
120
+ # Two-pass approach: diffuse modulated by lightmap
121
+ # Pass 1: render diffuse texture
122
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
123
+ GL.Color3f(1.0, 1.0, 1.0)
124
+
125
+ surfaces_by_texture.each do |miptex_index, surfaces|
126
+ @texture_manager.bind(miptex_index)
127
+
128
+ surfaces.each do |surf|
129
+ GL.Begin(GL::TRIANGLE_FAN)
130
+ surf[:vertices].each_with_index do |v, i|
131
+ u, t = surf[:texcoords][i]
132
+ GL.TexCoord2f(u, t)
133
+ GL.Vertex3f(v.x, v.y, v.z)
134
+ end
135
+ GL.End
136
+ end
137
+ end
138
+
139
+ # Pass 2: multiply lightmap on top
140
+ GL.Enable(GL::BLEND)
141
+ GL.BlendFunc(GL::ZERO, GL::SRC_COLOR) # dst = dst * src (multiply)
142
+ GL.DepthFunc(GL::EQUAL) # only write where geometry already drawn
143
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
144
+
145
+ last_lm_tex = -1
146
+ surfaces_by_texture.each do |_miptex_index, surfaces|
147
+ surfaces.each do |surf|
148
+ face_idx = surf[:face_index]
149
+ next unless face_idx && @lightmap.face_lightmaps[face_idx]
150
+
151
+ lm_info = @lightmap.face_lightmaps[face_idx]
152
+ if lm_info.gl_texture != last_lm_tex
153
+ GL.BindTexture(GL::TEXTURE_2D, lm_info.gl_texture)
154
+ last_lm_tex = lm_info.gl_texture
155
+ end
156
+
157
+ texinfo = @level.texinfo[surf[:texinfo_index]]
158
+ GL.Begin(GL::TRIANGLE_FAN)
159
+ surf[:vertices].each_with_index do |v, i|
160
+ ls, lt = @lightmap.lightmap_texcoords(face_idx, v, texinfo)
161
+ GL.TexCoord2f(ls, lt)
162
+ GL.Vertex3f(v.x, v.y, v.z)
163
+ end
164
+ GL.End
165
+ end
166
+ end
167
+
168
+ GL.DepthFunc(GL::LESS)
169
+ GL.Disable(GL::BLEND)
170
+ GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::MODULATE)
171
+ end
172
+
173
+ def precompute_surfaces
174
+ grouped = Hash.new { |h, k| h[k] = [] }
175
+
176
+ # Only render faces belonging to model 0 (the worldmodel).
177
+ # Sub-model faces (doors, buttons, lifts) are rendered separately
178
+ # by GLBrushModel with entity transforms applied.
179
+ world = @level.models[0]
180
+ world_first = world.first_face
181
+ world_last = world_first + world.num_faces - 1
182
+
183
+ @level.faces.each_with_index do |face, face_index|
184
+ next if face_index < world_first || face_index > world_last
185
+
186
+ texinfo = @level.texinfo[face.texinfo_index]
187
+ next if texinfo.nil?
188
+ next if texinfo.flags & TEX_SPECIAL != 0
189
+
190
+ miptex_index = texinfo.miptex_index
191
+ tex = @level.textures[miptex_index]
192
+ next if tex.nil?
193
+ # Skip sky and water (handled by dedicated renderers)
194
+ next if tex.name.start_with?("sky")
195
+ next if tex.name.start_with?("*")
196
+ # Trigger volumes are invisible collision brushes
197
+ next if tex.name == "trigger"
198
+ next if tex.name == "clip"
199
+
200
+ surf = Bsp::FaceVertices.extract_surface(@level, face)
201
+ surf_data = {
202
+ vertices: surf.vertices,
203
+ texcoords: surf.texcoords,
204
+ texinfo_index: surf.texinfo_index,
205
+ face_index: face_index
206
+ }
207
+ grouped[miptex_index] << surf_data
208
+ end
209
+
210
+ grouped
211
+ end
212
+
213
+ def build_face_index
214
+ index = {}
215
+ @all_surfaces_by_texture.each do |miptex_index, surfaces|
216
+ surfaces.each do |surf_data|
217
+ index[surf_data[:face_index]] = [miptex_index, surf_data]
218
+ end
219
+ end
220
+ index
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+
5
+ module Quake
6
+ module Renderer
7
+ # Renders the first-person weapon view model.
8
+ # Uses the same projection as the world camera. Depth range is compressed
9
+ # to [0, 0.3] so the weapon always draws in front of world geometry
10
+ # without z-fighting (matches GLQuake's R_DrawViewModel).
11
+ class GLViewmodel
12
+ # Quake bob cvars (from view.c)
13
+ CL_BOB = 0.02 # cl_bob: amplitude multiplier
14
+ CL_BOBCYCLE = 0.6 # cl_bobcycle: seconds per full bob cycle
15
+ CL_BOBUP = 0.5 # cl_bobup: fraction of cycle spent going up
16
+
17
+ # Current bob value (applied to camera Z by the game loop)
18
+ attr_reader :bob
19
+
20
+ def initialize(pak, palette)
21
+ @pak = pak
22
+ @palette = palette
23
+ @weapon_renderers = {} # model_path -> GLAliasModel
24
+ @time = 0.0
25
+ @bob_speed = 0.0
26
+ @bob = 0.0
27
+ @current_model_path = nil
28
+ end
29
+
30
+ def set_weapon(model_path)
31
+ return if model_path == @current_model_path
32
+ @current_model_path = model_path
33
+ load_weapon(model_path) unless @weapon_renderers.key?(model_path)
34
+ end
35
+
36
+ def update(dt, speed)
37
+ @time += dt
38
+ @bob_speed = speed
39
+ @bob = calc_bob
40
+ end
41
+
42
+ def render(camera, aspect)
43
+ return unless @current_model_path
44
+
45
+ gl_model = @weapon_renderers[@current_model_path]
46
+ return unless gl_model
47
+
48
+ # Compress depth range so weapon always appears in front of world
49
+ GL.DepthRange(0.0, 0.3)
50
+ GL.DepthFunc(GL::LEQUAL)
51
+
52
+ # Weapon origin = camera position + forward*bob*0.4
53
+ # Camera already has the vertical bob applied (in the game loop),
54
+ # so only the forward push is needed here.
55
+ pos = camera.position + camera.forward * (@bob * 0.4)
56
+
57
+ GL.Enable(GL::TEXTURE_2D)
58
+
59
+ gl_model.render(
60
+ frame_index: 0,
61
+ lerp: 0.0,
62
+ position: pos,
63
+ yaw: camera.yaw,
64
+ pitch: camera.pitch
65
+ )
66
+
67
+ GL.DepthRange(0.0, 1.0)
68
+ GL.DepthFunc(GL::LESS)
69
+ end
70
+
71
+ private
72
+
73
+ # Exact V_CalcBob from Quake's view.c
74
+ def calc_bob
75
+ speed = @bob_speed
76
+ return 0.0 if speed < 1.0
77
+
78
+ # Asymmetric cycle: spend cl_bobup fraction going up, rest going down
79
+ cycle = @time - (@time / CL_BOBCYCLE).floor * CL_BOBCYCLE
80
+ cycle /= CL_BOBCYCLE
81
+
82
+ if cycle < CL_BOBUP
83
+ cycle = ::Math::PI * cycle / CL_BOBUP
84
+ else
85
+ cycle = ::Math::PI + ::Math::PI * (cycle - CL_BOBUP) / (1.0 - CL_BOBUP)
86
+ end
87
+
88
+ # 30% constant offset + 70% oscillation, proportional to speed
89
+ bob = speed * CL_BOB
90
+ bob = bob * 0.3 + bob * 0.7 * ::Math.sin(cycle)
91
+ bob.clamp(-7.0, 4.0)
92
+ end
93
+
94
+ def load_weapon(model_path)
95
+ begin
96
+ data = @pak.read(model_path)
97
+ return unless data
98
+
99
+ mdl = Mdl::Reader.new(data).parse
100
+ @weapon_renderers[model_path] = GLAliasModel.new(mdl, @palette)
101
+ puts " Loaded weapon model: #{model_path}"
102
+ rescue => e
103
+ puts " Failed to load weapon #{model_path}: #{e.message}"
104
+ @weapon_renderers[model_path] = nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end