worlds-terminal 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []