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
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
|
-

|
|
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
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# Animated texture/flat cycling, matching Chocolate Doom's P_InitPicAnims
|
|
6
|
+
# and P_UpdateSpecials from p_spec.c.
|
|
7
|
+
#
|
|
8
|
+
# All animations run at 8 tics per frame (8/35 sec ≈ 0.23s).
|
|
9
|
+
# Frames must be consecutive entries in the WAD; the engine uses
|
|
10
|
+
# start/end names to find the range.
|
|
11
|
+
class Animations
|
|
12
|
+
TICS_PER_FRAME = 8
|
|
13
|
+
|
|
14
|
+
# [is_texture, start_name, end_name]
|
|
15
|
+
# From Chocolate Doom animdefs[] in p_spec.c
|
|
16
|
+
ANIMDEFS = [
|
|
17
|
+
# Animated flats
|
|
18
|
+
[false, 'NUKAGE1', 'NUKAGE3'],
|
|
19
|
+
[false, 'FWATER1', 'FWATER4'],
|
|
20
|
+
[false, 'SWATER1', 'SWATER4'],
|
|
21
|
+
[false, 'LAVA1', 'LAVA4'],
|
|
22
|
+
[false, 'BLOOD1', 'BLOOD3'],
|
|
23
|
+
[false, 'RROCK05', 'RROCK08'],
|
|
24
|
+
[false, 'SLIME01', 'SLIME04'],
|
|
25
|
+
[false, 'SLIME05', 'SLIME08'],
|
|
26
|
+
[false, 'SLIME09', 'SLIME12'],
|
|
27
|
+
# Animated wall textures
|
|
28
|
+
[true, 'BLODGR1', 'BLODGR4'],
|
|
29
|
+
[true, 'SLADRIP1', 'SLADRIP3'],
|
|
30
|
+
[true, 'BLODRIP1', 'BLODRIP4'],
|
|
31
|
+
[true, 'FIREWALA', 'FIREWALL'],
|
|
32
|
+
[true, 'GSTFONT1', 'GSTFONT3'],
|
|
33
|
+
[true, 'FIRELAV3', 'FIRELAVA'],
|
|
34
|
+
[true, 'FIREMAG1', 'FIREMAG3'],
|
|
35
|
+
[true, 'FIREBLU1', 'FIREBLU2'],
|
|
36
|
+
[true, 'ROCKRED1', 'ROCKRED3'],
|
|
37
|
+
[true, 'BFALL1', 'BFALL4'],
|
|
38
|
+
[true, 'SFALL1', 'SFALL4'],
|
|
39
|
+
[true, 'WFALL1', 'WFALL4'],
|
|
40
|
+
[true, 'DBRAIN1', 'DBRAIN4'],
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
attr_reader :flat_translation, :texture_translation
|
|
44
|
+
|
|
45
|
+
def initialize(texture_names, flat_names)
|
|
46
|
+
@flat_translation = {} # flat_name -> current_frame_name
|
|
47
|
+
@texture_translation = {} # texture_name -> current_frame_name
|
|
48
|
+
@anims = []
|
|
49
|
+
|
|
50
|
+
ANIMDEFS.each do |is_texture, start_name, end_name|
|
|
51
|
+
names = is_texture ? texture_names : flat_names
|
|
52
|
+
|
|
53
|
+
start_idx = names.index(start_name)
|
|
54
|
+
end_idx = names.index(end_name)
|
|
55
|
+
next unless start_idx && end_idx
|
|
56
|
+
next if end_idx <= start_idx
|
|
57
|
+
|
|
58
|
+
frames = names[start_idx..end_idx]
|
|
59
|
+
next if frames.size < 2
|
|
60
|
+
|
|
61
|
+
@anims << {
|
|
62
|
+
is_texture: is_texture,
|
|
63
|
+
frames: frames,
|
|
64
|
+
speed: TICS_PER_FRAME,
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Call every game tic (or approximate with leveltime).
|
|
70
|
+
# Matches Chocolate Doom P_UpdateSpecials:
|
|
71
|
+
# pic = basepic + ((leveltime / speed + i) % numpics)
|
|
72
|
+
def update(leveltime)
|
|
73
|
+
@anims.each do |anim|
|
|
74
|
+
frames = anim[:frames]
|
|
75
|
+
numpics = frames.size
|
|
76
|
+
phase = leveltime / anim[:speed]
|
|
77
|
+
translation = anim[:is_texture] ? @texture_translation : @flat_translation
|
|
78
|
+
|
|
79
|
+
numpics.times do |i|
|
|
80
|
+
current_frame = frames[(phase + i) % numpics]
|
|
81
|
+
translation[frames[i]] = current_frame
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Translate a flat name to its current animation frame
|
|
87
|
+
def translate_flat(name)
|
|
88
|
+
@flat_translation[name] || name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Translate a texture name to its current animation frame
|
|
92
|
+
def translate_texture(name)
|
|
93
|
+
@texture_translation[name] || name
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# Hitscan weapon firing and monster state tracking.
|
|
6
|
+
# Matches Chocolate Doom's P_LineAttack / P_AimLineAttack from p_map.c.
|
|
7
|
+
class Combat
|
|
8
|
+
# Monster starting HP (from mobjinfo[] in info.c)
|
|
9
|
+
MONSTER_HP = {
|
|
10
|
+
3004 => 20, # Zombieman
|
|
11
|
+
9 => 30, # Shotgun Guy
|
|
12
|
+
3001 => 60, # Imp
|
|
13
|
+
3002 => 150, # Demon
|
|
14
|
+
58 => 150, # Spectre
|
|
15
|
+
3003 => 1000, # Baron of Hell
|
|
16
|
+
69 => 500, # Hell Knight
|
|
17
|
+
3005 => 400, # Cacodemon
|
|
18
|
+
3006 => 100, # Lost Soul
|
|
19
|
+
16 => 4000, # Cyberdemon
|
|
20
|
+
7 => 3000, # Spider Mastermind
|
|
21
|
+
65 => 70, # Heavy Weapon Dude
|
|
22
|
+
64 => 700, # Archvile
|
|
23
|
+
71 => 400, # Pain Elemental
|
|
24
|
+
84 => 20, # Wolfenstein SS
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
MONSTER_RADIUS = {
|
|
28
|
+
3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
|
|
29
|
+
3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
|
|
30
|
+
7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Normal death frame sequences per sprite prefix (rotation 0 only)
|
|
34
|
+
# Identified by sprite heights: frames go from standing height to flat on ground
|
|
35
|
+
DEATH_FRAMES = {
|
|
36
|
+
'POSS' => %w[H I J K L], # Zombieman: 55→46→34→27→17
|
|
37
|
+
'SPOS' => %w[H I J K L], # Shotgun Guy: 60→50→35→27→17
|
|
38
|
+
'TROO' => %w[I J K L M], # Imp: 62→59→54→46→22
|
|
39
|
+
'SARG' => %w[I J K L M N], # Demon/Spectre: 56→56→53→57→46→32
|
|
40
|
+
'BOSS' => %w[H I J K L M N], # Baron
|
|
41
|
+
'BOS2' => %w[H I J K L M N], # Hell Knight
|
|
42
|
+
'HEAD' => %w[G H I J K L], # Cacodemon
|
|
43
|
+
'SKUL' => %w[G H I J K], # Lost Soul
|
|
44
|
+
'CYBR' => %w[I J], # Cyberdemon
|
|
45
|
+
'SPID' => %w[I J K], # Spider Mastermind
|
|
46
|
+
'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
|
|
47
|
+
'PAIN' => %w[H I J K L M], # Pain Elemental
|
|
48
|
+
'SSWV' => %w[I J K L M], # Wolfenstein SS
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
DEATH_ANIM_TICS = 6 # Tics per death frame
|
|
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
|
+
|
|
62
|
+
# Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
|
|
63
|
+
# Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
|
|
64
|
+
# Shotgun: 7 pellets, each 1*5..3*5 = 5-15
|
|
65
|
+
# Fist/chainsaw: 1*2..3*2 = 2-10
|
|
66
|
+
|
|
67
|
+
def initialize(map, player_state, sprites)
|
|
68
|
+
@map = map
|
|
69
|
+
@player = player_state
|
|
70
|
+
@sprites = sprites
|
|
71
|
+
@monster_hp = {} # thing_idx => current HP
|
|
72
|
+
@dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
|
|
73
|
+
@projectiles = [] # Active projectiles in flight
|
|
74
|
+
@explosions = [] # Active explosions (for rendering)
|
|
75
|
+
@tic = 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :dead_things, :projectiles, :explosions
|
|
79
|
+
|
|
80
|
+
def dead?(thing_idx)
|
|
81
|
+
@dead_things.key?(thing_idx)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get the current death frame sprite for a dead monster
|
|
85
|
+
def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
|
|
86
|
+
info = @dead_things[thing_idx]
|
|
87
|
+
return nil unless info
|
|
88
|
+
|
|
89
|
+
frames = DEATH_FRAMES[info[:prefix]]
|
|
90
|
+
return nil unless frames
|
|
91
|
+
|
|
92
|
+
elapsed = @tic - info[:tic]
|
|
93
|
+
frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
|
|
94
|
+
frame_letter = frames[frame_idx]
|
|
95
|
+
|
|
96
|
+
@sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Called each game tic
|
|
100
|
+
def update
|
|
101
|
+
@tic += 1
|
|
102
|
+
update_projectiles
|
|
103
|
+
update_explosions
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fire the current weapon
|
|
107
|
+
def fire(px, py, pz, cos_a, sin_a, weapon)
|
|
108
|
+
case weapon
|
|
109
|
+
when PlayerState::WEAPON_PISTOL, PlayerState::WEAPON_CHAINGUN
|
|
110
|
+
hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
|
|
111
|
+
when PlayerState::WEAPON_SHOTGUN
|
|
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)
|
|
115
|
+
when PlayerState::WEAPON_FIST
|
|
116
|
+
melee(px, py, cos_a, sin_a, 2, 64)
|
|
117
|
+
when PlayerState::WEAPON_CHAINSAW
|
|
118
|
+
melee(px, py, cos_a, sin_a, 2, 64)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
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
|
+
|
|
235
|
+
def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
|
|
236
|
+
pellets.times do
|
|
237
|
+
# Add random spread
|
|
238
|
+
if spread > 0
|
|
239
|
+
angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * spread * 2
|
|
240
|
+
ca = Math.cos(angle)
|
|
241
|
+
sa = Math.sin(angle)
|
|
242
|
+
else
|
|
243
|
+
# Slight pistol/chaingun spread
|
|
244
|
+
angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * 0.04
|
|
245
|
+
ca = Math.cos(angle)
|
|
246
|
+
sa = Math.sin(angle)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
wall_dist = trace_wall(px, py, ca, sa)
|
|
250
|
+
|
|
251
|
+
best_idx = nil
|
|
252
|
+
best_dist = wall_dist
|
|
253
|
+
|
|
254
|
+
@map.things.each_with_index do |thing, idx|
|
|
255
|
+
next unless MONSTER_HP[thing.type]
|
|
256
|
+
next if @dead_things[idx]
|
|
257
|
+
|
|
258
|
+
radius = MONSTER_RADIUS[thing.type] || 20
|
|
259
|
+
hit_dist = ray_circle_hit(px, py, ca, sa, thing.x, thing.y, radius)
|
|
260
|
+
if hit_dist && hit_dist > 0 && hit_dist < best_dist
|
|
261
|
+
best_dist = hit_dist
|
|
262
|
+
best_idx = idx
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if best_idx
|
|
267
|
+
damage = (rand(3) + 1) * multiplier
|
|
268
|
+
apply_damage(best_idx, damage)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def melee(px, py, cos_a, sin_a, multiplier, range)
|
|
274
|
+
best_idx = nil
|
|
275
|
+
best_dist = range.to_f
|
|
276
|
+
|
|
277
|
+
@map.things.each_with_index do |thing, idx|
|
|
278
|
+
next unless MONSTER_HP[thing.type]
|
|
279
|
+
next if @dead_things[idx]
|
|
280
|
+
|
|
281
|
+
dx = thing.x - px
|
|
282
|
+
dy = thing.y - py
|
|
283
|
+
dist = Math.sqrt(dx * dx + dy * dy)
|
|
284
|
+
next if dist > range + (MONSTER_RADIUS[thing.type] || 20)
|
|
285
|
+
|
|
286
|
+
# Check if roughly facing the monster
|
|
287
|
+
dot = dx * cos_a + dy * sin_a
|
|
288
|
+
next if dot < 0
|
|
289
|
+
|
|
290
|
+
if dist < best_dist
|
|
291
|
+
best_dist = dist
|
|
292
|
+
best_idx = idx
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
if best_idx
|
|
297
|
+
damage = (rand(3) + 1) * multiplier
|
|
298
|
+
apply_damage(best_idx, damage)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def apply_damage(thing_idx, damage)
|
|
303
|
+
thing = @map.things[thing_idx]
|
|
304
|
+
@monster_hp[thing_idx] ||= MONSTER_HP[thing.type] || 20
|
|
305
|
+
|
|
306
|
+
@monster_hp[thing_idx] -= damage
|
|
307
|
+
|
|
308
|
+
if @monster_hp[thing_idx] <= 0
|
|
309
|
+
prefix = @sprites.prefix_for(thing.type)
|
|
310
|
+
@dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def trace_wall(px, py, cos_a, sin_a)
|
|
315
|
+
best_t = 4096.0 # Max hitscan range
|
|
316
|
+
|
|
317
|
+
@map.linedefs.each do |ld|
|
|
318
|
+
v1 = @map.vertices[ld.v1]
|
|
319
|
+
v2 = @map.vertices[ld.v2]
|
|
320
|
+
|
|
321
|
+
# One-sided always blocks; two-sided only if impassable
|
|
322
|
+
blocks = (ld.sidedef_left == 0xFFFF) || (ld.flags & 0x0001 != 0)
|
|
323
|
+
unless blocks
|
|
324
|
+
next unless ld.sidedef_left < 0xFFFF
|
|
325
|
+
front = @map.sidedefs[ld.sidedef_right]
|
|
326
|
+
back = @map.sidedefs[ld.sidedef_left]
|
|
327
|
+
fs = @map.sectors[front.sector]
|
|
328
|
+
bs = @map.sectors[back.sector]
|
|
329
|
+
# Blocks if opening is too small (step or low ceiling)
|
|
330
|
+
max_floor = [fs.floor_height, bs.floor_height].max
|
|
331
|
+
min_ceil = [fs.ceiling_height, bs.ceiling_height].min
|
|
332
|
+
blocks = (min_ceil - max_floor) < 56
|
|
333
|
+
end
|
|
334
|
+
next unless blocks
|
|
335
|
+
|
|
336
|
+
t = ray_segment_intersect(px, py, cos_a, sin_a,
|
|
337
|
+
v1.x, v1.y, v2.x, v2.y)
|
|
338
|
+
best_t = t if t && t > 0 && t < best_t
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
best_t
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def ray_segment_intersect(px, py, dx, dy, x1, y1, x2, y2)
|
|
345
|
+
sx = x2 - x1
|
|
346
|
+
sy = y2 - y1
|
|
347
|
+
denom = dx * sy - dy * sx
|
|
348
|
+
return nil if denom.abs < 0.001
|
|
349
|
+
|
|
350
|
+
t = ((x1 - px) * sy - (y1 - py) * sx) / denom
|
|
351
|
+
u = ((x1 - px) * dy - (y1 - py) * dx) / denom
|
|
352
|
+
|
|
353
|
+
(t > 0 && u >= 0.0 && u <= 1.0) ? t : nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def ray_circle_hit(px, py, cos_a, sin_a, cx, cy, radius)
|
|
357
|
+
dx = cx - px
|
|
358
|
+
dy = cy - py
|
|
359
|
+
proj = dx * cos_a + dy * sin_a
|
|
360
|
+
return nil if proj < 0
|
|
361
|
+
|
|
362
|
+
perp_sq = dx * dx + dy * dy - proj * proj
|
|
363
|
+
return nil if perp_sq > radius * radius
|
|
364
|
+
|
|
365
|
+
chord_half = Math.sqrt([radius * radius - perp_sq, 0].max)
|
|
366
|
+
proj - chord_half
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|