doom 0.5.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 +43 -21
- data/bin/doom +5 -3
- data/lib/doom/game/combat.rb +127 -1
- data/lib/doom/game/monster_ai.rb +295 -0
- data/lib/doom/game/player_state.rb +43 -0
- data/lib/doom/platform/gosu_window.rb +139 -7
- data/lib/doom/render/renderer.rb +51 -2
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/sprite.rb +5 -4
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +3 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3cb562e8b88b905638cfa8792337bc1b7f818753fa90587bda474784513a6bd
|
|
4
|
+
data.tar.gz: 1f421713b797c02dfc10e2f9bdd3ad0981a631f88bc94df161aa5a792b539094
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b06fd18ebfa1c5755e27c11b49e9e568fc4e2acfc7bc2b9760e7b7dda500a438afe9a6ecf0e33aa4f17c2271a6c28205e6bbb793e3b9c618c4df24b8bf74679a
|
|
7
|
+
data.tar.gz: ed5387b2c42963956cfe42c1cfe790a8f7f0e89a15c5a38934780b5ceaca34e3e64456756280d9c8f2d280763ef0fff89434dd8e826dc2e9e0184e25c4f94508
|
data/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# DOOM Ruby
|
|
2
2
|
|
|
3
|
-
A faithful
|
|
3
|
+
A faithful port of the DOOM (1993) engine to pure Ruby. Renders the original WAD files with near pixel-perfect BSP rendering, full HUD, item pickups, and hitscan/projectile combat.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
9
|
+
- **Rendering**: BSP traversal, visplanes, drawsegs, sprite clipping, sky rendering matching Chocolate Doom
|
|
10
|
+
- **Combat**: Hitscan weapons (pistol, shotgun, chaingun), melee (fist, chainsaw), projectile rockets with splash damage
|
|
11
|
+
- **Items**: Weapons, ammo, health, armor, keys -- all pickupable with correct DOOM behavior
|
|
12
|
+
- **Movement**: Momentum-based physics with friction, smooth step transitions, wall sliding, view bob
|
|
13
|
+
- **HUD**: Full status bar with ammo, health, armor, face, weapon selector, key cards, small ammo counts
|
|
14
|
+
- **Effects**: Animated textures (NUKAGE, SLADRIP), sector light effects (flickering, glowing, strobing), scrolling walls
|
|
15
|
+
- **Monsters**: Death animations, solid collision, HP tracking
|
|
16
|
+
- **Compatibility**: Supports original WAD files (shareware and registered), YJIT-optimized
|
|
15
17
|
|
|
16
18
|
## Installation
|
|
17
19
|
|
|
@@ -21,7 +23,7 @@ gem install doom
|
|
|
21
23
|
|
|
22
24
|
## Quick Start
|
|
23
25
|
|
|
24
|
-
Just run `doom`
|
|
26
|
+
Just run `doom` -- it will offer to download the free shareware version:
|
|
25
27
|
|
|
26
28
|
```bash
|
|
27
29
|
doom
|
|
@@ -44,11 +46,16 @@ doom /path/to/doom.wad
|
|
|
44
46
|
| Left Arrow | Turn left |
|
|
45
47
|
| Right Arrow | Turn right |
|
|
46
48
|
| Mouse | Look around (click to capture) |
|
|
49
|
+
| Left Click / X / Shift | Fire weapon |
|
|
50
|
+
| Space / E | Use (open doors) |
|
|
51
|
+
| 1-7 | Switch weapons |
|
|
52
|
+
| M | Toggle automap |
|
|
53
|
+
| Z | Toggle debug overlay |
|
|
47
54
|
| Escape | Release mouse / Quit |
|
|
48
55
|
|
|
49
56
|
## Requirements
|
|
50
57
|
|
|
51
|
-
- Ruby 3.1
|
|
58
|
+
- Ruby 3.1+ (Ruby 4.0 with YJIT recommended for best performance)
|
|
52
59
|
- Gosu gem (for window/graphics)
|
|
53
60
|
- SDL2 (native library required by Gosu)
|
|
54
61
|
|
|
@@ -61,7 +68,7 @@ brew install sdl2
|
|
|
61
68
|
|
|
62
69
|
**Ubuntu/Debian:**
|
|
63
70
|
```bash
|
|
64
|
-
sudo apt-get install build-essential libsdl2-dev libgl1-mesa-dev libfontconfig1-dev
|
|
71
|
+
sudo apt-get install build-essential libsdl2-dev libgl1-mesa-dev libopenal-dev libsndfile1-dev libmpg123-dev libfontconfig1-dev
|
|
65
72
|
```
|
|
66
73
|
|
|
67
74
|
**Fedora:**
|
|
@@ -75,15 +82,15 @@ sudo pacman -S sdl2 mesa
|
|
|
75
82
|
```
|
|
76
83
|
|
|
77
84
|
**Windows:**
|
|
78
|
-
No additional setup needed
|
|
85
|
+
No additional setup needed -- the gem includes SDL2.
|
|
79
86
|
|
|
80
87
|
## Development
|
|
81
88
|
|
|
82
89
|
```bash
|
|
83
|
-
git clone https://github.com/khasinski/doom
|
|
84
|
-
cd doom
|
|
90
|
+
git clone https://github.com/khasinski/doom.git
|
|
91
|
+
cd doom
|
|
85
92
|
bundle install
|
|
86
|
-
ruby bin/doom
|
|
93
|
+
ruby bin/doom
|
|
87
94
|
```
|
|
88
95
|
|
|
89
96
|
Run specs:
|
|
@@ -92,15 +99,30 @@ Run specs:
|
|
|
92
99
|
bundle exec rspec
|
|
93
100
|
```
|
|
94
101
|
|
|
102
|
+
### Benchmarking
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ruby bench/benchmark.rb # without YJIT
|
|
106
|
+
ruby --yjit bench/benchmark.rb # with YJIT
|
|
107
|
+
ruby bench/benchmark.rb --compare # side-by-side
|
|
108
|
+
ruby bench/benchmark.rb --profile # CPU profile with StackProf
|
|
109
|
+
```
|
|
110
|
+
|
|
95
111
|
## Technical Details
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
- **BSP Traversal**: Front-to-back rendering using the map's BSP tree with R_CheckBBox culling
|
|
114
|
+
- **Visplanes**: Floor/ceiling rendering with R_CheckPlane splitting and span-based drawing
|
|
115
|
+
- **Drawsegs**: Wall segment tracking for proper sprite clipping (silhouette system)
|
|
116
|
+
- **Texture Mapping**: Perspective-correct ray-seg intersection with non-power-of-2 support
|
|
117
|
+
- **Lighting**: Distance-based light diminishing with wall and flat colormaps
|
|
118
|
+
- **Sky Rendering**: Chocolate Doom sky hack (worldtop = worldhigh) with correct placement
|
|
119
|
+
- **Movement Physics**: Continuous-time momentum/friction matching Chocolate Doom's P_XYMovement
|
|
120
|
+
- **Hitscan**: Ray tracing against walls and monster bounding circles
|
|
121
|
+
- **Projectiles**: Physical rockets with wall/monster collision and splash damage
|
|
122
|
+
|
|
123
|
+
## Performance
|
|
98
124
|
|
|
99
|
-
-
|
|
100
|
-
- **Visplanes**: Floor/ceiling rendering with R_CheckPlane splitting
|
|
101
|
-
- **Drawsegs**: Wall segment tracking for proper sprite clipping
|
|
102
|
-
- **Texture Mapping**: Perspective-correct texture coordinates
|
|
103
|
-
- **Lighting**: Distance-based light diminishing with colormaps
|
|
125
|
+
With Ruby 4.0 and YJIT enabled, the renderer achieves 80-130 FPS on E1M1 (Apple Silicon). See [docs/performance-profiling.md](docs/performance-profiling.md) and [docs/yjit-vs-zjit.md](docs/yjit-vs-zjit.md) for detailed analysis.
|
|
104
126
|
|
|
105
127
|
## Legal
|
|
106
128
|
|
|
@@ -112,7 +134,7 @@ please purchase DOOM from [Steam](https://store.steampowered.com/app/2280/Ultima
|
|
|
112
134
|
|
|
113
135
|
## License
|
|
114
136
|
|
|
115
|
-
GPL-2.0
|
|
137
|
+
GPL-2.0 -- Same license as the original DOOM source code.
|
|
116
138
|
|
|
117
139
|
## Author
|
|
118
140
|
|
data/bin/doom
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Enable YJIT
|
|
5
|
-
|
|
6
|
-
RubyVM::YJIT.enable
|
|
4
|
+
# Enable YJIT unless --no-yjit flag is passed (for live toggle demo)
|
|
5
|
+
unless ARGV.delete('--no-yjit')
|
|
6
|
+
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
|
|
7
|
+
RubyVM::YJIT.enable
|
|
8
|
+
end
|
|
7
9
|
end
|
|
8
10
|
|
|
9
11
|
# Parse arguments before loading heavy dependencies
|
data/lib/doom/game/combat.rb
CHANGED
|
@@ -50,6 +50,15 @@ module Doom
|
|
|
50
50
|
|
|
51
51
|
DEATH_ANIM_TICS = 6 # Tics per death frame
|
|
52
52
|
|
|
53
|
+
# Projectile constants
|
|
54
|
+
ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
|
|
55
|
+
ROCKET_DAMAGE = 20 # Direct hit base (DOOM: 1d8 * 20)
|
|
56
|
+
ROCKET_RADIUS = 11 # Collision radius
|
|
57
|
+
SPLASH_RADIUS = 128.0 # Splash damage radius
|
|
58
|
+
SPLASH_DAMAGE = 128 # Max splash damage at center
|
|
59
|
+
|
|
60
|
+
Projectile = Struct.new(:x, :y, :z, :dx, :dy, :type, :spawn_tic)
|
|
61
|
+
|
|
53
62
|
# Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
|
|
54
63
|
# Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
|
|
55
64
|
# Shotgun: 7 pellets, each 1*5..3*5 = 5-15
|
|
@@ -61,10 +70,12 @@ module Doom
|
|
|
61
70
|
@sprites = sprites
|
|
62
71
|
@monster_hp = {} # thing_idx => current HP
|
|
63
72
|
@dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
|
|
73
|
+
@projectiles = [] # Active projectiles in flight
|
|
74
|
+
@explosions = [] # Active explosions (for rendering)
|
|
64
75
|
@tic = 0
|
|
65
76
|
end
|
|
66
77
|
|
|
67
|
-
attr_reader :dead_things
|
|
78
|
+
attr_reader :dead_things, :projectiles, :explosions
|
|
68
79
|
|
|
69
80
|
def dead?(thing_idx)
|
|
70
81
|
@dead_things.key?(thing_idx)
|
|
@@ -88,6 +99,8 @@ module Doom
|
|
|
88
99
|
# Called each game tic
|
|
89
100
|
def update
|
|
90
101
|
@tic += 1
|
|
102
|
+
update_projectiles
|
|
103
|
+
update_explosions
|
|
91
104
|
end
|
|
92
105
|
|
|
93
106
|
# Fire the current weapon
|
|
@@ -97,6 +110,8 @@ module Doom
|
|
|
97
110
|
hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
|
|
98
111
|
when PlayerState::WEAPON_SHOTGUN
|
|
99
112
|
hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
|
|
113
|
+
when PlayerState::WEAPON_ROCKET
|
|
114
|
+
spawn_rocket(px, py, pz, cos_a, sin_a)
|
|
100
115
|
when PlayerState::WEAPON_FIST
|
|
101
116
|
melee(px, py, cos_a, sin_a, 2, 64)
|
|
102
117
|
when PlayerState::WEAPON_CHAINSAW
|
|
@@ -106,6 +121,117 @@ module Doom
|
|
|
106
121
|
|
|
107
122
|
private
|
|
108
123
|
|
|
124
|
+
def spawn_rocket(px, py, pz, cos_a, sin_a)
|
|
125
|
+
# Spawn slightly ahead of the player
|
|
126
|
+
@projectiles << Projectile.new(
|
|
127
|
+
px + cos_a * 20, py + sin_a * 20, pz,
|
|
128
|
+
cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED,
|
|
129
|
+
:rocket, @tic
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update_projectiles
|
|
134
|
+
@projectiles.reject! do |proj|
|
|
135
|
+
# Move projectile
|
|
136
|
+
new_x = proj.x + proj.dx
|
|
137
|
+
new_y = proj.y + proj.dy
|
|
138
|
+
|
|
139
|
+
# Check wall collision
|
|
140
|
+
hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
|
|
141
|
+
|
|
142
|
+
# Check monster collision
|
|
143
|
+
hit_monster = nil
|
|
144
|
+
@map.things.each_with_index do |thing, idx|
|
|
145
|
+
next unless MONSTER_HP[thing.type]
|
|
146
|
+
next if @dead_things[idx]
|
|
147
|
+
radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
|
|
148
|
+
dx = new_x - thing.x
|
|
149
|
+
dy = new_y - thing.y
|
|
150
|
+
if dx * dx + dy * dy < radius * radius
|
|
151
|
+
hit_monster = idx
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if hit_wall || hit_monster
|
|
157
|
+
explode(new_x, new_y, hit_monster)
|
|
158
|
+
true # Remove projectile
|
|
159
|
+
else
|
|
160
|
+
proj.x = new_x
|
|
161
|
+
proj.y = new_y
|
|
162
|
+
false # Keep projectile
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def explode(x, y, direct_hit_idx)
|
|
168
|
+
# Direct hit damage
|
|
169
|
+
if direct_hit_idx
|
|
170
|
+
damage = (rand(8) + 1) * ROCKET_DAMAGE
|
|
171
|
+
apply_damage(direct_hit_idx, damage)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Splash damage to all monsters in radius
|
|
175
|
+
@map.things.each_with_index do |thing, idx|
|
|
176
|
+
next unless MONSTER_HP[thing.type]
|
|
177
|
+
next if @dead_things[idx]
|
|
178
|
+
next if idx == direct_hit_idx # Already took direct hit
|
|
179
|
+
|
|
180
|
+
dx = x - thing.x
|
|
181
|
+
dy = y - thing.y
|
|
182
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
183
|
+
next if dist >= SPLASH_RADIUS
|
|
184
|
+
|
|
185
|
+
# Damage falls off linearly with distance
|
|
186
|
+
damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
|
|
187
|
+
apply_damage(idx, damage) if damage > 0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Spawn explosion visual
|
|
191
|
+
@explosions << { x: x, y: y, tic: @tic }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def update_explosions
|
|
195
|
+
# Explosions last 20 tics then disappear
|
|
196
|
+
@explosions.reject! { |e| @tic - e[:tic] > 20 }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def hits_wall?(x1, y1, x2, y2)
|
|
200
|
+
@map.linedefs.each do |ld|
|
|
201
|
+
# One-sided walls always block
|
|
202
|
+
blocks = ld.sidedef_left == 0xFFFF || (ld.flags & 0x0001 != 0)
|
|
203
|
+
unless blocks
|
|
204
|
+
next unless ld.sidedef_left < 0xFFFF
|
|
205
|
+
front = @map.sidedefs[ld.sidedef_right]
|
|
206
|
+
back = @map.sidedefs[ld.sidedef_left]
|
|
207
|
+
fs = @map.sectors[front.sector]
|
|
208
|
+
bs = @map.sectors[back.sector]
|
|
209
|
+
max_floor = [fs.floor_height, bs.floor_height].max
|
|
210
|
+
min_ceil = [fs.ceiling_height, bs.ceiling_height].min
|
|
211
|
+
blocks = (min_ceil - max_floor) < 56
|
|
212
|
+
end
|
|
213
|
+
next unless blocks
|
|
214
|
+
|
|
215
|
+
v1 = @map.vertices[ld.v1]
|
|
216
|
+
v2 = @map.vertices[ld.v2]
|
|
217
|
+
if segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
218
|
+
return true
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
|
|
225
|
+
d1x = ax2 - ax1; d1y = ay2 - ay1
|
|
226
|
+
d2x = bx2 - bx1; d2y = by2 - by1
|
|
227
|
+
denom = d1x * d2y - d1y * d2x
|
|
228
|
+
return false if denom.abs < 0.001
|
|
229
|
+
dx = bx1 - ax1; dy = by1 - ay1
|
|
230
|
+
t = (dx * d2y - dy * d2x).to_f / denom
|
|
231
|
+
u = (dx * d1y - dy * d1x).to_f / denom
|
|
232
|
+
t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
|
|
233
|
+
end
|
|
234
|
+
|
|
109
235
|
def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
|
|
110
236
|
pellets.times do
|
|
111
237
|
# Add random spread
|
|
@@ -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
|
|
@@ -46,6 +46,8 @@ 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
|
|
49
51
|
|
|
50
52
|
# Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
|
|
51
53
|
VIEWHEIGHT = 41.0
|
|
@@ -107,6 +109,11 @@ module Doom
|
|
|
107
109
|
@attack_frame = 0
|
|
108
110
|
@attack_tics = 0
|
|
109
111
|
|
|
112
|
+
# Death state
|
|
113
|
+
@dead = false
|
|
114
|
+
@death_tic = 0
|
|
115
|
+
@damage_count = 0
|
|
116
|
+
|
|
110
117
|
# Weapon bob
|
|
111
118
|
@bob_angle = 0.0
|
|
112
119
|
@bob_amount = 0.0
|
|
@@ -308,6 +315,42 @@ module Doom
|
|
|
308
315
|
|
|
309
316
|
@weapon = weapon_num
|
|
310
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
|
|
311
354
|
end
|
|
312
355
|
end
|
|
313
356
|
end
|
|
@@ -48,9 +48,11 @@ module Doom
|
|
|
48
48
|
2046 => 16, # Burning barrel
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
|
-
def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil, animations = nil, sector_effects = nil, item_pickup = nil, combat = nil)
|
|
52
|
-
|
|
51
|
+
def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil, animations = nil, sector_effects = nil, item_pickup = nil, combat = nil, monster_ai = nil)
|
|
52
|
+
fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
|
|
53
|
+
super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
|
|
53
54
|
self.caption = 'Doom Ruby'
|
|
55
|
+
self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
|
|
54
56
|
|
|
55
57
|
@renderer = renderer
|
|
56
58
|
@palette = palette
|
|
@@ -63,6 +65,7 @@ module Doom
|
|
|
63
65
|
@sector_effects = sector_effects
|
|
64
66
|
@item_pickup = item_pickup
|
|
65
67
|
@combat = combat
|
|
68
|
+
@monster_ai = monster_ai
|
|
66
69
|
@last_floor_height = nil
|
|
67
70
|
@move_momx = 0.0
|
|
68
71
|
@move_momy = 0.0
|
|
@@ -76,12 +79,21 @@ module Doom
|
|
|
76
79
|
@show_debug = false
|
|
77
80
|
@show_map = false
|
|
78
81
|
@debug_font = Gosu::Font.new(16)
|
|
82
|
+
@fps_frames = 0
|
|
83
|
+
@fps_time = Time.now
|
|
84
|
+
@fps_display = 0.0
|
|
79
85
|
|
|
80
86
|
# Precompute sector colors for automap
|
|
81
87
|
@sector_colors = build_sector_colors
|
|
82
88
|
|
|
83
|
-
# Pre-build palette
|
|
84
|
-
@
|
|
89
|
+
# Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
|
|
90
|
+
@all_palette_rgba = []
|
|
91
|
+
wad = renderer.instance_variable_get(:@wad)
|
|
92
|
+
14.times do |pal_idx|
|
|
93
|
+
pal = Wad::Palette.load(wad, pal_idx)
|
|
94
|
+
@all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
|
|
95
|
+
end
|
|
96
|
+
@palette_rgba = @all_palette_rgba[0]
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
def update
|
|
@@ -107,6 +119,19 @@ module Doom
|
|
|
107
119
|
@player_state&.update_viewheight
|
|
108
120
|
@player_state&.update_attack # Attack timing at 35fps like DOOM
|
|
109
121
|
@combat&.update
|
|
122
|
+
@monster_ai&.update(@renderer.player_x, @renderer.player_y)
|
|
123
|
+
|
|
124
|
+
@player_state&.update_damage_count
|
|
125
|
+
|
|
126
|
+
# Sector damage (nukage, lava, etc.) every 32 tics
|
|
127
|
+
if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
|
|
128
|
+
check_sector_damage
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Track death tic for death animation
|
|
132
|
+
if @player_state&.dead
|
|
133
|
+
@player_state.death_tic += 1
|
|
134
|
+
end
|
|
110
135
|
end
|
|
111
136
|
@animations&.update(@leveltime)
|
|
112
137
|
|
|
@@ -127,20 +152,38 @@ module Doom
|
|
|
127
152
|
|
|
128
153
|
# Pass combat state to renderer for death frame rendering
|
|
129
154
|
@renderer.combat = @combat
|
|
155
|
+
@renderer.monster_ai = @monster_ai
|
|
156
|
+
@renderer.leveltime = @leveltime
|
|
130
157
|
|
|
131
158
|
# Render the 3D world
|
|
132
159
|
@renderer.render_frame
|
|
133
160
|
|
|
134
161
|
# Render HUD on top
|
|
135
|
-
if @weapon_renderer
|
|
162
|
+
if @weapon_renderer && !@player_state&.dead
|
|
136
163
|
@weapon_renderer.render(@renderer.framebuffer)
|
|
137
164
|
end
|
|
138
165
|
if @status_bar
|
|
139
166
|
@status_bar.render(@renderer.framebuffer)
|
|
140
167
|
end
|
|
168
|
+
|
|
169
|
+
# Red tint when dead
|
|
170
|
+
if @player_state&.dead
|
|
171
|
+
apply_death_tint(@renderer.framebuffer)
|
|
172
|
+
end
|
|
141
173
|
end
|
|
142
174
|
|
|
143
175
|
def handle_input(delta_time)
|
|
176
|
+
# Handle respawn when dead
|
|
177
|
+
if @player_state&.dead
|
|
178
|
+
if @player_state.death_tic > 35 # 1 second delay before respawn allowed
|
|
179
|
+
if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
|
|
180
|
+
Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT)
|
|
181
|
+
respawn_player
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
return # No other input while dead
|
|
185
|
+
end
|
|
186
|
+
|
|
144
187
|
# Mouse look
|
|
145
188
|
handle_mouse_look
|
|
146
189
|
|
|
@@ -616,8 +659,10 @@ module Doom
|
|
|
616
659
|
if @show_map
|
|
617
660
|
draw_automap
|
|
618
661
|
else
|
|
619
|
-
#
|
|
620
|
-
|
|
662
|
+
# Select palette: red tint when taking damage (palettes 1-8)
|
|
663
|
+
pal_idx = @player_state ? @player_state.damage_count.clamp(0, 8) : 0
|
|
664
|
+
active_pal = @all_palette_rgba[pal_idx]
|
|
665
|
+
rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join
|
|
621
666
|
|
|
622
667
|
@screen_image = Gosu::Image.from_blob(
|
|
623
668
|
Render::SCREEN_WIDTH,
|
|
@@ -632,6 +677,16 @@ module Doom
|
|
|
632
677
|
end
|
|
633
678
|
|
|
634
679
|
def draw_debug_overlay
|
|
680
|
+
# Update FPS counter (refresh every 0.5 seconds)
|
|
681
|
+
@fps_frames += 1
|
|
682
|
+
now = Time.now
|
|
683
|
+
elapsed = now - @fps_time
|
|
684
|
+
if elapsed >= 0.5
|
|
685
|
+
@fps_display = (@fps_frames / elapsed).round(1)
|
|
686
|
+
@fps_frames = 0
|
|
687
|
+
@fps_time = now
|
|
688
|
+
end
|
|
689
|
+
|
|
635
690
|
sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
|
|
636
691
|
return unless sector
|
|
637
692
|
|
|
@@ -639,12 +694,14 @@ module Doom
|
|
|
639
694
|
sector_idx = @map.sectors.index(sector)
|
|
640
695
|
|
|
641
696
|
lines = [
|
|
697
|
+
"FPS: #{@fps_display}",
|
|
642
698
|
"Sector #{sector_idx}",
|
|
643
699
|
"Floor: #{sector.floor_height} (#{sector.floor_texture})",
|
|
644
700
|
"Ceil: #{sector.ceiling_height} (#{sector.ceiling_texture})",
|
|
645
701
|
"Light: #{sector.light_level}",
|
|
646
702
|
"Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
|
|
647
703
|
"Heading: #{(Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)}",
|
|
704
|
+
"YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'} (Y to toggle)",
|
|
648
705
|
]
|
|
649
706
|
|
|
650
707
|
y = 4
|
|
@@ -672,6 +729,17 @@ module Doom
|
|
|
672
729
|
end
|
|
673
730
|
when Gosu::KB_Z
|
|
674
731
|
@show_debug = !@show_debug
|
|
732
|
+
when Gosu::KB_Y
|
|
733
|
+
if defined?(RubyVM::YJIT)
|
|
734
|
+
setup_yjit_toggle
|
|
735
|
+
if RubyVM::YJIT.enabled?
|
|
736
|
+
RubyVM::YJIT.disable
|
|
737
|
+
puts "YJIT disabled!"
|
|
738
|
+
else
|
|
739
|
+
RubyVM::YJIT.enable
|
|
740
|
+
puts "YJIT enabled!"
|
|
741
|
+
end
|
|
742
|
+
end
|
|
675
743
|
when Gosu::KB_M
|
|
676
744
|
@show_map = !@show_map
|
|
677
745
|
when Gosu::KB_F12
|
|
@@ -679,6 +747,70 @@ module Doom
|
|
|
679
747
|
end
|
|
680
748
|
end
|
|
681
749
|
|
|
750
|
+
# Sector damage types from DOOM (p_spec.c)
|
|
751
|
+
# Type 5: 10 damage, Type 7: 5 damage, Type 4/16: 20 damage
|
|
752
|
+
SECTOR_DAMAGE = { 5 => 10, 7 => 5, 4 => 20, 16 => 20, 11 => 20 }.freeze
|
|
753
|
+
|
|
754
|
+
def check_sector_damage
|
|
755
|
+
sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
|
|
756
|
+
return unless sector
|
|
757
|
+
|
|
758
|
+
damage = SECTOR_DAMAGE[sector.special]
|
|
759
|
+
@player_state.take_damage(damage) if damage
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def apply_death_tint(framebuffer)
|
|
763
|
+
# Death keeps damage_count at max so the pain palette stays red
|
|
764
|
+
@player_state.damage_count = 8 if @player_state&.dead
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def respawn_player
|
|
768
|
+
@player_state.reset
|
|
769
|
+
@last_floor_height = nil
|
|
770
|
+
@move_momx = 0.0
|
|
771
|
+
@move_momy = 0.0
|
|
772
|
+
|
|
773
|
+
# Reset item pickup, combat, and monster AI state
|
|
774
|
+
sprites = @combat&.instance_variable_get(:@sprites)
|
|
775
|
+
@item_pickup = Game::ItemPickup.new(@map, @player_state) if @item_pickup
|
|
776
|
+
@combat = Game::Combat.new(@map, @player_state, sprites) if @combat && sprites
|
|
777
|
+
@monster_ai = Game::MonsterAI.new(@map, @combat) if @monster_ai && @combat
|
|
778
|
+
|
|
779
|
+
# Move player to start position
|
|
780
|
+
ps = @map.player_start
|
|
781
|
+
if ps
|
|
782
|
+
@renderer.set_player(ps.x, ps.y, 41, ps.angle)
|
|
783
|
+
update_player_height(ps.x, ps.y)
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def setup_yjit_toggle
|
|
788
|
+
return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
|
|
789
|
+
require "fiddle"
|
|
790
|
+
|
|
791
|
+
address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
|
|
792
|
+
enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)
|
|
793
|
+
|
|
794
|
+
RubyVM::YJIT.singleton_class.prepend(Module.new do
|
|
795
|
+
define_method(:enable) do |**kwargs|
|
|
796
|
+
return false if enabled?
|
|
797
|
+
return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
|
|
798
|
+
enabled_ptr[0] = 1
|
|
799
|
+
true
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
define_method(:disable) do
|
|
803
|
+
return false unless enabled?
|
|
804
|
+
enabled_ptr[0] = 0
|
|
805
|
+
true
|
|
806
|
+
end
|
|
807
|
+
end)
|
|
808
|
+
|
|
809
|
+
@yjit_toggle_ready = true
|
|
810
|
+
rescue => e
|
|
811
|
+
puts "YJIT toggle setup failed: #{e.message}"
|
|
812
|
+
end
|
|
813
|
+
|
|
682
814
|
def needs_cursor?
|
|
683
815
|
!@mouse_captured
|
|
684
816
|
end
|
data/lib/doom/render/renderer.rb
CHANGED
|
@@ -64,6 +64,8 @@ module Doom
|
|
|
64
64
|
@animations = animations
|
|
65
65
|
@hidden_things = nil
|
|
66
66
|
@combat = nil
|
|
67
|
+
@monster_ai = nil
|
|
68
|
+
@leveltime = 0
|
|
67
69
|
|
|
68
70
|
@framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
|
|
69
71
|
|
|
@@ -104,7 +106,7 @@ module Doom
|
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle, :framebuffer
|
|
107
|
-
attr_writer :hidden_things, :combat
|
|
109
|
+
attr_writer :hidden_things, :combat, :monster_ai, :leveltime
|
|
108
110
|
|
|
109
111
|
# Diagnostic: returns info about all sprites and why they are/aren't visible
|
|
110
112
|
def sprite_diagnostics
|
|
@@ -1302,9 +1304,17 @@ module Doom
|
|
|
1302
1304
|
dy = thing.y - @player_y
|
|
1303
1305
|
angle_to_thing = Math.atan2(dy, dx)
|
|
1304
1306
|
|
|
1305
|
-
# Get sprite
|
|
1307
|
+
# Get sprite: death frame > walking frame > idle frame
|
|
1306
1308
|
if @combat && @combat.dead?(thing_idx)
|
|
1307
1309
|
sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
|
|
1310
|
+
elsif @monster_ai
|
|
1311
|
+
mon = @monster_ai.monsters.find { |m| m.thing_idx == thing_idx }
|
|
1312
|
+
if mon && mon.active
|
|
1313
|
+
# Walking animation: cycle through frames A-D based on leveltime
|
|
1314
|
+
walk_frame = %w[A B C D][@leveltime / 4 % 4]
|
|
1315
|
+
sprite = @sprites.get_frame(thing.type, walk_frame, angle_to_thing, thing.angle)
|
|
1316
|
+
end
|
|
1317
|
+
sprite ||= @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
|
|
1308
1318
|
else
|
|
1309
1319
|
sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
|
|
1310
1320
|
end
|
|
@@ -1328,6 +1338,45 @@ module Doom
|
|
|
1328
1338
|
visible_sprites.each do |vs|
|
|
1329
1339
|
draw_sprite(vs)
|
|
1330
1340
|
end
|
|
1341
|
+
|
|
1342
|
+
# Draw projectiles and explosions
|
|
1343
|
+
render_projectiles if @combat
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
def render_projectiles
|
|
1347
|
+
# Render rockets in flight
|
|
1348
|
+
@combat.projectiles.each do |proj|
|
|
1349
|
+
view_x, view_y = transform_point(proj.x, proj.y)
|
|
1350
|
+
next if view_y <= 0
|
|
1351
|
+
|
|
1352
|
+
# Rocket has rotations 1-8 based on travel direction relative to viewer
|
|
1353
|
+
rocket_angle = Math.atan2(proj.dy, proj.dx)
|
|
1354
|
+
viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
|
|
1355
|
+
angle_diff = (viewer_angle - rocket_angle) % (2 * Math::PI)
|
|
1356
|
+
rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
|
|
1357
|
+
rocket_sprite = @sprites.send(:load_sprite_frame, 'MISL', 'A', rotation)
|
|
1358
|
+
next unless rocket_sprite
|
|
1359
|
+
|
|
1360
|
+
screen_x = HALF_WIDTH + (view_x * @projection / view_y)
|
|
1361
|
+
thing_stub = Map::Thing.new(proj.x, proj.y, 0, 0, 0)
|
|
1362
|
+
visible = VisibleSprite.new(thing_stub, rocket_sprite, view_x, view_y, view_y, screen_x)
|
|
1363
|
+
draw_sprite(visible)
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# Render explosions (frames B, C, D - all rotation 0)
|
|
1367
|
+
@combat.explosions.each do |expl|
|
|
1368
|
+
view_x, view_y = transform_point(expl[:x], expl[:y])
|
|
1369
|
+
next if view_y <= 0
|
|
1370
|
+
elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
|
|
1371
|
+
frame_idx = (elapsed / 4).clamp(0, 2)
|
|
1372
|
+
frame_letter = %w[B C D][frame_idx]
|
|
1373
|
+
expl_sprite = @sprites.send(:load_sprite_frame, 'MISL', frame_letter, 0)
|
|
1374
|
+
next unless expl_sprite
|
|
1375
|
+
screen_x = HALF_WIDTH + (view_x * @projection / view_y)
|
|
1376
|
+
thing_stub = Map::Thing.new(expl[:x], expl[:y], 0, 0, 0)
|
|
1377
|
+
visible = VisibleSprite.new(thing_stub, expl_sprite, view_x, view_y, view_y, screen_x)
|
|
1378
|
+
draw_sprite(visible)
|
|
1379
|
+
end
|
|
1331
1380
|
end
|
|
1332
1381
|
|
|
1333
1382
|
def draw_sprite(vs)
|
data/lib/doom/version.rb
CHANGED
data/lib/doom/wad/sprite.rb
CHANGED
|
@@ -178,8 +178,9 @@ module Doom
|
|
|
178
178
|
return sprite if sprite
|
|
179
179
|
|
|
180
180
|
# Calculate rotation frame (1-8)
|
|
181
|
-
#
|
|
182
|
-
|
|
181
|
+
# DOOM: rot = (R_PointToAngle(thing) - thing->angle + ANG45/2*9) >> 29
|
|
182
|
+
# Rotation 1=front (viewer faces monster's front), 5=back
|
|
183
|
+
angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
|
|
183
184
|
angle_diff = angle_diff % (2 * Math::PI)
|
|
184
185
|
angle_diff += 2 * Math::PI if angle_diff < 0
|
|
185
186
|
rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
|
|
@@ -196,8 +197,8 @@ module Doom
|
|
|
196
197
|
sprite = load_sprite_frame(prefix, frame_letter, 0)
|
|
197
198
|
return sprite if sprite
|
|
198
199
|
|
|
199
|
-
# Try with calculated rotation
|
|
200
|
-
angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0)
|
|
200
|
+
# Try with calculated rotation (same formula as get_rotated)
|
|
201
|
+
angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
|
|
201
202
|
angle_diff = angle_diff % (2 * Math::PI)
|
|
202
203
|
angle_diff += 2 * Math::PI if angle_diff < 0
|
|
203
204
|
rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
|
data/lib/doom/wad_downloader.rb
CHANGED
|
@@ -66,7 +66,16 @@ module Doom
|
|
|
66
66
|
uri = URI.parse(SHAREWARE_URL)
|
|
67
67
|
downloaded = 0
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
ssl_opts = { use_ssl: uri.scheme == 'https' }
|
|
70
|
+
# Workaround for SSL certificate issues on some systems
|
|
71
|
+
begin
|
|
72
|
+
Net::HTTP.start(uri.host, uri.port, **ssl_opts) { |h| h.head(uri) }
|
|
73
|
+
rescue OpenSSL::SSL::SSLError
|
|
74
|
+
puts "SSL certificate verification failed, retrying without verification..."
|
|
75
|
+
ssl_opts[:verify_mode] = OpenSSL::SSL::VERIFY_NONE
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Net::HTTP.start(uri.host, uri.port, **ssl_opts) do |http|
|
|
70
79
|
request = Net::HTTP::Get.new(uri)
|
|
71
80
|
|
|
72
81
|
http.request(request) do |response|
|
data/lib/doom.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative 'doom/game/animations'
|
|
|
17
17
|
require_relative 'doom/game/item_pickup'
|
|
18
18
|
require_relative 'doom/game/combat'
|
|
19
19
|
require_relative 'doom/game/sector_effects'
|
|
20
|
+
require_relative 'doom/game/monster_ai'
|
|
20
21
|
require_relative 'doom/render/renderer'
|
|
21
22
|
require_relative 'doom/render/status_bar'
|
|
22
23
|
require_relative 'doom/render/weapon_renderer'
|
|
@@ -81,9 +82,10 @@ module Doom
|
|
|
81
82
|
sector_effects = Game::SectorEffects.new(map)
|
|
82
83
|
item_pickup = Game::ItemPickup.new(map, player_state)
|
|
83
84
|
combat = Game::Combat.new(map, player_state, sprites)
|
|
85
|
+
monster_ai = Game::MonsterAI.new(map, combat)
|
|
84
86
|
|
|
85
87
|
puts 'Starting game window...'
|
|
86
|
-
window = Platform::GosuWindow.new(renderer, palette, map, player_state, status_bar, weapon_renderer, sector_actions, animations, sector_effects, item_pickup, combat)
|
|
88
|
+
window = Platform::GosuWindow.new(renderer, palette, map, player_state, status_bar, weapon_renderer, sector_actions, animations, sector_effects, item_pickup, combat, monster_ai)
|
|
87
89
|
window.show
|
|
88
90
|
end
|
|
89
91
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: doom
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Hasinski
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- lib/doom/game/animations.rb
|
|
67
67
|
- lib/doom/game/combat.rb
|
|
68
68
|
- lib/doom/game/item_pickup.rb
|
|
69
|
+
- lib/doom/game/monster_ai.rb
|
|
69
70
|
- lib/doom/game/player_state.rb
|
|
70
71
|
- lib/doom/game/sector_actions.rb
|
|
71
72
|
- lib/doom/game/sector_effects.rb
|
|
@@ -84,7 +85,7 @@ files:
|
|
|
84
85
|
- lib/doom/wad/sprite.rb
|
|
85
86
|
- lib/doom/wad/texture.rb
|
|
86
87
|
- lib/doom/wad_downloader.rb
|
|
87
|
-
homepage: https://github.com/khasinski/doom
|
|
88
|
+
homepage: https://github.com/khasinski/doom
|
|
88
89
|
licenses:
|
|
89
90
|
- GPL-2.0
|
|
90
91
|
metadata: {}
|
|
@@ -102,7 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
102
103
|
- !ruby/object:Gem::Version
|
|
103
104
|
version: '0'
|
|
104
105
|
requirements: []
|
|
105
|
-
rubygems_version: 4.0.
|
|
106
|
+
rubygems_version: 4.0.6
|
|
106
107
|
specification_version: 4
|
|
107
108
|
summary: Doom engine port in pure Ruby
|
|
108
109
|
test_files: []
|