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
data/lib/rich_engine/game.rb
CHANGED
|
@@ -4,8 +4,16 @@ require_relative "canvas"
|
|
|
4
4
|
require_relative "io"
|
|
5
5
|
|
|
6
6
|
module RichEngine
|
|
7
|
-
#
|
|
7
|
+
# The base class for all games. Subclass it, implement the lifecycle hooks,
|
|
8
|
+
# and draw to `@canvas` each frame; the game loop, input, rendering, and
|
|
9
|
+
# frame pacing are handled for you.
|
|
8
10
|
#
|
|
11
|
+
# The lifecycle hooks are the core public API:
|
|
12
|
+
# - {#on_create} runs once at start
|
|
13
|
+
# - {#on_update} runs every frame
|
|
14
|
+
# - {#on_destroy} runs when the game exits
|
|
15
|
+
#
|
|
16
|
+
# @example A minimal game that draws a title and quits on "q"
|
|
9
17
|
# class MyGame < RichEngine::Game
|
|
10
18
|
# def on_create
|
|
11
19
|
# @title = "My Awesome Game"
|
|
@@ -19,22 +27,44 @@ module RichEngine
|
|
|
19
27
|
# end
|
|
20
28
|
#
|
|
21
29
|
# MyGame.play
|
|
22
|
-
#
|
|
23
30
|
class Game
|
|
31
|
+
# Raised internally to break out of the game loop. Use {#quit!} instead of
|
|
32
|
+
# raising this directly.
|
|
33
|
+
#
|
|
34
|
+
# @api private
|
|
24
35
|
class Exit < StandardError; end
|
|
25
36
|
|
|
26
|
-
|
|
37
|
+
# @param width [Integer] the screen width in characters
|
|
38
|
+
# @param height [Integer] the screen height in characters
|
|
39
|
+
# @param target_fps [Integer, nil] target frames per second; pass nil to
|
|
40
|
+
# run uncapped without frame pacing
|
|
41
|
+
def initialize(width, height, target_fps: 60)
|
|
27
42
|
@width = width
|
|
28
43
|
@height = height
|
|
44
|
+
@target_fps = target_fps
|
|
45
|
+
@frame_budget = @target_fps ? 1.0 / @target_fps : nil
|
|
29
46
|
@config = {screen_width: @width, screen_height: @height}
|
|
30
47
|
@io = RichEngine::IO.new(width, height)
|
|
31
48
|
@canvas = RichEngine::Canvas.new(width, height)
|
|
32
49
|
end
|
|
33
50
|
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
# Builds a game and runs it. The convenient entry point for starting a
|
|
52
|
+
# game.
|
|
53
|
+
#
|
|
54
|
+
# @param width [Integer] the screen width in characters
|
|
55
|
+
# @param height [Integer] the screen height in characters
|
|
56
|
+
# @param target_fps [Integer, nil] target frames per second; pass nil to
|
|
57
|
+
# run uncapped without frame pacing
|
|
58
|
+
# @return [void]
|
|
59
|
+
def self.play(width: 50, height: 10, target_fps: 60)
|
|
60
|
+
new(width, height, target_fps: target_fps).play
|
|
36
61
|
end
|
|
37
62
|
|
|
63
|
+
# Runs the game: prepares the terminal, calls {#on_create}, then loops
|
|
64
|
+
# calling {#on_update} and rendering every frame until the game exits,
|
|
65
|
+
# finally calling {#on_destroy} and restoring the terminal.
|
|
66
|
+
#
|
|
67
|
+
# @return [void]
|
|
38
68
|
def play
|
|
39
69
|
prepare_screen
|
|
40
70
|
on_create
|
|
@@ -56,23 +86,49 @@ module RichEngine
|
|
|
56
86
|
restore_screen
|
|
57
87
|
end
|
|
58
88
|
|
|
89
|
+
# Lifecycle hook called once before the game loop starts. Override it to
|
|
90
|
+
# set up initial state (instance variables, timers, canvas slots, etc.).
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
59
93
|
def on_create
|
|
60
94
|
end
|
|
61
95
|
|
|
96
|
+
# Lifecycle hook called once per frame. Override it to update game state
|
|
97
|
+
# and draw to `@canvas`.
|
|
98
|
+
#
|
|
99
|
+
# @param _elapsed_time [Float] seconds elapsed since the last frame
|
|
100
|
+
# @param _key [Symbol, nil] the last key pressed (e.g. :q, :up, :space,
|
|
101
|
+
# :esc), or nil if no key was pressed this frame
|
|
102
|
+
# @return [void]
|
|
62
103
|
def on_update(_elapsed_time, _key)
|
|
63
104
|
end
|
|
64
105
|
|
|
106
|
+
# Lifecycle hook called once after the game loop ends. Override it to tear
|
|
107
|
+
# down state or print a final message.
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
65
110
|
def on_destroy
|
|
66
111
|
end
|
|
67
112
|
|
|
113
|
+
# Exits the game loop, triggering {#on_destroy} and terminal restore.
|
|
114
|
+
#
|
|
115
|
+
# @return [void]
|
|
116
|
+
# @raise [Exit] always, to unwind out of the loop
|
|
68
117
|
def quit!
|
|
69
118
|
raise Exit
|
|
70
119
|
end
|
|
71
120
|
|
|
121
|
+
# Runs a single frame: reads input, calls {#on_update}, renders, and
|
|
122
|
+
# sleeps to honor the target FPS.
|
|
123
|
+
#
|
|
124
|
+
# @param elapsed_time [Float] seconds elapsed since the last frame
|
|
125
|
+
# @return [void]
|
|
126
|
+
# @api private
|
|
72
127
|
def game_loop(elapsed_time)
|
|
73
128
|
key = read_input
|
|
74
129
|
on_update(elapsed_time, key)
|
|
75
130
|
render
|
|
131
|
+
sleep_if_needed(elapsed_time)
|
|
76
132
|
end
|
|
77
133
|
|
|
78
134
|
private
|
|
@@ -96,6 +152,13 @@ module RichEngine
|
|
|
96
152
|
@io.write(@canvas.canvas, use_caching: use_caching)
|
|
97
153
|
end
|
|
98
154
|
|
|
155
|
+
def sleep_if_needed(elapsed_time)
|
|
156
|
+
return if @frame_budget.nil?
|
|
157
|
+
|
|
158
|
+
sleep_time = @frame_budget - elapsed_time
|
|
159
|
+
sleep(sleep_time) if sleep_time > 0
|
|
160
|
+
end
|
|
161
|
+
|
|
99
162
|
def check_game_exit
|
|
100
163
|
yield
|
|
101
164
|
rescue Exit
|
data/lib/rich_engine/io.rb
CHANGED
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require "io/console"
|
|
5
|
+
require "stringio"
|
|
5
6
|
|
|
6
7
|
module RichEngine
|
|
8
|
+
# Internal plumbing that bridges {Game} and the terminal: it flushes the
|
|
9
|
+
# canvas to $stdout (with render caching) and reads non-blocking keyboard
|
|
10
|
+
# input, translating escape sequences into key symbols.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
7
13
|
class IO
|
|
8
14
|
Signal.trap("INT") { raise Game::Exit }
|
|
9
15
|
|
|
16
|
+
# @param width [Integer] the screen width in characters
|
|
17
|
+
# @param height [Integer] the screen height in characters
|
|
10
18
|
def initialize(width, height)
|
|
11
19
|
@screen_width = width
|
|
12
20
|
@screen_height = height
|
|
13
21
|
delete_cache
|
|
14
22
|
end
|
|
15
23
|
|
|
24
|
+
# Renders the canvas to $stdout, skipping the write when the canvas is
|
|
25
|
+
# unchanged since the last frame.
|
|
26
|
+
#
|
|
27
|
+
# @param canvas [Array] the flat array of canvas cells to draw
|
|
28
|
+
# @param use_caching [Boolean] when false, the render cache is dropped so
|
|
29
|
+
# the canvas is always redrawn
|
|
30
|
+
# @return [Symbol] :cache_hit when the frame was skipped, :cache_miss
|
|
31
|
+
# otherwise
|
|
16
32
|
def write(canvas, use_caching:)
|
|
17
33
|
delete_cache unless use_caching
|
|
18
34
|
|
|
@@ -23,23 +39,30 @@ module RichEngine
|
|
|
23
39
|
end
|
|
24
40
|
end
|
|
25
41
|
|
|
42
|
+
# Reads a single keypress without blocking, decoding multi-byte escape
|
|
43
|
+
# sequences (arrows, page keys, etc.) into key symbols.
|
|
44
|
+
#
|
|
45
|
+
# @return [Symbol, nil] the key pressed (e.g. :q, :up, :space, :esc), or
|
|
46
|
+
# nil if no input was waiting
|
|
26
47
|
def read_async
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
$stdin.raw do |io|
|
|
49
|
+
key = $stdin.read_nonblock(2)
|
|
50
|
+
_c1, c2 = key.chars
|
|
51
|
+
|
|
52
|
+
if c2 && csi?(key)
|
|
53
|
+
c3, c4 = $stdin.read_nonblock(2).chars
|
|
54
|
+
|
|
55
|
+
if digit?(c3)
|
|
56
|
+
symbolize_key("#{key}#{c3}#{c4}")
|
|
57
|
+
else
|
|
58
|
+
symbolize_key("#{key}#{c3}")
|
|
59
|
+
end
|
|
35
60
|
else
|
|
36
|
-
symbolize_key(
|
|
61
|
+
symbolize_key(key)
|
|
37
62
|
end
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
rescue ::IO::WaitReadable
|
|
64
|
+
nil
|
|
40
65
|
end
|
|
41
|
-
rescue ::IO::WaitReadable
|
|
42
|
-
nil
|
|
43
66
|
end
|
|
44
67
|
|
|
45
68
|
private
|
|
@@ -52,13 +75,13 @@ module RichEngine
|
|
|
52
75
|
return :cache_hit if canvas == @canvas_cache
|
|
53
76
|
|
|
54
77
|
yield
|
|
55
|
-
@canvas_cache = canvas
|
|
78
|
+
@canvas_cache = canvas.dup # Snapshot by value; the game may mutate the array in place
|
|
56
79
|
|
|
57
80
|
:cache_miss
|
|
58
81
|
end
|
|
59
82
|
|
|
60
83
|
def build_output(canvas)
|
|
61
|
-
output =
|
|
84
|
+
output = StringIO.new
|
|
62
85
|
|
|
63
86
|
i = 0
|
|
64
87
|
while i < canvas_size
|
|
@@ -67,7 +90,7 @@ module RichEngine
|
|
|
67
90
|
i += @screen_width
|
|
68
91
|
end
|
|
69
92
|
|
|
70
|
-
output
|
|
93
|
+
output.string
|
|
71
94
|
end
|
|
72
95
|
|
|
73
96
|
def canvas_size
|
data/lib/rich_engine/matrix.rb
CHANGED
|
@@ -1,27 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RichEngine
|
|
4
|
+
# A simple 2D grid utility backed by nested arrays, with convenience methods
|
|
5
|
+
# for indexing, iterating, mapping, zipping, and filling regions.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# grid = RichEngine::Matrix.new(width: 10, height: 5, fill_with: 0)
|
|
9
|
+
# grid[2, 3] = 1
|
|
10
|
+
# grid.fill(x: 0..2, y: 0..1, with: 9)
|
|
4
11
|
class Matrix
|
|
5
12
|
# TODO: implement Enumerable
|
|
6
13
|
|
|
14
|
+
# @return [Array<Array>] the backing nested array of rows
|
|
7
15
|
attr_accessor :vec
|
|
8
16
|
|
|
17
|
+
# Builds a matrix of the given dimensions, filling every cell with
|
|
18
|
+
# +fill_with+.
|
|
19
|
+
#
|
|
20
|
+
# @param width [Integer] the number of columns
|
|
21
|
+
# @param height [Integer] the number of rows
|
|
22
|
+
# @param fill_with [Object] the initial value for every cell
|
|
9
23
|
def initialize(width: 1, height: 1, fill_with: nil)
|
|
10
24
|
@vec = Array.new(width) { Array.new(height) { fill_with } }
|
|
11
25
|
end
|
|
12
26
|
|
|
27
|
+
# Reads the value at the given coordinates.
|
|
28
|
+
#
|
|
29
|
+
# @param x [Integer] the column index
|
|
30
|
+
# @param y [Integer] the row index
|
|
31
|
+
# @return [Object] the value at +(x, y)+
|
|
13
32
|
def [](x, y)
|
|
14
33
|
@vec[x][y]
|
|
15
34
|
end
|
|
16
35
|
|
|
36
|
+
# Writes a value at the given coordinates.
|
|
37
|
+
#
|
|
38
|
+
# @param x [Integer] the column index
|
|
39
|
+
# @param y [Integer] the row index
|
|
40
|
+
# @param value [Object] the value to store
|
|
41
|
+
# @return [Object] the stored value
|
|
17
42
|
def []=(x, y, value)
|
|
18
43
|
@vec[x][y] = value
|
|
19
44
|
end
|
|
20
45
|
|
|
46
|
+
# Whether any cell matches the given block.
|
|
47
|
+
#
|
|
48
|
+
# @yield [cell] each cell in the matrix
|
|
49
|
+
# @return [Boolean] true if the block returns truthy for any cell
|
|
21
50
|
def any?(&block)
|
|
22
51
|
@vec.any? { |row| row.any?(&block) }
|
|
23
52
|
end
|
|
24
53
|
|
|
54
|
+
# Iterates over every cell in row-major order.
|
|
55
|
+
#
|
|
56
|
+
# @yield [tile] each cell value
|
|
57
|
+
# @return [void]
|
|
25
58
|
def each
|
|
26
59
|
@vec.each do |row|
|
|
27
60
|
row.each do |tile|
|
|
@@ -30,12 +63,21 @@ module RichEngine
|
|
|
30
63
|
end
|
|
31
64
|
end
|
|
32
65
|
|
|
66
|
+
# Maps every cell through the block, returning a nested array of results.
|
|
67
|
+
#
|
|
68
|
+
# @yield [value] each cell value
|
|
69
|
+
# @return [Array<Array>] a nested array of mapped values
|
|
33
70
|
def map(&block)
|
|
34
71
|
@vec.map do |row|
|
|
35
72
|
row.map { |value| block.call(value) }
|
|
36
73
|
end
|
|
37
74
|
end
|
|
38
75
|
|
|
76
|
+
# Pairs each cell with the cell at the same coordinates in +other+.
|
|
77
|
+
#
|
|
78
|
+
# @param other [Matrix] another matrix of the same dimensions
|
|
79
|
+
# @return [Matrix] a new matrix whose cells are +[self_value, other_value]+
|
|
80
|
+
# pairs
|
|
39
81
|
def zip(other)
|
|
40
82
|
new_matrix = Matrix.new
|
|
41
83
|
new_matrix.vec = @vec.map.with_index do |row, i|
|
|
@@ -45,6 +87,11 @@ module RichEngine
|
|
|
45
87
|
new_matrix
|
|
46
88
|
end
|
|
47
89
|
|
|
90
|
+
# Iterates over every cell along with its column and row indexes.
|
|
91
|
+
#
|
|
92
|
+
# @yield [tile, i, j] each cell value with its column index +i+ and row
|
|
93
|
+
# index +j+
|
|
94
|
+
# @return [void]
|
|
48
95
|
def each_with_indexes
|
|
49
96
|
@vec.each_with_index do |row, i|
|
|
50
97
|
row.each_with_index do |tile, j|
|
|
@@ -53,6 +100,16 @@ module RichEngine
|
|
|
53
100
|
end
|
|
54
101
|
end
|
|
55
102
|
|
|
103
|
+
# Fills a cell or region with a value. +x+ and +y+ may each be a single
|
|
104
|
+
# index or any object responding to +each+ (e.g. a Range), so regions can
|
|
105
|
+
# be filled in one call.
|
|
106
|
+
#
|
|
107
|
+
# @param x [Integer, #each] the column index or range of columns
|
|
108
|
+
# @param y [Integer, #each] the row index or range of rows
|
|
109
|
+
# @param with [Object] the value to write
|
|
110
|
+
# @return [void]
|
|
111
|
+
# @example Fill a region
|
|
112
|
+
# grid.fill(x: 0..2, y: 0..1, with: 9)
|
|
56
113
|
def fill(x:, y:, with:)
|
|
57
114
|
xs = Iterable(x)
|
|
58
115
|
ys = Iterable(y)
|