termfront 0.1.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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +160 -0
  6. data/Rakefile +12 -0
  7. data/data/audio/THIRD_PARTY_NOTICES.md +45 -0
  8. data/data/audio/beep_02.ogg +0 -0
  9. data/data/audio/button1.ogg +0 -0
  10. data/data/audio/complete.ogg +0 -0
  11. data/data/audio/manifest.json +17 -0
  12. data/data/audio/mission_bgm.wav +0 -0
  13. data/data/audio/mission_clear_se.wav +0 -0
  14. data/data/audio/on.ogg +0 -0
  15. data/data/audio/page_se.wav +0 -0
  16. data/data/audio/sector.mp3 +0 -0
  17. data/data/audio/sfx_22b.ogg +0 -0
  18. data/data/audio/shield_alarm_se.wav +0 -0
  19. data/data/audio/shield_regen_se.wav +0 -0
  20. data/data/audio/shoot_01.ogg +0 -0
  21. data/data/audio/terminal_se.wav +0 -0
  22. data/data/audio/title.mp3 +0 -0
  23. data/data/audio/title_bgm.wav +0 -0
  24. data/data/audio/victory.mp3 +0 -0
  25. data/data/events/corridor_sweep.json +27 -0
  26. data/data/events/final_push.json +40 -0
  27. data/data/events/stronghold.json +27 -0
  28. data/data/events/the_gauntlet.json +27 -0
  29. data/data/events/training_grounds.json +31 -0
  30. data/exe/termfront +6 -0
  31. data/exe/termfront-server +7 -0
  32. data/lib/termfront/audio_manager.rb +225 -0
  33. data/lib/termfront/config.rb +38 -0
  34. data/lib/termfront/demo_player.rb +181 -0
  35. data/lib/termfront/drop_item/base.rb +26 -0
  36. data/lib/termfront/drop_item/weapon.rb +38 -0
  37. data/lib/termfront/enemy/base.rb +133 -0
  38. data/lib/termfront/enemy/crawler.rb +18 -0
  39. data/lib/termfront/enemy/executor.rb +18 -0
  40. data/lib/termfront/game.rb +637 -0
  41. data/lib/termfront/input.rb +75 -0
  42. data/lib/termfront/map.rb +72 -0
  43. data/lib/termfront/mission/base.rb +81 -0
  44. data/lib/termfront/mission/corridor_sweep.rb +41 -0
  45. data/lib/termfront/mission/event_loader.rb +87 -0
  46. data/lib/termfront/mission/event_runtime.rb +37 -0
  47. data/lib/termfront/mission/final_push.rb +44 -0
  48. data/lib/termfront/mission/stronghold.rb +45 -0
  49. data/lib/termfront/mission/the_gauntlet.rb +38 -0
  50. data/lib/termfront/mission/training.rb +38 -0
  51. data/lib/termfront/mission/training_grounds.rb +37 -0
  52. data/lib/termfront/network/client.rb +865 -0
  53. data/lib/termfront/network/connection.rb +101 -0
  54. data/lib/termfront/network/server.rb +620 -0
  55. data/lib/termfront/network/wavesfight_client.rb +364 -0
  56. data/lib/termfront/opponent.rb +24 -0
  57. data/lib/termfront/player.rb +147 -0
  58. data/lib/termfront/projectile.rb +44 -0
  59. data/lib/termfront/remote_enemy.rb +21 -0
  60. data/lib/termfront/renderer.rb +707 -0
  61. data/lib/termfront/scene_player.rb +164 -0
  62. data/lib/termfront/sprite.rb +73 -0
  63. data/lib/termfront/terminal_output.rb +63 -0
  64. data/lib/termfront/title_screen.rb +299 -0
  65. data/lib/termfront/version.rb +5 -0
  66. data/lib/termfront/weapon/assault_rifle.rb +15 -0
  67. data/lib/termfront/weapon/base.rb +44 -0
  68. data/lib/termfront/weapon/pistol.rb +15 -0
  69. data/lib/termfront/weapon/shock_pistol.rb +15 -0
  70. data/lib/termfront/weapon/shock_rifle.rb +15 -0
  71. data/lib/termfront.rb +51 -0
  72. data/sig/termfront.rbs +4 -0
  73. metadata +119 -0
@@ -0,0 +1,707 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Renderer
5
+ def initialize(stdout)
6
+ @stdout = stdout
7
+ end
8
+
9
+ def render(player:, map:, enemies:, projectiles:, drops:, terminals: [], status_line: nil, allies: [])
10
+ rows, cols = @stdout.winsize
11
+ rows = [rows, 6].max
12
+ cols = [cols, 20].max
13
+
14
+ radar_h = Config::RADAR_RADIUS * 2 + 1
15
+ view_h = [rows - 3 - radar_h, 4].max
16
+ view_w = cols
17
+ virt_h = view_h * 2
18
+
19
+ dx = Math.cos(player.angle)
20
+ dy = Math.sin(player.angle)
21
+ plane_x = -dy * Math.tan(Config::FOV / 2.0)
22
+ plane_y = dx * Math.tan(Config::FOV / 2.0)
23
+
24
+ dists = Array.new(view_w)
25
+ sides = Array.new(view_w)
26
+ view_w.times do |c|
27
+ cam = 2.0 * c / view_w - 1.0
28
+ dists[c], sides[c] = cast_ray(map, player.x, player.y, dx + plane_x * cam, dy + plane_y * cam)
29
+ end
30
+
31
+ vmid = virt_h / 2.0
32
+ wtop = Array.new(view_w)
33
+ wbot = Array.new(view_w)
34
+ wcol = Array.new(view_w)
35
+ view_w.times do |c|
36
+ d = dists[c]
37
+ lh = d > 0.01 ? (virt_h / d).to_i : virt_h
38
+ wtop[c] = [(vmid - lh / 2.0).to_i, 0].max
39
+ wbot[c] = [(vmid + lh / 2.0).to_i, virt_h].min
40
+ wcol[c] = Sprite.wall_brightness(d, sides[c])
41
+ end
42
+ pixels = build_view_pixels(virt_h, view_w, wtop, wbot, wcol)
43
+ overlay_enemies_3d(pixels, view_h, view_w, dists, player, enemies, projectiles, drops)
44
+ overlay_allies_3d(pixels, view_h, view_w, dists, player, allies)
45
+ overlay_damage_flash(pixels, view_h, view_w, player)
46
+
47
+ buf = TerminalOutput.begin_frame(home: true)
48
+
49
+ render_hud(buf, cols, player, drops, terminals, status_line)
50
+ render_view(buf, view_h, view_w, pixels)
51
+ buf << "\e[#{3 + view_h};1H"
52
+ render_radar(buf, cols, radar_h, player, enemies, drops, terminals, allies)
53
+ render_crosshair(buf, view_h, view_w, cols, player)
54
+
55
+ buf << TerminalOutput.end_frame
56
+ TerminalOutput.write_all(@stdout, buf)
57
+ end
58
+
59
+ def render_game_over(rows, cols)
60
+ render_centered_message(rows, cols, "GAME OVER", "\e[1;91m")
61
+ end
62
+
63
+ def render_mission_complete(rows, cols)
64
+ render_centered_message(rows, cols, "MISSION COMPLETE", "\e[1;92m")
65
+ end
66
+
67
+ def render_blank(rows, cols)
68
+ buf = TerminalOutput.begin_frame(home: true)
69
+ rows.times do |row|
70
+ buf << "\e[#{row + 1};1H"
71
+ buf << (" " * cols)
72
+ end
73
+ buf << TerminalOutput.end_frame
74
+ TerminalOutput.write_all(@stdout, buf)
75
+ end
76
+
77
+ def render_centered_message(rows, cols, msg, color)
78
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
79
+ r = rows / 2
80
+ c = [(cols - msg.size) / 2 + 1, 1].max
81
+ buf << "\e[#{r};#{c}H#{color}#{msg}\e[0m"
82
+ buf << TerminalOutput.end_frame
83
+ TerminalOutput.write_all(@stdout, buf)
84
+ end
85
+
86
+ def cast_ray(map, ox, oy, rdx, rdy)
87
+ mx = ox.floor
88
+ my = oy.floor
89
+
90
+ ddx = rdx == 0 ? 1e30 : (1.0 / rdx).abs
91
+ ddy = rdy == 0 ? 1e30 : (1.0 / rdy).abs
92
+
93
+ if rdx < 0
94
+ step_x = -1
95
+ sd_x = (ox - mx) * ddx
96
+ else
97
+ step_x = 1
98
+ sd_x = (mx + 1.0 - ox) * ddx
99
+ end
100
+ if rdy < 0
101
+ step_y = -1
102
+ sd_y = (oy - my) * ddy
103
+ else
104
+ step_y = 1
105
+ sd_y = (my + 1.0 - oy) * ddy
106
+ end
107
+
108
+ side = 0
109
+ 64.times do
110
+ if sd_x < sd_y
111
+ sd_x += ddx
112
+ mx += step_x
113
+ side = 0
114
+ else
115
+ sd_y += ddy
116
+ my += step_y
117
+ side = 1
118
+ end
119
+ return [1e30, 0] if my < 0 || my >= map.height || mx < 0 || mx >= map.width
120
+
121
+ next unless map.grid[my][mx] == "#"
122
+
123
+ d = if side == 0
124
+ (mx - ox + (1 - step_x) / 2.0) / rdx
125
+ else
126
+ (my - oy + (1 - step_y) / 2.0) / rdy
127
+ end
128
+ return [d.abs, side]
129
+ end
130
+ [1e30, 0]
131
+ end
132
+
133
+ private
134
+
135
+ def render_hud(buf, cols, player, drops, terminals, status_line)
136
+ bar_w = [cols - 20, 10].max
137
+ pct = player.shield / Config::SHIELD_MAX.to_f
138
+ filled = (pct * bar_w).to_i
139
+ empty = bar_w - filled
140
+ color = if pct >= 0.5
141
+ "\e[96m"
142
+ elsif pct >= 0.25
143
+ "\e[93m"
144
+ else
145
+ "\e[91m"
146
+ end
147
+ pct_s = "#{(pct * 100).to_i}%"
148
+ shield_str = "SHIELD #{color}#{"█" * filled}#{"░" * empty}\e[0m #{pct_s}"
149
+ shield_str = "#{shield_str}\e[90m#{status_line}\e[0m" if status_line
150
+ pad = [(cols - bar_w - 15) / 2, 0].max
151
+ buf << TerminalOutput.fit_ansi("#{" " * pad}#{shield_str}", cols) << "\r\n"
152
+
153
+ weapon = player.current_weapon
154
+ wcolor = weapon.type_id.to_s.start_with?("shock") ? "\e[96m" : "\e[97m"
155
+
156
+ if weapon.max_ammo
157
+ ammo_bar_w = 12
158
+ ammo_pct = weapon.ammo.to_f / weapon.max_ammo
159
+ ammo_filled = (ammo_pct * ammo_bar_w).to_i
160
+ ammo_empty = ammo_bar_w - ammo_filled
161
+ ammo_str = "#{wcolor}#{weapon.name}\e[0m [#{"█" * ammo_filled}#{"░" * ammo_empty}] #{weapon.ammo}/#{weapon.max_ammo}"
162
+ else
163
+ ammo_str = "#{wcolor}#{weapon.name}\e[0m [\xe2\x88\x9e]"
164
+ end
165
+
166
+ can_pickup = drops.any? { |d| d.in_range?(player.x, player.y) }
167
+ can_use_terminal = terminals.any? do |terminal|
168
+ (terminal[:x] - player.x)**2 + (terminal[:y] - player.y)**2 < Config::TERMINAL_USE_RADIUS**2
169
+ end
170
+ interact_str = if can_use_terminal
171
+ "\e[1;96m[E]Use Terminal\e[0m"
172
+ elsif can_pickup
173
+ "\e[1;93m[E]Pickup\e[0m"
174
+ else
175
+ "E:interact"
176
+ end
177
+
178
+ line = "#{ammo_str} T:swap #{interact_str} Space:fire"
179
+ buf << TerminalOutput.fit_ansi(line, cols) << "\r\n"
180
+ end
181
+
182
+ def build_view_pixels(virt_h, view_w, wtop, wbot, wcol)
183
+ pixels = Array.new(virt_h) { Array.new(view_w) }
184
+ virt_h.times do |vr|
185
+ row = pixels[vr]
186
+ view_w.times do |c|
187
+ row[c] = if vr < wtop[c]
188
+ Config::CEIL_C
189
+ elsif vr < wbot[c]
190
+ wcol[c]
191
+ else
192
+ Config::FLOOR_C
193
+ end
194
+ end
195
+ end
196
+ pixels
197
+ end
198
+
199
+ def render_view(buf, view_h, view_w, pixels)
200
+ view_h.times do |r|
201
+ vp0 = r * 2
202
+ vp1 = r * 2 + 1
203
+ pfg = nil
204
+ pbg = nil
205
+ top_row = pixels[vp0]
206
+ bot_row = pixels[vp1]
207
+
208
+ view_w.times do |c|
209
+ tc = top_row[c]
210
+ bc = bot_row[c]
211
+
212
+ if tc == bc
213
+ if bg_only?(tc)
214
+ if tc != pbg
215
+ buf << ansi_bg(tc)
216
+ pbg = tc
217
+ pfg = nil
218
+ end
219
+ buf << " "
220
+ else
221
+ if tc != pfg || pbg
222
+ buf << ansi_fg(tc)
223
+ pfg = tc
224
+ pbg = nil
225
+ end
226
+ buf << "\xE2\x96\x88"
227
+ end
228
+ else
229
+ if tc != pfg || bc != pbg
230
+ buf << ansi_fg(tc) << ansi_bg(bc)
231
+ pfg = tc
232
+ pbg = bc
233
+ end
234
+ buf << "\xE2\x96\x80"
235
+ end
236
+ end
237
+ buf << "\e[0m\r\n"
238
+ end
239
+ end
240
+
241
+ def render_radar(buf, cols, radar_h, player, enemies, drops, terminals, allies = [])
242
+ buf << ("\xE2\x94\x80" * cols)[0, cols * 3] << "\r\n"
243
+
244
+ r = Config::RADAR_RADIUS
245
+ diam = r * 2 + 1
246
+
247
+ grid = Array.new(diam) { Array.new(diam, " ") }
248
+ diam.times do |ry|
249
+ diam.times do |rx|
250
+ dx = rx - r
251
+ dy = ry - r
252
+ d2 = dx * dx + dy * dy
253
+ if d2 <= r * r
254
+ grid[ry][rx] = "."
255
+ elsif d2 <= (r + 1) * (r + 1)
256
+ grid[ry][rx] = "#"
257
+ end
258
+ end
259
+ end
260
+ grid[r][r] = "^"
261
+
262
+ cos_a = Math.cos(-player.angle + Math::PI / 2)
263
+ sin_a = Math.sin(-player.angle + Math::PI / 2)
264
+ enemy_cells = {}
265
+ enemies.each do |e|
266
+ next unless e.alive
267
+
268
+ ex = e.x - player.x
269
+ ey = e.y - player.y
270
+ dist = Math.sqrt(ex * ex + ey * ey)
271
+ next if dist > Config::RADAR_RANGE
272
+
273
+ rx = -(ex * cos_a - ey * sin_a)
274
+ ry = -(ex * sin_a + ey * cos_a)
275
+ sx = r + (rx / Config::RADAR_RANGE * r).round
276
+ sy = r + (ry / Config::RADAR_RANGE * r).round
277
+ next unless sx.between?(0, diam - 1) && sy.between?(0, diam - 1)
278
+
279
+ d2 = (sx - r)**2 + (sy - r)**2
280
+ next if d2 > r * r
281
+
282
+ enemy_cells[[sy, sx]] = e.sprite_id
283
+ end
284
+
285
+ drop_cells = {}
286
+ drops.each do |d|
287
+ ex = d.x - player.x
288
+ ey = d.y - player.y
289
+ dist = Math.sqrt(ex * ex + ey * ey)
290
+ next if dist > Config::RADAR_RANGE
291
+
292
+ rx = -(ex * cos_a - ey * sin_a)
293
+ ry = -(ex * sin_a + ey * cos_a)
294
+ sx = r + (rx / Config::RADAR_RANGE * r).round
295
+ sy = r + (ry / Config::RADAR_RANGE * r).round
296
+ next unless sx.between?(0, diam - 1) && sy.between?(0, diam - 1)
297
+
298
+ d2 = (sx - r)**2 + (sy - r)**2
299
+ next if d2 > r * r
300
+
301
+ drop_cells[[sy, sx]] = d
302
+ end
303
+
304
+ terminal_cells = {}
305
+ terminals.each do |terminal|
306
+ ex = terminal[:x] - player.x
307
+ ey = terminal[:y] - player.y
308
+ dist = Math.sqrt(ex * ex + ey * ey)
309
+ next if dist > Config::RADAR_RANGE
310
+
311
+ rx = -(ex * cos_a - ey * sin_a)
312
+ ry = -(ex * sin_a + ey * cos_a)
313
+ sx = r + (rx / Config::RADAR_RANGE * r).round
314
+ sy = r + (ry / Config::RADAR_RANGE * r).round
315
+ next unless sx.between?(0, diam - 1) && sy.between?(0, diam - 1)
316
+
317
+ d2 = (sx - r)**2 + (sy - r)**2
318
+ next if d2 > r * r
319
+
320
+ terminal_cells[[sy, sx]] = terminal
321
+ end
322
+
323
+ ally_cells = {}
324
+ allies.each do |ally|
325
+ ex = ally.x - player.x
326
+ ey = ally.y - player.y
327
+ dist = Math.sqrt(ex * ex + ey * ey)
328
+ next if dist > Config::RADAR_RANGE
329
+
330
+ rx = -(ex * cos_a - ey * sin_a)
331
+ ry = -(ex * sin_a + ey * cos_a)
332
+ sx = r + (rx / Config::RADAR_RANGE * r).round
333
+ sy = r + (ry / Config::RADAR_RANGE * r).round
334
+ next unless sx.between?(0, diam - 1) && sy.between?(0, diam - 1)
335
+
336
+ d2 = (sx - r)**2 + (sy - r)**2
337
+ next if d2 > r * r
338
+
339
+ ally_cells[[sy, sx]] = true
340
+ end
341
+
342
+ alive_count = enemies.count(&:alive)
343
+ total_count = enemies.size
344
+ info_lines = [
345
+ "Enemies: #{alive_count}/#{total_count}",
346
+ "Heading: #{format("%.0f", (player.angle % (Math::PI * 2)) * 180 / Math::PI)}\xC2\xB0",
347
+ "Pos: (#{"%.1f" % player.x}, #{"%.1f" % player.y}) T:terminal"
348
+ ]
349
+
350
+ radar_h.times do |row|
351
+ line = +""
352
+ if row < diam
353
+ line << " "
354
+ diam.times do |cx|
355
+ if (etype = enemy_cells[[row, cx]])
356
+ ec = etype == :executor ? "\e[95m" : "\e[91m"
357
+ line << "#{ec}*\e[0m"
358
+ elsif ally_cells[[row, cx]]
359
+ line << "\e[96m+\e[0m"
360
+ elsif (drop = drop_cells[[row, cx]])
361
+ dc = drop.type.to_s.start_with?("shock") ? "\e[96m" : "\e[93m"
362
+ dl = Weapon::Base.registry[drop.type].new.name[0]
363
+ line << "#{dc}#{dl}\e[0m"
364
+ elsif terminal_cells[[row, cx]]
365
+ line << "\e[96mT\e[0m"
366
+ elsif row == r && cx == r
367
+ line << "\e[92m^\e[0m"
368
+ elsif grid[row][cx] == "#"
369
+ line << "\e[90m#\e[0m"
370
+ else
371
+ line << grid[row][cx]
372
+ end
373
+ end
374
+ line << (row < info_lines.size ? " #{info_lines[row]}" : "")
375
+ end
376
+ buf << TerminalOutput.fit_ansi(line, cols)
377
+ buf << "\r\n" if row < radar_h - 1
378
+ end
379
+ end
380
+
381
+ def overlay_enemies_3d(pixels, view_h, view_w, dists, player, enemies, projectiles, drops)
382
+ dx = Math.cos(player.angle)
383
+ dy = Math.sin(player.angle)
384
+ px = -dy * Math.tan(Config::FOV / 2.0)
385
+ py = dx * Math.tan(Config::FOV / 2.0)
386
+ virt_h = view_h * 2
387
+ inv = 1.0 / (px * dy - py * dx)
388
+
389
+ sprites = []
390
+ enemies.each do |e|
391
+ next unless e.alive
392
+
393
+ ex = e.x - player.x
394
+ ey = e.y - player.y
395
+ tx = inv * (dy * ex - dx * ey)
396
+ tz = inv * (-py * ex + px * ey)
397
+ next if tz < 0.2
398
+
399
+ sprites << [tz, tx, e]
400
+ end
401
+
402
+ proj_sprites = []
403
+ projectiles.each do |p|
404
+ ex = p.x - player.x
405
+ ey = p.y - player.y
406
+ tx = inv * (dy * ex - dx * ey)
407
+ tz = inv * (-py * ex + px * ey)
408
+ next if tz < 0.2
409
+
410
+ proj_sprites << [tz, tx, p]
411
+ end
412
+
413
+ sprites.sort_by! { |s| -s[0] }
414
+
415
+ sprites.each do |tz, tx, e|
416
+ sx = ((view_w / 2.0) * (1 + tx / tz)).to_i
417
+ sprite_h = (virt_h / tz).to_i
418
+ draw_top = [(virt_h / 2 - sprite_h / 2), 0].max
419
+ draw_bot = [(virt_h / 2 + sprite_h / 2), virt_h].min
420
+ sprite_w = (sprite_h / 2.0).to_i
421
+ start_x = [sx - sprite_w / 2, 0].max
422
+ end_x = [sx + sprite_w / 2, view_w - 1].min
423
+
424
+ actual_h = draw_bot - draw_top
425
+ actual_w = end_x - start_x + 1
426
+ next if actual_h < 1 || actual_w < 1
427
+
428
+ fallback_color = e.sprite_id == :executor ? "100;60;200" : "220;140;30"
429
+ use_shape = actual_h >= 6
430
+
431
+ start_x.upto(end_x) do |c|
432
+ next if c < 0 || c >= view_w
433
+ next if dists[c] < tz
434
+
435
+ nx = (c - start_x).to_f / actual_w
436
+
437
+ r_top = (draw_top / 2.0).ceil
438
+ r_bot = (draw_bot / 2.0).floor
439
+ r_top.upto(r_bot - 1) do |r|
440
+ vp0 = r * 2
441
+ vp1 = r * 2 + 1
442
+ top_in = vp0 >= draw_top && vp0 < draw_bot
443
+ bot_in = vp1 >= draw_top && vp1 < draw_bot
444
+ next unless top_in || bot_in
445
+
446
+ if use_shape
447
+ ny0 = top_in ? (vp0 - draw_top).to_f / actual_h : nil
448
+ ny1 = bot_in ? (vp1 - draw_top).to_f / actual_h : nil
449
+ top_color = ny0 ? Sprite.for(e.sprite_id, nx, ny0) : nil
450
+ bot_color = ny1 ? Sprite.for(e.sprite_id, nx, ny1) : nil
451
+ next unless top_color || bot_color
452
+
453
+ pixels[vp0][c] = top_color if top_color
454
+ pixels[vp1][c] = bot_color if bot_color
455
+ else
456
+ pixels[vp0][c] = fallback_color if top_in
457
+ pixels[vp1][c] = fallback_color if bot_in
458
+ end
459
+ end
460
+ end
461
+
462
+ next unless e.max_hp > 1
463
+
464
+ bar_row = (draw_top / 2.0).ceil - 1
465
+ next unless bar_row >= 0 && bar_row < view_h
466
+
467
+ bar_w = [actual_w, 2].max
468
+ bar_sx = [sx - bar_w / 2, 0].max
469
+ bar_ex = [bar_sx + bar_w - 1, view_w - 1].min
470
+ hp_pct = e.hp.to_f / e.max_hp
471
+ filled = (hp_pct * (bar_ex - bar_sx + 1)).ceil
472
+ bar_sx.upto(bar_ex) do |c|
473
+ next if c < 0 || c >= view_w
474
+ next if dists[c] < tz
475
+
476
+ ci = c - bar_sx
477
+ color = ci < filled ? "0;200;0" : "200;0;0"
478
+ pixels[bar_row * 2][c] = color
479
+ pixels[bar_row * 2 + 1][c] = color
480
+ end
481
+ end
482
+
483
+ # Render weapon drops
484
+ drop_sprites = []
485
+ drops.each do |d|
486
+ ex = d.x - player.x
487
+ ey = d.y - player.y
488
+ tx = inv * (dy * ex - dx * ey)
489
+ tz = inv * (-py * ex + px * ey)
490
+ next if tz < 0.2
491
+
492
+ drop_sprites << [tz, tx, d]
493
+ end
494
+ drop_sprites.sort_by! { |s| -s[0] }
495
+
496
+ drop_sprites.each do |tz, tx, d|
497
+ sx = ((view_w / 2.0) * (1 + tx / tz)).to_i
498
+ sprite_h = (virt_h / tz * 0.3).to_i.clamp(2, virt_h / 2)
499
+ ground = (virt_h / 2 + virt_h / tz * 0.35).to_i
500
+ draw_bot = [ground, virt_h].min
501
+ draw_top = [draw_bot - sprite_h, 0].max
502
+ sprite_w = (sprite_h / 2.0).to_i.clamp(1, 6)
503
+ start_x = [sx - sprite_w / 2, 0].max
504
+ end_x = [sx + sprite_w / 2, view_w - 1].min
505
+
506
+ color = d.sprite_color
507
+
508
+ start_x.upto(end_x) do |c|
509
+ next if c < 0 || c >= view_w
510
+ next if dists[c] < tz
511
+
512
+ r_top = (draw_top / 2.0).ceil
513
+ r_bot = (draw_bot / 2.0).floor
514
+ r_top.upto(r_bot - 1) do |r|
515
+ next if r < 0 || r >= view_h
516
+
517
+ vp0 = r * 2
518
+ vp1 = r * 2 + 1
519
+ top_in = vp0 >= draw_top && vp0 < draw_bot
520
+ bot_in = vp1 >= draw_top && vp1 < draw_bot
521
+ next unless top_in || bot_in
522
+
523
+ pixels[vp0][c] = color if top_in
524
+ pixels[vp1][c] = color if bot_in
525
+ end
526
+ end
527
+ end
528
+
529
+ # Render projectiles
530
+ proj_sprites.sort_by! { |s| -s[0] }
531
+ proj_sprites.each do |tz, tx, p|
532
+ sx = ((view_w / 2.0) * (1 + tx / tz)).to_i
533
+ pw = (4.0 / tz).ceil.clamp(1, 5)
534
+ ph = (virt_h / tz * 0.15).ceil.clamp(2, 6)
535
+ vmid = virt_h / 2
536
+ draw_top = [(vmid - ph / 2), 0].max
537
+ draw_bot = [(vmid + ph / 2).clamp(draw_top + 2, virt_h), virt_h].min
538
+ start_x = [sx - pw / 2, 0].max
539
+ end_x = [sx + pw / 2, view_w - 1].min
540
+ col_code = p.type == :executor ? "94" : "93"
541
+
542
+ start_x.upto(end_x) do |c|
543
+ next if c < 0 || c >= view_w
544
+ next if dists[c] < tz
545
+
546
+ r_top = (draw_top / 2.0).ceil
547
+ r_bot = [(draw_bot / 2.0).floor, r_top + 1].max
548
+ r_top.upto(r_bot - 1) do |r|
549
+ next if r < 0 || r >= view_h
550
+
551
+ vp0 = r * 2
552
+ vp1 = r * 2 + 1
553
+ top_in = vp0 >= draw_top && vp0 < draw_bot
554
+ bot_in = vp1 >= draw_top && vp1 < draw_bot
555
+ next unless top_in || bot_in
556
+
557
+ proj_color = col_code == "94" ? "94;94;255" : "255;210;80"
558
+ pixels[vp0][c] = proj_color if top_in
559
+ pixels[vp1][c] = proj_color if bot_in
560
+ end
561
+ end
562
+ end
563
+ end
564
+
565
+ def overlay_allies_3d(pixels, view_h, view_w, dists, player, allies)
566
+ dx = Math.cos(player.angle)
567
+ dy = Math.sin(player.angle)
568
+ px = -dy * Math.tan(Config::FOV / 2.0)
569
+ py = dx * Math.tan(Config::FOV / 2.0)
570
+ virt_h = view_h * 2
571
+ inv = 1.0 / (px * dy - py * dx)
572
+
573
+ sprites = []
574
+ allies.each do |ally|
575
+ ex = ally.x - player.x
576
+ ey = ally.y - player.y
577
+ tx = inv * (dy * ex - dx * ey)
578
+ tz = inv * (-py * ex + px * ey)
579
+ next if tz < 0.2
580
+
581
+ sprites << [tz, tx, ally]
582
+ end
583
+ sprites.sort_by! { |s| -s[0] }
584
+
585
+ sprites.each do |tz, tx, ally|
586
+ sx = ((view_w / 2.0) * (1 + tx / tz)).to_i
587
+ sprite_h = (virt_h / tz).to_i
588
+ draw_top = [(virt_h / 2 - sprite_h / 2), 0].max
589
+ draw_bot = [(virt_h / 2 + sprite_h / 2), virt_h].min
590
+ sprite_w = (sprite_h / 2.0).to_i
591
+ start_x = [sx - sprite_w / 2, 0].max
592
+ end_x = [sx + sprite_w / 2, view_w - 1].min
593
+
594
+ actual_h = draw_bot - draw_top
595
+ actual_w = end_x - start_x + 1
596
+ next if actual_h < 1 || actual_w < 1
597
+
598
+ use_shape = actual_h >= 6
599
+
600
+ start_x.upto(end_x) do |c|
601
+ next if c < 0 || c >= view_w
602
+ next if dists[c] < tz
603
+
604
+ nx = (c - start_x).to_f / actual_w
605
+ r_top = (draw_top / 2.0).ceil
606
+ r_bot = (draw_bot / 2.0).floor
607
+
608
+ r_top.upto(r_bot - 1) do |r|
609
+ vp0 = r * 2
610
+ vp1 = r * 2 + 1
611
+ top_in = vp0 >= draw_top && vp0 < draw_bot
612
+ bot_in = vp1 >= draw_top && vp1 < draw_bot
613
+ next unless top_in || bot_in
614
+
615
+ if use_shape
616
+ ny0 = top_in ? (vp0 - draw_top).to_f / actual_h : nil
617
+ ny1 = bot_in ? (vp1 - draw_top).to_f / actual_h : nil
618
+ top_color = ny0 ? Sprite.player(nx, ny0) : nil
619
+ bot_color = ny1 ? Sprite.player(nx, ny1) : nil
620
+ next unless top_color || bot_color
621
+
622
+ pixels[vp0][c] = top_color if top_color
623
+ pixels[vp1][c] = bot_color if bot_color
624
+ else
625
+ pixels[vp0][c] = "70;210;255" if top_in
626
+ pixels[vp1][c] = "70;210;255" if bot_in
627
+ end
628
+ end
629
+ end
630
+
631
+ bar_row = (draw_top / 2.0).ceil - 1
632
+ next unless bar_row >= 0 && bar_row < view_h
633
+
634
+ bar_w = [actual_w, 2].max
635
+ bar_sx = [sx - bar_w / 2, 0].max
636
+ bar_ex = [bar_sx + bar_w - 1, view_w - 1].min
637
+ total = ally.shield + ally.health
638
+ max_total = Config::SHIELD_MAX + Config::HEALTH_MAX
639
+ hp_pct = total.to_f / max_total
640
+ filled = (hp_pct * (bar_ex - bar_sx + 1)).ceil
641
+ bar_sx.upto(bar_ex) do |c|
642
+ next if c < 0 || c >= view_w
643
+ next if dists[c] < tz
644
+
645
+ ci = c - bar_sx
646
+ color = ci < filled ? "0;180;255" : "80;20;20"
647
+ pixels[bar_row * 2][c] = color
648
+ pixels[bar_row * 2 + 1][c] = color
649
+ end
650
+ end
651
+ end
652
+
653
+ def overlay_damage_flash(pixels, view_h, view_w, player)
654
+ return unless player.damage_flash > 0
655
+
656
+ intensity = player.damage_flash * 60
657
+ flash_w = 2
658
+ color = "#{intensity};0;0"
659
+
660
+ view_h.times do |r|
661
+ vp0 = r * 2
662
+ vp1 = vp0 + 1
663
+ flash_w.times do |offset|
664
+ left = offset
665
+ right = view_w - flash_w + offset
666
+ pixels[vp0][left] = color if left.between?(0, view_w - 1)
667
+ pixels[vp1][left] = color if left.between?(0, view_w - 1)
668
+ pixels[vp0][right] = color if right.between?(0, view_w - 1)
669
+ pixels[vp1][right] = color if right.between?(0, view_w - 1)
670
+ end
671
+ end
672
+ end
673
+
674
+ def render_crosshair(buf, view_h, view_w, cols, player)
675
+ cr = 3 + (view_h / 2)
676
+ cc = view_w / 2 + 1
677
+ buf << "\e[#{cr};#{cc}H\e[97m+\e[0m"
678
+
679
+ return unless player.fire_flash > 0
680
+
681
+ hw = [player.fire_flash * 4, view_w / 4].min
682
+ fs = [cc - hw, 1].max
683
+ fe = [cc + hw, cols].min
684
+ buf << "\e[#{cr};#{fs}H\e[93m#{"*" * (fe - fs + 1)}\e[0m"
685
+ end
686
+
687
+ def bg_only?(color)
688
+ color.is_a?(Integer)
689
+ end
690
+
691
+ def ansi_fg(color)
692
+ if color.is_a?(Integer)
693
+ "\e[38;5;#{color}m"
694
+ else
695
+ "\e[38;2;#{color}m"
696
+ end
697
+ end
698
+
699
+ def ansi_bg(color)
700
+ if color.is_a?(Integer)
701
+ "\e[48;5;#{color}m"
702
+ else
703
+ "\e[48;2;#{color}m"
704
+ end
705
+ end
706
+ end
707
+ end