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.
data/lib/quake/entity.rb CHANGED
@@ -5,37 +5,76 @@ module Quake
5
5
  # All Quake entities (players, monsters, items, triggers, brush models)
6
6
  # are defined as key-value property bags in the BSP entity string.
7
7
  class Entity
8
+ FL_FLY = 1
9
+ FL_SWIM = 2
10
+ FL_CLIENT = 8
11
+ FL_MONSTER = 32
12
+ FL_GODMODE = 64
13
+ IT_ARMOR1 = 8192
14
+ IT_ARMOR2 = 16_384
15
+ IT_ARMOR3 = 32_768
16
+ IT_ARMOR_MASK = IT_ARMOR1 | IT_ARMOR2 | IT_ARMOR3
17
+
8
18
  attr_accessor :properties, :position, :angle, :angles, :classname,
9
19
  :model_index, :think_time, :state, :move_dir,
10
20
  :target, :targetname, :speed, :wait, :lip, :health,
11
- :sounds, :message
21
+ :height, :sounds, :message, :count, :spawnflags, :killtarget,
22
+ :frame, :skin, :flags, :effects, :velocity, :style, :lightstyle,
23
+ :armortype, :armorvalue, :invincible_finished,
24
+ :dmg_take, :dmg_save, :dmg_inflictor, :items,
25
+ :super_damage_finished
12
26
 
13
27
  def initialize(properties = {})
14
- @properties = properties
28
+ @properties = properties.dup
15
29
  @classname = properties["classname"] || ""
30
+ @spawnflags = (properties["spawnflags"] || "0").to_i
31
+ apply_spawn_defaults
16
32
 
17
33
  # Parse common fields
18
- @position = parse_vec3(properties["origin"]) || Math::Vec3::ORIGIN
19
- @angle = (properties["angle"] || "0").to_f
20
- @angles = parse_vec3(properties["angles"]) || Math::Vec3::ORIGIN
34
+ @position = parse_vec3(@properties["origin"]) || Math::Vec3::ORIGIN
35
+ @velocity = parse_vec3(@properties["velocity"]) || Math::Vec3::ORIGIN
36
+ @angle = (@properties["angle"] || "0").to_f
37
+ @angles = parse_vec3(@properties["angles"]) || Math::Vec3::ORIGIN
21
38
 
22
39
  # Brush model reference: "*1", "*2", etc.
23
40
  @model_index = nil
24
- if (model_str = properties["model"])
41
+ if (model_str = @properties["model"])
25
42
  @model_index = model_str[1..].to_i if model_str.start_with?("*")
26
43
  end
27
44
 
28
45
  # Targeting
29
- @target = properties["target"]
30
- @targetname = properties["targetname"]
46
+ @target = @properties["target"]
47
+ @targetname = @properties["targetname"]
48
+ @killtarget = @properties["killtarget"]
31
49
 
32
50
  # Movement/behavior
33
- @speed = (properties["speed"] || default_speed).to_f
34
- @wait = (properties["wait"] || "3").to_f
35
- @lip = (properties["lip"] || "8").to_f
36
- @health = (properties["health"] || "0").to_f
37
- @sounds = (properties["sounds"] || "0").to_i
38
- @message = properties["message"]
51
+ @speed = quake_float("speed", default_speed, zero_defaults_for: [
52
+ "func_door", "func_plat", "func_button", "func_train",
53
+ "trigger_push", "trigger_monsterjump"
54
+ ])
55
+ @wait = quake_float("wait", default_wait, zero_defaults_for: [
56
+ "func_door", "func_button", "trigger_multiple", "trap_shooter"
57
+ ])
58
+ @lip = quake_float("lip", default_lip, zero_defaults_for: ["func_door", "func_button"])
59
+ @health = (@properties["health"] || default_health).to_f
60
+ @height = quake_float("height", default_height, zero_defaults_for: ["trigger_monsterjump"])
61
+ @sounds = (@properties["sounds"] || "0").to_i
62
+ @message = @properties["message"]
63
+ @count = quake_int("count", default_count, zero_defaults_for: ["trigger_counter"])
64
+ @frame = quake_int("frame", default_frame)
65
+ @skin = quake_int("skin", default_skin)
66
+ @flags = quake_int("flags", "0") | default_runtime_flags
67
+ @takedamage = quake_int("takedamage", default_takedamage)
68
+ @armortype = quake_float("armortype", "0")
69
+ @armorvalue = quake_float("armorvalue", "0")
70
+ @invincible_finished = quake_float("invincible_finished", "0")
71
+ @items = quake_int("items", "0")
72
+ @super_damage_finished = quake_float("super_damage_finished", "0")
73
+ @dmg_take = quake_float("dmg_take", "0")
74
+ @dmg_save = quake_float("dmg_save", "0")
75
+ @dmg_inflictor = nil
76
+ @effects = quake_int("effects", "0") if @properties.key?("effects")
77
+ @style = quake_int("style", default_style, zero_defaults_for: ["light_fluorospark"])
39
78
 
40
79
  # Runtime state
41
80
  @state = :idle
@@ -47,8 +86,49 @@ module Quake
47
86
 
48
87
  def brush_entity? = !@model_index.nil?
49
88
 
89
+ def removed? = !!@removed
90
+
91
+ def damageable?
92
+ return false if removed?
93
+
94
+ @takedamage != 0
95
+ end
96
+
97
+ def take_damage(amount, game_time: nil)
98
+ return 0.0 unless damageable?
99
+
100
+ result = absorb_damage(amount, game_time: game_time)
101
+ apply_health_damage(result[:take]) if result[:take].positive?
102
+ result[:take].to_f
103
+ end
104
+
105
+ def absorb_damage(amount, game_time: nil)
106
+ damage = amount.to_f
107
+ save = (@armortype * damage).ceil
108
+ if save >= @armorvalue
109
+ save = @armorvalue
110
+ @armortype = 0.0
111
+ @items -= @items & IT_ARMOR_MASK
112
+ end
113
+ @armorvalue -= save
114
+ protected = (@flags & FL_GODMODE) != 0 ||
115
+ (game_time && @invincible_finished.positive? && @invincible_finished >= game_time.to_f)
116
+ take = protected ? 0.0 : (damage - save).ceil.to_f
117
+ { take: take, save: save.to_f, protected: protected }
118
+ end
119
+
120
+ def apply_health_damage(taken)
121
+ @health -= taken
122
+ @health = -99.0 if @health < -99.0
123
+ if @health <= 0
124
+ @removed = true
125
+ end
126
+ end
127
+
50
128
  # Direction vector from the "angle" field (Quake convention)
51
129
  def forward_vector
130
+ return angles_forward_vector if @angles != Math::Vec3::ORIGIN
131
+
52
132
  case @angle.to_i
53
133
  when -1 # UP
54
134
  Math::Vec3.new(0.0, 0.0, 1.0)
@@ -62,48 +142,356 @@ module Quake
62
142
 
63
143
  private
64
144
 
145
+ def angles_forward_vector
146
+ if @angles.x.zero? && @angles.y == -1.0 && @angles.z.zero?
147
+ return Math::Vec3.new(0.0, 0.0, 1.0)
148
+ end
149
+ if @angles.x.zero? && @angles.y == -2.0 && @angles.z.zero?
150
+ return Math::Vec3.new(0.0, 0.0, -1.0)
151
+ end
152
+
153
+ yaw = @angles.y * ::Math::PI / 180.0
154
+ pitch = @angles.x * ::Math::PI / 180.0
155
+ Math::Vec3.new(
156
+ ::Math.cos(pitch) * ::Math.cos(yaw),
157
+ ::Math.cos(pitch) * ::Math.sin(yaw),
158
+ -::Math.sin(pitch)
159
+ )
160
+ end
161
+
162
+ def apply_spawn_defaults
163
+ @properties["model"] ||= default_model
164
+ end
165
+
65
166
  def parse_vec3(str)
66
167
  return nil unless str
67
- parts = str.split.map(&:to_f)
68
- return nil unless parts.size == 3
168
+ parts = str.split.first(3).map(&:to_f)
169
+ parts << 0.0 while parts.size < 3
69
170
  Math::Vec3.new(parts[0], parts[1], parts[2])
70
171
  end
71
172
 
173
+ def quake_float(key, default, zero_defaults_for: [])
174
+ raw = @properties[key]
175
+ return default.to_f unless raw
176
+
177
+ value = raw.to_f
178
+ value.zero? && zero_defaults_for.include?(@classname) ? default.to_f : value
179
+ end
180
+
181
+ def quake_int(key, default, zero_defaults_for: [])
182
+ raw = @properties[key]
183
+ return default.to_i unless raw
184
+
185
+ value = raw.to_i
186
+ value.zero? && zero_defaults_for.include?(@classname) ? default.to_i : value
187
+ end
188
+
72
189
  def default_speed
73
190
  case @classname
74
191
  when "func_door" then "100"
75
192
  when "func_plat" then "150"
76
193
  when "func_button" then "40"
77
194
  when "func_train" then "100"
195
+ when "trigger_push" then "1000"
196
+ when "trigger_monsterjump" then "200"
78
197
  else "100"
79
198
  end
80
199
  end
200
+
201
+ def default_wait
202
+ case @classname
203
+ when "func_button" then "1"
204
+ when "trigger_multiple" then "0.2"
205
+ when "trigger_once", "trigger_secret", "trigger_counter" then "-1"
206
+ when "path_corner" then "0"
207
+ when "trap_shooter" then "1"
208
+ else "3"
209
+ end
210
+ end
211
+
212
+ def default_lip
213
+ case @classname
214
+ when "func_button" then "4"
215
+ else "8"
216
+ end
217
+ end
218
+
219
+ def default_count
220
+ case @classname
221
+ when "trigger_counter" then "2"
222
+ else "0"
223
+ end
224
+ end
225
+
226
+ def default_style
227
+ case @classname
228
+ when "light_fluorospark" then "10"
229
+ else "0"
230
+ end
231
+ end
232
+
233
+ def default_height
234
+ case @classname
235
+ when "trigger_monsterjump" then "200"
236
+ else "0"
237
+ end
238
+ end
239
+
240
+ def default_takedamage
241
+ if @classname.start_with?("monster_") ||
242
+ @classname == "misc_explobox" ||
243
+ @classname == "misc_explobox2"
244
+ "2"
245
+ else
246
+ "0"
247
+ end
248
+ end
249
+
250
+ def default_runtime_flags
251
+ return 0 unless @classname.start_with?("monster_")
252
+
253
+ flags = FL_MONSTER
254
+ flags |= FL_FLY if @classname == "monster_wizard"
255
+ flags |= FL_SWIM if @classname == "monster_fish"
256
+ flags
257
+ end
258
+
259
+ def default_health
260
+ case @classname
261
+ when "monster_army" then "30"
262
+ when "monster_dog" then "25"
263
+ when "monster_ogre" then "200"
264
+ when "monster_knight" then "75"
265
+ when "monster_wizard" then "80"
266
+ when "monster_demon1" then "300"
267
+ when "monster_zombie" then "60"
268
+ when "monster_fish" then "25"
269
+ when "misc_explobox", "misc_explobox2" then "20"
270
+ else "0"
271
+ end
272
+ end
273
+
274
+ def default_model
275
+ case @classname
276
+ when "monster_army" then "progs/soldier.mdl"
277
+ when "monster_dog" then "progs/dog.mdl"
278
+ when "monster_ogre" then "progs/ogre.mdl"
279
+ when "monster_knight" then "progs/knight.mdl"
280
+ when "monster_wizard" then "progs/wizard.mdl"
281
+ when "monster_demon1" then "progs/demon.mdl"
282
+ when "monster_zombie" then "progs/zombie.mdl"
283
+ when "weapon_supershotgun" then "progs/g_shot.mdl"
284
+ when "weapon_nailgun" then "progs/g_nail.mdl"
285
+ when "weapon_supernailgun" then "progs/g_nail2.mdl"
286
+ when "weapon_grenadelauncher" then "progs/g_rock.mdl"
287
+ when "weapon_rocketlauncher" then "progs/g_rock2.mdl"
288
+ when "weapon_lightning" then "progs/g_light.mdl"
289
+ when "item_health" then health_model
290
+ when "item_shells" then ammo_box_model("shell")
291
+ when "item_spikes" then ammo_box_model("nail")
292
+ when "item_rockets" then ammo_box_model("rock")
293
+ when "item_cells" then ammo_box_model("batt")
294
+ when "item_armor1", "item_armor2", "item_armorInv" then "progs/armor.mdl"
295
+ when "item_artifact_invulnerability" then "progs/invulner.mdl"
296
+ when "item_artifact_envirosuit" then "progs/suit.mdl"
297
+ when "item_artifact_invisibility" then "progs/invisibl.mdl"
298
+ when "item_artifact_super_damage" then "progs/quaddama.mdl"
299
+ when "item_backpack" then "progs/backpack.mdl"
300
+ when "item_key1" then "progs/w_s_key.mdl"
301
+ when "item_key2" then "progs/w_g_key.mdl"
302
+ when "item_sigil" then sigil_model
303
+ when "item_weapon" then legacy_weapon_ammo_model
304
+ when "misc_explobox" then "maps/b_explob.bsp"
305
+ when "misc_explobox2" then "maps/b_exbox2.bsp"
306
+ when "light_globe" then "progs/s_light.spr"
307
+ when "light_torch_small_walltorch" then "progs/flame.mdl"
308
+ when "light_flame_large_yellow", "light_flame_small_yellow", "light_flame_small_white" then "progs/flame2.mdl"
309
+ when "viewthing" then "progs/player.mdl"
310
+ when "misc_teleporttrain" then "progs/teleport.mdl"
311
+ end
312
+ end
313
+
314
+ def default_frame
315
+ case @classname
316
+ when "light_flame_large_yellow" then "1"
317
+ else "0"
318
+ end
319
+ end
320
+
321
+ def default_skin
322
+ case @classname
323
+ when "item_armor2" then "1"
324
+ when "item_armorInv" then "2"
325
+ else "0"
326
+ end
327
+ end
328
+
329
+ def health_model
330
+ if (@spawnflags & 1) != 0
331
+ "maps/b_bh10.bsp"
332
+ elsif (@spawnflags & 2) != 0
333
+ "maps/b_bh100.bsp"
334
+ else
335
+ "maps/b_bh25.bsp"
336
+ end
337
+ end
338
+
339
+ def ammo_box_model(prefix)
340
+ suffix = (@spawnflags & 1) != 0 ? "1" : "0"
341
+ "maps/b_#{prefix}#{suffix}.bsp"
342
+ end
343
+
344
+ def sigil_model
345
+ if (@spawnflags & 8) != 0
346
+ "progs/end4.mdl"
347
+ elsif (@spawnflags & 4) != 0
348
+ "progs/end3.mdl"
349
+ elsif (@spawnflags & 2) != 0
350
+ "progs/end2.mdl"
351
+ elsif (@spawnflags & 1) != 0
352
+ "progs/end1.mdl"
353
+ end
354
+ end
355
+
356
+ def legacy_weapon_ammo_model
357
+ big = (@spawnflags & 8) != 0
358
+ if (@spawnflags & 2) != 0
359
+ big ? "maps/b_rock1.bsp" : "maps/b_rock0.bsp"
360
+ elsif (@spawnflags & 4) != 0
361
+ big ? "maps/b_nail1.bsp" : "maps/b_nail0.bsp"
362
+ elsif (@spawnflags & 1) != 0
363
+ big ? "maps/b_shell1.bsp" : "maps/b_shell0.bsp"
364
+ end
365
+ end
81
366
  end
82
367
 
83
368
  # Parses the BSP entity string into an array of Entity objects.
84
369
  module EntityParser
370
+ SINGLE_CHAR_TOKENS = "{})(:'"
371
+ FIELD_NAMES = %w[
372
+ absmax absmin aflag aiment air_finished ammo_cells ammo_nails
373
+ ammo_rockets ammo_shells angles armortype armorvalue attack_finished
374
+ attack_state avelocity axhitme blocked bubble_count button0 button1
375
+ button2 chain classname cnt colormap count currentammo deadflag
376
+ deathtype delay dest dest1 dest2 distance dmg dmg_inflictor dmg_save
377
+ dmg_take effects enemy finalangle finaldest fixangle flags fly_sound
378
+ frags frame goalentity gravity groundentity health height ideal_yaw
379
+ impulse invincible_finished invincible_sound invincible_time
380
+ invisible_finished invisible_sound invisible_time items jump_flag
381
+ killtarget lastruntime lefty light_lev lip ltime mangle map max_health
382
+ maxs maxspeed mdl message mins model modelindex movedir movetarget
383
+ movetype netname nextthink noise noise1 noise2 noise3 noise4 oldenemy
384
+ oldorigin origin owner pain_finished pausetime pos1 pos2 rad_time
385
+ radsuit_finished search_time show_hostile size skin solid sounds
386
+ spawnflags speed state style super_damage_finished super_sound
387
+ super_time swim_flag t_length t_width takedamage target targetname team
388
+ teleport_time think touch trigger_field use v_angle velocity view_ofs
389
+ voided volume wad wait waitmax waitmin walkframe waterlevel watertype
390
+ weapon weaponframe weaponmodel worldtype yaw_speed
391
+ ].each_with_object({}) { |name, fields| fields[name] = true }.freeze
392
+
85
393
  def self.parse(entity_string)
394
+ return [] if entity_string.nil?
395
+
86
396
  entities = []
87
397
  current = nil
398
+ tokens = tokenize(entity_string)
399
+ index = 0
400
+
401
+ while index < tokens.length
402
+ token = tokens[index]
403
+ if current.nil?
404
+ raise "ED_LoadFromFile: found #{token} when expecting {" unless token == "{"
88
405
 
89
- entity_string.each_line do |line|
90
- line = line.strip
91
- case line
92
- when "{"
93
406
  current = {}
94
- when "}"
95
- entities << Entity.new(current) if current
407
+ index += 1
408
+ next
409
+ end
410
+
411
+ if token == "}"
412
+ entities << Entity.new(current) if current["classname"]
96
413
  current = nil
97
- else
98
- if current && line =~ /\A"([^"]+)"\s+"([^"]*)"\z/
99
- current[$1] = $2
414
+ index += 1
415
+ next
416
+ end
417
+
418
+ key = normalize_key(token)
419
+ index += 1
420
+ raise "ED_ParseEntity: EOF without closing brace" if index >= tokens.length
421
+
422
+ value = tokens[index]
423
+ raise "ED_ParseEntity: closing brace without data" if value == "}"
424
+
425
+ value = decode_string(value)
426
+
427
+ unless key.start_with?("_") || (key != "angle" && !FIELD_NAMES[key])
428
+ if key == "angle"
429
+ current["angles"] = "0 #{value} 0"
430
+ else
431
+ current[key] = value
100
432
  end
101
433
  end
434
+ index += 1
102
435
  end
103
436
 
437
+ raise "ED_ParseEntity: EOF without closing brace" if current
438
+
104
439
  entities
105
440
  end
106
441
 
442
+ def self.tokenize(data)
443
+ tokens = []
444
+ index = 0
445
+
446
+ while index < data.length
447
+ char = data[index]
448
+ break if char == "\0"
449
+
450
+ if char.ord <= 32
451
+ index += 1
452
+ next
453
+ end
454
+
455
+ if char == "/" && data[index + 1] == "/"
456
+ index += 2
457
+ index += 1 while index < data.length && data[index] != "\n"
458
+ next
459
+ end
460
+
461
+ if char == '"'
462
+ index += 1
463
+ start = index
464
+ index += 1 while index < data.length && data[index] != '"'
465
+ tokens << data[start...index]
466
+ index += 1
467
+ next
468
+ end
469
+
470
+ if SINGLE_CHAR_TOKENS.include?(char)
471
+ tokens << char
472
+ index += 1
473
+ next
474
+ end
475
+
476
+ start = index
477
+ while index < data.length && data[index].ord > 32 && !SINGLE_CHAR_TOKENS.include?(data[index])
478
+ index += 1
479
+ end
480
+ tokens << data[start...index]
481
+ end
482
+
483
+ tokens
484
+ end
485
+
486
+ def self.normalize_key(key)
487
+ key = "light_lev" if key == "light"
488
+ key.rstrip
489
+ end
490
+
491
+ def self.decode_string(value)
492
+ value.gsub(/\\./) { |match| match == "\\n" ? "\n" : "\\" }
493
+ end
494
+
107
495
  # Build a lookup table: targetname -> [Entity]
108
496
  def self.build_target_map(entities)
109
497
  map = Hash.new { |h, k| h[k] = [] }