doom 0.8.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 +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  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 +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  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 +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  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 +33 -58
  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 +604 -832
  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 +5 -3
  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 +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  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 +9 -10
  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 +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -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,25 +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
51
- attr_accessor :god_mode, :infinite_ammo
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
52
42
 
53
43
  # Smooth step-up/down (matching Chocolate Doom's P_CalcHeight / P_ZMovement)
54
44
  VIEWHEIGHT = 41.0
55
45
  VIEWHEIGHT_HALF = VIEWHEIGHT / 2.0
56
- DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
57
- attr_reader :viewheight, :deltaviewheight
46
+ DELTA_ACCEL = 0.25 # deltaviewheight += FRACUNIT/4 per tic
58
47
 
59
48
  # View bob (camera bounce when walking, matching Chocolate Doom's
60
49
  # P_CalcHeight + P_XYMovement + P_Thrust from p_user.c / p_mobj.c)
@@ -68,7 +57,7 @@ module Doom
68
57
  BOB_DECAY_RATE = 3.44 # Friction as continuous decay rate (1/sec)
69
58
  BOB_THRUST = 26.0 # Walk thrust (map units/sec), gives terminal ~7.5
70
59
  BOB_FREQUENCY = 11.0 # Bob cycle frequency (rad/sec): FINEANGLES/20 * 35 / 8192 * 2*PI
71
- attr_reader :view_bob_offset
60
+ attr_reader :viewheight, :deltaviewheight, :view_bob_offset
72
61
 
73
62
  def initialize
74
63
  reset
@@ -132,7 +121,10 @@ module Doom
132
121
  @view_bob_offset = 0.0
133
122
  @momx = 0.0 # Simulated X momentum (map units/sec, not actual movement)
134
123
  @momy = 0.0 # Simulated Y momentum
135
- @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
136
128
  @thrust_y = 0.0
137
129
  @view_bob_angle = 0.0
138
130
  end
@@ -156,21 +148,6 @@ module Doom
156
148
  end
157
149
  end
158
150
 
159
- def max_ammo_for_weapon
160
- case @weapon
161
- when WEAPON_PISTOL, WEAPON_CHAINGUN
162
- @max_bullets
163
- when WEAPON_SHOTGUN
164
- @max_shells
165
- when WEAPON_ROCKET
166
- @max_rockets
167
- when WEAPON_PLASMA, WEAPON_BFG
168
- @max_cells
169
- else
170
- nil
171
- end
172
- end
173
-
174
151
  def can_attack?
175
152
  return true if @weapon == WEAPON_FIST || @weapon == WEAPON_CHAINSAW
176
153
  return true if @infinite_ammo
@@ -219,21 +196,21 @@ module Doom
219
196
  @attack_frame = (@attack_tics / tics_per_frame).to_i
220
197
 
221
198
  # Attack finished?
222
- if @attack_tics >= duration
223
- @attacking = false
224
- @attack_frame = 0
225
- @attack_tics = 0
226
- end
199
+ return unless @attack_tics >= duration
200
+
201
+ @attacking = false
202
+ @attack_frame = 0
203
+ @attack_tics = 0
227
204
  end
228
205
 
229
206
  def update_bob(delta_time)
230
207
  if @is_moving
231
208
  # Increase bob while moving
232
209
  @bob_angle += delta_time * 10.0
233
- @bob_amount = [@bob_amount + delta_time * 16.0, 6.0].min
210
+ @bob_amount = [@bob_amount + (delta_time * 16.0), 6.0].min
234
211
  else
235
212
  # Decay bob when stopped
236
- @bob_amount = [@bob_amount - delta_time * 12.0, 0.0].max
213
+ @bob_amount = [@bob_amount - (delta_time * 12.0), 0.0].max
237
214
  end
238
215
  end
239
216
 
@@ -242,10 +219,18 @@ module Doom
242
219
  # so the camera doesn't snap, then let P_CalcHeight recover it smoothly.
243
220
  def notify_step(step_amount)
244
221
  return if step_amount == 0
222
+
245
223
  @viewheight -= step_amount
246
224
  @deltaviewheight = (VIEWHEIGHT - @viewheight) / 8.0
247
225
  end
248
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
+
249
234
  # Gradually restore viewheight to VIEWHEIGHT (called each tic).
250
235
  # Matches Chocolate Doom P_CalcHeight viewheight recovery loop.
251
236
  # For step-up: viewheight < 41, delta > 0, accelerates upward.
@@ -263,10 +248,10 @@ module Doom
263
248
  @deltaviewheight = 1.0 if @deltaviewheight <= 0
264
249
  end
265
250
 
266
- if @deltaviewheight != 0
267
- @deltaviewheight += DELTA_ACCEL
268
- @deltaviewheight = 0.0 if @deltaviewheight.abs < 0.01 && (@viewheight - VIEWHEIGHT).abs < 0.5
269
- 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
270
255
  end
271
256
 
272
257
  # Set movement momentum directly (called from GosuWindow with actual
@@ -281,13 +266,13 @@ module Doom
281
266
  # bob = (momx*momx + momy*momy) >> 2, capped at MAXBOB
282
267
  # viewz += finesine[angle] * bob/2
283
268
  # Momentum is in units/sec; DOOM's bob uses units/tic (divide by 35).
284
- 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
285
270
 
286
271
  def update_view_bob(delta_time)
287
272
  dt = delta_time.clamp(0.001, 0.05)
288
273
 
289
274
  # P_CalcHeight: bob = (momx_per_tic^2 + momy_per_tic^2) / 4, capped at MAXBOB
290
- bob = (@momx * @momx + @momy * @momy) * BOB_MOM_SCALE
275
+ bob = ((@momx * @momx) + (@momy * @momy)) * BOB_MOM_SCALE
291
276
  bob = MAXBOB if bob > MAXBOB
292
277
 
293
278
  # Advance bob sine wave (FINEANGLES/20 per tic = ~11 rad/sec)
@@ -305,17 +290,6 @@ module Doom
305
290
  Math.sin(@bob_angle * 2) * @bob_amount * 0.5
306
291
  end
307
292
 
308
- def health_level
309
- # 0 = dying, 4 = full health
310
- case @health
311
- when 80..200 then 4
312
- when 60..79 then 3
313
- when 40..59 then 2
314
- when 20..39 then 1
315
- else 0
316
- end
317
- end
318
-
319
293
  def switch_weapon(weapon_num)
320
294
  return unless weapon_num >= 0 && weapon_num < 8
321
295
  return unless @has_weapons[weapon_num]
@@ -331,7 +305,7 @@ module Doom
331
305
 
332
306
  absorbed = 0
333
307
  if @armor > 0
334
- absorbed = amount / 3 # Green armor absorbs 1/3
308
+ absorbed = amount / 3 # Green armor absorbs 1/3
335
309
  absorbed = @armor if absorbed > @armor
336
310
  @armor -= absorbed
337
311
  end
@@ -340,13 +314,13 @@ module Doom
340
314
  @health -= actual
341
315
 
342
316
  # Red flash proportional to damage (capped at palette 8)
343
- @damage_count = [(@damage_count + actual / 2.0).ceil, 8].min
317
+ @damage_count = [(@damage_count + (actual / 2.0)).ceil, 8].min
344
318
 
345
- if @health <= 0
346
- @health = 0
347
- @damage_count = 8
348
- die
349
- end
319
+ return unless @health <= 0
320
+
321
+ @health = 0
322
+ @damage_count = 8
323
+ die
350
324
  end
351
325
 
352
326
  # Decay damage flash each tic
@@ -358,7 +332,7 @@ module Doom
358
332
  @dead = true
359
333
  @death_tic = 0
360
334
  @attacking = false
361
- @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
335
+ @deltaviewheight = -VIEWHEIGHT / 8.0 # View drops to ground
362
336
  end
363
337
  end
364
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