Class: Doom::Game::MonsterAI

Inherits:
Object
  • Object
show all
Defined in:
lib/doom/game/monster_ai.rb

Overview

Basic monster AI: idle until seeing player, then chase. Matches Chocolate Doom's A_Look / A_Chase / P_NewChaseDir from p_enemy.c.

Defined Under Namespace

Classes: MonsterState

Constant Summary collapse

DI_EAST =

8 movement directions + no direction

0
DI_NORTHEAST =
1
DI_NORTH =
2
DI_NORTHWEST =
3
DI_WEST =
4
DI_SOUTHWEST =
5
DI_SOUTH =
6
DI_SOUTHEAST =
7
DI_NODIR =
8
XSPEED =

Movement deltas per direction (map units, 1.0 = FRACUNIT)

[1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071, 0.0, 0.7071].freeze
YSPEED =
[0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071].freeze
OPPOSITE =
[DI_WEST, DI_SOUTHWEST, DI_SOUTH, DI_SOUTHEAST,
DI_EAST, DI_NORTHEAST, DI_NORTH, DI_NORTHWEST, DI_NODIR].freeze
MONSTER_SPEED =

Monster speeds (from mobjinfo)

{
  3004 => 8, 9 => 8, 3001 => 8, 3002 => 10, 58 => 10,
  3003 => 8, 69 => 8, 3005 => 8, 3006 => 8, 16 => 16,
  7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8
}.freeze
CHASE_TICS =

Steps between A_Chase calls

4
SIGHT_RANGE =

Max distance for sight check

768.0
MELEE_RANGE =
64.0
MISSILE_RANGE =
768.0
KEEP_DISTANCE =

Ranged monsters prefer to stay this far from player

196.0
MONSTER_ATTACK =

Monster attack definitions (from mobjinfo / A_Chase) Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4) In DOOM, monsters only attempt attacks when movecount reaches 0, then play full attack animation before returning to chase.

{
  3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Zombieman
  9 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Shotgun Guy
  3001 => { type: :projectile, cooldown: 52 }, # Imp: fireball
  3002 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Demon
  58 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Spectre
  3003 => { type: :projectile, cooldown: 54 }, # Baron: fireball
  69 => { type: :projectile, cooldown: 54 }, # Hell Knight
  3005 => { type: :projectile, cooldown: 56 }, # Cacodemon
  65 => { type: :hitscan, damage: [3, 15], cooldown: 40 } # Heavy Weapon Dude
}.freeze
REACTIONTIME =

Tics before first attack after activation (from mobjinfo)

8
HITSCAN_ACCURACY =

Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread) The falloff is HITSCAN_ACCURACY * (1 - dist / (MISSILE_RANGE * 2)): close (dist 0) = ~85%, mid (dist 384) = ~64%, far (dist 768) = ~42%.

0.85
ATTACK_FRAMES =

Attack animation frames per sprite prefix (E, F, G typically)

{
  'POSS' => %w[E F],       # Zombieman: raise, fire
  'SPOS' => %w[E F],       # Shotgun Guy
  'TROO' => %w[E F G H],   # Imp: raise, fireball, throw, recover
  'SARG' => %w[E F G],     # Demon: bite
  'HEAD' => %w[E F],       # Cacodemon
  'BOSS' => %w[E F G],     # Baron
  'BOS2' => %w[E F G],     # Hell Knight
  'CPOS' => %w[E F] # Heavy Weapon Dude
}.freeze
ATTACK_FRAME_TICS =

Tics per attack animation frame

8
FIRE_FRAME_INDEX =

Which frame index the actual attack happens on (matching Chocolate Doom) Zombieman: A_PosAttack on frame F (index 1) Imp: A_TroopAttack on frame G (index 2) Demon: A_SargAttack on frame F (index 1)

{
  'POSS' => 1,  # Zombieman: E=raise, F=fire
  'SPOS' => 1,  # Shotgun Guy: E=raise, F=fire
  'TROO' => 2,  # Imp: E=raise, F=aim, G=throw, H=recover
  'SARG' => 1,  # Demon: E=open, F=bite, G=close
  'HEAD' => 1,  # Cacodemon: E=charge, F=fire
  'BOSS' => 1,  # Baron: E=raise, F=throw, G=recover
  'BOS2' => 1,  # Hell Knight
  'CPOS' => 1 # Heavy Weapon Dude
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil, random: Random.new) ⇒ MonsterAI

Returns a new instance of MonsterAI.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/doom/game/monster_ai.rb', line 96

def initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil,
               random: Random.new)
  @map = map
  @combat = combat
  @random = random
  @sprites_mgr = sprites_mgr
  @monsters = []
  @aggression = true # Monsters fight back (toggle with C)
  @damage_multiplier = 1.0
  @tic_counter = 0
  @sound = sound_engine
  @monster_by_thing_idx = {}

  map.things.each_with_index do |thing, idx|
    next if hidden_things[idx] # Filtered by difficulty
    next unless Combat::MONSTER_HP[thing.type]
    next if thing.type == Combat::BARREL_TYPE

    mon = MonsterState.new(
      idx, thing.x.to_f, thing.y.to_f,
      DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
      false, 0, false, nil
    )
    @monsters << mon
    @monster_by_thing_idx[idx] = mon
  end
end

Instance Attribute Details

#aggressionObject

Returns the value of attribute aggression.



125
126
127
# File 'lib/doom/game/monster_ai.rb', line 125

def aggression
  @aggression
end

#damage_multiplierObject

Returns the value of attribute damage_multiplier.



125
126
127
# File 'lib/doom/game/monster_ai.rb', line 125

def damage_multiplier
  @damage_multiplier
end

#monster_by_thing_idxObject (readonly)

Returns the value of attribute monster_by_thing_idx.



124
125
126
# File 'lib/doom/game/monster_ai.rb', line 124

def monster_by_thing_idx
  @monster_by_thing_idx
end

#monstersObject (readonly)

Returns the value of attribute monsters.



124
125
126
# File 'lib/doom/game/monster_ai.rb', line 124

def monsters
  @monsters
end

Instance Method Details

#update(players) ⇒ Object

Called each game tic with every live player. Each monster keeps its own target so a second player walking past does not yank a monster off the one it is already fighting; it re-picks only when its target dies or leaves.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/doom/game/monster_ai.rb', line 131

def update(players)
  players = Array(players)
  @tic_counter += 1
  @monsters.each do |mon|
    target = select_target(mon, players)
    next unless target
    next if @combat.dead?(mon.thing_idx)

    # Pain state: monster is stunned, skip movement and attacks
    next if @combat.in_pain?(mon.thing_idx)

    if mon.active
      # Attack animation in progress: freeze movement, tick animation
      if mon.attacking
        mon.attack_frame_tic += 1
        prefix = @sprites_mgr&.prefix_for(mon.type)
        frames = ATTACK_FRAMES[prefix]
        total_tics = (frames&.size || 2) * ATTACK_FRAME_TICS

        # Fire on the correct frame (matching Chocolate Doom)
        fire_idx = FIRE_FRAME_INDEX[prefix] || 1
        fire_tic = fire_idx * ATTACK_FRAME_TICS
        if !mon.fired && mon.attack_frame_tic >= fire_tic
          execute_attack(mon, target)
          mon.fired = true
        end

        if mon.attack_frame_tic >= total_tics
          mon.attacking = false
          mon.attack_frame_tic = 0
          mon.fired = false
        end
        next
      end

      mon.chase_timer -= 1
      if mon.chase_timer <= 0
        mon.chase_timer = CHASE_TICS
        chase(mon, target)
      end
    else
      look(mon, target)
    end
  end
end