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,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
|