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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../debug/screenshot"
4
+
5
+ module Quake
6
+ module Game
7
+ # Encapsulates the entire game state and per-frame loop, allowing both
8
+ # interactive (bin/quake) and scripted (bin/quake-debug) execution.
9
+ class Engine
10
+ attr_reader :pak, :palette, :wad, :window, :level, :entities,
11
+ :player, :player_state, :camera,
12
+ :viewmodel, :hud, :particles, :sound_events,
13
+ :brush_game, :item_pickups
14
+ attr_accessor :game_time
15
+
16
+ def initialize(pak_path:, window: nil, window_visible: true,
17
+ window_width: nil, window_height: nil,
18
+ enable_sound: true, enable_render: true)
19
+ @enable_render = enable_render
20
+
21
+ # PAK + assets
22
+ @pak = Pak::Reader.new(pak_path)
23
+ @palette = Palette.new(@pak.read("gfx/palette.lmp"))
24
+
25
+ wad_data = @pak.read("gfx.wad")
26
+ @wad = Wad::Reader.new(wad_data) if wad_data
27
+
28
+ # Sound (optional)
29
+ if enable_sound
30
+ @sound_mixer = Sound::Mixer.new(@pak)
31
+ @sound_mixer.open
32
+ end
33
+ @sound_events = Sound::Events.new(@sound_mixer)
34
+
35
+ # Window
36
+ if window
37
+ @window = window
38
+ @owns_window = false
39
+ else
40
+ opts = { visible: window_visible }
41
+ opts[:width] = window_width if window_width
42
+ opts[:height] = window_height if window_height
43
+ @window = Window.new(**opts)
44
+ @owns_window = true
45
+ end
46
+
47
+ @keys = {}
48
+ @game_time = 0.0
49
+ end
50
+
51
+ # Load a BSP map and (re)build all per-level state.
52
+ def load_map(map_name)
53
+ bsp_data = @pak.read(map_name)
54
+ raise "Map not found: #{map_name}" unless bsp_data
55
+
56
+ @level = Bsp::Reader.new(bsp_data).parse
57
+ @entities = EntityParser.parse(@level.entities)
58
+ @target_map = EntityParser.build_target_map(@entities)
59
+
60
+ # Find player start
61
+ player_ent = @entities.find { |e| e.classname == "info_player_start" }
62
+ @player_start = player_ent&.position || Math::Vec3::ORIGIN
63
+ @player_yaw = player_ent&.angle || 0.0
64
+
65
+ # Load MDL models referenced by entities
66
+ @mdl_cache ||= {}
67
+ @entities.each do |ent|
68
+ model_path = ent["model"]
69
+ next unless model_path&.end_with?(".mdl")
70
+ next if @mdl_cache.key?(model_path)
71
+ begin
72
+ mdl_data = @pak.read(model_path)
73
+ @mdl_cache[model_path] = Mdl::Reader.new(mdl_data).parse
74
+ rescue => e
75
+ warn "Failed to load #{model_path}: #{e.message}"
76
+ @mdl_cache[model_path] = nil
77
+ end
78
+ end
79
+
80
+ # Persistent player state across level loads
81
+ @player_state ||= PlayerState.new
82
+ @item_pickups = ItemPickups.new(@entities)
83
+ @brush_game = BrushEntities.new(@entities, @level, @target_map)
84
+
85
+ # Player + camera (reset position to start)
86
+ @player = Physics::Player.new(position: @player_start, yaw: @player_yaw)
87
+ @camera = Camera.new(position: @player.eye_position, yaw: @player_yaw)
88
+
89
+ build_renderers if @enable_render
90
+ end
91
+
92
+ # Update + render one frame.
93
+ def tick(dt)
94
+ @game_time += dt
95
+
96
+ # Brush entities first (so collision uses current positions)
97
+ if @brush_game
98
+ @brush_game.update(dt, @player.position)
99
+ @brush_game.check_triggers(@player.position) do |trigger|
100
+ handle_trigger(trigger)
101
+ end
102
+
103
+ snapped = @brush_game.snap_to_platform(@player.position)
104
+ if snapped
105
+ @player.position = snapped
106
+ @player.on_ground = true
107
+ @player.velocity = Math::Vec3.new(@player.velocity.x, @player.velocity.y, 0.0)
108
+ end
109
+ end
110
+
111
+ solid_brush = @entities.select(&:brush_entity?)
112
+ @player.update(dt, @level, @keys, brush_entities: solid_brush)
113
+
114
+ # Item pickups
115
+ events = @item_pickups.check_pickups(@player.position, @player_state)
116
+ events.each do |evt|
117
+ @sound_events&.on_pickup(evt)
118
+ @particles&.pickup_effect(evt[:entity].position)
119
+ @viewmodel&.set_weapon(@player_state.current_weapon_model)
120
+ end
121
+
122
+ # Viewmodel bob (before camera so bob is applied to camera too)
123
+ if @viewmodel
124
+ speed = ::Math.sqrt(@player.velocity.x**2 + @player.velocity.y**2)
125
+ @viewmodel.update(dt, speed)
126
+ end
127
+
128
+ # Sync camera
129
+ eye = @player.eye_position
130
+ bob = @viewmodel ? @viewmodel.bob : 0.0
131
+ @camera.position = Math::Vec3.new(eye.x, eye.y, eye.z + bob)
132
+ @camera.yaw = @player.yaw
133
+ @camera.pitch = @player.pitch
134
+
135
+ return unless @enable_render
136
+
137
+ @world_renderer.update(dt) if @world_renderer.respond_to?(:update)
138
+ @particles&.update(dt)
139
+
140
+ render
141
+ end
142
+
143
+ def render
144
+ @world_renderer.render(@camera, @window.aspect_ratio)
145
+ @brush_renderer&.render(@entities)
146
+
147
+ @entities.each do |ent|
148
+ next if @item_pickups.picked_up?(ent)
149
+ model_path = ent["model"]
150
+ next unless model_path&.end_with?(".mdl")
151
+
152
+ gl_model = @mdl_renderers[model_path]
153
+ next unless gl_model
154
+
155
+ frame_rate = 10.0
156
+ frame = (@game_time * frame_rate).to_i
157
+ lerp = (@game_time * frame_rate) % 1.0
158
+
159
+ gl_model.render(
160
+ frame_index: frame,
161
+ lerp: lerp,
162
+ position: ent.position,
163
+ yaw: ent.angle
164
+ )
165
+ end
166
+
167
+ @particles&.render
168
+ @viewmodel&.render(@camera, @window.aspect_ratio)
169
+ @hud&.render(@player_state)
170
+ end
171
+
172
+ def swap_buffers
173
+ @window.swap
174
+ end
175
+
176
+ # ---------------------- input control ----------------------
177
+
178
+ def set_key(scancode, pressed)
179
+ @keys[scancode] = pressed
180
+ end
181
+
182
+ def set_keys(keys_hash)
183
+ @keys = keys_hash.dup
184
+ end
185
+
186
+ def clear_keys
187
+ @keys = {}
188
+ end
189
+
190
+ def keys
191
+ @keys
192
+ end
193
+
194
+ # ---------------------- player control ---------------------
195
+
196
+ def teleport(x, y, z, yaw: nil, pitch: nil)
197
+ @player.position = Math::Vec3.new(x.to_f, y.to_f, z.to_f)
198
+ @player.velocity = Math::Vec3::ORIGIN
199
+ @player.instance_variable_set(:@yaw, yaw.to_f) if yaw
200
+ @player.instance_variable_set(:@pitch, pitch.to_f) if pitch
201
+ @camera.position = @player.eye_position
202
+ @camera.yaw = @player.yaw
203
+ @camera.pitch = @player.pitch
204
+ end
205
+
206
+ # ---------------------- debug output -----------------------
207
+
208
+ def screenshot(filename)
209
+ Debug::Screenshot.save(filename, @window.width, @window.height)
210
+ end
211
+
212
+ def dump_state
213
+ {
214
+ time: @game_time,
215
+ player: {
216
+ position: vec_to_a(@player.position),
217
+ velocity: vec_to_a(@player.velocity),
218
+ yaw: @player.yaw,
219
+ pitch: @player.pitch,
220
+ on_ground: @player.on_ground,
221
+ water_level: @player.water_level,
222
+ noclip: @player.noclip
223
+ },
224
+ stats: {
225
+ health: @player_state.health,
226
+ armor: @player_state.armor,
227
+ armor_type: @player_state.armor_type,
228
+ current_weapon: @player_state.current_weapon,
229
+ ammo: @player_state.ammo.dup
230
+ },
231
+ brush_entities: brush_entity_summaries,
232
+ particles: @particles ? @particles.particle_count : 0
233
+ }
234
+ end
235
+
236
+ def shutdown
237
+ @sound_mixer&.close
238
+ if @owns_window
239
+ @window&.close
240
+ end
241
+ @pak.close
242
+ end
243
+
244
+ private
245
+
246
+ def vec_to_a(v)
247
+ [v.x.round(3), v.y.round(3), v.z.round(3)]
248
+ end
249
+
250
+ def brush_entity_summaries
251
+ return [] unless @brush_game
252
+ @entities.select(&:brush_entity?).map do |e|
253
+ {
254
+ classname: e.classname,
255
+ targetname: e.targetname,
256
+ state: e.state,
257
+ position: vec_to_a(e.position)
258
+ }
259
+ end
260
+ end
261
+
262
+ def build_renderers
263
+ @tex_manager = Renderer::GLTextureManager.new(@level, @palette)
264
+ @tex_manager.upload_all
265
+
266
+ @lightmap = Renderer::GLLightmap.new(@level, @palette)
267
+ @lightmap.build_all
268
+
269
+ @sky = Renderer::GLSky.new(@level, @palette, @tex_manager)
270
+ @water = Renderer::GLWater.new(@level, @tex_manager)
271
+
272
+ @world_renderer = Renderer::GLTextured.new(
273
+ @level, @tex_manager,
274
+ lightmap: @lightmap, sky: @sky, water: @water
275
+ )
276
+
277
+ @brush_renderer = Renderer::GLBrushModel.new(@level, @tex_manager, @lightmap)
278
+
279
+ @mdl_renderers = {}
280
+ @mdl_cache.each do |path, mdl|
281
+ next unless mdl
282
+ @mdl_renderers[path] = Renderer::GLAliasModel.new(mdl, @palette)
283
+ end
284
+
285
+ @viewmodel = Renderer::GLViewmodel.new(@pak, @palette)
286
+ @viewmodel.set_weapon(@player_state.current_weapon_model)
287
+
288
+ @hud = Renderer::GLHud.new(@wad, @palette, @window.width, @window.height) if @wad
289
+ @particles = Renderer::GLParticles.new(@palette)
290
+ end
291
+
292
+ def handle_trigger(trigger)
293
+ case trigger.classname
294
+ when "trigger_changelevel"
295
+ # Will be implemented later (level transitions)
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Quake
6
+ module Game
7
+ # Handles item pickup logic: checks proximity between player and item
8
+ # entities, applies effects to PlayerState, marks items as picked up.
9
+ class ItemPickups
10
+ PICKUP_RADIUS = 32.0 # units (roughly player bbox width)
11
+
12
+ # Pickup definitions: classname -> { type:, ... }
13
+ ITEMS = {
14
+ # Health
15
+ "item_health" => { type: :health, amount: 25 },
16
+
17
+ # Armor
18
+ "item_armor1" => { type: :armor, points: 100, armor_type: 1 }, # green
19
+ "item_armor2" => { type: :armor, points: 150, armor_type: 2 }, # yellow
20
+ "item_armorInv" => { type: :armor, points: 200, armor_type: 3 }, # red
21
+
22
+ # Ammo
23
+ "item_shells" => { type: :ammo, ammo_type: :shells, amount: 20 },
24
+ "item_spikes" => { type: :ammo, ammo_type: :nails, amount: 25 },
25
+ "item_rockets" => { type: :ammo, ammo_type: :rockets, amount: 5 },
26
+ "item_cells" => { type: :ammo, ammo_type: :cells, amount: 6 },
27
+
28
+ # Weapons (give weapon + starting ammo)
29
+ "weapon_supershotgun" => {
30
+ type: :weapon, weapon: :super_shotgun,
31
+ ammo_type: :shells, ammo_amount: 5
32
+ },
33
+ "weapon_nailgun" => {
34
+ type: :weapon, weapon: :nailgun,
35
+ ammo_type: :nails, ammo_amount: 30
36
+ },
37
+ "weapon_supernailgun" => {
38
+ type: :weapon, weapon: :super_nailgun,
39
+ ammo_type: :nails, ammo_amount: 30
40
+ },
41
+ "weapon_grenadelauncher" => {
42
+ type: :weapon, weapon: :grenade_launcher,
43
+ ammo_type: :rockets, ammo_amount: 5
44
+ },
45
+ "weapon_rocketlauncher" => {
46
+ type: :weapon, weapon: :rocket_launcher,
47
+ ammo_type: :rockets, ammo_amount: 5
48
+ },
49
+ "weapon_lightning" => {
50
+ type: :weapon, weapon: :lightning_gun,
51
+ ammo_type: :cells, ammo_amount: 15
52
+ },
53
+
54
+ # Powerups
55
+ "item_artifact_super_damage" => { type: :powerup, powerup: :quad },
56
+ "item_artifact_invulnerability" => { type: :powerup, powerup: :pentagram },
57
+ "item_artifact_envirosuit" => { type: :powerup, powerup: :biosuit },
58
+ "item_artifact_invisibility" => { type: :powerup, powerup: :ring }
59
+ }.freeze
60
+
61
+ def initialize(entities)
62
+ @item_entities = entities.select { |e| ITEMS.key?(e.classname) }
63
+ @picked_up = Set.new # entity object_ids that have been collected
64
+ end
65
+
66
+ # Check for pickups near player position. Returns array of pickup
67
+ # event hashes (for sound/effect triggering).
68
+ def check_pickups(player_pos, player_state)
69
+ events = []
70
+
71
+ @item_entities.each do |ent|
72
+ next if @picked_up.include?(ent.object_id)
73
+
74
+ dx = player_pos.x - ent.position.x
75
+ dy = player_pos.y - ent.position.y
76
+ dz = player_pos.z - ent.position.z
77
+ dist_sq = dx * dx + dy * dy + dz * dz
78
+
79
+ next if dist_sq > PICKUP_RADIUS * PICKUP_RADIUS
80
+
81
+ defn = ITEMS[ent.classname]
82
+ next unless defn
83
+
84
+ picked = try_pickup(player_state, defn, ent)
85
+ if picked
86
+ @picked_up.add(ent.object_id)
87
+ events << { classname: ent.classname, type: defn[:type], entity: ent }
88
+ end
89
+ end
90
+
91
+ events
92
+ end
93
+
94
+ def picked_up?(entity)
95
+ @picked_up.include?(entity.object_id)
96
+ end
97
+
98
+ private
99
+
100
+ def try_pickup(ps, defn, ent)
101
+ case defn[:type]
102
+ when :health
103
+ # Health items: check spawnflags for mega/rotten
104
+ amount = defn[:amount]
105
+ mega = false
106
+ if (flags = ent["spawnflags"])
107
+ flags = flags.to_i
108
+ if flags & 1 != 0 # rotten (small health, 15hp, can exceed 100 but not by much)
109
+ amount = 15
110
+ elsif flags & 2 != 0 # mega health
111
+ amount = 100
112
+ mega = true
113
+ end
114
+ end
115
+ ps.add_health(amount, mega: mega)
116
+
117
+ when :armor
118
+ ps.add_armor(defn[:points], defn[:armor_type])
119
+
120
+ when :ammo
121
+ ps.add_ammo(defn[:ammo_type], defn[:amount])
122
+
123
+ when :weapon
124
+ ps.give_weapon(defn[:weapon],
125
+ ammo_type: defn[:ammo_type],
126
+ ammo_amount: defn[:ammo_amount])
127
+
128
+ when :powerup
129
+ # Powerups always pick up (full implementation would add timed effects)
130
+ true
131
+ else
132
+ false
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Quake
6
+ module Game
7
+ # Tracks player game state: health, armor, ammo, weapons.
8
+ # Separate from physics (Physics::Player handles movement/collision).
9
+ class PlayerState
10
+ WEAPONS = %i[axe shotgun super_shotgun nailgun super_nailgun
11
+ grenade_launcher rocket_launcher lightning_gun].freeze
12
+
13
+ WEAPON_MODELS = {
14
+ axe: "progs/v_axe.mdl",
15
+ shotgun: "progs/v_shot.mdl",
16
+ super_shotgun: "progs/v_shot2.mdl",
17
+ nailgun: "progs/v_nail.mdl",
18
+ super_nailgun: "progs/v_nail2.mdl",
19
+ grenade_launcher: "progs/v_rock.mdl",
20
+ rocket_launcher: "progs/v_rock2.mdl",
21
+ lightning_gun: "progs/v_light.mdl"
22
+ }.freeze
23
+
24
+ # Ammo type per weapon
25
+ WEAPON_AMMO = {
26
+ axe: nil,
27
+ shotgun: :shells,
28
+ super_shotgun: :shells,
29
+ nailgun: :nails,
30
+ super_nailgun: :nails,
31
+ grenade_launcher: :rockets,
32
+ rocket_launcher: :rockets,
33
+ lightning_gun: :cells
34
+ }.freeze
35
+
36
+ attr_accessor :health, :armor, :armor_type, :current_weapon
37
+ attr_reader :ammo, :weapons_owned
38
+
39
+ def initialize
40
+ @health = 100
41
+ @armor = 0
42
+ @armor_type = 0 # 0=none, 1=green(30%), 2=yellow(60%), 3=red(80%)
43
+ @current_weapon = :shotgun
44
+ @weapons_owned = Set.new([:axe, :shotgun])
45
+ @ammo = { shells: 25, nails: 0, rockets: 0, cells: 0 }
46
+ end
47
+
48
+ def current_ammo_type
49
+ WEAPON_AMMO[@current_weapon]
50
+ end
51
+
52
+ def current_ammo_count
53
+ type = current_ammo_type
54
+ type ? @ammo[type] : nil # nil = infinite (axe)
55
+ end
56
+
57
+ def current_weapon_model
58
+ WEAPON_MODELS[@current_weapon]
59
+ end
60
+
61
+ # Cycle to next owned weapon (mousewheel or number keys)
62
+ def next_weapon
63
+ idx = WEAPONS.index(@current_weapon) || 0
64
+ WEAPONS.size.times do
65
+ idx = (idx + 1) % WEAPONS.size
66
+ if @weapons_owned.include?(WEAPONS[idx])
67
+ @current_weapon = WEAPONS[idx]
68
+ return
69
+ end
70
+ end
71
+ end
72
+
73
+ def prev_weapon
74
+ idx = WEAPONS.index(@current_weapon) || 0
75
+ WEAPONS.size.times do
76
+ idx = (idx - 1) % WEAPONS.size
77
+ if @weapons_owned.include?(WEAPONS[idx])
78
+ @current_weapon = WEAPONS[idx]
79
+ return
80
+ end
81
+ end
82
+ end
83
+
84
+ # Select weapon by slot number (1-8)
85
+ def select_weapon(slot)
86
+ slot = slot.clamp(1, WEAPONS.size)
87
+ weapon = WEAPONS[slot - 1]
88
+ @current_weapon = weapon if @weapons_owned.include?(weapon)
89
+ end
90
+
91
+ def alive?
92
+ @health > 0
93
+ end
94
+
95
+ # Max ammo capacities (from Quake defs.qc)
96
+ MAX_HEALTH = 100
97
+ MAX_MEGA_HEALTH = 250
98
+ MAX_ARMOR = 200
99
+ MAX_SHELLS = 100
100
+ MAX_NAILS = 200
101
+ MAX_ROCKETS = 100
102
+ MAX_CELLS = 100
103
+
104
+ AMMO_CAPS = {
105
+ shells: MAX_SHELLS, nails: MAX_NAILS,
106
+ rockets: MAX_ROCKETS, cells: MAX_CELLS
107
+ }.freeze
108
+
109
+ # Add health, returns true if picked up
110
+ def add_health(amount, mega: false)
111
+ max = mega ? MAX_MEGA_HEALTH : MAX_HEALTH
112
+ return false if @health >= max
113
+
114
+ @health = [@health + amount, max].min
115
+ true
116
+ end
117
+
118
+ # Add armor, returns true if picked up
119
+ def add_armor(points, type)
120
+ # Only pick up if it's better than current armor
121
+ # type: 1=green(30%), 2=yellow(60%), 3=red(80%)
122
+ return false if type < @armor_type && @armor > 0
123
+
124
+ @armor = [points, MAX_ARMOR].min
125
+ @armor_type = type
126
+ true
127
+ end
128
+
129
+ # Add ammo, returns true if picked up
130
+ def add_ammo(type, amount)
131
+ cap = AMMO_CAPS[type]
132
+ return false unless cap
133
+ return false if @ammo[type] >= cap
134
+
135
+ @ammo[type] = [@ammo[type] + amount, cap].min
136
+ true
137
+ end
138
+
139
+ # Give weapon, returns true if picked up (always true for new weapons)
140
+ def give_weapon(weapon, ammo_type: nil, ammo_amount: 0)
141
+ had_weapon = @weapons_owned.include?(weapon)
142
+ @weapons_owned.add(weapon)
143
+
144
+ ammo_picked = false
145
+ if ammo_type && ammo_amount > 0
146
+ ammo_picked = add_ammo(ammo_type, ammo_amount)
147
+ end
148
+
149
+ if !had_weapon
150
+ @current_weapon = weapon
151
+ true
152
+ else
153
+ ammo_picked
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Math
5
+ Vec3 = Data.define(:x, :y, :z) do
6
+ def +(other) = Vec3.new(x + other.x, y + other.y, z + other.z)
7
+ def -(other) = Vec3.new(x - other.x, y - other.y, z - other.z)
8
+ def *(scalar) = Vec3.new(x * scalar, y * scalar, z * scalar)
9
+ def -@ = Vec3.new(-x, -y, -z)
10
+
11
+ def dot(other) = x * other.x + y * other.y + z * other.z
12
+
13
+ def cross(other)
14
+ Vec3.new(
15
+ y * other.z - z * other.y,
16
+ z * other.x - x * other.z,
17
+ x * other.y - y * other.x
18
+ )
19
+ end
20
+
21
+ def length = ::Math.sqrt(x * x + y * y + z * z)
22
+
23
+ def normalize
24
+ l = length
25
+ return self if l == 0.0
26
+ Vec3.new(x / l, y / l, z / l)
27
+ end
28
+
29
+ def to_a = [x, y, z]
30
+
31
+ end
32
+
33
+ Vec3::ORIGIN = Vec3.new(0.0, 0.0, 0.0).freeze
34
+ end
35
+ end