rich_engine 0.0.0 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/tests-and-linter.yml +9 -8
- data/.gitignore +7 -1
- data/.yardopts +6 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +42 -33
- data/README.md +318 -17
- data/examples/background.rb +1 -1
- data/examples/command_line_fps.rb +620 -0
- data/lib/rich_engine/animation.rb +137 -0
- data/lib/rich_engine/canvas/slot.rb +72 -0
- data/lib/rich_engine/canvas.rb +109 -4
- data/lib/rich_engine/chance.rb +17 -0
- data/lib/rich_engine/cooldown.rb +23 -4
- data/lib/rich_engine/enum/mixin.rb +34 -0
- data/lib/rich_engine/enum/value.rb +31 -0
- data/lib/rich_engine/enum.rb +26 -2
- data/lib/rich_engine/game.rb +68 -5
- data/lib/rich_engine/io.rb +39 -16
- data/lib/rich_engine/matrix.rb +57 -0
- data/lib/rich_engine/string_colors.rb +218 -125
- data/lib/rich_engine/terminal/cursor.rb +15 -0
- data/lib/rich_engine/terminal.rb +19 -0
- data/lib/rich_engine/timer/every.rb +15 -0
- data/lib/rich_engine/timer.rb +17 -0
- data/lib/rich_engine/ui/textures.rb +32 -0
- data/lib/rich_engine/version.rb +2 -1
- data/lib/rich_engine.rb +8 -0
- data/mise.toml +1 -1
- data/rich_engine.gemspec +1 -1
- metadata +12 -4
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Inspired by javidx9's CommandLineFPS raycaster (@OneLoneCoder).
|
|
4
|
+
|
|
5
|
+
require "rich_engine"
|
|
6
|
+
|
|
7
|
+
# A billboard sprite: a grid of glyphs with a parallel grid of colors, sampled
|
|
8
|
+
# by normalized (0..1) coordinates so it can be scaled to any size.
|
|
9
|
+
class Sprite
|
|
10
|
+
using RichEngine::StringColors
|
|
11
|
+
|
|
12
|
+
attr_reader :width, :height
|
|
13
|
+
|
|
14
|
+
def initialize(glyphs, colors)
|
|
15
|
+
@glyphs = glyphs
|
|
16
|
+
@colors = colors
|
|
17
|
+
@height = glyphs.size
|
|
18
|
+
@width = glyphs.first.size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sample(sample_x, sample_y)
|
|
22
|
+
gx = clamp((sample_x * @width).to_i, @width)
|
|
23
|
+
gy = clamp((sample_y * @height).to_i, @height)
|
|
24
|
+
|
|
25
|
+
glyph = @glyphs[gy][gx]
|
|
26
|
+
return nil if glyph == " "
|
|
27
|
+
|
|
28
|
+
color = @colors[gy][gx]
|
|
29
|
+
color ? glyph.fg(color) : glyph
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A square target: yellow bull, then concentric red/white square rings.
|
|
33
|
+
def self.bullseye(radius)
|
|
34
|
+
size = radius * 2 + 1
|
|
35
|
+
glyphs = Array.new(size) { Array.new(size, "█") }
|
|
36
|
+
colors = Array.new(size) { Array.new(size) }
|
|
37
|
+
|
|
38
|
+
(0...size).each do |row|
|
|
39
|
+
(0...size).each do |col|
|
|
40
|
+
ring = [(col - radius).abs, (row - radius).abs].max # Nested 1-cell squares
|
|
41
|
+
colors[row][col] =
|
|
42
|
+
if ring.zero? then :bright_yellow # Bull
|
|
43
|
+
elsif ring.odd? then :bright_red
|
|
44
|
+
else :white
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
new(glyphs, colors)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A glowing orb: yellow core fading to red.
|
|
53
|
+
def self.fireball(radius)
|
|
54
|
+
radial(radius) do |ratio|
|
|
55
|
+
ratio <= 0.5 ? :bright_yellow : :bright_red
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Builds a filled disc of "█", coloring each pixel by its distance ratio
|
|
60
|
+
# (0 at center, 1 at the rim). Pixels outside the disc are transparent.
|
|
61
|
+
def self.radial(radius)
|
|
62
|
+
size = radius * 2 + 1
|
|
63
|
+
glyphs = Array.new(size) { Array.new(size, " ") }
|
|
64
|
+
colors = Array.new(size) { Array.new(size) }
|
|
65
|
+
|
|
66
|
+
(0...size).each do |row|
|
|
67
|
+
(0...size).each do |col|
|
|
68
|
+
distance = Math.hypot(col - radius, row - radius)
|
|
69
|
+
next if distance > radius
|
|
70
|
+
|
|
71
|
+
glyphs[row][col] = "█"
|
|
72
|
+
colors[row][col] = yield(distance / radius)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
new(glyphs, colors)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clamp(value, size)
|
|
80
|
+
value.clamp(0, size - 1)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class CommandLineFPS < RichEngine::Game
|
|
85
|
+
using RichEngine::StringColors
|
|
86
|
+
|
|
87
|
+
Target = Struct.new(:x, :y)
|
|
88
|
+
Fireball = Struct.new(:x, :y, :vx, :vy, :traveled)
|
|
89
|
+
|
|
90
|
+
MAP_WIDTH = 32
|
|
91
|
+
MAP_HEIGHT = 32
|
|
92
|
+
|
|
93
|
+
# # = wall block, . = empty space. A 3x3 grid of rooms joined by doorways.
|
|
94
|
+
MAP = [
|
|
95
|
+
"################################",
|
|
96
|
+
"#.........#..........#.........#",
|
|
97
|
+
"#.........#..........#.........#",
|
|
98
|
+
"#.........#..........#.........#",
|
|
99
|
+
"#.........#..........#.........#",
|
|
100
|
+
"#..............................#",
|
|
101
|
+
"#.........#..........#.........#",
|
|
102
|
+
"#.........#..........#.........#",
|
|
103
|
+
"#.........#..........#.........#",
|
|
104
|
+
"#.........#..........#.........#",
|
|
105
|
+
"#####.#########.##########.#####",
|
|
106
|
+
"#.........#..........#.........#",
|
|
107
|
+
"#.........#..........#.........#",
|
|
108
|
+
"#.........#..........#.........#",
|
|
109
|
+
"#.........#..........#.........#",
|
|
110
|
+
"#.........#..........#.........#",
|
|
111
|
+
"#..............................#",
|
|
112
|
+
"#.........#..........#.........#",
|
|
113
|
+
"#.........#..........#.........#",
|
|
114
|
+
"#.........#..........#.........#",
|
|
115
|
+
"#.........#..........#.........#",
|
|
116
|
+
"#####.#########.##########.#####",
|
|
117
|
+
"#.........#..........#.........#",
|
|
118
|
+
"#.........#..........#.........#",
|
|
119
|
+
"#.........#..........#.........#",
|
|
120
|
+
"#.........#..........#.........#",
|
|
121
|
+
"#..............................#",
|
|
122
|
+
"#.........#..........#.........#",
|
|
123
|
+
"#.........#..........#.........#",
|
|
124
|
+
"#.........#..........#.........#",
|
|
125
|
+
"#.........#..........#.........#",
|
|
126
|
+
"################################"
|
|
127
|
+
].join
|
|
128
|
+
|
|
129
|
+
FOV = Math::PI / 4.0 # Field of view
|
|
130
|
+
DEPTH = 16.0 # Maximum rendering distance
|
|
131
|
+
SPEED = 5.0 # Walking speed
|
|
132
|
+
TIME_LIMIT = 90.0 # Seconds to clear every target
|
|
133
|
+
|
|
134
|
+
BULLET_SPEED = 8.0 # Fireball travel speed (units/second)
|
|
135
|
+
BULLET_RANGE = 6.0 # How far a fireball flies before fizzling out
|
|
136
|
+
FIRE_COOLDOWN = 0.25 # Seconds between shots
|
|
137
|
+
HIT_RADIUS = 0.6 # How close a fireball must get to pop a target
|
|
138
|
+
TARGET_SCALE = 0.5 # Target size as a fraction of full wall height
|
|
139
|
+
FIREBALL_SCALE = 0.2
|
|
140
|
+
|
|
141
|
+
TARGET_SPRITE = Sprite.bullseye(3)
|
|
142
|
+
FIREBALL_SPRITE = Sprite.fireball(2)
|
|
143
|
+
|
|
144
|
+
NUM_TARGETS = 6 # Targets scattered each round
|
|
145
|
+
MIN_SEPARATION = 5.0 # Min distance between targets and from the player spawn
|
|
146
|
+
|
|
147
|
+
# Wall texture/brightness ramp, nearest -> farthest.
|
|
148
|
+
WALLS = [
|
|
149
|
+
"█".fg(:white),
|
|
150
|
+
"▓".fg(:light_gray),
|
|
151
|
+
"▒".fg(:light_gray),
|
|
152
|
+
"░".fg(:gray)
|
|
153
|
+
].freeze
|
|
154
|
+
|
|
155
|
+
# Sky gradient, top of screen -> horizon.
|
|
156
|
+
SKY = [
|
|
157
|
+
"░".fg(:blue),
|
|
158
|
+
"▒".fg(:blue),
|
|
159
|
+
"▓".fg(:bright_blue)
|
|
160
|
+
].freeze
|
|
161
|
+
|
|
162
|
+
# Grassy floor, closest (bottom of screen) -> horizon.
|
|
163
|
+
FLOOR = [
|
|
164
|
+
"▓".fg(:green),
|
|
165
|
+
"▒".fg(:green),
|
|
166
|
+
"░".fg(:green),
|
|
167
|
+
"░".fg(:gray)
|
|
168
|
+
].freeze
|
|
169
|
+
|
|
170
|
+
# Player facing indicator on the minimap, indexed clockwise from east.
|
|
171
|
+
DIR_ARROWS = ["→", "↘", "↓", "↙", "←", "↖", "↑", "↗"].freeze
|
|
172
|
+
|
|
173
|
+
RADAR_RADIUS = 6 # Half-width of the player-centered minimap window (cells)
|
|
174
|
+
MINIMAP_X = 1 # Top-left screen origin of the minimap contents
|
|
175
|
+
MINIMAP_Y = 2
|
|
176
|
+
|
|
177
|
+
def on_create
|
|
178
|
+
reset_game
|
|
179
|
+
@state = :intro # The briefing shows once, before the first round
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def on_update(elapsed_time, key)
|
|
183
|
+
quit! if key == :esc
|
|
184
|
+
|
|
185
|
+
case @state
|
|
186
|
+
when :intro
|
|
187
|
+
draw_intro
|
|
188
|
+
@state = :playing if key # Any key begins the round
|
|
189
|
+
when :playing
|
|
190
|
+
play_frame(elapsed_time, key)
|
|
191
|
+
else
|
|
192
|
+
reset_game if key == :r
|
|
193
|
+
draw_result # Freeze the last frame and overlay the win/lose banner
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
def reset_game
|
|
200
|
+
spawn_player
|
|
201
|
+
@fps = 0.0
|
|
202
|
+
@state = :playing
|
|
203
|
+
|
|
204
|
+
@targets = spawn_targets
|
|
205
|
+
@target_count = @targets.size # Frozen total for the HUD/result screens
|
|
206
|
+
@fireballs = []
|
|
207
|
+
@hits = 0
|
|
208
|
+
@fire_cooldown = RichEngine::Cooldown.new(FIRE_COOLDOWN)
|
|
209
|
+
@clock = RichEngine::Cooldown.new(TIME_LIMIT) # Counts down to zero
|
|
210
|
+
@depth_buffer = Array.new(@width, DEPTH) # Wall distance per screen column
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def spawn_player
|
|
214
|
+
row, col = open_cells.sample
|
|
215
|
+
@player_x = row + 0.5
|
|
216
|
+
@player_y = col + 0.5
|
|
217
|
+
@player_a = rand * 2 * Math::PI
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def spawn_targets
|
|
221
|
+
chosen = []
|
|
222
|
+
open_cells.shuffle.each do |row, col|
|
|
223
|
+
x = row + 0.5
|
|
224
|
+
y = col + 0.5
|
|
225
|
+
next if Math.hypot(x - @player_x, y - @player_y) < MIN_SEPARATION
|
|
226
|
+
next if chosen.any? { |t| Math.hypot(t.x - x, t.y - y) < MIN_SEPARATION }
|
|
227
|
+
|
|
228
|
+
chosen << Target.new(x, y)
|
|
229
|
+
break if chosen.size >= NUM_TARGETS
|
|
230
|
+
end
|
|
231
|
+
chosen
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def open_cells
|
|
235
|
+
@open_cells ||= (0...MAP_HEIGHT).flat_map do |row|
|
|
236
|
+
(0...MAP_WIDTH).filter_map { |col| [row, col] unless wall?(row, col) }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def play_frame(elapsed_time, key)
|
|
241
|
+
@fps = elapsed_time.zero? ? 0.0 : 1.0 / elapsed_time
|
|
242
|
+
|
|
243
|
+
@clock.update(elapsed_time)
|
|
244
|
+
@fire_cooldown.update(elapsed_time)
|
|
245
|
+
handle_input(key, elapsed_time)
|
|
246
|
+
update_fireballs(elapsed_time)
|
|
247
|
+
|
|
248
|
+
render_world
|
|
249
|
+
draw_objects
|
|
250
|
+
draw_map
|
|
251
|
+
draw_crosshair
|
|
252
|
+
draw_hud
|
|
253
|
+
|
|
254
|
+
if @targets.empty?
|
|
255
|
+
@state = :won
|
|
256
|
+
elsif @clock.finished?
|
|
257
|
+
@state = :lost
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def handle_input(key, elapsed_time)
|
|
262
|
+
case key
|
|
263
|
+
when :a # Rotate counter-clockwise
|
|
264
|
+
@player_a -= (SPEED * 0.75) * elapsed_time
|
|
265
|
+
when :d # Rotate clockwise
|
|
266
|
+
@player_a += (SPEED * 0.75) * elapsed_time
|
|
267
|
+
when :w # Forwards (with collision)
|
|
268
|
+
dx = Math.sin(@player_a) * SPEED * elapsed_time
|
|
269
|
+
dy = Math.cos(@player_a) * SPEED * elapsed_time
|
|
270
|
+
move(dx, dy)
|
|
271
|
+
when :s # Backwards (with collision)
|
|
272
|
+
dx = Math.sin(@player_a) * SPEED * elapsed_time
|
|
273
|
+
dy = Math.cos(@player_a) * SPEED * elapsed_time
|
|
274
|
+
move(-dx, -dy)
|
|
275
|
+
when :q # Strafe left
|
|
276
|
+
strafe(-1, elapsed_time)
|
|
277
|
+
when :e # Strafe right
|
|
278
|
+
strafe(1, elapsed_time)
|
|
279
|
+
when :space
|
|
280
|
+
fire! if @fire_cooldown.ready?
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Sidestep perpendicular to the facing direction. +1 is right, -1 is left.
|
|
285
|
+
def strafe(direction, elapsed_time)
|
|
286
|
+
dx = Math.cos(@player_a) * SPEED * elapsed_time * direction
|
|
287
|
+
dy = -Math.sin(@player_a) * SPEED * elapsed_time * direction
|
|
288
|
+
move(dx, dy)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def move(dx, dy)
|
|
292
|
+
@player_x += dx
|
|
293
|
+
@player_y += dy
|
|
294
|
+
|
|
295
|
+
if wall?(@player_x.to_i, @player_y.to_i)
|
|
296
|
+
@player_x -= dx
|
|
297
|
+
@player_y -= dy
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def fire!
|
|
302
|
+
# Aim where the player faces, with a little spread.
|
|
303
|
+
angle = @player_a + (rand - 0.5) * 0.1
|
|
304
|
+
@fireballs << Fireball.new(
|
|
305
|
+
@player_x, @player_y,
|
|
306
|
+
Math.sin(angle) * BULLET_SPEED,
|
|
307
|
+
Math.cos(angle) * BULLET_SPEED,
|
|
308
|
+
0.0
|
|
309
|
+
)
|
|
310
|
+
@fire_cooldown.reset!
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def update_fireballs(elapsed_time)
|
|
314
|
+
@fireballs.each do |fireball|
|
|
315
|
+
fireball.x += fireball.vx * elapsed_time
|
|
316
|
+
fireball.y += fireball.vy * elapsed_time
|
|
317
|
+
fireball.traveled += BULLET_SPEED * elapsed_time
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
@fireballs.reject! { |fireball| fireball_spent?(fireball) }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# A fireball is spent when it outranges, leaves the map, buries into a wall,
|
|
324
|
+
# or pops a target. Popping a target removes it and scores a hit.
|
|
325
|
+
def fireball_spent?(fireball)
|
|
326
|
+
return true if fireball.traveled >= BULLET_RANGE
|
|
327
|
+
|
|
328
|
+
x = fireball.x.to_i
|
|
329
|
+
y = fireball.y.to_i
|
|
330
|
+
return true if x < 0 || x >= MAP_WIDTH || y < 0 || y >= MAP_HEIGHT
|
|
331
|
+
return true if wall?(x, y)
|
|
332
|
+
|
|
333
|
+
hit = @targets.find { |target| Math.hypot(target.x - fireball.x, target.y - fireball.y) < HIT_RADIUS }
|
|
334
|
+
if hit
|
|
335
|
+
@targets.delete(hit)
|
|
336
|
+
@hits += 1
|
|
337
|
+
return true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def render_world
|
|
344
|
+
(0...@width).each do |x|
|
|
345
|
+
distance, boundary = cast_ray(x)
|
|
346
|
+
@depth_buffer[x] = distance
|
|
347
|
+
|
|
348
|
+
ceiling = (@height / 2.0) - @height / distance
|
|
349
|
+
floor = @height - ceiling
|
|
350
|
+
|
|
351
|
+
wall = boundary ? " " : wall_tile(distance) # Black out tile boundaries
|
|
352
|
+
|
|
353
|
+
(0...@height).each do |y|
|
|
354
|
+
@canvas[x, y] =
|
|
355
|
+
if y <= ceiling
|
|
356
|
+
sky_tile(y, ceiling)
|
|
357
|
+
elsif y <= floor
|
|
358
|
+
wall
|
|
359
|
+
else
|
|
360
|
+
floor_tile(y)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Cast a single ray for screen column +x+, returning [distance, boundary?].
|
|
367
|
+
def cast_ray(x)
|
|
368
|
+
ray_angle = (@player_a - FOV / 2.0) + (x.to_f / @width) * FOV
|
|
369
|
+
|
|
370
|
+
step_size = 0.1
|
|
371
|
+
distance = 0.0
|
|
372
|
+
hit_wall = false
|
|
373
|
+
boundary = false
|
|
374
|
+
|
|
375
|
+
eye_x = Math.sin(ray_angle) # Unit vector for ray in player space
|
|
376
|
+
eye_y = Math.cos(ray_angle)
|
|
377
|
+
|
|
378
|
+
while !hit_wall && distance < DEPTH
|
|
379
|
+
distance += step_size
|
|
380
|
+
test_x = (@player_x + eye_x * distance).to_i
|
|
381
|
+
test_y = (@player_y + eye_y * distance).to_i
|
|
382
|
+
|
|
383
|
+
if test_x < 0 || test_x >= MAP_WIDTH || test_y < 0 || test_y >= MAP_HEIGHT
|
|
384
|
+
# Out of bounds: clamp to maximum depth
|
|
385
|
+
hit_wall = true
|
|
386
|
+
distance = DEPTH
|
|
387
|
+
elsif wall?(test_x, test_y)
|
|
388
|
+
hit_wall = true
|
|
389
|
+
boundary = tile_boundary?(test_x, test_y, eye_x, eye_y)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
[distance, boundary]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Highlight tile boundaries: cast a ray from each corner of the hit tile back
|
|
397
|
+
# to the player. The more coincident a corner ray is with the rendering ray,
|
|
398
|
+
# the closer we are to an edge, which we shade to add detail to the walls.
|
|
399
|
+
def tile_boundary?(test_x, test_y, eye_x, eye_y)
|
|
400
|
+
corners = []
|
|
401
|
+
|
|
402
|
+
2.times do |tx|
|
|
403
|
+
2.times do |ty|
|
|
404
|
+
vx = test_x + tx - @player_x
|
|
405
|
+
vy = test_y + ty - @player_y
|
|
406
|
+
d = Math.sqrt(vx * vx + vy * vy)
|
|
407
|
+
dot = (eye_x * vx / d) + (eye_y * vy / d)
|
|
408
|
+
corners << [d, dot]
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
corners.sort_by!(&:first) # Closest corners first
|
|
413
|
+
|
|
414
|
+
bound = 0.01
|
|
415
|
+
# The first three corners are the closest (we never see all four)
|
|
416
|
+
corners.first(3).any? { |(_d, dot)| Math.acos(dot) < bound }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Project every object into the view and draw it as a billboard, scaled by
|
|
420
|
+
# distance and clipped wherever a nearer wall (or sprite) sits in front.
|
|
421
|
+
def draw_objects
|
|
422
|
+
renderables = []
|
|
423
|
+
@targets.each { |t| renderables << [t.x, t.y, TARGET_SPRITE, TARGET_SCALE] }
|
|
424
|
+
@fireballs.each { |f| renderables << [f.x, f.y, FIREBALL_SPRITE, FIREBALL_SCALE] }
|
|
425
|
+
renderables.each { |r| r << Math.hypot(r[0] - @player_x, r[1] - @player_y) }
|
|
426
|
+
renderables.sort_by! { |r| -r.last } # Farthest first, so nearer overdraws
|
|
427
|
+
|
|
428
|
+
eye_x = Math.sin(@player_a)
|
|
429
|
+
eye_y = Math.cos(@player_a)
|
|
430
|
+
|
|
431
|
+
renderables.each do |ox, oy, sprite, scale, distance|
|
|
432
|
+
next if distance < 0.5 || distance >= DEPTH
|
|
433
|
+
|
|
434
|
+
angle = Math.atan2(eye_y, eye_x) - Math.atan2(oy - @player_y, ox - @player_x)
|
|
435
|
+
angle += 2 * Math::PI while angle < -Math::PI
|
|
436
|
+
angle -= 2 * Math::PI while angle > Math::PI
|
|
437
|
+
next unless angle.abs < FOV / 2.0
|
|
438
|
+
|
|
439
|
+
ceiling = (@height / 2.0) - @height / distance
|
|
440
|
+
floor = @height - ceiling
|
|
441
|
+
full_height = floor - ceiling
|
|
442
|
+
|
|
443
|
+
height = full_height * scale
|
|
444
|
+
# Stretch width to counter the terminal's ~2:1 cell aspect (rounder discs)
|
|
445
|
+
width = height * 2.0 * (sprite.width.to_f / sprite.height)
|
|
446
|
+
top = (@height / 2.0) - height / 2.0
|
|
447
|
+
middle_column = (0.5 * (angle / (FOV / 2.0)) + 0.5) * @width
|
|
448
|
+
|
|
449
|
+
draw_billboard(sprite, distance, width, height, top, middle_column)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def draw_billboard(sprite, distance, width, height, top, middle_column)
|
|
454
|
+
return if width <= 0 || height <= 0
|
|
455
|
+
|
|
456
|
+
(0...width.ceil).each do |lx|
|
|
457
|
+
column = (middle_column + lx - width / 2.0).to_i
|
|
458
|
+
next if column < 0 || column >= @width
|
|
459
|
+
next if @depth_buffer[column] < distance # Wall in front: skip this column
|
|
460
|
+
|
|
461
|
+
drew = false
|
|
462
|
+
(0...height.ceil).each do |ly|
|
|
463
|
+
pixel = sprite.sample(lx / width, ly / height)
|
|
464
|
+
next unless pixel
|
|
465
|
+
|
|
466
|
+
row = (top + ly).to_i
|
|
467
|
+
next if row < 0 || row >= @height
|
|
468
|
+
|
|
469
|
+
@canvas[column, row] = pixel
|
|
470
|
+
drew = true
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
@depth_buffer[column] = distance if drew # Occlude farther sprites
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def wall_tile(distance)
|
|
478
|
+
if distance <= DEPTH / 4.0 then WALLS[0] # Very close
|
|
479
|
+
elsif distance < DEPTH / 3.0 then WALLS[1]
|
|
480
|
+
elsif distance < DEPTH / 2.0 then WALLS[2]
|
|
481
|
+
elsif distance < DEPTH then WALLS[3]
|
|
482
|
+
else " " # Too far away
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def sky_tile(y, ceiling)
|
|
487
|
+
s = ceiling <= 0 ? 1.0 : y / ceiling # 0 at top of screen -> 1 at horizon
|
|
488
|
+
|
|
489
|
+
if s < 0.5 then SKY[0]
|
|
490
|
+
elsif s < 0.8 then SKY[1]
|
|
491
|
+
else SKY[2]
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def floor_tile(y)
|
|
496
|
+
b = 1.0 - ((y - @height / 2.0) / (@height / 2.0)) # 0 closest -> 1 horizon
|
|
497
|
+
|
|
498
|
+
if b < 0.25 then FLOOR[0]
|
|
499
|
+
elsif b < 0.5 then FLOOR[1]
|
|
500
|
+
elsif b < 0.75 then FLOOR[2]
|
|
501
|
+
elsif b < 0.9 then FLOOR[3]
|
|
502
|
+
else " "
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def wall?(x, y)
|
|
507
|
+
MAP[x * MAP_WIDTH + y] == "#"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# A player-centered radar: a small window of the map that scrolls with you,
|
|
511
|
+
# drawn 1:1 so walls and doorways stay crisp. North stays up.
|
|
512
|
+
def draw_map
|
|
513
|
+
draw_minimap_frame
|
|
514
|
+
|
|
515
|
+
px = @player_x.to_i
|
|
516
|
+
py = @player_y.to_i
|
|
517
|
+
|
|
518
|
+
(-RADAR_RADIUS..RADAR_RADIUS).each do |dr|
|
|
519
|
+
(-RADAR_RADIUS..RADAR_RADIUS).each do |dc|
|
|
520
|
+
plot_radar(dr, dc, radar_glyph(px + dr, py + dc))
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Targets blip on the radar whenever they come within range.
|
|
525
|
+
@targets.each do |t|
|
|
526
|
+
dr = t.x.to_i - px
|
|
527
|
+
dc = t.y.to_i - py
|
|
528
|
+
next if dr.abs > RADAR_RADIUS || dc.abs > RADAR_RADIUS
|
|
529
|
+
plot_radar(dr, dc, "◎".fg(:bright_red))
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
plot_radar(0, 0, player_arrow.fg(:bright_yellow)) # Player is always centered
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def radar_glyph(row, col)
|
|
536
|
+
if row < 0 || row >= MAP_HEIGHT || col < 0 || col >= MAP_WIDTH
|
|
537
|
+
"█".fg(:gray) # Outside the map
|
|
538
|
+
elsif wall?(row, col)
|
|
539
|
+
"█".fg(:white)
|
|
540
|
+
else
|
|
541
|
+
"·".fg(:gray)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Plot a map cell at offset (dr, dc) from the player onto the radar window.
|
|
546
|
+
def plot_radar(dr, dc, glyph)
|
|
547
|
+
@canvas[MINIMAP_X + dc + RADAR_RADIUS, MINIMAP_Y + dr + RADAR_RADIUS] = glyph
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def draw_minimap_frame
|
|
551
|
+
size = 2 * RADAR_RADIUS + 1
|
|
552
|
+
left = MINIMAP_X - 1
|
|
553
|
+
right = MINIMAP_X + size
|
|
554
|
+
top = MINIMAP_Y - 1
|
|
555
|
+
bottom = MINIMAP_Y + size
|
|
556
|
+
color = :gray
|
|
557
|
+
|
|
558
|
+
(left..right).each do |x|
|
|
559
|
+
@canvas[x, top] = "─".fg(color)
|
|
560
|
+
@canvas[x, bottom] = "─".fg(color)
|
|
561
|
+
end
|
|
562
|
+
(top..bottom).each do |y|
|
|
563
|
+
@canvas[left, y] = "│".fg(color)
|
|
564
|
+
@canvas[right, y] = "│".fg(color)
|
|
565
|
+
end
|
|
566
|
+
@canvas[left, top] = "┌".fg(color)
|
|
567
|
+
@canvas[right, top] = "┐".fg(color)
|
|
568
|
+
@canvas[left, bottom] = "└".fg(color)
|
|
569
|
+
@canvas[right, bottom] = "┘".fg(color)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def player_arrow
|
|
573
|
+
index = (@player_a / (Math::PI / 4.0)).round % 8
|
|
574
|
+
DIR_ARROWS[index]
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def draw_crosshair
|
|
578
|
+
cx = @width / 2
|
|
579
|
+
cy = @height / 2
|
|
580
|
+
@canvas[cx - 1, cy] = "─".fg(:bright_green)
|
|
581
|
+
@canvas[cx + 1, cy] = "─".fg(:bright_green)
|
|
582
|
+
@canvas[cx, cy] = "┼".fg(:bright_green)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def draw_hud
|
|
586
|
+
remaining = [@clock.get, 0.0].max
|
|
587
|
+
time_color = remaining <= 10 ? :bright_red : :white
|
|
588
|
+
@canvas.write_string(format("TIME %4.1f", remaining), x: 0, y: 0, fg: time_color)
|
|
589
|
+
@canvas.write_string("HITS #{@hits} LEFT #{@targets.size}", x: @width - 16, y: 0, fg: :bright_yellow)
|
|
590
|
+
@canvas.write_string(format("FPS %3.0f", @fps), x: @width - 16, y: 1, fg: :gray)
|
|
591
|
+
@canvas.write_string("W/S move A/D turn Q/E strafe SPACE fire ESC quit", x: 0, y: @height - 1, fg: :gray)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def draw_intro
|
|
595
|
+
cy = @height / 2
|
|
596
|
+
@canvas.write_string("TARGET RANGE", x: :center, y: cy - 4, fg: :bright_green)
|
|
597
|
+
@canvas.write_string("Hunt down all #{@target_count} targets in #{TIME_LIMIT.to_i} seconds.", x: :center, y: cy - 1, fg: :white)
|
|
598
|
+
@canvas.write_string("They blip on the radar when you get close. Go hunt them down.", x: :center, y: cy + 1, fg: :white)
|
|
599
|
+
@canvas.write_string("W/S move A/D turn Q/E strafe SPACE fire", x: :center, y: cy + 3, fg: :bright_yellow)
|
|
600
|
+
@canvas.write_string("Press any key to begin", x: :center, y: cy + 5, fg: :white)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def draw_result
|
|
604
|
+
if @state == :won
|
|
605
|
+
headline = "TARGETS CLEARED!"
|
|
606
|
+
headline_color = :bright_green
|
|
607
|
+
detail = format("Cleared in %.1fs", TIME_LIMIT - [@clock.get, 0.0].max)
|
|
608
|
+
else
|
|
609
|
+
headline = "TIME'S UP!"
|
|
610
|
+
headline_color = :bright_red
|
|
611
|
+
detail = "Hits #{@hits}/#{@target_count}"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
@canvas.write_string(headline, x: :center, y: @height / 2 - 2, fg: headline_color)
|
|
615
|
+
@canvas.write_string(detail, x: :center, y: @height / 2, fg: :white)
|
|
616
|
+
@canvas.write_string("Press R to play again ESC to quit", x: :center, y: @height / 2 + 2, fg: :white)
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
CommandLineFPS.play(width: 120, height: 40)
|