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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ # Represents a parsed entity from the BSP entities lump.
5
+ # All Quake entities (players, monsters, items, triggers, brush models)
6
+ # are defined as key-value property bags in the BSP entity string.
7
+ class Entity
8
+ attr_accessor :properties, :position, :angle, :angles, :classname,
9
+ :model_index, :think_time, :state, :move_dir,
10
+ :target, :targetname, :speed, :wait, :lip, :health,
11
+ :sounds, :message
12
+
13
+ def initialize(properties = {})
14
+ @properties = properties
15
+ @classname = properties["classname"] || ""
16
+
17
+ # Parse common fields
18
+ @position = parse_vec3(properties["origin"]) || Math::Vec3::ORIGIN
19
+ @angle = (properties["angle"] || "0").to_f
20
+ @angles = parse_vec3(properties["angles"]) || Math::Vec3::ORIGIN
21
+
22
+ # Brush model reference: "*1", "*2", etc.
23
+ @model_index = nil
24
+ if (model_str = properties["model"])
25
+ @model_index = model_str[1..].to_i if model_str.start_with?("*")
26
+ end
27
+
28
+ # Targeting
29
+ @target = properties["target"]
30
+ @targetname = properties["targetname"]
31
+
32
+ # Movement/behavior
33
+ @speed = (properties["speed"] || default_speed).to_f
34
+ @wait = (properties["wait"] || "3").to_f
35
+ @lip = (properties["lip"] || "8").to_f
36
+ @health = (properties["health"] || "0").to_f
37
+ @sounds = (properties["sounds"] || "0").to_i
38
+ @message = properties["message"]
39
+
40
+ # Runtime state
41
+ @state = :idle
42
+ @think_time = 0.0
43
+ @move_dir = Math::Vec3::ORIGIN
44
+ end
45
+
46
+ def [](key) = @properties[key]
47
+
48
+ def brush_entity? = !@model_index.nil?
49
+
50
+ # Direction vector from the "angle" field (Quake convention)
51
+ def forward_vector
52
+ case @angle.to_i
53
+ when -1 # UP
54
+ Math::Vec3.new(0.0, 0.0, 1.0)
55
+ when -2 # DOWN
56
+ Math::Vec3.new(0.0, 0.0, -1.0)
57
+ else
58
+ rad = @angle * ::Math::PI / 180.0
59
+ Math::Vec3.new(::Math.cos(rad), ::Math.sin(rad), 0.0)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def parse_vec3(str)
66
+ return nil unless str
67
+ parts = str.split.map(&:to_f)
68
+ return nil unless parts.size == 3
69
+ Math::Vec3.new(parts[0], parts[1], parts[2])
70
+ end
71
+
72
+ def default_speed
73
+ case @classname
74
+ when "func_door" then "100"
75
+ when "func_plat" then "150"
76
+ when "func_button" then "40"
77
+ when "func_train" then "100"
78
+ else "100"
79
+ end
80
+ end
81
+ end
82
+
83
+ # Parses the BSP entity string into an array of Entity objects.
84
+ module EntityParser
85
+ def self.parse(entity_string)
86
+ entities = []
87
+ current = nil
88
+
89
+ entity_string.each_line do |line|
90
+ line = line.strip
91
+ case line
92
+ when "{"
93
+ current = {}
94
+ when "}"
95
+ entities << Entity.new(current) if current
96
+ current = nil
97
+ else
98
+ if current && line =~ /\A"([^"]+)"\s+"([^"]*)"\z/
99
+ current[$1] = $2
100
+ end
101
+ end
102
+ end
103
+
104
+ entities
105
+ end
106
+
107
+ # Build a lookup table: targetname -> [Entity]
108
+ def self.build_target_map(entities)
109
+ map = Hash.new { |h, k| h[k] = [] }
110
+ entities.each do |ent|
111
+ map[ent.targetname] << ent if ent.targetname
112
+ end
113
+ map
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Game
5
+ # Manages brush entity behavior: doors, platforms, buttons, triggers.
6
+ # Each entity has a state machine and moves between positions.
7
+ class BrushEntities
8
+ # Entity states
9
+ STATE_IDLE = :idle
10
+ STATE_OPENING = :opening
11
+ STATE_OPEN = :open
12
+ STATE_CLOSING = :closing
13
+ STATE_CLOSED = :closed
14
+
15
+ def initialize(entities, level, target_map)
16
+ @entities = entities
17
+ @level = level
18
+ @target_map = target_map
19
+ @brush_entities = entities.select(&:brush_entity?)
20
+
21
+ # Initialize movement data for each brush entity
22
+ @brush_entities.each { |ent| init_brush_entity(ent) }
23
+
24
+ puts "Initialized #{@brush_entities.size} brush entities"
25
+ end
26
+
27
+ # Update all brush entities. Returns entities that need rendering.
28
+ def update(dt, player_pos)
29
+ # Save previous positions for platform-riding
30
+ @brush_entities.each do |ent|
31
+ ent.instance_variable_set(:@prev_pos, ent.position)
32
+ end
33
+
34
+ @brush_entities.each do |ent|
35
+ case ent.classname
36
+ when "func_door"
37
+ update_door(ent, dt, player_pos)
38
+ when "func_plat"
39
+ update_platform(ent, dt, player_pos)
40
+ when "func_button"
41
+ update_button(ent, dt, player_pos)
42
+ when "func_train"
43
+ update_train(ent, dt)
44
+ end
45
+ end
46
+
47
+ @brush_entities
48
+ end
49
+
50
+ # If the player is standing on a moving platform, return a new
51
+ # position snapped to the platform's top surface (so on_ground stays
52
+ # true and the player rides smoothly). Returns nil if not on a
53
+ # moving platform.
54
+ def snap_to_platform(player_pos)
55
+ @brush_entities.each do |ent|
56
+ next unless ent.classname == "func_plat"
57
+ model = @level.models[ent.model_index]
58
+ next unless model
59
+
60
+ prev = ent.instance_variable_get(:@prev_pos)
61
+ next unless prev
62
+
63
+ ent_delta_z = ent.position.z - prev.z
64
+
65
+ # Use the OLD platform position for the on_top check, since
66
+ # player_pos hasn't been updated for this frame's movement yet.
67
+ old_top_z = prev.z + model.maxs.z
68
+ next unless player_pos.z >= old_top_z - 8 && player_pos.z <= old_top_z + 24
69
+ next unless player_pos.x >= ent.position.x + model.mins.x &&
70
+ player_pos.x <= ent.position.x + model.maxs.x &&
71
+ player_pos.y >= ent.position.y + model.mins.y &&
72
+ player_pos.y <= ent.position.y + model.maxs.y
73
+
74
+ # Snap to the new platform top surface, slightly above so the
75
+ # trace-down ground check can detect it cleanly (avoid epsilon
76
+ # edge case at exact plane boundary).
77
+ new_top_z = ent.position.z + model.maxs.z + 0.5
78
+ return Math::Vec3.new(player_pos.x, player_pos.y, new_top_z)
79
+ end
80
+ nil
81
+ end
82
+
83
+ # Backward-compatible delta-only method
84
+ def platform_motion_for(player_pos)
85
+ snapped = snap_to_platform(player_pos)
86
+ return Math::Vec3::ORIGIN unless snapped
87
+ snapped - player_pos
88
+ end
89
+
90
+ # Check if player touches any trigger entities
91
+ def check_triggers(player_pos, player_radius: 16.0)
92
+ @entities.each do |ent|
93
+ next unless ent.classname.start_with?("trigger_")
94
+ next unless ent.brush_entity?
95
+
96
+ model = @level.models[ent.model_index]
97
+ next unless model
98
+
99
+ # Simple AABB check against player
100
+ mins = model.mins
101
+ maxs = model.maxs
102
+ origin = ent.position
103
+
104
+ if player_pos.x >= origin.x + mins.x - player_radius &&
105
+ player_pos.x <= origin.x + maxs.x + player_radius &&
106
+ player_pos.y >= origin.y + mins.y - player_radius &&
107
+ player_pos.y <= origin.y + maxs.y + player_radius &&
108
+ player_pos.z >= origin.z + mins.z - player_radius &&
109
+ player_pos.z <= origin.z + maxs.z + player_radius
110
+ fire_targets(ent.target) if ent.target
111
+ yield ent if block_given?
112
+ end
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def init_brush_entity(ent)
119
+ return unless ent.model_index
120
+
121
+ model = @level.models[ent.model_index]
122
+ return unless model
123
+
124
+ # Store the closed (original) position
125
+ ent.instance_variable_set(:@closed_pos, ent.position)
126
+ ent.instance_variable_set(:@open_pos, ent.position)
127
+ ent.instance_variable_set(:@move_fraction, 0.0)
128
+
129
+ case ent.classname
130
+ when "func_door"
131
+ setup_door(ent, model)
132
+ when "func_plat"
133
+ setup_platform(ent, model)
134
+ when "func_button"
135
+ setup_button(ent, model)
136
+ end
137
+ end
138
+
139
+ DOOR_START_OPEN = 1
140
+
141
+ def setup_door(ent, model)
142
+ # Calculate move direction from angle
143
+ dir = ent.forward_vector
144
+ size = model.maxs - model.mins
145
+ move_dist = dir.x.abs * size.x + dir.y.abs * size.y + dir.z.abs * size.z
146
+ move_dist -= ent.lip
147
+
148
+ pos1 = ent.position # authored = "closed"
149
+ pos2 = ent.position + dir * move_dist # offset = "open"
150
+
151
+ # DOOR_START_OPEN: door is authored at the open position. Swap so
152
+ # that pos1 = closed (down/hidden) and pos2 = open (visible).
153
+ spawnflags = (ent["spawnflags"] || "0").to_i
154
+ if (spawnflags & DOOR_START_OPEN) != 0
155
+ ent.position = pos2
156
+ ent.instance_variable_set(:@closed_pos, pos2)
157
+ ent.instance_variable_set(:@open_pos, pos1)
158
+ else
159
+ ent.instance_variable_set(:@closed_pos, pos1)
160
+ ent.instance_variable_set(:@open_pos, pos2)
161
+ end
162
+ end
163
+
164
+ def setup_platform(ent, model)
165
+ # In Quake, the map origin is the TOP position.
166
+ # See plats.qc func_plat:
167
+ # pos1 = top (authored origin)
168
+ # pos2 = top - (height - 8) // 8-unit lip stays visible
169
+ # Platforms WITHOUT a targetname start at the bottom and rise when
170
+ # the player steps on them. Platforms WITH a targetname start at
171
+ # the top and go down when triggered.
172
+ size = model.maxs - model.mins
173
+ height = size.z - 8.0
174
+
175
+ top_pos = ent.position
176
+ bottom_pos = Math::Vec3.new(top_pos.x, top_pos.y, top_pos.z - height)
177
+
178
+ if ent.targetname
179
+ # Targeted: start at top, button press lowers it
180
+ ent.instance_variable_set(:@closed_pos, top_pos)
181
+ ent.instance_variable_set(:@open_pos, bottom_pos)
182
+ else
183
+ # Normal: start at bottom, player steps on to ride up
184
+ ent.position = bottom_pos
185
+ ent.instance_variable_set(:@closed_pos, bottom_pos)
186
+ ent.instance_variable_set(:@open_pos, top_pos)
187
+ end
188
+ end
189
+
190
+ def setup_button(ent, model)
191
+ dir = ent.forward_vector
192
+ size = model.maxs - model.mins
193
+ move_dist = dir.x.abs * size.x + dir.y.abs * size.y + dir.z.abs * size.z
194
+ move_dist -= ent.lip
195
+
196
+ ent.instance_variable_set(:@open_pos, ent.position + dir * move_dist)
197
+ end
198
+
199
+ def update_door(ent, dt, player_pos)
200
+ case ent.state
201
+ when STATE_IDLE, STATE_CLOSED
202
+ # Check if player is near or if targeted
203
+ if near_entity?(ent, player_pos, 128.0) || ent.instance_variable_get(:@triggered)
204
+ ent.state = STATE_OPENING
205
+ ent.instance_variable_set(:@triggered, false)
206
+ end
207
+ when STATE_OPENING
208
+ move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
209
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
210
+ ent.state = STATE_OPEN
211
+ ent.think_time = ent.wait
212
+ end
213
+ when STATE_OPEN
214
+ ent.think_time -= dt
215
+ if ent.think_time <= 0 && ent.wait >= 0
216
+ ent.state = STATE_CLOSING
217
+ end
218
+ when STATE_CLOSING
219
+ move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
220
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
221
+ ent.state = STATE_CLOSED
222
+ end
223
+ end
224
+ end
225
+
226
+ def update_platform(ent, dt, player_pos)
227
+ case ent.state
228
+ when STATE_IDLE
229
+ model = @level.models[ent.model_index]
230
+ triggered = ent.instance_variable_get(:@triggered)
231
+ on_top = model && on_top_of?(ent, model, player_pos)
232
+
233
+ # Targeted platforms only react to button triggers.
234
+ # Normal platforms also activate when player steps on them.
235
+ should_move = if ent.targetname
236
+ triggered
237
+ else
238
+ triggered || on_top
239
+ end
240
+
241
+ if should_move
242
+ ent.state = STATE_OPENING
243
+ ent.instance_variable_set(:@triggered, false)
244
+ end
245
+ when STATE_OPENING
246
+ move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
247
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
248
+ ent.state = STATE_OPEN
249
+ ent.think_time = ent.wait
250
+ end
251
+ when STATE_OPEN
252
+ ent.think_time -= dt
253
+ if ent.think_time <= 0
254
+ ent.state = STATE_CLOSING
255
+ end
256
+ when STATE_CLOSING
257
+ move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
258
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
259
+ ent.state = STATE_IDLE
260
+ end
261
+ end
262
+ end
263
+
264
+ def update_button(ent, dt, player_pos)
265
+ case ent.state
266
+ when STATE_IDLE, STATE_CLOSED
267
+ if near_entity?(ent, player_pos, 64.0) || ent.instance_variable_get(:@triggered)
268
+ ent.state = STATE_OPENING
269
+ ent.instance_variable_set(:@triggered, false)
270
+ fire_targets(ent.target)
271
+ end
272
+ when STATE_OPENING
273
+ move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
274
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
275
+ ent.state = STATE_OPEN
276
+ ent.think_time = ent.wait
277
+ end
278
+ when STATE_OPEN
279
+ ent.think_time -= dt
280
+ if ent.think_time <= 0
281
+ ent.state = STATE_CLOSING
282
+ end
283
+ when STATE_CLOSING
284
+ move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
285
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
286
+ ent.state = STATE_CLOSED
287
+ end
288
+ end
289
+ end
290
+
291
+ def update_train(ent, dt)
292
+ # Trains follow path_corner entities - basic movement only
293
+ return unless ent.state == STATE_OPENING
294
+
295
+ move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
296
+ if ent.instance_variable_get(:@move_fraction) >= 1.0
297
+ ent.state = STATE_IDLE
298
+ end
299
+ end
300
+
301
+ def move_toward(ent, target, dt)
302
+ current = ent.position
303
+ diff = target - current
304
+ dist = diff.length
305
+
306
+ # Already at target
307
+ if dist < 0.1
308
+ ent.position = target
309
+ ent.instance_variable_set(:@move_fraction, 1.0)
310
+ return
311
+ end
312
+
313
+ move_amount = ent.speed * dt
314
+ if move_amount >= dist
315
+ # Snap to target
316
+ ent.position = target
317
+ ent.instance_variable_set(:@move_fraction, 1.0)
318
+ else
319
+ # Partial movement, not done yet
320
+ ent.position = current + diff * (move_amount / dist)
321
+ ent.instance_variable_set(:@move_fraction, 0.0)
322
+ end
323
+ end
324
+
325
+ def near_entity?(ent, player_pos, radius)
326
+ model = @level.models[ent.model_index]
327
+ return false unless model
328
+
329
+ # Check distance to model center
330
+ center = ent.position + (model.mins + model.maxs) * 0.5
331
+ dx = player_pos.x - center.x
332
+ dy = player_pos.y - center.y
333
+ dz = player_pos.z - center.z
334
+ (dx * dx + dy * dy + dz * dz) < radius * radius
335
+ end
336
+
337
+ def on_top_of?(ent, model, player_pos)
338
+ # Check if player is standing on top of the platform.
339
+ # Tolerance: -8 to +24 units above the surface (player feet within
340
+ # one stair step of the platform top).
341
+ top_z = ent.position.z + model.maxs.z
342
+ player_pos.z >= top_z - 8 && player_pos.z <= top_z + 24 &&
343
+ player_pos.x >= ent.position.x + model.mins.x &&
344
+ player_pos.x <= ent.position.x + model.maxs.x &&
345
+ player_pos.y >= ent.position.y + model.mins.y &&
346
+ player_pos.y <= ent.position.y + model.maxs.y
347
+ end
348
+
349
+ def fire_targets(target_name)
350
+ return unless target_name
351
+
352
+ targets = @target_map[target_name]
353
+ targets.each do |ent|
354
+ ent.instance_variable_set(:@triggered, true)
355
+ # Reset movement fraction for re-triggering
356
+ ent.instance_variable_set(:@move_fraction, 0.0)
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end