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.
@@ -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)