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.
@@ -5,42 +5,75 @@ module Quake
5
5
  # Manages brush entity behavior: doors, platforms, buttons, triggers.
6
6
  # Each entity has a state machine and moves between positions.
7
7
  class BrushEntities
8
+ BUTTON_TOUCH_RADIUS = 40.0
8
9
  # Entity states
9
10
  STATE_IDLE = :idle
10
11
  STATE_OPENING = :opening
11
12
  STATE_OPEN = :open
12
13
  STATE_CLOSING = :closing
13
14
  STATE_CLOSED = :closed
15
+ PLAT_LOW_TRIGGER = 1
16
+ FL_FLY = 1
17
+ FL_SWIM = 2
18
+ FL_MONSTER = 32
19
+ FL_ONGROUND = 512
14
20
 
15
- def initialize(entities, level, target_map)
21
+ attr_reader :total_secrets, :found_secrets
22
+
23
+ def initialize(entities, level, target_map, registered: false, sound_events: nil, serverflags: 0, worldtype: 0)
16
24
  @entities = entities
17
25
  @level = level
18
26
  @target_map = target_map
19
- @brush_entities = entities.select(&:brush_entity?)
27
+ @registered = registered
28
+ @sound_events = sound_events
29
+ @serverflags = serverflags.to_i
30
+ @worldtype = worldtype.to_i
31
+ @time = 0.0
32
+ @total_secrets = 0
33
+ @found_secrets = 0
34
+ @brush_entities = entities.select { |ent| ent.brush_entity? || ent.classname == "misc_teleporttrain" }
35
+ @delayed_uses = []
20
36
 
37
+ @entities.each { |ent| init_point_trigger(ent) }
21
38
  # Initialize movement data for each brush entity
22
39
  @brush_entities.each { |ent| init_brush_entity(ent) }
40
+ link_doors
23
41
 
24
42
  puts "Initialized #{@brush_entities.size} brush entities"
25
43
  end
26
44
 
27
45
  # Update all brush entities. Returns entities that need rendering.
28
- def update(dt, player_pos)
46
+ def update(dt, player_pos, player_state: nil)
47
+ @time_step = dt
48
+ @time += dt
49
+ update_delayed_uses(dt)
50
+ @entities.each { |ent| update_scheduled_remove(ent, dt) }
51
+
29
52
  # Save previous positions for platform-riding
30
53
  @brush_entities.each do |ent|
31
54
  ent.instance_variable_set(:@prev_pos, ent.position)
32
55
  end
33
56
 
34
57
  @brush_entities.each do |ent|
58
+ next if ent.instance_variable_get(:@removed)
59
+
60
+ update_trigger_cooldown(ent, dt)
61
+
35
62
  case ent.classname
36
- when "func_door"
37
- update_door(ent, dt, player_pos)
38
- when "func_plat"
39
- update_platform(ent, dt, player_pos)
63
+ when "func_door", "door"
64
+ if secret_door?(ent)
65
+ update_secret_door(ent, player_pos, player_state)
66
+ else
67
+ update_door(ent, dt, player_pos, player_state)
68
+ end
69
+ when "func_plat", "plat"
70
+ update_platform(ent, dt, player_pos, player_state)
40
71
  when "func_button"
41
- update_button(ent, dt, player_pos)
42
- when "func_train"
43
- update_train(ent, dt)
72
+ update_button(ent, dt, player_pos, player_state)
73
+ when "func_train", "train", "misc_teleporttrain"
74
+ update_train(ent, dt, player_pos, player_state)
75
+ when "func_door_secret"
76
+ update_secret_door(ent, player_pos, player_state)
44
77
  end
45
78
  end
46
79
 
@@ -51,9 +84,11 @@ module Quake
51
84
  # position snapped to the platform's top surface (so on_ground stays
52
85
  # true and the player rides smoothly). Returns nil if not on a
53
86
  # moving platform.
87
+ VERTICAL_RIDER_CLASSNAMES = %w[func_plat plat func_door door].freeze
88
+
54
89
  def snap_to_platform(player_pos)
55
90
  @brush_entities.each do |ent|
56
- next unless ent.classname == "func_plat"
91
+ next unless VERTICAL_RIDER_CLASSNAMES.include?(ent.classname)
57
92
  model = @level.models[ent.model_index]
58
93
  next unless model
59
94
 
@@ -61,21 +96,25 @@ module Quake
61
96
  next unless prev
62
97
 
63
98
  ent_delta_z = ent.position.z - prev.z
99
+ # Doors join the rider check only while actually moving
100
+ # vertically (elevators like e1m1's t1 are vertical func_doors --
101
+ # SV_PushMove carries riders for every pusher, not just plats).
102
+ next if ent.classname.include?("door") && ent_delta_z.zero?
64
103
 
65
104
  # Use the OLD platform position for the on_top check, since
66
105
  # player_pos hasn't been updated for this frame's movement yet.
106
+ # The player origin rides 24 above the surface (hull mins.z -24).
67
107
  old_top_z = prev.z + model.maxs.z
68
- next unless player_pos.z >= old_top_z - 8 && player_pos.z <= old_top_z + 24
108
+ next unless player_pos.z >= old_top_z + 16 && player_pos.z <= old_top_z + 48
69
109
  next unless player_pos.x >= ent.position.x + model.mins.x &&
70
110
  player_pos.x <= ent.position.x + model.maxs.x &&
71
111
  player_pos.y >= ent.position.y + model.mins.y &&
72
112
  player_pos.y <= ent.position.y + model.maxs.y
73
113
 
74
- # Snap to the new platform top surface, slightly above so the
75
- # trace-down ground check can detect it cleanly (avoid epsilon
76
- # edge case at exact plane boundary).
77
- new_top_z = ent.position.z + model.maxs.z + 0.5
78
- return Math::Vec3.new(player_pos.x, player_pos.y, new_top_z)
114
+ # Snap the origin to 24 above the new top surface (feet slightly
115
+ # above it so the trace-down ground check detects it cleanly).
116
+ new_top_z = ent.position.z + model.maxs.z
117
+ return Math::Vec3.new(player_pos.x, player_pos.y, new_top_z + 24.5)
79
118
  end
80
119
  nil
81
120
  end
@@ -88,12 +127,18 @@ module Quake
88
127
  end
89
128
 
90
129
  # Check if player touches any trigger entities
91
- def check_triggers(player_pos, player_radius: 16.0)
130
+ def check_triggers(player_pos, player_radius: 16.0, player_forward: nil)
92
131
  @entities.each do |ent|
93
132
  next unless ent.classname.start_with?("trigger_")
94
- next unless ent.brush_entity?
133
+ next unless trigger_model_index(ent)
134
+ next if ent.instance_variable_get(:@removed)
135
+ next if ent.instance_variable_get(:@touch_disabled)
136
+ next if notouch_trigger?(ent)
137
+ next if health_trigger?(ent)
138
+ next unless trigger_facing_allowed?(ent, player_forward)
139
+ next if trigger_cooldown_active?(ent)
95
140
 
96
- model = @level.models[ent.model_index]
141
+ model = @level.models[trigger_model_index(ent)]
97
142
  next unless model
98
143
 
99
144
  # Simple AABB check against player
@@ -107,40 +152,292 @@ module Quake
107
152
  player_pos.y <= origin.y + maxs.y + player_radius &&
108
153
  player_pos.z >= origin.z + mins.z - player_radius &&
109
154
  player_pos.z <= origin.z + maxs.z + player_radius
110
- fire_targets(ent.target) if ent.target
111
155
  yield ent if block_given?
112
156
  end
113
157
  end
114
158
  end
115
159
 
160
+ # QuakeC SUB_UseTargets, limited to local entity state transitions.
161
+ # The engine owns side effects like changelevel and teleport movement.
162
+ def use_targets(source, activator: nil)
163
+ delay = (source["delay"] || "0").to_f
164
+ unless delay.zero?
165
+ @delayed_uses << {
166
+ remaining: delay,
167
+ message: source.message,
168
+ killtarget: source.killtarget,
169
+ target: source.target,
170
+ activator: activator || source
171
+ }
172
+ return
173
+ end
174
+
175
+ fire_use_targets(
176
+ source.killtarget,
177
+ source.target,
178
+ activator: activator || source,
179
+ message: source.message,
180
+ source_noise: source.instance_variable_get(:@noise)
181
+ )
182
+ end
183
+
184
+ def touch_trigger(ent, activator: nil)
185
+ return false if ent.instance_variable_get(:@touch_disabled)
186
+ return false if trigger_cooldown_active?(ent)
187
+
188
+ case ent.classname
189
+ when "trigger_once"
190
+ return false unless activator&.classname == "player"
191
+ return false unless trigger_touch_facing_allowed?(ent, activator)
192
+
193
+ activate_multi_trigger(ent, activator: activator)
194
+ when "trigger_secret"
195
+ return false unless trigger_touch_facing_allowed?(ent, activator)
196
+
197
+ touch_secret_trigger(ent, activator: activator)
198
+ when "trigger_multiple"
199
+ return false unless activator&.classname == "player"
200
+ return false unless trigger_touch_facing_allowed?(ent, activator)
201
+
202
+ activate_multi_trigger(ent, activator: activator)
203
+ when "trigger_onlyregistered"
204
+ touch_onlyregistered(ent, activator: activator)
205
+ when "trigger_monsterjump"
206
+ touch_monsterjump(ent, activator)
207
+ when "trigger_push"
208
+ touch_push(ent, activator)
209
+ when "trigger_hurt"
210
+ touch_hurt(ent, activator)
211
+ else
212
+ false
213
+ end
214
+ end
215
+
216
+ def damage_trigger(ent, amount, activator: nil)
217
+ damage_brush_entity(ent, amount, activator: activator)
218
+ end
219
+
220
+ def damage_brush_entity(ent, amount, activator: nil)
221
+ return damage_button(ent, amount, activator: activator) if ent.classname == "func_button"
222
+ return damage_door(ent, amount, activator: activator) if regular_door?(ent)
223
+ return damage_secret_door(ent, activator: activator) if secret_door?(ent)
224
+ return false unless ["trigger_once", "trigger_multiple", "trigger_secret"].include?(ent.classname)
225
+ return false unless ent.health.positive?
226
+
227
+ ent.instance_variable_set(:@max_health, ent.health) unless ent.instance_variable_get(:@max_health)
228
+ ent.health -= amount.to_f.ceil
229
+ return true if ent.health.positive?
230
+
231
+ ent.health = -99.0 if ent.health < -99.0
232
+ activate_multi_trigger(ent, activator: activator || ent)
233
+ true
234
+ end
235
+
116
236
  private
117
237
 
238
+ def activate_multi_trigger(ent, activator:)
239
+ return false if multi_trigger_waiting?(ent)
240
+ return touch_secret_trigger(ent, activator: activator) if ent.classname == "trigger_secret"
241
+
242
+ ent.instance_variable_set(:@takedamage, 0)
243
+ play_trigger_sound(ent)
244
+ use_targets(ent, activator: activator)
245
+ if ent.wait.negative?
246
+ ent.instance_variable_set(:@touch_disabled, true)
247
+ schedule_remove(ent, 0.1)
248
+ else
249
+ ent.instance_variable_set(:@think, :multi_wait)
250
+ ent.think_time = ent.wait.positive? ? ent.wait : 0.2
251
+ end
252
+ true
253
+ end
254
+
255
+ def init_point_trigger(ent)
256
+ case ent.classname
257
+ when "trigger_relay"
258
+ ent.instance_variable_set(:@use, :SUB_UseTargets)
259
+ when "trigger_counter"
260
+ ent.wait = -1.0
261
+ ent.count = 2 if ent.count.zero?
262
+ ent.instance_variable_set(:@use, :counter_use)
263
+ end
264
+ end
265
+
118
266
  def init_brush_entity(ent)
267
+ if ent.classname == "misc_teleporttrain"
268
+ init_moving_entity(ent, Math::Vec3::ORIGIN)
269
+ setup_teleport_train(ent)
270
+ return
271
+ end
272
+
119
273
  return unless ent.model_index
120
274
 
121
275
  model = @level.models[ent.model_index]
122
276
  return unless model
123
277
 
124
- # Store the closed (original) position
125
- ent.instance_variable_set(:@closed_pos, ent.position)
126
- ent.instance_variable_set(:@open_pos, ent.position)
127
- ent.instance_variable_set(:@move_fraction, 0.0)
278
+ init_moving_entity(ent, model.mins)
279
+ ent.instance_variable_set(:@trigger_model_index, ent.model_index) if init_trigger_class?(ent)
280
+ ent.instance_variable_set(:@max_health, ent.health) if health_trigger?(ent)
128
281
 
129
282
  case ent.classname
283
+ when "trigger_teleport"
284
+ setup_teleport_trigger(ent)
285
+ when "trigger_changelevel"
286
+ setup_changelevel_trigger(ent)
287
+ when "trigger_hurt"
288
+ setup_hurt_trigger(ent)
289
+ when "trigger_onlyregistered"
290
+ setup_onlyregistered_trigger(ent)
291
+ when "trigger_setskill"
292
+ remove_entity(ent)
293
+ when "trigger_once", "trigger_multiple"
294
+ setup_multi_trigger(ent)
295
+ when "trigger_secret"
296
+ setup_secret_trigger(ent)
297
+ when "trigger_push"
298
+ setup_push_trigger(ent)
299
+ when "trigger_monsterjump"
300
+ setup_monsterjump_trigger(ent)
130
301
  when "func_door"
131
302
  setup_door(ent, model)
303
+ when "func_door_secret"
304
+ setup_secret_door(ent, model)
132
305
  when "func_plat"
133
306
  setup_platform(ent, model)
134
307
  when "func_button"
135
308
  setup_button(ent, model)
309
+ when "func_train"
310
+ setup_train(ent, model)
311
+ when "func_wall", "func_illusionary"
312
+ setup_static_brush_model(ent)
313
+ when "func_episodegate"
314
+ setup_episode_gate(ent)
315
+ when "func_bossgate"
316
+ setup_boss_gate(ent)
136
317
  end
137
318
  end
138
319
 
320
+ def setup_teleport_trigger(ent)
321
+ setup_init_trigger(ent)
322
+ ent.instance_variable_set(:@touch, :teleport_touch)
323
+ ent.instance_variable_set(:@use, :teleport_use)
324
+ raise "no target" if ent.target.nil? || ent.target.empty?
325
+ end
326
+
327
+ def setup_changelevel_trigger(ent)
328
+ setup_init_trigger(ent)
329
+ ent.instance_variable_set(:@touch, :changelevel_touch)
330
+ raise "chagnelevel trigger doesn't have map" if ent["map"].nil? || ent["map"].empty?
331
+ end
332
+
333
+ def setup_hurt_trigger(ent)
334
+ setup_init_trigger(ent)
335
+ ent.instance_variable_set(:@touch, :hurt_touch)
336
+ ent.properties["dmg"] = "5" if ent["dmg"].nil? || ent["dmg"].to_f.zero?
337
+ end
338
+
339
+ def setup_onlyregistered_trigger(ent)
340
+ setup_init_trigger(ent)
341
+ ent.instance_variable_set(:@touch, :trigger_onlyregistered_touch)
342
+ ent.instance_variable_set(:@noise, "misc/talk.wav")
343
+ end
344
+
345
+ def init_moving_entity(ent, mins)
346
+ ent.instance_variable_set(:@closed_pos, ent.position)
347
+ ent.instance_variable_set(:@open_pos, ent.position)
348
+ ent.instance_variable_set(:@move_fraction, 0.0)
349
+ ent.instance_variable_set(:@train_mins, mins)
350
+ end
351
+
139
352
  DOOR_START_OPEN = 1
353
+ DOOR_DONT_LINK = 4
354
+ DOOR_GOLD_KEY = 8
355
+ DOOR_SILVER_KEY = 16
356
+ DOOR_TOGGLE = 32
357
+ SECRET_NO_SHOOT = 8
358
+ SECRET_YES_SHOOT = 16
359
+
360
+ def setup_secret_trigger(ent)
361
+ @total_secrets += 1
362
+ ent.wait = -1.0
363
+ ent.message ||= "You found a secret area!"
364
+ ent.sounds = 1 if ent.sounds.zero?
365
+ case ent.sounds
366
+ when 1
367
+ ent.instance_variable_set(:@noise, "misc/secret.wav")
368
+ when 2
369
+ ent.instance_variable_set(:@noise, "misc/talk.wav")
370
+ end
371
+ setup_multi_trigger(ent)
372
+ end
373
+
374
+ def setup_multi_trigger(ent)
375
+ setup_init_trigger(ent)
376
+ ent.instance_variable_set(:@use, :multi_use)
377
+ if ent.health.positive? && (ent.spawnflags & 1) != 0
378
+ raise "health and notouch don't make sense"
379
+ end
380
+
381
+ case ent.sounds
382
+ when 1
383
+ ent.instance_variable_set(:@noise, "misc/secret.wav")
384
+ when 2
385
+ ent.instance_variable_set(:@noise, "misc/talk.wav")
386
+ when 3
387
+ ent.instance_variable_set(:@noise, "misc/trigger1.wav")
388
+ end
389
+
390
+ unless ent.health.positive?
391
+ ent.instance_variable_set(:@touch, :multi_touch) if (ent.spawnflags & 1).zero?
392
+ return
393
+ end
394
+
395
+ ent.instance_variable_set(:@takedamage, 1)
396
+ ent.instance_variable_set(:@solid, 2)
397
+ ent.instance_variable_set(:@max_health, ent.health)
398
+ ent.instance_variable_set(:@th_die, :multi_killed)
399
+ end
400
+
401
+ def setup_push_trigger(ent)
402
+ setup_init_trigger(ent)
403
+ ent.instance_variable_set(:@touch, :trigger_push_touch)
404
+ ent.speed = 1000.0 if ent.speed.zero?
405
+ end
406
+
407
+ def setup_monsterjump_trigger(ent)
408
+ ent.speed = 200.0 if ent.speed.zero?
409
+ ent.height = 200.0 if ent.height.zero?
410
+ if ent.angles == Math::Vec3::ORIGIN && (!ent["angle"] || ent["angle"].to_f.zero?)
411
+ ent.angles = Math::Vec3.new(0.0, 360.0, 0.0)
412
+ end
413
+ setup_init_trigger(ent)
414
+ ent.instance_variable_set(:@touch, :trigger_monsterjump_touch)
415
+ end
416
+
417
+ def setup_init_trigger(ent)
418
+ if ent.angles != Math::Vec3::ORIGIN
419
+ ent.move_dir = ent.forward_vector
420
+ ent.angles = Math::Vec3::ORIGIN
421
+ end
422
+ ent.instance_variable_set(:@solid, 1)
423
+ ent.instance_variable_set(:@movetype, 0)
424
+ ent.properties["model"] = ""
425
+ ent.model_index = nil
426
+ end
140
427
 
141
428
  def setup_door(ent, model)
142
- # Calculate move direction from angle
429
+ setup_bsp_push(ent)
430
+ ent.instance_variable_set(:@blocked, :door_blocked)
431
+ ent.instance_variable_set(:@use, :door_use)
432
+ ent.instance_variable_set(:@touch, :door_touch)
433
+ ent.instance_variable_set(:@think, :LinkDoors)
434
+ ent.instance_variable_set(:@nextthink, @time + 0.1)
435
+ ent.properties["dmg"] = "2" if ent["dmg"].nil? || ent["dmg"].to_f.zero?
436
+ # QuakeC SetMovedir consumes the authored angle and clears it.
143
437
  dir = ent.forward_vector
438
+ ent.move_dir = dir
439
+ ent.angle = 0.0
440
+ ent.angles = Math::Vec3::ORIGIN
144
441
  size = model.maxs - model.mins
145
442
  move_dist = dir.x.abs * size.x + dir.y.abs * size.y + dir.z.abs * size.z
146
443
  move_dist -= ent.lip
@@ -159,9 +456,106 @@ module Quake
159
456
  ent.instance_variable_set(:@closed_pos, pos1)
160
457
  ent.instance_variable_set(:@open_pos, pos2)
161
458
  end
459
+ ent.instance_variable_set(:@max_health, ent.health) if ent.health.positive?
460
+ if ent.health.positive?
461
+ ent.instance_variable_set(:@takedamage, 1)
462
+ ent.instance_variable_set(:@th_die, :door_killed)
463
+ end
464
+ ent.instance_variable_set(:@regular_door, true)
465
+ apply_door_noise(ent)
466
+ apply_key_door_noise(ent)
467
+ ent.classname = "door"
468
+
469
+ if (spawnflags & DOOR_SILVER_KEY) != 0
470
+ ent.instance_variable_set(:@required_key, :silver)
471
+ ent.wait = -1.0
472
+ end
473
+ if (spawnflags & DOOR_GOLD_KEY) != 0
474
+ ent.instance_variable_set(:@required_key, :gold)
475
+ ent.wait = -1.0
476
+ end
477
+ end
478
+
479
+ def apply_door_noise(ent)
480
+ noise1, noise2 = case ent.sounds
481
+ when 1
482
+ ["doors/drclos4.wav", "doors/doormv1.wav"]
483
+ when 2
484
+ ["doors/hydro2.wav", "doors/hydro1.wav"]
485
+ when 3
486
+ ["doors/stndr2.wav", "doors/stndr1.wav"]
487
+ when 4
488
+ ["doors/ddoor2.wav", "doors/ddoor1.wav"]
489
+ else
490
+ ["misc/null.wav", "misc/null.wav"]
491
+ end
492
+ ent.instance_variable_set(:@noise1, noise1)
493
+ ent.instance_variable_set(:@noise2, noise2)
494
+ end
495
+
496
+ def apply_key_door_noise(ent)
497
+ noise3, noise4 = case @worldtype
498
+ when 1
499
+ ["doors/runetry.wav", "doors/runeuse.wav"]
500
+ when 2
501
+ ["doors/basetry.wav", "doors/baseuse.wav"]
502
+ else
503
+ ["doors/medtry.wav", "doors/meduse.wav"]
504
+ end
505
+ ent.instance_variable_set(:@noise3, noise3)
506
+ ent.instance_variable_set(:@noise4, noise4)
507
+ end
508
+
509
+ def setup_secret_door(ent, model)
510
+ setup_bsp_push(ent)
511
+ ent.sounds = 3 if ent.sounds.zero?
512
+ apply_secret_door_noise(ent)
513
+ ent.speed = 50.0
514
+ ent.wait = 5.0 unless ent["wait"] && ent.wait.nonzero?
515
+ ent.properties["dmg"] = "2" if ent["dmg"].nil? || ent["dmg"].to_f.zero?
516
+ ent.instance_variable_set(:@dmg, ent["dmg"].to_f)
517
+ ent.instance_variable_set(:@oldorigin, ent.position)
518
+ ent.instance_variable_set(:@mangle, ent.angles)
519
+ ent.angles = Math::Vec3::ORIGIN
520
+ ent.instance_variable_set(:@touch, :secret_touch)
521
+ ent.instance_variable_set(:@blocked, :secret_blocked)
522
+ ent.instance_variable_set(:@use, :fd_secret_use)
523
+ ent.instance_variable_set(:@secret_door, true)
524
+ ent.instance_variable_set(:@secret_size, model.maxs - model.mins)
525
+ ent.classname = "door"
526
+ return unless secret_door_shootable?(ent)
527
+
528
+ ent.health = 10_000.0
529
+ ent.instance_variable_set(:@max_health, 10_000.0)
530
+ ent.instance_variable_set(:@takedamage, 1)
531
+ ent.instance_variable_set(:@th_pain, :fd_secret_use)
532
+ ent.instance_variable_set(:@th_die, :fd_secret_use)
533
+ end
534
+
535
+ def apply_secret_door_noise(ent)
536
+ noise1, noise2, noise3 = case ent.sounds
537
+ when 1
538
+ ["doors/latch2.wav", "doors/winch2.wav", "doors/drclos4.wav"]
539
+ when 2
540
+ ["doors/airdoor2.wav", "doors/airdoor1.wav", "doors/airdoor2.wav"]
541
+ else
542
+ ["doors/basesec2.wav", "doors/basesec1.wav", "doors/basesec2.wav"]
543
+ end
544
+ ent.instance_variable_set(:@noise1, noise1)
545
+ ent.instance_variable_set(:@noise2, noise2)
546
+ ent.instance_variable_set(:@noise3, noise3)
162
547
  end
163
548
 
164
549
  def setup_platform(ent, model)
550
+ setup_bsp_push(ent)
551
+ ent.sounds = 2 if ent.sounds.zero?
552
+ apply_platform_noise(ent)
553
+ ent.wait = 3.0
554
+ ent.instance_variable_set(:@mangle, ent.angles)
555
+ ent.angles = Math::Vec3::ORIGIN
556
+ ent.instance_variable_set(:@blocked, :plat_crush)
557
+ ent.classname = "plat"
558
+
165
559
  # In Quake, the map origin is the TOP position.
166
560
  # See plats.qc func_plat:
167
561
  # pos1 = top (authored origin)
@@ -170,7 +564,7 @@ module Quake
170
564
  # the player steps on them. Platforms WITH a targetname start at
171
565
  # the top and go down when triggered.
172
566
  size = model.maxs - model.mins
173
- height = size.z - 8.0
567
+ height = ent["height"].to_f.positive? ? ent["height"].to_f : (size.z - 8.0)
174
568
 
175
569
  top_pos = ent.position
176
570
  bottom_pos = Math::Vec3.new(top_pos.x, top_pos.y, top_pos.z - height)
@@ -179,108 +573,413 @@ module Quake
179
573
  # Targeted: start at top, button press lowers it
180
574
  ent.instance_variable_set(:@closed_pos, top_pos)
181
575
  ent.instance_variable_set(:@open_pos, bottom_pos)
576
+ ent.instance_variable_set(:@targeted_platform, true)
577
+ ent.instance_variable_set(:@use, :plat_use)
182
578
  else
183
579
  # Normal: start at bottom, player steps on to ride up
184
580
  ent.position = bottom_pos
185
581
  ent.instance_variable_set(:@closed_pos, bottom_pos)
186
582
  ent.instance_variable_set(:@open_pos, top_pos)
583
+ ent.instance_variable_set(:@use, :plat_trigger_use)
187
584
  end
188
585
  end
189
586
 
587
+ def apply_platform_noise(ent)
588
+ noise, noise1 = case ent.sounds
589
+ when 1
590
+ ["plats/plat1.wav", "plats/plat2.wav"]
591
+ else
592
+ ["plats/medplat1.wav", "plats/medplat2.wav"]
593
+ end
594
+ ent.instance_variable_set(:@noise, noise)
595
+ ent.instance_variable_set(:@noise1, noise1)
596
+ end
597
+
190
598
  def setup_button(ent, model)
599
+ setup_bsp_push(ent)
600
+ ent.instance_variable_set(:@blocked, :button_blocked)
601
+ ent.instance_variable_set(:@use, :button_use)
191
602
  dir = ent.forward_vector
603
+ ent.move_dir = dir
604
+ ent.angle = 0.0
605
+ ent.angles = Math::Vec3::ORIGIN
192
606
  size = model.maxs - model.mins
193
607
  move_dist = dir.x.abs * size.x + dir.y.abs * size.y + dir.z.abs * size.z
194
608
  move_dist -= ent.lip
195
609
 
196
610
  ent.instance_variable_set(:@open_pos, ent.position + dir * move_dist)
611
+ ent.instance_variable_set(:@max_health, ent.health) if ent.health.positive?
612
+ if ent.health.positive?
613
+ ent.instance_variable_set(:@takedamage, 1)
614
+ ent.instance_variable_set(:@th_die, :button_killed)
615
+ else
616
+ ent.instance_variable_set(:@touch, :button_touch)
617
+ end
618
+ ent.instance_variable_set(:@noise, button_noise(ent.sounds))
619
+ end
620
+
621
+ def setup_train(ent, model)
622
+ raise "func_train without a target" if ent.target.nil? || ent.target.empty?
623
+
624
+ setup_bsp_push(ent)
625
+ ent.instance_variable_set(:@cnt, 1)
626
+ ent.instance_variable_set(:@blocked, :train_blocked)
627
+ ent.instance_variable_set(:@use, :train_use)
628
+ ent.properties["dmg"] = "2" if ent["dmg"].nil? || ent["dmg"].to_f.zero?
629
+ apply_train_noise(ent)
630
+ ent.instance_variable_set(:@train_mins, model.mins)
631
+ ent.classname = "train"
632
+ ent.think_time = 0.1
633
+ ent.instance_variable_set(:@think, :func_train_find)
634
+ end
635
+
636
+ def setup_teleport_train(ent)
637
+ raise "func_train without a target" if ent.target.nil? || ent.target.empty?
638
+
639
+ ent.instance_variable_set(:@solid, 0)
640
+ ent.instance_variable_set(:@movetype, 7)
641
+ ent.instance_variable_set(:@cnt, 1)
642
+ ent.instance_variable_set(:@blocked, :train_blocked)
643
+ ent.instance_variable_set(:@use, :train_use)
644
+ ent.instance_variable_set(:@noise, "misc/null.wav")
645
+ ent.instance_variable_set(:@noise1, "misc/null.wav")
646
+ ent.instance_variable_set(:@avelocity, Math::Vec3.new(100.0, 200.0, 300.0))
647
+ ent.properties["model"] = "progs/teleport.mdl"
648
+ ent.instance_variable_set(:@train_mins, Math::Vec3::ORIGIN)
649
+ ent.think_time = 0.1
650
+ ent.instance_variable_set(:@think, :func_train_find)
651
+ end
652
+
653
+ def link_doors
654
+ doors = @brush_entities.select { |ent| regular_door?(ent) }
655
+ doors.each do |door|
656
+ next if door.instance_variable_get(:@enemy)
657
+
658
+ group = collect_linked_door_chain(door, doors)
659
+ group.each_with_index do |member, index|
660
+ member.instance_variable_set(:@door_group, group)
661
+ member.instance_variable_set(:@owner, group.first)
662
+ member.instance_variable_set(:@enemy, group[(index + 1) % group.length])
663
+ end
664
+ apply_linked_door_owner_fields(group)
665
+ end
666
+ end
667
+
668
+ def apply_linked_door_owner_fields(group)
669
+ owner = group.first
670
+ group.each do |member|
671
+ owner.health = member.health if member.health.positive?
672
+ owner.targetname = member.targetname if member.targetname
673
+ owner.message = member.message if member.message && !member.message.empty?
674
+ end
675
+ end
676
+
677
+ def collect_linked_door_chain(door, doors)
678
+ return [door] if (door.spawnflags & DOOR_DONT_LINK) != 0
679
+
680
+ group = [door]
681
+ current = door
682
+ loop do
683
+ candidate = doors[(doors.index(current) + 1)..]&.find do |later|
684
+ next false unless door_bounds_touch?(current, later)
685
+ raise "cross connected doors" if later.instance_variable_get(:@enemy)
686
+
687
+ true
688
+ end
689
+
690
+ unless candidate
691
+ current.instance_variable_set(:@enemy, door)
692
+ break
693
+ end
694
+
695
+ current.instance_variable_set(:@enemy, candidate)
696
+ group << candidate
697
+ current = candidate
698
+ end
699
+
700
+ group
701
+ end
702
+
703
+ def door_bounds_touch?(left, right)
704
+ left_model = @level.models[left.model_index]
705
+ right_model = @level.models[right.model_index]
706
+ return false unless left_model && right_model
707
+
708
+ left_mins = left.position + left_model.mins
709
+ left_maxs = left.position + left_model.maxs
710
+ right_mins = right.position + right_model.mins
711
+ right_maxs = right.position + right_model.maxs
712
+
713
+ left_mins.x <= right_maxs.x && left_maxs.x >= right_mins.x &&
714
+ left_mins.y <= right_maxs.y && left_maxs.y >= right_mins.y &&
715
+ left_mins.z <= right_maxs.z && left_maxs.z >= right_mins.z
716
+ end
717
+
718
+ def apply_train_noise(ent)
719
+ noise, noise1 = if ent.sounds == 1
720
+ ["plats/train2.wav", "plats/train1.wav"]
721
+ else
722
+ ["misc/null.wav", "misc/null.wav"]
723
+ end
724
+ ent.instance_variable_set(:@noise, noise)
725
+ ent.instance_variable_set(:@noise1, noise1)
726
+ end
727
+
728
+ def setup_bsp_push(ent)
729
+ ent.instance_variable_set(:@solid, 4)
730
+ ent.instance_variable_set(:@movetype, 7)
197
731
  end
198
732
 
199
- def update_door(ent, dt, player_pos)
733
+ def button_noise(sounds)
734
+ case sounds
735
+ when 1 then "buttons/switch21.wav"
736
+ when 2 then "buttons/switch02.wav"
737
+ when 3 then "buttons/switch04.wav"
738
+ else "buttons/airbut1.wav"
739
+ end
740
+ end
741
+
742
+ def setup_episode_gate(ent)
743
+ if (@serverflags & ent.spawnflags).zero?
744
+ setup_inactive_gate(ent)
745
+ else
746
+ setup_static_brush_model(ent)
747
+ end
748
+ end
749
+
750
+ def setup_boss_gate(ent)
751
+ if (@serverflags & 15) == 15
752
+ setup_inactive_gate(ent)
753
+ else
754
+ setup_static_brush_model(ent)
755
+ end
756
+ end
757
+
758
+ def setup_inactive_gate(ent)
759
+ ent.model_index = nil
760
+ ent.instance_variable_set(:@movetype, 0)
761
+ ent.instance_variable_set(:@solid, 0)
762
+ end
763
+
764
+ def setup_static_brush_model(ent)
765
+ ent.angle = 0.0
766
+ ent.angles = Math::Vec3::ORIGIN
767
+ if ent.classname == "func_illusionary"
768
+ ent.instance_variable_set(:@movetype, 0)
769
+ ent.instance_variable_set(:@solid, 0)
770
+ ent.instance_variable_set(:@static, true)
771
+ else
772
+ ent.instance_variable_set(:@movetype, 7)
773
+ ent.instance_variable_set(:@solid, 4)
774
+ ent.instance_variable_set(:@use, :func_wall_use)
775
+ end
776
+ end
777
+
778
+ def update_door(ent, dt, player_pos, player_state)
779
+ if toggle_door?(ent) && ent.instance_variable_get(:@triggered) &&
780
+ [STATE_OPENING, STATE_OPEN].include?(ent.state)
781
+ ent.instance_variable_set(:@triggered, false)
782
+ ent.instance_variable_set(:@trigger_activator, nil)
783
+ ent.state = STATE_CLOSING
784
+ ent.instance_variable_set(:@move_fraction, 0.0)
785
+ elsif toggle_door?(ent) && ent.instance_variable_get(:@triggered) && ent.state == STATE_CLOSING
786
+ activator = ent.instance_variable_get(:@trigger_activator) || player_state || ent
787
+ ent.instance_variable_set(:@triggered, false)
788
+ ent.instance_variable_set(:@trigger_activator, nil)
789
+ ent.state = STATE_OPENING
790
+ use_door_targets(ent, activator: activator)
791
+ clear_linked_door_messages(ent)
792
+ ent.instance_variable_set(:@move_fraction, 0.0)
793
+ end
794
+
200
795
  case ent.state
201
796
  when STATE_IDLE, STATE_CLOSED
202
- # Check if player is near or if targeted
203
- if near_entity?(ent, player_pos, 128.0) || ent.instance_variable_get(:@triggered)
204
- ent.state = STATE_OPENING
205
- ent.instance_variable_set(:@triggered, false)
797
+ # Check if player is touching the door, its generated trigger field, or if targeted.
798
+ door_touch = door_model_touch?(ent, player_pos)
799
+ trigger_touch = door_trigger_field_touch?(ent, player_pos)
800
+ keyed_door = linked_door_keyed?(ent)
801
+ key_touch = keyed_door && door_touch
802
+ triggered = ent.instance_variable_get(:@triggered)
803
+ door_touch_allowed = !door_touch || door_touch_allowed?(ent, player_state)
804
+ trigger_touch_allowed = !trigger_touch || door_trigger_touch_allowed?(ent)
805
+ touch_door_message(ent, player_state) if door_touch && door_touch_allowed
806
+ touch_opens = if key_touch
807
+ door_touch_allowed && unlock_door?(ent, player_state)
808
+ elsif trigger_touch
809
+ trigger_touch_allowed && unlock_door?(ent, player_state)
810
+ else
811
+ false
812
+ end
813
+ if triggered || (touch_activated_door?(ent) && touch_opens)
814
+ activator = ent.instance_variable_get(:@trigger_activator) || player_state || ent
815
+ use_door(ent, activator: activator)
206
816
  end
207
817
  when STATE_OPENING
818
+ if ent.instance_variable_get(:@activated_at_time) == @time
819
+ ent.instance_variable_set(:@activated_at_time, nil)
820
+ return
821
+ end
822
+
208
823
  move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
824
+ door_blocked(ent, player_pos, player_state)
209
825
  if ent.instance_variable_get(:@move_fraction) >= 1.0
210
826
  ent.state = STATE_OPEN
211
827
  ent.think_time = ent.wait
828
+ play_brush_noise(ent, :@noise1)
212
829
  end
213
830
  when STATE_OPEN
831
+ if ent.instance_variable_get(:@triggered) && !toggle_door?(ent)
832
+ ent.instance_variable_set(:@triggered, false)
833
+ ent.instance_variable_set(:@trigger_activator, nil)
834
+ ent.think_time = ent.wait
835
+ end
214
836
  ent.think_time -= dt
215
- if ent.think_time <= 0 && ent.wait >= 0
837
+ if ent.think_time <= 0 && ent.wait >= 0 && !toggle_door?(ent)
838
+ ent.health = ent.instance_variable_get(:@max_health) if shootable_door?(ent)
839
+ ent.instance_variable_set(:@shot_disabled, false)
840
+ ent.instance_variable_set(:@takedamage, 1) if shootable_door?(ent)
841
+ play_brush_noise(ent, :@noise2)
216
842
  ent.state = STATE_CLOSING
217
843
  end
218
844
  when STATE_CLOSING
845
+ if ent.instance_variable_get(:@activated_at_time) == @time
846
+ ent.instance_variable_set(:@activated_at_time, nil)
847
+ return
848
+ end
849
+
219
850
  move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
851
+ door_blocked(ent, player_pos, player_state)
220
852
  if ent.instance_variable_get(:@move_fraction) >= 1.0
221
853
  ent.state = STATE_CLOSED
854
+ play_brush_noise(ent, :@noise1)
222
855
  end
223
856
  end
224
857
  end
225
858
 
226
- def update_platform(ent, dt, player_pos)
859
+ def update_platform(ent, dt, player_pos, player_state = nil)
227
860
  case ent.state
228
- when STATE_IDLE
861
+ when STATE_IDLE, STATE_CLOSED
229
862
  model = @level.models[ent.model_index]
230
863
  triggered = ent.instance_variable_get(:@triggered)
231
- on_top = model && on_top_of?(ent, model, player_pos)
864
+ center_touch = model && platform_center_touch_actor?(player_state) &&
865
+ platform_center_trigger_touch?(ent, model, player_pos)
866
+ targeted_waiting_for_first_use = ent.targetname && ent.instance_variable_get(:@targeted_platform)
232
867
 
233
868
  # Targeted platforms only react to button triggers.
234
- # Normal platforms also activate when player steps on them.
235
- should_move = if ent.targetname
869
+ # Once lowered, they behave like normal platforms again.
870
+ should_move = if targeted_waiting_for_first_use
871
+ if triggered && ent.instance_variable_get(:@platform_used)
872
+ raise "plat_use: not in up state"
873
+ end
236
874
  triggered
237
875
  else
238
- triggered || on_top
876
+ triggered || center_touch
239
877
  end
240
878
 
241
879
  if should_move
880
+ ent.instance_variable_set(:@platform_used, true) if ent.instance_variable_get(:@targeted_platform)
881
+ play_brush_noise(ent, :@noise)
242
882
  ent.state = STATE_OPENING
243
883
  ent.instance_variable_set(:@triggered, false)
244
884
  end
245
885
  when STATE_OPENING
886
+ if ent.instance_variable_get(:@activated_at_time) == @time
887
+ ent.instance_variable_set(:@activated_at_time, nil)
888
+ return
889
+ end
890
+
246
891
  move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
892
+ platform_blocked(ent, player_pos, player_state)
247
893
  if ent.instance_variable_get(:@move_fraction) >= 1.0
248
- ent.state = STATE_OPEN
249
- ent.think_time = ent.wait
894
+ if ent.instance_variable_get(:@targeted_platform)
895
+ enable_normal_platform_operation(ent)
896
+ ent.state = STATE_CLOSED
897
+ ent.think_time = 0.0
898
+ else
899
+ ent.state = STATE_OPEN
900
+ ent.think_time = ent.wait
901
+ end
902
+ play_brush_noise(ent, :@noise1)
250
903
  end
251
904
  when STATE_OPEN
252
- ent.think_time -= dt
905
+ model = @level.models[ent.model_index]
906
+ if model && on_top_of?(ent, model, player_pos)
907
+ ent.think_time = 1.0
908
+ else
909
+ ent.think_time -= dt
910
+ end
253
911
  if ent.think_time <= 0
912
+ play_brush_noise(ent, :@noise)
254
913
  ent.state = STATE_CLOSING
255
914
  end
256
915
  when STATE_CLOSING
916
+ if ent.instance_variable_get(:@activated_at_time) == @time
917
+ ent.instance_variable_set(:@activated_at_time, nil)
918
+ return
919
+ end
920
+
257
921
  move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
922
+ platform_blocked(ent, player_pos, player_state)
258
923
  if ent.instance_variable_get(:@move_fraction) >= 1.0
259
- ent.state = STATE_IDLE
924
+ ent.state = STATE_CLOSED
925
+ play_brush_noise(ent, :@noise1)
260
926
  end
261
927
  end
262
928
  end
263
929
 
264
- def update_button(ent, dt, player_pos)
930
+ def enable_normal_platform_operation(ent)
931
+ top_pos = ent.instance_variable_get(:@closed_pos)
932
+ bottom_pos = ent.instance_variable_get(:@open_pos)
933
+ ent.instance_variable_set(:@closed_pos, bottom_pos)
934
+ ent.instance_variable_set(:@open_pos, top_pos)
935
+ ent.instance_variable_set(:@targeted_platform, false)
936
+ end
937
+
938
+ def update_button(ent, dt, player_pos, player_state = nil)
265
939
  case ent.state
266
940
  when STATE_IDLE, STATE_CLOSED
267
- if near_entity?(ent, player_pos, 64.0) || ent.instance_variable_get(:@triggered)
268
- ent.state = STATE_OPENING
941
+ # button_touch fires on brush contact. The expansion is wider than
942
+ # the 16-unit player half-width because this port's wall collision
943
+ # can hold the player a bit off flush surfaces; still much tighter
944
+ # than a center-distance sphere.
945
+ touched = !shootable_button?(ent) && door_model_touch?(ent, player_pos, player_radius: BUTTON_TOUCH_RADIUS)
946
+ if touched || ent.instance_variable_get(:@triggered)
947
+ ent.instance_variable_set(:@button_activator, player_state) if touched && player_state
948
+ fire_button(ent)
269
949
  ent.instance_variable_set(:@triggered, false)
270
- fire_targets(ent.target)
271
950
  end
272
951
  when STATE_OPENING
273
952
  move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
274
953
  if ent.instance_variable_get(:@move_fraction) >= 1.0
275
954
  ent.state = STATE_OPEN
276
955
  ent.think_time = ent.wait
956
+ activator = ent.instance_variable_get(:@button_activator) ||
957
+ ent.instance_variable_get(:@trigger_activator) ||
958
+ ent
959
+ use_targets(ent, activator: activator)
960
+ ent.instance_variable_set(:@button_activator, nil)
961
+ ent.instance_variable_set(:@trigger_activator, nil)
962
+ ent.frame = 1
277
963
  end
278
964
  when STATE_OPEN
279
965
  ent.think_time -= dt
966
+ # QC button_wait schedules the return even for wait -1 (the time is
967
+ # already in the past), so such buttons pop back out immediately
280
968
  if ent.think_time <= 0
281
969
  ent.state = STATE_CLOSING
970
+ ent.frame = 0
971
+ if shootable_button?(ent)
972
+ ent.instance_variable_set(:@shot_disabled, false)
973
+ ent.instance_variable_set(:@takedamage, 1)
974
+ end
282
975
  end
283
976
  when STATE_CLOSING
977
+ if !shootable_button?(ent) && door_model_touch?(ent, player_pos, player_radius: BUTTON_TOUCH_RADIUS)
978
+ ent.instance_variable_set(:@button_activator, player_state) if player_state
979
+ fire_button(ent)
980
+ ent.instance_variable_set(:@move_fraction, 0.0)
981
+ return
982
+ end
284
983
  move_toward(ent, ent.instance_variable_get(:@closed_pos), dt)
285
984
  if ent.instance_variable_get(:@move_fraction) >= 1.0
286
985
  ent.state = STATE_CLOSED
@@ -288,40 +987,143 @@ module Quake
288
987
  end
289
988
  end
290
989
 
291
- def update_train(ent, dt)
292
- # Trains follow path_corner entities - basic movement only
990
+ def update_train(ent, dt, player_pos, player_state = nil)
991
+ cooldown = ent.instance_variable_get(:@blocked_cooldown).to_f
992
+ ent.instance_variable_set(:@blocked_cooldown, [cooldown - dt, 0.0].max) if cooldown.positive?
993
+
994
+ return if update_train_start_think(ent, dt)
995
+
996
+ if ent.state == STATE_IDLE && ent.instance_variable_get(:@triggered)
997
+ ent.instance_variable_set(:@triggered, false)
998
+ start_train_next(ent)
999
+ end
1000
+
1001
+ if ent.state == STATE_OPEN
1002
+ ent.think_time -= dt
1003
+ start_train_next(ent) if ent.think_time <= 0
1004
+ return
1005
+ end
1006
+
293
1007
  return unless ent.state == STATE_OPENING
294
1008
 
295
1009
  move_toward(ent, ent.instance_variable_get(:@open_pos), dt)
1010
+ train_blocked(ent, player_pos, player_state)
296
1011
  if ent.instance_variable_get(:@move_fraction) >= 1.0
297
- ent.state = STATE_IDLE
1012
+ ent.state = STATE_OPEN
1013
+ ent.think_time = ent.wait.nonzero? ? ent.wait : 0.1
1014
+ play_brush_noise(ent, :@noise) if ent.wait.nonzero?
1015
+ end
1016
+ end
1017
+
1018
+ def start_train_next(ent)
1019
+ raise "train_next: no next target" if ent.target.nil? || ent.target.empty?
1020
+
1021
+ target = @target_map[ent.target]&.first
1022
+ raise "train_next: no next target" unless target
1023
+
1024
+ ent.target = target.target
1025
+ raise "train_next: no next target" if ent.target.nil? || ent.target.empty?
1026
+
1027
+ ent.wait = target.wait.nonzero? ? target.wait : 0.0
1028
+ mins = ent.instance_variable_get(:@train_mins) || Math::Vec3::ORIGIN
1029
+ ent.instance_variable_set(:@open_pos, target.position - mins)
1030
+ ent.instance_variable_set(:@move_fraction, 0.0)
1031
+ ent.instance_variable_set(:@think, nil)
1032
+ ent.think_time = 0.0
1033
+ play_brush_noise(ent, :@noise1)
1034
+ ent.state = STATE_OPENING
1035
+ end
1036
+
1037
+ def update_train_start_think(ent, dt)
1038
+ think = ent.instance_variable_get(:@think)
1039
+ return false unless %i[func_train_find train_next].include?(think)
1040
+ return false if think == :func_train_find && ent.instance_variable_get(:@train_found)
1041
+
1042
+ ent.think_time -= dt
1043
+ return true if ent.think_time.positive?
1044
+
1045
+ if think == :func_train_find
1046
+ train_find(ent)
1047
+ else
1048
+ start_train_next(ent)
1049
+ end
1050
+ true
1051
+ end
1052
+
1053
+ def train_find(ent)
1054
+ target = @target_map[ent.target]&.first
1055
+ if target
1056
+ mins = ent.instance_variable_get(:@train_mins) || Math::Vec3::ORIGIN
1057
+ ent.position = target.position - mins
1058
+ ent.target = target.target
1059
+ else
1060
+ ent.target = nil
298
1061
  end
1062
+ ent.instance_variable_set(:@train_found, true)
1063
+
1064
+ return if ent.targetname
1065
+
1066
+ ent.think_time = 0.1
1067
+ ent.instance_variable_set(:@think, :train_next)
299
1068
  end
300
1069
 
301
1070
  def move_toward(ent, target, dt)
302
1071
  current = ent.position
303
1072
  diff = target - current
304
1073
  dist = diff.length
1074
+ move_end = ent.instance_variable_get(:@move_end_pos)
305
1075
 
306
- # Already at target
307
1076
  if dist < 0.1
1077
+ unless move_end == target
1078
+ ent.instance_variable_set(:@move_start_pos, current)
1079
+ ent.instance_variable_set(:@move_end_pos, target)
1080
+ ent.instance_variable_set(:@move_elapsed, 0.0)
1081
+ ent.instance_variable_set(:@move_duration, 0.1)
1082
+ end
1083
+ elapsed = ent.instance_variable_get(:@move_elapsed).to_f + dt
1084
+ duration = ent.instance_variable_get(:@move_duration).to_f
1085
+ ent.instance_variable_set(:@move_elapsed, elapsed)
308
1086
  ent.position = target
309
- ent.instance_variable_set(:@move_fraction, 1.0)
1087
+ if elapsed >= duration
1088
+ ent.instance_variable_set(:@move_fraction, 1.0)
1089
+ clear_move_timing(ent)
1090
+ else
1091
+ ent.instance_variable_set(:@move_fraction, 0.0)
1092
+ end
310
1093
  return
311
1094
  end
312
1095
 
313
- move_amount = ent.speed * dt
314
- if move_amount >= dist
315
- # Snap to target
1096
+ unless move_end == target
1097
+ ent.instance_variable_set(:@move_start_pos, current)
1098
+ ent.instance_variable_set(:@move_end_pos, target)
1099
+ ent.instance_variable_set(:@move_elapsed, 0.0)
1100
+ ent.instance_variable_set(:@move_duration, [dist / ent.speed, 0.03].max)
1101
+ end
1102
+
1103
+ elapsed = ent.instance_variable_get(:@move_elapsed).to_f + dt
1104
+ duration = ent.instance_variable_get(:@move_duration).to_f
1105
+ fraction = duration.positive? ? elapsed / duration : 1.0
1106
+ ent.instance_variable_set(:@move_elapsed, elapsed)
1107
+
1108
+ if fraction >= 1.0
316
1109
  ent.position = target
317
1110
  ent.instance_variable_set(:@move_fraction, 1.0)
1111
+ clear_move_timing(ent)
318
1112
  else
319
- # Partial movement, not done yet
320
- ent.position = current + diff * (move_amount / dist)
1113
+ start = ent.instance_variable_get(:@move_start_pos)
1114
+ total_diff = target - start
1115
+ ent.position = start + total_diff * fraction
321
1116
  ent.instance_variable_set(:@move_fraction, 0.0)
322
1117
  end
323
1118
  end
324
1119
 
1120
+ def clear_move_timing(ent)
1121
+ ent.instance_variable_set(:@move_start_pos, nil)
1122
+ ent.instance_variable_set(:@move_end_pos, nil)
1123
+ ent.instance_variable_set(:@move_elapsed, 0.0)
1124
+ ent.instance_variable_set(:@move_duration, 0.0)
1125
+ end
1126
+
325
1127
  def near_entity?(ent, player_pos, radius)
326
1128
  model = @level.models[ent.model_index]
327
1129
  return false unless model
@@ -334,26 +1136,973 @@ module Quake
334
1136
  (dx * dx + dy * dy + dz * dz) < radius * radius
335
1137
  end
336
1138
 
1139
+ def door_trigger_field_touch?(ent, player_pos)
1140
+ return false unless touch_activated_door?(ent)
1141
+ return false if linked_door_keyed?(ent)
1142
+
1143
+ mins, maxs = linked_door_group_bounds(ent)
1144
+ return false unless mins && maxs
1145
+
1146
+ player_pos.x >= mins.x - 60.0 &&
1147
+ player_pos.x <= maxs.x + 60.0 &&
1148
+ player_pos.y >= mins.y - 60.0 &&
1149
+ player_pos.y <= maxs.y + 60.0 &&
1150
+ player_pos.z >= mins.z - 8.0 &&
1151
+ player_pos.z <= maxs.z + 8.0
1152
+ end
1153
+
1154
+ def linked_door_group_bounds(ent)
1155
+ mins = nil
1156
+ maxs = nil
1157
+ linked_door_group(ent).each do |member|
1158
+ model = @level.models[member.model_index]
1159
+ next unless model
1160
+
1161
+ member_mins = member.position + model.mins
1162
+ member_maxs = member.position + model.maxs
1163
+ mins = member_mins if mins.nil?
1164
+ maxs = member_maxs if maxs.nil?
1165
+ mins = Math::Vec3.new([mins.x, member_mins.x].min, [mins.y, member_mins.y].min, [mins.z, member_mins.z].min)
1166
+ maxs = Math::Vec3.new([maxs.x, member_maxs.x].max, [maxs.y, member_maxs.y].max, [maxs.z, member_maxs.z].max)
1167
+ end
1168
+ [mins, maxs]
1169
+ end
1170
+
337
1171
  def on_top_of?(ent, model, player_pos)
338
1172
  # Check if player is standing on top of the platform.
339
1173
  # Tolerance: -8 to +24 units above the surface (player feet within
340
1174
  # one stair step of the platform top).
341
1175
  top_z = ent.position.z + model.maxs.z
342
- player_pos.z >= top_z - 8 && player_pos.z <= top_z + 24 &&
1176
+ player_pos.z >= top_z + 16 && player_pos.z <= top_z + 48 &&
343
1177
  player_pos.x >= ent.position.x + model.mins.x &&
344
1178
  player_pos.x <= ent.position.x + model.maxs.x &&
345
1179
  player_pos.y >= ent.position.y + model.mins.y &&
346
1180
  player_pos.y <= ent.position.y + model.maxs.y
347
1181
  end
348
1182
 
349
- def fire_targets(target_name)
350
- return unless target_name
1183
+ def door_model_touch?(ent, player_pos, player_radius: 16.0)
1184
+ model = @level.models[ent.model_index]
1185
+ return false unless model
351
1186
 
352
- targets = @target_map[target_name]
353
- targets.each do |ent|
354
- ent.instance_variable_set(:@triggered, true)
355
- # Reset movement fraction for re-triggering
356
- ent.instance_variable_set(:@move_fraction, 0.0)
1187
+ player_pos.x >= ent.position.x + model.mins.x - player_radius &&
1188
+ player_pos.x <= ent.position.x + model.maxs.x + player_radius &&
1189
+ player_pos.y >= ent.position.y + model.mins.y - player_radius &&
1190
+ player_pos.y <= ent.position.y + model.maxs.y + player_radius &&
1191
+ player_pos.z >= ent.position.z + model.mins.z - player_radius &&
1192
+ player_pos.z <= ent.position.z + model.maxs.z + player_radius
1193
+ end
1194
+
1195
+ def platform_center_trigger_touch?(ent, model, player_pos)
1196
+ lower_origin_z, upper_origin_z = [
1197
+ ent.instance_variable_get(:@closed_pos).z,
1198
+ ent.instance_variable_get(:@open_pos).z
1199
+ ].minmax
1200
+ bottom_top_z = lower_origin_z + model.maxs.z
1201
+ top_trigger_z = if (ent.spawnflags & PLAT_LOW_TRIGGER) != 0
1202
+ bottom_top_z + 8.0
1203
+ else
1204
+ upper_origin_z + model.maxs.z + 8.0
1205
+ end
1206
+
1207
+ return false unless player_pos.z >= bottom_top_z
1208
+ return false unless player_pos.z <= top_trigger_z
1209
+
1210
+ mins = ent.position + model.mins
1211
+ maxs = ent.position + model.maxs
1212
+ size = model.maxs - model.mins
1213
+ min_x = mins.x + 25.0
1214
+ max_x = maxs.x - 25.0
1215
+ min_y = mins.y + 25.0
1216
+ max_y = maxs.y - 25.0
1217
+
1218
+ if size.x <= 50.0
1219
+ min_x = (mins.x + maxs.x) / 2.0
1220
+ max_x = min_x + 1.0
1221
+ end
1222
+ if size.y <= 50.0
1223
+ min_y = (mins.y + maxs.y) / 2.0
1224
+ max_y = min_y + 1.0
1225
+ end
1226
+
1227
+ player_pos.x >= min_x && player_pos.x <= max_x &&
1228
+ player_pos.y >= min_y && player_pos.y <= max_y
1229
+ end
1230
+
1231
+ def platform_center_touch_actor?(player_state)
1232
+ return true unless player_state
1233
+
1234
+ player_state.classname == "player" && player_state.alive?
1235
+ end
1236
+
1237
+ def door_blocked(ent, player_pos, player_state)
1238
+ return unless player_state&.alive?
1239
+ # A player standing on top rides the pusher (SV_PushMove), same as
1240
+ # for plats -- vertical doors are Quake's elevators.
1241
+ return if riding_on_top?(ent, player_pos)
1242
+ return unless player_blocking_brush?(ent, player_pos)
1243
+
1244
+ damage = (ent["dmg"] || "2").to_f
1245
+ player_state.deathtype = "squish" if player_state.respond_to?(:deathtype=)
1246
+ player_state.take_damage(damage)
1247
+ return if ent.wait.negative?
1248
+
1249
+ ent.state = ent.state == STATE_CLOSING ? STATE_OPENING : STATE_CLOSING
1250
+ play_brush_noise(ent, :@noise2)
1251
+ ent.instance_variable_set(:@move_fraction, 0.0)
1252
+ end
1253
+
1254
+ def platform_blocked(ent, player_pos, player_state)
1255
+ return unless player_state&.alive?
1256
+ # A player standing on top is a rider carried with the pusher
1257
+ # (SV_PushMove), not a blocker. player_pos is pre-carry here, so
1258
+ # judge "on top" against the platform's pre-move position.
1259
+ return if riding_on_top?(ent, player_pos)
1260
+ return unless player_blocking_brush?(ent, player_pos)
1261
+
1262
+ player_state.deathtype = "squish" if player_state.respond_to?(:deathtype=)
1263
+ player_state.take_damage(1)
1264
+ ent.state = ent.state == STATE_CLOSING ? STATE_OPENING : STATE_CLOSING
1265
+ play_brush_noise(ent, :@noise)
1266
+ ent.instance_variable_set(:@move_fraction, 0.0)
1267
+ end
1268
+
1269
+ def riding_on_top?(ent, player_pos)
1270
+ model = @level.models[ent.model_index]
1271
+ return false unless model
1272
+
1273
+ prev = ent.instance_variable_get(:@prev_pos) || ent.position
1274
+ old_top_z = prev.z + model.maxs.z
1275
+ # Rider origins sit ~24 above the surface; a player squeezed from
1276
+ # the side has an origin below the top
1277
+ player_pos.z >= old_top_z + 16 &&
1278
+ player_pos.x >= ent.position.x + model.mins.x &&
1279
+ player_pos.x <= ent.position.x + model.maxs.x &&
1280
+ player_pos.y >= ent.position.y + model.mins.y &&
1281
+ player_pos.y <= ent.position.y + model.maxs.y
1282
+ end
1283
+
1284
+ def train_blocked(ent, player_pos, player_state)
1285
+ return unless player_state&.alive?
1286
+ return unless player_blocking_brush?(ent, player_pos)
1287
+ return if ent.instance_variable_get(:@blocked_cooldown).to_f.positive?
1288
+
1289
+ damage = (ent["dmg"] || "2").to_f
1290
+ player_state.deathtype = "squish" if player_state.respond_to?(:deathtype=)
1291
+ player_state.take_damage(damage)
1292
+ ent.instance_variable_set(:@blocked_cooldown, 0.5)
1293
+ end
1294
+
1295
+ def player_blocking_brush?(ent, player_pos)
1296
+ model = @level.models[ent.model_index]
1297
+ return false unless model
1298
+
1299
+ player_pos.x >= ent.position.x + model.mins.x &&
1300
+ player_pos.x <= ent.position.x + model.maxs.x &&
1301
+ player_pos.y >= ent.position.y + model.mins.y &&
1302
+ player_pos.y <= ent.position.y + model.maxs.y &&
1303
+ player_pos.z >= ent.position.z + model.mins.z &&
1304
+ player_pos.z <= ent.position.z + model.maxs.z
1305
+ end
1306
+
1307
+ def unlock_door?(ent, player_state)
1308
+ required_key = ent.instance_variable_get(:@required_key)
1309
+ return true unless required_key
1310
+ unless player_state&.has_key?(required_key)
1311
+ centerprint_key_required(player_state, required_key)
1312
+ play_brush_noise(ent, :@noise3)
1313
+ return false
1314
+ end
1315
+
1316
+ player_state.consume_key(required_key)
1317
+ disable_key_door_touch(ent)
1318
+ true
1319
+ end
1320
+
1321
+ def disable_key_door_touch(ent)
1322
+ ent.instance_variable_set(:@touch, :sub_null)
1323
+ enemy = ent.instance_variable_get(:@enemy)
1324
+ enemy.instance_variable_set(:@touch, :sub_null) if enemy
1325
+ end
1326
+
1327
+ def door_touch_allowed?(ent, player_state)
1328
+ return true unless player_state&.classname == "player"
1329
+ owner = linked_door_owner(ent)
1330
+ return false if owner.instance_variable_get(:@attack_finished).to_f > @time
1331
+
1332
+ owner.instance_variable_set(:@attack_finished, @time + 2.0)
1333
+ true
1334
+ end
1335
+
1336
+ def door_trigger_touch_allowed?(ent)
1337
+ owner = linked_door_owner(ent)
1338
+ return false if owner.instance_variable_get(:@trigger_attack_finished).to_f > @time
1339
+
1340
+ owner.instance_variable_set(:@trigger_attack_finished, @time + 1.0)
1341
+ true
1342
+ end
1343
+
1344
+ def centerprint_key_required(player_state, required_key)
1345
+ return unless player_state
1346
+
1347
+ player_state.instance_variable_set(:@centerprint, key_required_message(required_key))
1348
+ end
1349
+
1350
+ def touch_door_message(ent, player_state)
1351
+ return unless player_state&.classname == "player"
1352
+ message = linked_door_owner(ent).message
1353
+ return if message.nil? || message.empty?
1354
+ player_state.instance_variable_set(:@centerprint, message)
1355
+ play_default_message_sound
1356
+ end
1357
+
1358
+ def key_required_message(required_key)
1359
+ key_name = case [required_key, @worldtype]
1360
+ when [:silver, 1] then "silver runekey"
1361
+ when [:silver, 2] then "silver keycard"
1362
+ when [:gold, 1] then "gold runekey"
1363
+ when [:gold, 2] then "gold keycard"
1364
+ when [:gold, 0] then "gold key"
1365
+ else "silver key"
1366
+ end
1367
+ "You need the #{key_name}"
1368
+ end
1369
+
1370
+ def update_trigger_cooldown(ent, dt)
1371
+ return unless ["trigger_multiple", "trigger_hurt"].include?(ent.classname)
1372
+ return unless ent.think_time.positive?
1373
+
1374
+ previous = ent.think_time
1375
+ ent.think_time = [ent.think_time - dt, 0.0].max
1376
+ ent.think_time = 0.0 if ent.think_time <= 0.000001
1377
+ return unless previous.positive? && ent.think_time.zero?
1378
+
1379
+ if ent.classname == "trigger_hurt"
1380
+ ent.instance_variable_set(:@touch_disabled, false)
1381
+ ent.instance_variable_set(:@solid, 1)
1382
+ ent.think_time = -1.0
1383
+ end
1384
+ restore_health_trigger(ent)
1385
+ end
1386
+
1387
+ def update_scheduled_remove(ent, dt)
1388
+ return unless ent.instance_variable_get(:@think) == :sub_remove
1389
+ return unless ent.think_time.positive?
1390
+
1391
+ ent.think_time = [ent.think_time - dt, 0.0].max
1392
+ remove_entity(ent) if ent.think_time <= 0.000001
1393
+ end
1394
+
1395
+ def schedule_remove(ent, delay)
1396
+ ent.instance_variable_set(:@think, :sub_remove)
1397
+ ent.think_time = delay
1398
+ end
1399
+
1400
+ def update_delayed_uses(dt)
1401
+ return if @delayed_uses.empty?
1402
+
1403
+ ready, waiting = @delayed_uses.partition do |delayed_use|
1404
+ delayed_use[:remaining] -= dt
1405
+ delayed_use[:remaining] <= 0.000001
1406
+ end
1407
+ @delayed_uses = waiting
1408
+
1409
+ ready.each do |delayed_use|
1410
+ fire_use_targets(
1411
+ delayed_use[:killtarget],
1412
+ delayed_use[:target],
1413
+ activator: delayed_use[:activator],
1414
+ message: delayed_use[:message],
1415
+ source_noise: nil
1416
+ )
1417
+ end
1418
+ end
1419
+
1420
+ def use_door_targets(ent, activator:)
1421
+ delay = (ent["delay"] || "0").to_f
1422
+ unless delay.zero?
1423
+ @delayed_uses << {
1424
+ remaining: delay,
1425
+ message: nil,
1426
+ killtarget: ent.killtarget,
1427
+ target: ent.target,
1428
+ activator: activator,
1429
+ source_noise: nil
1430
+ }
1431
+ return
1432
+ end
1433
+
1434
+ fire_use_targets(ent.killtarget, ent.target, activator: activator, message: nil, source_noise: nil)
1435
+ end
1436
+
1437
+ def touch_onlyregistered(ent, activator:)
1438
+ return false unless activator&.classname == "player"
1439
+ return true if ent.instance_variable_get(:@attack_finished).to_f > @time
1440
+
1441
+ ent.instance_variable_set(:@attack_finished, @time + 2.0)
1442
+ unless @registered
1443
+ unless ent.message.nil? || ent.message.empty?
1444
+ centerprint_use_message(activator, ent.message, source_noise: ent.instance_variable_get(:@noise))
1445
+ play_trigger_sound(ent)
1446
+ end
1447
+ return true
1448
+ end
1449
+
1450
+ ent.message = ""
1451
+ use_targets(ent, activator: activator)
1452
+ remove_entity(ent)
1453
+ true
1454
+ end
1455
+
1456
+ def touch_monsterjump(ent, activator)
1457
+ return false unless activator
1458
+ return false unless (activator.flags & (FL_MONSTER | FL_FLY | FL_SWIM)) == FL_MONSTER
1459
+
1460
+ movedir = trigger_movedir(ent)
1461
+ activator.velocity = Math::Vec3.new(
1462
+ movedir.x * ent.speed,
1463
+ movedir.y * ent.speed,
1464
+ activator.velocity.z
1465
+ )
1466
+
1467
+ return true if (activator.flags & FL_ONGROUND).zero?
1468
+
1469
+ activator.flags -= FL_ONGROUND
1470
+ activator.velocity = Math::Vec3.new(
1471
+ activator.velocity.x,
1472
+ activator.velocity.y,
1473
+ ent.height
1474
+ )
1475
+ true
1476
+ end
1477
+
1478
+ def touch_push(ent, activator)
1479
+ pushed = false
1480
+ if activator&.classname == "grenade" || activator&.health&.positive?
1481
+ activator.velocity = trigger_movedir(ent) * ent.speed * 10.0
1482
+ play_push_player_sound(activator) if activator.classname == "player"
1483
+ pushed = true
1484
+ end
1485
+ remove_entity(ent) if (ent.spawnflags & 1) != 0
1486
+ pushed
1487
+ end
1488
+
1489
+ def play_push_player_sound(activator)
1490
+ return unless activator.instance_variable_get(:@fly_sound).to_f < @time
1491
+
1492
+ activator.instance_variable_set(:@fly_sound, @time + 1.5)
1493
+ @sound_events&.on_trigger_push_player
1494
+ end
1495
+
1496
+ def touch_hurt(ent, activator)
1497
+ return false unless activator_takedamage?(activator)
1498
+
1499
+ ent.instance_variable_set(:@solid, 0)
1500
+ damage_takedamage_activator(activator, (ent["dmg"] || "5").to_f)
1501
+ ent.instance_variable_set(:@think, :hurt_on)
1502
+ ent.instance_variable_set(:@touch_disabled, true)
1503
+ ent.think_time = 1.0
1504
+ true
1505
+ end
1506
+
1507
+ def touch_secret_trigger(ent, activator:)
1508
+ return false unless activator&.classname == "player"
1509
+
1510
+ ent.instance_variable_set(:@takedamage, 0)
1511
+ @found_secrets += 1
1512
+ play_trigger_sound(ent)
1513
+ use_targets(ent, activator: activator)
1514
+ ent.instance_variable_set(:@touch_disabled, true)
1515
+ schedule_remove(ent, 0.1)
1516
+ true
1517
+ end
1518
+
1519
+ def play_trigger_sound(ent)
1520
+ return unless ent.instance_variable_get(:@noise)
1521
+ return unless @sound_events
1522
+
1523
+ if @sound_events.respond_to?(:on_trigger)
1524
+ @sound_events.on_trigger(ent)
1525
+ elsif @sound_events.respond_to?(:on_trigger_secret)
1526
+ @sound_events.on_trigger_secret(ent)
1527
+ end
1528
+ end
1529
+
1530
+ def play_brush_noise(ent, ivar)
1531
+ sound = ent.instance_variable_get(ivar)
1532
+ return if sound.nil? || sound.empty?
1533
+ return if sound == "misc/null.wav"
1534
+ return unless @sound_events&.respond_to?(:on_trigger)
1535
+
1536
+ event = Object.new
1537
+ event.instance_variable_set(:@noise, sound)
1538
+ @sound_events.on_trigger(event)
1539
+ end
1540
+
1541
+ def trigger_cooldown_active?(ent)
1542
+ ["trigger_multiple", "trigger_hurt"].include?(ent.classname) && ent.think_time.positive?
1543
+ end
1544
+
1545
+ def multi_trigger?(ent)
1546
+ ["trigger_once", "trigger_multiple", "trigger_secret"].include?(ent.classname)
1547
+ end
1548
+
1549
+ def multi_trigger_waiting?(ent)
1550
+ return false unless multi_trigger?(ent)
1551
+
1552
+ ent.think_time.positive?
1553
+ end
1554
+
1555
+ def notouch_trigger?(ent)
1556
+ multi_trigger?(ent) && (ent.spawnflags & 1) != 0
1557
+ end
1558
+
1559
+ def trigger_facing_allowed?(ent, player_forward)
1560
+ return true unless multi_trigger?(ent)
1561
+ return true unless player_forward
1562
+ return true unless trigger_has_angle_restriction?(ent)
1563
+
1564
+ player_forward.dot(trigger_movedir(ent)) >= 0.0
1565
+ end
1566
+
1567
+ def trigger_touch_facing_allowed?(ent, activator)
1568
+ trigger_facing_allowed?(ent, activator_forward_vector(activator))
1569
+ end
1570
+
1571
+ def activator_forward_vector(activator)
1572
+ return activator.forward_flat if activator.respond_to?(:forward_flat)
1573
+ return activator.forward_vector if activator.respond_to?(:forward_vector)
1574
+
1575
+ nil
1576
+ end
1577
+
1578
+ def trigger_has_angle_restriction?(ent)
1579
+ return true if ent.move_dir != Math::Vec3::ORIGIN
1580
+ return true if ent["angles"] && ent["angles"].split.map(&:to_f) != [0.0, 0.0, 0.0]
1581
+ return false unless ent["angle"]
1582
+
1583
+ ent["angle"].to_f != 0.0
1584
+ end
1585
+
1586
+ def health_trigger?(ent)
1587
+ multi_trigger?(ent) && ent.health.positive?
1588
+ end
1589
+
1590
+ def restore_health_trigger(ent)
1591
+ return unless ent.classname == "trigger_multiple"
1592
+ max_health = ent.instance_variable_get(:@max_health)
1593
+ return unless max_health
1594
+ return unless ent.health <= 0.0
1595
+
1596
+ ent.health = max_health
1597
+ ent.instance_variable_set(:@takedamage, 1)
1598
+ ent.instance_variable_set(:@solid, 2)
1599
+ end
1600
+
1601
+ def shootable_button?(ent)
1602
+ ent.classname == "func_button" && ent.instance_variable_get(:@max_health).to_f.positive?
1603
+ end
1604
+
1605
+ def shootable_door?(ent)
1606
+ regular_door?(ent) && ent.instance_variable_get(:@max_health).to_f.positive?
1607
+ end
1608
+
1609
+ def toggle_door?(ent)
1610
+ regular_door?(ent) && (ent.spawnflags & DOOR_TOGGLE) != 0
1611
+ end
1612
+
1613
+ def touch_activated_door?(ent)
1614
+ regular_door?(ent) && !linked_door_targetname?(ent) && !linked_door_shootable?(ent)
1615
+ end
1616
+
1617
+ def linked_door_keyed?(ent)
1618
+ linked_door_group(ent).any? { |door| door.instance_variable_get(:@required_key) }
1619
+ end
1620
+
1621
+ def regular_door?(ent)
1622
+ ent.classname == "func_door" || ent.instance_variable_get(:@regular_door)
1623
+ end
1624
+
1625
+ def linked_door_targetname?(ent)
1626
+ linked_door_group(ent).any? { |door| door.targetname }
1627
+ end
1628
+
1629
+ def linked_door_shootable?(ent)
1630
+ linked_door_group(ent).any? { |door| shootable_door?(door) }
1631
+ end
1632
+
1633
+ def linked_door_group(ent)
1634
+ ent.instance_variable_get(:@door_group) || [ent]
1635
+ end
1636
+
1637
+ def linked_door_owner(ent)
1638
+ ent.instance_variable_get(:@owner) || ent
1639
+ end
1640
+
1641
+ def damage_button(ent, amount, activator: nil)
1642
+ return false unless shootable_button?(ent)
1643
+ return true if ent.instance_variable_get(:@shot_disabled)
1644
+
1645
+ ent.health -= amount.to_f.ceil
1646
+ return true if ent.health.positive?
1647
+
1648
+ ent.health = ent.instance_variable_get(:@max_health)
1649
+ ent.instance_variable_set(:@shot_disabled, true)
1650
+ ent.instance_variable_set(:@takedamage, 0)
1651
+ ent.instance_variable_set(:@button_activator, activator) if activator
1652
+ fire_button(ent)
1653
+ ent.instance_variable_set(:@move_fraction, 0.0)
1654
+ true
1655
+ end
1656
+
1657
+ def damage_door(ent, amount, activator: nil)
1658
+ return false unless shootable_door?(ent)
1659
+ return true if ent.instance_variable_get(:@shot_disabled)
1660
+
1661
+ ent.health -= amount.to_f.ceil
1662
+ return true if ent.health.positive?
1663
+
1664
+ ent.health = ent.instance_variable_get(:@max_health)
1665
+ ent.instance_variable_set(:@shot_disabled, true)
1666
+ ent.instance_variable_set(:@takedamage, 0)
1667
+ open_linked_doors_now(ent, activator: activator || ent)
1668
+ true
1669
+ end
1670
+
1671
+ def damage_secret_door(ent, activator:)
1672
+ return false unless secret_door_shootable?(ent)
1673
+ return false if ent.instance_variable_get(:@takedamage).to_i.zero?
1674
+
1675
+ use_secret_door(ent, activator: activator || ent)
1676
+ true
1677
+ end
1678
+
1679
+ def update_secret_door(ent, player_pos, player_state = nil)
1680
+ if ent.instance_variable_get(:@triggered)
1681
+ ent.instance_variable_set(:@triggered, false)
1682
+ use_secret_door(ent, activator: ent)
1683
+ end
1684
+
1685
+ case ent.state
1686
+ when STATE_IDLE, STATE_CLOSED
1687
+ touch_door_message(ent, player_state) if near_entity?(ent, player_pos, 128.0)
1688
+ when STATE_OPENING
1689
+ move_toward(ent, ent.instance_variable_get(:@open_pos), @time_step)
1690
+ secret_door_blocked(ent, player_pos, player_state)
1691
+ advance_secret_door(ent) if ent.instance_variable_get(:@move_fraction) >= 1.0
1692
+ when :secret_pause1
1693
+ ent.think_time -= @time_step
1694
+ start_secret_move(ent, :move2, ent.instance_variable_get(:@secret_dest2)) if ent.think_time <= 0.0
1695
+ when STATE_OPEN
1696
+ return if (ent.spawnflags & 1) != 0
1697
+
1698
+ ent.think_time -= @time_step
1699
+ start_secret_move(ent, :move3, ent.instance_variable_get(:@secret_dest1)) if ent.think_time <= 0.0
1700
+ when :secret_pause2
1701
+ ent.think_time -= @time_step
1702
+ start_secret_move(ent, :move4, ent.instance_variable_get(:@oldorigin)) if ent.think_time <= 0.0
1703
+ end
1704
+ end
1705
+
1706
+ def use_secret_door(ent, activator:)
1707
+ return if ent.position != ent.instance_variable_get(:@oldorigin)
1708
+
1709
+ ent.health = 10_000.0
1710
+ ent.message = nil
1711
+ use_targets(ent, activator: activator)
1712
+ unless (ent.spawnflags & SECRET_NO_SHOOT) != 0
1713
+ ent.instance_variable_set(:@shot_disabled, true)
1714
+ ent.instance_variable_set(:@takedamage, 0)
1715
+ ent.instance_variable_set(:@th_pain, :sub_null)
1716
+ end
1717
+ calculate_secret_door_destinations(ent)
1718
+ play_brush_noise(ent, :@noise1)
1719
+ start_secret_move(ent, :move1, ent.instance_variable_get(:@secret_dest1))
1720
+ end
1721
+
1722
+ def secret_door_blocked(ent, player_pos, player_state)
1723
+ return unless player_state&.alive?
1724
+ return unless player_blocking_brush?(ent, player_pos)
1725
+ return if ent.instance_variable_get(:@attack_finished).to_f > @time
1726
+
1727
+ ent.instance_variable_set(:@attack_finished, @time + 0.5)
1728
+ player_state.deathtype = "squish" if player_state.respond_to?(:deathtype=)
1729
+ player_state.take_damage((ent["dmg"] || "2").to_f)
1730
+ end
1731
+
1732
+ def calculate_secret_door_destinations(ent)
1733
+ return if ent.instance_variable_get(:@secret_dest1)
1734
+
1735
+ forward, right, up = secret_door_vectors(ent)
1736
+ size = ent.instance_variable_get(:@secret_size)
1737
+ width = ent["t_width"]&.to_f
1738
+ width = nil if width&.zero?
1739
+ width ||= if (ent.spawnflags & 4) != 0
1740
+ up.dot(size).abs
1741
+ else
1742
+ right.dot(size).abs
1743
+ end
1744
+ length = ent["t_length"]&.to_f
1745
+ length = nil if length&.zero?
1746
+ length ||= forward.dot(size).abs
1747
+
1748
+ left_sign = (ent.spawnflags & 2) != 0 ? -1.0 : 1.0
1749
+ dest1 = if (ent.spawnflags & 4) != 0
1750
+ ent.position - up * width
1751
+ else
1752
+ ent.position + right * (width * left_sign)
1753
+ end
1754
+ ent.instance_variable_set(:@secret_dest1, dest1)
1755
+ ent.instance_variable_set(:@secret_dest2, dest1 + forward * length)
1756
+ end
1757
+
1758
+ def secret_door_vectors(ent)
1759
+ mangle = ent.instance_variable_get(:@mangle)
1760
+ yaw = if mangle && mangle != Math::Vec3::ORIGIN
1761
+ mangle.y
1762
+ elsif ent.angles != Math::Vec3::ORIGIN
1763
+ ent.angles.y
1764
+ else
1765
+ ent.angle
1766
+ end
1767
+ rad = yaw * ::Math::PI / 180.0
1768
+ forward = Math::Vec3.new(::Math.cos(rad), ::Math.sin(rad), 0.0)
1769
+ right_rad = (yaw - 90.0) * ::Math::PI / 180.0
1770
+ right = Math::Vec3.new(::Math.cos(right_rad), ::Math.sin(right_rad), 0.0)
1771
+ [forward, right, Math::Vec3.new(0.0, 0.0, 1.0)]
1772
+ end
1773
+
1774
+ def start_secret_move(ent, phase, target)
1775
+ ent.instance_variable_set(:@secret_phase, phase)
1776
+ ent.instance_variable_set(:@open_pos, target)
1777
+ ent.instance_variable_set(:@move_fraction, 0.0)
1778
+ play_brush_noise(ent, :@noise2)
1779
+ ent.state = STATE_OPENING
1780
+ end
1781
+
1782
+ def advance_secret_door(ent)
1783
+ case ent.instance_variable_get(:@secret_phase)
1784
+ when :move1
1785
+ ent.state = :secret_pause1
1786
+ ent.think_time = 1.0
1787
+ play_brush_noise(ent, :@noise3)
1788
+ when :move2
1789
+ ent.state = STATE_OPEN
1790
+ ent.think_time = ent.wait
1791
+ play_brush_noise(ent, :@noise3)
1792
+ when :move3
1793
+ ent.state = :secret_pause2
1794
+ ent.think_time = 1.0
1795
+ play_brush_noise(ent, :@noise3)
1796
+ when :move4
1797
+ ent.state = STATE_CLOSED
1798
+ if secret_door_shootable?(ent)
1799
+ ent.health = 10_000.0
1800
+ ent.instance_variable_set(:@takedamage, 1)
1801
+ ent.instance_variable_set(:@th_pain, :fd_secret_use)
1802
+ ent.instance_variable_set(:@th_die, :fd_secret_use)
1803
+ ent.instance_variable_set(:@shot_disabled, false)
1804
+ end
1805
+ play_brush_noise(ent, :@noise3)
1806
+ end
1807
+ end
1808
+
1809
+ def secret_door?(ent)
1810
+ ent.classname == "func_door_secret" || ent.instance_variable_get(:@secret_door)
1811
+ end
1812
+
1813
+ def secret_door_shootable?(ent)
1814
+ !ent.targetname || (ent.spawnflags & SECRET_YES_SHOOT) != 0
1815
+ end
1816
+
1817
+ def remove_killtargets(target_name)
1818
+ @entities.each do |ent|
1819
+ next unless ent.targetname == target_name
1820
+
1821
+ remove_entity(ent)
1822
+ end
1823
+ end
1824
+
1825
+ def remove_entity(ent)
1826
+ ent.instance_variable_set(:@removed, true)
1827
+ ent.instance_variable_set(:@touch_disabled, true)
1828
+ end
1829
+
1830
+ def fire_use_targets(killtarget, target, activator:, message: nil, source_noise: nil)
1831
+ centerprint_use_message(activator, message, source_noise: source_noise)
1832
+ if killtarget && !killtarget.empty?
1833
+ remove_killtargets(killtarget)
1834
+ return
1835
+ end
1836
+ fire_targets(target, activator: activator) if target
1837
+ end
1838
+
1839
+ def centerprint_use_message(activator, message, source_noise: nil)
1840
+ return unless activator.respond_to?(:classname)
1841
+ return unless activator&.classname == "player"
1842
+ return if message.nil? || message.empty?
1843
+
1844
+ activator.instance_variable_set(:@centerprint, message)
1845
+ play_default_message_sound if source_noise.nil? || source_noise.empty?
1846
+ end
1847
+
1848
+ def play_default_message_sound
1849
+ return unless @sound_events&.respond_to?(:on_trigger)
1850
+
1851
+ entity = Object.new
1852
+ entity.instance_variable_set(:@noise, "misc/talk.wav")
1853
+ @sound_events.on_trigger(entity)
1854
+ end
1855
+
1856
+ def fire_targets(target_name, activator:)
1857
+ return unless target_name
1858
+
1859
+ targets = @target_map[target_name]
1860
+ return unless targets
1861
+
1862
+ targets.each do |ent|
1863
+ next if ent.instance_variable_get(:@removed)
1864
+
1865
+ if ent.classname == "trigger_counter"
1866
+ use_counter(ent, activator: activator)
1867
+ next
1868
+ end
1869
+
1870
+ if ent.classname == "trigger_relay"
1871
+ use_targets(ent, activator: activator)
1872
+ next
1873
+ end
1874
+
1875
+ if multi_trigger?(ent)
1876
+ activate_multi_trigger(ent, activator: activator)
1877
+ next
1878
+ end
1879
+
1880
+ if ent.classname == "trigger_teleport"
1881
+ ent.think_time = @time + 0.2
1882
+ ent.instance_variable_set(:@think, :sub_null)
1883
+ next
1884
+ end
1885
+
1886
+ if toggle_frame_bmodel?(ent)
1887
+ ent.frame = 1 - ent.frame
1888
+ next
1889
+ end
1890
+
1891
+ if toggle_light?(ent)
1892
+ toggle_light(ent)
1893
+ next
1894
+ end
1895
+
1896
+ if ent.classname == "func_button"
1897
+ use_button(ent, activator: activator)
1898
+ next
1899
+ end
1900
+
1901
+ if regular_door?(ent)
1902
+ use_door(ent, activator: activator)
1903
+ next
1904
+ end
1905
+
1906
+ if secret_door?(ent)
1907
+ use_secret_door(ent, activator: activator)
1908
+ next
1909
+ end
1910
+
1911
+ if train_entity?(ent)
1912
+ use_train(ent)
1913
+ next
1914
+ end
1915
+
1916
+ if platform_entity?(ent)
1917
+ use_platform(ent)
1918
+ next
1919
+ end
1920
+ end
1921
+ end
1922
+
1923
+ def use_door(ent, activator:)
1924
+ owner = linked_door_owner(ent)
1925
+ play_brush_noise(owner, :@noise4) if owner.instance_variable_get(:@required_key)
1926
+ if toggle_door?(owner) && [STATE_OPENING, STATE_OPEN].include?(owner.state)
1927
+ linked_door_group(owner).each do |member|
1928
+ member.instance_variable_set(:@triggered, false)
1929
+ member.instance_variable_set(:@trigger_activator, nil)
1930
+ member.instance_variable_set(:@move_fraction, 0.0)
1931
+ member.instance_variable_set(:@activated_at_time, @time)
1932
+ play_brush_noise(member, :@noise2)
1933
+ member.state = STATE_CLOSING
1934
+ end
1935
+ clear_linked_door_messages(owner)
1936
+ return
1937
+ end
1938
+
1939
+ linked_door_group(owner).each do |member|
1940
+ member.instance_variable_set(:@triggered, false)
1941
+ member.instance_variable_set(:@trigger_activator, activator)
1942
+ if member.state == STATE_OPENING
1943
+ next
1944
+ elsif member.state == STATE_OPEN
1945
+ member.think_time = member.wait
1946
+ next
1947
+ end
1948
+
1949
+ member.instance_variable_set(:@move_fraction, 0.0)
1950
+ member.instance_variable_set(:@activated_at_time, @time)
1951
+ play_brush_noise(member, :@noise2)
1952
+ member.state = STATE_OPENING
1953
+
1954
+ use_door_targets(member, activator: activator)
1955
+ end
1956
+ clear_linked_door_messages(owner)
1957
+ end
1958
+
1959
+ def train_entity?(ent)
1960
+ ["train", "misc_teleporttrain"].include?(ent.classname)
1961
+ end
1962
+
1963
+ def platform_entity?(ent)
1964
+ ent.classname == "plat" || ent.classname == "func_plat"
1965
+ end
1966
+
1967
+ def use_train(ent)
1968
+ return unless ent.state == STATE_IDLE
1969
+
1970
+ ent.instance_variable_set(:@triggered, false)
1971
+ start_train_next(ent)
1972
+ end
1973
+
1974
+ def use_platform(ent)
1975
+ return if ent.instance_variable_get(:@use) == :sub_null
1976
+
1977
+ if ent.instance_variable_get(:@targeted_platform)
1978
+ ent.instance_variable_set(:@use, :sub_null)
1979
+ raise "plat_use: not in up state" unless ent.state == STATE_IDLE
1980
+
1981
+ ent.instance_variable_set(:@platform_used, true)
1982
+ ent.instance_variable_set(:@triggered, false)
1983
+ ent.instance_variable_set(:@move_fraction, 0.0)
1984
+ ent.instance_variable_set(:@activated_at_time, @time)
1985
+ play_brush_noise(ent, :@noise)
1986
+ ent.state = STATE_OPENING
1987
+ return
1988
+ end
1989
+
1990
+ return unless ent.state == STATE_IDLE || ent.state == STATE_CLOSED
1991
+
1992
+ ent.instance_variable_set(:@triggered, false)
1993
+ ent.instance_variable_set(:@move_fraction, 0.0)
1994
+ ent.instance_variable_set(:@activated_at_time, @time)
1995
+ play_brush_noise(ent, :@noise)
1996
+ ent.state = STATE_CLOSING
1997
+ end
1998
+
1999
+ def open_linked_doors_now(ent, activator:)
2000
+ linked_door_group(ent).each do |member|
2001
+ next if [STATE_OPENING, STATE_OPEN].include?(member.state)
2002
+
2003
+ member.instance_variable_set(:@trigger_activator, activator)
2004
+ member.instance_variable_set(:@move_fraction, 0.0)
2005
+ member.instance_variable_set(:@activated_at_time, @time)
2006
+ play_brush_noise(member, :@noise2)
2007
+ member.state = STATE_OPENING
2008
+ use_door_targets(member, activator: activator)
2009
+ end
2010
+ clear_linked_door_messages(ent)
2011
+ end
2012
+
2013
+ def clear_linked_door_messages(ent)
2014
+ linked_door_group(ent).each { |member| member.message = nil }
2015
+ end
2016
+
2017
+ def toggle_frame_bmodel?(ent)
2018
+ ent.instance_variable_get(:@use) == :func_wall_use
2019
+ end
2020
+
2021
+ def toggle_light?(ent)
2022
+ ["light", "light_fluoro"].include?(ent.classname) && ent.style >= 32
2023
+ end
2024
+
2025
+ def toggle_light(ent)
2026
+ ent.instance_variable_set(:@use, :light_use)
2027
+ if (ent.spawnflags & 1) != 0
2028
+ ent.lightstyle = "m"
2029
+ ent.spawnflags -= 1
2030
+ else
2031
+ ent.lightstyle = "a"
2032
+ ent.spawnflags += 1
2033
+ end
2034
+ end
2035
+
2036
+ def use_button(ent, activator:)
2037
+ return if [STATE_OPENING, STATE_OPEN].include?(ent.state)
2038
+
2039
+ ent.instance_variable_set(:@button_activator, activator)
2040
+ ent.instance_variable_set(:@triggered, false)
2041
+ ent.instance_variable_set(:@move_fraction, 0.0)
2042
+ fire_button(ent)
2043
+ end
2044
+
2045
+ def fire_button(ent)
2046
+ return if [STATE_OPENING, STATE_OPEN].include?(ent.state)
2047
+
2048
+ play_trigger_sound(ent)
2049
+ ent.state = STATE_OPENING
2050
+ end
2051
+
2052
+ def use_counter(ent, activator:)
2053
+ ent.count -= 1
2054
+ return if ent.count.negative?
2055
+ unless ent.count.zero?
2056
+ centerprint_counter_message(ent, activator, counter_remaining_message(ent.count))
2057
+ return
2058
+ end
2059
+
2060
+ centerprint_counter_message(ent, activator, "Sequence completed!")
2061
+ ent.instance_variable_set(:@enemy, activator)
2062
+ activate_multi_trigger(ent, activator: activator)
2063
+ end
2064
+
2065
+ def centerprint_counter_message(ent, activator, message)
2066
+ return unless activator&.classname == "player"
2067
+ return if (ent.spawnflags & 1) != 0
2068
+
2069
+ activator.instance_variable_set(:@centerprint, message)
2070
+ end
2071
+
2072
+ def trigger_movedir(ent)
2073
+ return ent.move_dir if ent.move_dir != Math::Vec3::ORIGIN
2074
+ return ent.forward_vector if ent["angles"] && ent["angles"].split.map(&:to_f) != [0.0, 0.0, 0.0]
2075
+ return ent.forward_vector if ent["angle"] && ent["angle"].to_f != 0.0
2076
+
2077
+ Math::Vec3::ORIGIN
2078
+ end
2079
+
2080
+ def trigger_model_index(ent)
2081
+ ent.instance_variable_get(:@trigger_model_index)
2082
+ end
2083
+
2084
+ def activator_takedamage?(activator)
2085
+ activator && activator.instance_variable_get(:@takedamage).to_i != 0
2086
+ end
2087
+
2088
+ def damage_takedamage_activator(activator, damage)
2089
+ if activator.respond_to?(:damageable?) && activator.damageable?
2090
+ activator.take_damage(damage)
2091
+ else
2092
+ activator.health -= damage.to_f.ceil if activator.respond_to?(:health) && activator.respond_to?(:health=)
2093
+ end
2094
+ end
2095
+
2096
+ def init_trigger_class?(ent)
2097
+ ent.classname.start_with?("trigger_")
2098
+ end
2099
+
2100
+ def counter_remaining_message(count)
2101
+ case count
2102
+ when 3 then "Only 3 more to go..."
2103
+ when 2 then "Only 2 more to go..."
2104
+ when 1 then "Only 1 more to go..."
2105
+ else "There are more to go..."
357
2106
  end
358
2107
  end
359
2108
  end