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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require_relative "../math/vec3"
4
5
 
5
6
  module Quake
6
7
  module Game
@@ -33,8 +34,140 @@ module Quake
33
34
  lightning_gun: :cells
34
35
  }.freeze
35
36
 
36
- attr_accessor :health, :armor, :armor_type, :current_weapon
37
- attr_reader :ammo, :weapons_owned
37
+ POWERUP_DURATIONS = {
38
+ biosuit: 30.0,
39
+ pentagram: 30.0,
40
+ ring: 30.0,
41
+ quad: 30.0
42
+ }.freeze
43
+
44
+ EF_DIMLIGHT = 8
45
+ EF_BLUE = 64
46
+ EF_RED = 128
47
+ POWERUP_EFFECT_BITS = EF_DIMLIGHT | EF_BLUE | EF_RED
48
+ FL_CLIENT = 8
49
+ FL_GODMODE = 64
50
+ FL_WATERJUMP = 2048
51
+ FL_JUMPRELEASED = 4096
52
+ WEAPON_ITEM_INDEX = {
53
+ shotgun: 0,
54
+ super_shotgun: 1,
55
+ nailgun: 2,
56
+ super_nailgun: 3,
57
+ grenade_launcher: 4,
58
+ rocket_launcher: 5,
59
+ lightning_gun: 6
60
+ }.freeze
61
+
62
+ # QuakeC weapons.qc/player.qc weapon fire parameters.
63
+ WEAPON_FIRE = {
64
+ axe: {
65
+ cooldown: 0.5,
66
+ ammo_type: nil,
67
+ ammo_cost: 0,
68
+ sound: "weapons/ax1.wav",
69
+ range: 64.0,
70
+ damage: 20,
71
+ animation_frames: 1..4,
72
+ kind: :axe
73
+ },
74
+ shotgun: {
75
+ cooldown: 0.5,
76
+ ammo_type: :shells,
77
+ ammo_cost: 1,
78
+ sound: "weapons/guncock.wav",
79
+ pellets: 6,
80
+ range: 2048.0,
81
+ spread_x: 0.04,
82
+ spread_y: 0.04,
83
+ damage: 4,
84
+ kick: :small,
85
+ animation_frames: 1..6,
86
+ kind: :bullets
87
+ },
88
+ super_shotgun: {
89
+ cooldown: 0.7,
90
+ ammo_type: :shells,
91
+ ammo_cost: 2,
92
+ sound: "weapons/shotgn2.wav",
93
+ pellets: 14,
94
+ range: 2048.0,
95
+ spread_x: 0.14,
96
+ spread_y: 0.08,
97
+ damage: 4,
98
+ kick: :big,
99
+ animation_frames: 1..6,
100
+ kind: :bullets
101
+ },
102
+ nailgun: {
103
+ cooldown: 0.2,
104
+ ammo_type: :nails,
105
+ ammo_cost: 1,
106
+ sound: "weapons/rocket1i.wav",
107
+ range: 1000.0,
108
+ damage: 9,
109
+ kick: :small,
110
+ animation_frames: 1..8,
111
+ kind: :spike
112
+ },
113
+ super_nailgun: {
114
+ cooldown: 0.2,
115
+ ammo_type: :nails,
116
+ ammo_cost: 2,
117
+ sound: "weapons/spike2.wav",
118
+ range: 1000.0,
119
+ damage: 18,
120
+ kick: :small,
121
+ animation_frames: 1..8,
122
+ kind: :spike
123
+ },
124
+ rocket_launcher: {
125
+ cooldown: 0.8,
126
+ ammo_type: :rockets,
127
+ ammo_cost: 1,
128
+ sound: "weapons/sgun1.wav",
129
+ range: 1000.0,
130
+ damage: 100,
131
+ damage_variance: 20,
132
+ radius_damage: 120,
133
+ kick: :small,
134
+ animation_frames: 1..6,
135
+ kind: :rocket
136
+ },
137
+ lightning_gun: {
138
+ cooldown: 0.1,
139
+ ammo_type: :cells,
140
+ ammo_cost: 1,
141
+ sound: "weapons/lhit.wav",
142
+ range: 600.0,
143
+ damage: 30,
144
+ kick: :small,
145
+ animation_frames: 1..4,
146
+ kind: :lightning
147
+ },
148
+ grenade_launcher: {
149
+ cooldown: 0.6,
150
+ ammo_type: :rockets,
151
+ ammo_cost: 1,
152
+ sound: "weapons/grenade.wav",
153
+ damage: 120,
154
+ radius_damage: 120,
155
+ kick: :small,
156
+ animation_frames: 1..6,
157
+ kind: :grenade
158
+ }
159
+ }.freeze
160
+
161
+ FireResult = Data.define(
162
+ :weapon, :sound, :pellets, :range, :spread_x, :spread_y,
163
+ :damage, :damage_variance, :radius_damage, :kick, :animation_frames, :kind
164
+ )
165
+
166
+ attr_accessor :health, :armor, :armor_type, :current_weapon, :flags, :deathtype,
167
+ :deadflag, :view_offset, :weapon_model, :face_anim_time, :frags,
168
+ :show_hostile, :b_switch, :w_switch, :centerprint
169
+ attr_reader :ammo, :weapons_owned, :keys_owned, :serverflags, :powerup_finished,
170
+ :powerup_warning_time, :megahealth_rot_time, :effects, :weapon_gettime
38
171
 
39
172
  def initialize
40
173
  @health = 100
@@ -42,7 +175,25 @@ module Quake
42
175
  @armor_type = 0 # 0=none, 1=green(30%), 2=yellow(60%), 3=red(80%)
43
176
  @current_weapon = :shotgun
44
177
  @weapons_owned = Set.new([:axe, :shotgun])
178
+ @keys_owned = Set.new
179
+ @serverflags = 0
180
+ @flags = FL_CLIENT
45
181
  @ammo = { shells: 25, nails: 0, rockets: 0, cells: 0 }
182
+ @powerup_finished = {}
183
+ @powerup_warning_time = {}
184
+ @weapon_gettime = {}
185
+ @megahealth_rot_time = nil
186
+ @effects = 0
187
+ @deathtype = ""
188
+ @deadflag = DEAD_NO
189
+ @view_offset = DEFAULT_VIEW_OFFSET
190
+ @weapon_model = nil
191
+ @face_anim_time = 0.0
192
+ @frags = 0
193
+ @show_hostile = 0.0
194
+ @b_switch = 8
195
+ @w_switch = 8
196
+ @centerprint = nil
46
197
  end
47
198
 
48
199
  def current_ammo_type
@@ -55,6 +206,8 @@ module Quake
55
206
  end
56
207
 
57
208
  def current_weapon_model
209
+ return @weapon_model unless @weapon_model.nil?
210
+
58
211
  WEAPON_MODELS[@current_weapon]
59
212
  end
60
213
 
@@ -63,7 +216,7 @@ module Quake
63
216
  idx = WEAPONS.index(@current_weapon) || 0
64
217
  WEAPONS.size.times do
65
218
  idx = (idx + 1) % WEAPONS.size
66
- if @weapons_owned.include?(WEAPONS[idx])
219
+ if selectable_weapon?(WEAPONS[idx])
67
220
  @current_weapon = WEAPONS[idx]
68
221
  return
69
222
  end
@@ -74,7 +227,7 @@ module Quake
74
227
  idx = WEAPONS.index(@current_weapon) || 0
75
228
  WEAPONS.size.times do
76
229
  idx = (idx - 1) % WEAPONS.size
77
- if @weapons_owned.include?(WEAPONS[idx])
230
+ if selectable_weapon?(WEAPONS[idx])
78
231
  @current_weapon = WEAPONS[idx]
79
232
  return
80
233
  end
@@ -83,15 +236,89 @@ module Quake
83
236
 
84
237
  # Select weapon by slot number (1-8)
85
238
  def select_weapon(slot)
86
- slot = slot.clamp(1, WEAPONS.size)
239
+ return unless (1..WEAPONS.size).cover?(slot)
240
+
87
241
  weapon = WEAPONS[slot - 1]
88
- @current_weapon = weapon if @weapons_owned.include?(weapon)
242
+ @current_weapon = weapon if selectable_weapon?(weapon)
243
+ end
244
+
245
+ def handle_impulse_command(impulse)
246
+ command = impulse.to_i
247
+ if (1..WEAPONS.size).cover?(command)
248
+ select_weapon(command)
249
+ elsif command == 10
250
+ next_weapon
251
+ elsif command == 11
252
+ advance_serverflags_command
253
+ elsif command == 12
254
+ prev_weapon
255
+ end
89
256
  end
90
257
 
91
258
  def alive?
92
259
  @health > 0
93
260
  end
94
261
 
262
+ def classname
263
+ "player"
264
+ end
265
+
266
+ def can_fire_current_weapon?
267
+ _weapon, fire = effective_fire_definition
268
+ return false unless fire
269
+
270
+ ammo_type = fire[:ammo_type]
271
+ ammo_type.nil? || @ammo[ammo_type] >= fire[:ammo_cost]
272
+ end
273
+
274
+ def fire_current_weapon(water_level: 0, deathmatch: 0)
275
+ weapon, fire = effective_fire_definition
276
+ return nil unless fire
277
+ unless can_fire_current_weapon?
278
+ @current_weapon = best_weapon(water_level: water_level)
279
+ return nil
280
+ end
281
+
282
+ ammo_type = fire[:ammo_type]
283
+ @ammo[ammo_type] -= fire[:ammo_cost] if ammo_type && deathmatch.to_i != 4
284
+
285
+ damage = fire[:damage]
286
+ damage = 75 if weapon == :axe && deathmatch.to_i > 3
287
+
288
+ FireResult.new(
289
+ weapon: weapon,
290
+ sound: fire[:sound],
291
+ pellets: fire[:pellets],
292
+ range: fire[:range],
293
+ spread_x: fire[:spread_x],
294
+ spread_y: fire[:spread_y],
295
+ damage: damage,
296
+ damage_variance: fire[:damage_variance],
297
+ radius_damage: fire[:radius_damage],
298
+ kick: fire[:kick],
299
+ animation_frames: fire[:animation_frames],
300
+ kind: fire[:kind]
301
+ )
302
+ end
303
+
304
+ def best_weapon(water_level: 0)
305
+ return :lightning_gun if water_level <= 1 && @weapons_owned.include?(:lightning_gun) && @ammo[:cells] >= 1
306
+ return :super_nailgun if @weapons_owned.include?(:super_nailgun) && @ammo[:nails] >= 2
307
+ return :super_shotgun if @weapons_owned.include?(:super_shotgun) && @ammo[:shells] >= 2
308
+ return :nailgun if @weapons_owned.include?(:nailgun) && @ammo[:nails] >= 1
309
+ return :shotgun if @weapons_owned.include?(:shotgun) && @ammo[:shells] >= 1
310
+
311
+ :axe
312
+ end
313
+
314
+ def switch_to_best_weapon(water_level: 0)
315
+ @current_weapon = best_weapon(water_level: water_level)
316
+ end
317
+
318
+ def current_weapon_cooldown
319
+ WEAPON_FIRE.dig(@current_weapon, :cooldown) || 0.0
320
+ end
321
+
95
322
  # Max ammo capacities (from Quake defs.qc)
96
323
  MAX_HEALTH = 100
97
324
  MAX_MEGA_HEALTH = 250
@@ -100,58 +327,328 @@ module Quake
100
327
  MAX_NAILS = 200
101
328
  MAX_ROCKETS = 100
102
329
  MAX_CELLS = 100
330
+ DEAD_NO = 0
331
+ DEAD_DYING = 1
332
+ DEAD_DEAD = 2
333
+ DEAD_RESPAWNABLE = 3
334
+ DEFAULT_VIEW_OFFSET = Math::Vec3.new(0.0, 0.0, 22.0)
335
+ DEATH_VIEW_OFFSET = Math::Vec3.new(0.0, 0.0, -8.0)
336
+ GIB_VIEW_OFFSET = Math::Vec3.new(0.0, 0.0, 8.0)
337
+
338
+ ARMOR_SAVE = {
339
+ 0 => 0.0,
340
+ 1 => 0.3,
341
+ 2 => 0.6,
342
+ 3 => 0.8,
343
+ 0.0 => 0.0,
344
+ 0.3 => 0.3,
345
+ 0.6 => 0.6,
346
+ 0.8 => 0.8
347
+ }.freeze
103
348
 
104
349
  AMMO_CAPS = {
105
350
  shells: MAX_SHELLS, nails: MAX_NAILS,
106
351
  rockets: MAX_ROCKETS, cells: MAX_CELLS
107
352
  }.freeze
108
353
 
354
+ # QuakeC items.qc RankForWeapon: lower ranks are preferred on pickup.
355
+ WEAPON_PICKUP_RANK = {
356
+ lightning_gun: 1,
357
+ rocket_launcher: 2,
358
+ super_nailgun: 3,
359
+ grenade_launcher: 4,
360
+ super_shotgun: 5,
361
+ nailgun: 6
362
+ }.freeze
363
+
109
364
  # Add health, returns true if picked up
110
- def add_health(amount, mega: false)
365
+ def add_health(amount, mega: false, game_time: nil)
111
366
  max = mega ? MAX_MEGA_HEALTH : MAX_HEALTH
367
+ return false if @health <= 0
112
368
  return false if @health >= max
113
369
 
370
+ amount = amount.to_f.ceil
114
371
  @health = [@health + amount, max].min
372
+ @megahealth_rot_time = game_time.to_f + 5.0 if mega && game_time
115
373
  true
116
374
  end
117
375
 
376
+ def update_megahealth(game_time)
377
+ return unless @megahealth_rot_time
378
+ if @health <= MAX_HEALTH
379
+ @megahealth_rot_time = nil
380
+ return
381
+ end
382
+ return if game_time < @megahealth_rot_time
383
+
384
+ ticks = ((game_time - @megahealth_rot_time).floor + 1).to_i
385
+ @health = [@health - ticks, MAX_HEALTH].max
386
+ @megahealth_rot_time += ticks
387
+ @megahealth_rot_time = nil if @health <= MAX_HEALTH
388
+ end
389
+
118
390
  # Add armor, returns true if picked up
119
391
  def add_armor(points, type)
120
- # Only pick up if it's better than current armor
121
- # type: 1=green(30%), 2=yellow(60%), 3=red(80%)
122
- return false if type < @armor_type && @armor > 0
392
+ return false unless alive?
393
+
394
+ # QuakeC armor_touch compares armortype * armorvalue against
395
+ # incoming type * value, where armortype is the save fraction.
396
+ current_protection = ARMOR_SAVE.fetch(@armor_type, 0.0) * @armor
397
+ incoming_protection = ARMOR_SAVE.fetch(type, 0.0) * points
398
+ return false if current_protection >= incoming_protection
123
399
 
124
400
  @armor = [points, MAX_ARMOR].min
125
401
  @armor_type = type
126
402
  true
127
403
  end
128
404
 
405
+ def add_powerup(powerup, game_time:, finish_time: nil)
406
+ return false unless alive?
407
+
408
+ duration = POWERUP_DURATIONS.fetch(powerup)
409
+ @powerup_finished[powerup] = finish_time&.to_f || game_time.to_f + duration
410
+ @powerup_warning_time[powerup] = 1.0
411
+ true
412
+ end
413
+
414
+ def add_key(key)
415
+ return false unless alive?
416
+ return false if @keys_owned.include?(key)
417
+
418
+ @keys_owned.add(key)
419
+ true
420
+ end
421
+
422
+ def has_key?(key)
423
+ @keys_owned.include?(key)
424
+ end
425
+
426
+ def consume_key(key)
427
+ @keys_owned.delete?(key)
428
+ end
429
+
430
+ def add_sigil(flags)
431
+ return false unless alive?
432
+
433
+ sigil_flags = flags.to_i & 15
434
+ @serverflags |= sigil_flags
435
+ true
436
+ end
437
+
438
+ def advance_serverflags_command
439
+ @serverflags = (@serverflags * 2) + 1
440
+ end
441
+
442
+ def powerup_active?(powerup, game_time:)
443
+ (@powerup_finished[powerup] || 0.0) > game_time.to_f
444
+ end
445
+
446
+ def powerup_item_active?(powerup, game_time:)
447
+ finished = @powerup_finished[powerup]
448
+ finished && finished >= game_time.to_f
449
+ end
450
+
451
+ def invulnerable_to_damage?(game_time:)
452
+ finished = @powerup_finished[:pentagram]
453
+ finished && finished >= game_time.to_f
454
+ end
455
+
456
+ def update_powerups(game_time)
457
+ now = game_time.to_f
458
+ update_powerup_effects(now)
459
+ @powerup_finished.delete_if do |powerup, finished|
460
+ expired = finished < now
461
+ @powerup_warning_time.delete(powerup) if expired
462
+ expired
463
+ end
464
+ end
465
+
466
+ def update_powerup_effects(game_time)
467
+ @effects &= ~POWERUP_EFFECT_BITS
468
+
469
+ if powerup_active?(:pentagram, game_time: game_time)
470
+ @effects |= EF_DIMLIGHT
471
+ @effects |= EF_RED
472
+ end
473
+
474
+ if powerup_active?(:quad, game_time: game_time)
475
+ @effects |= EF_DIMLIGHT
476
+ @effects |= EF_BLUE
477
+ end
478
+ end
479
+
480
+ def clear_powerups_on_death
481
+ clear_temporary_powerups
482
+ end
483
+
484
+ def apply_changelevel_parms
485
+ if @health <= 0
486
+ reset_to_new_parms
487
+ return
488
+ end
489
+
490
+ @keys_owned.clear
491
+ clear_temporary_powerups
492
+ @megahealth_rot_time = nil
493
+ @health = [[@health, 50].max, 100].min
494
+ @ammo[:shells] = 25 if @ammo[:shells] < 25
495
+ end
496
+
497
+ def reset_spawn_runtime_fields
498
+ @flags = FL_CLIENT
499
+ @deathtype = ""
500
+ @deadflag = DEAD_NO
501
+ @view_offset = DEFAULT_VIEW_OFFSET
502
+ @weapon_model = nil
503
+ @face_anim_time = 0.0
504
+ @show_hostile = 0.0
505
+ @weapon_gettime.clear
506
+ clear_temporary_powerups
507
+ end
508
+
509
+ def reset_to_new_parms
510
+ @health = 100
511
+ @armor = 0
512
+ @armor_type = 0
513
+ @current_weapon = :shotgun
514
+ @weapons_owned = Set.new([:axe, :shotgun])
515
+ @keys_owned.clear
516
+ reset_spawn_runtime_fields
517
+ @ammo = { shells: 25, nails: 0, rockets: 0, cells: 0 }
518
+ @megahealth_rot_time = nil
519
+ end
520
+
521
+ def clear_temporary_powerups
522
+ @powerup_finished.clear
523
+ @powerup_warning_time.clear
524
+ @effects &= ~POWERUP_EFFECT_BITS
525
+ end
526
+
527
+ def take_damage(amount, game_time: nil)
528
+ amount = amount.to_f
529
+ save = (ARMOR_SAVE.fetch(@armor_type, 0.0) * amount).ceil
530
+ if save >= @armor
531
+ save = @armor
532
+ @armor_type = 0
533
+ end
534
+
535
+ @armor -= save
536
+ return { take: 0, save: save } if (@flags & FL_GODMODE) != 0
537
+ if game_time && invulnerable_to_damage?(game_time: game_time)
538
+ return { take: 0, save: save }
539
+ end
540
+
541
+ take = (amount - save).ceil
542
+ @health -= take
543
+ @health = -99 if @health < -99
544
+ { take: take, save: save }
545
+ end
546
+
547
+ def effective_fire_definition
548
+ if @current_weapon == :super_shotgun && @ammo[:shells] == 1
549
+ return [:shotgun, WEAPON_FIRE[:shotgun]]
550
+ end
551
+
552
+ if @current_weapon == :super_nailgun && @ammo[:nails] == 1
553
+ return [:nailgun, WEAPON_FIRE[:nailgun]]
554
+ end
555
+
556
+ [@current_weapon, WEAPON_FIRE[@current_weapon]]
557
+ end
558
+
559
+ def selectable_weapon?(weapon)
560
+ return false unless @weapons_owned.include?(weapon)
561
+
562
+ fire = WEAPON_FIRE[weapon]
563
+ return false unless fire
564
+
565
+ ammo_type = fire[:ammo_type]
566
+ ammo_type.nil? || @ammo[ammo_type] >= fire[:ammo_cost]
567
+ end
568
+
129
569
  # Add ammo, returns true if picked up
130
- def add_ammo(type, amount)
570
+ def add_ammo(type, amount, auto_switch: false, water_level: 0)
571
+ return false unless alive?
572
+
131
573
  cap = AMMO_CAPS[type]
132
574
  return false unless cap
133
575
  return false if @ammo[type] >= cap
134
576
 
577
+ old_best = best_weapon(water_level: water_level) if auto_switch
135
578
  @ammo[type] = [@ammo[type] + amount, cap].min
579
+ @current_weapon = best_weapon(water_level: water_level) if auto_switch && @current_weapon == old_best
136
580
  true
137
581
  end
138
582
 
139
- # Give weapon, returns true if picked up (always true for new weapons)
140
- def give_weapon(weapon, ammo_type: nil, ammo_amount: 0)
583
+ def add_backpack(ammo:, weapon: nil, water_level: 0, game_time: nil, b_switch: nil)
584
+ return false unless alive?
585
+
586
+ ammo.each do |type, amount|
587
+ add_ammo(type, amount) if amount.positive?
588
+ end
589
+
590
+ if weapon
591
+ current = @current_weapon
592
+ give_weapon(weapon, game_time: game_time)
593
+ @current_weapon = current unless backpack_weapon_switch_allowed?(weapon, water_level, b_switch)
594
+ end
595
+ true
596
+ end
597
+
598
+ def backpack_weapon_switch_allowed?(weapon, water_level, b_switch)
599
+ return false if weapon == :lightning_gun && water_level.positive?
600
+
601
+ switch_limit = b_switch.to_i
602
+ switch_limit = 8 if switch_limit.zero?
603
+ weapon_code(weapon) <= switch_limit
604
+ end
605
+
606
+ def weapon_code(weapon)
607
+ case weapon
608
+ when :super_shotgun then 3
609
+ when :nailgun then 4
610
+ when :super_nailgun then 5
611
+ when :grenade_launcher then 6
612
+ when :rocket_launcher then 7
613
+ when :lightning_gun then 8
614
+ else 1
615
+ end
616
+ end
617
+
618
+ # Give weapon, returns true if Quake weapon_touch accepts the pickup.
619
+ def give_weapon(weapon, ammo_type: nil, ammo_amount: 0, water_level: 0, game_time: nil, w_switch: nil)
620
+ return false unless alive?
621
+
141
622
  had_weapon = @weapons_owned.include?(weapon)
142
623
  @weapons_owned.add(weapon)
624
+ record_weapon_gettime(weapon, game_time) unless had_weapon
143
625
 
144
- ammo_picked = false
145
- if ammo_type && ammo_amount > 0
146
- ammo_picked = add_ammo(ammo_type, ammo_amount)
147
- end
626
+ add_ammo(ammo_type, ammo_amount) if ammo_type && ammo_amount > 0
148
627
 
149
- if !had_weapon
628
+ if weapon_pickup_rank(weapon) < weapon_pickup_rank(@current_weapon) &&
629
+ weapon_switch_allowed?(weapon, water_level, w_switch)
150
630
  @current_weapon = weapon
151
- true
152
- else
153
- ammo_picked
154
631
  end
632
+ true
633
+ end
634
+
635
+ def weapon_switch_allowed?(weapon, water_level, w_switch)
636
+ return false if weapon == :lightning_gun && water_level.positive?
637
+
638
+ switch_limit = w_switch.to_i
639
+ switch_limit = 8 if switch_limit.zero?
640
+ weapon_code(weapon) <= switch_limit
641
+ end
642
+
643
+ def weapon_pickup_rank(weapon)
644
+ WEAPON_PICKUP_RANK.fetch(weapon, 7)
645
+ end
646
+
647
+ def record_weapon_gettime(weapon, game_time)
648
+ index = WEAPON_ITEM_INDEX[weapon]
649
+ return unless index && game_time
650
+
651
+ @weapon_gettime[index] = game_time.to_f
155
652
  end
156
653
  end
157
654
  end