doom 0.6.0 → 0.11.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Plays sound effects for game events.
6
+ class SoundEngine
7
+ # Weapon fire sounds
8
+ WEAPON_SOUNDS = {
9
+ PlayerState::WEAPON_FIST => 'DSPUNCH',
10
+ PlayerState::WEAPON_PISTOL => 'DSPISTOL',
11
+ PlayerState::WEAPON_SHOTGUN => 'DSSHOTGN',
12
+ PlayerState::WEAPON_CHAINGUN => 'DSPISTOL',
13
+ PlayerState::WEAPON_ROCKET => 'DSRLAUNC',
14
+ PlayerState::WEAPON_PLASMA => 'DSPLASMA',
15
+ PlayerState::WEAPON_BFG => 'DSBFG',
16
+ PlayerState::WEAPON_CHAINSAW => 'DSSAWIDL'
17
+ }.freeze
18
+
19
+ # Monster see/activation sounds (from mobjinfo seesound)
20
+ MONSTER_SEE = {
21
+ 3004 => 'DSPOSIT1', # Zombieman
22
+ 9 => 'DSSGTSIT', # Shotgun Guy
23
+ 3001 => 'DSBGSIT1', # Imp
24
+ 3002 => 'DSSGTSIT', # Demon
25
+ 58 => 'DSSGTSIT', # Spectre
26
+ 3003 => 'DSBRSSIT', # Baron
27
+ 69 => 'DSBRSSIT', # Hell Knight
28
+ 3005 => 'DSCACSIT', # Cacodemon
29
+ 3006 => 'DSSKLATK', # Lost Soul
30
+ 65 => 'DSPOSIT2', # Heavy Weapon Dude
31
+ 16 => 'DSCYBSIT', # Cyberdemon
32
+ 7 => 'DSSPISIT' # Spider Mastermind
33
+ }.freeze
34
+
35
+ # Monster death sounds (from mobjinfo deathsound)
36
+ MONSTER_DEATH = {
37
+ 3004 => 'DSPODTH1', # Zombieman
38
+ 9 => 'DSSGTDTH', # Shotgun Guy
39
+ 3001 => 'DSBGDTH1', # Imp
40
+ 3002 => 'DSSGTDTH', # Demon
41
+ 58 => 'DSSGTDTH', # Spectre
42
+ 3003 => 'DSBRSDTH', # Baron
43
+ 69 => 'DSBRSDTH', # Hell Knight
44
+ 3005 => 'DSCACDTH', # Cacodemon
45
+ 3006 => 'DSFIRXPL', # Lost Soul
46
+ 65 => 'DSPODTH2', # Heavy Weapon Dude
47
+ 16 => 'DSCYBDTH', # Cyberdemon
48
+ 7 => 'DSSPIDTH' # Spider Mastermind
49
+ }.freeze
50
+
51
+ # Monster pain sounds (from mobjinfo painsound)
52
+ MONSTER_PAIN = {
53
+ 3004 => 'DSPOPAIN', # Zombieman
54
+ 9 => 'DSPOPAIN', # Shotgun Guy
55
+ 3001 => 'DSDMPAIN', # Imp
56
+ 3002 => 'DSDMPAIN', # Demon
57
+ 58 => 'DSDMPAIN', # Spectre
58
+ 3003 => 'DSDMPAIN', # Baron
59
+ 69 => 'DSDMPAIN', # Hell Knight
60
+ 3005 => 'DSDMPAIN', # Cacodemon
61
+ 3006 => 'DSDMPAIN', # Lost Soul
62
+ 65 => 'DSPOPAIN' # Heavy Weapon Dude
63
+ }.freeze
64
+
65
+ # Monster attack sounds (from mobjinfo attacksound)
66
+ MONSTER_ATTACK = {
67
+ 3004 => 'DSPISTOL', # Zombieman: pistol
68
+ 9 => 'DSSHOTGN', # Shotgun Guy: shotgun
69
+ 3001 => 'DSFIRSHT', # Imp: fireball launch
70
+ 3002 => 'DSSGTATK', # Demon: bite
71
+ 58 => 'DSSGTATK', # Spectre: bite
72
+ 3003 => 'DSFIRSHT', # Baron: fireball
73
+ 69 => 'DSFIRSHT', # Hell Knight: fireball
74
+ 3005 => 'DSFIRSHT', # Cacodemon: fireball
75
+ 65 => 'DSSHOTGN' # Heavy Weapon Dude: chaingun burst
76
+ }.freeze
77
+
78
+ def initialize(sound_manager)
79
+ @sounds = sound_manager
80
+ @last_played = {} # Throttle rapid repeats
81
+ end
82
+
83
+ def play(name, volume: 1.0, throttle: 0)
84
+ now = Time.now.to_f
85
+ return if (throttle > 0) && @last_played[name] && (now - @last_played[name]) < throttle
86
+
87
+ sample = @sounds[name]
88
+ sample&.play(volume)
89
+ @last_played[name] = now
90
+ end
91
+
92
+ # --- Menu sounds ---
93
+ def menu_move
94
+ play('DSPSTOP', throttle: 0.05)
95
+ end
96
+
97
+ def menu_select
98
+ play('DSPISTOL')
99
+ end
100
+
101
+ def menu_back
102
+ play('DSSWTCHX')
103
+ end
104
+
105
+ # --- Weapon events ---
106
+ def weapon_fire(weapon)
107
+ sound = WEAPON_SOUNDS[weapon]
108
+ play(sound, throttle: 0.05) if sound
109
+ end
110
+
111
+ # --- Player events ---
112
+ def player_pain
113
+ play('DSPLPAIN', throttle: 0.3)
114
+ end
115
+
116
+ def player_death
117
+ play('DSPLDETH')
118
+ end
119
+
120
+ def item_pickup
121
+ play('DSITEMUP', throttle: 0.1)
122
+ end
123
+
124
+ def weapon_pickup
125
+ play('DSWPNUP')
126
+ end
127
+
128
+ def noway
129
+ play('DSNOWAY', throttle: 0.3)
130
+ end
131
+
132
+ # --- Door/environment sounds ---
133
+ def door_open
134
+ play('DSDOROPN', throttle: 0.2)
135
+ end
136
+
137
+ def door_close
138
+ play('DSDORCLS', throttle: 0.2)
139
+ end
140
+
141
+ def platform_start
142
+ play('DSPSTART', throttle: 0.2)
143
+ end
144
+
145
+ def platform_stop
146
+ play('DSPSTOP', throttle: 0.2)
147
+ end
148
+
149
+ # --- Monster sounds ---
150
+ def monster_see(type)
151
+ sound = MONSTER_SEE[type]
152
+ play(sound, throttle: 0.5) if sound
153
+ end
154
+
155
+ def monster_death(type)
156
+ sound = MONSTER_DEATH[type]
157
+ play(sound) if sound
158
+ end
159
+
160
+ def monster_pain(type)
161
+ sound = MONSTER_PAIN[type]
162
+ play(sound, throttle: 0.2) if sound
163
+ end
164
+
165
+ def monster_attack(type)
166
+ sound = MONSTER_ATTACK[type]
167
+ play(sound, throttle: 0.1) if sound
168
+ end
169
+
170
+ # --- Explosions / impacts ---
171
+ def explosion
172
+ play('DSBAREXP')
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Doom
6
+ module Game
7
+ # A fingerprint of the simulation, for catching lockstep desync.
8
+ #
9
+ # Peers exchange this every so often. If two peers disagree at the same
10
+ # tic their worlds have diverged, and we want to say so loudly at the tic
11
+ # it happened rather than let the games drift silently apart.
12
+ #
13
+ # Deliberately NOT built on Object#hash: Ruby randomises string and array
14
+ # hashing per process, so two peers would disagree on identical state and
15
+ # every session would look like a desync. Zlib.crc32 over an explicitly
16
+ # packed byte string is stable across processes and machines.
17
+ #
18
+ # Floats are packed as raw IEEE754 ('E', little-endian), not rounded. In
19
+ # lockstep a one-ULP difference is a real divergence -- it compounds -- so
20
+ # the check must see it.
21
+ module StateHash
22
+ # Sections are hashed separately as well as folded together. When a
23
+ # desync fires, the section that differs says where to look.
24
+ SECTIONS = %i[rng players monsters combat sectors].freeze
25
+
26
+ module_function
27
+
28
+ def of(world)
29
+ fold(sections(world))
30
+ end
31
+
32
+ def sections(world)
33
+ {
34
+ rng: crc([world.leveltime, world.random.index].pack('q<q<')),
35
+ players: crc(pack_players(world.players)),
36
+ monsters: crc(pack_monsters(world.monster_ai)),
37
+ combat: crc(pack_combat(world.combat)),
38
+ sectors: crc(pack_sectors(world))
39
+ }
40
+ end
41
+
42
+ def fold(sections)
43
+ crc(SECTIONS.map { |name| sections[name] }.pack('L<*'))
44
+ end
45
+
46
+ def crc(bytes)
47
+ Zlib.crc32(bytes)
48
+ end
49
+
50
+ def pack_players(players)
51
+ players.flat_map do |p|
52
+ s = p.state
53
+ [
54
+ p.id, s.health, s.armor, s.weapon, (s.dead ? 1 : 0),
55
+ s.ammo_bullets, s.ammo_shells, s.ammo_rockets, s.ammo_cells,
56
+ s.keys.count { |_, v| v },
57
+ # Frags are simulation state: peers that disagree on the score
58
+ # disagree on who won, and that must show up as a desync.
59
+ p.frags
60
+ ].pack('q<*') +
61
+ [p.x, p.y, p.z, p.angle, p.momx, p.momy].pack('E*')
62
+ end.join
63
+ end
64
+
65
+ def pack_monsters(monster_ai)
66
+ return '' unless monster_ai
67
+
68
+ things = monster_ai.instance_variable_get(:@map).things
69
+ monster_ai.monsters.flat_map do |m|
70
+ thing = things[m.thing_idx]
71
+ [
72
+ m.thing_idx, m.movedir, m.movecount, (m.active ? 1 : 0),
73
+ (m.attacking ? 1 : 0), m.target&.id || -1,
74
+ # The map thing's integer position, which hitscan traces against,
75
+ # lags mon.x/mon.y and is distinct sim state -- a desync in it makes
76
+ # bullets miss where a monster appears to be.
77
+ thing.x, thing.y
78
+ ].pack('q<*') + [m.x, m.y].pack('E*')
79
+ end.join
80
+ end
81
+
82
+ def pack_combat(combat)
83
+ return '' unless combat
84
+
85
+ hp = combat.instance_variable_get(:@monster_hp) || {}
86
+ parts = [
87
+ hp.sort.flatten.pack('q<*'),
88
+ combat.dead_things.keys.sort.pack('q<*')
89
+ ]
90
+ # z and dz are as much sim state as x/y -- a projectile diverging only in
91
+ # height still diverges. spawn_tic pins its identity, and the damage
92
+ # multiplier it carries changes the damage it will deal on impact.
93
+ parts << combat.projectiles.flat_map do |pr|
94
+ [pr.x, pr.y, pr.z, pr.dx, pr.dy, pr.dz || 0.0, pr.damage_multiplier || 1.0]
95
+ end.pack('E*')
96
+ parts << combat.projectiles.map(&:spawn_tic).pack('q<*')
97
+ parts.join
98
+ end
99
+
100
+ # Sector heights as well as lights. Doors and lifts move floor and
101
+ # ceiling heights, and until now the fingerprint watched only the light
102
+ # level -- so a door desyncing between peers went unnoticed. Active door
103
+ # and lift animations are folded in too, so a divergence is caught while
104
+ # the sector is still moving rather than only once it settles.
105
+ def pack_sectors(world)
106
+ sectors = world.map.sectors
107
+ heights = sectors.flat_map { |s| [s.light_level, s.floor_height, s.ceiling_height] }
108
+
109
+ actions = world.sector_actions
110
+ doors = actions.instance_variable_get(:@active_doors) || {}
111
+ lifts = actions.instance_variable_get(:@active_lifts) || {}
112
+ sorted_lifts = lifts.sort_by { |idx, _| idx }
113
+ moving = doors.sort_by { |idx, _| idx }.flat_map { |idx, d| [idx, d[:state], d[:wait_tics]] } +
114
+ sorted_lifts.flat_map { |idx, l| [idx, l[:wait_tics]] }
115
+ # A lift's :state is a symbol (going down / waiting / coming up), not an
116
+ # integer like a door's, so it is folded in as bytes rather than packed
117
+ # with the integer run -- otherwise a lift changing phase between peers
118
+ # would go unnoticed the way a moving door once did.
119
+ lift_states = sorted_lifts.map { |_, l| l[:state].to_s }.join("\0")
120
+
121
+ heights.pack('q<*') + moving.pack('q<*') + lift_states
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # One tic's worth of player intent, as in DOOM's ticcmd_t.
6
+ #
7
+ # This is the only thing that travels between peers in lockstep
8
+ # multiplayer: never positions, never health. Each peer feeds identical
9
+ # ticcmds to an identical simulation and arrives at an identical world, so
10
+ # the wire format stays tiny and cheating by position injection is
11
+ # impossible by construction.
12
+ #
13
+ # forwardmove -1.0..1.0 (negative = backward)
14
+ # sidemove -1.0..1.0 (positive = strafe right)
15
+ # angleturn degrees to turn this tic (positive = left, as DOOM)
16
+ # buttons bitmask, see constants below
17
+ # Subclassed rather than built with a Struct.new block: constants assigned
18
+ # inside a block land in the enclosing lexical scope (Doom::Game), not on
19
+ # the struct class, so Ticcmd::BTN_FIRE would not resolve.
20
+ class Ticcmd < Struct.new(:forwardmove, :sidemove, :angleturn, :buttons)
21
+ BTN_FIRE = 1
22
+ BTN_USE = 2
23
+ PACKED_SIZE = 7
24
+
25
+ def self.none
26
+ new(0.0, 0.0, 0.0, 0)
27
+ end
28
+
29
+ def fire?
30
+ (buttons & BTN_FIRE) != 0
31
+ end
32
+
33
+ def use?
34
+ (buttons & BTN_USE) != 0
35
+ end
36
+
37
+ def moving?
38
+ forwardmove != 0.0 || sidemove != 0.0
39
+ end
40
+
41
+ # Fixed 7 bytes on the wire ('s>s>s>C', == PACKED_SIZE): three signed
42
+ # shorts plus a byte of buttons, no padding. Movement is quantised to
43
+ # 1/1000 and the turn to 1/100 of a degree -- both far finer than any input
44
+ # device resolves, and identical on every peer, which matters more than the
45
+ # precision itself.
46
+ def pack
47
+ [
48
+ (forwardmove * 1000).round.clamp(-32_768, 32_767),
49
+ (sidemove * 1000).round.clamp(-32_768, 32_767),
50
+ (angleturn * 100).round.clamp(-32_768, 32_767),
51
+ buttons & 0xff
52
+ ].pack('s>s>s>C')
53
+ end
54
+
55
+ def self.unpack(bytes)
56
+ f, s, a, b = bytes.unpack('s>s>s>C')
57
+ new(f / 1000.0, s / 1000.0, a / 100.0, b)
58
+ end
59
+ end
60
+ end
61
+ end