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,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quake
|
|
4
|
+
module Bsp
|
|
5
|
+
# PVS (Potentially Visible Set) decompression and leaf lookup.
|
|
6
|
+
# Each leaf stores a compressed bitvector indicating which other leaves
|
|
7
|
+
# are visible from it. The compression is simple RLE: a zero byte is
|
|
8
|
+
# followed by a count of zero bytes to insert.
|
|
9
|
+
module Vis
|
|
10
|
+
# Decompress the PVS for a leaf into an array of visible leaf indices.
|
|
11
|
+
# visibility - raw visibility lump data (String)
|
|
12
|
+
# vis_offset - byte offset into visibility data for this leaf
|
|
13
|
+
# num_leafs - total number of leafs in the level (excluding leaf 0)
|
|
14
|
+
# Returns a Set of visible leaf indices (1-based, matching leafs array).
|
|
15
|
+
def self.decompress_pvs(visibility, vis_offset, num_leafs)
|
|
16
|
+
return all_visible(num_leafs) if vis_offset < 0 || visibility.nil? || visibility.empty?
|
|
17
|
+
|
|
18
|
+
num_bytes = (num_leafs + 7) / 8
|
|
19
|
+
pvs = String.new("\0" * num_bytes, encoding: Encoding::BINARY)
|
|
20
|
+
|
|
21
|
+
src = vis_offset
|
|
22
|
+
dst = 0
|
|
23
|
+
|
|
24
|
+
while dst < num_bytes
|
|
25
|
+
byte = visibility.getbyte(src)
|
|
26
|
+
# Vis data ended before filling the bitvector. Remaining bytes
|
|
27
|
+
# are already zero (no additional visible leaves), which is safe.
|
|
28
|
+
break if byte.nil?
|
|
29
|
+
src += 1
|
|
30
|
+
|
|
31
|
+
if byte != 0
|
|
32
|
+
pvs.setbyte(dst, byte)
|
|
33
|
+
dst += 1
|
|
34
|
+
else
|
|
35
|
+
# RLE: next byte is count of zero bytes
|
|
36
|
+
count = visibility.getbyte(src) || 0
|
|
37
|
+
src += 1
|
|
38
|
+
dst += count # pvs already zeroed
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Convert bitvector to set of leaf indices
|
|
43
|
+
visible = Set.new
|
|
44
|
+
num_leafs.times do |i|
|
|
45
|
+
byte_idx = i / 8
|
|
46
|
+
bit_idx = i % 8
|
|
47
|
+
if pvs.getbyte(byte_idx) & (1 << bit_idx) != 0
|
|
48
|
+
visible << (i + 1) # leaf indices are 1-based (leaf 0 is the solid leaf)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
visible
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Find which leaf a point is in by walking the BSP node tree.
|
|
55
|
+
def self.point_in_leaf(level, point)
|
|
56
|
+
node_index = 0
|
|
57
|
+
|
|
58
|
+
loop do
|
|
59
|
+
node = level.nodes[node_index]
|
|
60
|
+
plane = level.planes[node.plane_index]
|
|
61
|
+
|
|
62
|
+
dist = point.dot(plane.normal) - plane.dist
|
|
63
|
+
child = dist >= 0 ? node.children[0] : node.children[1]
|
|
64
|
+
|
|
65
|
+
# Negative child index means leaf: ~child gives leaf index
|
|
66
|
+
if child < 0
|
|
67
|
+
return ~child
|
|
68
|
+
else
|
|
69
|
+
node_index = child
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Compute a "fat" PVS by unioning the PVS of every leaf within ~8 units
|
|
75
|
+
# of the given point. Used near translucent water surfaces so the
|
|
76
|
+
# underwater leaves (which standard PVS excludes from above-water
|
|
77
|
+
# leaves) are included; without this the cave bed isn't drawn and
|
|
78
|
+
# alpha-blended water shows the clear color through gaps.
|
|
79
|
+
# Port of quakespasm SV_FatPVS / SV_AddToFatPVS.
|
|
80
|
+
def self.fat_pvs(level, point)
|
|
81
|
+
num_leafs = level.leafs.size - 1
|
|
82
|
+
result = Set.new
|
|
83
|
+
add_to_fat_pvs(level, point, 0, num_leafs, result)
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.add_to_fat_pvs(level, point, node_index, num_leafs, result)
|
|
88
|
+
loop do
|
|
89
|
+
if node_index < 0
|
|
90
|
+
leaf_index = ~node_index
|
|
91
|
+
return if leaf_index == 0 # skip the universal solid leaf
|
|
92
|
+
leaf = level.leafs[leaf_index]
|
|
93
|
+
return if leaf.nil?
|
|
94
|
+
result.merge(decompress_pvs(level.visibility, leaf.vis_offset, num_leafs))
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
node = level.nodes[node_index]
|
|
99
|
+
plane = level.planes[node.plane_index]
|
|
100
|
+
d = point.dot(plane.normal) - plane.dist
|
|
101
|
+
|
|
102
|
+
if d > 8.0
|
|
103
|
+
node_index = node.children[0]
|
|
104
|
+
elsif d < -8.0
|
|
105
|
+
node_index = node.children[1]
|
|
106
|
+
else
|
|
107
|
+
# Straddling the plane within the fat radius - descend both sides
|
|
108
|
+
add_to_fat_pvs(level, point, node.children[0], num_leafs, result)
|
|
109
|
+
node_index = node.children[1]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# True if any of the given leaf's marksurfaces is a turbulent (liquid)
|
|
115
|
+
# surface. Quakespasm's "near water portal" check.
|
|
116
|
+
def self.near_liquid_portal?(level, leaf)
|
|
117
|
+
return false if leaf.nil?
|
|
118
|
+
leaf.num_marksurfaces.times do |i|
|
|
119
|
+
face_idx = level.marksurfaces[leaf.first_marksurface + i]
|
|
120
|
+
face = level.faces[face_idx]
|
|
121
|
+
next if face.nil?
|
|
122
|
+
ti = level.texinfo[face.texinfo_index]
|
|
123
|
+
next if ti.nil?
|
|
124
|
+
tex = level.textures[ti.miptex_index]
|
|
125
|
+
return true if tex && tex.name.start_with?("*")
|
|
126
|
+
end
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Precompute, for each liquid (turb) face, the two leaves it physically
|
|
131
|
+
# separates (computed via BSP traversal of a point on either side of
|
|
132
|
+
# the face's plane). The standard Quake VIS compiler treats water as
|
|
133
|
+
# opaque, so above-water and underwater leaves never see each other in
|
|
134
|
+
# the PVS. Marksurface lists are also unreliable here - many water
|
|
135
|
+
# faces are marked in only one leaf - so we use geometry instead. We
|
|
136
|
+
# use this index at runtime to "vis-through" water surfaces: when a
|
|
137
|
+
# water face is in the visible set, also include the leaves on its
|
|
138
|
+
# other side. Without this, translucent water alpha-blends against
|
|
139
|
+
# the clear color where the cave bed should be.
|
|
140
|
+
def self.liquid_face_to_leaves(level)
|
|
141
|
+
@liquid_face_to_leaves_cache ||= {}
|
|
142
|
+
cached = @liquid_face_to_leaves_cache[level.object_id]
|
|
143
|
+
return cached if cached
|
|
144
|
+
|
|
145
|
+
index = {}
|
|
146
|
+
level.faces.each_with_index do |face, face_idx|
|
|
147
|
+
next if face.nil?
|
|
148
|
+
ti = level.texinfo[face.texinfo_index]
|
|
149
|
+
next if ti.nil?
|
|
150
|
+
tex = level.textures[ti.miptex_index]
|
|
151
|
+
next unless tex && tex.name.start_with?("*")
|
|
152
|
+
|
|
153
|
+
# Compute face center
|
|
154
|
+
cx = cy = cz = 0.0
|
|
155
|
+
n = face.num_edges
|
|
156
|
+
n.times do |i|
|
|
157
|
+
se = level.surfedges[face.first_edge + i]
|
|
158
|
+
edge = level.edges[se.abs]
|
|
159
|
+
v = se >= 0 ? level.vertices[edge.v0] : level.vertices[edge.v1]
|
|
160
|
+
cx += v.x; cy += v.y; cz += v.z
|
|
161
|
+
end
|
|
162
|
+
cx /= n; cy /= n; cz /= n
|
|
163
|
+
|
|
164
|
+
plane = level.planes[face.plane_index]
|
|
165
|
+
# Step a small distance along + and - the plane normal, find the
|
|
166
|
+
# leaf each point lands in.
|
|
167
|
+
offsets = [1.0, -1.0]
|
|
168
|
+
leaves = offsets.map do |o|
|
|
169
|
+
pt = Math::Vec3.new(
|
|
170
|
+
cx + plane.normal.x * o,
|
|
171
|
+
cy + plane.normal.y * o,
|
|
172
|
+
cz + plane.normal.z * o
|
|
173
|
+
)
|
|
174
|
+
point_in_leaf(level, pt)
|
|
175
|
+
end.uniq.reject { |l| l == 0 } # skip solid leaf
|
|
176
|
+
|
|
177
|
+
index[face_idx] = leaves
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@liquid_face_to_leaves_cache[level.object_id] = index
|
|
181
|
+
index
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Mark which faces are visible from the given leaf.
|
|
185
|
+
# Returns a Set of face indices that should be rendered.
|
|
186
|
+
def self.visible_faces(level, leaf_index, point: nil)
|
|
187
|
+
leaf = level.leafs[leaf_index]
|
|
188
|
+
return Set.new if leaf.nil?
|
|
189
|
+
|
|
190
|
+
num_leafs = level.leafs.size - 1 # exclude leaf 0
|
|
191
|
+
# Leaf 0 is the universal SOLID leaf and has no real PVS; its
|
|
192
|
+
# vis_offset is unused but Quake BSPs sometimes store 0 here, which
|
|
193
|
+
# would otherwise decompress garbage. Match TyrQuake Mod_LeafPVS:
|
|
194
|
+
# treat the solid leaf as all-visible so a camera that ends up in
|
|
195
|
+
# solid space (noclip, edge cases) still sees the world.
|
|
196
|
+
visible_leafs = if leaf_index == 0
|
|
197
|
+
all_visible(num_leafs)
|
|
198
|
+
elsif point && near_liquid_portal?(level, leaf)
|
|
199
|
+
# Camera leaf touches a liquid surface. Use FatPVS
|
|
200
|
+
# so the underwater leaves are included and
|
|
201
|
+
# translucent water doesn't reveal void where the
|
|
202
|
+
# bed should be.
|
|
203
|
+
fat_pvs(level, point)
|
|
204
|
+
else
|
|
205
|
+
decompress_pvs(level.visibility, leaf.vis_offset, num_leafs)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
face_set = Set.new
|
|
209
|
+
leafs_to_walk = visible_leafs.dup
|
|
210
|
+
leafs_to_walk << leaf_index # also include current leaf
|
|
211
|
+
|
|
212
|
+
# Vis-through water: any visible water face brings the leaves on its
|
|
213
|
+
# other side into view too. Standard Quake PVS treats water as
|
|
214
|
+
# opaque, so without this expansion translucent water from above
|
|
215
|
+
# blends against void instead of the underwater cave bed.
|
|
216
|
+
liquid_index = liquid_face_to_leaves(level)
|
|
217
|
+
seen_leafs = Set.new
|
|
218
|
+
worklist = leafs_to_walk.to_a
|
|
219
|
+
|
|
220
|
+
while (li = worklist.shift)
|
|
221
|
+
next unless seen_leafs.add?(li)
|
|
222
|
+
vl = level.leafs[li]
|
|
223
|
+
next if vl.nil?
|
|
224
|
+
|
|
225
|
+
vl.num_marksurfaces.times do |i|
|
|
226
|
+
face_idx = level.marksurfaces[vl.first_marksurface + i]
|
|
227
|
+
face_set << face_idx
|
|
228
|
+
|
|
229
|
+
# If this is a water face, queue the leaves on its other side.
|
|
230
|
+
other_leaves = liquid_index[face_idx]
|
|
231
|
+
next unless other_leaves
|
|
232
|
+
other_leaves.each do |other_leaf|
|
|
233
|
+
worklist << other_leaf unless seen_leafs.include?(other_leaf)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
face_set
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def self.all_visible(num_leafs)
|
|
242
|
+
Set.new(1..num_leafs)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/quake/camera.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
require "glu"
|
|
5
|
+
|
|
6
|
+
module Quake
|
|
7
|
+
class Camera
|
|
8
|
+
attr_accessor :position, :yaw, :pitch
|
|
9
|
+
attr_reader :fov, :near, :far
|
|
10
|
+
|
|
11
|
+
SPEED = 320.0 # units per second (Quake run speed)
|
|
12
|
+
SENSITIVITY = 0.15 # degrees per pixel
|
|
13
|
+
MAX_MOUSE_DELTA = 50 # clamp insane deltas (e.g. first frame warp)
|
|
14
|
+
|
|
15
|
+
def initialize(position: Math::Vec3::ORIGIN, yaw: 0.0, pitch: 0.0,
|
|
16
|
+
fov: 90.0, near: 4.0, far: 4096.0)
|
|
17
|
+
@position = position
|
|
18
|
+
@yaw = yaw
|
|
19
|
+
@pitch = pitch
|
|
20
|
+
@fov = fov
|
|
21
|
+
@near = near
|
|
22
|
+
@far = far
|
|
23
|
+
@ignore_mouse = 2 # skip first N motion events (SDL warp artifacts)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def forward
|
|
27
|
+
# Quake: X = forward, Y = left, Z = up
|
|
28
|
+
# yaw rotates around Z, pitch rotates around Y
|
|
29
|
+
ry = deg2rad(@yaw)
|
|
30
|
+
rp = deg2rad(@pitch)
|
|
31
|
+
cp = ::Math.cos(rp)
|
|
32
|
+
Math::Vec3.new(
|
|
33
|
+
::Math.cos(ry) * cp,
|
|
34
|
+
::Math.sin(ry) * cp,
|
|
35
|
+
-::Math.sin(rp)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def right
|
|
40
|
+
ry = deg2rad(@yaw - 90.0)
|
|
41
|
+
Math::Vec3.new(::Math.cos(ry), ::Math.sin(ry), 0.0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def up
|
|
45
|
+
Math::Vec3.new(0.0, 0.0, 1.0)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def move_forward(dt)
|
|
49
|
+
@position = @position + forward * (SPEED * dt)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def move_right(dt)
|
|
53
|
+
@position = @position + right * (SPEED * dt)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def move_up(dt)
|
|
57
|
+
@position = @position + up * (SPEED * dt)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rotate(dx, dy)
|
|
61
|
+
if @ignore_mouse > 0
|
|
62
|
+
@ignore_mouse -= 1
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
dx = dx.clamp(-MAX_MOUSE_DELTA, MAX_MOUSE_DELTA)
|
|
66
|
+
dy = dy.clamp(-MAX_MOUSE_DELTA, MAX_MOUSE_DELTA)
|
|
67
|
+
@yaw -= dx * SENSITIVITY
|
|
68
|
+
@pitch += dy * SENSITIVITY
|
|
69
|
+
@pitch = @pitch.clamp(-89.0, 89.0)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_projection_gl(aspect)
|
|
73
|
+
GL.MatrixMode(GL::PROJECTION)
|
|
74
|
+
GL.LoadIdentity
|
|
75
|
+
GLU.Perspective(@fov, aspect, @near, @far)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply_gl
|
|
79
|
+
GL.MatrixMode(GL::MODELVIEW)
|
|
80
|
+
GL.LoadIdentity
|
|
81
|
+
|
|
82
|
+
# Quake coords (X-forward, Y-left, Z-up) -> GL coords (X-right, Y-up, Z-backward)
|
|
83
|
+
# Rotate -90 around X to convert Z-up to Y-up
|
|
84
|
+
# Rotate 90 around Z to convert X-forward to -Z-backward
|
|
85
|
+
GL.Rotatef(-90.0, 1.0, 0.0, 0.0) # Z-up -> Y-up
|
|
86
|
+
GL.Rotatef(90.0, 0.0, 0.0, 1.0) # X-forward -> GL
|
|
87
|
+
|
|
88
|
+
GL.Rotatef(-@pitch, 0.0, 1.0, 0.0)
|
|
89
|
+
GL.Rotatef(-@yaw, 0.0, 0.0, 1.0)
|
|
90
|
+
GL.Translatef(-@position.x, -@position.y, -@position.z)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def deg2rad(deg)
|
|
96
|
+
deg * ::Math::PI / 180.0
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Quake
|
|
6
|
+
module Debug
|
|
7
|
+
# Minimal PNG encoder using zlib (no external dependencies).
|
|
8
|
+
# Writes 8-bit RGB PNGs.
|
|
9
|
+
module PngWriter
|
|
10
|
+
SIGNATURE = "\x89PNG\r\n\x1a\n".b
|
|
11
|
+
|
|
12
|
+
# Write an RGB PNG. rgb_data is W*H*3 bytes, top-to-bottom rows.
|
|
13
|
+
def self.write(filename, width, height, rgb_data)
|
|
14
|
+
File.binwrite(filename, encode(width, height, rgb_data))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.encode(width, height, rgb_data)
|
|
18
|
+
bytes_per_row = width * 3
|
|
19
|
+
|
|
20
|
+
# PNG requires a filter byte at the start of each row
|
|
21
|
+
filtered = String.new(capacity: (bytes_per_row + 1) * height)
|
|
22
|
+
height.times do |y|
|
|
23
|
+
filtered << "\x00".b # filter type 0 = None
|
|
24
|
+
filtered << rgb_data[y * bytes_per_row, bytes_per_row]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
compressed = Zlib::Deflate.deflate(filtered)
|
|
28
|
+
|
|
29
|
+
out = String.new(capacity: 64 + compressed.bytesize)
|
|
30
|
+
out << SIGNATURE
|
|
31
|
+
# IHDR: width, height, bit_depth, color_type, compression, filter, interlace
|
|
32
|
+
ihdr = [width, height].pack("NN") + [8, 2, 0, 0, 0].pack("CCCCC")
|
|
33
|
+
out << chunk("IHDR", ihdr)
|
|
34
|
+
out << chunk("IDAT", compressed)
|
|
35
|
+
out << chunk("IEND", "".b)
|
|
36
|
+
out
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build a PNG chunk: length, type, data, CRC
|
|
40
|
+
def self.chunk(type, data)
|
|
41
|
+
type_data = type.b + data.b
|
|
42
|
+
crc = Zlib.crc32(type_data)
|
|
43
|
+
[data.bytesize].pack("N") + type_data + [crc].pack("N")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Flip RGB data vertically (glReadPixels returns bottom-up rows,
|
|
47
|
+
# PNG expects top-down).
|
|
48
|
+
def self.flip_rows(rgb_data, width, height)
|
|
49
|
+
bytes_per_row = width * 3
|
|
50
|
+
out = String.new(capacity: rgb_data.bytesize)
|
|
51
|
+
(height - 1).downto(0) do |y|
|
|
52
|
+
out << rgb_data[y * bytes_per_row, bytes_per_row]
|
|
53
|
+
end
|
|
54
|
+
out
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opengl"
|
|
4
|
+
require_relative "png_writer"
|
|
5
|
+
|
|
6
|
+
module Quake
|
|
7
|
+
module Debug
|
|
8
|
+
# Captures the current GL framebuffer to a PNG file via glReadPixels.
|
|
9
|
+
module Screenshot
|
|
10
|
+
# Read the current framebuffer as RGB bytes (bottom-up).
|
|
11
|
+
def self.capture(width, height)
|
|
12
|
+
buf = ("\x00".b * (width * height * 3))
|
|
13
|
+
GL.PixelStorei(GL::PACK_ALIGNMENT, 1)
|
|
14
|
+
GL.ReadPixels(0, 0, width, height, GL::RGB, GL::UNSIGNED_BYTE, buf)
|
|
15
|
+
buf
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Save the current framebuffer as a PNG file.
|
|
19
|
+
def self.save(filename, width, height)
|
|
20
|
+
raw = capture(width, height)
|
|
21
|
+
flipped = PngWriter.flip_rows(raw, width, height)
|
|
22
|
+
PngWriter.write(filename, width, height, flipped)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "sdl2"
|
|
5
|
+
|
|
6
|
+
module Quake
|
|
7
|
+
module Debug
|
|
8
|
+
# DSL for scripting headless game runs.
|
|
9
|
+
#
|
|
10
|
+
# Example script.rb:
|
|
11
|
+
#
|
|
12
|
+
# load_map "maps/e1m1.bsp"
|
|
13
|
+
# teleport 480, 600, 96, yaw: 90
|
|
14
|
+
# ticks 30
|
|
15
|
+
# screenshot "shots/start.png"
|
|
16
|
+
#
|
|
17
|
+
# hold :w
|
|
18
|
+
# ticks 60 # 1 second forward
|
|
19
|
+
# release :w
|
|
20
|
+
#
|
|
21
|
+
# ticks 30
|
|
22
|
+
# screenshot "shots/forward.png"
|
|
23
|
+
# dump_state
|
|
24
|
+
class Script
|
|
25
|
+
DEFAULT_DT = 1.0 / 60.0
|
|
26
|
+
|
|
27
|
+
KEY_MAP = {
|
|
28
|
+
w: SDL::SCANCODE_W, a: SDL::SCANCODE_A,
|
|
29
|
+
s: SDL::SCANCODE_S, d: SDL::SCANCODE_D,
|
|
30
|
+
space: SDL::SCANCODE_SPACE,
|
|
31
|
+
ctrl: SDL::SCANCODE_LCTRL,
|
|
32
|
+
shift: SDL::SCANCODE_LSHIFT,
|
|
33
|
+
c: SDL::SCANCODE_C
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def initialize(engine, output: $stdout)
|
|
37
|
+
@engine = engine
|
|
38
|
+
@output = output
|
|
39
|
+
@held = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_file(path)
|
|
43
|
+
instance_eval(File.read(path), path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_block(&block)
|
|
47
|
+
instance_eval(&block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ---- Map / level ----
|
|
51
|
+
|
|
52
|
+
def load_map(map_name)
|
|
53
|
+
@output.puts ">> load_map #{map_name}"
|
|
54
|
+
@engine.load_map(map_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ---- Player control ----
|
|
58
|
+
|
|
59
|
+
def teleport(x, y, z, yaw: nil, pitch: nil)
|
|
60
|
+
@output.puts ">> teleport (#{x}, #{y}, #{z}) yaw=#{yaw} pitch=#{pitch}"
|
|
61
|
+
@engine.teleport(x, y, z, yaw: yaw, pitch: pitch)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def set_yaw(yaw)
|
|
65
|
+
@engine.player.instance_variable_set(:@yaw, yaw.to_f)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set_pitch(pitch)
|
|
69
|
+
@engine.player.instance_variable_set(:@pitch, pitch.to_f)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def noclip(enabled = true)
|
|
73
|
+
@engine.player.noclip = enabled
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ---- Input ----
|
|
77
|
+
|
|
78
|
+
def hold(key)
|
|
79
|
+
sc = scancode(key)
|
|
80
|
+
@held[sc] = true
|
|
81
|
+
@engine.set_key(sc, true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def release(key)
|
|
85
|
+
sc = scancode(key)
|
|
86
|
+
@held[sc] = false
|
|
87
|
+
@engine.set_key(sc, false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def press(key, frames: 2)
|
|
91
|
+
hold(key)
|
|
92
|
+
ticks(frames)
|
|
93
|
+
release(key)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def clear_keys
|
|
97
|
+
@held = {}
|
|
98
|
+
@engine.clear_keys
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ---- Time ----
|
|
102
|
+
|
|
103
|
+
def ticks(count, dt: DEFAULT_DT)
|
|
104
|
+
count.to_i.times { @engine.tick(dt) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_seconds(seconds, dt: DEFAULT_DT)
|
|
108
|
+
ticks((seconds / dt).round, dt: dt)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ---- Output ----
|
|
112
|
+
|
|
113
|
+
def screenshot(filename)
|
|
114
|
+
FileUtils.mkdir_p(File.dirname(filename)) if filename.include?("/")
|
|
115
|
+
@engine.screenshot(filename)
|
|
116
|
+
@output.puts ">> screenshot #{filename}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dump_state
|
|
120
|
+
state = @engine.dump_state
|
|
121
|
+
@output.puts ">> state"
|
|
122
|
+
@output.puts JSON.pretty_generate(state)
|
|
123
|
+
state
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Print just specific fields
|
|
127
|
+
def dump(*paths)
|
|
128
|
+
state = @engine.dump_state
|
|
129
|
+
paths.each do |path|
|
|
130
|
+
value = path.to_s.split(".").inject(state) do |acc, key|
|
|
131
|
+
acc.is_a?(Hash) ? acc[key.to_sym] : nil
|
|
132
|
+
end
|
|
133
|
+
@output.puts " #{path} = #{value.inspect}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def find_entity(classname:, targetname: nil)
|
|
138
|
+
@engine.entities.find do |e|
|
|
139
|
+
e.classname == classname && (targetname.nil? || e.targetname == targetname)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def list_entities(classname_pattern = nil)
|
|
144
|
+
ents = @engine.entities
|
|
145
|
+
if classname_pattern
|
|
146
|
+
re = Regexp.new(classname_pattern)
|
|
147
|
+
ents = ents.select { |e| e.classname =~ re }
|
|
148
|
+
end
|
|
149
|
+
ents.each do |e|
|
|
150
|
+
@output.puts " #{e.classname} pos=#{[e.position.x, e.position.y, e.position.z].inspect} " \
|
|
151
|
+
"target=#{e.target.inspect} targetname=#{e.targetname.inspect}"
|
|
152
|
+
end
|
|
153
|
+
ents.size
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Trigger a brush entity by name (simulates a button press)
|
|
157
|
+
def trigger(target_name)
|
|
158
|
+
@engine.brush_game.send(:fire_targets, target_name)
|
|
159
|
+
@output.puts ">> trigger #{target_name}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def log(msg)
|
|
163
|
+
@output.puts msg
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def scancode(key)
|
|
169
|
+
case key
|
|
170
|
+
when Symbol then KEY_MAP.fetch(key) { raise "Unknown key: #{key}" }
|
|
171
|
+
when Integer then key
|
|
172
|
+
else raise "Unknown key: #{key.inspect}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
require "fileutils"
|