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