quake-rb 0.1.0 → 0.2.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.
@@ -1,22 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../debug/screenshot"
4
+ require_relative "../mdl/types"
5
+ require_relative "../mdl/reader"
6
+ require_relative "../renderer/gl_lightmap"
7
+ require_relative "../renderer/gl_view_blend"
4
8
 
5
9
  module Quake
6
10
  module Game
7
11
  # Encapsulates the entire game state and per-frame loop, allowing both
8
12
  # interactive (bin/quake) and scripted (bin/quake-debug) execution.
9
13
  class Engine
14
+ POWERUP_EXPIRING_EVENTS = {
15
+ ring: :ring_expiring,
16
+ pentagram: :pentagram_expiring,
17
+ biosuit: :biosuit_expiring,
18
+ quad: :quad_expiring
19
+ }.freeze
20
+
21
+ # CheckPowerups sprints (client.qc) shown when a powerup has less
22
+ # than three seconds left.
23
+ POWERUP_EXPIRING_MESSAGES = {
24
+ ring: "Ring of Shadows magic is fading",
25
+ pentagram: "Protection is almost burned out",
26
+ biosuit: "Air supply in Biosuit expiring",
27
+ quad: "Quad Damage is wearing off"
28
+ }.freeze
29
+
30
+ # scr_centertime (screen.c) and con_notifytime (console.c) defaults.
31
+ CENTERPRINT_TIME = 2.0
32
+ CON_NOTIFYTIME = 3.0
33
+ # NUM_CON_TIMES notify lines shown at once (console.c Con_DrawNotify).
34
+ MAX_NOTIFY_LINES = 4
35
+
36
+ START_ITEM_CLASSNAMES = %w[
37
+ item_health item_armor1 item_armor2 item_armorInv
38
+ weapon_supershotgun weapon_nailgun weapon_supernailgun
39
+ weapon_grenadelauncher weapon_rocketlauncher weapon_lightning
40
+ item_shells item_spikes item_rockets item_cells item_weapon
41
+ item_key1 item_key2 item_sigil
42
+ item_artifact_invulnerability item_artifact_envirosuit
43
+ item_artifact_invisibility item_artifact_super_damage
44
+ ].freeze
45
+
46
+ ITEM_WIDE_BOUNDS_CLASSNAMES = %w[
47
+ item_health item_shells item_spikes item_rockets item_cells item_weapon
48
+ ].freeze
49
+
50
+ ITEM_TALL_BOUNDS_CLASSNAMES = %w[
51
+ item_armor1 item_armor2 item_armorInv
52
+ weapon_supershotgun weapon_nailgun weapon_supernailgun
53
+ weapon_grenadelauncher weapon_rocketlauncher weapon_lightning
54
+ ].freeze
55
+
56
+ QUAKEWORLD_REMOVED_MONSTER_CLASSNAMES = %w[
57
+ monster_ogre monster_demon1 monster_shambler monster_knight
58
+ monster_army monster_wizard monster_dog monster_zombie
59
+ monster_boss monster_tarbaby monster_hell_knight monster_fish
60
+ monster_shalrath monster_enforcer monster_oldone
61
+ ].freeze
62
+
63
+ PLAYER_DEATH_FRAMES = {
64
+ axe: (41..49).to_a,
65
+ a: (50..60).to_a,
66
+ b: (61..69).to_a,
67
+ c: (70..84).to_a,
68
+ d: (85..93).to_a,
69
+ e: (94..102).to_a
70
+ }.freeze
71
+ PLAYER_DEATHA11_FRAME = 60
72
+
73
+ PLAYER_PAIN_FRAMES = {
74
+ axe: (29..34).to_a,
75
+ normal: (35..40).to_a
76
+ }.freeze
77
+ PLAYER_STAND_FRAMES = {
78
+ axe: (17..28).to_a,
79
+ normal: (12..16).to_a
80
+ }.freeze
81
+
10
82
  attr_reader :pak, :palette, :wad, :window, :level, :entities,
11
83
  :player, :player_state, :camera,
12
84
  :viewmodel, :hud, :particles, :sound_events,
13
- :brush_game, :item_pickups
14
- attr_accessor :game_time
85
+ :brush_game, :item_pickups, :dynamic_lights
86
+ attr_accessor :game_time, :samelevel, :deathmatch, :teamplay, :rj, :axe, :temp1,
87
+ :timelimit, :fraglimit
88
+ attr_writer :attack_pressed
15
89
 
16
90
  def initialize(pak_path:, window: nil, window_visible: true,
17
91
  window_width: nil, window_height: nil,
18
- enable_sound: true, enable_render: true)
92
+ enable_sound: true, enable_render: true,
93
+ registered: false)
19
94
  @enable_render = enable_render
95
+ @registered = registered
20
96
 
21
97
  # PAK + assets
22
98
  @pak = Pak::Reader.new(pak_path)
@@ -45,7 +121,57 @@ module Quake
45
121
  end
46
122
 
47
123
  @keys = {}
124
+ @attack_pressed = false
125
+ @attack_finished = 0.0
126
+ @pending_changelevel = nil
127
+ @pending_changelevel_time = nil
128
+ @samelevel = 0
129
+ @deathmatch = 0
130
+ @teamplay = 0
131
+ @rj = 1.0
132
+ @axe = 0
133
+ @temp1 = 0
134
+ @timelimit = 0
135
+ @fraglimit = 0
136
+ @current_map = nil
137
+ @killed_monsters = 0
138
+ @total_monsters = 0
139
+ @level_name = nil
140
+ @intermission_running = false
141
+ @intermission_start_time = nil
142
+ @gravity = GRAVITY
143
+ @projectiles = []
144
+ @temporary_dynamic_lights = []
145
+ @temporary_beams = []
146
+ @environment_damage_time = 0.0
147
+ @air_finished = 12.0
148
+ @drown_damage = 2.0
149
+ @drown_pain_finished = 0.0
150
+ @pain_sound_finished = 0.0
151
+ @invincible_sound_finished = 0.0
152
+ @swim_sound_finished = 0.0
153
+ @quad_attack_sound_finished = 0.0
154
+ @ring_idle_sound_finished = 0.0
155
+ @powerup_warning_finished = {}
156
+ @bonus_cshift_percent = 0.0
157
+ @damage_cshift = Renderer::GLViewBlend::CShift.new(color: [0, 0, 0], percent: 0.0)
158
+ @damage_kick_time = 0.0
159
+ @damage_kick_roll = 0.0
160
+ @damage_kick_pitch = 0.0
161
+ @weapon_punch_angle = Math::Vec3::ORIGIN
162
+ @view_oldz = 0.0
163
+ @view_frame_dt = 0.0
164
+ @view_idle_scale = 0.0
165
+ @view_idealpitch = 0.0
166
+ @view_pitch_velocity = 0.0
167
+ @view_pitch_nodrift = true
168
+ @view_pitch_driftmove = 0.0
169
+ @view_pitch_laststop = 0.0
170
+ @jump_flag = 0.0
48
171
  @game_time = 0.0
172
+ @notify_lines = []
173
+ @centerprint = nil
174
+ @centerprint_expire = nil
49
175
  end
50
176
 
51
177
  # Load a BSP map and (re)build all per-level state.
@@ -54,37 +180,79 @@ module Quake
54
180
  raise "Map not found: #{map_name}" unless bsp_data
55
181
 
56
182
  @level = Bsp::Reader.new(bsp_data).parse
183
+ @current_map = map_name
184
+ configure_world_gravity(map_name)
57
185
  @entities = EntityParser.parse(@level.entities)
58
186
  @target_map = EntityParser.build_target_map(@entities)
187
+ initialize_body_queue
188
+ @projectiles = []
189
+ @temporary_dynamic_lights = []
190
+ @temporary_beams = []
191
+ @environment_damage_time = 0.0
192
+ @air_finished = @game_time + 12.0
193
+ @drown_damage = 2.0
194
+ @drown_pain_finished = 0.0
195
+ @player_in_water = false
196
+ @bonus_cshift_percent = 0.0
197
+ @damage_cshift = Renderer::GLViewBlend::CShift.new(color: [0, 0, 0], percent: 0.0)
198
+ @damage_kick_time = 0.0
199
+ @damage_kick_roll = 0.0
200
+ @damage_kick_pitch = 0.0
201
+ @weapon_punch_angle = Math::Vec3::ORIGIN
202
+ @view_oldz = 0.0
203
+ @view_frame_dt = 0.0
204
+ @view_idle_scale = 0.0
205
+ @view_idealpitch = 0.0
206
+ @view_pitch_velocity = 0.0
207
+ @view_pitch_nodrift = true
208
+ @view_pitch_driftmove = 0.0
209
+ @view_pitch_laststop = 0.0
210
+ @attack_finished = @game_time
211
+ @jump_flag = 0.0
212
+ @notify_lines = []
213
+ @centerprint = nil
214
+ @centerprint_expire = nil
215
+ setup_misc_entities
216
+ setup_ambient_sounds
217
+
218
+ # Level stats for the HUD scoreboard/intermission (Quake's
219
+ # total_monsters / killed_monsters, sbar.c STAT_* values).
220
+ # Monsters count only entities that actually spawned (the
221
+ # QuakeWorld-style monster stubs remove themselves at spawn).
222
+ @killed_monsters = 0
223
+ @total_monsters = @entities.count do |ent|
224
+ ent.classname.to_s.start_with?("monster_") && !ent.removed?
225
+ end
226
+ message = world_info_value("message")
227
+ @level_name = message && !message.empty? ? message : File.basename(map_name, ".bsp")
228
+ @intermission_start_time = nil
59
229
 
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
230
+ set_player_spawn_point(select_spawn_point)
64
231
 
65
232
  # Load MDL models referenced by entities
66
233
  @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
234
+ @entities.each { |ent| load_mdl_model(ent["model"]) unless ent.removed? }
235
+ TEMP_ENTITY_MODELS.each { |model_path| load_mdl_model(model_path) }
79
236
 
80
237
  # Persistent player state across level loads
81
238
  @player_state ||= PlayerState.new
82
- @item_pickups = ItemPickups.new(@entities)
83
- @brush_game = BrushEntities.new(@entities, @level, @target_map)
239
+ @player_state.reset_spawn_runtime_fields
240
+ apply_start_map_parms
241
+ @item_pickups = ItemPickups.new(@entities, deathmatch: deathmatch)
242
+ @brush_game = BrushEntities.new(
243
+ @entities, @level, @target_map,
244
+ sound_events: @sound_events,
245
+ registered: @registered,
246
+ serverflags: @player_state.serverflags,
247
+ worldtype: current_worldtype
248
+ )
84
249
 
85
250
  # 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)
251
+ @player = Physics::Player.new(position: player_spawn_origin, yaw: @player_yaw)
252
+ @player.gravity = current_gravity
253
+ set_player_spawn_entity_state
254
+ apply_put_client_in_server_spawn_effects
255
+ @camera = Camera.new(position: player_eye_position, yaw: @player_yaw)
88
256
 
89
257
  build_renderers if @enable_render
90
258
  end
@@ -93,10 +261,19 @@ module Quake
93
261
  def tick(dt)
94
262
  @game_time += dt
95
263
 
264
+ if @intermission_running
265
+ intermission_think
266
+ sync_camera
267
+ return unless @enable_render
268
+
269
+ render
270
+ return
271
+ end
272
+
96
273
  # Brush entities first (so collision uses current positions)
97
274
  if @brush_game
98
- @brush_game.update(dt, @player.position)
99
- @brush_game.check_triggers(@player.position) do |trigger|
275
+ @brush_game.update(dt, @player.position, player_state: @player_state)
276
+ @brush_game.check_triggers(@player.position, player_forward: @player.forward_flat) do |trigger|
100
277
  handle_trigger(trigger)
101
278
  end
102
279
 
@@ -109,14 +286,40 @@ module Quake
109
286
  end
110
287
 
111
288
  solid_brush = @entities.select(&:brush_entity?)
112
- @player.update(dt, @level, @keys, brush_entities: solid_brush)
289
+ update_player_control(dt, solid_brush)
290
+ @player_state.update_megahealth(@game_time) unless @item_pickups&.owns_megahealth_rot_for?(@player_state)
291
+ update_start_items
292
+ if @item_pickups
293
+ @item_pickups.update(@game_time, player_state: @player_state).each do |evt|
294
+ @sound_events&.on_pickup(evt)
295
+ end
296
+ end
113
297
 
114
298
  # Item pickups
115
- events = @item_pickups.check_pickups(@player.position, @player_state)
299
+ events = @item_pickups.check_pickups(
300
+ @player.position, @player_state, game_time: @game_time, water_level: @player.water_level
301
+ )
116
302
  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)
303
+ process_item_pickup_feedback(evt)
304
+ process_item_pickup_targets(evt)
305
+ end
306
+
307
+ if player_postthink_active?
308
+ check_powerup_warnings
309
+ refresh_biosuit_air
310
+ update_player_powerup_visual_state
311
+ update_player_powerups
312
+ check_no_ammo_weapon_switch
313
+ fire_weapon if @attack_pressed && @game_time >= @attack_finished
314
+ end
315
+ update_triggered_misc_entities
316
+ update_projectiles(dt)
317
+ update_temporary_entities
318
+ update_player_pain_animation
319
+ update_player_death_animation
320
+ check_rules if player_postthink_active?
321
+ if @pending_changelevel && @game_time + 0.000001 >= @pending_changelevel_time.to_f
322
+ run_pending_changelevel
120
323
  end
121
324
 
122
325
  # Viewmodel bob (before camera so bob is applied to camera too)
@@ -125,48 +328,132 @@ module Quake
125
328
  @viewmodel.update(dt, speed)
126
329
  end
127
330
 
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
331
+ @view_frame_dt = dt
332
+ sync_camera
333
+ update_screen_messages
134
334
 
135
335
  return unless @enable_render
136
336
 
137
337
  @world_renderer.update(dt) if @world_renderer.respond_to?(:update)
138
338
  @particles&.update(dt)
339
+ update_view_cshifts(dt)
139
340
 
140
341
  render
141
342
  end
142
343
 
143
344
  def render
144
- @world_renderer.render(@camera, @window.aspect_ratio)
145
- @brush_renderer&.render(@entities)
345
+ build_dynamic_lights
346
+ @lightmap&.update(@game_time, lightstyles: current_lightstyles, dynamic_lights: @dynamic_lights)
347
+ @world_renderer.render(@camera, @window.aspect_ratio, time: @game_time)
348
+ @brush_renderer&.render(@entities, view_origin: @camera.position, time: @game_time,
349
+ frustum: @camera.frustum_planes(@window.aspect_ratio))
146
350
 
147
351
  @entities.each do |ent|
352
+ next if ent.removed?
148
353
  next if @item_pickups.picked_up?(ent)
354
+
355
+ emit_entity_effect_particles(ent)
149
356
  model_path = ent["model"]
150
357
  next unless model_path&.end_with?(".mdl")
151
358
 
152
359
  gl_model = @mdl_renderers[model_path]
153
360
  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
361
+ ambient_light, shade_light = alias_entity_lighting(ent.position)
158
362
 
159
363
  gl_model.render(
160
- frame_index: frame,
161
- lerp: lerp,
364
+ frame_index: ent.frame,
365
+ lerp: 0.0,
162
366
  position: ent.position,
163
- yaw: ent.angle
367
+ yaw: alias_entity_yaw(ent, model_path),
368
+ pitch: -ent.angles.x,
369
+ roll: ent.angles.z,
370
+ skin_index: ent.skin,
371
+ time: @game_time + entity_sync_base(ent, gl_model),
372
+ ambient_light: ambient_light,
373
+ shade_light: shade_light
164
374
  )
165
375
  end
166
376
 
167
- @particles&.render
168
- @viewmodel&.render(@camera, @window.aspect_ratio)
169
- @hud&.render(@player_state)
377
+ render_projectiles
378
+ render_temp_beams
379
+ @particles&.render(camera: @camera)
380
+ render_viewmodel
381
+ @world_renderer.render_water
382
+ @view_blend&.render(current_view_blend)
383
+ @hud&.render(@player_state, time: @game_time, stats: hud_stats)
384
+ end
385
+
386
+ # Level statistics for the HUD (Quake's cl.stats STAT_MONSTERS /
387
+ # STAT_SECRETS etc. plus intermission state, sbar.c).
388
+ def hud_stats
389
+ {
390
+ monsters: @killed_monsters.to_i,
391
+ total_monsters: @total_monsters.to_i,
392
+ secrets: @brush_game&.found_secrets.to_i,
393
+ total_secrets: @brush_game&.total_secrets.to_i,
394
+ time: @game_time,
395
+ level_name: @level_name,
396
+ intermission: !!@intermission_running,
397
+ completed_time: @intermission_start_time,
398
+ sb_lines: sb_lines,
399
+ centerprint: active_centerprint,
400
+ notify_lines: active_notify_lines
401
+ }
402
+ end
403
+
404
+ # QuakeC sprint(): queue a console notify line (top-left text,
405
+ # console.c Con_Print + Con_DrawNotify). Multi-line messages become
406
+ # one notify line each; only the last MAX_NOTIFY_LINES are kept.
407
+ def sprint(text)
408
+ @notify_lines ||= []
409
+ text.to_s.split("\n").each do |line|
410
+ next if line.empty?
411
+
412
+ @notify_lines << { text: line, expire: @game_time + CON_NOTIFYTIME }
413
+ @notify_lines.shift while @notify_lines.length > MAX_NOTIFY_LINES
414
+ end
415
+ end
416
+
417
+ # Latch centerprints written to the player (QuakeC centerprint())
418
+ # and drop expired on-screen text. Runs once per tick.
419
+ def update_screen_messages
420
+ message = @player_state&.centerprint
421
+ if message && !message.to_s.empty?
422
+ @centerprint = message
423
+ @centerprint_expire = @game_time + CENTERPRINT_TIME
424
+ @player_state.centerprint = nil
425
+ end
426
+ @centerprint = nil if @centerprint && @centerprint_expire.to_f <= @game_time
427
+ @notify_lines&.reject! { |line| line[:expire] <= @game_time }
428
+ end
429
+
430
+ def active_centerprint
431
+ return nil unless @centerprint
432
+ return nil if @centerprint_expire.to_f <= @game_time
433
+
434
+ @centerprint
435
+ end
436
+
437
+ def active_notify_lines
438
+ (@notify_lines || []).select { |line| line[:expire] > @game_time }
439
+ .map { |line| line[:text] }
440
+ end
441
+
442
+ # viewsize like Quake's +/- keys: 120 hides the bar, 110 shows the
443
+ # status bar only (default), 100 adds the inventory bar. Sub-100
444
+ # sizes (shrunk 3D view) are not supported.
445
+ def adjust_viewsize(delta)
446
+ @viewsize = ((@viewsize || DEFAULT_VIEWSIZE) + delta).clamp(100, 120)
447
+ puts "viewsize #{@viewsize}"
448
+ @viewsize
449
+ end
450
+
451
+ def sb_lines
452
+ size = @viewsize || DEFAULT_VIEWSIZE
453
+ return 0 if size >= 120
454
+ return 24 if size >= 110
455
+
456
+ 40
170
457
  end
171
458
 
172
459
  def swap_buffers
@@ -191,6 +478,10 @@ module Quake
191
478
  @keys
192
479
  end
193
480
 
481
+ def set_attack(pressed)
482
+ @attack_pressed = pressed
483
+ end
484
+
194
485
  # ---------------------- player control ---------------------
195
486
 
196
487
  def teleport(x, y, z, yaw: nil, pitch: nil)
@@ -198,9 +489,7 @@ module Quake
198
489
  @player.velocity = Math::Vec3::ORIGIN
199
490
  @player.instance_variable_set(:@yaw, yaw.to_f) if yaw
200
491
  @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
492
+ sync_camera
204
493
  end
205
494
 
206
495
  # ---------------------- debug output -----------------------
@@ -212,6 +501,7 @@ module Quake
212
501
  def dump_state
213
502
  {
214
503
  time: @game_time,
504
+ map: @current_map,
215
505
  player: {
216
506
  position: vec_to_a(@player.position),
217
507
  velocity: vec_to_a(@player.velocity),
@@ -226,7 +516,8 @@ module Quake
226
516
  armor: @player_state.armor,
227
517
  armor_type: @player_state.armor_type,
228
518
  current_weapon: @player_state.current_weapon,
229
- ammo: @player_state.ammo.dup
519
+ ammo: @player_state.ammo.dup,
520
+ attack_finished: @attack_finished
230
521
  },
231
522
  brush_entities: brush_entity_summaries,
232
523
  particles: @particles ? @particles.particle_count : 0
@@ -249,7 +540,7 @@ module Quake
249
540
 
250
541
  def brush_entity_summaries
251
542
  return [] unless @brush_game
252
- @entities.select(&:brush_entity?).map do |e|
543
+ @entities.select { |ent| ent.brush_entity? && !ent.removed? }.map do |e|
253
544
  {
254
545
  classname: e.classname,
255
546
  targetname: e.targetname,
@@ -259,11 +550,124 @@ module Quake
259
550
  end
260
551
  end
261
552
 
553
+ def viewmodel_visible?
554
+ @player_state&.alive? && !@player_state.powerup_item_active?(:ring, game_time: @game_time)
555
+ end
556
+
557
+ def render_viewmodel
558
+ return unless @viewmodel
559
+
560
+ camera = viewmodel_camera
561
+ ambient_light, shade_light = alias_entity_lighting(camera.position)
562
+ @viewmodel.render(
563
+ camera,
564
+ @window.aspect_ratio,
565
+ visible: viewmodel_visible?,
566
+ ambient_light: ambient_light,
567
+ shade_light: shade_light
568
+ )
569
+ end
570
+
571
+ ENTITY_HULL_MINS = Math::Vec3.new(-16.0, -16.0, -24.0)
572
+ ENTITY_HULL_MAXS = Math::Vec3.new(16.0, 16.0, 32.0)
573
+ GRAVITY = 800.0
574
+ STOP_EPSILON = 0.1
575
+ MOVETYPE_NONE = 0
576
+ MOVETYPE_WALK = 3
577
+ MOVETYPE_TOSS = 6
578
+ MOVETYPE_NOCLIP = 8
579
+ MOVETYPE_BOUNCE = 10
580
+ MOVETYPE_PUSH = 7
581
+ SOLID_NOT = 0
582
+ SOLID_BBOX = 2
583
+ SOLID_SLIDEBOX = 3
584
+ DAMAGE_AIM = 2
585
+ FL_ONGROUND = 512
586
+ GRENADE_FUSE = 2.5
587
+ ROCKET_LIFETIME = 5.0
588
+ DEFAULT_VIEWSIZE = 110 # status bar only; 100 adds the inventory bar
589
+ SPIKE_LIFETIME = 6.0
590
+ LASER_LIFETIME = 5.0
591
+ EF_ROCKET = 1
592
+ EF_GRENADE = 2
593
+ EF_GIB = 4
594
+ EF_ROTATE = 8
595
+ EF_TRACER = 16
596
+ EF_ZOMGIB = 32
597
+ EF_TRACER2 = 64
598
+ EF_TRACER3 = 128
599
+ ENTITY_EFFECT_BRIGHTFIELD = 1
600
+ ENTITY_EFFECT_MUZZLEFLASH = 2
601
+ ENTITY_EFFECT_BRIGHTLIGHT = 4
602
+ ENTITY_EFFECT_DIMLIGHT = 8
603
+ PLAYER_WEAPON_ITEM_BITS = {
604
+ shotgun: 1,
605
+ super_shotgun: 2,
606
+ nailgun: 4,
607
+ super_nailgun: 8,
608
+ grenade_launcher: 16,
609
+ rocket_launcher: 32,
610
+ lightning_gun: 64,
611
+ axe: 4096
612
+ }.freeze
613
+ PLAYER_WEAPON_NETNAMES = {
614
+ axe: "Axe",
615
+ shotgun: "Shotgun",
616
+ super_shotgun: "Double-barrelled Shotgun",
617
+ nailgun: "Nailgun",
618
+ super_nailgun: "Super Nailgun",
619
+ grenade_launcher: "Grenade Launcher",
620
+ rocket_launcher: "Rocket Launcher",
621
+ lightning_gun: "Thunderbolt"
622
+ }.freeze
623
+ TEMP_ENTITY_MODELS = %w[
624
+ progs/bolt.mdl progs/bolt2.mdl progs/bolt3.mdl
625
+ ].freeze
626
+ MAX_BEAMS = 24
627
+ VIEW_ENTITY = 1
628
+ VIEW_ORIGIN_NUDGE = 1.0 / 32.0
629
+ VIEW_ORIGIN_NUDGE_VECTOR = Math::Vec3.new(
630
+ VIEW_ORIGIN_NUDGE,
631
+ VIEW_ORIGIN_NUDGE,
632
+ VIEW_ORIGIN_NUDGE
633
+ )
634
+ VIEW_BOUND_XY = 14.0
635
+ VIEW_BOUND_Z_MIN = -22.0
636
+ VIEW_BOUND_Z_MAX = 30.0
637
+ STAIR_SMOOTH_SPEED = 80.0
638
+ STAIR_SMOOTH_MAX = 12.0
639
+ VIEW_IDLE_YAW_CYCLE = 2.0
640
+ VIEW_IDLE_ROLL_CYCLE = 0.5
641
+ VIEW_IDLE_PITCH_CYCLE = 1.0
642
+ VIEW_IDLE_YAW_LEVEL = 0.3
643
+ VIEW_IDLE_ROLL_LEVEL = 0.1
644
+ VIEW_IDLE_PITCH_LEVEL = 0.3
645
+ VIEW_CENTER_MOVE = 0.15
646
+ VIEW_CENTER_SPEED = 500.0
647
+ WEAPON_PUNCH_DECAY = 10.0
648
+ WEAPON_PUNCH_ANGLES = {
649
+ small: Math::Vec3.new(-2.0, 0.0, 0.0),
650
+ big: Math::Vec3.new(-4.0, 0.0, 0.0)
651
+ }.freeze
652
+ Projectile = Data.define(
653
+ :kind, :position, :velocity, :damage, :damage_variance, :radius_damage, :explode_at,
654
+ :model, :movetype, :solid, :effects, :angles, :avelocity, :voided, :owner
655
+ ) do
656
+ def classname
657
+ kind == :fireball ? "fireball" : kind.to_s
658
+ end
659
+ end
660
+ DynamicLight = Data.define(:key, :origin, :radius, :minlight, :die)
661
+ TemporaryDynamicLight = Data.define(:origin, :radius, :minlight, :start_time, :die, :decay)
662
+ TemporaryBeam = Data.define(:entity, :model, :end_time, :start, :finish)
663
+ TemporaryBeamSegment = Data.define(:model, :origin, :pitch, :yaw, :roll)
664
+ WeaponSound = Data.define(:sound)
665
+
262
666
  def build_renderers
263
667
  @tex_manager = Renderer::GLTextureManager.new(@level, @palette)
264
668
  @tex_manager.upload_all
265
669
 
266
- @lightmap = Renderer::GLLightmap.new(@level, @palette)
670
+ @lightmap = Renderer::GLLightmap.new(@level, @palette, lightstyles: current_lightstyles)
267
671
  @lightmap.build_all
268
672
 
269
673
  @sky = Renderer::GLSky.new(@level, @palette, @tex_manager)
@@ -279,22 +683,3937 @@ module Quake
279
683
  @mdl_renderers = {}
280
684
  @mdl_cache.each do |path, mdl|
281
685
  next unless mdl
282
- @mdl_renderers[path] = Renderer::GLAliasModel.new(mdl, @palette)
686
+ @mdl_renderers[path] = Renderer::GLAliasModel.new(mdl, @palette, model_name: path)
283
687
  end
284
688
 
285
689
  @viewmodel = Renderer::GLViewmodel.new(@pak, @palette)
286
690
  @viewmodel.set_weapon(@player_state.current_weapon_model)
287
691
 
288
- @hud = Renderer::GLHud.new(@wad, @palette, @window.width, @window.height) if @wad
692
+ @hud = Renderer::GLHud.new(@wad, @palette, @window.width, @window.height, pak: @pak) if @wad
289
693
  @particles = Renderer::GLParticles.new(@palette)
694
+ @view_blend = Renderer::GLViewBlend.new
695
+ end
696
+
697
+ def load_mdl_model(model_path)
698
+ return unless model_path&.end_with?(".mdl")
699
+ return if @mdl_cache.key?(model_path)
700
+
701
+ begin
702
+ mdl_data = @pak.read(model_path)
703
+ @mdl_cache[model_path] = Mdl::Reader.new(mdl_data).parse
704
+ rescue => e
705
+ warn "Failed to load #{model_path}: #{e.message}"
706
+ @mdl_cache[model_path] = nil
707
+ end
708
+ end
709
+
710
+ def apply_start_map_parms
711
+ return unless @current_map == "maps/start.bsp"
712
+ return if @player_state.serverflags.zero?
713
+
714
+ @player_state.reset_to_new_parms
290
715
  end
291
716
 
292
717
  def handle_trigger(trigger)
293
718
  case trigger.classname
294
719
  when "trigger_changelevel"
295
- # Will be implemented later (level transitions)
720
+ if samelevel_blocks_changelevel?
721
+ damage_player(50_000, inflictor: trigger)
722
+ return
723
+ end
724
+
725
+ @brush_game.use_targets(trigger, activator: @player_state)
726
+ queue_changelevel(trigger)
727
+ when "trigger_teleport"
728
+ return if trigger.targetname && (trigger.think_time.zero? || trigger.think_time < @game_time)
729
+ return unless @player_state.alive?
730
+
731
+ @brush_game.use_targets(trigger, activator: @player_state)
732
+ teleport_trigger(trigger)
733
+ when "trigger_hurt"
734
+ hurt_trigger(trigger)
735
+ when "trigger_push"
736
+ push_trigger(trigger)
737
+ else
738
+ @brush_game.touch_trigger(trigger, activator: @player_state)
739
+ end
740
+ end
741
+
742
+ def queue_changelevel(trigger)
743
+ map = trigger["map"]
744
+ raise "chagnelevel trigger doesn't have map" if map.nil? || map.empty?
745
+
746
+ trigger.instance_variable_set(:@touch_disabled, true)
747
+ trigger.instance_variable_set(:@think, :execute_changelevel)
748
+ trigger.think_time = @game_time.to_f + 0.1
749
+ @pending_changelevel = normalize_map_name(map)
750
+ @pending_changelevel_time = [@pending_changelevel_time || trigger.think_time, trigger.think_time].min
751
+ end
752
+
753
+ def samelevel_blocks_changelevel?
754
+ @samelevel.to_i == 2 || (@samelevel.to_i == 3 && current_quake_mapname != "start")
755
+ end
756
+
757
+ def next_level
758
+ return if @pending_changelevel
759
+
760
+ mapname = current_quake_mapname
761
+ trigger = if @samelevel.to_i == 1
762
+ Entity.new("map" => mapname)
763
+ elsif mapname == "start"
764
+ Entity.new("map" => start_map_next_level)
765
+ else
766
+ @entities&.find { |ent| ent.classname == "trigger_changelevel" } ||
767
+ Entity.new("map" => mapname)
768
+ end
769
+
770
+ queue_changelevel(trigger)
771
+ end
772
+
773
+ def check_rules
774
+ next_level if timelimit.to_f.nonzero? && @game_time.to_f >= timelimit.to_f
775
+ next_level if fraglimit.to_i.nonzero? && @player_state.frags.to_i >= fraglimit.to_i
776
+ end
777
+
778
+ def current_quake_mapname
779
+ File.basename(@current_map.to_s, ".bsp")
780
+ end
781
+
782
+ def start_map_next_level
783
+ return "e1m1" unless @registered
784
+
785
+ if (@player_state.serverflags & 1).zero?
786
+ set_serverflags(@player_state.serverflags | 1)
787
+ "e1m1"
788
+ elsif (@player_state.serverflags & 2).zero?
789
+ set_serverflags(@player_state.serverflags | 2)
790
+ "e2m1"
791
+ elsif (@player_state.serverflags & 4).zero?
792
+ set_serverflags(@player_state.serverflags | 4)
793
+ "e3m1"
794
+ elsif (@player_state.serverflags & 8).zero?
795
+ set_serverflags(@player_state.serverflags - 7)
796
+ "e4m1"
797
+ else
798
+ "start"
799
+ end
800
+ end
801
+
802
+ def set_serverflags(flags)
803
+ @player_state.instance_variable_set(:@serverflags, flags.to_i)
804
+ end
805
+
806
+ def process_item_pickup_targets(event)
807
+ entity = event[:entity]
808
+ return unless entity
809
+ return if event[:use_targets] == false
810
+ return if event[:type] == :backpack || entity.classname == "item_backpack"
811
+
812
+ @brush_game&.use_targets(entity, activator: @player_state)
813
+ end
814
+
815
+ def process_item_pickup_feedback(event)
816
+ sprint(event[:message]) if event[:message]
817
+ @sound_events&.on_pickup(event)
818
+ trigger_bonus_flash
819
+ @particles&.pickup_effect(event[:entity].position)
820
+ @viewmodel&.set_weapon(@player_state.current_weapon_model)
821
+ end
822
+
823
+ def normalize_map_name(map)
824
+ map = map.to_s
825
+ map = "maps/#{map}" unless map.include?("/")
826
+ map.end_with?(".bsp") ? map : "#{map}.bsp"
827
+ end
828
+
829
+ def run_pending_changelevel
830
+ @intermission_next_map = changelevel_destination_map(@pending_changelevel)
831
+ @pending_changelevel = nil
832
+ @pending_changelevel_time = nil
833
+ execute_changelevel
834
+ end
835
+
836
+ def execute_changelevel
837
+ @intermission_running = true
838
+ @intermission_start_time = @game_time.to_f
839
+ @intermission_exittime = @game_time.to_f + 5.0
840
+ @intermission_spot = find_intermission_spot
841
+ @intermission_origin = @intermission_spot.position
842
+ @intermission_angles = vec3_property(@intermission_spot, "mangle")
843
+ freeze_intermission_player(@player) if @player
844
+ (@entities || []).each { |ent| freeze_intermission_player(ent) if ent.classname == "player" }
845
+ end
846
+
847
+ def freeze_intermission_player(player)
848
+ player.instance_variable_set(:@takedamage, 0)
849
+ player.instance_variable_set(:@solid, SOLID_NOT)
850
+ player.instance_variable_set(:@movetype, MOVETYPE_NONE)
851
+ if player.respond_to?(:model_index=)
852
+ player.model_index = 0
853
+ else
854
+ player.instance_variable_set(:@modelindex, 0)
855
+ end
856
+ end
857
+
858
+ def intermission_think
859
+ return if @game_time.to_f < @intermission_exittime.to_f
860
+ return unless intermission_exit_button_down?
861
+
862
+ complete_intermission_changelevel
863
+ end
864
+
865
+ def intermission_exit_button_down?
866
+ @attack_pressed || (@keys || {})[SDL::SCANCODE_SPACE]
867
+ end
868
+
869
+ def complete_intermission_changelevel
870
+ next_map = @intermission_next_map
871
+ @intermission_running = false
872
+ @intermission_next_map = nil
873
+ @intermission_spot = nil
874
+ @intermission_origin = nil
875
+ @intermission_angles = nil
876
+
877
+ # QuakeC changelevel_touch saves SetChangeParms before Host_Changelevel
878
+ # spawns the next server. PlayerState remains persistent after applying
879
+ # those transition rules, while player/camera reset to the new start.
880
+ @player_state.apply_changelevel_parms
881
+ load_map(next_map)
882
+ end
883
+
884
+ def changelevel_destination_map(next_map)
885
+ if @samelevel.to_i == 1
886
+ normalize_map_name(current_quake_mapname)
887
+ else
888
+ override = world_info_value(current_quake_mapname)
889
+ normalize_map_name(override.nil? || override.empty? ? next_map : override)
890
+ end
891
+ end
892
+
893
+ def teleport_trigger(trigger)
894
+ target = trigger.target
895
+ raise "trigger_teleport doesn't have target" if target.nil? || target.empty?
896
+
897
+ dest = @target_map[target]&.find { |ent| ent.classname == "info_teleport_destination" } ||
898
+ @target_map[target]&.first
899
+ raise "couldn't find target" unless dest
900
+
901
+ setup_teleport_destination(dest) if dest.classname == "info_teleport_destination"
902
+ old_pos = @player.position
903
+ dest_pos = dest.position
904
+ mangle = dest.instance_variable_get(:@mangle) || dest.angles
905
+ forward = angle_forward(mangle)
906
+ spawn_teleport_fog(old_pos)
907
+ spawn_teleport_fog(dest_pos + forward * 32.0)
908
+ apply_telefrag_damage(dest_pos)
909
+ if @player_state.health <= 0
910
+ velocity = @player.velocity
911
+ @player.position = dest_pos
912
+ @player.velocity = forward * velocity.x + forward * velocity.y
913
+ return
914
+ end
915
+
916
+ teleport(dest_pos.x, dest_pos.y, dest_pos.z, yaw: mangle.y, pitch: 0.0)
917
+
918
+ forward = @player.forward_flat
919
+ @player.velocity = forward * 300.0
920
+ @player.instance_variable_set(:@fixangle, true)
921
+ @player.instance_variable_set(:@teleport_time, @game_time.to_f + 0.7)
922
+ @player_state.flags &= ~FL_ONGROUND
923
+ @player.on_ground = false
924
+ end
925
+
926
+ def spawn_teleport_fog(origin)
927
+ @particles&.teleport_splash(origin)
928
+ return unless @entities
929
+
930
+ fog = Entity.new("classname" => "teleport_fog", "origin" => vec3_string(origin))
931
+ fog.think_time = @game_time.to_f + 0.2
932
+ fog.instance_variable_set(:@think, :play_teleport)
933
+ @entities << fog
934
+ fog
935
+ end
936
+
937
+ def apply_telefrag_damage(origin)
938
+ teledeath = teledeath_entity(origin)
939
+ mins = origin + ENTITY_HULL_MINS - Math::Vec3.new(1.0, 1.0, 1.0)
940
+ maxs = origin + ENTITY_HULL_MAXS + Math::Vec3.new(1.0, 1.0, 1.0)
941
+
942
+ @entities.each do |ent|
943
+ next unless ent.damageable?
944
+ next if ent.health.to_f.zero?
945
+
946
+ ent_mins, ent_maxs = damageable_bounds(ent)
947
+ next unless ent_mins && ent_maxs
948
+ next unless aabb_intersect?(mins, maxs, ent_mins, ent_maxs)
949
+
950
+ if ent.classname == "player" && ent.invincible_finished.to_f > @game_time.to_f
951
+ if player_invincible_finished > @game_time.to_f
952
+ teledeath.classname = "teledeath3"
953
+ ent.invincible_finished = 0.0
954
+ clear_player_invincibility
955
+ damage_entity(ent, 50_000.0, attacker: teledeath, inflictor: teledeath)
956
+ teledeath.instance_variable_set(:@owner, ent)
957
+ @player_state.deathtype = "teledeath3"
958
+ damage_player(50_000.0, inflictor: teledeath)
959
+ next
960
+ end
961
+
962
+ teledeath.classname = "teledeath2"
963
+ @player_state.deathtype = "teledeath2"
964
+ damage_player(50_000.0, inflictor: teledeath)
965
+ next
966
+ end
967
+
968
+ damage_entity(ent, 50_000.0, attacker: teledeath, inflictor: teledeath)
969
+ end
970
+ end
971
+
972
+ def player_invincible_finished
973
+ @player_state&.powerup_finished&.fetch(:pentagram, 0.0).to_f
974
+ end
975
+
976
+ def clear_player_invincibility
977
+ @player_state&.powerup_finished&.delete(:pentagram)
978
+ @player_state&.powerup_warning_time&.delete(:pentagram)
979
+ end
980
+
981
+ def teledeath_entity(origin)
982
+ Entity.new("classname" => "teledeath", "origin" => "#{origin.x} #{origin.y} #{origin.z}").tap do |ent|
983
+ ent.instance_variable_set(:@owner, @player_state)
984
+ ent.instance_variable_set(:@movetype, MOVETYPE_NONE)
985
+ ent.instance_variable_set(:@solid, 1)
986
+ end
987
+ end
988
+
989
+ def hurt_trigger(trigger)
990
+ return unless player_takedamage?
991
+
992
+ damage_player(trigger_hurt_damage(trigger), pain_sound: true, inflictor: trigger)
993
+ trigger.instance_variable_set(:@touch_disabled, true)
994
+ trigger.instance_variable_set(:@solid, 0)
995
+ trigger.instance_variable_set(:@think, :hurt_on)
996
+ # hurt_touch re-arms 1s later; update_trigger_cooldown counts
997
+ # think_time down as a duration, so store the delay, not a time
998
+ trigger.think_time = 1.0
999
+ end
1000
+
1001
+ def player_takedamage?
1002
+ return @player_state&.alive? unless @player
1003
+
1004
+ return @player_state&.alive? unless @player.instance_variable_defined?(:@takedamage)
1005
+
1006
+ @player.instance_variable_get(:@takedamage).to_i != 0
1007
+ end
1008
+
1009
+ def trigger_hurt_damage(trigger)
1010
+ dmg = trigger["dmg"].to_f
1011
+ dmg.zero? ? 5.0 : dmg
1012
+ end
1013
+
1014
+ def push_trigger(trigger)
1015
+ if @player_state.alive?
1016
+ @player.velocity = trigger_movedir(trigger) * trigger.speed * 10.0
1017
+ @player.on_ground = false
1018
+ if @player.instance_variable_get(:@fly_sound).to_f < @game_time
1019
+ @player.instance_variable_set(:@fly_sound, @game_time + 1.5)
1020
+ @sound_events&.on_trigger_push_player
1021
+ end
1022
+ end
1023
+ trigger.instance_variable_set(:@removed, true) if (trigger.spawnflags & 1) != 0
1024
+ end
1025
+
1026
+ # trigger_push_touch pushes grenades too: velocity = movedir*speed*10
1027
+ def grenade_push_trigger_velocity(position)
1028
+ (@entities || []).each do |trigger|
1029
+ next unless trigger.classname == "trigger_push"
1030
+ next if trigger.removed?
1031
+ next unless trigger.model_index
1032
+
1033
+ model = @level&.models&.[](trigger.model_index)
1034
+ next unless model
1035
+ next unless position.x >= trigger.position.x + model.mins.x &&
1036
+ position.x <= trigger.position.x + model.maxs.x &&
1037
+ position.y >= trigger.position.y + model.mins.y &&
1038
+ position.y <= trigger.position.y + model.maxs.y &&
1039
+ position.z >= trigger.position.z + model.mins.z &&
1040
+ position.z <= trigger.position.z + model.maxs.z
1041
+
1042
+ velocity = trigger_movedir(trigger) * trigger.speed * 10.0
1043
+ trigger.instance_variable_set(:@removed, true) if (trigger.spawnflags & 1) != 0
1044
+ return velocity
1045
+ end
1046
+ nil
1047
+ end
1048
+
1049
+ def trigger_movedir(trigger)
1050
+ return trigger.move_dir if trigger.move_dir != Math::Vec3::ORIGIN
1051
+ return trigger.forward_vector if trigger["angles"] && trigger["angles"].split.map(&:to_f) != [0.0, 0.0, 0.0]
1052
+ return trigger.forward_vector if trigger["angle"] && trigger["angle"].to_f != 0.0
1053
+
1054
+ Math::Vec3::ORIGIN
1055
+ end
1056
+
1057
+ def apply_environment_damage(dt = @view_frame_dt.to_f)
1058
+ return if @player&.noclip
1059
+ return if @player_state&.health&.negative?
1060
+
1061
+ apply_drowning
1062
+ if @player.water_level.zero?
1063
+ update_water_transition_sounds
1064
+ return
1065
+ end
1066
+
1067
+ if @game_time > (@environment_damage_time || 0.0)
1068
+ case @player.water_type
1069
+ when Physics::CONTENTS_LAVA
1070
+ delay = biosuit_slows_lava_damage? ? 1.0 : 0.2
1071
+ @environment_damage_time = @game_time + delay
1072
+ damage_player(10.0 * @player.water_level)
1073
+ when Physics::CONTENTS_SLIME
1074
+ unless biosuit_blocks_slime_damage?
1075
+ @environment_damage_time = @game_time + 1.0
1076
+ damage_player(4.0 * @player.water_level)
1077
+ end
1078
+ end
1079
+ end
1080
+
1081
+ update_water_transition_sounds
1082
+ apply_water_velocity_drag(dt)
1083
+ end
1084
+
1085
+ def update_water_transition_sounds
1086
+ if @player.water_level.zero?
1087
+ if @player_in_water
1088
+ @sound_events&.on_player(:water_exit)
1089
+ @player_in_water = false
1090
+ end
1091
+ return
1092
+ end
1093
+
1094
+ return if @player_in_water
1095
+
1096
+ @player_in_water = true
1097
+ case @player.water_type
1098
+ when Physics::CONTENTS_LAVA
1099
+ @sound_events&.on_player(:lava_enter)
1100
+ when Physics::CONTENTS_SLIME
1101
+ @sound_events&.on_player(:slime_enter)
1102
+ when Physics::CONTENTS_WATER
1103
+ @sound_events&.on_player(:water_enter)
1104
+ end
1105
+ @environment_damage_time = 0.0
1106
+ end
1107
+
1108
+ def play_jump_sounds
1109
+ return unless @keys[SDL::SCANCODE_SPACE]
1110
+ return if @player_state && (@player_state.flags & PlayerState::FL_WATERJUMP) != 0
1111
+
1112
+ if @player.water_level >= 2
1113
+ return unless (@swim_sound_finished || 0.0) < @game_time
1114
+
1115
+ @swim_sound_finished = @game_time + 1.0
1116
+ @sound_events&.on_player(rand < 0.5 ? :swim1 : :swim2)
1117
+ return
1118
+ end
1119
+
1120
+ return unless @player.on_ground
1121
+ return if (@player_state.flags & PlayerState::FL_JUMPRELEASED).zero?
1122
+ return if @player.jump_held
1123
+
1124
+ @sound_events&.on_player(:jump)
1125
+ end
1126
+
1127
+ def apply_fall_damage
1128
+ return unless @player_state.alive?
1129
+
1130
+ if @jump_flag < -300.0 && @player.on_ground
1131
+ if @player.water_type == Physics::CONTENTS_WATER
1132
+ @sound_events&.on_player(:water_land)
1133
+ elsif @jump_flag < -650.0
1134
+ @player_state.deathtype = "falling"
1135
+ damage_player(5)
1136
+ @sound_events&.on_player(:land_hurt)
1137
+ else
1138
+ @sound_events&.on_player(:land)
1139
+ end
1140
+ end
1141
+
1142
+ @jump_flag = @player.velocity.z
1143
+ end
1144
+
1145
+ def apply_drowning
1146
+ if @player.water_level != 3
1147
+ if @air_finished < @game_time
1148
+ @sound_events&.on_player(:gasp_deep)
1149
+ elsif @air_finished < @game_time + 9.0
1150
+ @sound_events&.on_player(:gasp)
1151
+ end
1152
+ @air_finished = @game_time + 12.0
1153
+ @drown_damage = 2.0
1154
+ return
1155
+ end
1156
+
1157
+ return unless @air_finished < @game_time
1158
+ return unless @drown_pain_finished < @game_time
1159
+
1160
+ @drown_damage += 2.0
1161
+ @drown_damage = 10.0 if @drown_damage > 15.0
1162
+ result = damage_player(@drown_damage)
1163
+ @sound_events&.on_player(rand > 0.5 ? :drown1 : :drown2) if result[:take].positive? && @player_state.alive?
1164
+ @drown_pain_finished = @game_time + 1.0
1165
+ end
1166
+
1167
+ def biosuit_blocks_slime_damage?
1168
+ finished = @player_state.powerup_finished[:biosuit]
1169
+ finished && finished >= @game_time
1170
+ end
1171
+
1172
+ def biosuit_slows_lava_damage?
1173
+ finished = @player_state.powerup_finished[:biosuit]
1174
+ finished && finished > @game_time
1175
+ end
1176
+
1177
+ def apply_water_velocity_drag(dt)
1178
+ return if dt.to_f.zero?
1179
+ return if (@player_state.flags & PlayerState::FL_WATERJUMP) != 0
1180
+
1181
+ scale = 1.0 - 0.8 * @player.water_level.to_f * dt.to_f
1182
+ @player.velocity = @player.velocity * scale
1183
+ end
1184
+
1185
+ def damage_player(amount, pain_sound: false, inflictor: nil, attacker: nil, apply_momentum: true)
1186
+ if @player_state.invulnerable_to_damage?(game_time: @game_time) &&
1187
+ (@invincible_sound_finished || 0.0) < @game_time
1188
+ @invincible_sound_finished = @game_time + 2.0
1189
+ @sound_events&.on_player(:pentagram_protect)
1190
+ end
1191
+
1192
+ was_alive = @player_state.alive?
1193
+ apply_player_direct_damage_momentum(amount, inflictor) if apply_momentum
1194
+ result = @player_state.take_damage(amount, game_time: @game_time)
1195
+ result = result.merge(display_take: (amount.to_f - result[:save].to_f).ceil)
1196
+ trigger_damage_flash(result)
1197
+ trigger_damage_kick(result, inflictor)
1198
+ if was_alive && !@player_state.alive?
1199
+ apply_player_obituary_frags(attacker || inflictor)
1200
+ drop_player_powerups
1201
+ drop_player_backpack
1202
+ apply_player_death_state(inflictor: inflictor)
1203
+ @player_state.clear_powerups_on_death
1204
+ play_death_sound(attacker: attacker || inflictor)
1205
+ select_player_death_animation
1206
+ return result
1207
+ end
1208
+
1209
+ if result[:take].positive?
1210
+ select_player_pain_animation
1211
+ if pain_sound
1212
+ play_player_pain_sound(attacker: attacker || inflictor)
1213
+ else
1214
+ play_environment_pain_sound
1215
+ end
1216
+ end
1217
+ result
1218
+ end
1219
+
1220
+ def play_player_pain_sound(attacker: nil)
1221
+ if attacker_classname(attacker) == "teledeath"
1222
+ @sound_events&.on_player(:teledeath)
1223
+ return
1224
+ end
1225
+
1226
+ if @player&.water_type == Physics::CONTENTS_WATER && @player.water_level == 3
1227
+ spawn_death_bubbles(1)
1228
+ @sound_events&.on_player(rand > 0.5 ? :drown1 : :drown2)
1229
+ return
1230
+ end
1231
+
1232
+ case @player&.water_type
1233
+ when Physics::CONTENTS_LAVA, Physics::CONTENTS_SLIME
1234
+ @sound_events&.on_player(rand > 0.5 ? :burn1 : :burn2)
1235
+ else
1236
+ play_generic_pain_sound
1237
+ end
1238
+ end
1239
+
1240
+ def play_environment_pain_sound
1241
+ case @player&.water_type
1242
+ when Physics::CONTENTS_LAVA, Physics::CONTENTS_SLIME
1243
+ @sound_events&.on_player(rand > 0.5 ? :burn1 : :burn2)
1244
+ end
1245
+ end
1246
+
1247
+ def play_generic_pain_sound
1248
+ if (@pain_sound_finished || 0.0) > @game_time
1249
+ @player&.instance_variable_set(:@axhitme, 0)
1250
+ return
1251
+ end
1252
+
1253
+ @pain_sound_finished = @game_time + 0.5
1254
+ if @player&.instance_variable_get(:@axhitme).to_i == 1
1255
+ @player.instance_variable_set(:@axhitme, 0)
1256
+ @sound_events&.on_player(:axe_body_hit)
1257
+ return
1258
+ end
1259
+
1260
+ index = ((rand * 5.0) + 1.0).round
1261
+ index = [[index, 1].max, 6].min
1262
+ @sound_events&.on_player(:"pain#{index}")
1263
+ end
1264
+
1265
+ def play_death_sound(attacker: nil)
1266
+ if @player_state.health < -40
1267
+ if %w[teledeath teledeath2].include?(attacker_classname(attacker)) ||
1268
+ %w[teledeath teledeath2].include?(@player_state.deathtype)
1269
+ @sound_events&.on_player(:teledeath)
1270
+ return
1271
+ end
1272
+
1273
+ @sound_events&.on_player(rand < 0.5 ? :gib : :udeath)
1274
+ return
1275
+ end
1276
+
1277
+ if @player&.water_level == 3
1278
+ spawn_death_bubbles(5)
1279
+ @sound_events&.on_player(:water_death)
1280
+ return
1281
+ end
1282
+
1283
+ index = ((rand * 4.0) + 1.0).round
1284
+ index = [[index, 1].max, 5].min
1285
+ @sound_events&.on_player(:"death#{index}")
1286
+ end
1287
+
1288
+ def apply_player_obituary_frags(attacker)
1289
+ if deathmatch.to_i > 3 && @player_state.deathtype == "selfwater"
1290
+ @player_state.frags -= 1
1291
+ return
1292
+ end
1293
+
1294
+ if @player_state.deathtype == "squish"
1295
+ apply_player_squish_obituary_frags(attacker)
1296
+ return
1297
+ end
1298
+
1299
+ case attacker_classname(attacker)
1300
+ when "teledeath"
1301
+ telefrag_owner(attacker)&.frags += 1
1302
+ when "teledeath2", "teledeath3"
1303
+ @player_state.frags -= 1
1304
+ when "player"
1305
+ apply_player_attacker_obituary_frags(attacker)
1306
+ else
1307
+ @player_state.frags -= 1
1308
+ end
1309
+ end
1310
+
1311
+ def apply_player_squish_obituary_frags(attacker)
1312
+ if teamplay.to_i.nonzero? &&
1313
+ !quake_info_key(attacker, "team").empty? &&
1314
+ quake_info_key(attacker, "team") == quake_info_key(@player_state, "team") &&
1315
+ !attacker.equal?(@player_state)
1316
+ attacker.frags -= 1 if attacker.respond_to?(:frags=)
1317
+ elsif attacker_classname(attacker) == "player" && !attacker.equal?(@player_state)
1318
+ attacker.frags += 1 if attacker.respond_to?(:frags=)
1319
+ else
1320
+ @player_state.frags -= 1
296
1321
  end
297
1322
  end
1323
+
1324
+ def apply_player_attacker_obituary_frags(attacker)
1325
+ if attacker.equal?(@player_state)
1326
+ @player_state.frags -= 1
1327
+ elsif teamplay.to_i == 2 &&
1328
+ !quake_info_key(attacker, "team").empty? &&
1329
+ quake_info_key(attacker, "team") == quake_info_key(@player_state, "team")
1330
+ attacker.frags -= 1 if attacker.respond_to?(:frags=)
1331
+ elsif attacker.respond_to?(:frags=)
1332
+ attacker.frags += 1
1333
+ end
1334
+ end
1335
+
1336
+ def attacker_classname(attacker)
1337
+ attacker.respond_to?(:classname) ? attacker.classname : ""
1338
+ end
1339
+
1340
+ def telefrag_owner(attacker)
1341
+ attacker.instance_variable_get(:@owner) if attacker&.instance_variable_defined?(:@owner)
1342
+ end
1343
+
1344
+ def drop_player_backpack
1345
+ return unless @player_state
1346
+ return unless @player
1347
+
1348
+ ammo = @player_state.ammo
1349
+ return unless ammo.values.sum.positive?
1350
+
1351
+ backpack = Entity.new("classname" => "item_backpack", "origin" => backpack_origin_string)
1352
+ backpack.instance_variable_set(:@items, PLAYER_WEAPON_ITEM_BITS.fetch(@player_state.current_weapon, 0))
1353
+ backpack.instance_variable_set(:@netname, PLAYER_WEAPON_NETNAMES.fetch(@player_state.current_weapon, ""))
1354
+ backpack.instance_variable_set(:@ammo_shells, ammo[:shells])
1355
+ backpack.instance_variable_set(:@ammo_nails, ammo[:nails])
1356
+ backpack.instance_variable_set(:@ammo_rockets, ammo[:rockets])
1357
+ backpack.instance_variable_set(:@ammo_cells, ammo[:cells])
1358
+ backpack.velocity = Math::Vec3.new(-100.0 + rand * 200.0, -100.0 + rand * 200.0, 300.0)
1359
+ backpack.flags = 256
1360
+ backpack.instance_variable_set(:@solid, 1)
1361
+ backpack.instance_variable_set(:@movetype, 6)
1362
+ backpack.properties["model"] = "progs/backpack.mdl"
1363
+ backpack.instance_variable_set(:@mins, Math::Vec3.new(-16.0, -16.0, 0.0))
1364
+ backpack.instance_variable_set(:@maxs, Math::Vec3.new(16.0, 16.0, 56.0))
1365
+ backpack.instance_variable_set(:@touch, :backpack_touch)
1366
+ backpack.think_time = @game_time.to_f + 120.0
1367
+ backpack.instance_variable_set(:@think, :sub_remove)
1368
+ @entities << backpack if @entities
1369
+ @item_pickups&.add_entity(backpack)
1370
+ backpack
1371
+ end
1372
+
1373
+ def drop_player_powerups
1374
+ drop_player_powerup(
1375
+ :quad,
1376
+ "dq",
1377
+ "item_artifact_super_damage",
1378
+ "progs/quaddama.mdl",
1379
+ "items/damage.wav",
1380
+ :q_touch
1381
+ )
1382
+ drop_player_powerup(
1383
+ :ring,
1384
+ "dr",
1385
+ "item_artifact_invisibility",
1386
+ "progs/invisibl.mdl",
1387
+ "items/inv1.wav",
1388
+ :r_touch
1389
+ )
1390
+ end
1391
+
1392
+ def drop_player_powerup(powerup, world_key, classname, model, noise, touch)
1393
+ return unless @player_state
1394
+ return unless @player
1395
+ return unless @entities
1396
+ return unless world_info_enabled?(world_key)
1397
+
1398
+ finish_time = @player_state.powerup_finished[powerup]
1399
+ return unless finish_time&.positive?
1400
+
1401
+ item = Entity.new("classname" => classname, "origin" => player_origin_string)
1402
+ item.velocity = Math::Vec3.new(-100.0 + rand * 200.0, -100.0 + rand * 200.0, 300.0)
1403
+ item.flags = 256
1404
+ item.instance_variable_set(:@solid, 1)
1405
+ item.instance_variable_set(:@movetype, MOVETYPE_TOSS)
1406
+ item.properties["model"] = model
1407
+ item.instance_variable_set(:@mins, Math::Vec3.new(-16.0, -16.0, -24.0))
1408
+ item.instance_variable_set(:@maxs, Math::Vec3.new(16.0, 16.0, 32.0))
1409
+ item.instance_variable_set(:@noise, noise)
1410
+ item.instance_variable_set(:@cnt, finish_time.to_f)
1411
+ item.instance_variable_set(:@touch, touch)
1412
+ item.think_time = finish_time.to_f
1413
+ item.instance_variable_set(:@think, :sub_remove)
1414
+ @entities << item
1415
+ @item_pickups&.add_entity(item)
1416
+ item
1417
+ end
1418
+
1419
+ def player_origin_string
1420
+ origin = @player.position
1421
+ "#{origin.x} #{origin.y} #{origin.z}"
1422
+ end
1423
+
1424
+ def backpack_origin_string
1425
+ origin = @player.position - Math::Vec3.new(0.0, 0.0, 24.0)
1426
+ "#{origin.x} #{origin.y} #{origin.z}"
1427
+ end
1428
+
1429
+ def player_eye_position
1430
+ return @player.eye_position unless @player_state&.view_offset
1431
+
1432
+ @player.position + @player_state.view_offset
1433
+ end
1434
+
1435
+ def sync_camera
1436
+ # Mouse look is always active in this port. With +mlook Quake calls
1437
+ # V_StopPitchDrift every frame (in_win.c IN_MouseMove), and the
1438
+ # laststop == cl.time guard in V_StartPitchDrift then keeps the
1439
+ # view from auto-centering while running forward.
1440
+ stop_pitch_drift
1441
+ drift_view_pitch
1442
+ eye = player_eye_position
1443
+ bob = @viewmodel ? @viewmodel.bob : 0.0
1444
+ punch = current_weapon_punch_angle
1445
+ @viewmodel_base_position = Math::Vec3.new(eye.x, eye.y, eye.z + bob)
1446
+ camera_position = bound_view_origin(Math::Vec3.new(
1447
+ eye.x + VIEW_ORIGIN_NUDGE,
1448
+ eye.y + VIEW_ORIGIN_NUDGE,
1449
+ eye.z + bob + VIEW_ORIGIN_NUDGE
1450
+ ))
1451
+ stair_offset = stair_smooth_offset
1452
+ idle = current_view_idle_angles
1453
+ @camera.position = Math::Vec3.new(
1454
+ camera_position.x,
1455
+ camera_position.y,
1456
+ camera_position.z + stair_offset
1457
+ )
1458
+ @viewmodel_base_position = Math::Vec3.new(
1459
+ @viewmodel_base_position.x,
1460
+ @viewmodel_base_position.y,
1461
+ @viewmodel_base_position.z + stair_offset
1462
+ )
1463
+ @camera.yaw = @player.yaw + idle.y + punch.y
1464
+ damage_roll, pitch = current_damage_kick_angles
1465
+ @camera.pitch = @player.pitch + pitch + idle.x + punch.x
1466
+ @camera.roll = current_view_roll(damage_roll) + idle.z + punch.z
1467
+ end
1468
+
1469
+ def bound_view_origin(origin)
1470
+ player_origin = @player.position
1471
+ Math::Vec3.new(
1472
+ origin.x.clamp(player_origin.x - VIEW_BOUND_XY, player_origin.x + VIEW_BOUND_XY),
1473
+ origin.y.clamp(player_origin.y - VIEW_BOUND_XY, player_origin.y + VIEW_BOUND_XY),
1474
+ origin.z.clamp(player_origin.z + VIEW_BOUND_Z_MIN, player_origin.z + VIEW_BOUND_Z_MAX)
1475
+ )
1476
+ end
1477
+
1478
+ def stair_smooth_offset
1479
+ oldz = @view_oldz.to_f
1480
+ origin_z = @player.position.z
1481
+ if @player.on_ground && origin_z - oldz > 0.0
1482
+ oldz += [@view_frame_dt.to_f, 0.0].max * STAIR_SMOOTH_SPEED
1483
+ oldz = origin_z if oldz > origin_z
1484
+ oldz = origin_z - STAIR_SMOOTH_MAX if origin_z - oldz > STAIR_SMOOTH_MAX
1485
+ @view_oldz = oldz
1486
+ oldz - origin_z
1487
+ else
1488
+ @view_oldz = origin_z
1489
+ 0.0
1490
+ end
1491
+ end
1492
+
1493
+ def current_view_idle_angles
1494
+ scale = @view_idle_scale.to_f
1495
+ return Math::Vec3::ORIGIN if scale.zero?
1496
+
1497
+ time = @game_time.to_f
1498
+ Math::Vec3.new(
1499
+ scale * ::Math.sin(time * VIEW_IDLE_PITCH_CYCLE) * VIEW_IDLE_PITCH_LEVEL,
1500
+ scale * ::Math.sin(time * VIEW_IDLE_YAW_CYCLE) * VIEW_IDLE_YAW_LEVEL,
1501
+ scale * ::Math.sin(time * VIEW_IDLE_ROLL_CYCLE) * VIEW_IDLE_ROLL_LEVEL
1502
+ )
1503
+ end
1504
+
1505
+ def start_pitch_drift
1506
+ return if @view_pitch_laststop.to_f == @game_time.to_f
1507
+ return unless @view_pitch_nodrift || @view_pitch_velocity.to_f.zero?
1508
+
1509
+ @view_pitch_velocity = VIEW_CENTER_SPEED
1510
+ @view_pitch_nodrift = false
1511
+ @view_pitch_driftmove = 0.0
1512
+ end
1513
+
1514
+ def stop_pitch_drift
1515
+ @view_pitch_laststop = @game_time.to_f
1516
+ @view_pitch_nodrift = true
1517
+ @view_pitch_velocity = 0.0
1518
+ end
1519
+
1520
+ def drift_view_pitch
1521
+ return unless @player
1522
+ unless @player.on_ground
1523
+ @view_pitch_driftmove = 0.0
1524
+ @view_pitch_velocity = 0.0
1525
+ return
1526
+ end
1527
+
1528
+ if @view_pitch_nodrift
1529
+ if forward_move_pressed?
1530
+ @view_pitch_driftmove = @view_pitch_driftmove.to_f + @view_frame_dt.to_f
1531
+ start_pitch_drift if @view_pitch_driftmove > VIEW_CENTER_MOVE
1532
+ else
1533
+ @view_pitch_driftmove = 0.0
1534
+ end
1535
+ return
1536
+ end
1537
+
1538
+ delta = @view_idealpitch.to_f - @player.pitch.to_f
1539
+ if delta.zero?
1540
+ @view_pitch_velocity = 0.0
1541
+ return
1542
+ end
1543
+
1544
+ move = @view_frame_dt.to_f * @view_pitch_velocity.to_f
1545
+ @view_pitch_velocity = @view_pitch_velocity.to_f + @view_frame_dt.to_f * VIEW_CENTER_SPEED
1546
+ if delta.positive?
1547
+ if move > delta
1548
+ @view_pitch_velocity = 0.0
1549
+ move = delta
1550
+ end
1551
+ @player.instance_variable_set(:@pitch, @player.pitch + move)
1552
+ else
1553
+ if move > -delta
1554
+ @view_pitch_velocity = 0.0
1555
+ move = -delta
1556
+ end
1557
+ @player.instance_variable_set(:@pitch, @player.pitch - move)
1558
+ end
1559
+ end
1560
+
1561
+ def forward_move_pressed?
1562
+ keys = @keys || {}
1563
+ keys[SDL::SCANCODE_W] || keys[SDL::SCANCODE_S]
1564
+ end
1565
+
1566
+ def viewmodel_camera
1567
+ punch = current_weapon_punch_angle
1568
+ idle = current_view_idle_angles
1569
+
1570
+ camera = Camera.new(
1571
+ position: @viewmodel_base_position || (@camera.position - VIEW_ORIGIN_NUDGE_VECTOR),
1572
+ yaw: @camera.yaw - punch.y - idle.y,
1573
+ pitch: @camera.pitch - punch.x - idle.x,
1574
+ fov: @camera.fov,
1575
+ near: @camera.near,
1576
+ far: @camera.far
1577
+ )
1578
+ camera.roll = @camera.roll - punch.z - idle.z
1579
+ camera
1580
+ end
1581
+
1582
+ def current_view_roll(damage_roll = 0.0)
1583
+ return 80.0 if @player_state&.health.to_i <= 0
1584
+
1585
+ @player.instance_variable_get(:@roll).to_f + movement_view_roll + damage_roll.to_f
1586
+ end
1587
+
1588
+ def movement_view_roll
1589
+ quake_calc_roll(@player.velocity, @player.right)
1590
+ end
1591
+
1592
+ def quake_calc_roll(velocity, right)
1593
+ side = velocity.dot(right)
1594
+ sign = side.negative? ? -1.0 : 1.0
1595
+ side = side.abs
1596
+ value = 2.0
1597
+ side = if side < 200.0
1598
+ side * value / 200.0
1599
+ else
1600
+ value
1601
+ end
1602
+ side * sign
1603
+ end
1604
+
1605
+ def apply_player_death_state(inflictor: nil)
1606
+ @player_state.weapon_model = ""
1607
+ @player_state.view_offset = PlayerState::DEATH_VIEW_OFFSET
1608
+ @player_state.deadflag = PlayerState::DEAD_DYING
1609
+ @player_state.flags &= ~FL_ONGROUND
1610
+ @player&.instance_variable_set(:@model, "progs/player.mdl")
1611
+ @player&.instance_variable_set(:@solid, SOLID_NOT)
1612
+ @player&.instance_variable_set(:@movetype, MOVETYPE_TOSS)
1613
+ @player&.instance_variable_set(:@takedamage, 0)
1614
+ @player&.instance_variable_set(:@touch, nil)
1615
+ @player&.instance_variable_set(:@effects, 0)
1616
+ @player.on_ground = false if @player
1617
+ return apply_player_gib_state(inflictor: inflictor) if @player_state.health < -40
1618
+ @player&.instance_variable_set(:@pitch, 0.0)
1619
+ @player&.instance_variable_set(:@roll, 0.0)
1620
+ return unless @player && @player.velocity.z < 10.0
1621
+
1622
+ @player.velocity = Math::Vec3.new(
1623
+ @player.velocity.x,
1624
+ @player.velocity.y,
1625
+ @player.velocity.z + rand * 300.0
1626
+ )
1627
+ end
1628
+
1629
+ def apply_player_gib_state(inflictor: nil)
1630
+ @player_state.view_offset = PlayerState::GIB_VIEW_OFFSET
1631
+ @player_state.deadflag = PlayerState::DEAD_DEAD
1632
+ @player&.instance_variable_set(:@movetype, MOVETYPE_BOUNCE)
1633
+ return unless @player
1634
+
1635
+ @player.instance_variable_set(:@model, "progs/h_player.mdl")
1636
+ @player.instance_variable_set(:@frame, 0)
1637
+ @player.instance_variable_set(:@nextthink, -1)
1638
+ @player.instance_variable_set(:@mins, Math::Vec3.new(-16.0, -16.0, 0.0))
1639
+ @player.instance_variable_set(:@maxs, Math::Vec3.new(16.0, 16.0, 56.0))
1640
+ @player.instance_variable_set(:@avelocity, Math::Vec3.new(0.0, crandom * 600.0, 0.0))
1641
+ @player.velocity = gib_velocity_for_damage(@player_state.health, inflictor: inflictor)
1642
+ @player.position = @player.position - Math::Vec3.new(0.0, 0.0, 24.0)
1643
+ throw_player_gib("progs/gib1.mdl", inflictor: inflictor)
1644
+ throw_player_gib("progs/gib2.mdl", inflictor: inflictor)
1645
+ throw_player_gib("progs/gib3.mdl", inflictor: inflictor)
1646
+ end
1647
+
1648
+ def select_player_death_animation
1649
+ return unless @player && @player_state.health >= -40
1650
+
1651
+ frames = if @player_state.current_weapon == :axe
1652
+ PLAYER_DEATH_FRAMES.fetch(:axe)
1653
+ else
1654
+ PLAYER_DEATH_FRAMES.fetch(random_player_death_sequence)
1655
+ end
1656
+ @player.instance_variable_set(:@death_animation_frames, frames)
1657
+ @player.instance_variable_set(:@death_animation_index, 0)
1658
+ @player.instance_variable_set(:@frame, frames.fetch(0))
1659
+ @player.instance_variable_set(:@nextthink, @game_time.to_f + 0.1)
1660
+ end
1661
+
1662
+ def random_player_death_sequence
1663
+ forced_sequence = @temp1.to_i
1664
+ sequence = forced_sequence.zero? ? 1 + (rand * 6.0).floor : forced_sequence
1665
+ case sequence
1666
+ when 1 then :a
1667
+ when 2 then :b
1668
+ when 3 then :c
1669
+ when 4 then :d
1670
+ else :e
1671
+ end
1672
+ end
1673
+
1674
+ def update_player_death_animation
1675
+ return unless @player && @player_state
1676
+ return unless @player_state.deadflag == PlayerState::DEAD_DYING
1677
+
1678
+ nextthink = @player.instance_variable_get(:@nextthink)
1679
+ return unless nextthink && nextthink >= 0.0
1680
+ return if nextthink > @game_time
1681
+
1682
+ frames = @player.instance_variable_get(:@death_animation_frames)
1683
+ return unless frames && !frames.empty?
1684
+
1685
+ index = @player.instance_variable_get(:@death_animation_index).to_i + 1
1686
+ index = frames.length - 1 if index >= frames.length
1687
+ @player.instance_variable_set(:@death_animation_index, index)
1688
+ @player.instance_variable_set(:@frame, frames.fetch(index))
1689
+ if index == frames.length - 1
1690
+ player_dead
1691
+ else
1692
+ @player.instance_variable_set(:@nextthink, @game_time.to_f + 0.1)
1693
+ end
1694
+ end
1695
+
1696
+ def player_dead
1697
+ @player_state.deadflag = PlayerState::DEAD_DEAD
1698
+ @player&.instance_variable_set(:@nextthink, -1)
1699
+ end
1700
+
1701
+ def set_suicide_frame
1702
+ return unless @player && @player_state
1703
+ return unless @player.instance_variable_get(:@model) == "progs/player.mdl"
1704
+
1705
+ @player.instance_variable_set(:@frame, PLAYER_DEATHA11_FRAME)
1706
+ @player.instance_variable_set(:@solid, SOLID_NOT)
1707
+ @player.instance_variable_set(:@movetype, MOVETYPE_TOSS)
1708
+ @player_state.deadflag = PlayerState::DEAD_DEAD
1709
+ @player.instance_variable_set(:@nextthink, -1)
1710
+ end
1711
+
1712
+ def client_kill
1713
+ return unless @player && @player_state
1714
+
1715
+ set_suicide_frame
1716
+ @player.instance_variable_set(:@model, "progs/player.mdl")
1717
+ @player_state.frags -= 2
1718
+ respawn_player
1719
+ end
1720
+
1721
+ def update_player_control(dt, solid_brush)
1722
+ return if @player_state.view_offset == Math::Vec3::ORIGIN
1723
+
1724
+ @player_state.deathtype = ""
1725
+ if @player_state.deadflag >= PlayerState::DEAD_DEAD
1726
+ player_death_think
1727
+ return
1728
+ end
1729
+ return if @player_state.deadflag == PlayerState::DEAD_DYING
1730
+
1731
+ play_jump_sounds
1732
+ apply_player_pausetime
1733
+ @player.update(dt, @level, quake_player_movement_keys, brush_entities: solid_brush)
1734
+ sync_water_jump_flag
1735
+ apply_fall_damage
1736
+ apply_environment_damage(dt)
1737
+ end
1738
+
1739
+ # Mirror the physics player's FL_WATERJUMP state into the QuakeC-style
1740
+ # player flags so jump sounds and water drag see it.
1741
+ def sync_water_jump_flag
1742
+ if @player.water_jump
1743
+ @player_state.flags |= PlayerState::FL_WATERJUMP
1744
+ else
1745
+ @player_state.flags &= ~PlayerState::FL_WATERJUMP
1746
+ end
1747
+ end
1748
+
1749
+ def player_postthink_active?
1750
+ @player_state.view_offset != Math::Vec3::ORIGIN &&
1751
+ @player_state.deadflag == PlayerState::DEAD_NO
1752
+ end
1753
+
1754
+ def apply_player_pausetime
1755
+ return unless @game_time.to_f < @player.instance_variable_get(:@pausetime).to_f
1756
+
1757
+ @player.velocity = Math::Vec3::ORIGIN
1758
+ end
1759
+
1760
+ def quake_player_movement_keys
1761
+ keys = @keys || {}
1762
+ keys = quake_jump_movement_keys(keys)
1763
+ teleport_time = @player.instance_variable_get(:@teleport_time).to_f
1764
+ return keys unless @game_time.to_f < teleport_time
1765
+ return keys if keys[SDL::SCANCODE_W] || !keys[SDL::SCANCODE_S]
1766
+
1767
+ keys.merge(SDL::SCANCODE_S => false)
1768
+ end
1769
+
1770
+ def quake_jump_movement_keys(keys)
1771
+ return keys unless @player_state
1772
+
1773
+ jump_pressed = keys[SDL::SCANCODE_SPACE]
1774
+ unless jump_pressed
1775
+ @player_state.flags |= PlayerState::FL_JUMPRELEASED
1776
+ return keys
1777
+ end
1778
+
1779
+ return keys if @player.water_level >= 2
1780
+ return keys unless @player.on_ground
1781
+
1782
+ if (@player_state.flags & PlayerState::FL_JUMPRELEASED).zero?
1783
+ return keys.merge(SDL::SCANCODE_SPACE => false)
1784
+ end
1785
+
1786
+ @player_state.flags &= ~PlayerState::FL_JUMPRELEASED
1787
+ keys
1788
+ end
1789
+
1790
+ def player_death_think
1791
+ apply_player_death_friction if @player.on_ground
1792
+ if @player_state.deadflag == PlayerState::DEAD_DEAD
1793
+ return if player_death_button_down?
1794
+
1795
+ @player_state.deadflag = PlayerState::DEAD_RESPAWNABLE
1796
+ return
1797
+ end
1798
+
1799
+ return unless player_death_button_down?
1800
+
1801
+ @attack_pressed = false
1802
+ @keys.delete(SDL::SCANCODE_SPACE) if defined?(SDL::SCANCODE_SPACE)
1803
+ respawn_player
1804
+ end
1805
+
1806
+ def respawn_player
1807
+ copy_player_to_body_queue
1808
+ @player_state.reset_to_new_parms
1809
+ @attack_finished = @game_time
1810
+ @air_finished = @game_time + 12.0
1811
+ @drown_damage = 2.0
1812
+ @drown_pain_finished = 0.0
1813
+ @player_in_water = false
1814
+ @jump_flag = 0.0
1815
+
1816
+ set_player_spawn_point(select_spawn_point)
1817
+ @player.position = player_spawn_origin
1818
+ @player.instance_variable_set(:@yaw, (@player_yaw || 0.0).to_f)
1819
+ apply_put_client_in_server_spawn_effects
1820
+ @player.velocity = Math::Vec3::ORIGIN
1821
+ @player.on_ground = false
1822
+ @player.water_level = 0
1823
+ @player.water_type = Physics::CONTENTS_EMPTY
1824
+ @player.instance_variable_set(:@fixangle, true)
1825
+ @player.instance_variable_set(:@pitch, 0.0)
1826
+ @player.instance_variable_set(:@roll, 0.0)
1827
+ set_player_spawn_entity_state
1828
+ @viewmodel&.set_weapon(@player_state.current_weapon_model)
1829
+ sync_camera if @camera
1830
+ end
1831
+
1832
+ def set_player_spawn_point(spot)
1833
+ return unless spot
1834
+
1835
+ @player_start = spot&.position || Math::Vec3::ORIGIN
1836
+ @player_yaw = spot ? spot.angles.y : 0.0
1837
+ end
1838
+
1839
+ def player_spawn_origin
1840
+ (@player_start || Math::Vec3::ORIGIN) + Math::Vec3.new(0.0, 0.0, 1.0)
1841
+ end
1842
+
1843
+ # SelectSpawnPoint (client.qc): deathmatch spots only in deathmatch;
1844
+ # single player uses info_player_start2 on episode returns
1845
+ # (serverflags set), else info_player_start.
1846
+ def select_spawn_point
1847
+ test_start = @entities&.find { |ent| ent.classname == "testplayerstart" }
1848
+ return test_start if test_start
1849
+
1850
+ if @deathmatch.to_i.positive?
1851
+ deathmatch_spots = @entities&.select { |ent| ent.classname == "info_player_deathmatch" } || []
1852
+ return select_deathmatch_spawn_point(deathmatch_spots) unless deathmatch_spots.empty?
1853
+ end
1854
+
1855
+ if @player_state&.serverflags.to_i.positive?
1856
+ episode_return = @entities&.find { |ent| ent.classname == "info_player_start2" }
1857
+ return episode_return if episode_return
1858
+ end
1859
+
1860
+ @entities&.find { |ent| ent.classname == "info_player_start" }
1861
+ end
1862
+
1863
+ def select_deathmatch_spawn_point(spots)
1864
+ open_spots = spots.select { |spot| player_count_near_spawn(spot).zero? }
1865
+ return random_spawn_spot(spots) if open_spots.empty?
1866
+
1867
+ random_spawn_spot(open_spots.reverse)
1868
+ end
1869
+
1870
+ def player_count_near_spawn(spot)
1871
+ (@entities || []).count do |ent|
1872
+ ent.classname == "player" && (ent.position - spot.position).length <= 84.0
1873
+ end
1874
+ end
1875
+
1876
+ def random_spawn_spot(spots)
1877
+ return nil if spots.empty?
1878
+
1879
+ index = (rand * (spots.length - 1)).round
1880
+ spots.fetch(index)
1881
+ end
1882
+
1883
+ def set_player_spawn_entity_state
1884
+ @player.instance_variable_set(:@model, "progs/player.mdl")
1885
+ frames = @player_state.current_weapon == :axe ? PLAYER_STAND_FRAMES.fetch(:axe) : PLAYER_STAND_FRAMES.fetch(:normal)
1886
+ @player.instance_variable_set(:@frame, frames.fetch(0))
1887
+ @player.instance_variable_set(:@walkframe, 1)
1888
+ @player.instance_variable_set(:@weaponframe, 0)
1889
+ @player.instance_variable_set(:@solid, SOLID_SLIDEBOX)
1890
+ @player.instance_variable_set(:@movetype, MOVETYPE_WALK)
1891
+ @player.instance_variable_set(:@takedamage, DAMAGE_AIM)
1892
+ @player.instance_variable_set(:@effects, 0)
1893
+ @player.instance_variable_set(:@pausetime, 0.0)
1894
+ @player.instance_variable_set(:@mins, ENTITY_HULL_MINS)
1895
+ @player.instance_variable_set(:@maxs, ENTITY_HULL_MAXS)
1896
+ @player.instance_variable_set(:@nextthink, nil)
1897
+ @player.instance_variable_set(:@death_animation_frames, nil)
1898
+ @player.instance_variable_set(:@pain_animation_frames, nil)
1899
+ end
1900
+
1901
+ def apply_put_client_in_server_spawn_effects
1902
+ yaw = (@player_yaw || 0.0).to_f
1903
+ spawn_teleport_fog(@player.position + angle_forward(Math::Vec3.new(0.0, yaw, 0.0)) * 20.0)
1904
+ apply_spawn_telefrag_damage(@player.position)
1905
+ apply_world_spawn_modifiers
1906
+ apply_deathmatch_spawn_parms if @player_state.alive?
1907
+ end
1908
+
1909
+ def apply_world_spawn_modifiers
1910
+ world_rj = world_info_value("rj")
1911
+ @rj = world_rj.to_f if world_rj && world_rj.to_f.nonzero?
1912
+ end
1913
+
1914
+ def apply_deathmatch_spawn_parms
1915
+ case deathmatch.to_i
1916
+ when 4
1917
+ @player_state.health = 250
1918
+ @player_state.armor = 200
1919
+ @player_state.armor_type = 3
1920
+ @player_state.ammo[:shells] = 0
1921
+ if !deathmatch_four_axe_mode?
1922
+ @player_state.ammo[:shells] = 255
1923
+ @player_state.ammo[:nails] = 255
1924
+ @player_state.ammo[:rockets] = 255
1925
+ @player_state.ammo[:cells] = 255
1926
+ @player_state.weapons_owned.merge(
1927
+ %i[nailgun super_nailgun super_shotgun rocket_launcher lightning_gun]
1928
+ )
1929
+ end
1930
+ @player_state.add_powerup(:pentagram, game_time: @game_time, finish_time: @game_time.to_f + 3.0)
1931
+ when 5
1932
+ @player_state.health = 200
1933
+ @player_state.armor = 200
1934
+ @player_state.armor_type = 3
1935
+ @player_state.ammo[:shells] = 30
1936
+ @player_state.ammo[:nails] = 80
1937
+ @player_state.ammo[:rockets] = 10
1938
+ @player_state.ammo[:cells] = 30
1939
+ @player_state.weapons_owned.merge(
1940
+ %i[nailgun super_nailgun super_shotgun rocket_launcher grenade_launcher lightning_gun]
1941
+ )
1942
+ @player_state.add_powerup(:pentagram, game_time: @game_time, finish_time: @game_time.to_f + 3.0)
1943
+ end
1944
+ end
1945
+
1946
+ def deathmatch_four_axe_mode?
1947
+ axe.to_f.nonzero? || world_info_enabled?("axe")
1948
+ end
1949
+
1950
+ def apply_spawn_telefrag_damage(origin)
1951
+ apply_telefrag_damage(origin)
1952
+ end
1953
+
1954
+ def copy_player_to_body_queue
1955
+ initialize_body_queue
1956
+ body = @body_queue.fetch(@body_queue_index)
1957
+ body.angles = Math::Vec3.new(@player.pitch, @player.yaw, @player.instance_variable_get(:@roll).to_f)
1958
+ body.properties["model"] = @player.instance_variable_get(:@model).to_s
1959
+ body.model_index = player_model_index
1960
+ body.frame = @player.instance_variable_get(:@frame).to_i
1961
+ body.instance_variable_set(:@colormap, @player.instance_variable_get(:@colormap))
1962
+ body.instance_variable_set(:@movetype, @player.instance_variable_get(:@movetype))
1963
+ body.velocity = @player.velocity
1964
+ body.flags = 0
1965
+ body.position = @player.position
1966
+ body.instance_variable_set(:@mins, @player.instance_variable_get(:@mins))
1967
+ body.instance_variable_set(:@maxs, @player.instance_variable_get(:@maxs))
1968
+ @body_queue_index = (@body_queue_index + 1) % @body_queue.length
1969
+ body
1970
+ end
1971
+
1972
+ def player_model_index
1973
+ return @player.model_index if @player.respond_to?(:model_index)
1974
+
1975
+ @player.instance_variable_get(:@model_index)
1976
+ end
1977
+
1978
+ def initialize_body_queue
1979
+ return if @body_queue && @body_queue.all? { |body| @entities&.include?(body) }
1980
+
1981
+ @body_queue = 4.times.map { Entity.new("classname" => "bodyque") }
1982
+ @body_queue.each_with_index do |body, index|
1983
+ body.instance_variable_set(:@owner, @body_queue[(index + 1) % @body_queue.length])
1984
+ end
1985
+ @body_queue_index = 0
1986
+ @entities ||= []
1987
+ @entities.concat(@body_queue)
1988
+ end
1989
+
1990
+ def apply_player_death_friction
1991
+ speed = @player.velocity.length - 20.0
1992
+ @player.velocity = if speed <= 0.0
1993
+ Math::Vec3::ORIGIN
1994
+ else
1995
+ @player.velocity.normalize * speed
1996
+ end
1997
+ end
1998
+
1999
+ def player_death_button_down?
2000
+ @attack_pressed || (@keys || {})[SDL::SCANCODE_SPACE]
2001
+ end
2002
+
2003
+ def select_player_pain_animation
2004
+ return unless @player && @player_state
2005
+ return unless @player_state.deadflag == PlayerState::DEAD_NO
2006
+ return if @player.instance_variable_get(:@weaponframe).to_i.positive?
2007
+ return if @player_state.powerup_active?(:ring, game_time: @game_time)
2008
+
2009
+ frames = if @player_state.current_weapon == :axe
2010
+ PLAYER_PAIN_FRAMES.fetch(:axe)
2011
+ else
2012
+ PLAYER_PAIN_FRAMES.fetch(:normal)
2013
+ end
2014
+ @player.instance_variable_set(:@pain_animation_frames, frames)
2015
+ @player.instance_variable_set(:@pain_animation_index, 0)
2016
+ @player.instance_variable_set(:@frame, frames.fetch(0))
2017
+ @player.instance_variable_set(:@weaponframe, 0)
2018
+ @player.instance_variable_set(:@pain_nextthink, @game_time.to_f + 0.1)
2019
+ end
2020
+
2021
+ def update_player_pain_animation
2022
+ return unless @player && @player_state
2023
+ return unless @player_state.deadflag == PlayerState::DEAD_NO
2024
+
2025
+ nextthink = @player.instance_variable_get(:@pain_nextthink)
2026
+ return unless nextthink
2027
+ return if nextthink > @game_time
2028
+
2029
+ frames = @player.instance_variable_get(:@pain_animation_frames)
2030
+ return unless frames && !frames.empty?
2031
+
2032
+ index = @player.instance_variable_get(:@pain_animation_index).to_i + 1
2033
+ if index >= frames.length
2034
+ @player.instance_variable_set(:@pain_animation_frames, nil)
2035
+ @player.instance_variable_set(:@pain_animation_index, nil)
2036
+ @player.instance_variable_set(:@pain_nextthink, nil)
2037
+ return
2038
+ end
2039
+
2040
+ @player.instance_variable_set(:@pain_animation_index, index)
2041
+ @player.instance_variable_set(:@frame, frames.fetch(index))
2042
+ @player.instance_variable_set(:@pain_nextthink, @game_time.to_f + 0.1)
2043
+ end
2044
+
2045
+ def gib_velocity_for_damage(damage, inflictor: nil)
2046
+ velocity = if moving_damage_inflictor?(inflictor)
2047
+ base = inflictor.velocity * 0.5
2048
+ push = (@player.position - inflictor.position).normalize * 25.0
2049
+ velocity = base + push
2050
+ z = 100.0 + 240.0 * rand
2051
+ Math::Vec3.new(velocity.x + 200.0 * crandom, velocity.y + 200.0 * crandom, z)
2052
+ else
2053
+ Math::Vec3.new(100.0 * crandom, 100.0 * crandom, 200.0 + 100.0 * rand)
2054
+ end
2055
+ scale = if damage > -50
2056
+ 0.7
2057
+ elsif damage > -200
2058
+ 2.0
2059
+ else
2060
+ 10.0
2061
+ end
2062
+ velocity * scale
2063
+ end
2064
+
2065
+ def moving_damage_inflictor?(inflictor)
2066
+ @player && inflictor&.respond_to?(:velocity) && inflictor.respond_to?(:position) &&
2067
+ inflictor.velocity.length.positive?
2068
+ end
2069
+
2070
+ def throw_player_gib(model, inflictor: nil)
2071
+ return unless @entities && @player_state && @player
2072
+
2073
+ gib = Entity.new("origin" => vec3_string(@player.position), "model" => model)
2074
+ gib.instance_variable_set(:@mins, Math::Vec3::ORIGIN)
2075
+ gib.instance_variable_set(:@maxs, Math::Vec3::ORIGIN)
2076
+ gib.velocity = gib_velocity_for_damage(@player_state.health, inflictor: inflictor)
2077
+ gib.instance_variable_set(:@movetype, MOVETYPE_BOUNCE)
2078
+ gib.instance_variable_set(:@solid, SOLID_NOT)
2079
+ gib.instance_variable_set(:@avelocity, Math::Vec3.new(rand * 600.0, rand * 600.0, rand * 600.0))
2080
+ gib.think_time = @game_time.to_f + 10.0 + rand * 10.0
2081
+ gib.frame = 0
2082
+ gib.flags = 0
2083
+ gib.instance_variable_set(:@think, :sub_remove)
2084
+ @entities << gib
2085
+ gib
2086
+ end
2087
+
2088
+ def spawn_death_bubbles(count)
2089
+ return unless @entities && @player
2090
+
2091
+ spawner = Entity.new("origin" => vec3_string(@player.position))
2092
+ spawner.instance_variable_set(:@movetype, MOVETYPE_NONE)
2093
+ spawner.instance_variable_set(:@solid, SOLID_NOT)
2094
+ spawner.think_time = @game_time.to_f + 0.1
2095
+ spawner.instance_variable_set(:@think, :death_bubbles_spawn)
2096
+ spawner.instance_variable_set(:@air_finished, 0)
2097
+ spawner.instance_variable_set(:@owner, @player)
2098
+ spawner.instance_variable_set(:@bubble_count, count)
2099
+ @entities << spawner
2100
+ spawner
2101
+ end
2102
+
2103
+ def spawn_death_bubble(owner)
2104
+ spawn_bubble(
2105
+ origin: owner.position + Math::Vec3.new(0.0, 0.0, 24.0),
2106
+ velocity: Math::Vec3.new(0.0, 0.0, 15.0),
2107
+ frame: 0,
2108
+ cnt: 0,
2109
+ waterlevel: owner.water_level,
2110
+ touch: nil
2111
+ )
2112
+ end
2113
+
2114
+ def spawn_bubble(origin:, velocity:, frame:, cnt:, waterlevel: 0, touch: :bubble_remove)
2115
+ bubble = Entity.new(
2116
+ "classname" => "bubble",
2117
+ "origin" => vec3_string(origin),
2118
+ "model" => "progs/s_bubble.spr"
2119
+ )
2120
+ bubble.instance_variable_set(:@movetype, MOVETYPE_NOCLIP)
2121
+ bubble.instance_variable_set(:@solid, SOLID_NOT)
2122
+ bubble.velocity = velocity
2123
+ bubble.think_time = @game_time.to_f + 0.5
2124
+ bubble.instance_variable_set(:@think, :bubble_bob)
2125
+ bubble.instance_variable_set(:@touch, touch)
2126
+ bubble.frame = frame
2127
+ bubble.instance_variable_set(:@cnt, cnt)
2128
+ bubble.instance_variable_set(:@waterlevel, waterlevel.to_i)
2129
+ bubble.instance_variable_set(:@mins, Math::Vec3.new(-8.0, -8.0, -8.0))
2130
+ bubble.instance_variable_set(:@maxs, Math::Vec3.new(8.0, 8.0, 8.0))
2131
+ @entities << bubble
2132
+ bubble
2133
+ end
2134
+
2135
+ def update_death_bubble_spawner(ent)
2136
+ return if ent.think_time > @game_time
2137
+
2138
+ owner = ent.instance_variable_get(:@owner)
2139
+ return unless owner&.water_level == 3
2140
+
2141
+ spawn_death_bubble(owner)
2142
+ ent.think_time = @game_time.to_f + 0.1
2143
+ ent.instance_variable_set(:@think, :death_bubbles_spawn)
2144
+ air_finished = ent.instance_variable_get(:@air_finished).to_i + 1
2145
+ ent.instance_variable_set(:@air_finished, air_finished)
2146
+ if air_finished >= ent.instance_variable_get(:@bubble_count).to_i
2147
+ ent.instance_variable_set(:@removed, true)
2148
+ end
2149
+ end
2150
+
2151
+ def update_bubble_bob(ent)
2152
+ return if ent.think_time > @game_time
2153
+
2154
+ cnt = ent.instance_variable_get(:@cnt).to_i + 1
2155
+ ent.instance_variable_set(:@cnt, cnt)
2156
+ if cnt == 4
2157
+ waterlevel = ent.instance_variable_get(:@waterlevel).to_i
2158
+ spawn_bubble(origin: ent.position, velocity: ent.velocity, frame: 1, cnt: 10, waterlevel: waterlevel)
2159
+ ent.frame = 1
2160
+ ent.instance_variable_set(:@cnt, 10)
2161
+ ent.instance_variable_set(:@removed, true) if waterlevel != 3
2162
+ end
2163
+ if ent.instance_variable_get(:@cnt).to_i == 20
2164
+ ent.instance_variable_set(:@removed, true)
2165
+ end
2166
+
2167
+ rnd1 = ent.velocity.x + (-10.0 + rand * 20.0)
2168
+ rnd2 = ent.velocity.y + (-10.0 + rand * 20.0)
2169
+ rnd3 = ent.velocity.z + 10.0 + rand * 10.0
2170
+ rnd1 = 5.0 if rnd1 > 10.0
2171
+ rnd1 = -5.0 if rnd1 < -10.0
2172
+ rnd2 = 5.0 if rnd2 > 10.0
2173
+ rnd2 = -5.0 if rnd2 < -10.0
2174
+ rnd3 = 15.0 if rnd3 < 10.0
2175
+ rnd3 = 25.0 if rnd3 > 30.0
2176
+ ent.velocity = Math::Vec3.new(rnd1, rnd2, rnd3)
2177
+ ent.think_time = @game_time.to_f + 0.5
2178
+ ent.instance_variable_set(:@think, :bubble_bob)
2179
+ end
2180
+
2181
+ def update_temporary_entities
2182
+ @temporary_beams&.reject! { |beam| beam.end_time < @game_time.to_f }
2183
+ return unless @entities
2184
+
2185
+ @entities.each do |ent|
2186
+ next if ent.removed?
2187
+ case ent.instance_variable_get(:@think)
2188
+ when :play_teleport
2189
+ next if ent.think_time > @game_time
2190
+
2191
+ @sound_events&.on_teleport_fog(teleport_fog_sound)
2192
+ ent.instance_variable_set(:@removed, true)
2193
+ next
2194
+ when :death_bubbles_spawn
2195
+ update_death_bubble_spawner(ent)
2196
+ next
2197
+ when :bubble_bob
2198
+ update_bubble_bob(ent)
2199
+ next
2200
+ end
2201
+
2202
+ next unless ent.instance_variable_get(:@think) == :sub_remove
2203
+ next if ent.think_time > @game_time
2204
+
2205
+ ent.instance_variable_set(:@removed, true)
2206
+ end
2207
+ end
2208
+
2209
+ def teleport_fog_sound
2210
+ index = (rand * 5.0).floor + 1
2211
+ index = [[index, 1].max, 5].min
2212
+ "misc/r_tele#{index}.wav"
2213
+ end
2214
+
2215
+ def vec3_string(vec)
2216
+ "#{vec.x} #{vec.y} #{vec.z}"
2217
+ end
2218
+
2219
+ def vec3_property(ent, key)
2220
+ raw = ent[key]
2221
+ return Math::Vec3::ORIGIN unless raw
2222
+
2223
+ parts = raw.split.first(3).map(&:to_f)
2224
+ parts << 0.0 while parts.length < 3
2225
+ Math::Vec3.new(parts[0], parts[1], parts[2])
2226
+ end
2227
+
2228
+ def check_powerup_warnings
2229
+ return if @player_state.health <= 0
2230
+
2231
+ play_ring_idle_sound
2232
+ POWERUP_EXPIRING_EVENTS.each do |powerup, event|
2233
+ finished = @player_state.powerup_finished[powerup]
2234
+ next unless finished
2235
+ next unless finished < @game_time + 3.0
2236
+
2237
+ warning_time = @player_state.powerup_warning_time[powerup].to_f
2238
+ if warning_time == 1.0
2239
+ @player_state.powerup_warning_time[powerup] = @game_time.to_f + 1.0
2240
+ sprint(powerup_expiring_message(powerup))
2241
+ trigger_bonus_flash
2242
+ @sound_events&.on_player(event)
2243
+ elsif warning_time < @game_time
2244
+ @player_state.powerup_warning_time[powerup] = @game_time.to_f + 1.0
2245
+ trigger_bonus_flash
2246
+ end
2247
+ end
2248
+ end
2249
+
2250
+ # client.qc CheckPowerups sprints; deathmatch 4 renames the quad to
2251
+ # OctaPower.
2252
+ def powerup_expiring_message(powerup)
2253
+ return "OctaPower is wearing off" if powerup == :quad && @deathmatch.to_i == 4
2254
+
2255
+ POWERUP_EXPIRING_MESSAGES[powerup]
2256
+ end
2257
+
2258
+ def play_ring_idle_sound
2259
+ return unless @player_state.powerup_finished[:ring]
2260
+ return unless (@ring_idle_sound_finished || 0.0) < @game_time
2261
+
2262
+ @ring_idle_sound_finished = @game_time + (rand * 3.0) + 1.0
2263
+ @sound_events&.on_player(:ring_idle)
2264
+ end
2265
+
2266
+ def refresh_biosuit_air
2267
+ return unless @player_state.powerup_finished[:biosuit]
2268
+
2269
+ @air_finished = @game_time + 12.0
2270
+ end
2271
+
2272
+ def update_player_powerup_visual_state
2273
+ return unless @player && @player_state
2274
+ return if @player_state.health <= 0
2275
+
2276
+ if @player_state.powerup_finished[:ring]
2277
+ @player.instance_variable_set(:@model, "progs/eyes.mdl")
2278
+ @player.instance_variable_set(:@frame, 0)
2279
+ else
2280
+ @player.instance_variable_set(:@model, "progs/player.mdl")
2281
+ end
2282
+ end
2283
+
2284
+ def update_player_powerups
2285
+ apply_deathmatch_four_quad_expiry
2286
+ @player_state.update_powerups(@game_time)
2287
+ end
2288
+
2289
+ def apply_deathmatch_four_quad_expiry
2290
+ return unless deathmatch.to_i == 4
2291
+
2292
+ finished = @player_state.powerup_finished[:quad]
2293
+ return unless finished && finished < @game_time.to_f
2294
+
2295
+ @player_state.ammo[:cells] = 255
2296
+ @player_state.armor = 1
2297
+ @player_state.armor_type = 3
2298
+ @player_state.health = 100
2299
+ end
2300
+
2301
+ def check_no_ammo_weapon_switch
2302
+ return unless @player_state.alive?
2303
+ return unless @game_time > @attack_finished
2304
+ return if @player_state.current_weapon == :axe
2305
+ return unless @player_state.current_ammo_count.to_i.zero?
2306
+
2307
+ previous = @player_state.current_weapon
2308
+ @player_state.switch_to_best_weapon(water_level: @player&.water_level.to_i)
2309
+ @viewmodel&.set_weapon(@player_state.current_weapon_model) if @player_state.current_weapon != previous
2310
+ end
2311
+
2312
+ def fire_weapon
2313
+ return unless @player_state.alive?
2314
+ return if @game_time.to_f < @attack_finished.to_f
2315
+
2316
+ play_quad_attack_sound
2317
+
2318
+ if lightning_underwater_discharge?
2319
+ mark_player_show_hostile
2320
+ return if fire_lightning_underwater_discharge
2321
+ end
2322
+
2323
+ event = @player_state.fire_current_weapon(water_level: @player.water_level, deathmatch: deathmatch)
2324
+ return unless event
2325
+
2326
+ mark_player_show_hostile
2327
+ @attack_finished = @game_time + @player_state.current_weapon_cooldown
2328
+ trigger_weapon_punch(event)
2329
+ play_weapon_fire_sound(event)
2330
+ @viewmodel&.play_attack(viewmodel_attack_frames(event))
2331
+ add_muzzle_flash_dynamic_light unless event.kind == :axe
2332
+ case event.kind
2333
+ when :axe
2334
+ fire_axe(event)
2335
+ when :spike
2336
+ fire_spike(event)
2337
+ when :rocket
2338
+ fire_rocket(event)
2339
+ when :lightning
2340
+ fire_lightning(event)
2341
+ when :grenade
2342
+ fire_grenade(event)
2343
+ else
2344
+ fire_bullets(event)
2345
+ end
2346
+ end
2347
+
2348
+ def viewmodel_attack_frames(event)
2349
+ return event.animation_frames unless event.weapon == :axe
2350
+
2351
+ r = rand
2352
+ if r < 0.25
2353
+ 1..4
2354
+ elsif r < 0.5
2355
+ 5..8
2356
+ elsif r < 0.75
2357
+ 1..4
2358
+ else
2359
+ 5..8
2360
+ end
2361
+ end
2362
+
2363
+ def mark_player_show_hostile
2364
+ @player_state.show_hostile = @game_time.to_f + 1.0
2365
+ end
2366
+
2367
+ def play_quad_attack_sound
2368
+ return unless @player_state.powerup_active?(:quad, game_time: @game_time)
2369
+ return unless (@quad_attack_sound_finished || 0.0) < @game_time
2370
+
2371
+ @quad_attack_sound_finished = @game_time + 1.0
2372
+ @sound_events&.on_player(:quad_attack)
2373
+ end
2374
+
2375
+ def lightning_underwater_discharge?
2376
+ @player_state.current_weapon == :lightning_gun &&
2377
+ @player.water_level > 1 &&
2378
+ @player_state.can_fire_current_weapon?
2379
+ end
2380
+
2381
+ def fire_lightning_underwater_discharge
2382
+ if deathmatch.to_i > 3 && rand <= 0.5
2383
+ @player_state.deathtype = "selfwater"
2384
+ damage_player(4000.0, inflictor: @player)
2385
+ return false
2386
+ end
2387
+
2388
+ cells = @player_state.ammo[:cells]
2389
+ @player_state.ammo[:cells] = 0
2390
+ @attack_finished = @game_time + @player_state.current_weapon_cooldown
2391
+ play_lightning_start_sound
2392
+ @viewmodel&.play_attack(PlayerState::WEAPON_FIRE.dig(:lightning_gun, :animation_frames))
2393
+ apply_radius_damage(@player.position, 35.0 * cells, inflictor: @player)
2394
+ true
2395
+ end
2396
+
2397
+ def play_weapon_fire_sound(event)
2398
+ if event.kind == :lightning
2399
+ play_lightning_weapon_sounds
2400
+ else
2401
+ @sound_events&.on_weapon_fire(event)
2402
+ end
2403
+ end
2404
+
2405
+ def play_lightning_weapon_sounds
2406
+ play_lightning_start_sound
2407
+
2408
+ return unless (@lightning_hit_sound_finished || 0.0) < @game_time
2409
+
2410
+ @lightning_hit_sound_finished = @game_time + 0.6
2411
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "weapons/lhit.wav"))
2412
+ end
2413
+
2414
+ def play_lightning_start_sound
2415
+ if (@lightning_attack_active_until || 0.0) < @game_time
2416
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "weapons/lstart.wav"))
2417
+ end
2418
+ @lightning_attack_active_until = @game_time + 0.2
2419
+ end
2420
+
2421
+ def fire_axe(event)
2422
+ src = Math::Vec3.new(@player.position.x, @player.position.y, @player.position.z + 16.0)
2423
+ dir = @player.forward
2424
+ solid_brush = @entities.select(&:brush_entity?)
2425
+ trace = Physics::HullTrace.trace_world_and_entities(
2426
+ @level, src, src + dir * event.range, solid_brush, hull_num: 0
2427
+ )
2428
+ entity_hit = trace_axe_touch_entity(src, dir, event.range * trace.fraction)
2429
+ if entity_hit
2430
+ ent, hit_pos = entity_hit
2431
+ impact = hit_pos - dir * 4.0
2432
+ if ent.damageable?
2433
+ ent.instance_variable_set(:@axhitme, 1)
2434
+ damage = deathmatch.to_i > 3 ? 75 : event.damage
2435
+ damage_entity(ent, player_damage(damage), attacker: @player_state, inflictor: @player)
2436
+ @particles&.blood(impact, count: 20)
2437
+ else
2438
+ @sound_events&.on_player(:axe_wall_hit)
2439
+ @particles&.gunshot(impact, count: 3)
2440
+ end
2441
+ return
2442
+ end
2443
+
2444
+ return if trace.fraction >= 1.0
2445
+
2446
+ impact = trace.end_pos - dir * 4.0
2447
+ @sound_events&.on_player(:axe_wall_hit)
2448
+ @particles&.gunshot(impact, count: 3)
2449
+ end
2450
+
2451
+ def fire_spike(event)
2452
+ @projectiles ||= []
2453
+ offset = spike_lateral_offset(event.weapon)
2454
+ @projectiles << Projectile.new(
2455
+ kind: event.weapon == :super_nailgun ? :super_spike : :spike,
2456
+ position: @player.position + Math::Vec3.new(0.0, 0.0, 16.0) + @player.right * offset,
2457
+ velocity: @player.forward * 1000.0,
2458
+ damage: event.damage,
2459
+ damage_variance: event.damage_variance,
2460
+ radius_damage: event.radius_damage,
2461
+ explode_at: @game_time + SPIKE_LIFETIME,
2462
+ model: event.weapon == :super_nailgun ? "progs/s_spike.mdl" : "progs/spike.mdl",
2463
+ movetype: 9,
2464
+ solid: 2,
2465
+ effects: 0,
2466
+ angles: projectile_angles_from_velocity(@player.forward * 1000.0),
2467
+ avelocity: Math::Vec3::ORIGIN,
2468
+ voided: false,
2469
+ owner: @player_state
2470
+ )
2471
+ end
2472
+
2473
+ def fire_rocket(event)
2474
+ @projectiles ||= []
2475
+ @projectiles << Projectile.new(
2476
+ kind: :rocket,
2477
+ position: @player.position + @player.forward * 8.0 + Math::Vec3.new(0.0, 0.0, 16.0),
2478
+ velocity: @player.forward * 1000.0,
2479
+ damage: event.damage,
2480
+ damage_variance: event.damage_variance,
2481
+ radius_damage: event.radius_damage,
2482
+ explode_at: @game_time + ROCKET_LIFETIME,
2483
+ model: "progs/missile.mdl",
2484
+ movetype: 9,
2485
+ solid: 2,
2486
+ effects: 0,
2487
+ angles: projectile_angles_from_velocity(@player.forward * 1000.0),
2488
+ avelocity: Math::Vec3::ORIGIN,
2489
+ voided: false,
2490
+ owner: @player_state
2491
+ )
2492
+ end
2493
+
2494
+ def fire_lightning(event)
2495
+ return unless @level
2496
+
2497
+ src = @player.position + Math::Vec3.new(0.0, 0.0, 16.0)
2498
+ dir = @player.forward
2499
+ solid_brush = @entities.select(&:brush_entity?)
2500
+ trace = Physics::HullTrace.trace_world_and_entities(
2501
+ @level, src, src + dir * event.range, solid_brush, hull_num: 0
2502
+ )
2503
+ spawn_temp_lightning_beam(
2504
+ entity: VIEW_ENTITY,
2505
+ start: src,
2506
+ finish: trace.end_pos,
2507
+ model: "progs/bolt2.mdl"
2508
+ )
2509
+ lightning_damage(@player.position, trace.end_pos + dir * 4.0, event.damage)
2510
+ end
2511
+
2512
+ def spawn_temp_lightning_beam(entity:, start:, finish:, model:)
2513
+ @temporary_beams ||= []
2514
+ beam = TemporaryBeam.new(
2515
+ entity: entity,
2516
+ model: model,
2517
+ end_time: @game_time.to_f + 0.2,
2518
+ start: start,
2519
+ finish: finish
2520
+ )
2521
+ index = @temporary_beams.find_index { |existing| existing.entity == entity }
2522
+ index ||= @temporary_beams.find_index { |existing| existing.end_time < @game_time.to_f }
2523
+ if index
2524
+ @temporary_beams[index] = beam
2525
+ elsif @temporary_beams.size < MAX_BEAMS
2526
+ @temporary_beams << beam
2527
+ end
2528
+ beam
2529
+ end
2530
+
2531
+ def lightning_damage(src, dst, damage)
2532
+ dir = (dst - src).normalize
2533
+ side = quake_lightning_side_offset(dir)
2534
+ offsets = [
2535
+ Math::Vec3::ORIGIN,
2536
+ side,
2537
+ side * -1.0
2538
+ ]
2539
+ trace_entities = []
2540
+
2541
+ offsets.each_with_index do |offset, index|
2542
+ hit = trace_lightning_damage(src + offset, dst + offset)
2543
+ next unless hit
2544
+
2545
+ ent, hit_pos = hit
2546
+ next if trace_entities.include?(ent)
2547
+
2548
+ trace_entities << ent
2549
+ next unless ent.damageable?
2550
+
2551
+ damage_entity(ent, player_damage(damage), attacker: @player_state, inflictor: @player)
2552
+ apply_direct_lightning_player_kick(ent) if index.zero?
2553
+ @particles&.lightning_blood(hit_pos)
2554
+ end
2555
+ end
2556
+
2557
+ def apply_direct_lightning_player_kick(ent)
2558
+ return unless ent.classname == "player"
2559
+
2560
+ ent.velocity = Math::Vec3.new(ent.velocity.x, ent.velocity.y, ent.velocity.z + 400.0)
2561
+ end
2562
+
2563
+ def quake_lightning_side_offset(dir)
2564
+ # QuakeC LightningDamage mutates f_x before assigning f_y, so both
2565
+ # horizontal side-offset components use the negated original y value.
2566
+ component = -dir.y
2567
+ Math::Vec3.new(component, component, 0.0) * 16.0
2568
+ end
2569
+
2570
+ def trace_lightning_damage(src, dst)
2571
+ dir = (dst - src).normalize
2572
+ solid_brush = @entities.select(&:brush_entity?)
2573
+ trace = Physics::HullTrace.trace_world_and_entities(
2574
+ @level, src, dst, solid_brush, hull_num: 0
2575
+ )
2576
+ trace_lightning_touch_entity(src, dir, (dst - src).length * trace.fraction)
2577
+ end
2578
+
2579
+ def fire_grenade(event)
2580
+ @projectiles ||= []
2581
+ velocity = if @player.pitch.zero?
2582
+ @player.forward_flat * 600.0 + Math::Vec3.new(0.0, 0.0, 200.0)
2583
+ else
2584
+ @player.forward * 600.0 + @player.up * 200.0 +
2585
+ @player.right * (crandom * 10.0) + @player.up * (crandom * 10.0)
2586
+ end
2587
+
2588
+ @projectiles << Projectile.new(
2589
+ kind: :grenade,
2590
+ position: @player.position,
2591
+ velocity: velocity,
2592
+ damage: event.damage,
2593
+ damage_variance: event.damage_variance,
2594
+ radius_damage: event.radius_damage,
2595
+ explode_at: @game_time + GRENADE_FUSE,
2596
+ model: "progs/grenade.mdl",
2597
+ movetype: 10,
2598
+ solid: 2,
2599
+ effects: 0,
2600
+ angles: projectile_angles_from_velocity(velocity),
2601
+ avelocity: Math::Vec3.new(300.0, 300.0, 300.0),
2602
+ voided: false,
2603
+ owner: @player_state
2604
+ )
2605
+ return unless deathmatch.to_i == 4
2606
+
2607
+ @attack_finished = @game_time.to_f + 1.1
2608
+ damage_player(10.0, inflictor: @player)
2609
+ end
2610
+
2611
+ def update_triggered_misc_entities
2612
+ @entities.each do |ent|
2613
+ case ent.classname
2614
+ when "trap_spikeshooter", "trap_shooter"
2615
+ if ent.instance_variable_get(:@triggered)
2616
+ ent.instance_variable_set(:@triggered, false)
2617
+ fire_spikeshooter(ent)
2618
+ end
2619
+ update_trap_shooter(ent) if ent.classname == "trap_shooter"
2620
+ when "fireball"
2621
+ update_fireball_emitter(ent) if ent.instance_variable_get(:@fireball_emitter)
2622
+ when "misc_noisemaker"
2623
+ update_noisemaker(ent)
2624
+ end
2625
+ end
2626
+ end
2627
+
2628
+ def setup_misc_entities
2629
+ @entities.each do |ent|
2630
+ case ent.classname
2631
+ when "trap_spikeshooter"
2632
+ setup_trap_spikeshooter(ent)
2633
+ when "trap_shooter"
2634
+ setup_trap_spikeshooter(ent)
2635
+ ent.wait = 1.0 if ent.wait.zero?
2636
+ ent.think_time = @game_time + ent["nextthink"].to_f + ent.wait
2637
+ ent.instance_variable_set(:@think, :shooter_think)
2638
+ when "misc_fireball"
2639
+ setup_fireball_emitter(ent)
2640
+ when "misc_noisemaker"
2641
+ setup_noisemaker(ent)
2642
+ when "misc_explobox", "misc_explobox2"
2643
+ setup_explobox(ent)
2644
+ when "viewthing"
2645
+ setup_viewthing(ent)
2646
+ when "light_globe", "light_torch_small_walltorch",
2647
+ "light_flame_large_yellow", "light_flame_small_yellow",
2648
+ "light_flame_small_white"
2649
+ setup_static_light_model(ent)
2650
+ when "item_key1", "item_key2"
2651
+ setup_key_item(ent)
2652
+ when "item_sigil"
2653
+ setup_sigil_item(ent)
2654
+ when "item_artifact_invulnerability", "item_artifact_envirosuit",
2655
+ "item_artifact_invisibility", "item_artifact_super_damage"
2656
+ setup_powerup_item(ent)
2657
+ when "item_health"
2658
+ setup_health_item(ent)
2659
+ when "item_armor1", "item_armor2", "item_armorInv"
2660
+ setup_armor_item(ent)
2661
+ when "item_shells", "item_spikes", "item_rockets", "item_cells", "item_weapon"
2662
+ setup_ammo_item(ent)
2663
+ when "weapon_supershotgun", "weapon_nailgun", "weapon_supernailgun",
2664
+ "weapon_grenadelauncher", "weapon_rocketlauncher", "weapon_lightning"
2665
+ setup_weapon_item(ent)
2666
+ when "info_null", "air_bubbles", "noclass", "event_lightning",
2667
+ *QUAKEWORLD_REMOVED_MONSTER_CLASSNAMES
2668
+ ent.instance_variable_set(:@removed, true)
2669
+ when "light"
2670
+ if ent.targetname
2671
+ setup_toggle_light(ent)
2672
+ else
2673
+ ent.instance_variable_set(:@removed, true)
2674
+ end
2675
+ when "light_fluoro"
2676
+ setup_toggle_light(ent)
2677
+ when "light_fluorospark"
2678
+ setup_fluorospark_light(ent)
2679
+ when "info_intermission"
2680
+ setup_intermission(ent)
2681
+ when "info_teleport_destination"
2682
+ setup_teleport_destination(ent)
2683
+ when "path_corner"
2684
+ setup_path_corner(ent)
2685
+ end
2686
+
2687
+ setup_start_item(ent) if start_item?(ent) && !ent.removed?
2688
+ end
2689
+ end
2690
+
2691
+ def start_item?(ent)
2692
+ START_ITEM_CLASSNAMES.include?(ent.classname)
2693
+ end
2694
+
2695
+ def setup_start_item(ent)
2696
+ mins, maxs = start_item_bounds(ent.classname)
2697
+ ent.instance_variable_set(:@mins, mins)
2698
+ ent.instance_variable_set(:@maxs, maxs)
2699
+ ent.instance_variable_set(:@solid, 0)
2700
+ ent.think_time = (@game_time || 0.0) + 0.2
2701
+ ent.instance_variable_set(:@think, :place_item)
2702
+ end
2703
+
2704
+ def start_item_bounds(classname)
2705
+ if ITEM_WIDE_BOUNDS_CLASSNAMES.include?(classname)
2706
+ [Math::Vec3::ORIGIN, Math::Vec3.new(32.0, 32.0, 56.0)]
2707
+ elsif ITEM_TALL_BOUNDS_CLASSNAMES.include?(classname)
2708
+ [Math::Vec3.new(-16.0, -16.0, 0.0), Math::Vec3.new(16.0, 16.0, 56.0)]
2709
+ else
2710
+ [Math::Vec3.new(-16.0, -16.0, -24.0), Math::Vec3.new(16.0, 16.0, 32.0)]
2711
+ end
2712
+ end
2713
+
2714
+ def update_start_items
2715
+ return unless @entities
2716
+
2717
+ @entities.each do |ent|
2718
+ next unless ent.instance_variable_get(:@think) == :place_item
2719
+ next if ent.think_time > @game_time
2720
+
2721
+ place_item(ent)
2722
+ end
2723
+ end
2724
+
2725
+ def place_item(ent)
2726
+ ent.instance_variable_set(:@mdl, ent["model"])
2727
+ ent.flags = 256
2728
+ ent.instance_variable_set(:@solid, 1)
2729
+ ent.instance_variable_set(:@movetype, 6)
2730
+ ent.velocity = Math::Vec3::ORIGIN
2731
+ ent.position += Math::Vec3.new(0.0, 0.0, 6.0)
2732
+ return unless drop_item_to_floor(ent)
2733
+
2734
+ ent.think_time = 0.0
2735
+ ent.instance_variable_set(:@think, nil)
2736
+ end
2737
+
2738
+ def drop_item_to_floor(ent)
2739
+ return true unless @level
2740
+
2741
+ floor = trace_entity_floor(ent)
2742
+ unless floor
2743
+ ent.instance_variable_set(:@removed, true)
2744
+ return false
2745
+ end
2746
+
2747
+ ent.position = floor
2748
+ ent.flags |= FL_ONGROUND
2749
+ true
2750
+ end
2751
+
2752
+ HULL1_CLIP_MINS = Math::Vec3.new(-16.0, -16.0, -24.0)
2753
+
2754
+ def trace_entity_floor(ent)
2755
+ # SV_ClipMoveToEntity aligns the moving entity's box with the
2756
+ # clipping hull: offset = hull.clip_mins - ent.mins. Items have
2757
+ # mins.z = 0, so without this the player-hull trace starts 24
2758
+ # units inside the floor and reports all_solid ("bonus item fell
2759
+ # out of level" removes the item).
2760
+ ent_mins = ent.instance_variable_get(:@mins)
2761
+ offset = ent_mins ? HULL1_CLIP_MINS - ent_mins : Math::Vec3::ORIGIN
2762
+ start_pos = ent.position - offset
2763
+ end_pos = start_pos + Math::Vec3.new(0.0, 0.0, -256.0)
2764
+ trace = Physics::HullTrace.trace_world_and_entities(@level, start_pos, end_pos, @entities)
2765
+ return nil if trace.fraction == 1.0 || trace.all_solid
2766
+
2767
+ trace.end_pos + offset
2768
+ end
2769
+
2770
+ def setup_key_item(ent)
2771
+ worldtype = current_worldtype
2772
+ key = key_spawn_fields(ent.classname, worldtype)
2773
+ ent.properties["model"] = key[:model]
2774
+ ent.instance_variable_set(:@touch, :key_touch)
2775
+ ent.instance_variable_set(:@noise, key_sound(worldtype))
2776
+ ent.instance_variable_set(:@netname, key[:netname])
2777
+ ent.instance_variable_set(:@items, key[:items])
2778
+ end
2779
+
2780
+ def setup_sigil_item(ent)
2781
+ raise "no spawnflags" if ent.spawnflags.zero?
2782
+
2783
+ ent.properties["model"] = sigil_model(ent.spawnflags)
2784
+ ent.instance_variable_set(:@touch, :sigil_touch)
2785
+ ent.instance_variable_set(:@noise, "misc/runekey.wav")
2786
+ end
2787
+
2788
+ def setup_powerup_item(ent)
2789
+ powerup = powerup_spawn_fields(ent.classname)
2790
+ ent.properties["model"] = powerup[:model]
2791
+ ent.instance_variable_set(:@touch, :powerup_touch)
2792
+ ent.instance_variable_set(:@noise, powerup[:noise])
2793
+ ent.instance_variable_set(:@netname, powerup[:netname])
2794
+ ent.instance_variable_set(:@items, powerup[:items])
2795
+ ent.instance_variable_set(:@effects, powerup_effects(ent, powerup[:effects])) if powerup[:effects]
2796
+ ent.instance_variable_set(:@mins, ENTITY_HULL_MINS)
2797
+ ent.instance_variable_set(:@maxs, ENTITY_HULL_MAXS)
2798
+ end
2799
+
2800
+ def powerup_effects(ent, effects)
2801
+ existing = ent.instance_variable_get(:@effects)
2802
+ existing ||= ent["effects"].to_i if ent["effects"]
2803
+ existing.to_i | effects
2804
+ end
2805
+
2806
+ def key_spawn_fields(classname, worldtype)
2807
+ case [classname, worldtype]
2808
+ when ["item_key1", 1]
2809
+ { model: "progs/m_s_key.mdl", netname: "silver runekey", items: 131_072 }
2810
+ when ["item_key1", 2]
2811
+ { model: "progs/b_s_key.mdl", netname: "silver keycard", items: 131_072 }
2812
+ when ["item_key2", 1]
2813
+ { model: "progs/m_g_key.mdl", netname: "gold runekey", items: 262_144 }
2814
+ when ["item_key2", 2]
2815
+ { model: "progs/b_g_key.mdl", netname: "gold keycard", items: 262_144 }
2816
+ else
2817
+ medieval_key_spawn_fields(classname)
2818
+ end
2819
+ end
2820
+
2821
+ def medieval_key_spawn_fields(classname)
2822
+ if classname == "item_key2"
2823
+ { model: "progs/w_g_key.mdl", netname: "gold key", items: 262_144 }
2824
+ else
2825
+ { model: "progs/w_s_key.mdl", netname: "silver key", items: 131_072 }
2826
+ end
2827
+ end
2828
+
2829
+ def powerup_spawn_fields(classname)
2830
+ case classname
2831
+ when "item_artifact_envirosuit"
2832
+ { model: "progs/suit.mdl", noise: "items/suit.wav", netname: "Biosuit", items: 2_097_152 }
2833
+ when "item_artifact_invulnerability"
2834
+ {
2835
+ model: "progs/invulner.mdl",
2836
+ noise: "items/protect.wav",
2837
+ netname: "Pentagram of Protection",
2838
+ items: 1_048_576,
2839
+ effects: 128
2840
+ }
2841
+ when "item_artifact_invisibility"
2842
+ { model: "progs/invisibl.mdl", noise: "items/inv1.wav", netname: "Ring of Shadows", items: 524_288 }
2843
+ when "item_artifact_super_damage"
2844
+ {
2845
+ model: "progs/quaddama.mdl",
2846
+ noise: "items/damage.wav",
2847
+ netname: deathmatch.to_i == 4 ? "OctaPower" : "Quad Damage",
2848
+ items: 4_194_304,
2849
+ effects: 64
2850
+ }
2851
+ end
2852
+ end
2853
+
2854
+ def sigil_model(spawnflags)
2855
+ if (spawnflags & 8) != 0
2856
+ "progs/end4.mdl"
2857
+ elsif (spawnflags & 4) != 0
2858
+ "progs/end3.mdl"
2859
+ elsif (spawnflags & 2) != 0
2860
+ "progs/end2.mdl"
2861
+ elsif (spawnflags & 1) != 0
2862
+ "progs/end1.mdl"
2863
+ end
2864
+ end
2865
+
2866
+ def setup_health_item(ent)
2867
+ health = health_spawn_fields(ent)
2868
+ ent.properties["model"] = health[:model]
2869
+ ent.instance_variable_set(:@touch, :health_touch)
2870
+ ent.instance_variable_set(:@noise, health[:noise])
2871
+ ent.instance_variable_set(:@healamount, health[:healamount])
2872
+ ent.instance_variable_set(:@healtype, health[:healtype])
2873
+ end
2874
+
2875
+ def setup_armor_item(ent)
2876
+ armor = armor_spawn_fields(ent.classname)
2877
+ ent.properties["model"] = armor[:model]
2878
+ ent.skin = armor[:skin]
2879
+ ent.instance_variable_set(:@touch, :armor_touch)
2880
+ end
2881
+
2882
+ def armor_spawn_fields(classname)
2883
+ skin = case classname
2884
+ when "item_armor2" then 1
2885
+ when "item_armorInv" then 2
2886
+ else 0
2887
+ end
2888
+ { model: "progs/armor.mdl", skin: skin }
2889
+ end
2890
+
2891
+ def health_spawn_fields(ent)
2892
+ if (ent.spawnflags & 1) != 0
2893
+ { model: "maps/b_bh10.bsp", noise: "items/r_item1.wav", healamount: 15, healtype: 0 }
2894
+ elsif (ent.spawnflags & 2) != 0
2895
+ { model: "maps/b_bh100.bsp", noise: "items/r_item2.wav", healamount: 100, healtype: 2 }
2896
+ else
2897
+ { model: "maps/b_bh25.bsp", noise: "items/health1.wav", healamount: 25, healtype: 1 }
2898
+ end
2899
+ end
2900
+
2901
+ def setup_ammo_item(ent)
2902
+ if deathmatch.to_i == 4 && ent.classname != "item_weapon"
2903
+ disable_spawned_item(ent)
2904
+ return
2905
+ end
2906
+
2907
+ ammo = ammo_spawn_fields(ent)
2908
+ if ammo.nil? && ent.classname == "item_weapon"
2909
+ ent.instance_variable_set(:@touch, :ammo_touch)
2910
+ return
2911
+ end
2912
+ return unless ammo
2913
+
2914
+ ent.properties["model"] = ammo[:model]
2915
+ ent.instance_variable_set(:@touch, :ammo_touch)
2916
+ ent.instance_variable_set(:@aflag, ammo[:amount])
2917
+ ent.instance_variable_set(:@weapon, ammo[:weapon])
2918
+ ent.instance_variable_set(:@netname, ammo[:netname])
2919
+ end
2920
+
2921
+ def ammo_spawn_fields(ent)
2922
+ big = (ent.spawnflags & 1) != 0
2923
+ case ent.classname
2924
+ when "item_shells"
2925
+ { model: big ? "maps/b_shell1.bsp" : "maps/b_shell0.bsp", weapon: 1, netname: "shells", amount: big ? 40 : 20 }
2926
+ when "item_spikes"
2927
+ { model: big ? "maps/b_nail1.bsp" : "maps/b_nail0.bsp", weapon: 2, netname: "nails", amount: big ? 50 : 25 }
2928
+ when "item_rockets"
2929
+ { model: big ? "maps/b_rock1.bsp" : "maps/b_rock0.bsp", weapon: 3, netname: "rockets", amount: big ? 10 : 5 }
2930
+ when "item_cells"
2931
+ { model: big ? "maps/b_batt1.bsp" : "maps/b_batt0.bsp", weapon: 4, netname: "cells", amount: big ? 12 : 6 }
2932
+ when "item_weapon"
2933
+ legacy_weapon_spawn_fields(ent)
2934
+ end
2935
+ end
2936
+
2937
+ def legacy_weapon_spawn_fields(ent)
2938
+ flags = ent.spawnflags
2939
+ big = (flags & 8) != 0
2940
+ if (flags & 2) != 0
2941
+ { model: big ? "maps/b_rock1.bsp" : "maps/b_rock0.bsp", weapon: 3, netname: "rockets", amount: big ? 10 : 5 }
2942
+ elsif (flags & 4) != 0
2943
+ { model: big ? "maps/b_nail1.bsp" : "maps/b_nail0.bsp", weapon: 2, netname: "spikes", amount: big ? 40 : 20 }
2944
+ elsif (flags & 1) != 0
2945
+ { model: big ? "maps/b_shell1.bsp" : "maps/b_shell0.bsp", weapon: 1, netname: "shells", amount: big ? 40 : 20 }
2946
+ end
2947
+ end
2948
+
2949
+ def setup_weapon_item(ent)
2950
+ if deathmatch.to_i > 3
2951
+ disable_spawned_item(ent)
2952
+ return
2953
+ end
2954
+
2955
+ weapon = weapon_spawn_fields(ent.classname)
2956
+ return unless weapon
2957
+
2958
+ ent.properties["model"] = weapon[:model]
2959
+ ent.instance_variable_set(:@touch, :weapon_touch)
2960
+ ent.instance_variable_set(:@weapon, weapon[:weapon])
2961
+ ent.instance_variable_set(:@netname, weapon[:netname])
2962
+ end
2963
+
2964
+ def disable_spawned_item(ent)
2965
+ ent.properties["model"] = ""
2966
+ ent.instance_variable_set(:@solid, 0)
2967
+ ent.instance_variable_set(:@removed, true)
2968
+ end
2969
+
2970
+ def weapon_spawn_fields(classname)
2971
+ case classname
2972
+ when "weapon_supershotgun"
2973
+ { model: "progs/g_shot.mdl", weapon: 2, netname: "Double-barrelled Shotgun" }
2974
+ when "weapon_nailgun"
2975
+ { model: "progs/g_nail.mdl", weapon: 4, netname: "nailgun" }
2976
+ when "weapon_supernailgun"
2977
+ { model: "progs/g_nail2.mdl", weapon: 8, netname: "Super Nailgun" }
2978
+ when "weapon_grenadelauncher"
2979
+ { model: "progs/g_rock.mdl", weapon: 3, netname: "Grenade Launcher" }
2980
+ when "weapon_rocketlauncher"
2981
+ { model: "progs/g_rock2.mdl", weapon: 3, netname: "Rocket Launcher" }
2982
+ when "weapon_lightning"
2983
+ { model: "progs/g_light.mdl", weapon: 3, netname: "Thunderbolt" }
2984
+ end
2985
+ end
2986
+
2987
+ def setup_explobox(ent)
2988
+ height = ent.classname == "misc_explobox2" ? 32.0 : 64.0
2989
+ ent.properties["model"] = ent.classname == "misc_explobox2" ? "maps/b_exbox2.bsp" : "maps/b_explob.bsp"
2990
+ ent.health = 20.0
2991
+ ent.instance_variable_set(:@solid, 2)
2992
+ ent.instance_variable_set(:@movetype, 0)
2993
+ ent.instance_variable_set(:@takedamage, 2)
2994
+ ent.instance_variable_set(:@th_die, :barrel_explode)
2995
+ ent.instance_variable_set(:@mins, Math::Vec3::ORIGIN)
2996
+ ent.instance_variable_set(:@maxs, Math::Vec3.new(32.0, 32.0, height))
2997
+ unless ent.instance_variable_get(:@explobox_setup)
2998
+ ent.position += Math::Vec3.new(0.0, 0.0, 2.0)
2999
+ drop_explobox_to_floor(ent)
3000
+ ent.instance_variable_set(:@explobox_setup, true)
3001
+ end
3002
+ end
3003
+
3004
+ def drop_explobox_to_floor(ent)
3005
+ return unless @level
3006
+
3007
+ old_z = ent.position.z
3008
+ if (floor = trace_entity_floor(ent))
3009
+ ent.position = floor
3010
+ ent.flags |= FL_ONGROUND
3011
+ end
3012
+ ent.instance_variable_set(:@removed, true) if old_z - ent.position.z > 250.0
3013
+ end
3014
+
3015
+ def setup_viewthing(ent)
3016
+ ent.properties["model"] = "progs/player.mdl"
3017
+ ent.instance_variable_set(:@movetype, 0)
3018
+ ent.instance_variable_set(:@solid, 0)
3019
+ end
3020
+
3021
+ def setup_static_light_model(ent)
3022
+ light = static_light_spawn_fields(ent.classname)
3023
+ ent.properties["model"] = light[:model]
3024
+ ent.frame = light.fetch(:frame, 0)
3025
+ ent.instance_variable_set(:@static, true)
3026
+ end
3027
+
3028
+ def static_light_spawn_fields(classname)
3029
+ case classname
3030
+ when "light_globe"
3031
+ { model: "progs/s_light.spr" }
3032
+ when "light_torch_small_walltorch"
3033
+ { model: "progs/flame.mdl" }
3034
+ when "light_flame_large_yellow"
3035
+ { model: "progs/flame2.mdl", frame: 1 }
3036
+ when "light_flame_small_yellow", "light_flame_small_white"
3037
+ { model: "progs/flame2.mdl" }
3038
+ end
3039
+ end
3040
+
3041
+ def current_worldtype
3042
+ world = @entities.find { |ent| ent.classname == "worldspawn" }
3043
+ (world&.[]("worldtype") || "0").to_i
3044
+ end
3045
+
3046
+ def world_info_value(key)
3047
+ world = @entities&.find { |ent| ent.classname == "worldspawn" }
3048
+ world&.[](key)
3049
+ end
3050
+
3051
+ def configure_world_gravity(map_name)
3052
+ @gravity = map_name == "maps/e1m8.bsp" ? 100.0 : GRAVITY
3053
+ end
3054
+
3055
+ def current_gravity
3056
+ (@gravity || GRAVITY).to_f
3057
+ end
3058
+
3059
+ def world_info_enabled?(key)
3060
+ value = world_info_value(key)
3061
+ value && value.to_f != 0.0
3062
+ end
3063
+
3064
+ def key_sound(worldtype)
3065
+ case worldtype
3066
+ when 1
3067
+ "misc/runekey.wav"
3068
+ when 2
3069
+ "misc/basekey.wav"
3070
+ else
3071
+ "misc/medkey.wav"
3072
+ end
3073
+ end
3074
+
3075
+ def setup_toggle_light(ent)
3076
+ return if ent.style < 32
3077
+
3078
+ ent.instance_variable_set(:@use, :light_use)
3079
+ ent.lightstyle = (ent.spawnflags & 1) != 0 ? "a" : "m"
3080
+ end
3081
+
3082
+ def setup_fluorospark_light(ent)
3083
+ ent.style = 10 if ent.style.zero?
3084
+ end
3085
+
3086
+ def current_lightstyles
3087
+ lightstyles = Renderer::GLLightmap.default_lightstyles
3088
+ @entities&.each do |ent|
3089
+ next unless ent.lightstyle
3090
+ next if ent.style.negative? || ent.style >= Renderer::GLLightmap::MAX_LIGHTSTYLES
3091
+
3092
+ lightstyles[ent.style] = ent.lightstyle
3093
+ end
3094
+ lightstyles
3095
+ end
3096
+
3097
+ def setup_noisemaker(ent)
3098
+ ent.think_time = @game_time + 0.1 + rand
3099
+ ent.instance_variable_set(:@think, :noise_think)
3100
+ end
3101
+
3102
+ def update_noisemaker(ent)
3103
+ return if ent.think_time > @game_time
3104
+
3105
+ @sound_events&.on_misc_noisemaker(ent)
3106
+ ent.think_time = @game_time + 0.5
3107
+ end
3108
+
3109
+ def setup_intermission(ent)
3110
+ ent.angles = vec3_property(ent, "mangle")
3111
+ end
3112
+
3113
+ def find_intermission_spot
3114
+ intermissions = (@entities || []).select { |ent| ent.classname == "info_intermission" }
3115
+ unless intermissions.empty?
3116
+ spot = intermissions.first
3117
+ cyc = rand * 4.0
3118
+ while cyc > 1.0
3119
+ index = intermissions.index(spot) + 1
3120
+ spot = intermissions[index] || intermissions.first
3121
+ cyc -= 1.0
3122
+ end
3123
+ return spot
3124
+ end
3125
+
3126
+ start = (@entities || []).find { |ent| ent.classname == "info_player_start" }
3127
+ return start if start
3128
+
3129
+ raise "FindIntermission: no spot"
3130
+ end
3131
+
3132
+ def setup_teleport_destination(ent)
3133
+ return if ent.instance_variable_get(:@teleport_destination_setup)
3134
+
3135
+ ent.instance_variable_set(:@mangle, ent.angles)
3136
+ ent.angles = Math::Vec3::ORIGIN
3137
+ ent.properties["model"] = ""
3138
+ ent.position += Math::Vec3.new(0.0, 0.0, 27.0)
3139
+ raise "no targetname" if ent.targetname.nil? || ent.targetname.empty?
3140
+
3141
+ ent.instance_variable_set(:@teleport_destination_setup, true)
3142
+ end
3143
+
3144
+ def setup_path_corner(ent)
3145
+ raise "monster_movetarget: no targetname" if ent.targetname.nil? || ent.targetname.empty?
3146
+
3147
+ ent.instance_variable_set(:@solid, 1)
3148
+ ent.instance_variable_set(:@touch, :t_movetarget)
3149
+ ent.instance_variable_set(:@mins, Math::Vec3.new(-8.0, -8.0, -8.0))
3150
+ ent.instance_variable_set(:@maxs, Math::Vec3.new(8.0, 8.0, 8.0))
3151
+ end
3152
+
3153
+ def setup_ambient_sounds
3154
+ @entities.each { |ent| @sound_events&.on_ambient_entity(ent) }
3155
+ end
3156
+
3157
+ def update_trap_shooter(ent)
3158
+ return if ent.think_time > @game_time
3159
+
3160
+ fire_spikeshooter(ent)
3161
+ @projectiles[-1] = trap_shooter_projectile_with_quake_think_velocity(@projectiles[-1], ent)
3162
+ ent.think_time = @game_time + ent.wait
3163
+ ent.instance_variable_set(:@think, :shooter_think)
3164
+ end
3165
+
3166
+ def setup_trap_spikeshooter(ent)
3167
+ ent.move_dir = ent.forward_vector
3168
+ ent.angles = Math::Vec3::ORIGIN
3169
+ ent.instance_variable_set(:@use, :spikeshooter_use)
3170
+ end
3171
+
3172
+ def fire_spikeshooter(ent)
3173
+ @projectiles ||= []
3174
+ laser = (ent.spawnflags & 2) != 0
3175
+ superspike = (ent.spawnflags & 1) != 0
3176
+ move_dir = ent.move_dir == Math::Vec3::ORIGIN ? ent.forward_vector : ent.move_dir
3177
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: laser ? "enforcer/enfire.wav" : "weapons/spike2.wav"))
3178
+ @projectiles << Projectile.new(
3179
+ kind: laser ? :laser : (superspike ? :super_spike : :spike),
3180
+ position: ent.position,
3181
+ velocity: move_dir * (laser ? 600.0 : 500.0),
3182
+ damage: laser ? 15 : (superspike ? 18 : 9),
3183
+ damage_variance: nil,
3184
+ radius_damage: nil,
3185
+ explode_at: @game_time + (laser ? LASER_LIFETIME : SPIKE_LIFETIME),
3186
+ model: laser ? "progs/laser.mdl" : "progs/spike.mdl",
3187
+ movetype: laser ? 5 : 9,
3188
+ solid: 2,
3189
+ effects: laser ? 8 : 0,
3190
+ angles: projectile_angles_from_velocity(move_dir),
3191
+ avelocity: Math::Vec3::ORIGIN,
3192
+ voided: false,
3193
+ owner: ent
3194
+ )
3195
+ end
3196
+
3197
+ def trap_shooter_projectile_with_quake_think_velocity(projectile, ent)
3198
+ return projectile unless projectile
3199
+
3200
+ projectile.with(velocity: ent.move_dir * 500.0, owner: ent)
3201
+ end
3202
+
3203
+ def setup_fireball_emitter(ent)
3204
+ ent.classname = "fireball"
3205
+ ent.speed = 0.0 unless ent["speed"]
3206
+ ent.think_time = @game_time + rand * 5.0
3207
+ ent.instance_variable_set(:@think, :fire_fly)
3208
+ ent.instance_variable_set(:@fireball_emitter, true)
3209
+ end
3210
+
3211
+ def update_fireball_emitter(ent)
3212
+ return if ent.think_time > @game_time
3213
+
3214
+ fire_lava_ball(ent)
3215
+ ent.think_time = @game_time + rand * 5.0 + 3.0
3216
+ ent.instance_variable_set(:@think, :fire_fly)
3217
+ end
3218
+
3219
+ def fire_lava_ball(ent)
3220
+ @projectiles ||= []
3221
+ velocity = Math::Vec3.new(
3222
+ rand * 100.0 - 50.0,
3223
+ rand * 100.0 - 50.0,
3224
+ ent.speed + rand * 200.0
3225
+ )
3226
+ @projectiles << Projectile.new(
3227
+ kind: :fireball,
3228
+ position: ent.position,
3229
+ velocity: velocity,
3230
+ damage: 20,
3231
+ damage_variance: nil,
3232
+ radius_damage: nil,
3233
+ explode_at: @game_time + 5.0,
3234
+ model: "progs/lavaball.mdl",
3235
+ movetype: 6,
3236
+ solid: 1,
3237
+ effects: 0,
3238
+ angles: projectile_angles_from_velocity(velocity),
3239
+ avelocity: Math::Vec3::ORIGIN,
3240
+ voided: false,
3241
+ owner: nil
3242
+ )
3243
+ end
3244
+
3245
+ def fire_bullets(event)
3246
+ return unless @level
3247
+
3248
+ src = @player.position + @player.forward * 10.0
3249
+ # QuakeC FireBullets: src_z = self.absmin_z + self.size_z * 0.7.
3250
+ # Player hull is -24..32, so the shot source is origin z + 15.2.
3251
+ src = Math::Vec3.new(src.x, src.y, @player.position.z + 15.2)
3252
+ right = @player.right
3253
+ up = @player.up
3254
+ solid_brush = @entities.select(&:brush_entity?)
3255
+ multi_ent = nil
3256
+ multi_damage = 0.0
3257
+ blood_position = nil
3258
+ blood_count = 0
3259
+ puff_position = nil
3260
+ puff_count = 0
3261
+ apply_multi_damage = lambda do
3262
+ return unless multi_ent
3263
+
3264
+ damage_entity(multi_ent, multi_damage, attacker: @player_state, inflictor: @player)
3265
+ end
3266
+
3267
+ aim_trace = Physics::HullTrace.trace_world_and_entities(
3268
+ @level, src, src + @player.forward * event.range, solid_brush, hull_num: 0
3269
+ )
3270
+ aim_entity_hit = trace_bullet_touch_entity(src, @player.forward, event.range * aim_trace.fraction)
3271
+ puff_position = ((aim_entity_hit&.last) || aim_trace.end_pos) - @player.forward * 4.0
3272
+
3273
+ event.pellets.times do
3274
+ dir = @player.forward +
3275
+ right * (crandom * event.spread_x) +
3276
+ up * (crandom * event.spread_y)
3277
+ trace = Physics::HullTrace.trace_world_and_entities(
3278
+ @level, src, src + dir * event.range, solid_brush, hull_num: 0
3279
+ )
3280
+ entity_hit = trace_bullet_touch_entity(src, dir, event.range * trace.fraction)
3281
+ if entity_hit
3282
+ ent, hit_pos = entity_hit
3283
+ if ent.damageable?
3284
+ damage = player_damage(event.damage)
3285
+ if ent.equal?(multi_ent)
3286
+ multi_damage += damage
3287
+ else
3288
+ apply_multi_damage.call
3289
+ multi_ent = ent
3290
+ multi_damage = damage
3291
+ end
3292
+ blood_position = hit_pos - dir * 4.0
3293
+ blood_count += 1
3294
+ else
3295
+ puff_count += 1
3296
+ end
3297
+ next
3298
+ end
3299
+
3300
+ next if trace.fraction >= 1.0
3301
+
3302
+ puff_count += 1
3303
+ end
3304
+
3305
+ apply_multi_damage.call
3306
+ @particles&.gunshot(puff_position, count: puff_count) if puff_count.positive?
3307
+ @particles&.blood(blood_position, count: blood_count) if blood_count.positive?
3308
+ end
3309
+
3310
+ def update_projectiles(dt)
3311
+ return unless @projectiles&.any?
3312
+
3313
+ solid_brush = @entities.select(&:brush_entity?)
3314
+ next_projectiles = []
3315
+ @projectiles.each do |projectile|
3316
+ if @game_time >= projectile.explode_at
3317
+ explode_projectile(projectile, projectile.position) if projectile.kind == :grenade
3318
+ next
3319
+ end
3320
+
3321
+ velocity = projectile.velocity
3322
+ avelocity = projectile.avelocity
3323
+ angles = projectile.angles + avelocity * dt
3324
+ # trigger_push_touch also redirects grenades (triggers.qc)
3325
+ if projectile.kind == :grenade
3326
+ push = grenade_push_trigger_velocity(projectile.position)
3327
+ velocity = push if push
3328
+ end
3329
+ velocity += Math::Vec3.new(0.0, 0.0, -current_gravity * dt) if toss_projectile?(projectile)
3330
+ desired = projectile.position + velocity * dt
3331
+ if projectile.voided
3332
+ next_projectiles << projectile.with(position: desired, velocity: velocity, angles: angles)
3333
+ next
3334
+ end
3335
+
3336
+ trace = Physics::HullTrace.trace_world_and_entities(
3337
+ @level, projectile.position, desired, solid_brush, hull_num: 0
3338
+ )
3339
+ segment = desired - projectile.position
3340
+ distance = segment.length
3341
+ dir = distance.zero? ? Math::Vec3::ORIGIN : segment.normalize
3342
+ entity_hit = if spike_projectile?(projectile)
3343
+ trace_spike_touch_entity(projectile.position, dir, distance * trace.fraction, ignore: projectile.owner)
3344
+ elsif laser_projectile?(projectile)
3345
+ trace_laser_touch_entity(projectile.position, dir, distance * trace.fraction, ignore: projectile.owner)
3346
+ elsif projectile.kind == :fireball
3347
+ trace_fireball_touch_entity(projectile.position, dir, distance * trace.fraction, ignore: projectile.owner)
3348
+ elsif projectile.kind == :rocket || projectile.kind == :grenade
3349
+ trace_missile_touch_entity(projectile.position, dir, distance * trace.fraction, ignore: projectile.owner)
3350
+ else
3351
+ trace_damageable_entity(projectile.position, dir, distance * trace.fraction)
3352
+ end
3353
+ if entity_hit
3354
+ ent, hit_pos = entity_hit
3355
+ if projectile.kind == :grenade && !grenade_explodes_on_touch?(ent)
3356
+ @sound_events&.on_grenade_bounce
3357
+ # SV_Physics_Toss reflects off the contact plane (backoff 1.5);
3358
+ # approximate the entity-box face normal from the hit point
3359
+ normal = aabb_face_normal(ent, hit_pos)
3360
+ bounced = normal ? bounce_velocity(velocity, normal) : velocity
3361
+ next_projectiles << projectile.with(
3362
+ position: hit_pos,
3363
+ velocity: bounced,
3364
+ angles: angles,
3365
+ avelocity: grenade_bounce_avelocity(projectile, bounced)
3366
+ )
3367
+ next
3368
+ end
3369
+
3370
+ touch_projectile(projectile, hit_pos, ent)
3371
+ next
3372
+ end
3373
+
3374
+ if trace.fraction < 1.0 && trace.plane_normal
3375
+ impact_pos = trace.end_pos
3376
+ if projectile_removed_by_sky?(projectile, impact_pos)
3377
+ next
3378
+ elsif projectile.kind == :rocket
3379
+ explode_projectile(projectile, impact_pos)
3380
+ next
3381
+ elsif laser_projectile?(projectile)
3382
+ play_laser_impact_sound
3383
+ @particles&.gunshot(laser_impact_position(projectile, impact_pos), count: 5)
3384
+ next
3385
+ elsif spike_projectile?(projectile)
3386
+ play_spike_impact_sound
3387
+ @particles&.gunshot(impact_pos, count: spike_impact_particle_count(projectile))
3388
+ next
3389
+ elsif projectile.kind == :fireball
3390
+ next
3391
+ else
3392
+ velocity = bounce_velocity(velocity, trace.plane_normal)
3393
+ @sound_events&.on_grenade_bounce if projectile.kind == :grenade
3394
+ avelocity = grenade_bounce_avelocity(projectile, velocity)
3395
+ desired = impact_pos
3396
+ end
3397
+ end
3398
+
3399
+ emit_projectile_trail(projectile, desired)
3400
+ next_projectiles << Projectile.new(
3401
+ kind: projectile.kind,
3402
+ position: desired,
3403
+ velocity: velocity,
3404
+ damage: projectile.damage,
3405
+ damage_variance: projectile.damage_variance,
3406
+ radius_damage: projectile.radius_damage,
3407
+ explode_at: projectile.explode_at,
3408
+ model: projectile.model,
3409
+ movetype: projectile.movetype,
3410
+ solid: projectile.solid,
3411
+ effects: projectile.effects,
3412
+ angles: angles,
3413
+ avelocity: avelocity,
3414
+ voided: projectile.voided,
3415
+ owner: projectile.owner
3416
+ )
3417
+ end
3418
+ @projectiles = next_projectiles
3419
+ end
3420
+
3421
+ def emit_projectile_trail(projectile, end_pos)
3422
+ trail_type = projectile_trail_type(projectile)
3423
+ @particles&.rocket_trail(projectile.position, end_pos, type: trail_type) if trail_type
3424
+ end
3425
+
3426
+ def projectile_trail_type(projectile)
3427
+ flags = model_flags(projectile.model)
3428
+ if flags
3429
+ return 2 if (flags & EF_GIB) != 0
3430
+ return 4 if (flags & EF_ZOMGIB) != 0
3431
+ return 3 if (flags & EF_TRACER) != 0
3432
+ return 5 if (flags & EF_TRACER2) != 0
3433
+ return 0 if (flags & EF_ROCKET) != 0
3434
+ return 1 if (flags & EF_GRENADE) != 0
3435
+ return 6 if (flags & EF_TRACER3) != 0
3436
+ end
3437
+
3438
+ case projectile.kind
3439
+ when :rocket then 0
3440
+ when :grenade then 1
3441
+ end
3442
+ end
3443
+
3444
+ def render_projectiles
3445
+ return unless @projectiles&.any?
3446
+
3447
+ @projectiles.each do |projectile|
3448
+ gl_model = @mdl_renderers&.[](projectile.model)
3449
+ next unless gl_model
3450
+ ambient_light, shade_light = alias_entity_lighting(projectile.position)
3451
+
3452
+ gl_model.render(
3453
+ frame_index: 0,
3454
+ lerp: 0.0,
3455
+ position: projectile.position,
3456
+ yaw: projectile.angles.y,
3457
+ pitch: -projectile.angles.x,
3458
+ roll: projectile.angles.z,
3459
+ time: @game_time,
3460
+ ambient_light: ambient_light,
3461
+ shade_light: shade_light
3462
+ )
3463
+ end
3464
+ end
3465
+
3466
+ def render_temp_beams
3467
+ active_temp_beam_segments.each do |segment|
3468
+ gl_model = @mdl_renderers&.[](segment.model)
3469
+ next unless gl_model
3470
+
3471
+ ambient_light, shade_light = alias_entity_lighting(segment.origin)
3472
+ gl_model.render(
3473
+ frame_index: 0,
3474
+ lerp: 0.0,
3475
+ position: segment.origin,
3476
+ yaw: segment.yaw,
3477
+ pitch: -segment.pitch,
3478
+ roll: segment.roll,
3479
+ time: @game_time,
3480
+ ambient_light: ambient_light,
3481
+ shade_light: shade_light
3482
+ )
3483
+ end
3484
+ end
3485
+
3486
+ def active_temp_beam_segments
3487
+ Array(@temporary_beams).flat_map do |beam|
3488
+ next [] if beam.end_time < @game_time.to_f
3489
+
3490
+ beam_segments(beam)
3491
+ end
3492
+ end
3493
+
3494
+ def beam_segments(beam)
3495
+ start = beam_start_position(beam)
3496
+ dist = beam.finish - start
3497
+ length = dist.length
3498
+ return [] if length.zero?
3499
+
3500
+ dir = dist * (1.0 / length)
3501
+ pitch, yaw = quake_vectoangles(dist)
3502
+ origin = start
3503
+ remaining = length
3504
+ segments = []
3505
+ while remaining.positive?
3506
+ segments << TemporaryBeamSegment.new(
3507
+ model: beam.model,
3508
+ origin: origin,
3509
+ pitch: pitch,
3510
+ yaw: yaw,
3511
+ roll: (rand * 360.0).to_i
3512
+ )
3513
+ origin += dir * 30.0
3514
+ remaining -= 30.0
3515
+ end
3516
+ segments
3517
+ end
3518
+
3519
+ def beam_start_position(beam)
3520
+ if beam.entity == VIEW_ENTITY && @player
3521
+ return @player.position + Math::Vec3.new(0.0, 0.0, 16.0)
3522
+ end
3523
+
3524
+ beam.start
3525
+ end
3526
+
3527
+ def alias_entity_lighting(origin, ambient_light: nil, shade_light: nil)
3528
+ base_light = quake_light_point(origin)
3529
+ ambient = (ambient_light || base_light).to_f
3530
+ shade = (shade_light || base_light).to_f
3531
+ @dynamic_lights&.each do |light|
3532
+ next if light.die < @game_time.to_f
3533
+
3534
+ add = light.radius.to_f - (origin - light.origin).length
3535
+ next unless add.positive?
3536
+
3537
+ ambient += add
3538
+ shade += add
3539
+ end
3540
+ [ambient, shade]
3541
+ end
3542
+
3543
+ def quake_light_point(point)
3544
+ return 200 unless @level
3545
+ return 255 if @level.lighting.nil? || @level.lighting.empty?
3546
+ return 0 if @level.nodes.empty? || @level.planes.empty?
3547
+
3548
+ # Static geometry means a static answer per position; most callers
3549
+ # (items, torches, the view entity) barely move, so memoize. Sampled
3550
+ # light also depends on the lightstyle values, so the cache drops
3551
+ # whenever they change (style animation or entity-driven styles).
3552
+ styles = current_lightstyle_values
3553
+ if @light_point_cache_level != @level || @light_point_cache_styles != styles ||
3554
+ @light_point_cache.size > 4096
3555
+ @light_point_cache = {}
3556
+ @light_point_cache_level = @level
3557
+ @light_point_cache_styles = styles.dup
3558
+ end
3559
+ key = [point.x.round, point.y.round, point.z.round]
3560
+ @light_point_cache.fetch(key) do
3561
+ finish = point - Math::Vec3.new(0.0, 0.0, 8192.0 + 2.0)
3562
+ light = recursive_light_point(0, point, finish)
3563
+ @light_point_cache[key] = (light == -1 ? 0 : light)
3564
+ end
3565
+ end
3566
+
3567
+ def recursive_light_point(node_index, start, finish)
3568
+ return -1 if node_index.negative?
3569
+
3570
+ node = @level.nodes[node_index]
3571
+ return -1 unless node
3572
+
3573
+ plane = @level.planes[node.plane_index]
3574
+ return -1 unless plane
3575
+
3576
+ front = start.dot(plane.normal) - plane.dist
3577
+ back = finish.dot(plane.normal) - plane.dist
3578
+ side = front.negative? ? 1 : 0
3579
+
3580
+ return recursive_light_point(node.children[side], start, finish) if back.negative? == (side == 1)
3581
+
3582
+ frac = front / (front - back)
3583
+ mid = start + (finish - start) * frac
3584
+
3585
+ light = recursive_light_point(node.children[side], start, mid)
3586
+ return light if light >= 0
3587
+ return -1 if back.negative? == (side == 1)
3588
+
3589
+ sampled = light_point_from_node_surfaces(node, mid)
3590
+ return sampled unless sampled.nil?
3591
+
3592
+ recursive_light_point(node.children[1 - side], mid, finish)
3593
+ end
3594
+
3595
+ def light_point_from_node_surfaces(node, point)
3596
+ first_face = node.first_face
3597
+ last_face = first_face + node.num_faces
3598
+ (first_face...last_face).each do |face_index|
3599
+ face = @level.faces[face_index]
3600
+ next unless face
3601
+ next if (face.flags & Bsp::Face::SURF_DRAWTILED) != 0
3602
+
3603
+ texinfo = @level.texinfo[face.texinfo_index]
3604
+ next unless texinfo
3605
+
3606
+ s = point.dot(texinfo.s_vec) + texinfo.s_offset
3607
+ t = point.dot(texinfo.t_vec) + texinfo.t_offset
3608
+ mins = face.texture_mins || [0, 0]
3609
+ extents = face.extents || [0, 0]
3610
+ next if s < mins[0] || t < mins[1]
3611
+
3612
+ ds = s - mins[0]
3613
+ dt = t - mins[1]
3614
+ next if ds > extents[0] || dt > extents[1]
3615
+
3616
+ return 0 if face.light_offset.negative?
3617
+
3618
+ return sample_surface_light(face, ds.to_i >> 4, dt.to_i >> 4)
3619
+ end
3620
+ nil
3621
+ end
3622
+
3623
+ def sample_surface_light(face, ds, dt)
3624
+ width = (face.extents[0] >> 4) + 1
3625
+ height = (face.extents[1] >> 4) + 1
3626
+ lightmap_size = width * height
3627
+ offset = face.light_offset + dt * width + ds
3628
+ light = 0
3629
+
3630
+ face.styles.each do |style|
3631
+ break if style == 255
3632
+
3633
+ sample = @level.lighting.getbyte(offset)
3634
+ light += sample * current_lightstyle_values.fetch(style, 256) if sample
3635
+ offset += lightmap_size
3636
+ end
3637
+
3638
+ light >> 8
3639
+ end
3640
+
3641
+ def current_lightstyle_values
3642
+ @lightmap&.lightstyle_values ||
3643
+ Renderer::GLLightmap.lightstyle_values_for((@game_time || 0.0).to_f, current_lightstyles)
3644
+ end
3645
+
3646
+ def emit_entity_effect_particles(ent)
3647
+ # NOTE: effects may be nil, and nil & 1 == false in Ruby, which != 0
3648
+ return unless (ent.effects.to_i & ENTITY_EFFECT_BRIGHTFIELD) != 0
3649
+
3650
+ @particles&.entity_particles(ent.position, time: @game_time.to_f)
3651
+ end
3652
+
3653
+ def current_view_blend
3654
+ contents = point_contents(@camera&.position || player_eye_position)
3655
+ Renderer::GLViewBlend.calc_blend(
3656
+ [
3657
+ Renderer::GLViewBlend.contents_cshift(contents),
3658
+ @damage_cshift || Renderer::GLViewBlend::CShift.new(color: [0, 0, 0], percent: 0),
3659
+ Renderer::GLViewBlend.bonus_cshift(@bonus_cshift_percent.to_f),
3660
+ Renderer::GLViewBlend.powerup_cshift(@player_state, game_time: @game_time.to_f)
3661
+ ]
3662
+ )
3663
+ end
3664
+
3665
+ def trigger_bonus_flash
3666
+ @sound_events&.on_player(:bonus_flash)
3667
+ @bonus_cshift_percent = 50.0
3668
+ end
3669
+
3670
+ def update_view_cshifts(dt)
3671
+ @bonus_cshift_percent = [@bonus_cshift_percent.to_f - dt.to_f * 100.0, 0.0].max
3672
+ damage = @damage_cshift || Renderer::GLViewBlend::CShift.new(color: [0, 0, 0], percent: 0.0)
3673
+ @damage_cshift = Renderer::GLViewBlend::CShift.new(
3674
+ color: damage.color,
3675
+ percent: [damage.percent.to_f - dt.to_f * 150.0, 0.0].max
3676
+ )
3677
+ @damage_kick_time = [@damage_kick_time.to_f - dt.to_f, 0.0].max
3678
+ update_weapon_punch_angle(dt)
3679
+ end
3680
+
3681
+ def trigger_weapon_punch(event)
3682
+ @weapon_punch_angle = WEAPON_PUNCH_ANGLES.fetch(event.kick, Math::Vec3::ORIGIN)
3683
+ end
3684
+
3685
+ def update_weapon_punch_angle(dt)
3686
+ punch = current_weapon_punch_angle
3687
+ len = punch.length
3688
+ return if len.zero?
3689
+
3690
+ len = [len - WEAPON_PUNCH_DECAY * dt.to_f, 0.0].max
3691
+ @weapon_punch_angle = len.zero? ? Math::Vec3::ORIGIN : punch.normalize * len
3692
+ end
3693
+
3694
+ def current_weapon_punch_angle
3695
+ @weapon_punch_angle || Math::Vec3::ORIGIN
3696
+ end
3697
+
3698
+ def trigger_damage_flash(result)
3699
+ armor = result[:save].to_f
3700
+ blood = result.fetch(:display_take, result[:take]).to_f
3701
+ return if armor <= 0.0 && blood <= 0.0
3702
+
3703
+ @player_state.face_anim_time = @game_time.to_f + 0.2 if @player_state
3704
+ @damage_cshift = Renderer::GLViewBlend.damage_cshift(
3705
+ armor: armor,
3706
+ blood: blood,
3707
+ current_percent: @damage_cshift&.percent.to_f
3708
+ )
3709
+ end
3710
+
3711
+ def trigger_damage_kick(result, inflictor)
3712
+ source = damage_kick_source(inflictor)
3713
+ return unless @player && source
3714
+
3715
+ from = source - @player.position
3716
+ return if from.length.zero?
3717
+
3718
+ from = from.normalize
3719
+ blood = result.fetch(:display_take, result[:take]).to_f
3720
+ count = blood * 0.5 + result[:save].to_f * 0.5
3721
+ count = 10.0 if count < 10.0
3722
+ @damage_kick_roll = count * from.dot(@player.right) * 0.6
3723
+ @damage_kick_pitch = count * from.dot(@player.forward) * 0.6
3724
+ @damage_kick_time = 0.5
3725
+ end
3726
+
3727
+ def damage_kick_source(inflictor)
3728
+ return inflictor if inflictor.is_a?(Math::Vec3)
3729
+ return inflictor.position if inflictor.respond_to?(:position)
3730
+
3731
+ nil
3732
+ end
3733
+
3734
+ def current_damage_kick_angles
3735
+ return [0.0, 0.0] unless @damage_kick_time.to_f.positive?
3736
+
3737
+ scale = @damage_kick_time.to_f / 0.5
3738
+ [@damage_kick_roll.to_f * scale, @damage_kick_pitch.to_f * scale]
3739
+ end
3740
+
3741
+ def build_dynamic_lights
3742
+ lights = []
3743
+ @entities&.each_with_index do |ent, index|
3744
+ next if ent.removed?
3745
+
3746
+ lights.concat(quake_effect_dynamic_lights(
3747
+ ent.effects,
3748
+ origin: ent.position,
3749
+ angles: ent.angles,
3750
+ key: index + 1
3751
+ ))
3752
+ end
3753
+ @projectiles&.each_with_index do |projectile, index|
3754
+ lights.concat(quake_effect_dynamic_lights(
3755
+ projectile.effects,
3756
+ origin: projectile.position,
3757
+ angles: projectile.angles,
3758
+ key: 10_000 + index
3759
+ ))
3760
+ end
3761
+ @temporary_dynamic_lights = Array(@temporary_dynamic_lights).filter_map do |light|
3762
+ next if light.die < @game_time.to_f
3763
+
3764
+ radius = light.radius.to_f - (@game_time.to_f - light.start_time.to_f) * light.decay.to_f
3765
+ next if radius <= 0.0
3766
+
3767
+ lights << DynamicLight.new(
3768
+ key: 0,
3769
+ origin: light.origin,
3770
+ radius: radius,
3771
+ minlight: light.minlight,
3772
+ die: light.die
3773
+ )
3774
+ light
3775
+ end
3776
+ @dynamic_lights = lights
3777
+ end
3778
+
3779
+ # CL_MuzzleFlash: dlight 18 units ahead of the eyes, radius
3780
+ # 200 + rand&31, minlight 32, dies after 0.1s
3781
+ def add_muzzle_flash_dynamic_light
3782
+ return unless @player
3783
+
3784
+ origin = player_eye_position + @player.forward * 18.0
3785
+ @temporary_dynamic_lights ||= []
3786
+ @temporary_dynamic_lights << TemporaryDynamicLight.new(
3787
+ origin: origin,
3788
+ radius: 200.0 + rand * 32.0,
3789
+ minlight: 32.0,
3790
+ start_time: @game_time.to_f,
3791
+ die: @game_time.to_f + 0.1,
3792
+ decay: 0.0
3793
+ )
3794
+ end
3795
+
3796
+ def add_temp_explosion_dynamic_light(origin)
3797
+ @temporary_dynamic_lights ||= []
3798
+ @temporary_dynamic_lights << TemporaryDynamicLight.new(
3799
+ origin: origin,
3800
+ radius: 350.0,
3801
+ minlight: 0.0,
3802
+ start_time: @game_time.to_f,
3803
+ die: @game_time.to_f + 0.5,
3804
+ decay: 300.0
3805
+ )
3806
+ end
3807
+
3808
+ def spawn_temp_color_mapped_explosion(origin, color_start:, color_length:)
3809
+ @particles&.color_mapped_explosion(origin, color_start: color_start, color_length: color_length)
3810
+ add_temp_explosion_dynamic_light(origin)
3811
+ play_temp_explosion_sound
3812
+ end
3813
+
3814
+ def spawn_temp_tar_explosion(origin)
3815
+ @particles&.blob_explosion(origin)
3816
+ play_temp_explosion_sound
3817
+ end
3818
+
3819
+ def spawn_temp_spike_impact(origin)
3820
+ @particles&.gunshot(origin, count: 10)
3821
+ play_spike_impact_sound
3822
+ end
3823
+
3824
+ def spawn_temp_super_spike_impact(origin)
3825
+ @particles&.gunshot(origin, count: 20)
3826
+ play_spike_impact_sound
3827
+ end
3828
+
3829
+ def spawn_temp_wizard_spike_impact(origin)
3830
+ @particles&.run_particle_effect(origin, color: 20, count: 30)
3831
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "wizard/hit.wav"))
3832
+ end
3833
+
3834
+ def spawn_temp_knight_spike_impact(origin)
3835
+ @particles&.run_particle_effect(origin, color: 226, count: 20)
3836
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "hknight/hit.wav"))
3837
+ end
3838
+
3839
+ def spawn_temp_gunshot_impact(origin)
3840
+ @particles&.gunshot(origin, count: 20)
3841
+ end
3842
+
3843
+ def quake_effect_dynamic_lights(effects, origin:, angles:, key:)
3844
+ light = nil
3845
+ effects = effects.to_i
3846
+
3847
+ if (effects & ENTITY_EFFECT_MUZZLEFLASH) != 0
3848
+ light = DynamicLight.new(
3849
+ key: key,
3850
+ origin: origin + Math::Vec3.new(0.0, 0.0, 16.0) + angle_forward(angles) * 18.0,
3851
+ radius: 200.0 + quake_effect_light_jitter,
3852
+ minlight: 32.0,
3853
+ die: @game_time.to_f + 0.1
3854
+ )
3855
+ end
3856
+
3857
+ if (effects & ENTITY_EFFECT_BRIGHTLIGHT) != 0
3858
+ light = DynamicLight.new(
3859
+ key: key,
3860
+ origin: origin + Math::Vec3.new(0.0, 0.0, 16.0),
3861
+ radius: 400.0 + quake_effect_light_jitter,
3862
+ minlight: 0.0,
3863
+ die: @game_time.to_f + 0.001
3864
+ )
3865
+ end
3866
+
3867
+ if (effects & ENTITY_EFFECT_DIMLIGHT) != 0
3868
+ light = DynamicLight.new(
3869
+ key: key,
3870
+ origin: origin,
3871
+ radius: 200.0 + quake_effect_light_jitter,
3872
+ minlight: 0.0,
3873
+ die: @game_time.to_f + 0.001
3874
+ )
3875
+ end
3876
+
3877
+ light ? [light] : []
3878
+ end
3879
+
3880
+ def quake_effect_light_jitter
3881
+ (rand * 32.0).to_i
3882
+ end
3883
+
3884
+ def projectile_angles_from_velocity(velocity)
3885
+ pitch, yaw = quake_vectoangles(velocity)
3886
+ Math::Vec3.new(pitch, yaw, 0.0)
3887
+ end
3888
+
3889
+ def alias_entity_yaw(ent, model_path)
3890
+ flags = model_flags(model_path)
3891
+ return quake_anglemod(100.0 * @game_time.to_f) if flags && (flags & EF_ROTATE) != 0
3892
+
3893
+ ent.angles == Math::Vec3::ORIGIN ? ent.angle : ent.angles.y
3894
+ end
3895
+
3896
+ # Per-entity random animation phase (entity_t.syncbase) so identical
3897
+ # ST_RAND models (torches, flames) don't animate in lockstep.
3898
+ def entity_sync_base(ent, gl_model)
3899
+ return 0.0 unless gl_model.random_sync?
3900
+
3901
+ @entity_sync_bases ||= {}
3902
+ @entity_sync_bases[ent.object_id] ||= rand
3903
+ end
3904
+
3905
+ def model_flags(model_path)
3906
+ @mdl_cache&.[](model_path)&.flags
3907
+ end
3908
+
3909
+ def quake_anglemod(angle)
3910
+ (360.0 / 65_536.0) * ((angle * (65_536.0 / 360.0)).to_i & 65_535)
3911
+ end
3912
+
3913
+ def quake_vectoangles(vec)
3914
+ if vec.y.zero? && vec.x.zero?
3915
+ pitch = vec.z.positive? ? 90 : 270
3916
+ return [pitch, 0]
3917
+ end
3918
+
3919
+ yaw = (::Math.atan2(vec.y, vec.x) * 180.0 / ::Math::PI).to_i
3920
+ yaw += 360 if yaw.negative?
3921
+ forward = ::Math.sqrt(vec.x * vec.x + vec.y * vec.y)
3922
+ pitch = (::Math.atan2(vec.z, forward) * 180.0 / ::Math::PI).to_i
3923
+ pitch += 360 if pitch.negative?
3924
+ [pitch, yaw]
3925
+ end
3926
+
3927
+ def touch_projectile(projectile, position, ent)
3928
+ return if projectile_removed_by_sky?(projectile, position)
3929
+
3930
+ touch_inflictor = projectile.with(position: position)
3931
+ if projectile.kind == :rocket
3932
+ explode_projectile(projectile, position, direct_hit: ent)
3933
+ elsif spike_projectile?(projectile)
3934
+ if ent.damageable?
3935
+ ent.instance_variable_set(:@deathtype, projectile_deathtype(projectile))
3936
+ damage_entity(
3937
+ ent,
3938
+ projectile_owner_damage(projectile, projectile.damage),
3939
+ attacker: projectile.owner,
3940
+ inflictor: touch_inflictor
3941
+ )
3942
+ @particles&.blood(position, count: projectile_touch_blood_count(projectile))
3943
+ else
3944
+ play_spike_impact_sound
3945
+ @particles&.gunshot(position, count: spike_impact_particle_count(projectile))
3946
+ end
3947
+ elsif laser_projectile?(projectile)
3948
+ play_laser_impact_sound
3949
+ impact = laser_impact_position(projectile, position)
3950
+ if ent.health != 0.0
3951
+ ent.instance_variable_set(:@deathtype, "laser")
3952
+ damage_entity(
3953
+ ent,
3954
+ projectile_owner_damage(projectile, projectile.damage),
3955
+ attacker: projectile.owner,
3956
+ inflictor: touch_inflictor
3957
+ )
3958
+ @particles&.blood(impact, count: projectile_touch_blood_count(projectile))
3959
+ else
3960
+ @particles&.gunshot(impact, count: 5)
3961
+ end
3962
+ elsif projectile.kind == :fireball
3963
+ damage_entity(
3964
+ ent,
3965
+ projectile.damage,
3966
+ attacker: touch_inflictor,
3967
+ inflictor: touch_inflictor
3968
+ )
3969
+ else
3970
+ explode_projectile(projectile, position)
3971
+ end
3972
+ end
3973
+
3974
+ def grenade_explodes_on_touch?(ent)
3975
+ takedamage = ent.instance_variable_get(:@takedamage)
3976
+ takedamage == 2
3977
+ end
3978
+
3979
+ def grenade_bounce_avelocity(projectile, velocity)
3980
+ return projectile.avelocity unless projectile.kind == :grenade
3981
+
3982
+ velocity == Math::Vec3::ORIGIN ? Math::Vec3::ORIGIN : projectile.avelocity
3983
+ end
3984
+
3985
+ def spike_projectile?(projectile)
3986
+ projectile.kind == :spike || projectile.kind == :super_spike
3987
+ end
3988
+
3989
+ def laser_projectile?(projectile)
3990
+ projectile.kind == :laser
3991
+ end
3992
+
3993
+ def projectile_touched_sky?(position)
3994
+ point_contents(position) == Physics::CONTENTS_SKY
3995
+ end
3996
+
3997
+ def projectile_removed_by_sky?(projectile, position)
3998
+ (projectile.kind == :rocket || spike_projectile?(projectile) || laser_projectile?(projectile)) &&
3999
+ projectile_touched_sky?(position)
4000
+ end
4001
+
4002
+ def point_contents(position)
4003
+ return Physics::CONTENTS_EMPTY unless @level
4004
+ return Physics::CONTENTS_EMPTY if @level.nodes.empty? || @level.planes.empty? || @level.leafs.empty?
4005
+
4006
+ leaf = @level.leafs[Bsp::Vis.point_in_leaf(@level, position)]
4007
+ leaf ? leaf.contents : Physics::CONTENTS_EMPTY
4008
+ end
4009
+
4010
+ def play_laser_impact_sound
4011
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "enforcer/enfstop.wav"))
4012
+ end
4013
+
4014
+ def play_spike_impact_sound
4015
+ sound = if (rand * 5).to_i != 0
4016
+ "weapons/tink1.wav"
4017
+ else
4018
+ case (rand * 4).to_i
4019
+ when 1 then "weapons/ric1.wav"
4020
+ when 2 then "weapons/ric2.wav"
4021
+ else "weapons/ric3.wav"
4022
+ end
4023
+ end
4024
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: sound))
4025
+ end
4026
+
4027
+ def spike_impact_particle_count(projectile)
4028
+ projectile.kind == :super_spike ? 20 : 10
4029
+ end
4030
+
4031
+ def projectile_touch_blood_count(projectile)
4032
+ case projectile.kind
4033
+ when :spike then 9
4034
+ when :super_spike then 18
4035
+ when :laser then 15
4036
+ else 1
4037
+ end
4038
+ end
4039
+
4040
+ def laser_impact_position(projectile, position)
4041
+ position - projectile.velocity.normalize * 8.0
4042
+ end
4043
+
4044
+ def toss_projectile?(projectile)
4045
+ projectile.kind == :grenade || projectile.kind == :fireball
4046
+ end
4047
+
4048
+ def explode_projectile(projectile, position, direct_hit: nil)
4049
+ explosion_inflictor = projectile.with(position: position)
4050
+ if direct_hit && projectile.kind == :rocket && direct_hit.health.to_f.nonzero?
4051
+ direct_damage = projectile.damage + rand * projectile.damage_variance.to_f
4052
+ direct_hit.instance_variable_set(:@deathtype, "rocket")
4053
+ damage_entity(
4054
+ direct_hit,
4055
+ projectile_owner_damage(projectile, direct_damage),
4056
+ attacker: projectile.owner,
4057
+ inflictor: explosion_inflictor
4058
+ )
4059
+ end
4060
+
4061
+ radius_ignore = projectile.kind == :rocket ? direct_hit : nil
4062
+ apply_radius_damage(
4063
+ position,
4064
+ projectile.radius_damage,
4065
+ ignore: radius_ignore,
4066
+ attacker: projectile.owner,
4067
+ attacker_player: player_attacker?(projectile.owner),
4068
+ inflictor: explosion_inflictor,
4069
+ deathtype: projectile_radius_deathtype(projectile)
4070
+ )
4071
+ play_temp_explosion_sound
4072
+ effect_position = explosion_effect_position(projectile, position)
4073
+ add_temp_explosion_dynamic_light(effect_position)
4074
+ @particles&.explosion(effect_position)
4075
+ end
4076
+
4077
+ def explosion_effect_position(projectile, position)
4078
+ return position unless projectile.kind == :rocket
4079
+
4080
+ position - projectile.velocity.normalize * 8.0
4081
+ end
4082
+
4083
+ def bounce_velocity(velocity, normal)
4084
+ backoff = velocity.dot(normal) * 1.5
4085
+ Math::Vec3.new(
4086
+ stop_epsilon_component(velocity.x - normal.x * backoff),
4087
+ stop_epsilon_component(velocity.y - normal.y * backoff),
4088
+ stop_epsilon_component(velocity.z - normal.z * backoff)
4089
+ )
4090
+ end
4091
+
4092
+ # Nearest box face to the hit point approximates the contact normal
4093
+ # for projectiles bouncing off bbox entities.
4094
+ def aabb_face_normal(ent, hit_pos)
4095
+ mins, maxs = damageable_bounds(ent)
4096
+ return nil unless mins && maxs
4097
+
4098
+ [
4099
+ [(hit_pos.x - mins.x).abs, Math::Vec3.new(-1.0, 0.0, 0.0)],
4100
+ [(maxs.x - hit_pos.x).abs, Math::Vec3.new(1.0, 0.0, 0.0)],
4101
+ [(hit_pos.y - mins.y).abs, Math::Vec3.new(0.0, -1.0, 0.0)],
4102
+ [(maxs.y - hit_pos.y).abs, Math::Vec3.new(0.0, 1.0, 0.0)],
4103
+ [(hit_pos.z - mins.z).abs, Math::Vec3.new(0.0, 0.0, -1.0)],
4104
+ [(maxs.z - hit_pos.z).abs, Math::Vec3.new(0.0, 0.0, 1.0)]
4105
+ ].min_by(&:first).last
4106
+ end
4107
+
4108
+ def stop_epsilon_component(value)
4109
+ value > -STOP_EPSILON && value < STOP_EPSILON ? 0.0 : value
4110
+ end
4111
+
4112
+ def apply_radius_damage(origin, damage, ignore: nil, attacker_player: true, attacker: nil, inflictor: nil,
4113
+ deathtype: nil)
4114
+ radius_attacker = attacker || (attacker_player ? @player_state : nil)
4115
+ radius = damage + 40.0
4116
+ if @player && @player_state&.alive?
4117
+ player_center = @player.position + (ENTITY_HULL_MINS + ENTITY_HULL_MAXS) * 0.5
4118
+ points = radius_damage_points(origin, player_center, damage, radius)
4119
+ if points.positive? && can_radius_damage_position?(@player.position, origin)
4120
+ points *= 0.5 if attacker_player
4121
+ player_points = player_damage(points)
4122
+ apply_player_damage_momentum(player_points, inflictor || origin)
4123
+ @player_state.deathtype = deathtype if deathtype
4124
+ damage_player(
4125
+ player_points,
4126
+ pain_sound: true,
4127
+ inflictor: inflictor || origin,
4128
+ attacker: radius_attacker,
4129
+ apply_momentum: false
4130
+ )
4131
+ end
4132
+ end
4133
+
4134
+ @entities.each do |ent|
4135
+ next unless ent.damageable?
4136
+ next if ent.instance_variable_defined?(:@solid) && ent.instance_variable_get(:@solid) == SOLID_NOT
4137
+ next if ent.brush_entity? && !damageable_brush_entity?(ent)
4138
+ next if ent.equal?(ignore)
4139
+
4140
+ damage_pos = damageable_position(ent)
4141
+ points = radius_damage_points(origin, radius_damage_center(ent), damage, radius)
4142
+ if points.positive? && can_radius_damage_entity?(ent, damage_pos, origin)
4143
+ points *= 0.5 if ent.equal?(radius_attacker)
4144
+ ent.instance_variable_set(:@deathtype, deathtype) if deathtype
4145
+ damage_entity(
4146
+ ent,
4147
+ radius_attacker_damage(points, attacker_player),
4148
+ attacker: radius_attacker,
4149
+ inflictor: inflictor || origin
4150
+ )
4151
+ end
4152
+ end
4153
+ end
4154
+
4155
+ def radius_damage_points(origin, center, damage, radius)
4156
+ distance = (origin - center).length
4157
+ return 0.0 if distance > radius
4158
+
4159
+ damage - 0.5 * distance
4160
+ end
4161
+
4162
+ def can_radius_damage_position?(position, origin)
4163
+ return true unless @level
4164
+
4165
+ offsets = [
4166
+ Math::Vec3::ORIGIN,
4167
+ Math::Vec3.new(15.0, 15.0, 0.0),
4168
+ Math::Vec3.new(-15.0, -15.0, 0.0),
4169
+ Math::Vec3.new(-15.0, 15.0, 0.0),
4170
+ Math::Vec3.new(15.0, -15.0, 0.0)
4171
+ ]
4172
+ solid_brush = @entities.select(&:brush_entity?)
4173
+ offsets.any? do |offset|
4174
+ trace = Physics::HullTrace.trace_world_and_entities(
4175
+ @level, origin, position + offset, solid_brush, hull_num: 0
4176
+ )
4177
+ trace.fraction >= 1.0
4178
+ end
4179
+ end
4180
+
4181
+ def can_radius_damage_entity?(ent, position, origin)
4182
+ return true unless @level
4183
+ return can_radius_damage_position?(position, origin) unless ent.brush_entity?
4184
+
4185
+ solid_brush = @entities.select(&:brush_entity?)
4186
+ trace = Physics::HullTrace.trace_world_and_entities(
4187
+ @level, origin, position, solid_brush, hull_num: 0
4188
+ )
4189
+ trace.fraction >= 1.0
4190
+ end
4191
+
4192
+ def player_damage(amount)
4193
+ multiplier = if @player_state&.powerup_active?(:quad, game_time: @game_time)
4194
+ deathmatch.to_i == 4 ? 8.0 : 4.0
4195
+ else
4196
+ 1.0
4197
+ end
4198
+ amount * multiplier
4199
+ end
4200
+
4201
+ def radius_attacker_damage(amount, attacker_player)
4202
+ attacker_player ? player_damage(amount) : amount
4203
+ end
4204
+
4205
+ def projectile_owner_damage(projectile, amount)
4206
+ player_attacker?(projectile.owner) ? player_damage(amount) : amount
4207
+ end
4208
+
4209
+ def projectile_deathtype(projectile)
4210
+ projectile.kind == :super_spike ? "supernail" : "nail"
4211
+ end
4212
+
4213
+ def projectile_radius_deathtype(projectile)
4214
+ case projectile.kind
4215
+ when :rocket then "rocket"
4216
+ when :grenade then "grenade"
4217
+ end
4218
+ end
4219
+
4220
+ def spike_lateral_offset(weapon)
4221
+ return 0.0 if weapon == :super_nailgun
4222
+
4223
+ @spike_lateral_offset ||= 4.0
4224
+ offset = @spike_lateral_offset
4225
+ @spike_lateral_offset = -@spike_lateral_offset
4226
+ offset
4227
+ end
4228
+
4229
+ def crandom
4230
+ 2.0 * (rand - 0.5)
4231
+ end
4232
+
4233
+ def angle_forward(angles)
4234
+ yaw = angles.y * ::Math::PI / 180.0
4235
+ pitch = angles.x * ::Math::PI / 180.0
4236
+ cp = ::Math.cos(pitch)
4237
+ Math::Vec3.new(::Math.cos(yaw) * cp, ::Math.sin(yaw) * cp, -::Math.sin(pitch))
4238
+ end
4239
+
4240
+ def damage_entity(ent, amount, attacker: nil, inflictor: nil)
4241
+ if damageable_brush_runtime_entity?(ent) && @brush_game
4242
+ return true if @brush_game.damage_brush_entity(ent, amount, activator: damage_activator(attacker))
4243
+ end
4244
+
4245
+ amount = quake_damage_amount(amount, attacker, inflictor)
4246
+ was_alive = ent.damageable?
4247
+ protected = entity_damage_protected?(ent)
4248
+ old_armor = ent.armorvalue
4249
+ damage = was_alive ? ent.absorb_damage(amount, game_time: @game_time) : { take: 0.0 }
4250
+ taken = damage[:take].to_f
4251
+ armor_saved = old_armor - ent.armorvalue
4252
+ record_client_damage(ent, amount, armor_saved, inflictor) if was_alive
4253
+ apply_damage_momentum(ent, amount, inflictor, attacker) if was_alive
4254
+ return taken if was_alive && teamplay_damage_avoided?(ent, attacker, inflictor)
4255
+
4256
+ ent.apply_health_damage(taken) if was_alive && taken.positive?
4257
+ armor_absorbed_damage = armor_saved.positive?
4258
+ if was_alive && (taken.positive? || (armor_absorbed_damage && !protected))
4259
+ if ent.removed?
4260
+ killed_entity(ent, attacker)
4261
+ else
4262
+ react_to_entity_damage(ent, attacker)
4263
+ call_entity_pain(ent, attacker, taken)
4264
+ end
4265
+ end
4266
+ explode_box(ent) if was_alive && taken.positive? && ent.removed? && explobox?(ent)
4267
+ taken
4268
+ end
4269
+
4270
+ def entity_damage_protected?(ent)
4271
+ return true if (ent.flags & Entity::FL_GODMODE) != 0
4272
+
4273
+ @game_time && ent.invincible_finished.positive? && ent.invincible_finished >= @game_time.to_f
4274
+ end
4275
+
4276
+ def record_client_damage(ent, amount, armor_saved, inflictor)
4277
+ return if (ent.flags & Entity::FL_CLIENT) == 0
4278
+
4279
+ ent.dmg_take += (amount.to_f - armor_saved).ceil
4280
+ ent.dmg_save += armor_saved
4281
+ ent.dmg_inflictor = inflictor
4282
+ end
4283
+
4284
+ def teamplay_damage_avoided?(ent, attacker, inflictor)
4285
+ return false unless [1, 3].include?(teamplay.to_i)
4286
+ return false unless attacker.respond_to?(:classname) && attacker.classname == "player"
4287
+ return false if inflictor.respond_to?(:classname) && inflictor.classname == "door"
4288
+
4289
+ attacker_team = quake_info_key(attacker, "team")
4290
+ return false if attacker_team.empty?
4291
+ return false unless quake_info_key(ent, "team") == attacker_team
4292
+ return false if teamplay.to_i == 3 && ent.equal?(attacker)
4293
+
4294
+ true
4295
+ end
4296
+
4297
+ def quake_info_key(ent, key)
4298
+ return "" unless ent
4299
+ return ent[key].to_s if ent.respond_to?(:[]) && ent[key]
4300
+ return ent.public_send(key).to_s if ent.respond_to?(key)
4301
+
4302
+ value = ent.instance_variable_get(:"@#{key}") if ent.instance_variable_defined?(:"@#{key}")
4303
+ value.to_s
4304
+ end
4305
+
4306
+ def quake_damage_amount(amount, attacker, inflictor)
4307
+ return amount unless attacker_quad_damage_active?(attacker, inflictor)
4308
+
4309
+ amount.to_f * (deathmatch.to_i == 4 ? 8.0 : 4.0)
4310
+ end
4311
+
4312
+ def attacker_quad_damage_active?(attacker, inflictor)
4313
+ return false unless attacker.respond_to?(:super_damage_finished)
4314
+ return false if @game_time.nil?
4315
+ return false if inflictor.respond_to?(:classname) && inflictor.classname == "door"
4316
+
4317
+ attacker.super_damage_finished > @game_time.to_f
4318
+ end
4319
+
4320
+ def damage_activator(attacker)
4321
+ return @player if attacker.nil? || attacker.equal?(@player_state)
4322
+
4323
+ attacker
4324
+ end
4325
+
4326
+ def apply_player_direct_damage_momentum(amount, inflictor)
4327
+ return unless @player&.instance_variable_get(:@movetype) == MOVETYPE_WALK
4328
+
4329
+ apply_player_damage_momentum(amount, inflictor)
4330
+ end
4331
+
4332
+ def apply_player_damage_momentum(amount, inflictor)
4333
+ return unless @player
4334
+ return unless inflictor
4335
+
4336
+ center = inflictor_damage_center(inflictor)
4337
+ return unless center
4338
+
4339
+ dir = (@player.position - center).normalize
4340
+ @player.velocity = @player.velocity + dir * amount.to_f * 8.0
4341
+ # T_Damage adds dir*damage*rj for self-inflicted player damage
4342
+ # (rocket jumps) when the rj server setting exceeds 1
4343
+ return unless rj.to_f > 1.0
4344
+
4345
+ @player.velocity = @player.velocity + dir * amount.to_f * rj.to_f
4346
+ end
4347
+
4348
+ def apply_damage_momentum(ent, amount, inflictor, attacker = nil)
4349
+ return unless inflictor
4350
+ return unless ent.instance_variable_get(:@movetype) == MOVETYPE_WALK
4351
+
4352
+ center = inflictor_damage_center(inflictor)
4353
+ return unless center
4354
+
4355
+ dir = (ent.position - center).normalize
4356
+ ent.velocity = ent.velocity + dir * amount.to_f * 8.0
4357
+ if rocket_jump_modifier_active?(ent, attacker)
4358
+ ent.velocity = ent.velocity + dir * amount.to_f * rj.to_f
4359
+ end
4360
+ end
4361
+
4362
+ def rocket_jump_modifier_active?(ent, attacker)
4363
+ return false unless rj.to_f > 1.0
4364
+ return false unless ent.classname == "player"
4365
+ return false unless attacker.respond_to?(:classname) && attacker.classname == "player"
4366
+
4367
+ quake_info_key(attacker, "netname") == quake_info_key(ent, "netname")
4368
+ end
4369
+
4370
+ def inflictor_damage_center(inflictor)
4371
+ return inflictor if inflictor.is_a?(Math::Vec3)
4372
+ return nil unless inflictor.respond_to?(:position)
4373
+ return inflictor.position + (ENTITY_HULL_MINS + ENTITY_HULL_MAXS) * 0.5 if inflictor.equal?(@player)
4374
+
4375
+ if inflictor.is_a?(Entity)
4376
+ mins, maxs = damageable_bounds(inflictor)
4377
+ return (mins + maxs) * 0.5 if mins && maxs
4378
+ end
4379
+
4380
+ inflictor.position
4381
+ end
4382
+
4383
+ def killed_entity(ent, attacker)
4384
+ movetype = ent.instance_variable_get(:@movetype)
4385
+ if movetype == MOVETYPE_PUSH || movetype == MOVETYPE_NONE
4386
+ call_entity_die(ent)
4387
+ return
4388
+ end
4389
+
4390
+ ent.instance_variable_set(:@enemy, attacker)
4391
+ @killed_monsters = @killed_monsters.to_i + 1 if (ent.flags & Entity::FL_MONSTER) != 0
4392
+ ent.instance_variable_set(:@takedamage, 0)
4393
+ ent.instance_variable_set(:@touch, nil)
4394
+ ent.effects = 0
4395
+ call_entity_die(ent)
4396
+ end
4397
+
4398
+ def react_to_entity_damage(ent, attacker)
4399
+ return unless attacker
4400
+ return unless (ent.flags & Entity::FL_MONSTER) != 0
4401
+ return if ent.equal?(attacker)
4402
+ return if ent.instance_variable_get(:@enemy).equal?(attacker)
4403
+ return if same_monster_class?(ent, attacker) && ent.classname != "monster_army"
4404
+
4405
+ current_enemy = ent.instance_variable_get(:@enemy)
4406
+ ent.instance_variable_set(:@oldenemy, current_enemy) if player_attacker?(current_enemy)
4407
+ ent.instance_variable_set(:@enemy, attacker)
4408
+ ent.instance_variable_set(:@found_target, true)
4409
+ end
4410
+
4411
+ def call_entity_pain(ent, attacker, damage)
4412
+ pain = ent.instance_variable_get(:@th_pain)
4413
+ return unless pain
4414
+
4415
+ ent.instance_variable_set(:@pain_function, pain)
4416
+ ent.instance_variable_set(:@pain_attacker, attacker)
4417
+ ent.instance_variable_set(:@pain_damage, damage.to_f)
4418
+ end
4419
+
4420
+ def call_entity_die(ent)
4421
+ die = ent.instance_variable_get(:@th_die)
4422
+ if die
4423
+ ent.instance_variable_set(:@die_function, die)
4424
+ ent.instance_variable_set(:@die_enemy, ent.instance_variable_get(:@enemy))
4425
+ end
4426
+ end
4427
+
4428
+ def same_monster_class?(ent, attacker)
4429
+ attacker.respond_to?(:classname) && ent.classname == attacker.classname
4430
+ end
4431
+
4432
+ def player_attacker?(attacker)
4433
+ attacker.is_a?(PlayerState) ||
4434
+ (attacker.respond_to?(:classname) && attacker.classname == "player")
4435
+ end
4436
+
4437
+ def trace_damageable_entity(src, dir, max_dist, ignore: [], ignore_solid_trigger: false)
4438
+ best_ent = nil
4439
+ best_t = max_dist
4440
+
4441
+ @entities.each do |ent|
4442
+ next unless ent.damageable?
4443
+ next if ignore.include?(ent)
4444
+ next if ignore_solid_trigger && ent.instance_variable_get(:@solid) == 1
4445
+
4446
+ mins, maxs = damageable_bounds(ent)
4447
+ next unless mins && maxs
4448
+
4449
+ hit_t = ray_aabb(src, dir, mins, maxs)
4450
+ next unless hit_t
4451
+ next if hit_t.negative? || hit_t > best_t
4452
+
4453
+ best_ent = ent
4454
+ best_t = hit_t
4455
+ end
4456
+
4457
+ return nil unless best_ent
4458
+
4459
+ [best_ent, src + dir * best_t]
4460
+ end
4461
+
4462
+ def trace_axe_touch_entity(src, dir, max_dist)
4463
+ trace_touch_entity(src, dir, max_dist)
4464
+ end
4465
+
4466
+ def trace_bullet_touch_entity(src, dir, max_dist)
4467
+ trace_touch_entity(src, dir, max_dist)
4468
+ end
4469
+
4470
+ def trace_lightning_touch_entity(src, dir, max_dist)
4471
+ trace_touch_entity(src, dir, max_dist)
4472
+ end
4473
+
4474
+ def trace_spike_touch_entity(src, dir, max_dist, ignore: nil)
4475
+ trace_touch_entity(src, dir, max_dist, ignore: ignore)
4476
+ end
4477
+
4478
+ def trace_fireball_touch_entity(src, dir, max_dist, ignore: nil)
4479
+ trace_touch_entity(src, dir, max_dist, ignore: ignore)
4480
+ end
4481
+
4482
+ def trace_missile_touch_entity(src, dir, max_dist, ignore: nil)
4483
+ trace_touch_entity(src, dir, max_dist, ignore: ignore)
4484
+ end
4485
+
4486
+ def trace_laser_touch_entity(src, dir, max_dist, ignore: nil)
4487
+ trace_touch_entity(src, dir, max_dist, ignore: ignore)
4488
+ end
4489
+
4490
+ def trace_touch_entity(src, dir, max_dist, ignore: nil)
4491
+ best_ent = nil
4492
+ best_t = max_dist
4493
+
4494
+ @entities.each do |ent|
4495
+ next if ent.removed?
4496
+ next if ent.equal?(ignore)
4497
+ # Traces never collide with SOLID_TRIGGER (1) pickups -- only
4498
+ # damageable or genuinely solid (SOLID_BBOX and up) entities
4499
+ next unless ent.damageable? || ent.instance_variable_get(:@solid).to_i >= SOLID_BBOX
4500
+
4501
+ mins, maxs = damageable_bounds(ent)
4502
+ next unless mins && maxs
4503
+
4504
+ hit_t = ray_aabb(src, dir, mins, maxs)
4505
+ next unless hit_t
4506
+ next if hit_t.negative? || hit_t > best_t
4507
+
4508
+ best_ent = ent
4509
+ best_t = hit_t
4510
+ end
4511
+
4512
+ return nil unless best_ent
4513
+
4514
+ [best_ent, src + dir * best_t]
4515
+ end
4516
+
4517
+ def damageable_bounds(ent)
4518
+ if damageable_brush_runtime_entity?(ent)
4519
+ return nil unless damageable_brush_entity?(ent)
4520
+
4521
+ model = damageable_brush_model(ent)
4522
+ return nil unless model
4523
+
4524
+ [ent.position + model.mins, ent.position + model.maxs]
4525
+ elsif explobox?(ent)
4526
+ height = ent.classname == "misc_explobox2" ? 32.0 : 64.0
4527
+ [ent.position, ent.position + Math::Vec3.new(32.0, 32.0, height)]
4528
+ else
4529
+ [ent.position + ENTITY_HULL_MINS, ent.position + ENTITY_HULL_MAXS]
4530
+ end
4531
+ end
4532
+
4533
+ def damageable_position(ent)
4534
+ if damageable_brush_runtime_entity?(ent) && damageable_brush_entity?(ent)
4535
+ model = damageable_brush_model(ent)
4536
+ return ent.position + (model.mins + model.maxs) * 0.5 if model
4537
+ end
4538
+
4539
+ ent.position
4540
+ end
4541
+
4542
+ def radius_damage_center(ent)
4543
+ mins, maxs = damageable_bounds(ent)
4544
+ return damageable_position(ent) unless mins && maxs
4545
+
4546
+ (mins + maxs) * 0.5
4547
+ end
4548
+
4549
+ def damageable_brush_entity?(ent)
4550
+ ent.classname.start_with?("trigger_") ||
4551
+ ent.classname == "func_button" ||
4552
+ ent.classname == "func_door" ||
4553
+ ent.instance_variable_get(:@regular_door) ||
4554
+ ent.instance_variable_get(:@secret_door)
4555
+ end
4556
+
4557
+ def damageable_brush_runtime_entity?(ent)
4558
+ ent.brush_entity? || !ent.instance_variable_get(:@trigger_model_index).nil?
4559
+ end
4560
+
4561
+ def damageable_brush_model(ent)
4562
+ model_index = ent.model_index || ent.instance_variable_get(:@trigger_model_index)
4563
+ @level.models[model_index] if model_index && @level
4564
+ end
4565
+
4566
+ def explobox?(ent)
4567
+ ent.classname == "misc_explobox" || ent.classname == "misc_explobox2"
4568
+ end
4569
+
4570
+ def explode_box(ent)
4571
+ ent.instance_variable_set(:@takedamage, 0)
4572
+ ent.classname = "explo_box"
4573
+ apply_radius_damage(ent.position, 160.0, ignore: ent, attacker_player: false, attacker: ent, inflictor: ent)
4574
+ play_temp_explosion_sound
4575
+ effect_position = ent.position + Math::Vec3.new(0.0, 0.0, 32.0)
4576
+ add_temp_explosion_dynamic_light(effect_position)
4577
+ @particles&.explosion(effect_position)
4578
+ end
4579
+
4580
+ def play_temp_explosion_sound
4581
+ @sound_events&.on_weapon_fire(WeaponSound.new(sound: "weapons/r_exp3.wav"))
4582
+ end
4583
+
4584
+ def ray_aabb(origin, dir, mins, maxs)
4585
+ t_min = 0.0
4586
+ t_max = Float::INFINITY
4587
+
4588
+ [:x, :y, :z].each do |axis|
4589
+ o = origin.public_send(axis)
4590
+ d = dir.public_send(axis)
4591
+ min = mins.public_send(axis)
4592
+ max = maxs.public_send(axis)
4593
+
4594
+ if d.abs < 0.000001
4595
+ return nil if o < min || o > max
4596
+ next
4597
+ end
4598
+
4599
+ inv = 1.0 / d
4600
+ t1 = (min - o) * inv
4601
+ t2 = (max - o) * inv
4602
+ t1, t2 = t2, t1 if t1 > t2
4603
+
4604
+ t_min = [t_min, t1].max
4605
+ t_max = [t_max, t2].min
4606
+ return nil if t_min > t_max
4607
+ end
4608
+
4609
+ t_min
4610
+ end
4611
+
4612
+ def aabb_intersect?(mins_a, maxs_a, mins_b, maxs_b)
4613
+ mins_a.x <= maxs_b.x && maxs_a.x >= mins_b.x &&
4614
+ mins_a.y <= maxs_b.y && maxs_a.y >= mins_b.y &&
4615
+ mins_a.z <= maxs_b.z && maxs_a.z >= mins_b.z
4616
+ end
298
4617
  end
299
4618
  end
300
4619
  end