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