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,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Player movement and vertical physics, extracted from the Gosu window
6
+ # so it can be unit-tested without a graphics stack.
7
+ #
8
+ # Tracks the player's feet position (floor_z) and vertical velocity (momz)
9
+ # in DOOM map units, matching Chocolate Doom's P_ZMovement semantics:
10
+ # GRAVITY=1 unit/tic^2, falls clamp to floor, viewheight squat on landing.
11
+ class PlayerPhysics
12
+ PLAYER_RADIUS = 16.0
13
+
14
+ MAX_STEP_UP = 24
15
+ MIN_HEADROOM = 56
16
+
17
+ GRAVITY = 1.0
18
+ INITIAL_FALL_MOMZ = -2.0 # First-tic kick when momz==0 (P_ZMovement uses -GRAVITY*2)
19
+ FALL_IMPACT_THRESHOLD = -8.0 # momz < -GRAVITY*8 triggers viewheight squat
20
+
21
+ TICRATE = 35.0
22
+
23
+ # Horizontal movement runs per-tic, not per-frame. A frame-rate-dependent
24
+ # integration can never be deterministic, and lockstep needs every peer to
25
+ # get the same result from the same ticcmd.
26
+ #
27
+ # FRICTION is DOOM's exact per-tic value (ORIG_FRICTION 0xE800/65536).
28
+ # Momentum is carried in units/sec, so THRUST_PER_TIC is chosen to hold
29
+ # the same terminal speed the old continuous model had:
30
+ # v_terminal = thrust * FRICTION / (1 - FRICTION)
31
+ FRICTION = 0.90625
32
+ TERMINAL_SPEED = 264.0 # units/sec
33
+ THRUST_PER_TIC = TERMINAL_SPEED * (1.0 - FRICTION) / FRICTION # ~27.3
34
+ SIDE_THRUST_SCALE = 0.8 # DOOM strafes slower than it walks
35
+ STOPSPEED = 0.5 # Snap-to-zero threshold (units/sec)
36
+
37
+ # Solid thing types with their collision radii (from mobjinfo[] MF_SOLID).
38
+ SOLID_THING_RADIUS = {
39
+ 9 => 20, 65 => 20, 66 => 20, 67 => 20, 68 => 20, # Shotgun Guy variants
40
+ 3004 => 20, 84 => 20, # Zombieman
41
+ 3001 => 20, # Imp
42
+ 3002 => 30, 58 => 30, # Demon, Spectre
43
+ 3003 => 24, 69 => 24, # Baron, Hell Knight
44
+ 3006 => 16, # Lost Soul
45
+ 3005 => 31, # Cacodemon
46
+ 16 => 40, # Cyberdemon
47
+ 7 => 128, # Spider Mastermind
48
+ 64 => 20, # Archvile
49
+ 71 => 31, # Pain Elemental
50
+ 2035 => 10, # Barrel
51
+ 2028 => 16, # Tall lamp
52
+ 48 => 16, 30 => 16, 32 => 16, # Tech column, green/red pillars
53
+ 31 => 16, 33 => 16, 36 => 16, # Short pillars
54
+ 41 => 16, 43 => 16, # Evil eye, burnt tree
55
+ 54 => 32, # Brown tree
56
+ 44 => 16, 45 => 16, 46 => 16, # Tall torches
57
+ 55 => 16, 56 => 16, 57 => 16, # Short torches
58
+ 47 => 16, 70 => 16, # Stubs
59
+ 85 => 16, 86 => 16, # Tall tech lamps
60
+ 2046 => 16 # Burning barrel
61
+ }.freeze
62
+
63
+ attr_reader :floor_z, :momz
64
+ attr_writer :skill_hidden, :item_pickup, :combat
65
+
66
+ def initialize(map, player_state)
67
+ @map = map
68
+ @player_state = player_state
69
+ @floor_z = nil
70
+ @momz = 0.0
71
+ @skill_hidden = {}
72
+ @item_pickup = nil
73
+ @combat = nil
74
+ end
75
+
76
+ def reset
77
+ @floor_z = nil
78
+ @momz = 0.0
79
+ end
80
+
81
+ # Eye position used by the renderer: feet + viewheight + view-bob.
82
+ # Returns nil before the first settle_at.
83
+ def eye_z
84
+ return nil unless @floor_z
85
+
86
+ if @player_state
87
+ @floor_z + @player_state.viewheight + @player_state.view_bob_offset
88
+ else
89
+ @floor_z + PlayerState::VIEWHEIGHT
90
+ end
91
+ end
92
+
93
+ # Bring the player onto the floor at (x, y) after a horizontal move.
94
+ # Snaps up for step-up (gated to <= MAX_STEP_UP by valid_move?), leaves
95
+ # @floor_z alone for step-down so per-tic gravity can drop the player.
96
+ def settle_at(x, y)
97
+ sector = @map.sector_at(x, y)
98
+ return unless sector
99
+
100
+ new_floor = sector.floor_height
101
+
102
+ if @player_state
103
+ @floor_z ||= new_floor
104
+ if new_floor > @floor_z
105
+ step = new_floor - @floor_z
106
+ @player_state.notify_step(step) if step.abs <= MAX_STEP_UP
107
+ @floor_z = new_floor
108
+ @momz = 0.0
109
+ end
110
+ else
111
+ @floor_z = new_floor
112
+ end
113
+ end
114
+
115
+ # Per-tic vertical movement. Mutates @floor_z and @momz; calls
116
+ # player_state.notify_step / apply_fall_impact as needed.
117
+ def step(x, y)
118
+ return unless @player_state && @floor_z
119
+
120
+ sector = @map.sector_at(x, y)
121
+ return unless sector
122
+
123
+ ground = sector.floor_height
124
+
125
+ if @floor_z > ground
126
+ @momz = (@momz == 0.0 ? INITIAL_FALL_MOMZ : @momz - GRAVITY)
127
+ @floor_z += @momz
128
+ if @floor_z <= ground
129
+ impact_momz = @momz
130
+ @floor_z = ground
131
+ @momz = 0.0
132
+ @player_state.apply_fall_impact(impact_momz) if impact_momz < FALL_IMPACT_THRESHOLD
133
+ end
134
+ elsif @floor_z < ground
135
+ # Floor rose under player (lift, raising sector)
136
+ step_amount = ground - @floor_z
137
+ @player_state.notify_step(step_amount) if step_amount.abs <= MAX_STEP_UP
138
+ @floor_z = ground
139
+ @momz = 0.0
140
+ end
141
+ end
142
+
143
+ # Advance one player by one tic from its ticcmd: turn, thrust, friction,
144
+ # then move with wall sliding. This is the whole horizontal simulation,
145
+ # and it is a pure function of (player, cmd, map) -- no wall clock, no
146
+ # frame rate, no Kernel#rand. That is what makes lockstep possible.
147
+ def run_tic(player, cmd)
148
+ player.turn(cmd.angleturn) if cmd.angleturn != 0.0
149
+
150
+ if cmd.forwardmove != 0.0
151
+ thrust = cmd.forwardmove * THRUST_PER_TIC
152
+ player.momx += player.cos_angle * thrust
153
+ player.momy += player.sin_angle * thrust
154
+ end
155
+
156
+ if cmd.sidemove != 0.0
157
+ thrust = cmd.sidemove * THRUST_PER_TIC * SIDE_THRUST_SCALE
158
+ # Strafe right is 90 degrees clockwise from facing.
159
+ player.momx += player.sin_angle * thrust
160
+ player.momy -= player.cos_angle * thrust
161
+ end
162
+
163
+ if !cmd.moving? && player.momx.abs < STOPSPEED && player.momy.abs < STOPSPEED
164
+ player.momx = 0.0
165
+ player.momy = 0.0
166
+ else
167
+ player.momx *= FRICTION
168
+ player.momy *= FRICTION
169
+ end
170
+
171
+ return unless player.momx.abs > STOPSPEED || player.momy.abs > STOPSPEED
172
+
173
+ move(player, player.momx / TICRATE, player.momy / TICRATE)
174
+ end
175
+
176
+ # Move a player by (dx, dy) with collision and wall sliding, settling it
177
+ # onto the floor wherever it ends up.
178
+ def move(player, dx, dy)
179
+ old_x = player.x
180
+ old_y = player.y
181
+ new_x = old_x + dx
182
+ new_y = old_y + dy
183
+
184
+ if valid_move?(old_x, old_y, new_x, new_y)
185
+ player.move_to(new_x, new_y)
186
+ settle(player, new_x, new_y)
187
+ return
188
+ end
189
+
190
+ # Wall sliding: project movement along the blocking wall
191
+ slide_x, slide_y = compute_slide(old_x, old_y, dx, dy)
192
+ if slide_x && (slide_x != 0.0 || slide_y != 0.0)
193
+ sx = old_x + slide_x
194
+ sy = old_y + slide_y
195
+ if valid_move?(old_x, old_y, sx, sy)
196
+ player.move_to(sx, sy)
197
+ settle(player, sx, sy)
198
+ scale = [dx.abs, dy.abs].max.nonzero? || 1
199
+ player.momx = slide_x / scale * player.momx.abs
200
+ player.momy = slide_y / scale * player.momy.abs
201
+ return
202
+ end
203
+ end
204
+
205
+ # Fallback: try axis-aligned sliding
206
+ if dx != 0.0 && valid_move?(old_x, old_y, new_x, old_y)
207
+ player.move_to(new_x, old_y)
208
+ settle(player, new_x, old_y)
209
+ player.momy = 0.0
210
+ elsif dy != 0.0 && valid_move?(old_x, old_y, old_x, new_y)
211
+ player.move_to(old_x, new_y)
212
+ settle(player, old_x, new_y)
213
+ player.momx = 0.0
214
+ else
215
+ player.momx = 0.0
216
+ player.momy = 0.0
217
+ end
218
+ end
219
+
220
+ # Settle onto the floor and push the resulting eye height to the player.
221
+ def settle(player, x, y)
222
+ settle_at(x, y)
223
+ z = eye_z
224
+ player.z = z if z
225
+ end
226
+
227
+ # When valid_move? fails, find the nearest blocking wall and return a
228
+ # slide vector projected along it. Returns nil if no wall blocks.
229
+ def compute_slide(px, py, dx, dy)
230
+ best_wall = nil
231
+ best_dist = Float::INFINITY
232
+
233
+ each_nearby_linedef(px, py, px + dx, py + dy) do |linedef|
234
+ v1 = @map.vertices[linedef.v1]
235
+ v2 = @map.vertices[linedef.v2]
236
+
237
+ next unless Geometry.line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, px + dx, py + dy, PLAYER_RADIUS)
238
+ next unless linedef_blocks?(linedef, px, py, px + dx, py + dy) ||
239
+ crosses_blocking_linedef?(px, py, px + dx, py + dy, linedef)
240
+
241
+ dist = Geometry.point_to_segment_distance(px, py, v1.x, v1.y, v2.x, v2.y)
242
+ if dist < best_dist
243
+ best_dist = dist
244
+ best_wall = linedef
245
+ end
246
+ end
247
+
248
+ return nil unless best_wall
249
+
250
+ v1 = @map.vertices[best_wall.v1]
251
+ v2 = @map.vertices[best_wall.v2]
252
+ wall_dx = (v2.x - v1.x).to_f
253
+ wall_dy = (v2.y - v1.y).to_f
254
+ wall_len = Math.sqrt((wall_dx * wall_dx) + (wall_dy * wall_dy))
255
+ return nil if wall_len == 0
256
+
257
+ wall_dx /= wall_len
258
+ wall_dy /= wall_len
259
+ dot = (dx * wall_dx) + (dy * wall_dy)
260
+ [dot * wall_dx, dot * wall_dy]
261
+ end
262
+
263
+ # True if the player can occupy (new_x, new_y). Step-up is capped at
264
+ # MAX_STEP_UP from the player's current floor; step-down is unlimited.
265
+ def valid_move?(old_x, old_y, new_x, new_y)
266
+ sector = @map.sector_at(new_x, new_y)
267
+ return false unless sector
268
+
269
+ current_floor = @floor_z || sector.floor_height
270
+ return false if sector.floor_height - current_floor > MAX_STEP_UP
271
+
272
+ each_nearby_linedef(old_x, old_y, new_x, new_y) do |linedef|
273
+ return false if linedef_blocks?(linedef, old_x, old_y, new_x, new_y)
274
+ return false if crosses_blocking_linedef?(old_x, old_y, new_x, new_y, linedef)
275
+ end
276
+
277
+ return false if collides_with_solid_thing?(new_x, new_y)
278
+
279
+ true
280
+ end
281
+
282
+ private
283
+
284
+ # Iterate linedefs that may interact with the player moving from
285
+ # (x1, y1) to (x2, y2). Uses BLOCKMAP when available; otherwise falls
286
+ # back to scanning all linedefs.
287
+ def each_nearby_linedef(x1, y1, x2, y2, &)
288
+ unless @map.respond_to?(:blockmap_loaded?) && @map.blockmap_loaded?
289
+ @map.linedefs.each(&)
290
+ return
291
+ end
292
+
293
+ # Pad the bbox by player radius so walls just outside still register.
294
+ min_x = [x1, x2].min - PLAYER_RADIUS
295
+ max_x = [x1, x2].max + PLAYER_RADIUS
296
+ min_y = [y1, y2].min - PLAYER_RADIUS
297
+ max_y = [y1, y2].max + PLAYER_RADIUS
298
+ @map.each_linedef_near(min_x, min_y, max_x, max_y, &)
299
+ end
300
+
301
+ def crosses_blocking_linedef?(x1, y1, x2, y2, linedef)
302
+ v1 = @map.vertices[linedef.v1]
303
+ v2 = @map.vertices[linedef.v2]
304
+
305
+ return Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y) if linedef.sidedef_left == 0xFFFF
306
+
307
+ if (linedef.flags & 0x0001) != 0 # ML_BLOCKING
308
+ return Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
309
+ end
310
+
311
+ front_sector = sector_for_side(linedef.sidedef_right)
312
+ back_sector = sector_for_side(linedef.sidedef_left)
313
+
314
+ min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
315
+ max_floor = [front_sector.floor_height, back_sector.floor_height].max
316
+ current_floor = @floor_z || front_sector.floor_height
317
+ step_up = max_floor - current_floor
318
+
319
+ return false if step_up <= MAX_STEP_UP && (min_ceiling - max_floor) >= MIN_HEADROOM
320
+
321
+ Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
322
+ end
323
+
324
+ def linedef_blocks?(linedef, old_x, old_y, new_x, new_y)
325
+ v1 = @map.vertices[linedef.v1]
326
+ v2 = @map.vertices[linedef.v2]
327
+
328
+ return false unless Geometry.line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, PLAYER_RADIUS)
329
+
330
+ impassable =
331
+ if linedef.sidedef_left == 0xFFFF
332
+ true
333
+ else
334
+ # ML_BLOCKING on two-sided lines is handled by crosses_blocking_linedef?
335
+ # (a proximity check would block the player when standing near, not
336
+ # just crossing).
337
+ front_sector = sector_for_side(linedef.sidedef_right)
338
+ back_sector = sector_for_side(linedef.sidedef_left)
339
+
340
+ min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
341
+ max_floor = [front_sector.floor_height, back_sector.floor_height].max
342
+ current_floor = @floor_z || front_sector.floor_height
343
+ step_up = max_floor - current_floor
344
+
345
+ step_up > MAX_STEP_UP || (min_ceiling - max_floor) < MIN_HEADROOM
346
+ end
347
+ return false unless impassable
348
+
349
+ # The circle overlaps an impassable wall at the destination. Block the
350
+ # move only if it does not increase the distance to that wall, so a body
351
+ # that is already overlapping one -- e.g. after landing from a fall at
352
+ # the base of a tall step, where the airborne slide let the radius graze
353
+ # the ledge -- can still move away and unstick, but can never push
354
+ # further in or climb the step. A normal head-on approach still stops at
355
+ # radius; sliding parallel (equal distance) is still allowed.
356
+ d_new = Geometry.point_to_segment_distance(new_x, new_y, v1.x, v1.y, v2.x, v2.y)
357
+ d_old = Geometry.point_to_segment_distance(old_x, old_y, v1.x, v1.y, v2.x, v2.y)
358
+ d_new < d_old
359
+ end
360
+
361
+ def collides_with_solid_thing?(x, y)
362
+ picked = @item_pickup&.picked_up
363
+ @map.things.each_with_index do |thing, idx|
364
+ next if @skill_hidden[idx]
365
+ next if picked && picked[idx]
366
+ next if @combat && @combat.dead?(idx)
367
+
368
+ thing_radius = SOLID_THING_RADIUS[thing.type]
369
+ next unless thing_radius
370
+
371
+ dx = x - thing.x
372
+ dy = y - thing.y
373
+ min_dist = PLAYER_RADIUS + thing_radius
374
+ return true if (dx * dx) + (dy * dy) < min_dist * min_dist
375
+ end
376
+ false
377
+ end
378
+
379
+ def sector_for_side(side_idx)
380
+ @map.sectors[@map.sidedefs[side_idx].sector]
381
+ end
382
+ end
383
+ end
384
+ end
@@ -36,24 +36,14 @@ module Doom
36
36
  WEAPON_PLASMA => 8, # Fast energy weapon
37
37
  WEAPON_BFG => 60, # Long charge + fire
38
38
  WEAPON_CHAINSAW => 6 # Fast melee
39
- }.freeze
40
-
41
- attr_accessor :health, :armor, :max_health, :max_armor
42
- attr_accessor :ammo_bullets, :ammo_shells, :ammo_rockets, :ammo_cells
43
- attr_accessor :max_bullets, :max_shells, :max_rockets, :max_cells
44
- attr_accessor :weapon, :has_weapons
45
- attr_accessor :keys
46
- attr_accessor :attacking, :attack_frame, :attack_tics
47
- attr_accessor :bob_angle, :bob_amount
48
- attr_accessor :is_moving
49
- attr_accessor :dead, :death_tic
50
- attr_accessor :damage_count # Red flash intensity (0-8), decays each tic
39
+ }.freeze # Red flash intensity (0-8), decays each tic
40
+ attr_accessor :health, :armor, :max_health, :max_armor, :ammo_bullets, :ammo_shells, :ammo_rockets, :ammo_cells,
41
+ :max_bullets, :max_shells, :max_rockets, :max_cells, :weapon, :has_weapons, :keys, :attacking, :attack_frame, :attack_tics, :bob_angle, :bob_amount, :is_moving, :dead, :death_tic, :damage_count, :god_mode, :infinite_ammo
51
42
 
52
43
  # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
53
44
  VIEWHEIGHT = 41.0
54
45
  VIEWHEIGHT_HALF = VIEWHEIGHT / 2.0
55
- DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
56
- attr_reader :viewheight, :deltaviewheight
46
+ DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
57
47
 
58
48
  # View bob (camera bounce when walking, matching Chocolate Doom's
59
49
  # P_CalcHeight + P_XYMovement + P_Thrust from p_user.c / p_mobj.c)
@@ -67,7 +57,7 @@ module Doom
67
57
  BOB_DECAY_RATE = 3.44 # Friction as continuous decay rate (1/sec)
68
58
  BOB_THRUST = 26.0 # Walk thrust (map units/sec), gives terminal ~7.5
69
59
  BOB_FREQUENCY = 11.0 # Bob cycle frequency (rad/sec): FINEANGLES/20 * 35 / 8192 * 2*PI
70
- attr_reader :view_bob_offset
60
+ attr_reader :viewheight, :deltaviewheight, :view_bob_offset
71
61
 
72
62
  def initialize
73
63
  reset
@@ -114,6 +104,10 @@ module Doom
114
104
  @death_tic = 0
115
105
  @damage_count = 0
116
106
 
107
+ # Cheats
108
+ @god_mode = false
109
+ @infinite_ammo = false
110
+
117
111
  # Weapon bob
118
112
  @bob_angle = 0.0
119
113
  @bob_amount = 0.0
@@ -127,7 +121,10 @@ module Doom
127
121
  @view_bob_offset = 0.0
128
122
  @momx = 0.0 # Simulated X momentum (map units/sec, not actual movement)
129
123
  @momy = 0.0 # Simulated Y momentum
130
- @thrust_x = 0.0 # Per-frame thrust input (raw, before normalization)
124
+ # Held at zero and never read by game logic, but part of the snapshot
125
+ # wire format and state fingerprint (PLAYER_STATE_FLOATS), so removing
126
+ # them would need a snapshot version bump. Left in place deliberately.
127
+ @thrust_x = 0.0
131
128
  @thrust_y = 0.0
132
129
  @view_bob_angle = 0.0
133
130
  end
@@ -151,23 +148,9 @@ module Doom
151
148
  end
152
149
  end
153
150
 
154
- def max_ammo_for_weapon
155
- case @weapon
156
- when WEAPON_PISTOL, WEAPON_CHAINGUN
157
- @max_bullets
158
- when WEAPON_SHOTGUN
159
- @max_shells
160
- when WEAPON_ROCKET
161
- @max_rockets
162
- when WEAPON_PLASMA, WEAPON_BFG
163
- @max_cells
164
- else
165
- nil
166
- end
167
- end
168
-
169
151
  def can_attack?
170
152
  return true if @weapon == WEAPON_FIST || @weapon == WEAPON_CHAINSAW
153
+ return true if @infinite_ammo
171
154
 
172
155
  ammo = current_ammo
173
156
  ammo && ammo > 0
@@ -181,7 +164,9 @@ module Doom
181
164
  @attack_frame = 0
182
165
  @attack_tics = 0
183
166
 
184
- # Consume ammo
167
+ # Consume ammo (skipped with infinite ammo)
168
+ return if @infinite_ammo
169
+
185
170
  case @weapon
186
171
  when WEAPON_PISTOL
187
172
  @ammo_bullets -= 1 if @ammo_bullets > 0
@@ -211,21 +196,21 @@ module Doom
211
196
  @attack_frame = (@attack_tics / tics_per_frame).to_i
212
197
 
213
198
  # Attack finished?
214
- if @attack_tics >= duration
215
- @attacking = false
216
- @attack_frame = 0
217
- @attack_tics = 0
218
- end
199
+ return unless @attack_tics >= duration
200
+
201
+ @attacking = false
202
+ @attack_frame = 0
203
+ @attack_tics = 0
219
204
  end
220
205
 
221
206
  def update_bob(delta_time)
222
207
  if @is_moving
223
208
  # Increase bob while moving
224
209
  @bob_angle += delta_time * 10.0
225
- @bob_amount = [@bob_amount + delta_time * 16.0, 6.0].min
210
+ @bob_amount = [@bob_amount + (delta_time * 16.0), 6.0].min
226
211
  else
227
212
  # Decay bob when stopped
228
- @bob_amount = [@bob_amount - delta_time * 12.0, 0.0].max
213
+ @bob_amount = [@bob_amount - (delta_time * 12.0), 0.0].max
229
214
  end
230
215
  end
231
216
 
@@ -234,10 +219,18 @@ module Doom
234
219
  # so the camera doesn't snap, then let P_CalcHeight recover it smoothly.
235
220
  def notify_step(step_amount)
236
221
  return if step_amount == 0
222
+
237
223
  @viewheight -= step_amount
238
224
  @deltaviewheight = (VIEWHEIGHT - @viewheight) / 8.0
239
225
  end
240
226
 
227
+ # Called on landing after a fall. Matches Chocolate Doom P_ZMovement:
228
+ # deltaviewheight = momz >> 3, producing a squat that update_viewheight
229
+ # then recovers via DELTA_ACCEL.
230
+ def apply_fall_impact(momz)
231
+ @deltaviewheight = momz / 8.0
232
+ end
233
+
241
234
  # Gradually restore viewheight to VIEWHEIGHT (called each tic).
242
235
  # Matches Chocolate Doom P_CalcHeight viewheight recovery loop.
243
236
  # For step-up: viewheight < 41, delta > 0, accelerates upward.
@@ -255,10 +248,10 @@ module Doom
255
248
  @deltaviewheight = 1.0 if @deltaviewheight <= 0
256
249
  end
257
250
 
258
- if @deltaviewheight != 0
259
- @deltaviewheight += DELTA_ACCEL
260
- @deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
261
- end
251
+ return unless @deltaviewheight != 0
252
+
253
+ @deltaviewheight += DELTA_ACCEL
254
+ @deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
262
255
  end
263
256
 
264
257
  # Set movement momentum directly (called from GosuWindow with actual
@@ -273,13 +266,13 @@ module Doom
273
266
  # bob = (momx*momx + momy*momy) >> 2, capped at MAXBOB
274
267
  # viewz += finesine[angle] * bob/2
275
268
  # Momentum is in units/sec; DOOM's bob uses units/tic (divide by 35).
276
- BOB_MOM_SCALE = 1.0 / (35.0 * 35.0 * 4.0) # (mom/35)^2 / 4
269
+ BOB_MOM_SCALE = 1.0 / (35.0 * 35.0 * 4.0) # (mom/35)^2 / 4
277
270
 
278
271
  def update_view_bob(delta_time)
279
272
  dt = delta_time.clamp(0.001, 0.05)
280
273
 
281
274
  # P_CalcHeight: bob = (momx_per_tic^2 + momy_per_tic^2) / 4, capped at MAXBOB
282
- bob = (@momx * @momx + @momy * @momy) * BOB_MOM_SCALE
275
+ bob = ((@momx * @momx) + (@momy * @momy)) * BOB_MOM_SCALE
283
276
  bob = MAXBOB if bob > MAXBOB
284
277
 
285
278
  # Advance bob sine wave (FINEANGLES/20 per tic = ~11 rad/sec)
@@ -297,17 +290,6 @@ module Doom
297
290
  Math.sin(@bob_angle * 2) * @bob_amount * 0.5
298
291
  end
299
292
 
300
- def health_level
301
- # 0 = dying, 4 = full health
302
- case @health
303
- when 80..200 then 4
304
- when 60..79 then 3
305
- when 40..59 then 2
306
- when 20..39 then 1
307
- else 0
308
- end
309
- end
310
-
311
293
  def switch_weapon(weapon_num)
312
294
  return unless weapon_num >= 0 && weapon_num < 8
313
295
  return unless @has_weapons[weapon_num]
@@ -319,10 +301,11 @@ module Doom
319
301
  # Apply damage (from environment or enemies). Armor absorbs some.
320
302
  def take_damage(amount)
321
303
  return if @dead
304
+ return if @god_mode
322
305
 
323
306
  absorbed = 0
324
307
  if @armor > 0
325
- absorbed = amount / 3 # Green armor absorbs 1/3
308
+ absorbed = amount / 3 # Green armor absorbs 1/3
326
309
  absorbed = @armor if absorbed > @armor
327
310
  @armor -= absorbed
328
311
  end
@@ -331,13 +314,13 @@ module Doom
331
314
  @health -= actual
332
315
 
333
316
  # Red flash proportional to damage (capped at palette 8)
334
- @damage_count = [(@damage_count + actual / 2.0).ceil, 8].min
317
+ @damage_count = [(@damage_count + (actual / 2.0)).ceil, 8].min
335
318
 
336
- if @health <= 0
337
- @health = 0
338
- @damage_count = 8
339
- die
340
- end
319
+ return unless @health <= 0
320
+
321
+ @health = 0
322
+ @damage_count = 8
323
+ die
341
324
  end
342
325
 
343
326
  # Decay damage flash each tic
@@ -349,7 +332,7 @@ module Doom
349
332
  @dead = true
350
333
  @death_tic = 0
351
334
  @attacking = false
352
- @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
335
+ @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
353
336
  end
354
337
  end
355
338
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # DOOM's P_Random -- a fixed 256-entry table walked by an index, not a PRNG.
6
+ #
7
+ # The point is determinism: two peers running the same tics with the same
8
+ # starting index observe identical results, which is what lockstep
9
+ # networking requires. Kernel#rand cannot give us that.
10
+ #
11
+ # Matches m_random.c. Only the game-state RNG lives here; presentation-only
12
+ # randomness (screen melt) stays on Kernel#rand since it never feeds the
13
+ # simulation.
14
+ class Random
15
+ RNDTABLE = [
16
+ 0, 8, 109, 220, 222, 241, 149, 107, 75, 248, 254, 140, 16, 66,
17
+ 74, 21, 211, 47, 80, 242, 154, 27, 205, 128, 161, 89, 77, 36,
18
+ 95, 110, 85, 48, 212, 140, 211, 249, 22, 79, 200, 50, 28, 188,
19
+ 52, 140, 202, 120, 68, 145, 62, 70, 184, 190, 91, 197, 152, 224,
20
+ 149, 104, 25, 178, 252, 182, 202, 182, 141, 197, 4, 81, 181, 242,
21
+ 145, 42, 39, 227, 156, 198, 225, 193, 219, 93, 122, 175, 249, 0,
22
+ 175, 143, 70, 239, 46, 246, 163, 53, 163, 109, 168, 135, 2, 235,
23
+ 25, 92, 20, 145, 138, 77, 69, 166, 78, 176, 173, 212, 166, 113,
24
+ 94, 161, 41, 50, 239, 49, 111, 164, 70, 60, 2, 37, 171, 75,
25
+ 136, 156, 11, 56, 42, 146, 138, 229, 73, 146, 77, 61, 98, 196,
26
+ 135, 106, 63, 197, 195, 86, 96, 203, 113, 101, 170, 247, 181, 113,
27
+ 80, 250, 108, 7, 255, 237, 129, 226, 79, 107, 112, 166, 103, 241,
28
+ 24, 223, 239, 120, 198, 58, 60, 82, 128, 3, 184, 66, 143, 224,
29
+ 145, 224, 81, 206, 163, 45, 63, 90, 168, 114, 59, 33, 159, 95,
30
+ 28, 139, 123, 98, 125, 196, 15, 70, 194, 253, 54, 14, 109, 226,
31
+ 71, 17, 161, 93, 186, 87, 244, 138, 20, 52, 123, 251, 26, 36,
32
+ 17, 46, 52, 231, 232, 76, 31, 221, 84, 37, 216, 165, 212, 106,
33
+ 197, 242, 98, 43, 39, 175, 254, 145, 190, 84, 118, 222, 187, 136,
34
+ 120, 163, 236, 249
35
+ ].freeze
36
+
37
+ # Index into RNDTABLE. Part of the game state -- include it in desync
38
+ # hashes, and save/restore it alongside player positions.
39
+ attr_accessor :index
40
+
41
+ def initialize(seed = 0)
42
+ reset(seed)
43
+ end
44
+
45
+ def reset(seed = 0)
46
+ @index = seed & 0xff
47
+ end
48
+
49
+ # P_Random(): next table entry, 0-255.
50
+ def byte
51
+ @index = (@index + 1) & 0xff
52
+ RNDTABLE[@index]
53
+ end
54
+
55
+ # Signed difference of two draws, -255..255. DOOM uses this shape a lot
56
+ # (P_Random() - P_Random()) for spread and jitter.
57
+ def diff
58
+ byte - byte
59
+ end
60
+
61
+ # Kernel#rand-compatible surface so call sites read the same as before.
62
+ #
63
+ # rand -> Float in [0, 1)
64
+ # rand(n) -> Integer in 0...n (DOOM's P_Random()%n)
65
+ # rand(a..b) -> Integer in a..b
66
+ def rand(arg = nil)
67
+ case arg
68
+ when nil
69
+ byte / 256.0
70
+ when ::Range
71
+ lo = arg.begin
72
+ hi = arg.exclude_end? ? arg.end - 1 : arg.end
73
+ span = hi - lo + 1
74
+ span <= 0 ? lo : lo + (byte % span)
75
+ else
76
+ n = arg.to_i
77
+ n <= 0 ? 0 : byte % n
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end