doom 0.5.0 → 0.8.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 +10 -5
- data/lib/doom/game/combat.rb +325 -13
- data/lib/doom/game/intermission.rb +248 -0
- data/lib/doom/game/item_pickup.rb +40 -4
- data/lib/doom/game/menu.rb +342 -0
- data/lib/doom/game/monster_ai.rb +494 -0
- data/lib/doom/game/player_state.rb +53 -1
- data/lib/doom/game/sector_actions.rb +376 -15
- data/lib/doom/game/sound_engine.rb +201 -0
- data/lib/doom/platform/gosu_window.rb +589 -49
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +395 -18
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/weapon_renderer.rb +11 -12
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/reader.rb +52 -0
- data/lib/doom/wad/sound.rb +85 -0
- data/lib/doom/wad/sprite.rb +34 -8
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +48 -4
- metadata +10 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e59cb32b1487d61b1c310ee5eae7ec30a702c64129c7fe5e18e1c06204d08208
|
|
4
|
+
data.tar.gz: f8f5450c3c37d661618234cf0abe80a5f0cd796aaa13694ee35d786ef25c5728
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a65c31832aff4838f924eb1263bda1f16ef6e820da9d776fa759656fdb9a58d83b2146ea30d5e0c236d1fc9e39206ad14312191f3bf5db70597cec815aef113
|
|
7
|
+
data.tar.gz: f6215f28a05e9924b9864f2f51d36cd694c4abfe7c0ad1618116486f62c72d26b0d7f609e945725b2218ee4b3e77e13eb3316153396977bf773673185d93ba40
|
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,13 +1,18 @@
|
|
|
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
|
|
|
11
|
+
# Parse flags
|
|
12
|
+
rubykaigi_mode = ARGV.delete('--rubykaigi') || ARGV.delete('--kaigi')
|
|
13
|
+
|
|
9
14
|
# Parse arguments before loading heavy dependencies
|
|
10
|
-
wad_path = ARGV
|
|
15
|
+
wad_path = ARGV.find { |a| !a.start_with?('-') }
|
|
11
16
|
|
|
12
17
|
# Show help (before loading anything)
|
|
13
18
|
if wad_path == '-h' || wad_path == '--help'
|
|
@@ -49,7 +54,7 @@ require_relative '../lib/doom'
|
|
|
49
54
|
begin
|
|
50
55
|
# Find or download WAD
|
|
51
56
|
wad_path = Doom::WadDownloader.ensure_wad_available(wad_path)
|
|
52
|
-
Doom.run(wad_path)
|
|
57
|
+
Doom.run(wad_path, rubykaigi: rubykaigi_mode)
|
|
53
58
|
rescue Doom::WadDownloader::DownloadError => e
|
|
54
59
|
puts "Error: #{e.message}"
|
|
55
60
|
exit 1
|
data/lib/doom/game/combat.rb
CHANGED
|
@@ -22,14 +22,22 @@ module Doom
|
|
|
22
22
|
64 => 700, # Archvile
|
|
23
23
|
71 => 400, # Pain Elemental
|
|
24
24
|
84 => 20, # Wolfenstein SS
|
|
25
|
+
2035 => 20, # Explosive barrel
|
|
25
26
|
}.freeze
|
|
26
27
|
|
|
27
28
|
MONSTER_RADIUS = {
|
|
28
29
|
3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
|
|
29
30
|
3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
|
|
30
31
|
7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
|
|
32
|
+
2035 => 10, # Barrel
|
|
31
33
|
}.freeze
|
|
32
34
|
|
|
35
|
+
# Barrel (explosive, not a monster but damageable)
|
|
36
|
+
BARREL_TYPE = 2035
|
|
37
|
+
BARREL_HP = 20
|
|
38
|
+
BARREL_SPLASH_RADIUS = 128.0
|
|
39
|
+
BARREL_SPLASH_DAMAGE = 128
|
|
40
|
+
|
|
33
41
|
# Normal death frame sequences per sprite prefix (rotation 0 only)
|
|
34
42
|
# Identified by sprite heights: frames go from standing height to flat on ground
|
|
35
43
|
DEATH_FRAMES = {
|
|
@@ -46,48 +54,149 @@ module Doom
|
|
|
46
54
|
'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
|
|
47
55
|
'PAIN' => %w[H I J K L M], # Pain Elemental
|
|
48
56
|
'SSWV' => %w[I J K L M], # Wolfenstein SS
|
|
57
|
+
'BEXP' => %w[A B C D E], # Barrel explosion
|
|
49
58
|
}.freeze
|
|
50
59
|
|
|
51
60
|
DEATH_ANIM_TICS = 6 # Tics per death frame
|
|
52
61
|
|
|
62
|
+
# Pain chance per monster (out of 256, from mobjinfo)
|
|
63
|
+
PAIN_CHANCE = {
|
|
64
|
+
3004 => 200, 9 => 170, 3001 => 200, 3002 => 180, 58 => 180,
|
|
65
|
+
3003 => 50, 69 => 50, 3005 => 128, 3006 => 256, 16 => 40,
|
|
66
|
+
7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170,
|
|
67
|
+
}.freeze
|
|
68
|
+
PAIN_DURATION = 6 # Tics monster is stunned when in pain
|
|
69
|
+
|
|
70
|
+
# Projectile constants
|
|
71
|
+
ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
|
|
72
|
+
ROCKET_DAMAGE = 20 # Direct hit base (DOOM: 1d8 * 20)
|
|
73
|
+
ROCKET_RADIUS = 11 # Collision radius
|
|
74
|
+
SPLASH_RADIUS = 128.0 # Splash damage radius
|
|
75
|
+
SPLASH_DAMAGE = 128 # Max splash damage at center
|
|
76
|
+
|
|
77
|
+
# Monster projectile definitions
|
|
78
|
+
MONSTER_PROJECTILES = {
|
|
79
|
+
imp: { sprite: 'BAL1', speed: 10.0, damage: [3, 24], radius: 6, splash: false },
|
|
80
|
+
baron: { sprite: 'BAL7', speed: 15.0, damage: [8, 64], radius: 6, splash: false },
|
|
81
|
+
caco: { sprite: 'BAL2', speed: 10.0, damage: [5, 40], radius: 6, splash: false },
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
# Map monster type to projectile type
|
|
85
|
+
MONSTER_PROJECTILE_TYPE = {
|
|
86
|
+
3001 => :imp, # Imp
|
|
87
|
+
3003 => :baron, # Baron
|
|
88
|
+
69 => :baron, # Hell Knight
|
|
89
|
+
3005 => :caco, # Cacodemon
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
Projectile = Struct.new(:x, :y, :z, :dx, :dy, :dz, :type, :spawn_tic, :sprite_prefix, :target)
|
|
93
|
+
|
|
53
94
|
# Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
|
|
54
95
|
# Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
|
|
55
96
|
# Shotgun: 7 pellets, each 1*5..3*5 = 5-15
|
|
56
97
|
# Fist/chainsaw: 1*2..3*2 = 2-10
|
|
57
98
|
|
|
58
|
-
def initialize(map, player_state, sprites)
|
|
99
|
+
def initialize(map, player_state, sprites, hidden_things = {}, sound_engine = nil)
|
|
59
100
|
@map = map
|
|
60
101
|
@player = player_state
|
|
61
102
|
@sprites = sprites
|
|
103
|
+
@hidden_things = hidden_things
|
|
104
|
+
@sound = sound_engine
|
|
62
105
|
@monster_hp = {} # thing_idx => current HP
|
|
63
106
|
@dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
|
|
107
|
+
@pain_until = {} # thing_idx => tic when pain ends
|
|
108
|
+
@projectiles = [] # Active projectiles in flight
|
|
109
|
+
@explosions = [] # Active explosions (for rendering)
|
|
110
|
+
@puffs = [] # Bullet puff effects
|
|
111
|
+
@player_x = 0.0
|
|
112
|
+
@player_y = 0.0
|
|
113
|
+
@player_z = 0.0
|
|
64
114
|
@tic = 0
|
|
65
115
|
end
|
|
66
116
|
|
|
67
|
-
|
|
117
|
+
def update_player_pos(x, y, z = nil)
|
|
118
|
+
@player_x = x
|
|
119
|
+
@player_y = y
|
|
120
|
+
@player_z = z if z
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Spawn a monster projectile (fireball, etc.)
|
|
124
|
+
# Matches Chocolate Doom's P_SpawnMissile: calculates momz for vertical aim
|
|
125
|
+
def spawn_monster_projectile(monster_x, monster_y, monster_z, monster_type, damage_multiplier)
|
|
126
|
+
proj_type = MONSTER_PROJECTILE_TYPE[monster_type]
|
|
127
|
+
return unless proj_type
|
|
128
|
+
|
|
129
|
+
info = MONSTER_PROJECTILES[proj_type]
|
|
130
|
+
return unless info
|
|
131
|
+
|
|
132
|
+
dx = @player_x - monster_x
|
|
133
|
+
dy = @player_y - monster_y
|
|
134
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
135
|
+
return if dist < 1
|
|
136
|
+
|
|
137
|
+
# Normalize direction and apply speed
|
|
138
|
+
speed = info[:speed]
|
|
139
|
+
ndx = dx / dist * speed
|
|
140
|
+
ndy = dy / dist * speed
|
|
141
|
+
|
|
142
|
+
# P_SpawnMissile: momz = (target.z - source.z) / (dist / speed)
|
|
143
|
+
# This makes the projectile arc toward the target's height
|
|
144
|
+
target_z = @player_z - 16 # Aim at player center (z + height/2, roughly)
|
|
145
|
+
travel_tics = dist / speed
|
|
146
|
+
travel_tics = 1.0 if travel_tics < 1.0
|
|
147
|
+
ndz = (target_z - monster_z) / travel_tics
|
|
148
|
+
|
|
149
|
+
@projectiles << Projectile.new(
|
|
150
|
+
monster_x + ndx * 2, monster_y + ndy * 2, monster_z,
|
|
151
|
+
ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
attr_reader :dead_things, :projectiles, :explosions, :puffs
|
|
156
|
+
|
|
157
|
+
def in_pain?(thing_idx)
|
|
158
|
+
@pain_until[thing_idx] && @tic < @pain_until[thing_idx]
|
|
159
|
+
end
|
|
68
160
|
|
|
69
161
|
def dead?(thing_idx)
|
|
70
162
|
@dead_things.key?(thing_idx)
|
|
71
163
|
end
|
|
72
164
|
|
|
73
|
-
# Get the current death frame sprite for a dead monster
|
|
165
|
+
# Get the current death frame sprite for a dead monster/barrel
|
|
74
166
|
def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
|
|
75
167
|
info = @dead_things[thing_idx]
|
|
76
168
|
return nil unless info
|
|
77
169
|
|
|
78
|
-
|
|
170
|
+
prefix = info[:prefix]
|
|
171
|
+
frames = DEATH_FRAMES[prefix]
|
|
79
172
|
return nil unless frames
|
|
80
173
|
|
|
81
174
|
elapsed = @tic - info[:tic]
|
|
82
|
-
frame_idx =
|
|
175
|
+
frame_idx = elapsed / DEATH_ANIM_TICS
|
|
176
|
+
|
|
177
|
+
# Barrels disappear after explosion animation (S_NULL in Chocolate Doom)
|
|
178
|
+
if thing_type == BARREL_TYPE && frame_idx >= frames.size
|
|
179
|
+
return nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
frame_idx = frame_idx.clamp(0, frames.size - 1)
|
|
83
183
|
frame_letter = frames[frame_idx]
|
|
84
184
|
|
|
85
|
-
|
|
185
|
+
# Use prefix directly if it differs from the thing's sprite (e.g. BEXP for barrels)
|
|
186
|
+
thing_prefix = @sprites.prefix_for(thing_type)
|
|
187
|
+
if prefix != thing_prefix
|
|
188
|
+
@sprites.get_frame_by_prefix(prefix, frame_letter)
|
|
189
|
+
else
|
|
190
|
+
@sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
|
|
191
|
+
end
|
|
86
192
|
end
|
|
87
193
|
|
|
88
194
|
# Called each game tic
|
|
89
195
|
def update
|
|
90
196
|
@tic += 1
|
|
197
|
+
update_projectiles
|
|
198
|
+
update_explosions
|
|
199
|
+
@puffs.reject! { |p| @tic - p[:tic] > 12 }
|
|
91
200
|
end
|
|
92
201
|
|
|
93
202
|
# Fire the current weapon
|
|
@@ -97,6 +206,8 @@ module Doom
|
|
|
97
206
|
hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
|
|
98
207
|
when PlayerState::WEAPON_SHOTGUN
|
|
99
208
|
hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
|
|
209
|
+
when PlayerState::WEAPON_ROCKET
|
|
210
|
+
spawn_rocket(px, py, pz, cos_a, sin_a)
|
|
100
211
|
when PlayerState::WEAPON_FIST
|
|
101
212
|
melee(px, py, cos_a, sin_a, 2, 64)
|
|
102
213
|
when PlayerState::WEAPON_CHAINSAW
|
|
@@ -106,6 +217,151 @@ module Doom
|
|
|
106
217
|
|
|
107
218
|
private
|
|
108
219
|
|
|
220
|
+
def spawn_rocket(px, py, pz, cos_a, sin_a)
|
|
221
|
+
@projectiles << Projectile.new(
|
|
222
|
+
px + cos_a * 20, py + sin_a * 20, pz,
|
|
223
|
+
cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED, 0.0,
|
|
224
|
+
:rocket, @tic, 'MISL', :monsters
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def update_projectiles
|
|
229
|
+
@projectiles.reject! do |proj|
|
|
230
|
+
new_x = proj.x + proj.dx
|
|
231
|
+
new_y = proj.y + proj.dy
|
|
232
|
+
new_z = proj.z + (proj.dz || 0)
|
|
233
|
+
|
|
234
|
+
hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
|
|
235
|
+
|
|
236
|
+
# Check if projectile hit the floor or ceiling
|
|
237
|
+
sector = @map.sector_at(new_x, new_y)
|
|
238
|
+
if sector
|
|
239
|
+
hit_wall = true if new_z <= sector.floor_height || new_z >= sector.ceiling_height
|
|
240
|
+
end
|
|
241
|
+
hit = false
|
|
242
|
+
|
|
243
|
+
if proj.target == :monsters
|
|
244
|
+
# Player projectile: check monster collision
|
|
245
|
+
hit_monster = nil
|
|
246
|
+
@map.things.each_with_index do |thing, idx|
|
|
247
|
+
next unless MONSTER_HP[thing.type]
|
|
248
|
+
next if @dead_things[idx]
|
|
249
|
+
radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
|
|
250
|
+
dx = new_x - thing.x
|
|
251
|
+
dy = new_y - thing.y
|
|
252
|
+
if dx * dx + dy * dy < radius * radius
|
|
253
|
+
hit_monster = idx
|
|
254
|
+
break
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if hit_wall || hit_monster
|
|
259
|
+
explode(new_x, new_y, hit_monster) if proj.type == :rocket
|
|
260
|
+
hit_monster ? apply_damage(hit_monster, (rand(8) + 1) * 5) : nil unless proj.type == :rocket
|
|
261
|
+
hit = true
|
|
262
|
+
end
|
|
263
|
+
elsif proj.target == :player
|
|
264
|
+
# Monster projectile: check player collision
|
|
265
|
+
player_radius = 16
|
|
266
|
+
dx = new_x - @player_x
|
|
267
|
+
dy = new_y - @player_y
|
|
268
|
+
if hit_wall || (dx * dx + dy * dy < (player_radius + 6) ** 2)
|
|
269
|
+
unless hit_wall
|
|
270
|
+
info = MONSTER_PROJECTILES[proj.type]
|
|
271
|
+
if info
|
|
272
|
+
min_d, max_d = info[:damage]
|
|
273
|
+
@player.take_damage(rand(min_d..max_d))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
# Spawn fireball explosion
|
|
277
|
+
@explosions << { x: new_x, y: new_y, z: proj.z, tic: @tic, sprite: proj.sprite_prefix }
|
|
278
|
+
hit = true
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
if hit
|
|
283
|
+
true
|
|
284
|
+
else
|
|
285
|
+
proj.x = new_x
|
|
286
|
+
proj.y = new_y
|
|
287
|
+
proj.z = new_z
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def explode(x, y, direct_hit_idx)
|
|
294
|
+
# Direct hit damage
|
|
295
|
+
if direct_hit_idx
|
|
296
|
+
damage = (rand(8) + 1) * ROCKET_DAMAGE
|
|
297
|
+
apply_damage(direct_hit_idx, damage)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Splash damage to all monsters in radius
|
|
301
|
+
@map.things.each_with_index do |thing, idx|
|
|
302
|
+
next unless MONSTER_HP[thing.type]
|
|
303
|
+
next if @dead_things[idx]
|
|
304
|
+
next if idx == direct_hit_idx # Already took direct hit
|
|
305
|
+
|
|
306
|
+
dx = x - thing.x
|
|
307
|
+
dy = y - thing.y
|
|
308
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
309
|
+
next if dist >= SPLASH_RADIUS
|
|
310
|
+
|
|
311
|
+
# Damage falls off linearly with distance
|
|
312
|
+
damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
|
|
313
|
+
apply_damage(idx, damage) if damage > 0
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Spawn explosion visual
|
|
317
|
+
@explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def update_explosions
|
|
321
|
+
# Explosions last 20 tics then disappear
|
|
322
|
+
@explosions.reject! { |e| @tic - e[:tic] > 20 }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def hits_wall?(x1, y1, x2, y2)
|
|
326
|
+
@map.linedefs.each do |ld|
|
|
327
|
+
# One-sided walls always block projectiles
|
|
328
|
+
if ld.sidedef_left == 0xFFFF
|
|
329
|
+
blocks = true
|
|
330
|
+
elsif ld.sidedef_left < 0xFFFF
|
|
331
|
+
# Two-sided: only block if opening is too small for a projectile
|
|
332
|
+
# BLOCKING flag (0x0001) stops players/monsters but NOT projectiles
|
|
333
|
+
front = @map.sidedefs[ld.sidedef_right]
|
|
334
|
+
back = @map.sidedefs[ld.sidedef_left]
|
|
335
|
+
fs = @map.sectors[front.sector]
|
|
336
|
+
bs = @map.sectors[back.sector]
|
|
337
|
+
max_floor = [fs.floor_height, bs.floor_height].max
|
|
338
|
+
min_ceil = [fs.ceiling_height, bs.ceiling_height].min
|
|
339
|
+
blocks = (min_ceil - max_floor) < 1 # Closed door/wall
|
|
340
|
+
else
|
|
341
|
+
next
|
|
342
|
+
end
|
|
343
|
+
next unless blocks
|
|
344
|
+
|
|
345
|
+
v1 = @map.vertices[ld.v1]
|
|
346
|
+
v2 = @map.vertices[ld.v2]
|
|
347
|
+
if segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
|
|
348
|
+
return true
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
|
|
355
|
+
d1x = ax2 - ax1; d1y = ay2 - ay1
|
|
356
|
+
d2x = bx2 - bx1; d2y = by2 - by1
|
|
357
|
+
denom = d1x * d2y - d1y * d2x
|
|
358
|
+
return false if denom.abs < 0.001
|
|
359
|
+
dx = bx1 - ax1; dy = by1 - ay1
|
|
360
|
+
t = (dx * d2y - dy * d2x).to_f / denom
|
|
361
|
+
u = (dx * d1y - dy * d1x).to_f / denom
|
|
362
|
+
t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
|
|
363
|
+
end
|
|
364
|
+
|
|
109
365
|
def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
|
|
110
366
|
pellets.times do
|
|
111
367
|
# Add random spread
|
|
@@ -126,6 +382,7 @@ module Doom
|
|
|
126
382
|
best_dist = wall_dist
|
|
127
383
|
|
|
128
384
|
@map.things.each_with_index do |thing, idx|
|
|
385
|
+
next if @hidden_things[idx]
|
|
129
386
|
next unless MONSTER_HP[thing.type]
|
|
130
387
|
next if @dead_things[idx]
|
|
131
388
|
|
|
@@ -137,6 +394,12 @@ module Doom
|
|
|
137
394
|
end
|
|
138
395
|
end
|
|
139
396
|
|
|
397
|
+
# Spawn bullet puff at hit location
|
|
398
|
+
puff_x = px + ca * best_dist
|
|
399
|
+
puff_y = py + sa * best_dist
|
|
400
|
+
puff_z = @player_z
|
|
401
|
+
@puffs << { x: puff_x, y: puff_y, z: puff_z, tic: @tic }
|
|
402
|
+
|
|
140
403
|
if best_idx
|
|
141
404
|
damage = (rand(3) + 1) * multiplier
|
|
142
405
|
apply_damage(best_idx, damage)
|
|
@@ -180,8 +443,54 @@ module Doom
|
|
|
180
443
|
@monster_hp[thing_idx] -= damage
|
|
181
444
|
|
|
182
445
|
if @monster_hp[thing_idx] <= 0
|
|
183
|
-
|
|
184
|
-
|
|
446
|
+
return if @dead_things[thing_idx] # Already dead
|
|
447
|
+
|
|
448
|
+
if thing.type == BARREL_TYPE
|
|
449
|
+
@dead_things[thing_idx] = { tic: @tic, prefix: 'BEXP' }
|
|
450
|
+
@sound&.explosion
|
|
451
|
+
barrel_explode(thing.x, thing.y, thing_idx)
|
|
452
|
+
else
|
|
453
|
+
prefix = @sprites.prefix_for(thing.type)
|
|
454
|
+
@dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
|
|
455
|
+
@sound&.monster_death(thing.type)
|
|
456
|
+
end
|
|
457
|
+
else
|
|
458
|
+
# Pain state: monster flinches (not barrels)
|
|
459
|
+
if thing.type != BARREL_TYPE
|
|
460
|
+
pain_chance = PAIN_CHANCE[thing.type] || 128
|
|
461
|
+
if rand(256) < pain_chance
|
|
462
|
+
@pain_until[thing_idx] = @tic + PAIN_DURATION
|
|
463
|
+
@sound&.monster_pain(thing.type)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def barrel_explode(x, y, barrel_idx)
|
|
470
|
+
@explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
|
|
471
|
+
|
|
472
|
+
# Splash damage to monsters and other barrels (chain reactions!)
|
|
473
|
+
@map.things.each_with_index do |thing, idx|
|
|
474
|
+
next unless MONSTER_HP[thing.type]
|
|
475
|
+
next if @dead_things[idx]
|
|
476
|
+
next if idx == barrel_idx
|
|
477
|
+
|
|
478
|
+
dx = x - thing.x
|
|
479
|
+
dy = y - thing.y
|
|
480
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
481
|
+
next if dist >= BARREL_SPLASH_RADIUS
|
|
482
|
+
|
|
483
|
+
damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
|
|
484
|
+
apply_damage(idx, damage) if damage > 0
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Splash damage to player
|
|
488
|
+
dx = x - @player_x
|
|
489
|
+
dy = y - @player_y
|
|
490
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
491
|
+
if dist < BARREL_SPLASH_RADIUS
|
|
492
|
+
damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
|
|
493
|
+
@player.take_damage(damage) if damage > 0
|
|
185
494
|
end
|
|
186
495
|
end
|
|
187
496
|
|
|
@@ -192,18 +501,21 @@ module Doom
|
|
|
192
501
|
v1 = @map.vertices[ld.v1]
|
|
193
502
|
v2 = @map.vertices[ld.v2]
|
|
194
503
|
|
|
195
|
-
# One-sided always blocks
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
504
|
+
# One-sided always blocks hitscan
|
|
505
|
+
if ld.sidedef_left == 0xFFFF
|
|
506
|
+
blocks = true
|
|
507
|
+
elsif ld.sidedef_left < 0xFFFF
|
|
508
|
+
# Two-sided: only blocks if opening is too small
|
|
509
|
+
# BLOCKING flag does NOT stop hitscan (only affects movement)
|
|
199
510
|
front = @map.sidedefs[ld.sidedef_right]
|
|
200
511
|
back = @map.sidedefs[ld.sidedef_left]
|
|
201
512
|
fs = @map.sectors[front.sector]
|
|
202
513
|
bs = @map.sectors[back.sector]
|
|
203
|
-
# Blocks if opening is too small (step or low ceiling)
|
|
204
514
|
max_floor = [fs.floor_height, bs.floor_height].max
|
|
205
515
|
min_ceil = [fs.ceiling_height, bs.ceiling_height].min
|
|
206
516
|
blocks = (min_ceil - max_floor) < 56
|
|
517
|
+
else
|
|
518
|
+
next
|
|
207
519
|
end
|
|
208
520
|
next unless blocks
|
|
209
521
|
|