doom 0.6.0 → 0.8.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.
@@ -48,6 +48,7 @@ module Doom
48
48
  attr_accessor :is_moving
49
49
  attr_accessor :dead, :death_tic
50
50
  attr_accessor :damage_count # Red flash intensity (0-8), decays each tic
51
+ attr_accessor :god_mode, :infinite_ammo
51
52
 
52
53
  # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
53
54
  VIEWHEIGHT = 41.0
@@ -114,6 +115,10 @@ module Doom
114
115
  @death_tic = 0
115
116
  @damage_count = 0
116
117
 
118
+ # Cheats
119
+ @god_mode = false
120
+ @infinite_ammo = false
121
+
117
122
  # Weapon bob
118
123
  @bob_angle = 0.0
119
124
  @bob_amount = 0.0
@@ -168,6 +173,7 @@ module Doom
168
173
 
169
174
  def can_attack?
170
175
  return true if @weapon == WEAPON_FIST || @weapon == WEAPON_CHAINSAW
176
+ return true if @infinite_ammo
171
177
 
172
178
  ammo = current_ammo
173
179
  ammo && ammo > 0
@@ -181,7 +187,9 @@ module Doom
181
187
  @attack_frame = 0
182
188
  @attack_tics = 0
183
189
 
184
- # Consume ammo
190
+ # Consume ammo (skipped with infinite ammo)
191
+ return if @infinite_ammo
192
+
185
193
  case @weapon
186
194
  when WEAPON_PISTOL
187
195
  @ammo_bullets -= 1 if @ammo_bullets > 0
@@ -319,6 +327,7 @@ module Doom
319
327
  # Apply damage (from environment or enemies). Armor absorbs some.
320
328
  def take_damage(amount)
321
329
  return if @dead
330
+ return if @god_mode
322
331
 
323
332
  absorbed = 0
324
333
  if @armor > 0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Doom
4
6
  module Game
5
7
  # Manages animated sector actions (doors, lifts, etc.)
@@ -13,13 +15,30 @@ module Doom
13
15
  # Door speeds (units per tic, 35 tics/sec)
14
16
  DOOR_SPEED = 2
15
17
  DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
16
- PLAYER_HEIGHT = 56 # Player height for door collision
18
+ PLAYER_HEIGHT = 56
19
+
20
+ # Lift constants
21
+ LIFT_SPEED = 4
22
+ LIFT_WAIT = 105 # ~3 seconds
23
+
24
+ attr_reader :exit_triggered, :secrets_found
17
25
 
18
- def initialize(map)
26
+ def pop_teleport
27
+ dest = @teleport_dest
28
+ @teleport_dest = nil
29
+ dest
30
+ end
31
+
32
+ def initialize(map, sound_engine = nil)
19
33
  @map = map
20
- @active_doors = {} # sector_index => door_state
34
+ @sound = sound_engine
35
+ @active_doors = {} # sector_index => door_state
36
+ @active_lifts = {} # sector_index => lift_state
21
37
  @player_x = 0
22
38
  @player_y = 0
39
+ @exit_triggered = nil
40
+ @secrets_found = {} # sector_index => true
41
+ @crossed_linedefs = {}
23
42
  end
24
43
 
25
44
  def update_player_position(x, y)
@@ -29,6 +48,9 @@ module Doom
29
48
 
30
49
  def update
31
50
  update_doors
51
+ update_lifts
52
+ check_walk_triggers
53
+ check_secrets
32
54
  end
33
55
 
34
56
  # Try to use a linedef (called when player presses use key)
@@ -36,28 +58,156 @@ module Doom
36
58
  return false if linedef.special == 0
37
59
 
38
60
  case linedef.special
39
- when 1 # DR Door Open Wait Close
61
+ # --- Doors ---
62
+ when 1 # DR Door Open Wait Close
40
63
  activate_door(linedef)
41
- true
42
- when 31 # D1 Door Open Stay
43
- activate_door(linedef, stay_open: true)
44
- true
45
- when 26 # DR Blue Door
64
+ when 26 # DR Blue Door
46
65
  activate_door(linedef, key: :blue_card)
47
- true
48
- when 27 # DR Yellow Door
66
+ when 27 # DR Yellow Door
49
67
  activate_door(linedef, key: :yellow_card)
50
- true
51
- when 28 # DR Red Door
68
+ when 28 # DR Red Door
52
69
  activate_door(linedef, key: :red_card)
53
- true
70
+ when 31 # D1 Door Open Stay
71
+ activate_door(linedef, stay_open: true)
72
+ when 32 # D1 Blue Door Open Stay
73
+ activate_door(linedef, key: :blue_card, stay_open: true)
74
+ when 33 # D1 Red Door Open Stay
75
+ activate_door(linedef, key: :red_card, stay_open: true)
76
+ when 34 # D1 Yellow Door Open Stay
77
+ activate_door(linedef, key: :yellow_card, stay_open: true)
78
+ when 103 # S1 Door Open Wait Close (tagged)
79
+ activate_tagged_door(linedef)
80
+
81
+ # --- Lifts ---
82
+ when 62 # SR Lift Lower Wait Raise (repeatable)
83
+ activate_lift(linedef)
84
+
85
+ # --- Floor changes ---
86
+ when 18 # S1 Raise Floor to Next Higher
87
+ raise_floor_to_next(linedef)
88
+ when 20 # S1 Raise Floor to Next Higher (platform)
89
+ raise_floor_to_next(linedef)
90
+ when 22 # W1 Raise Floor to Next Higher
91
+ raise_floor_to_next(linedef)
92
+ when 23 # S1 Lower Floor to Lowest
93
+ lower_floor_to_lowest(linedef)
94
+ when 36 # S1 Lower Floor to Highest Adjacent - 8
95
+ lower_floor_to_highest(linedef)
96
+ when 70 # SR Lower Floor to Highest Adjacent - 8
97
+ lower_floor_to_highest(linedef)
98
+
99
+ # --- Exits ---
100
+ when 11 # S1 Exit
101
+ @exit_triggered = :normal
102
+ when 51 # S1 Secret Exit
103
+ @exit_triggered = :secret
104
+
54
105
  else
55
- false
106
+ return false
56
107
  end
108
+ true
57
109
  end
58
110
 
59
111
  private
60
112
 
113
+ # Walk-over trigger types:
114
+ # W1 = once, WR = repeatable
115
+ WALK_TRIGGERS = {
116
+ 2 => :door_open_stay, # W1 Door Open Stay
117
+ 5 => :raise_floor, # W1 Raise Floor to Lowest Ceiling
118
+ 7 => :stairs, # S1 Build Stairs
119
+ 8 => :stairs, # W1 Build Stairs
120
+ 52 => :exit, # W1 Exit
121
+ 82 => :lower_floor, # WR Lower Floor to Lowest
122
+ 86 => :door_open_stay, # WR Door Open Stay
123
+ 88 => :lift, # WR Lift Lower Wait Raise
124
+ 90 => :door, # WR Door Open Wait Close
125
+ 91 => :raise_floor, # WR Raise Floor to Lowest Ceiling
126
+ 97 => :teleport, # WR Teleport
127
+ 98 => :lower_floor, # WR Lower Floor to Highest - 8
128
+ 124 => :secret_exit, # W1 Secret Exit
129
+ }.freeze
130
+
131
+ # W1 types that only trigger once
132
+ W1_TYPES = [2, 5, 7, 8, 52, 124].freeze
133
+
134
+ def check_walk_triggers
135
+ @near_linedefs ||= {}
136
+
137
+ @map.linedefs.each_with_index do |ld, idx|
138
+ next if ld.special == 0
139
+ action = WALK_TRIGGERS[ld.special]
140
+ next unless action
141
+
142
+ # W1 types only trigger once
143
+ if W1_TYPES.include?(ld.special)
144
+ next if @crossed_linedefs[idx]
145
+ end
146
+
147
+ v1 = @map.vertices[ld.v1]
148
+ v2 = @map.vertices[ld.v2]
149
+
150
+ # Determine which side of the linedef the player is on
151
+ # DOOM's P_CrossSpecialLine fires when the player transitions sides
152
+ side = line_side(@player_x, @player_y, v1.x, v1.y, v2.x, v2.y)
153
+ dist = point_line_dist(@player_x, @player_y, v1.x, v1.y, v2.x, v2.y)
154
+
155
+ near = dist < 32 # Detection range
156
+ prev_side = @near_linedefs[idx]
157
+
158
+ if near && prev_side && prev_side != side
159
+ # Player crossed the line - trigger!
160
+ @near_linedefs[idx] = side
161
+ elsif near && prev_side.nil?
162
+ # First time near - record side but don't trigger yet
163
+ @near_linedefs[idx] = side
164
+ next
165
+ elsif !near
166
+ @near_linedefs[idx] = nil
167
+ next
168
+ else
169
+ next # Same side, no crossing
170
+ end
171
+
172
+ @crossed_linedefs[idx] = true
173
+
174
+ case action
175
+ when :exit
176
+ @exit_triggered = :normal
177
+ when :secret_exit
178
+ @exit_triggered = :secret
179
+ when :door_open_stay
180
+ activate_tagged_door(ld, stay_open: true)
181
+ when :door
182
+ activate_tagged_door(ld)
183
+ when :lift
184
+ activate_lift(ld)
185
+ when :raise_floor
186
+ raise_floor_to_next(ld)
187
+ when :lower_floor
188
+ lower_floor_to_highest(ld)
189
+ when :teleport
190
+ teleport_player(ld)
191
+ end
192
+ end
193
+ end
194
+
195
+ # Returns which side of a line a point is on (:front or :back)
196
+ def line_side(px, py, x1, y1, x2, y2)
197
+ cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
198
+ cross >= 0 ? :front : :back
199
+ end
200
+
201
+ def point_line_dist(px, py, x1, y1, x2, y2)
202
+ dx = x2 - x1; dy = y2 - y1
203
+ len_sq = dx * dx + dy * dy
204
+ return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2) if len_sq == 0
205
+ t = ((px - x1) * dx + (py - y1) * dy).to_f / len_sq
206
+ t = [[t, 0.0].max, 1.0].min
207
+ cx = x1 + t * dx; cy = y1 + t * dy
208
+ Math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
209
+ end
210
+
61
211
  def activate_door(linedef, stay_open: false, key: nil)
62
212
  # Find the sector on the back side of the linedef
63
213
  return unless linedef.two_sided?
@@ -92,6 +242,7 @@ module Doom
92
242
  wait_tics: 0,
93
243
  stay_open: stay_open
94
244
  }
245
+ @sound&.door_open
95
246
  end
96
247
 
97
248
  def update_doors
@@ -113,6 +264,7 @@ module Doom
113
264
  door[:wait_tics] -= 1
114
265
  if door[:wait_tics] <= 0
115
266
  door[:state] = DOOR_CLOSING
267
+ @sound&.door_close
116
268
  end
117
269
 
118
270
  when DOOR_CLOSING
@@ -157,6 +309,215 @@ module Doom
157
309
 
158
310
  lowest == Float::INFINITY ? 128 : lowest
159
311
  end
312
+
313
+ # Door activated by tag (for S1/W1/WR tagged doors)
314
+ def activate_tagged_door(linedef, stay_open: false)
315
+ tag = linedef.tag
316
+ return if tag == 0
317
+
318
+ @map.sectors.each_with_index do |sector, idx|
319
+ next unless sector_has_tag?(idx, tag)
320
+ next if @active_doors[idx]
321
+
322
+ target = find_lowest_ceiling_around(idx) - 4
323
+ @active_doors[idx] = {
324
+ sector: sector,
325
+ state: DOOR_OPENING,
326
+ target_height: target,
327
+ original_height: sector.ceiling_height,
328
+ wait_tics: 0,
329
+ stay_open: stay_open,
330
+ }
331
+ end
332
+ @sound&.door_open
333
+ end
334
+
335
+ # Lift: lower floor to lowest adjacent, wait, raise back
336
+ def activate_lift(linedef)
337
+ tag = linedef.tag
338
+ return if tag == 0
339
+
340
+ activated = false
341
+ @map.sectors.each_with_index do |sector, idx|
342
+ next unless sector_has_tag?(idx, tag)
343
+ next if @active_lifts[idx] # Already moving
344
+
345
+ lowest = find_lowest_floor_around(idx)
346
+ @active_lifts[idx] = {
347
+ sector: sector,
348
+ state: :lowering,
349
+ target_low: lowest,
350
+ original_height: sector.floor_height,
351
+ wait_tics: 0,
352
+ }
353
+ activated = true
354
+ end
355
+ @sound&.platform_start if activated
356
+ end
357
+
358
+ def update_lifts
359
+ @active_lifts.each do |idx, lift|
360
+ case lift[:state]
361
+ when :lowering
362
+ lift[:sector].floor_height -= LIFT_SPEED
363
+ if lift[:sector].floor_height <= lift[:target_low]
364
+ lift[:sector].floor_height = lift[:target_low]
365
+ lift[:state] = :waiting
366
+ lift[:wait_tics] = LIFT_WAIT
367
+ @sound&.platform_stop
368
+ end
369
+ when :waiting
370
+ lift[:wait_tics] -= 1
371
+ if lift[:wait_tics] <= 0
372
+ lift[:state] = :raising
373
+ @sound&.platform_start
374
+ end
375
+ when :raising
376
+ lift[:sector].floor_height += LIFT_SPEED
377
+ if lift[:sector].floor_height >= lift[:original_height]
378
+ lift[:sector].floor_height = lift[:original_height]
379
+ @active_lifts.delete(idx)
380
+ @sound&.platform_stop
381
+ end
382
+ end
383
+ end
384
+ end
385
+
386
+ def raise_floor_to_next(linedef)
387
+ tag = linedef.tag
388
+ return if tag == 0
389
+
390
+ @map.sectors.each_with_index do |sector, idx|
391
+ next unless sector_has_tag?(idx, tag)
392
+ target = find_next_higher_floor(idx)
393
+ next if target <= sector.floor_height
394
+
395
+ @active_lifts[idx] = {
396
+ sector: sector,
397
+ state: :raising,
398
+ target_low: sector.floor_height,
399
+ original_height: target,
400
+ wait_tics: 0,
401
+ }
402
+ end
403
+ end
404
+
405
+ def lower_floor_to_lowest(linedef)
406
+ tag = linedef.tag
407
+ return if tag == 0
408
+
409
+ @map.sectors.each_with_index do |sector, idx|
410
+ next unless sector_has_tag?(idx, tag)
411
+ target = find_lowest_floor_around(idx)
412
+ sector.floor_height = target
413
+ end
414
+ end
415
+
416
+ def lower_floor_to_highest(linedef)
417
+ tag = linedef.tag
418
+ return if tag == 0
419
+
420
+ @map.sectors.each_with_index do |sector, idx|
421
+ next unless sector_has_tag?(idx, tag)
422
+ target = find_highest_floor_around(idx) - 8
423
+ sector.floor_height = target if target < sector.floor_height
424
+ end
425
+ end
426
+
427
+ def teleport_player(linedef)
428
+ tag = linedef.tag
429
+ return if tag == 0
430
+
431
+ # Find teleport destination thing (type 14) in tagged sector
432
+ @map.things.each do |thing|
433
+ next unless thing.type == 14 # Teleport destination
434
+ sector = @map.sector_at(thing.x, thing.y)
435
+ next unless sector
436
+ sector_idx = @map.sectors.index(sector)
437
+ next unless sector_has_tag?(sector_idx, tag)
438
+
439
+ @teleport_dest = { x: thing.x, y: thing.y, angle: thing.angle }
440
+ return
441
+ end
442
+ end
443
+
444
+
445
+ def check_secrets
446
+ # Build set of secret sector indices on first call
447
+ @secret_sectors ||= Set.new(
448
+ @map.sectors.each_with_index.filter_map { |s, i| i if s.special == 9 }
449
+ )
450
+ return if @secret_sectors.empty?
451
+
452
+ # Find which sector the player is in via BSP subsector lookup
453
+ subsector = @map.subsector_at(@player_x, @player_y)
454
+ return unless subsector
455
+
456
+ seg = @map.segs[subsector.first_seg]
457
+ return unless seg
458
+
459
+ ld = @map.linedefs[seg.linedef]
460
+ return unless ld
461
+
462
+ sd_idx = seg.direction == 0 ? ld.sidedef_right : ld.sidedef_left
463
+ return if sd_idx == 0xFFFF
464
+
465
+ sector_idx = @map.sidedefs[sd_idx].sector
466
+ return if @secrets_found[sector_idx]
467
+
468
+ if @secret_sectors.include?(sector_idx)
469
+ @secrets_found[sector_idx] = true
470
+ # Clear the special so it doesn't retrigger (matching Chocolate Doom)
471
+ @map.sectors[sector_idx].special = 0
472
+ @secret_sectors.delete(sector_idx)
473
+ end
474
+ end
475
+
476
+ def sector_has_tag?(sector_idx, tag)
477
+ @map.sectors[sector_idx].tag == tag
478
+ end
479
+
480
+ def find_lowest_floor_around(sector_idx)
481
+ lowest = @map.sectors[sector_idx].floor_height
482
+ each_adjacent_sector(sector_idx) do |adj|
483
+ lowest = adj.floor_height if adj.floor_height < lowest
484
+ end
485
+ lowest
486
+ end
487
+
488
+ def find_highest_floor_around(sector_idx)
489
+ highest = -32768
490
+ each_adjacent_sector(sector_idx) do |adj|
491
+ highest = adj.floor_height if adj.floor_height > highest
492
+ end
493
+ highest == -32768 ? @map.sectors[sector_idx].floor_height : highest
494
+ end
495
+
496
+ def find_next_higher_floor(sector_idx)
497
+ current = @map.sectors[sector_idx].floor_height
498
+ best = Float::INFINITY
499
+ each_adjacent_sector(sector_idx) do |adj|
500
+ if adj.floor_height > current && adj.floor_height < best
501
+ best = adj.floor_height
502
+ end
503
+ end
504
+ best == Float::INFINITY ? current : best
505
+ end
506
+
507
+ def each_adjacent_sector(sector_idx)
508
+ @map.linedefs.each do |ld|
509
+ next unless ld.two_sided?
510
+ right = @map.sidedefs[ld.sidedef_right]
511
+ left = @map.sidedefs[ld.sidedef_left] if ld.sidedef_left != 0xFFFF
512
+ next unless left
513
+
514
+ if right.sector == sector_idx
515
+ yield @map.sectors[left.sector]
516
+ elsif left.sector == sector_idx
517
+ yield @map.sectors[right.sector]
518
+ end
519
+ end
520
+ end
160
521
  end
161
522
  end
162
523
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Plays sound effects for game events.
6
+ class SoundEngine
7
+ # Weapon fire sounds
8
+ WEAPON_SOUNDS = {
9
+ PlayerState::WEAPON_FIST => 'DSPUNCH',
10
+ PlayerState::WEAPON_PISTOL => 'DSPISTOL',
11
+ PlayerState::WEAPON_SHOTGUN => 'DSSHOTGN',
12
+ PlayerState::WEAPON_CHAINGUN => 'DSPISTOL',
13
+ PlayerState::WEAPON_ROCKET => 'DSRLAUNC',
14
+ PlayerState::WEAPON_PLASMA => 'DSPLASMA',
15
+ PlayerState::WEAPON_BFG => 'DSBFG',
16
+ PlayerState::WEAPON_CHAINSAW => 'DSSAWIDL',
17
+ }.freeze
18
+
19
+ # Monster see/activation sounds (from mobjinfo seesound)
20
+ MONSTER_SEE = {
21
+ 3004 => 'DSPOSIT1', # Zombieman
22
+ 9 => 'DSSGTSIT', # Shotgun Guy
23
+ 3001 => 'DSBGSIT1', # Imp
24
+ 3002 => 'DSSGTSIT', # Demon
25
+ 58 => 'DSSGTSIT', # Spectre
26
+ 3003 => 'DSBRSSIT', # Baron
27
+ 69 => 'DSBRSSIT', # Hell Knight
28
+ 3005 => 'DSCACSIT', # Cacodemon
29
+ 3006 => 'DSSKLATK', # Lost Soul
30
+ 65 => 'DSPOSIT2', # Heavy Weapon Dude
31
+ 16 => 'DSCYBSIT', # Cyberdemon
32
+ 7 => 'DSSPISIT', # Spider Mastermind
33
+ }.freeze
34
+
35
+ # Monster death sounds (from mobjinfo deathsound)
36
+ MONSTER_DEATH = {
37
+ 3004 => 'DSPODTH1', # Zombieman
38
+ 9 => 'DSSGTDTH', # Shotgun Guy
39
+ 3001 => 'DSBGDTH1', # Imp
40
+ 3002 => 'DSSGTDTH', # Demon
41
+ 58 => 'DSSGTDTH', # Spectre
42
+ 3003 => 'DSBRSDTH', # Baron
43
+ 69 => 'DSBRSDTH', # Hell Knight
44
+ 3005 => 'DSCACDTH', # Cacodemon
45
+ 3006 => 'DSFIRXPL', # Lost Soul
46
+ 65 => 'DSPODTH2', # Heavy Weapon Dude
47
+ 16 => 'DSCYBDTH', # Cyberdemon
48
+ 7 => 'DSSPIDTH', # Spider Mastermind
49
+ }.freeze
50
+
51
+ # Monster pain sounds (from mobjinfo painsound)
52
+ MONSTER_PAIN = {
53
+ 3004 => 'DSPOPAIN', # Zombieman
54
+ 9 => 'DSPOPAIN', # Shotgun Guy
55
+ 3001 => 'DSDMPAIN', # Imp
56
+ 3002 => 'DSDMPAIN', # Demon
57
+ 58 => 'DSDMPAIN', # Spectre
58
+ 3003 => 'DSDMPAIN', # Baron
59
+ 69 => 'DSDMPAIN', # Hell Knight
60
+ 3005 => 'DSDMPAIN', # Cacodemon
61
+ 3006 => 'DSDMPAIN', # Lost Soul
62
+ 65 => 'DSPOPAIN', # Heavy Weapon Dude
63
+ }.freeze
64
+
65
+ # Monster attack sounds (from mobjinfo attacksound)
66
+ MONSTER_ATTACK = {
67
+ 3004 => 'DSPISTOL', # Zombieman: pistol
68
+ 9 => 'DSSHOTGN', # Shotgun Guy: shotgun
69
+ 3001 => 'DSFIRSHT', # Imp: fireball launch
70
+ 3002 => 'DSSGTATK', # Demon: bite
71
+ 58 => 'DSSGTATK', # Spectre: bite
72
+ 3003 => 'DSFIRSHT', # Baron: fireball
73
+ 69 => 'DSFIRSHT', # Hell Knight: fireball
74
+ 3005 => 'DSFIRSHT', # Cacodemon: fireball
75
+ 65 => 'DSSHOTGN', # Heavy Weapon Dude: chaingun burst
76
+ }.freeze
77
+
78
+ def initialize(sound_manager)
79
+ @sounds = sound_manager
80
+ @last_played = {} # Throttle rapid repeats
81
+ end
82
+
83
+ def play(name, volume: 1.0, throttle: 0)
84
+ now = Time.now.to_f
85
+ if throttle > 0
86
+ return if @last_played[name] && (now - @last_played[name]) < throttle
87
+ end
88
+ sample = @sounds[name]
89
+ sample&.play(volume)
90
+ @last_played[name] = now
91
+ end
92
+
93
+ # --- Menu sounds ---
94
+ def menu_move
95
+ play('DSPSTOP', throttle: 0.05)
96
+ end
97
+
98
+ def menu_select
99
+ play('DSPISTOL')
100
+ end
101
+
102
+ def menu_back
103
+ play('DSSWTCHX')
104
+ end
105
+
106
+ # --- Weapon events ---
107
+ def weapon_fire(weapon)
108
+ sound = WEAPON_SOUNDS[weapon]
109
+ play(sound, throttle: 0.05) if sound
110
+ end
111
+
112
+ def shotgun_cock
113
+ play('DSSGCOCK', throttle: 0.3)
114
+ end
115
+
116
+ def chainsaw_hit
117
+ play('DSSAWFUL', throttle: 0.1)
118
+ end
119
+
120
+ # --- Player events ---
121
+ def player_pain
122
+ play('DSPLPAIN', throttle: 0.3)
123
+ end
124
+
125
+ def player_death
126
+ play('DSPLDETH')
127
+ end
128
+
129
+ def item_pickup
130
+ play('DSITEMUP', throttle: 0.1)
131
+ end
132
+
133
+ def weapon_pickup
134
+ play('DSWPNUP')
135
+ end
136
+
137
+ def oof
138
+ play('DSOOF', throttle: 0.3)
139
+ end
140
+
141
+ def noway
142
+ play('DSNOWAY', throttle: 0.3)
143
+ end
144
+
145
+ # --- Door/environment sounds ---
146
+ def door_open
147
+ play('DSDOROPN', throttle: 0.2)
148
+ end
149
+
150
+ def door_close
151
+ play('DSDORCLS', throttle: 0.2)
152
+ end
153
+
154
+ def switch_activate
155
+ play('DSSWTCHN')
156
+ end
157
+
158
+ def platform_start
159
+ play('DSPSTART', throttle: 0.2)
160
+ end
161
+
162
+ def platform_stop
163
+ play('DSPSTOP', throttle: 0.2)
164
+ end
165
+
166
+ # --- Monster sounds ---
167
+ def monster_see(type)
168
+ sound = MONSTER_SEE[type]
169
+ play(sound, throttle: 0.5) if sound
170
+ end
171
+
172
+ def monster_death(type)
173
+ sound = MONSTER_DEATH[type]
174
+ play(sound) if sound
175
+ end
176
+
177
+ def monster_pain(type)
178
+ sound = MONSTER_PAIN[type]
179
+ play(sound, throttle: 0.2) if sound
180
+ end
181
+
182
+ def monster_attack(type)
183
+ sound = MONSTER_ATTACK[type]
184
+ play(sound, throttle: 0.1) if sound
185
+ end
186
+
187
+ # --- Explosions / impacts ---
188
+ def explosion
189
+ play('DSBAREXP')
190
+ end
191
+
192
+ def rocket_explode
193
+ play('DSRXPLOD')
194
+ end
195
+
196
+ def fireball_hit
197
+ play('DSFIRXPL', throttle: 0.1)
198
+ end
199
+ end
200
+ end
201
+ end