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.
@@ -4,8 +4,16 @@ require_relative "canvas"
4
4
  require_relative "io"
5
5
 
6
6
  module RichEngine
7
- # Example:
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
- def initialize(width, height)
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
- def self.play(width: 50, height: 10)
35
- new(width, height).play
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
@@ -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
- key = $stdin.read_nonblock(2)
28
- _c1, c2 = key.chars
29
-
30
- if c2 && csi?(key)
31
- c3, c4 = $stdin.read_nonblock(2).chars
32
-
33
- if digit?(c3)
34
- symbolize_key("#{key}#{c3}#{c4}")
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("#{key}#{c3}")
61
+ symbolize_key(key)
37
62
  end
38
- else
39
- symbolize_key(key)
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
@@ -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)