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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Mdl
5
+ # Parses Quake MDL (alias model) files.
6
+ # Binary format: 76-byte header, skins, texcoords, triangles, frames.
7
+ # Vertices are stored as 3 compressed bytes decompressed via scale/origin.
8
+ class Reader
9
+ IDPOLYHEADER = 0x4F504449 # "IDPO" in little-endian
10
+ MDL_VERSION = 6
11
+
12
+ def initialize(data)
13
+ @data = data
14
+ @pos = 0
15
+ end
16
+
17
+ def parse
18
+ header = read_header
19
+ skins = read_skins(header)
20
+ stverts = read_stverts(header[:numverts])
21
+ triangles = read_triangles(header[:numtris])
22
+ frames = read_frames(header[:numframes], header[:numverts])
23
+
24
+ Model.new(
25
+ scale: header[:scale],
26
+ scale_origin: header[:scale_origin],
27
+ bounding_radius: header[:boundingradius],
28
+ eye_position: header[:eyeposition],
29
+ skin_width: header[:skinwidth],
30
+ skin_height: header[:skinheight],
31
+ skins: skins,
32
+ stverts: stverts,
33
+ triangles: triangles,
34
+ frames: frames,
35
+ flags: header[:flags],
36
+ sync_type: header[:synctype]
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def read_header
43
+ ident, version = read_fmt("VV")
44
+ raise "Not an MDL file (ident=0x#{ident.to_s(16)})" unless ident == IDPOLYHEADER
45
+ raise "Unsupported MDL version #{version}" unless version == MDL_VERSION
46
+
47
+ sx, sy, sz = read_fmt("eee")
48
+ ox, oy, oz = read_fmt("eee")
49
+ boundingradius = read_fmt("e").first
50
+ ex, ey, ez = read_fmt("eee")
51
+ numskins, skinwidth, skinheight = read_fmt("VVV")
52
+ numverts, numtris, numframes = read_fmt("VVV")
53
+ synctype, flags = read_fmt("VV")
54
+ size = read_fmt("e").first
55
+
56
+ {
57
+ scale: Math::Vec3.new(sx, sy, sz),
58
+ scale_origin: Math::Vec3.new(ox, oy, oz),
59
+ boundingradius: boundingradius,
60
+ eyeposition: Math::Vec3.new(ex, ey, ez),
61
+ numskins: numskins,
62
+ skinwidth: skinwidth,
63
+ skinheight: skinheight,
64
+ numverts: numverts,
65
+ numtris: numtris,
66
+ numframes: numframes,
67
+ synctype: synctype,
68
+ flags: flags,
69
+ size: size
70
+ }
71
+ end
72
+
73
+ def read_skins(header)
74
+ skins = []
75
+ skin_size = header[:skinwidth] * header[:skinheight]
76
+
77
+ header[:numskins].times do
78
+ skin_type = read_fmt("V").first
79
+
80
+ if skin_type == 0 # ALIAS_SKIN_SINGLE
81
+ pixels = @data[@pos, skin_size]
82
+ @pos += skin_size
83
+ skins << [pixels]
84
+ else # ALIAS_SKIN_GROUP
85
+ num_skins = read_fmt("V").first
86
+ _intervals = read_fmt("e" * num_skins) # timing intervals
87
+ group = []
88
+ num_skins.times do
89
+ pixels = @data[@pos, skin_size]
90
+ @pos += skin_size
91
+ group << pixels
92
+ end
93
+ skins << group
94
+ end
95
+ end
96
+
97
+ skins
98
+ end
99
+
100
+ def read_stverts(count)
101
+ count.times.map do
102
+ on_seam, s, t = read_fmt("l<l<l<")
103
+ STVert.new(on_seam: on_seam, s: s, t: t)
104
+ end
105
+ end
106
+
107
+ def read_triangles(count)
108
+ count.times.map do
109
+ faces_front, v0, v1, v2 = read_fmt("l<l<l<l<")
110
+ Triangle.new(faces_front: faces_front, vertex_indices: [v0, v1, v2])
111
+ end
112
+ end
113
+
114
+ def read_frames(count, numverts)
115
+ count.times.map do
116
+ frame_type = read_fmt("V").first
117
+
118
+ if frame_type == 0 # ALIAS_SINGLE
119
+ read_single_frame(numverts)
120
+ else # ALIAS_GROUP
121
+ read_frame_group(numverts)
122
+ end
123
+ end
124
+ end
125
+
126
+ def read_single_frame(numverts)
127
+ bbox_min = read_trivertx
128
+ bbox_max = read_trivertx
129
+ name = @data[@pos, 16].unpack1("Z16")
130
+ @pos += 16
131
+
132
+ vertices = numverts.times.map { read_trivertx }
133
+
134
+ Frame.new(name: name, vertices: vertices,
135
+ bbox_min: bbox_min, bbox_max: bbox_max)
136
+ end
137
+
138
+ def read_frame_group(numverts)
139
+ num_frames = read_fmt("V").first
140
+ _bbox_min = read_trivertx
141
+ _bbox_max = read_trivertx
142
+
143
+ intervals = read_fmt("e" * num_frames)
144
+
145
+ frames = num_frames.times.map do
146
+ read_single_frame(numverts)
147
+ end
148
+
149
+ FrameGroup.new(frames: frames, intervals: intervals)
150
+ end
151
+
152
+ def read_trivertx
153
+ x, y, z, n = @data[@pos, 4].unpack("CCCC")
154
+ @pos += 4
155
+ Vertex.new(x: x, y: y, z: z, normal_index: n)
156
+ end
157
+
158
+ def read_fmt(fmt)
159
+ # Calculate size by counting format characters
160
+ size = 0
161
+ fmt.each_char do |c|
162
+ case c
163
+ when "e", "E", "V", "N" then size += 4
164
+ when "v", "n", "S", "s" then size += 2
165
+ when "C", "c" then size += 1
166
+ when "l", "L" then size += 4
167
+ when "<", ">" then next # endian modifiers
168
+ end
169
+ end
170
+ values = @data[@pos, size].unpack(fmt)
171
+ @pos += size
172
+ values
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Mdl
5
+ # Compressed vertex: 3 bytes position + 1 byte normal index
6
+ Vertex = Data.define(:x, :y, :z, :normal_index)
7
+
8
+ # Single animation frame
9
+ Frame = Data.define(:name, :vertices, :bbox_min, :bbox_max)
10
+
11
+ # Frame group (multiple sub-frames with timing)
12
+ FrameGroup = Data.define(:frames, :intervals)
13
+
14
+ # Triangle: 3 vertex indices + front-facing flag
15
+ Triangle = Data.define(:faces_front, :vertex_indices)
16
+
17
+ # Texture coordinate for a vertex
18
+ STVert = Data.define(:on_seam, :s, :t)
19
+
20
+ # Complete MDL model
21
+ Model = Data.define(
22
+ :scale, :scale_origin, :bounding_radius, :eye_position,
23
+ :skin_width, :skin_height, :skins,
24
+ :stverts, :triangles, :frames, :flags, :sync_type
25
+ )
26
+
27
+ ALIAS_ONSEAM = 0x0020
28
+ DT_FACES_FRONT = 0x0010
29
+ end
30
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Pak
5
+ Entry = Data.define(:name, :offset, :size)
6
+
7
+ class Reader
8
+ MAGIC = "PACK"
9
+ HEADER_SIZE = 12
10
+ DIR_ENTRY_SIZE = 64
11
+
12
+ attr_reader :entries
13
+
14
+ def initialize(path)
15
+ @path = path
16
+ @io = File.open(path, "rb")
17
+ @entries = {}
18
+ parse_header
19
+ end
20
+
21
+ def list
22
+ @entries.keys
23
+ end
24
+
25
+ def read(name)
26
+ entry = @entries[name] || @entries[name.downcase]
27
+ raise "File not found in PAK: #{name}" unless entry
28
+
29
+ @io.seek(entry.offset)
30
+ @io.read(entry.size)
31
+ end
32
+
33
+ def close
34
+ @io.close
35
+ end
36
+
37
+ private
38
+
39
+ def parse_header
40
+ header = @io.read(HEADER_SIZE)
41
+ magic, dirofs, dirlen = header.unpack("a4V2")
42
+ raise "Not a PAK file: #{magic.inspect}" unless magic == MAGIC
43
+
44
+ num_entries = dirlen / DIR_ENTRY_SIZE
45
+ @io.seek(dirofs)
46
+ dir_data = @io.read(dirlen)
47
+
48
+ num_entries.times do |i|
49
+ offset = i * DIR_ENTRY_SIZE
50
+ raw = dir_data[offset, DIR_ENTRY_SIZE]
51
+ name, filepos, filelen = raw.unpack("Z56V2")
52
+ @entries[name.downcase] = Entry.new(name: name, offset: filepos, size: filelen)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "fileutils"
6
+ require "tmpdir"
7
+
8
+ module Quake
9
+ # Downloads the shareware Quake pak0.pak if not present.
10
+ # The Quake shareware (episode 1) is freely redistributable; we fetch the
11
+ # archive from archive.org and extract pak0.pak via the system `unzip`.
12
+ class PakDownloader
13
+ SHAREWARE_URL = "https://archive.org/download/quakeshareware/QUAKE_SW.zip"
14
+ SHAREWARE_ZIP_SIZE = 18_079_976 # approximate, for progress fallback
15
+ PAK_FILENAME = "pak0.pak"
16
+
17
+ class DownloadError < StandardError; end
18
+
19
+ # Returns a path to a usable PAK directory (containing id1/pak0.pak), or
20
+ # nil/raises. Search order: `custom_path` -> `./data` -> `~/.quake/data`.
21
+ # If nothing is found, prompts the user to download.
22
+ def self.ensure_pak_available(custom_path = nil)
23
+ return custom_path if custom_path && File.exist?(File.join(custom_path, "id1", PAK_FILENAME))
24
+
25
+ local = File.join(Dir.pwd, "data")
26
+ return local if File.exist?(File.join(local, "id1", PAK_FILENAME))
27
+
28
+ home = File.join(Dir.home, ".quake", "data")
29
+ return home if File.exist?(File.join(home, "id1", PAK_FILENAME))
30
+
31
+ raise DownloadError, "PAK not found at #{custom_path}" if custom_path
32
+
33
+ prompt_and_download(home)
34
+ end
35
+
36
+ def self.prompt_and_download(destination_data_dir)
37
+ puts "No Quake pak0.pak found."
38
+ puts
39
+ puts "Download the shareware version of Quake (about 9 MB)?"
40
+ puts "It includes the first episode and is freely redistributable."
41
+ puts
42
+ print "Download shareware Quake? [Y/n] "
43
+
44
+ response = $stdin.gets&.strip&.downcase
45
+ if response.nil? || response.empty? || response == "y" || response == "yes"
46
+ download_shareware(destination_data_dir)
47
+ destination_data_dir
48
+ else
49
+ puts
50
+ puts "To play Quake you need a pak0.pak. Options:"
51
+ puts " 1. Run `quake-rb` again and accept the shareware download"
52
+ puts " 2. Copy your own pak0.pak into ./data/id1/"
53
+ puts " 3. Pass a custom path: quake-rb -basedir /path/to/data"
54
+ exit 1
55
+ end
56
+ end
57
+
58
+ def self.download_shareware(destination_data_dir)
59
+ Dir.mktmpdir("quake-rb-shareware") do |tmp|
60
+ zip_path = File.join(tmp, "quake106.zip")
61
+ download(SHAREWARE_URL, zip_path)
62
+ extract_pak(zip_path, destination_data_dir)
63
+ end
64
+ end
65
+
66
+ def self.download(url, dest_path)
67
+ puts
68
+ puts "Downloading shareware Quake from #{URI.parse(url).host}..."
69
+ FileUtils.mkdir_p(File.dirname(dest_path))
70
+ fetch_to_file(url, dest_path)
71
+ puts
72
+ size = File.size(dest_path)
73
+ raise DownloadError, "Download appears incomplete (#{size} bytes)" if size < 1_000_000
74
+ end
75
+
76
+ def self.fetch_to_file(url, dest_path, redirect_limit: 5)
77
+ raise DownloadError, "Too many redirects" if redirect_limit <= 0
78
+
79
+ uri = URI.parse(url)
80
+ ssl_opts = { use_ssl: uri.scheme == "https" }
81
+
82
+ Net::HTTP.start(uri.host, uri.port, **ssl_opts) do |http|
83
+ request = Net::HTTP::Get.new(uri)
84
+ http.request(request) do |response|
85
+ case response
86
+ when Net::HTTPRedirection
87
+ return fetch_to_file(response["location"], dest_path,
88
+ redirect_limit: redirect_limit - 1)
89
+ when Net::HTTPSuccess
90
+ total = response["content-length"]&.to_i || SHAREWARE_ZIP_SIZE
91
+ downloaded = 0
92
+ File.open(dest_path, "wb") do |f|
93
+ response.read_body do |chunk|
94
+ f.write(chunk)
95
+ downloaded += chunk.size
96
+ print_progress(downloaded, total)
97
+ end
98
+ end
99
+ else
100
+ raise DownloadError, "HTTP #{response.code} #{response.message}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # Quake's shareware ZIP contains an installer, with pak0.pak nested inside
107
+ # `resource/Q!ZIP/ID1/PAK0.PAK`. Extract just that one entry into
108
+ # `<destination_data_dir>/id1/pak0.pak`. Uses system `unzip` so we don't
109
+ # need a runtime dep on rubyzip.
110
+ def self.extract_pak(zip_path, destination_data_dir)
111
+ target_dir = File.join(destination_data_dir, "id1")
112
+ FileUtils.mkdir_p(target_dir)
113
+ target_pak = File.join(target_dir, PAK_FILENAME)
114
+
115
+ puts "Extracting pak0.pak..."
116
+
117
+ # -j junks paths so the entry lands as just "PAK0.PAK"
118
+ # -C makes glob matching case-insensitive
119
+ Dir.mktmpdir("quake-rb-unzip") do |tmp|
120
+ ok = system("unzip", "-jo", "-C", zip_path, "*PAK0.PAK", "-d", tmp,
121
+ out: File::NULL)
122
+ raise DownloadError, "unzip failed (is `unzip` installed?)" unless ok
123
+
124
+ extracted = Dir[File.join(tmp, "*")].find { |p| File.basename(p).match?(/pak0\.pak/i) }
125
+ raise DownloadError, "pak0.pak not found inside ZIP" unless extracted
126
+
127
+ FileUtils.mv(extracted, target_pak)
128
+ end
129
+
130
+ raise DownloadError, "extracted pak0.pak is suspiciously small" if File.size(target_pak) < 1_000_000
131
+ puts "Installed: #{target_pak} (#{(File.size(target_pak) / 1_048_576.0).round(1)} MB)"
132
+ puts
133
+ end
134
+
135
+ def self.print_progress(downloaded, total)
136
+ percent = (downloaded.to_f / total * 100).clamp(0, 100).to_i
137
+ bar_w = 40
138
+ filled = percent * bar_w / 100
139
+ bar = ("=" * filled) + ("-" * (bar_w - filled))
140
+ mb_d = (downloaded / 1_048_576.0).round(1)
141
+ mb_t = (total / 1_048_576.0).round(1)
142
+ print "\r[#{bar}] #{percent}% (#{mb_d}/#{mb_t} MB)"
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ class Palette
5
+ SIZE = 256
6
+
7
+ def initialize(data)
8
+ raise "Palette data must be 768 bytes, got #{data.bytesize}" unless data.bytesize == 768
9
+ @rgb = Array.new(SIZE)
10
+ SIZE.times do |i|
11
+ r, g, b = data[i * 3, 3].unpack("C3")
12
+ @rgb[i] = [r, g, b]
13
+ end
14
+ end
15
+
16
+ def rgb(index)
17
+ @rgb[index]
18
+ end
19
+
20
+ # Convert an array of 8-bit indexed pixels to RGBA bytes
21
+ # Last palette entry (255) is typically transparent
22
+ def indexed_to_rgba(pixels, transparent_index: 255)
23
+ rgba = String.new(capacity: pixels.bytesize * 4)
24
+ pixels.each_byte do |idx|
25
+ r, g, b = @rgb[idx]
26
+ a = (idx == transparent_index) ? 0 : 255
27
+ rgba << r << g << b << a
28
+ end
29
+ rgba
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Physics
5
+ # Trace result from hull tracing.
6
+ TraceResult = Data.define(
7
+ :all_solid, # started inside solid
8
+ :start_solid, # started inside solid but found empty space
9
+ :in_open, # trace passed through open space
10
+ :in_water, # trace passed through water
11
+ :fraction, # 0.0-1.0, how far along the trace before hitting
12
+ :end_pos, # final position (Vec3)
13
+ :plane_normal, # normal of hit plane (Vec3 or nil)
14
+ :plane_dist # distance of hit plane (Float or nil)
15
+ )
16
+
17
+ CONTENTS_EMPTY = -1
18
+ CONTENTS_SOLID = -2
19
+ CONTENTS_WATER = -3
20
+ CONTENTS_SLIME = -4
21
+ CONTENTS_LAVA = -5
22
+
23
+ DIST_EPSILON = 0.03125 # 1/32, same as Quake
24
+
25
+ module HullTrace
26
+ # Check what content type a point is in, using the clipnode tree.
27
+ # hull_clipnodes: array of ClipNode
28
+ # planes: array of Plane
29
+ # node_index: starting clipnode index
30
+ # point: Vec3
31
+ def self.point_contents(clipnodes, planes, node_index, point)
32
+ while node_index >= 0
33
+ node = clipnodes[node_index]
34
+ plane = planes[node.plane_index]
35
+
36
+ dist = point.dot(plane.normal) - plane.dist
37
+ node_index = dist >= 0 ? node.children[0] : node.children[1]
38
+ end
39
+ node_index # negative value = content type
40
+ end
41
+
42
+ # Trace a line from p1 to p2 through the hull.
43
+ # Returns a TraceResult.
44
+ def self.trace(clipnodes, planes, node_index, p1, p2)
45
+ result = {
46
+ all_solid: false, start_solid: false,
47
+ in_open: false, in_water: false,
48
+ fraction: 1.0, end_pos: p2,
49
+ plane_normal: nil, plane_dist: nil
50
+ }
51
+
52
+ recursive_trace(clipnodes, planes, node_index, 0.0, 1.0, p1, p2, result)
53
+
54
+ if result[:fraction] == 1.0
55
+ result[:end_pos] = p2
56
+ else
57
+ result[:end_pos] = Math::Vec3.new(
58
+ p1.x + result[:fraction] * (p2.x - p1.x),
59
+ p1.y + result[:fraction] * (p2.y - p1.y),
60
+ p1.z + result[:fraction] * (p2.z - p1.z)
61
+ )
62
+ end
63
+
64
+ TraceResult.new(**result)
65
+ end
66
+
67
+ # Trace against the world AND all solid brush entities, returning the
68
+ # nearest hit. Brush entity traces are done in entity-local space
69
+ # (subtract origin, trace sub-model hull, transform back).
70
+ def self.trace_world_and_entities(level, p1, p2, brush_entities, hull_num: 1)
71
+ clipnodes = level.clipnodes
72
+ planes = level.planes
73
+
74
+ # World trace
75
+ world_hull = level.models[0].head_nodes[hull_num]
76
+ world_hull = level.models[0].head_nodes[0] if world_hull < 0 || world_hull >= clipnodes.size
77
+ best = trace(clipnodes, planes, world_hull, p1, p2)
78
+
79
+ # Trace against each brush entity's sub-model
80
+ brush_entities&.each do |ent|
81
+ next unless ent.brush_entity?
82
+ # Triggers are SOLID_TRIGGER in Quake: they detect overlap but
83
+ # don't block movement. They're handled by check_triggers.
84
+ next if ent.classname.start_with?("trigger_")
85
+ model = level.models[ent.model_index]
86
+ next unless model
87
+
88
+ sub_hull = model.head_nodes[hull_num]
89
+ # Many sub-models only have hull 0; fall back gracefully
90
+ sub_hull = model.head_nodes[0] if sub_hull < 0 || sub_hull >= clipnodes.size
91
+ next if sub_hull < 0 || sub_hull >= clipnodes.size
92
+
93
+ # Transform trace into entity-local space
94
+ offset = ent.position
95
+ local_p1 = Math::Vec3.new(p1.x - offset.x, p1.y - offset.y, p1.z - offset.z)
96
+ local_p2 = Math::Vec3.new(p2.x - offset.x, p2.y - offset.y, p2.z - offset.z)
97
+
98
+ sub_result = trace(clipnodes, planes, sub_hull, local_p1, local_p2)
99
+
100
+ # Keep nearest hit
101
+ if sub_result.fraction < best.fraction
102
+ # Transform end_pos back to world space
103
+ ep = sub_result.end_pos
104
+ best = TraceResult.new(
105
+ all_solid: sub_result.all_solid,
106
+ start_solid: sub_result.start_solid,
107
+ in_open: sub_result.in_open,
108
+ in_water: sub_result.in_water,
109
+ fraction: sub_result.fraction,
110
+ end_pos: Math::Vec3.new(ep.x + offset.x, ep.y + offset.y, ep.z + offset.z),
111
+ plane_normal: sub_result.plane_normal,
112
+ plane_dist: sub_result.plane_dist
113
+ )
114
+ end
115
+ end
116
+
117
+ best
118
+ end
119
+
120
+ private_class_method def self.recursive_trace(clipnodes, planes, node_index, p1f, p2f, p1, p2, result)
121
+ if node_index < 0
122
+ # Leaf content
123
+ if node_index == CONTENTS_SOLID
124
+ result[:all_solid] = true unless result[:in_open]
125
+ result[:start_solid] = true
126
+ else
127
+ result[:all_solid] = false
128
+ result[:in_open] = true if node_index == CONTENTS_EMPTY
129
+ result[:in_water] = true if node_index <= CONTENTS_WATER
130
+ end
131
+ return
132
+ end
133
+
134
+ node = clipnodes[node_index]
135
+ plane = planes[node.plane_index]
136
+
137
+ d1 = p1.dot(plane.normal) - plane.dist
138
+ d2 = p2.dot(plane.normal) - plane.dist
139
+
140
+ # Both on same side?
141
+ if d1 >= 0 && d2 >= 0
142
+ recursive_trace(clipnodes, planes, node.children[0], p1f, p2f, p1, p2, result)
143
+ return
144
+ end
145
+ if d1 < 0 && d2 < 0
146
+ recursive_trace(clipnodes, planes, node.children[1], p1f, p2f, p1, p2, result)
147
+ return
148
+ end
149
+
150
+ # Split - find the crossing point
151
+ if d1 < 0
152
+ frac = (d1 + DIST_EPSILON) / (d1 - d2)
153
+ side = 1
154
+ else
155
+ frac = (d1 - DIST_EPSILON) / (d1 - d2)
156
+ side = 0
157
+ end
158
+
159
+ frac = frac.clamp(0.0, 1.0)
160
+
161
+ midf = p1f + (p2f - p1f) * frac
162
+ mid = Math::Vec3.new(
163
+ p1.x + frac * (p2.x - p1.x),
164
+ p1.y + frac * (p2.y - p1.y),
165
+ p1.z + frac * (p2.z - p1.z)
166
+ )
167
+
168
+ # Traverse near side first
169
+ recursive_trace(clipnodes, planes, node.children[side], p1f, midf, p1, mid, result)
170
+
171
+ # Check if near side hit anything
172
+ contents = point_contents(clipnodes, planes, node.children[1 - side], mid)
173
+ if contents != CONTENTS_SOLID
174
+ # Continue to far side
175
+ recursive_trace(clipnodes, planes, node.children[1 - side], midf, p2f, mid, p2, result)
176
+ return
177
+ end
178
+
179
+ # Far side is solid - this is the impact point
180
+ if result[:fraction] > midf
181
+ result[:fraction] = midf
182
+ if side == 0
183
+ result[:plane_normal] = plane.normal
184
+ result[:plane_dist] = plane.dist
185
+ else
186
+ result[:plane_normal] = -plane.normal
187
+ result[:plane_dist] = -plane.dist
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end