worlds-terminal 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5f66773252b9e9c4dde7a90174f3715e8be7cb32ba46bf88833698f631aa8a71
4
+ data.tar.gz: 20ef4d48c714b41cf58b236d3368b80357a212b44bf0f7ed5b23ab8b1e930733
5
+ SHA512:
6
+ metadata.gz: 221cf441e1d4a20f2f86de54c71fbe3db670e9db1f0f7888a92749e2988cdc1bcd04404cf1df8bf9a1509c0e0413a085cac0f2b8180b56606368886ee7f662cc
7
+ data.tar.gz: 0c0f66fb15ae29187705e1360040da4113777cb044bf6c2baea029fc230eb9d4e5a2c361fd48defc0d36ac0b33e06381073438ca204f730bdc51e154d2830f62
data/bin/worlds ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Runs the game.
4
+
5
+ require_relative '../lib/worlds/terminal/helper'
6
+ require_relative '../lib/worlds/terminal/runner'
7
+
8
+ # Not namespaced as Worlds::Terminal because the Worlds module will eventually be
9
+ # split into its own gem, which can be used by more than this terminal interface
10
+ # (e.g. a web interface).
11
+ module Worlds
12
+ module Terminal
13
+ Helper.io_mode_raw!
14
+ Helper.hide_cursor!
15
+
16
+ begin
17
+ Runner.io_loop
18
+ ensure
19
+ Helper.show_cursor!
20
+ Helper.io_mode_normal!
21
+ end
22
+ end
23
+ end
24
+
@@ -0,0 +1,21 @@
1
+ module Worlds
2
+ class Area
3
+ attr_reader :name, :entities, :linked_areas
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ @entities = []
8
+ @linked_areas = []
9
+ end
10
+
11
+ def update(ms)
12
+ entities.flat_map { |entity|
13
+ entity.update(ms)
14
+ }
15
+ end
16
+
17
+ def to_s
18
+ name
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ module Worlds
2
+ module Components
3
+ class Component
4
+ attr_reader :owner
5
+ attr_accessor :commands
6
+
7
+ def initialize(entity, commands: [])
8
+ @owner = entity
9
+ @commands = commands
10
+ end
11
+
12
+ def select_heading
13
+ "Select an option:"
14
+ end
15
+
16
+ def select_options
17
+ []
18
+ end
19
+
20
+ def select
21
+ return nil unless select_options.any?
22
+
23
+ {
24
+ type: :select,
25
+ heading: select_heading,
26
+ options: select_options.map(&:to_s),
27
+ }
28
+ end
29
+
30
+ def invoke
31
+ end
32
+
33
+ def update(ms)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,10 @@
1
+ module Worlds
2
+ module Components
3
+ class Ticker < Component
4
+ def update(ms)
5
+ @tick = !@tick
6
+ { color: :blue, content: @tick ? 'Tick!' : 'Tock!' }
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ module Worlds
2
+ module Components
3
+ class Travel < Component
4
+ def select_heading
5
+ "Choose a destination:"
6
+ end
7
+
8
+ def select_options
9
+ owner.area.linked_areas
10
+ end
11
+
12
+ # @param target [Area, Integer] an Area, or the index of an Area in the
13
+ # owner's linked areas.
14
+ def invoke(target)
15
+ target = target.is_a?(Area) ? target : owner.area.linked_areas[target]
16
+
17
+ owner.area&.entities&.delete(owner)
18
+ target.entities << owner
19
+ owner.area = target
20
+
21
+ { color: :green, content: "You are now in #{target.name}" }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Worlds
2
+ class Entity
3
+ attr_reader :components
4
+ attr_accessor :area
5
+
6
+ class << self
7
+ attr_accessor :player
8
+ end
9
+
10
+ def initialize
11
+ @components = []
12
+ end
13
+
14
+ def update(ms)
15
+ components.flat_map { |component|
16
+ component.update(ms)
17
+ }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ module Worlds
2
+ class SpecialCommands
3
+ ACTIONS = {
4
+ exit: -> {
5
+ [{ color: :white, content: "Exiting..." },
6
+ { type: :exit }]
7
+ },
8
+ }
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ module Worlds
2
+ module Terminal
3
+ # Utility methods for allowing output above the input line in the terminal.
4
+ class Helper
5
+ # To allow output above the input line, disables input buffering.
6
+ def self.io_mode_raw! = `stty raw`
7
+ def self.io_mode_normal! = `stty -raw`
8
+
9
+ # From https://stackoverflow.com/a/50152099
10
+ # If the cursor weren't hidden, it would appear at the beginning of the line
11
+ # due to ::io_mode_raw!
12
+ def self.hide_cursor! = print "\033[?25l"
13
+ def self.show_cursor! = print "\033[?25h"
14
+
15
+ # Reads newly inputted characters in a way that doesn't block output,
16
+ # to allow output above the input line. Based on https://stackoverflow.com/a/9900628
17
+ # @return [String] all inputted characters.
18
+ def self.read_nonblock
19
+ line = ''
20
+
21
+ while char = STDIN.read_nonblock(1, exception: false)
22
+ return line if char == :wait_readable
23
+ line << char
24
+ end
25
+ end
26
+
27
+ # To allow output above the input line, wraps `puts` in a change to the
28
+ # terminal mode. Also right-pads the output with spaces to prevent the input
29
+ # from "bleeding over" into output wherever an output line is shorter than a
30
+ # line being inputted.
31
+ # @parmam str [String] The string to print.
32
+ def self.puts(str)
33
+ # From https://gist.github.com/KINGSABRI/4687864
34
+ terminal_width = `tput cols`.to_i
35
+
36
+ io_mode_normal!
37
+ Kernel.puts str.ljust(terminal_width, ' ')
38
+ io_mode_raw!
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ require 'pastel'
2
+ require_relative 'helper'
3
+ require_relative 'updater'
4
+
5
+ module Worlds
6
+ module Terminal
7
+ # A container for the input loop, which is needed because input is read in
8
+ # a non-blocking way, i.e. input is read while new output is displayed.
9
+ class Runner
10
+ PASTEL = Pastel.new
11
+ CURSOR = '█'
12
+ BACKSPACE = "\x7F"
13
+ CTRL_BACKSPACE = "\x17" # or Cmd+Backspace on MacOS
14
+ INTERRUPT = "\x03" # Ctrl+C
15
+
16
+ # Loops continuously, reading input and allowing Worlds to update and output.
17
+ def self.io_loop
18
+ loop do
19
+ # We need our own input buffer here because the terminal input buffer is
20
+ # disabled due to Helper::io_mode_raw!
21
+ @input_buffer ||= ''
22
+
23
+ new_input = Helper.read_nonblock
24
+
25
+ if new_input
26
+ return if new_input.include?(INTERRUPT)
27
+
28
+ # Handle Enter.
29
+ new_input_has_newline = new_input.include?("\n") || new_input.include?("\r")
30
+ new_input = new_input.split(/[\n\r]/).first if new_input_has_newline
31
+
32
+ # Add new input to buffer (or add nothing, if no new input).
33
+ @input_buffer << (new_input || '')
34
+
35
+ # Handle deletion: Ctrl + Backspace (line), or Backspace (character).
36
+ # In either case, re-print the input buffer with spaces at the end
37
+ # to cover over the deleted characters.
38
+ if @input_buffer.include?(CTRL_BACKSPACE)
39
+ print "#{CURSOR}#{' ' * @input_buffer.length}\r"
40
+ @input_buffer = ''
41
+ else
42
+ backspace_count = @input_buffer.count(BACKSPACE)
43
+
44
+ while @input_buffer.length > 0 && @input_buffer.include?(BACKSPACE)
45
+ @input_buffer.sub!(/[^#{BACKSPACE}]#{BACKSPACE}/, '')
46
+ @input_buffer = '' if @input_buffer.chars.uniq == [BACKSPACE]
47
+ end
48
+
49
+ print "#{@input_buffer}#{CURSOR}#{' ' * backspace_count}\r"
50
+ end
51
+
52
+ # Echo input. The \r is to make the line replaceable by new output,
53
+ # while the input line will re-appear below the new output; in effect,
54
+ # to allow output above the input line.
55
+ print "#{@input_buffer}#{CURSOR}\r"
56
+ end
57
+
58
+ # Empty the input buffer if Enter was pressed.
59
+ if new_input_has_newline
60
+ input_line = @input_buffer.strip
61
+ @input_buffer = ''
62
+ end
63
+
64
+ # If a line was just inputted, set up an input hash as either a number
65
+ # selection (if a selection menu was just shown) or else a new command.
66
+ if input_line
67
+ if @select_for && input_line.match?(/\A\d+\z/)
68
+ input = { type: :select, command: @select_for, selection: input_line.to_i }
69
+ @select_for = nil
70
+ else
71
+ input = { type: :command, command: input_line }
72
+ end
73
+ end
74
+
75
+ # Allow Worlds to loop, and print outputs if any.
76
+ if outputs = Worlds::Updater.tick(input)
77
+ outputs.each do |output|
78
+ case output[:type]
79
+ when :exit
80
+ return
81
+ when :select # selection menu
82
+ Helper.puts PASTEL.white(output[:heading])
83
+ output[:options].each.with_index do |option, i|
84
+ Helper.puts PASTEL.blue("#{i + 1}. ") + PASTEL.white(option)
85
+ end
86
+
87
+ @select_for = input_line
88
+ else # informational
89
+ Helper.puts PASTEL.send(output[:color], output[:content])
90
+ end
91
+ end
92
+ end
93
+
94
+ # Reset input, to remain empty until next time Enter is pressed.
95
+ input_line = nil && input = nil if input_line
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ require_relative '../special_commands'
2
+ require_relative '../world'
3
+
4
+ module Worlds
5
+ # A container for ::tick, which processes input and updates the world state.
6
+ class Updater
7
+ UPDATES_PER_SECOND = 1
8
+
9
+ # Whether the world has been started.
10
+ # @return [Boolean]
11
+ def self.started?
12
+ !!@time_start
13
+ end
14
+
15
+ # Processes input if any, or performs an update if enough time has passed.
16
+ # @param input [Hash] a line of input from the player.
17
+ # @return [Array<Hash>, nil] an array of output hashes, if input was processed
18
+ # or an update was performed.
19
+ def self.tick(input = nil)
20
+ unless started?
21
+ # On why not Time.now, see
22
+ # https://blog.dnsimple.com/2018/03/elapsed-time-with-ruby-the-right-way
23
+ @time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ return World.setup
26
+ end
27
+
28
+ return process_input(input) if input
29
+
30
+ time_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ time_elapsed = time_now - @time_start
32
+
33
+ if time_elapsed >= (1.0 / UPDATES_PER_SECOND)
34
+ @time_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
+ return update
36
+ end
37
+ end
38
+
39
+ # Processes input and returns output in response.
40
+ # @param input [Hash] a line of input from the player.
41
+ # @return [Array<Hash>, nil] an array of output hashes.
42
+ private_class_method def self.process_input(input)
43
+ outputs = []
44
+
45
+ if action = SpecialCommands::ACTIONS[input[:command].to_sym]
46
+ outputs += action.call
47
+ else
48
+ outputs += [World.input(input)].flatten.compact
49
+ end
50
+
51
+ if outputs.empty?
52
+ outputs << { color: :red, content: "Invalid command: #{input[:command]}" }
53
+ end
54
+
55
+ outputs
56
+ end
57
+
58
+ # Updates the world state and returns any resulting output.
59
+ # @return [Array<Hash>, nil] an array of output hashes.
60
+ private_class_method def self.update
61
+ outputs = []
62
+
63
+ ms = 1000 / UPDATES_PER_SECOND.round(2)
64
+ outputs += World.update(ms)
65
+
66
+ outputs
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ module Worlds
2
+ module Terminal
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
@@ -0,0 +1,87 @@
1
+ require_relative 'entity'
2
+ require_relative 'components/component'
3
+ require_relative 'components/travel'
4
+ require_relative 'components/ticker'
5
+ require_relative 'area'
6
+
7
+ module Worlds
8
+ class World
9
+ attr_reader :areas
10
+
11
+ class << self
12
+ attr_reader :instance
13
+ end
14
+
15
+ def self.setup
16
+ @instance = new
17
+ @instance.demo_setup
18
+ end
19
+
20
+ def self.input(input)
21
+ instance.input(input)
22
+ end
23
+
24
+ def self.update(ms)
25
+ instance.update(ms)
26
+ end
27
+
28
+ def initialize
29
+ @areas = []
30
+ end
31
+
32
+ def input(input)
33
+ Entity.player.components.each do |component|
34
+ if component.commands.include? input[:command]
35
+ if input[:type] == :select
36
+ selection_index = input[:selection] - 1
37
+
38
+ if selection_index >= component.select_options.count
39
+ return { color: :red, content: "Invalid selection" }
40
+ end
41
+
42
+ return component.invoke(selection_index)
43
+ else
44
+ return [component.select] || [component.invoke]
45
+ end
46
+ end
47
+ end
48
+
49
+ nil
50
+ end
51
+
52
+ def update(ms)
53
+ outputs = []
54
+
55
+ areas.each { |area|
56
+ area_outputs = area.update(ms)
57
+ outputs = area_outputs.compact if area == Entity.player.area
58
+ }
59
+
60
+ return outputs
61
+ end
62
+
63
+ def demo_setup
64
+ hero = Entity.new
65
+ hero.components << Components::Travel.new(hero, commands: %w[travel t go leave])
66
+ Entity.player = hero
67
+
68
+ clock = Entity.new
69
+ clock.components << Components::Ticker.new(clock)
70
+
71
+ street = Area.new("a quiet street")
72
+ street.entities << hero
73
+
74
+ shop = Area.new("Rolf's Clock Shop")
75
+ shop.linked_areas << street
76
+ shop.entities << clock
77
+ street.linked_areas << shop
78
+
79
+ @areas << street
80
+ @areas << shop
81
+
82
+ output = Entity.player.components.find { _1.is_a? Components::Travel }.invoke(street)
83
+
84
+ [output]
85
+ end
86
+ end
87
+ end
metadata ADDED
@@ -0,0 +1,159 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: worlds-terminal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Felipe Vogel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-03-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: pastel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: debug
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.7'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest-reporters
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: shoulda-context
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pretty-diffs
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubycritic
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '4.7'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '4.7'
111
+ description:
112
+ email:
113
+ - fps.vogel@gmail.com
114
+ executables:
115
+ - worlds
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - bin/worlds
120
+ - lib/worlds/area.rb
121
+ - lib/worlds/components/component.rb
122
+ - lib/worlds/components/ticker.rb
123
+ - lib/worlds/components/travel.rb
124
+ - lib/worlds/entity.rb
125
+ - lib/worlds/special_commands.rb
126
+ - lib/worlds/terminal/helper.rb
127
+ - lib/worlds/terminal/runner.rb
128
+ - lib/worlds/terminal/updater.rb
129
+ - lib/worlds/terminal/version.rb
130
+ - lib/worlds/world.rb
131
+ homepage: https://github.com/fpsvogel/worlds-terminal
132
+ licenses:
133
+ - MIT
134
+ metadata:
135
+ allowed_push_host: https://rubygems.org
136
+ homepage_uri: https://github.com/fpsvogel/worlds-terminal
137
+ source_code_uri: https://github.com/fpsvogel/worlds-terminal
138
+ changelog_uri: https://github.com/fpsvogel/worlds-terminal/blob/master/CHANGELOG.md
139
+ post_install_message:
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: 3.0.0
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubygems_version: 3.5.6
155
+ signing_key:
156
+ specification_version: 4
157
+ summary: A command-line interface for Worlds, a text-based world simulation and role-playing
158
+ game toolkit.
159
+ test_files: []