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.
@@ -1,183 +1,276 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RichEngine
4
+ # A refinement that adds color and style methods to String, plus helpers
5
+ # for resolving color specs.
6
+ #
7
+ # Colors are emitted as 256-color (8-bit) escape sequences using only the
8
+ # theme-independent regions of the palette: the 6x6x6 color cube (16-231)
9
+ # and the grayscale ramp (232-255). Unlike the classic 16 ANSI colors,
10
+ # these render the same RGB values in every terminal.
11
+ #
12
+ # Anywhere a color is accepted, you can pass:
13
+ #
14
+ # - a named color (`Symbol`): `:red`, `:bright_cyan`, ... (see {PALETTE})
15
+ # - a hex string: `"#ff8800"` (also `"ff8800"` and shorthand `"#f80"`)
16
+ # - an RGB array: `[255, 136, 0]`
17
+ # - a raw 256-color index (`Integer`): `208`
18
+ #
19
+ # Hex and RGB values snap to the nearest color in the fixed 256-color
20
+ # palette.
21
+ #
22
+ # @example
23
+ # using RichEngine::StringColors
24
+ #
25
+ # "hello".fg(:red) # named color
26
+ # "hello".fg("#ff8800").bold # custom color, chained with a style
27
+ # "hello".bg([0, 0, 215]) # RGB background
4
28
  module StringColors
5
- refine String do
6
- def fg(color)
7
- send(color)
8
- end
9
-
10
- def bg(color)
11
- send("on_#{color}")
12
- end
13
-
14
- # Colors
15
-
16
- def transparent
17
- gsub(/./, " ")
18
- end
19
-
20
- def black
21
- color(30)
22
- end
23
-
24
- def red
25
- color(31)
26
- end
27
-
28
- def green
29
- color(32)
30
- end
31
-
32
- def yellow
33
- color(33)
34
- end
35
-
36
- def blue
37
- color(34)
38
- end
39
-
40
- def magenta
41
- color(35)
42
- end
43
-
44
- def cyan
45
- color(36)
46
- end
47
-
48
- def white
49
- color(37)
50
- end
51
-
52
- def bright_black
53
- color(90)
29
+ # Named colors mapped to fixed color-cube/grayscale indices.
30
+ #
31
+ # @return [Hash{Symbol => Integer}]
32
+ PALETTE = {
33
+ black: 16,
34
+ red: 160,
35
+ green: 40,
36
+ yellow: 184,
37
+ orange: 208,
38
+ blue: 20,
39
+ magenta: 164,
40
+ cyan: 44,
41
+ white: 231,
42
+ dark_gray: 238,
43
+ dark_grey: 238,
44
+ gray: 244,
45
+ grey: 244,
46
+ light_gray: 188,
47
+ light_grey: 188,
48
+ bright_red: 196,
49
+ bright_green: 46,
50
+ bright_yellow: 226,
51
+ bright_blue: 21,
52
+ bright_magenta: 201,
53
+ bright_cyan: 51
54
+ }.freeze
55
+
56
+ # Channel intensities used by the 6x6x6 color cube.
57
+ #
58
+ # @return [Array<Integer>]
59
+ CUBE_LEVELS = [0, 95, 135, 175, 215, 255].freeze
60
+
61
+ # Resolves a color spec into a 256-color index.
62
+ #
63
+ # @example
64
+ # index_for(:red) # => 160 (named palette color)
65
+ # index_for("#ff8800") # => 208 (nearest cube color)
66
+ # index_for([255, 136, 0]) # => 208 (nearest cube color)
67
+ # index_for(208) # => 208 (used as-is)
68
+ #
69
+ # @param color [Symbol, String, Array<Integer>, Integer] a color spec
70
+ # @return [Integer] an index into the 256-color palette
71
+ # @raise [ArgumentError] if the spec is not one of the supported types
72
+ # @raise [KeyError] if a named color is not in {PALETTE}
73
+ def self.index_for(color)
74
+ case color
75
+ when Symbol then PALETTE.fetch(color)
76
+ when Integer then color
77
+ when Array then rgb_to_index(*color)
78
+ when String then rgb_to_index(*hex_to_rgb(color))
79
+ else
80
+ raise ArgumentError, "invalid color: #{color.inspect}"
54
81
  end
82
+ end
55
83
 
56
- def bright_red
57
- color(91)
58
- end
84
+ # Returns `:black` or `:white`, whichever has the higher WCAG contrast
85
+ # ratio against the given color, like CSS's `contrast-color()` function.
86
+ # Ties go to `:white`.
87
+ #
88
+ # @example Readable labels on a dynamic background
89
+ # label_color = StringColors.contrast_color(bg_color)
90
+ # canvas.write_string("Score", x: 0, y: 0, fg: label_color, bg: bg_color)
91
+ #
92
+ # @example
93
+ # contrast_color(:yellow) # => :black
94
+ # contrast_color("#0000d7") # => :white
95
+ #
96
+ # @param color [Symbol, String, Array<Integer>, Integer] a color spec
97
+ # (raw indices 0-15 raise, since their RGB values are theme-dependent)
98
+ # @return [Symbol] `:black` or `:white`
99
+ def self.contrast_color(color)
100
+ luminance = relative_luminance(rgb_for(color))
101
+ white_contrast = 1.05 / (luminance + 0.05)
102
+ black_contrast = (luminance + 0.05) / 0.05
103
+
104
+ (white_contrast >= black_contrast) ? :white : :black
105
+ end
59
106
 
60
- def bright_green
61
- color(92)
107
+ # Resolves a color spec into `[r, g, b]` channel values.
108
+ #
109
+ # @api private
110
+ def self.rgb_for(color)
111
+ case color
112
+ when Symbol then index_to_rgb(PALETTE.fetch(color))
113
+ when Integer then index_to_rgb(color)
114
+ when Array then color
115
+ when String then hex_to_rgb(color)
116
+ else
117
+ raise ArgumentError, "invalid color: #{color.inspect}"
62
118
  end
119
+ end
63
120
 
64
- def bright_yellow
65
- color(93)
121
+ # Converts a 256-color index (16-255) back into `[r, g, b]` values.
122
+ #
123
+ # @api private
124
+ def self.index_to_rgb(index)
125
+ if index.between?(16, 231)
126
+ cube = index - 16
127
+ [cube / 36, (cube % 36) / 6, cube % 6].map { |level| CUBE_LEVELS[level] }
128
+ elsif index.between?(232, 255)
129
+ level = 8 + (10 * (index - 232))
130
+ [level, level, level]
131
+ else
132
+ raise ArgumentError, "color index #{index} is theme-dependent; use 16-255"
66
133
  end
134
+ end
67
135
 
68
- def bright_blue
69
- color(94)
136
+ # WCAG 2 relative luminance of an sRGB color, from 0.0 (black) to 1.0
137
+ # (white). See https://www.w3.org/TR/WCAG20/#relativeluminancedef
138
+ #
139
+ # @api private
140
+ def self.relative_luminance(rgb)
141
+ r, g, b = rgb.map do |channel|
142
+ value = channel / 255.0
143
+ (value <= 0.04045) ? value / 12.92 : ((value + 0.055) / 1.055)**2.4
70
144
  end
71
145
 
72
- def bright_magenta
73
- color(95)
74
- end
146
+ (0.2126 * r) + (0.7152 * g) + (0.0722 * b)
147
+ end
75
148
 
76
- def bright_cyan
77
- color(96)
78
- end
149
+ # Parses `"#rrggbb"`, `"rrggbb"`, or `"#rgb"` into `[r, g, b]` values.
150
+ #
151
+ # @api private
152
+ def self.hex_to_rgb(hex)
153
+ digits = hex.delete_prefix("#")
154
+ digits = digits.each_char.map { |c| c * 2 }.join if digits.length == 3
155
+ digits.scan(/../).map { |pair| pair.to_i(16) }
156
+ end
79
157
 
80
- def bright_white
81
- color(97)
158
+ # Snaps `[r, g, b]` values to the nearest cube/grayscale index.
159
+ #
160
+ # @api private
161
+ def self.rgb_to_index(red, green, blue)
162
+ if red == green && green == blue
163
+ gray_index(red)
164
+ else
165
+ r, g, b = [red, green, blue].map { |channel| nearest_cube_level(channel) }
166
+ 16 + (36 * r) + (6 * g) + b
82
167
  end
168
+ end
83
169
 
84
- # Background colors
170
+ # Index of the cube level (0-5) closest to the given channel value.
171
+ #
172
+ # @api private
173
+ def self.nearest_cube_level(channel)
174
+ CUBE_LEVELS.each_index.min_by { |i| (CUBE_LEVELS[i] - channel).abs }
175
+ end
85
176
 
86
- def on_black
87
- color(40)
88
- end
177
+ # The grayscale ramp (232-255) covers intensities 8, 18, ... 238. Levels
178
+ # near the extremes snap to the cube's black (16) and white (231).
179
+ #
180
+ # @api private
181
+ def self.gray_index(level)
182
+ return 16 if level < 4
183
+ return 231 if level > 243
89
184
 
90
- def on_red
91
- color(41)
92
- end
185
+ 232 + ((level - 8) / 10.0).round.clamp(0, 23)
186
+ end
93
187
 
94
- def on_green
95
- color(42)
96
- end
188
+ refine String do
189
+ # Colors the string's foreground.
190
+ #
191
+ # @example
192
+ # "hello".fg(:red)
193
+ # "hello".fg("#ff8800")
194
+ #
195
+ # @param color [Symbol, String, Array<Integer>, Integer] a color spec
196
+ # (see {StringColors}); `:transparent` replaces the text with spaces
197
+ # @return [String] the string wrapped in escape sequences
198
+ def fg(color)
199
+ return transparent if color == :transparent
97
200
 
98
- def on_yellow
99
- color(43)
201
+ "\e[38;5;#{StringColors.index_for(color)}m#{self}\e[39m"
100
202
  end
101
203
 
102
- def on_blue
103
- color(44)
104
- end
204
+ # Colors the string's background.
205
+ #
206
+ # @example
207
+ # "hello".bg(:cyan)
208
+ # "hello".bg("#222222")
209
+ #
210
+ # @param color [Symbol, String, Array<Integer>, Integer] a color spec
211
+ # (see {StringColors}); `:transparent` keeps the terminal's default
212
+ # background
213
+ # @return [String] the string wrapped in escape sequences
214
+ def bg(color)
215
+ return on_transparent if color == :transparent
105
216
 
106
- def on_magenta
107
- color(45)
217
+ "\e[48;5;#{StringColors.index_for(color)}m#{self}\e[49m"
108
218
  end
109
219
 
110
- def on_cyan
111
- color(46)
112
- end
220
+ # Colors
113
221
 
114
- def on_gray
115
- color(47)
222
+ # Replaces every character with a space.
223
+ #
224
+ # @return [String]
225
+ def transparent
226
+ gsub(/./, " ")
116
227
  end
117
228
 
229
+ # Renders the string on the terminal's default background.
230
+ #
231
+ # @return [String]
118
232
  def on_transparent
119
- color(49)
233
+ "\e[49m#{self}\e[49m"
120
234
  end
121
235
 
122
- def on_bright_black
123
- color(100)
124
- end
125
-
126
- def on_bright_red
127
- color(101)
128
- end
129
-
130
- def on_bright_green
131
- color(102)
132
- end
133
-
134
- def on_bright_yellow
135
- color(103)
136
- end
137
-
138
- def on_bright_blue
139
- color(104)
140
- end
141
-
142
- def on_bright_magenta
143
- color(105)
144
- end
145
-
146
- def on_bright_cyan
147
- color(106)
148
- end
149
-
150
- def on_bright_white
151
- color(107)
236
+ # @!macro [attach] palette_color
237
+ # @!method $1
238
+ # Colors the foreground `$1` (equivalent to `fg(:$1)`).
239
+ # @return [String]
240
+ # @!method on_$1
241
+ # Colors the background `$1` (equivalent to `bg(:$1)`).
242
+ # @return [String]
243
+ PALETTE.each_key do |name|
244
+ define_method(name) { fg(name) }
245
+ define_method("on_#{name}") { bg(name) }
152
246
  end
153
247
 
154
248
  # STYLES
155
249
 
250
+ # @return [String] the string styled bold
156
251
  def bold
157
252
  "\e[1m#{self}\e[22m"
158
253
  end
159
254
 
255
+ # @return [String] the string styled italic
160
256
  def italic
161
257
  "\e[3m#{self}\e[23m"
162
258
  end
163
259
 
260
+ # @return [String] the string underlined
164
261
  def underline
165
262
  "\e[4m#{self}\e[24m"
166
263
  end
167
264
 
265
+ # @return [String] the string styled blinking
168
266
  def blink
169
267
  "\e[5m#{self}\e[25m"
170
268
  end
171
269
 
270
+ # @return [String] the string with foreground and background swapped
172
271
  def reverse_color
173
272
  "\e[7m#{self}\e[27m"
174
273
  end
175
-
176
- private
177
-
178
- def color(n)
179
- "\e[#{n}m#{self}\e[0m"
180
- end
181
274
  end
182
275
  end
183
276
  end
@@ -2,17 +2,32 @@
2
2
 
3
3
  module RichEngine
4
4
  module Terminal
5
+ # Internal plumbing for controlling the terminal cursor: visibility and
6
+ # positioning.
7
+ #
8
+ # @api private
5
9
  module Cursor
6
10
  extend self
7
11
 
12
+ # Hides the cursor.
13
+ #
14
+ # @return [void]
8
15
  def hide
9
16
  system("tput civis")
10
17
  end
11
18
 
19
+ # Shows the cursor.
20
+ #
21
+ # @return [void]
12
22
  def display
13
23
  system("tput cnorm")
14
24
  end
15
25
 
26
+ # Moves the cursor to the given screen position.
27
+ #
28
+ # @param x [Integer] the column to move to
29
+ # @param y [Integer] the row to move to
30
+ # @return [void]
16
31
  def goto(x, y)
17
32
  $stdout.goto(x, y)
18
33
  end
@@ -3,25 +3,44 @@
3
3
  require_relative "terminal/cursor"
4
4
 
5
5
  module RichEngine
6
+ # Internal plumbing used by {Game} to prepare and restore the terminal:
7
+ # clearing the screen, toggling cursor visibility, and toggling input echo.
8
+ #
9
+ # @api private
6
10
  module Terminal
7
11
  module_function
8
12
 
13
+ # Clears the screen.
14
+ #
15
+ # @return [void]
9
16
  def clear
10
17
  $stdout.clear_screen
11
18
  end
12
19
 
20
+ # Hides the terminal cursor.
21
+ #
22
+ # @return [void]
13
23
  def hide_cursor
14
24
  Cursor.hide
15
25
  end
16
26
 
27
+ # Shows the terminal cursor.
28
+ #
29
+ # @return [void]
17
30
  def display_cursor
18
31
  Cursor.display
19
32
  end
20
33
 
34
+ # Stops typed characters from being echoed to the screen.
35
+ #
36
+ # @return [void]
21
37
  def disable_echo
22
38
  $stdin.echo = false
23
39
  end
24
40
 
41
+ # Resumes echoing typed characters to the screen.
42
+ #
43
+ # @return [void]
25
44
  def enable_echo
26
45
  $stdin.echo = true
27
46
  end
@@ -2,13 +2,20 @@
2
2
 
3
3
  module RichEngine
4
4
  class Timer
5
+ # A scheduler that fires at a fixed interval, created via {Timer.every}.
5
6
  class Every
7
+ # @param interval [Integer, Float] seconds between firings.
6
8
  def initialize(interval)
7
9
  @interval = interval
8
10
  @ready = false
9
11
  @timer = Timer.new
10
12
  end
11
13
 
14
+ # Accumulates elapsed time and marks the scheduler ready once the
15
+ # interval is reached.
16
+ #
17
+ # @param elapsed_time [Float] seconds since the last frame.
18
+ # @return [void]
12
19
  def update(elapsed_time)
13
20
  @timer.update(elapsed_time)
14
21
 
@@ -17,6 +24,10 @@ module RichEngine
17
24
  end
18
25
  end
19
26
 
27
+ # Runs the block and resets the timer when the interval has elapsed.
28
+ #
29
+ # @yield called once each time the interval is reached.
30
+ # @return [void]
20
31
  def when_ready(&block)
21
32
  if @ready
22
33
  block.call
@@ -24,6 +35,10 @@ module RichEngine
24
35
  end
25
36
  end
26
37
 
38
+ # Changes the firing interval and resets the timer.
39
+ #
40
+ # @param interval [Integer, Float] the new interval in seconds.
41
+ # @return [void]
27
42
  def interval=(interval)
28
43
  @interval = interval
29
44
  reset!
@@ -1,7 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RichEngine
4
+ # Accumulates elapsed time; drive it by calling {#update} each frame.
4
5
  class Timer
6
+ # Returns a small scheduler that fires a block at a fixed interval.
7
+ #
8
+ # @param seconds [Integer, Float] the interval between firings.
9
+ # @return [RichEngine::Timer::Every] the interval scheduler.
10
+ # @example
11
+ # spawn = RichEngine::Timer.every(seconds: 0.5)
12
+ # spawn.update(dt)
13
+ # spawn.when_ready { spawn_enemy! }
5
14
  def self.every(seconds: 1, &block)
6
15
  Every.new(seconds)
7
16
  end
@@ -10,14 +19,22 @@ module RichEngine
10
19
  @timer = 0
11
20
  end
12
21
 
22
+ # Adds the elapsed time to the accumulated total.
23
+ #
24
+ # @param dt [Float] seconds since the last frame.
25
+ # @return [Float] the new accumulated time.
13
26
  def update(dt)
14
27
  @timer += dt
15
28
  end
16
29
 
30
+ # @return [Float] the accumulated time in seconds.
17
31
  def get
18
32
  @timer
19
33
  end
20
34
 
35
+ # Resets the accumulated time back to zero.
36
+ #
37
+ # @return [Integer] zero.
21
38
  def reset!
22
39
  @timer = 0
23
40
  end
@@ -1,44 +1,76 @@
1
1
  module RichEngine
2
+ # Reusable UI building blocks for games.
2
3
  module UI
4
+ # Convenience glyphs for shading and blocky fills.
3
5
  module Textures
4
6
  extend self
5
7
 
8
+ # The empty glyph (space).
9
+ #
10
+ # @return [String] " "
6
11
  def empty
7
12
  " "
8
13
  end
9
14
 
15
+ # The solid block glyph.
16
+ #
17
+ # @return [String] "█"
10
18
  def solid
11
19
  "█"
12
20
  end
13
21
 
22
+ # The light shade glyph.
23
+ #
24
+ # @return [String] "▓"
14
25
  def light_shade
15
26
  "▓"
16
27
  end
17
28
 
29
+ # The medium shade glyph.
30
+ #
31
+ # @return [String] "▒"
18
32
  def medium_shade
19
33
  "▒"
20
34
  end
21
35
 
36
+ # The dark shade glyph.
37
+ #
38
+ # @return [String] "░"
22
39
  def dark_shade
23
40
  "░"
24
41
  end
25
42
 
43
+ # The upper half block glyph.
44
+ #
45
+ # @return [String] "▀"
26
46
  def top_half
27
47
  "▀"
28
48
  end
29
49
 
50
+ # The lower half block glyph.
51
+ #
52
+ # @return [String] "▄"
30
53
  def bottom_half
31
54
  "▄"
32
55
  end
33
56
 
57
+ # The left half block glyph.
58
+ #
59
+ # @return [String] "▌"
34
60
  def left_half
35
61
  "▌"
36
62
  end
37
63
 
64
+ # The right half block glyph.
65
+ #
66
+ # @return [String] "▐"
38
67
  def right_half
39
68
  "▐"
40
69
  end
41
70
 
71
+ # The plaid (half-shade diagonal) glyph.
72
+ #
73
+ # @return [String] "▞"
42
74
  def plaid
43
75
  "▞"
44
76
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RichEngine
4
- VERSION = "0.0.0"
4
+ # The current gem version.
5
+ VERSION = "0.1.0"
5
6
  end
data/lib/rich_engine.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "rich_engine/animation"
3
4
  require_relative "rich_engine/canvas"
4
5
  require_relative "rich_engine/chance"
5
6
  require_relative "rich_engine/cooldown"
@@ -13,5 +14,12 @@ require_relative "rich_engine/timer/every"
13
14
  require_relative "rich_engine/ui/textures"
14
15
  require_relative "rich_engine/version"
15
16
 
17
+ # A tiny terminal game engine for Ruby. It provides a simple game loop, a 2D
18
+ # character canvas with colors, non-blocking keyboard input, and a handful of
19
+ # helpers (timers, cooldowns, RNG, enums, matrices) so you can ship playful
20
+ # ASCII games quickly.
21
+ #
22
+ # At its core, you subclass {RichEngine::Game}, implement a few lifecycle hooks,
23
+ # and draw to a {RichEngine::Canvas} each frame.
16
24
  module RichEngine
17
25
  end
data/mise.toml CHANGED
@@ -1,2 +1,2 @@
1
1
  [tools]
2
- ruby = "3.4.6"
2
+ ruby = "3.2"
data/rich_engine.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "A Ruby engine for terminal games."
12
12
  spec.homepage = "https://github.com/MatheusRich/rich_engine"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
17
  # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."