doom 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +44 -22
- data/bin/doom +5 -3
- data/lib/doom/game/animations.rb +97 -0
- data/lib/doom/game/combat.rb +370 -0
- data/lib/doom/game/item_pickup.rb +170 -0
- data/lib/doom/game/monster_ai.rb +295 -0
- data/lib/doom/game/player_state.rb +144 -9
- data/lib/doom/game/sector_effects.rb +179 -0
- data/lib/doom/platform/gosu_window.rb +673 -71
- data/lib/doom/render/renderer.rb +346 -90
- data/lib/doom/render/status_bar.rb +74 -22
- data/lib/doom/render/weapon_renderer.rb +25 -28
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +70 -2
- data/lib/doom/wad/sprite.rb +98 -24
- data/lib/doom/wad/texture.rb +23 -12
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +15 -2
- metadata +9 -7
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# Handles item pickup when player touches things.
|
|
6
|
+
# Matches Chocolate Doom's P_TouchSpecialThing from p_inter.c.
|
|
7
|
+
class ItemPickup
|
|
8
|
+
PLAYER_RADIUS = 16.0
|
|
9
|
+
THING_RADIUS = 20.0
|
|
10
|
+
PICKUP_DIST = PLAYER_RADIUS + THING_RADIUS # 36 units (bounding box overlap)
|
|
11
|
+
|
|
12
|
+
# Item definitions: type => { category, value, ... }
|
|
13
|
+
ITEMS = {
|
|
14
|
+
# Weapons (give weapon + some ammo)
|
|
15
|
+
2001 => { cat: :weapon, weapon: 2, ammo: :shells, amount: 8 }, # Shotgun
|
|
16
|
+
2002 => { cat: :weapon, weapon: 3, ammo: :bullets, amount: 20 }, # Chaingun
|
|
17
|
+
2003 => { cat: :weapon, weapon: 4, ammo: :rockets, amount: 2 }, # Rocket launcher
|
|
18
|
+
2004 => { cat: :weapon, weapon: 5, ammo: :cells, amount: 40 }, # Plasma rifle
|
|
19
|
+
2006 => { cat: :weapon, weapon: 6, ammo: :cells, amount: 40 }, # BFG9000
|
|
20
|
+
2005 => { cat: :weapon, weapon: 7, ammo: :bullets, amount: 0 }, # Chainsaw
|
|
21
|
+
|
|
22
|
+
# Ammo
|
|
23
|
+
2007 => { cat: :ammo, ammo: :bullets, amount: 10 }, # Clip
|
|
24
|
+
2048 => { cat: :ammo, ammo: :bullets, amount: 50 }, # Box of bullets
|
|
25
|
+
2008 => { cat: :ammo, ammo: :shells, amount: 4 }, # 4 shells
|
|
26
|
+
2049 => { cat: :ammo, ammo: :shells, amount: 20 }, # Box of shells
|
|
27
|
+
2010 => { cat: :ammo, ammo: :rockets, amount: 1 }, # Rocket
|
|
28
|
+
2046 => { cat: :ammo, ammo: :rockets, amount: 5 }, # Box of rockets
|
|
29
|
+
17 => { cat: :ammo, ammo: :cells, amount: 20 }, # Cell charge
|
|
30
|
+
2047 => { cat: :ammo, ammo: :cells, amount: 100 }, # Cell pack
|
|
31
|
+
8 => { cat: :backpack }, # Backpack (doubles max ammo + some ammo)
|
|
32
|
+
|
|
33
|
+
# Health
|
|
34
|
+
2014 => { cat: :health, amount: 1, max: 200 }, # Health bonus (+1, up to 200)
|
|
35
|
+
2011 => { cat: :health, amount: 10, max: 100 }, # Stimpack
|
|
36
|
+
2012 => { cat: :health, amount: 25, max: 100 }, # Medikit
|
|
37
|
+
2013 => { cat: :health, amount: 100, max: 200 }, # Soul sphere
|
|
38
|
+
|
|
39
|
+
# Armor
|
|
40
|
+
2015 => { cat: :armor, amount: 1, max: 200 }, # Armor bonus (+1, up to 200)
|
|
41
|
+
2018 => { cat: :armor, amount: 100, armor_type: 1 }, # Green armor (100%, absorbs 1/3)
|
|
42
|
+
2019 => { cat: :armor, amount: 200, armor_type: 2 }, # Blue armor (200%, absorbs 1/2)
|
|
43
|
+
|
|
44
|
+
# Keys
|
|
45
|
+
5 => { cat: :key, key: :blue_card },
|
|
46
|
+
6 => { cat: :key, key: :yellow_card },
|
|
47
|
+
13 => { cat: :key, key: :red_card },
|
|
48
|
+
40 => { cat: :key, key: :blue_skull },
|
|
49
|
+
39 => { cat: :key, key: :yellow_skull },
|
|
50
|
+
38 => { cat: :key, key: :red_skull },
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
attr_reader :picked_up
|
|
54
|
+
|
|
55
|
+
def initialize(map, player_state)
|
|
56
|
+
@map = map
|
|
57
|
+
@player = player_state
|
|
58
|
+
@picked_up = {} # thing index => true (to avoid re-picking)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update(player_x, player_y)
|
|
62
|
+
@map.things.each_with_index do |thing, idx|
|
|
63
|
+
next if @picked_up[idx]
|
|
64
|
+
item = ITEMS[thing.type]
|
|
65
|
+
next unless item
|
|
66
|
+
|
|
67
|
+
# DOOM uses bounding box overlap: abs(dx) < sum_of_radii
|
|
68
|
+
dx = (player_x - thing.x).abs
|
|
69
|
+
dy = (player_y - thing.y).abs
|
|
70
|
+
next if dx >= PICKUP_DIST || dy >= PICKUP_DIST
|
|
71
|
+
|
|
72
|
+
if try_pickup(item)
|
|
73
|
+
@picked_up[idx] = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def try_pickup(item)
|
|
81
|
+
case item[:cat]
|
|
82
|
+
when :weapon
|
|
83
|
+
give_weapon(item)
|
|
84
|
+
when :ammo
|
|
85
|
+
give_ammo(item[:ammo], item[:amount])
|
|
86
|
+
when :backpack
|
|
87
|
+
give_backpack
|
|
88
|
+
when :health
|
|
89
|
+
give_health(item[:amount], item[:max])
|
|
90
|
+
when :armor
|
|
91
|
+
give_armor(item)
|
|
92
|
+
when :key
|
|
93
|
+
give_key(item[:key])
|
|
94
|
+
else
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def give_weapon(item)
|
|
100
|
+
weapon_idx = item[:weapon]
|
|
101
|
+
had_weapon = @player.has_weapons[weapon_idx]
|
|
102
|
+
|
|
103
|
+
@player.has_weapons[weapon_idx] = true
|
|
104
|
+
ammo_given = item[:ammo] ? give_ammo(item[:ammo], item[:amount]) : false
|
|
105
|
+
|
|
106
|
+
unless had_weapon
|
|
107
|
+
@player.switch_weapon(weapon_idx) unless @player.attacking
|
|
108
|
+
return true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
ammo_given
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def give_ammo(type, amount)
|
|
115
|
+
case type
|
|
116
|
+
when :bullets
|
|
117
|
+
return false if @player.ammo_bullets >= @player.max_bullets
|
|
118
|
+
@player.ammo_bullets = [@player.ammo_bullets + amount, @player.max_bullets].min
|
|
119
|
+
when :shells
|
|
120
|
+
return false if @player.ammo_shells >= @player.max_shells
|
|
121
|
+
@player.ammo_shells = [@player.ammo_shells + amount, @player.max_shells].min
|
|
122
|
+
when :rockets
|
|
123
|
+
return false if @player.ammo_rockets >= @player.max_rockets
|
|
124
|
+
@player.ammo_rockets = [@player.ammo_rockets + amount, @player.max_rockets].min
|
|
125
|
+
when :cells
|
|
126
|
+
return false if @player.ammo_cells >= @player.max_cells
|
|
127
|
+
@player.ammo_cells = [@player.ammo_cells + amount, @player.max_cells].min
|
|
128
|
+
end
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def give_backpack
|
|
133
|
+
@player.max_bullets = 400
|
|
134
|
+
@player.max_shells = 100
|
|
135
|
+
@player.max_rockets = 100
|
|
136
|
+
@player.max_cells = 600
|
|
137
|
+
give_ammo(:bullets, 10)
|
|
138
|
+
give_ammo(:shells, 4)
|
|
139
|
+
give_ammo(:rockets, 1)
|
|
140
|
+
give_ammo(:cells, 20)
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def give_health(amount, max)
|
|
145
|
+
return false if @player.health >= max
|
|
146
|
+
@player.health = [@player.health + amount, max].min
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def give_armor(item)
|
|
151
|
+
if item[:armor_type]
|
|
152
|
+
# Green/blue armor: only pick up if better than current
|
|
153
|
+
return false if @player.armor >= item[:amount]
|
|
154
|
+
@player.armor = item[:amount]
|
|
155
|
+
else
|
|
156
|
+
# Armor bonus: +1, up to max
|
|
157
|
+
return false if @player.armor >= item[:max]
|
|
158
|
+
@player.armor = [@player.armor + item[:amount], item[:max]].min
|
|
159
|
+
end
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def give_key(key)
|
|
164
|
+
return false if @player.keys[key]
|
|
165
|
+
@player.keys[key] = true
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# Basic monster AI: idle until seeing player, then chase.
|
|
6
|
+
# Matches Chocolate Doom's A_Look / A_Chase / P_NewChaseDir from p_enemy.c.
|
|
7
|
+
class MonsterAI
|
|
8
|
+
# 8 movement directions + no direction
|
|
9
|
+
DI_EAST = 0; DI_NORTHEAST = 1; DI_NORTH = 2; DI_NORTHWEST = 3
|
|
10
|
+
DI_WEST = 4; DI_SOUTHWEST = 5; DI_SOUTH = 6; DI_SOUTHEAST = 7
|
|
11
|
+
DI_NODIR = 8
|
|
12
|
+
|
|
13
|
+
# Movement deltas per direction (map units, 1.0 = FRACUNIT)
|
|
14
|
+
XSPEED = [1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071, 0.0, 0.7071].freeze
|
|
15
|
+
YSPEED = [0.0, 0.7071, 1.0, 0.7071, 0.0, -0.7071, -1.0, -0.7071].freeze
|
|
16
|
+
|
|
17
|
+
OPPOSITE = [DI_WEST, DI_SOUTHWEST, DI_SOUTH, DI_SOUTHEAST,
|
|
18
|
+
DI_EAST, DI_NORTHEAST, DI_NORTH, DI_NORTHWEST, DI_NODIR].freeze
|
|
19
|
+
|
|
20
|
+
# Monster speeds (from mobjinfo)
|
|
21
|
+
MONSTER_SPEED = {
|
|
22
|
+
3004 => 8, 9 => 8, 3001 => 8, 3002 => 10, 58 => 10,
|
|
23
|
+
3003 => 8, 69 => 8, 3005 => 8, 3006 => 8, 16 => 16,
|
|
24
|
+
7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8,
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
CHASE_TICS = 4 # Steps between A_Chase calls
|
|
28
|
+
SIGHT_RANGE = 768.0 # Max distance for sight check (DOOM uses sector sound propagation, we approximate)
|
|
29
|
+
MELEE_RANGE = 64.0
|
|
30
|
+
|
|
31
|
+
# Direction to angle (for sprite facing)
|
|
32
|
+
DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
|
|
33
|
+
|
|
34
|
+
MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
|
|
35
|
+
:active, :chase_timer, :type)
|
|
36
|
+
|
|
37
|
+
def initialize(map, combat)
|
|
38
|
+
@map = map
|
|
39
|
+
@combat = combat
|
|
40
|
+
@monsters = []
|
|
41
|
+
|
|
42
|
+
map.things.each_with_index do |thing, idx|
|
|
43
|
+
next unless Combat::MONSTER_HP[thing.type]
|
|
44
|
+
@monsters << MonsterState.new(
|
|
45
|
+
idx, thing.x.to_f, thing.y.to_f,
|
|
46
|
+
DI_NODIR, 0, false, 0, thing.type
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :monsters
|
|
52
|
+
|
|
53
|
+
# Called each game tic
|
|
54
|
+
def update(player_x, player_y)
|
|
55
|
+
@monsters.each do |mon|
|
|
56
|
+
next if @combat.dead?(mon.thing_idx)
|
|
57
|
+
|
|
58
|
+
if mon.active
|
|
59
|
+
mon.chase_timer -= 1
|
|
60
|
+
if mon.chase_timer <= 0
|
|
61
|
+
mon.chase_timer = CHASE_TICS
|
|
62
|
+
chase(mon, player_x, player_y)
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
look(mon, player_x, player_y)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def look(mon, player_x, player_y)
|
|
73
|
+
dx = player_x - mon.x
|
|
74
|
+
dy = player_y - mon.y
|
|
75
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
76
|
+
return if dist > SIGHT_RANGE
|
|
77
|
+
|
|
78
|
+
# DOOM A_Look: monster only sees in ~180-degree forward arc
|
|
79
|
+
# unless player is very close (melee range)
|
|
80
|
+
if dist > MELEE_RANGE
|
|
81
|
+
thing = @map.things[mon.thing_idx]
|
|
82
|
+
face_angle = thing.angle * Math::PI / 180.0
|
|
83
|
+
to_player = Math.atan2(dy, dx)
|
|
84
|
+
angle_diff = ((to_player - face_angle + Math::PI) % (2 * Math::PI) - Math::PI).abs
|
|
85
|
+
return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
|
|
89
|
+
mon.active = true
|
|
90
|
+
mon.chase_timer = CHASE_TICS
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def chase(mon, player_x, player_y)
|
|
95
|
+
speed = MONSTER_SPEED[mon.type] || 8
|
|
96
|
+
|
|
97
|
+
# Decrement movecount; pick new direction when expired or blocked
|
|
98
|
+
mon.movecount -= 1
|
|
99
|
+
if mon.movecount < 0 || !try_move(mon, speed)
|
|
100
|
+
new_chase_dir(mon, player_x, player_y)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Update the thing's position and facing angle in the map for rendering
|
|
104
|
+
thing = @map.things[mon.thing_idx]
|
|
105
|
+
thing.x = mon.x.to_i
|
|
106
|
+
thing.y = mon.y.to_i
|
|
107
|
+
|
|
108
|
+
# Face toward the player (smooth turning)
|
|
109
|
+
target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
|
|
110
|
+
thing.angle = target_angle.round.to_i
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def try_move(mon, speed)
|
|
114
|
+
return false if mon.movedir == DI_NODIR
|
|
115
|
+
|
|
116
|
+
new_x = mon.x + speed * XSPEED[mon.movedir]
|
|
117
|
+
new_y = mon.y + speed * YSPEED[mon.movedir]
|
|
118
|
+
|
|
119
|
+
# Check if the position is valid (inside a sector, not blocked by walls)
|
|
120
|
+
sector = @map.sector_at(new_x, new_y)
|
|
121
|
+
return false unless sector
|
|
122
|
+
|
|
123
|
+
# Check wall collision
|
|
124
|
+
blocked = false
|
|
125
|
+
@map.linedefs.each do |ld|
|
|
126
|
+
v1 = @map.vertices[ld.v1]
|
|
127
|
+
v2 = @map.vertices[ld.v2]
|
|
128
|
+
|
|
129
|
+
# Simple line-circle intersection
|
|
130
|
+
radius = Combat::MONSTER_RADIUS[mon.type] || 20
|
|
131
|
+
next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
|
|
132
|
+
|
|
133
|
+
# One-sided walls always block
|
|
134
|
+
if ld.sidedef_left == 0xFFFF
|
|
135
|
+
blocked = true
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Two-sided: check step height and headroom
|
|
140
|
+
if ld.sidedef_left < 0xFFFF
|
|
141
|
+
front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
|
|
142
|
+
back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
|
|
143
|
+
step = (back.floor_height - front.floor_height).abs
|
|
144
|
+
min_ceil = [front.ceiling_height, back.ceiling_height].min
|
|
145
|
+
max_floor = [front.floor_height, back.floor_height].max
|
|
146
|
+
if step > 24 || (min_ceil - max_floor) < 56
|
|
147
|
+
blocked = true
|
|
148
|
+
break
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
return false if blocked
|
|
153
|
+
|
|
154
|
+
mon.x = new_x
|
|
155
|
+
mon.y = new_y
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def new_chase_dir(mon, player_x, player_y)
|
|
160
|
+
deltax = player_x - mon.x
|
|
161
|
+
deltay = player_y - mon.y
|
|
162
|
+
old_dir = mon.movedir
|
|
163
|
+
|
|
164
|
+
# Determine preferred directions
|
|
165
|
+
dir_x = if deltax > 10 then DI_EAST
|
|
166
|
+
elsif deltax < -10 then DI_WEST
|
|
167
|
+
else DI_NODIR
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
dir_y = if deltay > 10 then DI_NORTH
|
|
171
|
+
elsif deltay < -10 then DI_SOUTH
|
|
172
|
+
else DI_NODIR
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Try diagonal
|
|
176
|
+
if dir_x != DI_NODIR && dir_y != DI_NODIR
|
|
177
|
+
diag = diagonal_dir(dir_x, dir_y)
|
|
178
|
+
if diag != OPPOSITE[old_dir]
|
|
179
|
+
mon.movedir = diag
|
|
180
|
+
if try_walk(mon)
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Randomly swap X/Y priority
|
|
187
|
+
if rand > 0.22 || deltay.abs > deltax.abs
|
|
188
|
+
dir_x, dir_y = dir_y, dir_x
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Try primary direction
|
|
192
|
+
if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
|
|
193
|
+
mon.movedir = dir_x
|
|
194
|
+
return if try_walk(mon)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Try secondary direction
|
|
198
|
+
if dir_y != DI_NODIR && dir_y != OPPOSITE[old_dir]
|
|
199
|
+
mon.movedir = dir_y
|
|
200
|
+
return if try_walk(mon)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Try old direction
|
|
204
|
+
if old_dir != DI_NODIR
|
|
205
|
+
mon.movedir = old_dir
|
|
206
|
+
return if try_walk(mon)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Try all other directions
|
|
210
|
+
start = rand(8)
|
|
211
|
+
8.times do |i|
|
|
212
|
+
d = (start + i) % 8
|
|
213
|
+
next if d == OPPOSITE[old_dir]
|
|
214
|
+
mon.movedir = d
|
|
215
|
+
return if try_walk(mon)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Last resort: turnaround
|
|
219
|
+
if old_dir != DI_NODIR
|
|
220
|
+
mon.movedir = OPPOSITE[old_dir]
|
|
221
|
+
return if try_walk(mon)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
mon.movedir = DI_NODIR
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def try_walk(mon)
|
|
228
|
+
speed = MONSTER_SPEED[mon.type] || 8
|
|
229
|
+
if try_move(mon, speed)
|
|
230
|
+
mon.movecount = rand(16)
|
|
231
|
+
true
|
|
232
|
+
else
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def diagonal_dir(dx, dy)
|
|
238
|
+
case [dx, dy]
|
|
239
|
+
when [DI_EAST, DI_NORTH] then DI_NORTHEAST
|
|
240
|
+
when [DI_EAST, DI_SOUTH] then DI_SOUTHEAST
|
|
241
|
+
when [DI_WEST, DI_NORTH] then DI_NORTHWEST
|
|
242
|
+
when [DI_WEST, DI_SOUTH] then DI_SOUTHWEST
|
|
243
|
+
else DI_NODIR
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def has_line_of_sight?(x1, y1, x2, y2)
|
|
248
|
+
# Check if any wall blocks the line of sight
|
|
249
|
+
@map.linedefs.each do |ld|
|
|
250
|
+
v1 = @map.vertices[ld.v1]
|
|
251
|
+
v2 = @map.vertices[ld.v2]
|
|
252
|
+
|
|
253
|
+
next unless segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
254
|
+
|
|
255
|
+
# One-sided walls always block
|
|
256
|
+
return false if ld.sidedef_left == 0xFFFF
|
|
257
|
+
|
|
258
|
+
# Two-sided: check if opening is big enough to see through
|
|
259
|
+
if ld.sidedef_left < 0xFFFF
|
|
260
|
+
front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
|
|
261
|
+
back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
|
|
262
|
+
max_floor = [front.floor_height, back.floor_height].max
|
|
263
|
+
min_ceil = [front.ceiling_height, back.ceiling_height].min
|
|
264
|
+
# Block sight if the opening is too small
|
|
265
|
+
return false if (min_ceil - max_floor) < 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
|
|
272
|
+
d1x = ax2 - ax1; d1y = ay2 - ay1
|
|
273
|
+
d2x = bx2 - bx1; d2y = by2 - by1
|
|
274
|
+
denom = d1x * d2y - d1y * d2x
|
|
275
|
+
return false if denom.abs < 0.001
|
|
276
|
+
dx = bx1 - ax1; dy = by1 - ay1
|
|
277
|
+
t = (dx * d2y - dy * d2x).to_f / denom
|
|
278
|
+
u = (dx * d1y - dy * d1x).to_f / denom
|
|
279
|
+
t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
|
|
283
|
+
dx = cx - x1; dy = cy - y1
|
|
284
|
+
line_dx = x2 - x1; line_dy = y2 - y1
|
|
285
|
+
line_len_sq = line_dx * line_dx + line_dy * line_dy
|
|
286
|
+
return false if line_len_sq == 0
|
|
287
|
+
t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
|
|
288
|
+
t = [[t, 0.0].max, 1.0].min
|
|
289
|
+
closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
|
|
290
|
+
dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
|
|
291
|
+
dist_sq < radius * radius
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -26,16 +26,16 @@ module Doom
|
|
|
26
26
|
WEAPON_CHAINSAW => :chainsaw
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
|
-
# Attack durations
|
|
29
|
+
# Attack durations in tics (at 35fps), matching DOOM's weapon state sequences
|
|
30
30
|
ATTACK_DURATIONS = {
|
|
31
|
-
WEAPON_FIST =>
|
|
32
|
-
WEAPON_PISTOL =>
|
|
33
|
-
WEAPON_SHOTGUN =>
|
|
34
|
-
WEAPON_CHAINGUN =>
|
|
35
|
-
WEAPON_ROCKET =>
|
|
36
|
-
WEAPON_PLASMA =>
|
|
37
|
-
WEAPON_BFG =>
|
|
38
|
-
WEAPON_CHAINSAW =>
|
|
31
|
+
WEAPON_FIST => 14, # Punch windup + swing
|
|
32
|
+
WEAPON_PISTOL => 16, # S_PISTOL: 6+4+5+1 tics
|
|
33
|
+
WEAPON_SHOTGUN => 40, # Pump action cycle
|
|
34
|
+
WEAPON_CHAINGUN => 8, # Rapid fire (2 shots per cycle)
|
|
35
|
+
WEAPON_ROCKET => 20, # Rocket launch + recovery
|
|
36
|
+
WEAPON_PLASMA => 8, # Fast energy weapon
|
|
37
|
+
WEAPON_BFG => 60, # Long charge + fire
|
|
38
|
+
WEAPON_CHAINSAW => 6 # Fast melee
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
41
|
attr_accessor :health, :armor, :max_health, :max_armor
|
|
@@ -46,6 +46,28 @@ module Doom
|
|
|
46
46
|
attr_accessor :attacking, :attack_frame, :attack_tics
|
|
47
47
|
attr_accessor :bob_angle, :bob_amount
|
|
48
48
|
attr_accessor :is_moving
|
|
49
|
+
attr_accessor :dead, :death_tic
|
|
50
|
+
attr_accessor :damage_count # Red flash intensity (0-8), decays each tic
|
|
51
|
+
|
|
52
|
+
# Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
|
|
53
|
+
VIEWHEIGHT = 41.0
|
|
54
|
+
VIEWHEIGHT_HALF = VIEWHEIGHT / 2.0
|
|
55
|
+
DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
|
|
56
|
+
attr_reader :viewheight, :deltaviewheight
|
|
57
|
+
|
|
58
|
+
# View bob (camera bounce when walking, matching Chocolate Doom's
|
|
59
|
+
# P_CalcHeight + P_XYMovement + P_Thrust from p_user.c / p_mobj.c)
|
|
60
|
+
MAXBOB = 16.0 # Maximum bob amplitude (0x100000 in fixed-point = 16 map units)
|
|
61
|
+
STOPSPEED = 0.0625 # Snap-to-zero threshold (0x1000 in fixed-point)
|
|
62
|
+
# Continuous-time equivalents of DOOM's per-tic constants (35 fps tic rate):
|
|
63
|
+
# FRICTION = 0xE800/0x10000 = 0.90625 per tic
|
|
64
|
+
# decay_rate = -ln(0.90625) * 35 = 3.44/sec
|
|
65
|
+
# walk thrust = forwardmove(25) * 2048 / 65536 = 0.78 map units/tic = 27.3/sec
|
|
66
|
+
# terminal velocity = 27.3 / 3.44 = 7.56 -> bob = 7.56^2/4 = 14.3 (89% of MAXBOB)
|
|
67
|
+
BOB_DECAY_RATE = 3.44 # Friction as continuous decay rate (1/sec)
|
|
68
|
+
BOB_THRUST = 26.0 # Walk thrust (map units/sec), gives terminal ~7.5
|
|
69
|
+
BOB_FREQUENCY = 11.0 # Bob cycle frequency (rad/sec): FINEANGLES/20 * 35 / 8192 * 2*PI
|
|
70
|
+
attr_reader :view_bob_offset
|
|
49
71
|
|
|
50
72
|
def initialize
|
|
51
73
|
reset
|
|
@@ -87,10 +109,27 @@ module Doom
|
|
|
87
109
|
@attack_frame = 0
|
|
88
110
|
@attack_tics = 0
|
|
89
111
|
|
|
112
|
+
# Death state
|
|
113
|
+
@dead = false
|
|
114
|
+
@death_tic = 0
|
|
115
|
+
@damage_count = 0
|
|
116
|
+
|
|
90
117
|
# Weapon bob
|
|
91
118
|
@bob_angle = 0.0
|
|
92
119
|
@bob_amount = 0.0
|
|
93
120
|
@is_moving = false
|
|
121
|
+
|
|
122
|
+
# Smooth step height (P_CalcHeight viewheight/deltaviewheight)
|
|
123
|
+
@viewheight = VIEWHEIGHT
|
|
124
|
+
@deltaviewheight = 0.0
|
|
125
|
+
|
|
126
|
+
# View bob (camera bounce) - simulated momentum for P_CalcHeight
|
|
127
|
+
@view_bob_offset = 0.0
|
|
128
|
+
@momx = 0.0 # Simulated X momentum (map units/sec, not actual movement)
|
|
129
|
+
@momy = 0.0 # Simulated Y momentum
|
|
130
|
+
@thrust_x = 0.0 # Per-frame thrust input (raw, before normalization)
|
|
131
|
+
@thrust_y = 0.0
|
|
132
|
+
@view_bob_angle = 0.0
|
|
94
133
|
end
|
|
95
134
|
|
|
96
135
|
def weapon_name
|
|
@@ -190,6 +229,66 @@ module Doom
|
|
|
190
229
|
end
|
|
191
230
|
end
|
|
192
231
|
|
|
232
|
+
# Called when player moves onto a different floor height.
|
|
233
|
+
# Matches Chocolate Doom P_ZMovement: reduce viewheight by the step amount
|
|
234
|
+
# so the camera doesn't snap, then let P_CalcHeight recover it smoothly.
|
|
235
|
+
def notify_step(step_amount)
|
|
236
|
+
return if step_amount == 0
|
|
237
|
+
@viewheight -= step_amount
|
|
238
|
+
@deltaviewheight = (VIEWHEIGHT - @viewheight) / 8.0
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Gradually restore viewheight to VIEWHEIGHT (called each tic).
|
|
242
|
+
# Matches Chocolate Doom P_CalcHeight viewheight recovery loop.
|
|
243
|
+
# For step-up: viewheight < 41, delta > 0, accelerates upward.
|
|
244
|
+
# For step-down: viewheight > 41, delta < 0, decelerates then recovers.
|
|
245
|
+
def update_viewheight
|
|
246
|
+
@viewheight += @deltaviewheight
|
|
247
|
+
|
|
248
|
+
if @viewheight > VIEWHEIGHT && @deltaviewheight >= 0
|
|
249
|
+
@viewheight = VIEWHEIGHT
|
|
250
|
+
@deltaviewheight = 0.0
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if @viewheight < VIEWHEIGHT_HALF
|
|
254
|
+
@viewheight = VIEWHEIGHT_HALF
|
|
255
|
+
@deltaviewheight = 1.0 if @deltaviewheight <= 0
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if @deltaviewheight != 0
|
|
259
|
+
@deltaviewheight += DELTA_ACCEL
|
|
260
|
+
@deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Set movement momentum directly (called from GosuWindow with actual
|
|
265
|
+
# movement momentum, which already has thrust + friction applied).
|
|
266
|
+
def set_movement_momentum(momx, momy)
|
|
267
|
+
@momx = momx
|
|
268
|
+
@momy = momy
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Compute view bob from actual movement momentum.
|
|
272
|
+
# Matches Chocolate Doom P_CalcHeight:
|
|
273
|
+
# bob = (momx*momx + momy*momy) >> 2, capped at MAXBOB
|
|
274
|
+
# viewz += finesine[angle] * bob/2
|
|
275
|
+
# Momentum is in units/sec; DOOM's bob uses units/tic (divide by 35).
|
|
276
|
+
BOB_MOM_SCALE = 1.0 / (35.0 * 35.0 * 4.0) # (mom/35)^2 / 4
|
|
277
|
+
|
|
278
|
+
def update_view_bob(delta_time)
|
|
279
|
+
dt = delta_time.clamp(0.001, 0.05)
|
|
280
|
+
|
|
281
|
+
# P_CalcHeight: bob = (momx_per_tic^2 + momy_per_tic^2) / 4, capped at MAXBOB
|
|
282
|
+
bob = (@momx * @momx + @momy * @momy) * BOB_MOM_SCALE
|
|
283
|
+
bob = MAXBOB if bob > MAXBOB
|
|
284
|
+
|
|
285
|
+
# Advance bob sine wave (FINEANGLES/20 per tic = ~11 rad/sec)
|
|
286
|
+
@view_bob_angle += BOB_FREQUENCY * dt
|
|
287
|
+
|
|
288
|
+
# viewz offset: sin(angle) * bob/2
|
|
289
|
+
@view_bob_offset = Math.sin(@view_bob_angle) * bob / 2.0
|
|
290
|
+
end
|
|
291
|
+
|
|
193
292
|
def weapon_bob_x
|
|
194
293
|
Math.cos(@bob_angle) * @bob_amount
|
|
195
294
|
end
|
|
@@ -216,6 +315,42 @@ module Doom
|
|
|
216
315
|
|
|
217
316
|
@weapon = weapon_num
|
|
218
317
|
end
|
|
318
|
+
|
|
319
|
+
# Apply damage (from environment or enemies). Armor absorbs some.
|
|
320
|
+
def take_damage(amount)
|
|
321
|
+
return if @dead
|
|
322
|
+
|
|
323
|
+
absorbed = 0
|
|
324
|
+
if @armor > 0
|
|
325
|
+
absorbed = amount / 3 # Green armor absorbs 1/3
|
|
326
|
+
absorbed = @armor if absorbed > @armor
|
|
327
|
+
@armor -= absorbed
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
actual = amount - absorbed
|
|
331
|
+
@health -= actual
|
|
332
|
+
|
|
333
|
+
# Red flash proportional to damage (capped at palette 8)
|
|
334
|
+
@damage_count = [(@damage_count + actual / 2.0).ceil, 8].min
|
|
335
|
+
|
|
336
|
+
if @health <= 0
|
|
337
|
+
@health = 0
|
|
338
|
+
@damage_count = 8
|
|
339
|
+
die
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Decay damage flash each tic
|
|
344
|
+
def update_damage_count
|
|
345
|
+
@damage_count -= 1 if @damage_count > 0
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def die
|
|
349
|
+
@dead = true
|
|
350
|
+
@death_tic = 0
|
|
351
|
+
@attacking = false
|
|
352
|
+
@deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
|
|
353
|
+
end
|
|
219
354
|
end
|
|
220
355
|
end
|
|
221
356
|
end
|