diff --git a/lib/cyberarm_engine.rb b/lib/cyberarm_engine.rb
index 8edd35a..ecc1343 100644
--- a/lib/cyberarm_engine.rb
+++ b/lib/cyberarm_engine.rb
@@ -30,6 +30,11 @@ require_relative "cyberarm_engine/text"
require_relative "cyberarm_engine/timer"
require_relative "cyberarm_engine/config_file"
+require_relative "cyberarm_engine/console"
+require_relative "cyberarm_engine/console/command"
+require_relative "cyberarm_engine/console/subcommand"
+require_relative "cyberarm_engine/console/commands/help_command"
+
require_relative "cyberarm_engine/ui/dsl"
require_relative "cyberarm_engine/ui/theme"
diff --git a/lib/cyberarm_engine/common.rb b/lib/cyberarm_engine/common.rb
index 2e6ec33..0691098 100644
--- a/lib/cyberarm_engine/common.rb
+++ b/lib/cyberarm_engine/common.rb
@@ -97,5 +97,17 @@ module CyberarmEngine
def window
$window
end
+
+ def control_down?
+ Gosu.button_down?(Gosu::KB_LEFT_CONTROL) || Gosu.button_down?(Gosu::KB_RIGHT_CONTROL)
+ end
+
+ def shift_down?
+ Gosu.button_down?(Gosu::KB_LEFT_SHIFT) || Gosu.button_down?(Gosu::KB_RIGHT_SHIFT)
+ end
+
+ def alt_down?
+ Gosu.button_down?(Gosu::KB_LEFT_ALT) || Gosu.button_down?(Gosu::KB_RIGHT_ALT)
+ end
end
end
diff --git a/lib/cyberarm_engine/console.rb b/lib/cyberarm_engine/console.rb
new file mode 100644
index 0000000..bb23f87
--- /dev/null
+++ b/lib/cyberarm_engine/console.rb
@@ -0,0 +1,248 @@
+# frozen_string_literal: true
+
+module CyberarmEngine
+ class Console
+ Z = 100_000
+ PADDING = 2
+ include Common
+
+ attr_reader :text_input
+
+ def initialize(font: Gosu.default_font_name)
+ @text_input = Gosu::TextInput.new
+ @width = window.width / 4 * 3
+ @height = window.height / 4 * 3
+
+ @input = Text.new("", x: 4, y: @height - (PADDING * 2), z: Console::Z + 1, font: font)
+ @input.y -= @input.height
+
+ @history = Text.new("", x: 4, z: Console::Z + 1, font: font, border: true, border_color: Gosu::Color::BLACK)
+ update_history_y
+
+ @command_history = []
+ @command_history_index = 0
+
+ @memory = ""
+
+ @background_color = Gosu::Color.rgba(0, 0, 0, 200)
+ @foreground_color = Gosu::Color.rgba(100, 100, 100, 100)
+ @input_color = Gosu::Color.rgba(100, 100, 100, 200)
+
+ @showing_cursor = false
+ @active_text_input = nil
+
+ @show_caret = true
+ @caret_last_change = Gosu.milliseconds
+ @caret_interval = 250
+ @caret_color = Gosu::Color::WHITE
+ @selection_color = Gosu::Color.new(0x5522ff22)
+ end
+
+ def draw
+ # Background/Border
+ draw_rect(0, 0, @width, @height, @background_color, Console::Z)
+ # Foregound/History
+ draw_rect(PADDING, PADDING, @width - (PADDING * 2), @height - (PADDING * 2), @foreground_color, Console::Z)
+ # Text bar
+ draw_rect(2, @input.y, @width - (PADDING * 2), @input.height, @input_color, Console::Z)
+
+ @history.draw
+ @input.draw
+ # Caret
+ if @show_caret
+ draw_rect(@input.x + caret_from_left, @input.y, Console::PADDING, @input.height, @caret_color, Console::Z + 2)
+ end
+ # Caret selection
+ if caret_start != caret_end
+ if caret_start < @text_input.selection_start
+ draw_rect(@input.x + caret_from_left, @input.y, caret_selection_width, @input.height, @selection_color, Console::Z)
+ else
+ draw_rect((@input.x + caret_from_left) - caret_selection_width, @input.y, caret_selection_width, @input.height, @selection_color, Console::Z)
+ end
+ end
+ end
+
+ def caret_from_left
+ return 0 if @text_input.caret_pos.zero?
+
+ @input.textobject.text_width(@text_input.text[0..@text_input.caret_pos - 1])
+ end
+
+ def caret_selection_width
+ @input.textobject.text_width(@text_input.text[caret_start..(caret_end - 1)])
+ end
+
+ def caret_pos
+ @text_input.caret_pos
+ end
+
+ def caret_start
+ @text_input.selection_start < @text_input.caret_pos ? @text_input.selection_start : @text_input.caret_pos
+ end
+
+ def caret_end
+ @text_input.selection_start > @text_input.caret_pos ? @text_input.selection_start : @text_input.caret_pos
+ end
+
+ def update
+ if Gosu.milliseconds - @caret_last_change >= @caret_interval
+ @caret_last_change = Gosu.milliseconds
+ @show_caret = !@show_caret
+ end
+
+ if @width != window.width || @height != @height
+ @width = window.width / 4 * 3
+ @height = window.height / 4 * 3
+
+ @input.y = @height - (PADDING * 2 + @input.height)
+ update_history_y
+ end
+
+ @input.text = @text_input.text
+ end
+
+ def button_down(id)
+ case id
+ when Gosu::KbEnter, Gosu::KbReturn
+ return unless @text_input.text.length.positive?
+
+ @history.text += "\n> #{@text_input.text}"
+ @command_history << @text_input.text
+ @command_history_index = @command_history.size
+ update_history_y
+ handle_command
+ @text_input.text = ""
+
+ when Gosu::KbUp
+ @command_history_index -= 1
+ @command_history_index = 0 if @command_history_index.negative?
+ @text_input.text = @command_history[@command_history_index]
+
+ when Gosu::KbDown
+ @command_history_index += 1
+ if @command_history_index > @command_history.size - 1
+ @text_input.text = "" unless @command_history_index > @command_history.size
+ @command_history_index = @command_history.size
+ else
+ @text_input.text = @command_history[@command_history_index]
+ end
+
+ when Gosu::KbTab
+ split = @text_input.text.split(" ")
+
+ if !@text_input.text.end_with?(" ") && split.size == 1
+ list = abbrev_search(Console::Command.list_commands.map { |cmd| cmd.command.to_s }, @text_input.text)
+
+ if list.size == 1
+ @text_input.text = "#{list.first} "
+ elsif list.size.positive?
+ stdin("\n#{list.map { |cmd| Console::Style.highlight(cmd) }.join(', ')}")
+ end
+ elsif split.size.positive? && cmd = Console::Command.find(split.first)
+ cmd.autocomplete(self)
+ end
+
+ when Gosu::KbBacktick
+ # Remove backtick character from input
+ @text_input.text = if @text_input.text.size > 1
+ @text_input.text[0..@text_input.text.size - 2]
+ else
+ ""
+ end
+
+ # Copy
+ when Gosu::KbC
+ if control_down? && shift_down?
+ @memory = @text_input.text[caret_start..caret_end - 1] if caret_start != caret_end
+ p @memory
+ elsif control_down?
+ @text_input.text = ""
+ end
+
+ # Paste
+ when Gosu::KbV
+ if control_down? && shift_down?
+ string = @text_input.text.chars.insert(caret_pos, @memory).join
+ _caret_pos = caret_pos
+ @text_input.text = string
+ @text_input.caret_pos = _caret_pos + @memory.length
+ @text_input.selection_start = _caret_pos + @memory.length
+ end
+
+ # Cut
+ when Gosu::KbX
+ if control_down? && shift_down?
+ @memory = @text_input.text[caret_start..caret_end - 1] if caret_start != caret_end
+ string = @text_input.text.chars
+ Array(caret_start..caret_end - 1).each_with_index do |i, j|
+ string.delete_at(i - j)
+ end
+
+ @text_input.text = string.join
+ end
+
+ # Delete word to left of caret
+ when Gosu::KbW
+ if control_down?
+ split = @text_input.text.split(" ")
+ split.delete(split.last)
+ @text_input.text = split.join(" ")
+ end
+
+ # Clear history
+ when Gosu::KbL
+ @history.text = "" if control_down?
+ end
+ end
+
+ def button_up(id)
+ end
+
+ def update_history_y
+ @history.y = @height - (PADDING * 2) - @input.height - (@history.text.lines.count * @history.textobject.height)
+ end
+
+ def handle_command
+ string = @text_input.text
+ split = string.split(" ")
+ command = split.first
+ arguments = split.length.positive? ? split[1..split.length - 1] : []
+
+ CyberarmEngine::Console::Command.use(command, arguments, self)
+ end
+
+ def abbrev_search(array, text)
+ return [] unless text.length.positive?
+
+ list = []
+ Abbrev.abbrev(array).each do |abbrev, value|
+ next unless abbrev&.start_with?(text)
+
+ list << value
+ end
+
+ list.uniq
+ end
+
+ def stdin(string)
+ @history.text += "\n#{string}"
+ update_history_y
+ end
+
+ def focus
+ @active_text_input = window.text_input
+ window.text_input = @text_input
+
+ @showing_cursor = window.needs_cursor
+ window.needs_cursor = true
+
+ @show_caret = true
+ @caret_last_change = Gosu.milliseconds
+ end
+
+ def blur
+ window.text_input = @active_text_input
+ window.needs_cursor = @showing_cursor
+ end
+ end
+end
diff --git a/lib/cyberarm_engine/console/command.rb b/lib/cyberarm_engine/console/command.rb
new file mode 100644
index 0000000..01e1aaf
--- /dev/null
+++ b/lib/cyberarm_engine/console/command.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+module CyberarmEngine
+ class Console
+ module Style
+ def self.error(string)
+ "#{string}"
+ end
+
+ def self.warn(string)
+ "#{string}"
+ end
+
+ def self.notice(string)
+ "#{string}"
+ end
+
+ def self.highlight(string, color = "5555ff")
+ "#{string}"
+ end
+ end
+
+ class Command
+ def self.inherited(subclass)
+ @list ||= []
+ @commands ||= []
+ @list << subclass
+ end
+
+ def self.setup
+ @list ||= []
+ @commands = []
+ @list.each do |subclass|
+ cmd = subclass.new
+ if @commands.detect { |c| c.command == cmd.command }
+ raise "Command '#{cmd.command}' from '#{cmd.class}' already exists!"
+ end
+
+ @commands << cmd
+ end
+ end
+
+ def self.use(command, arguments, console)
+ found_command = @commands.detect { |cmd| cmd.command == command.to_sym }
+
+ if found_command
+ found_command.handle(arguments, console)
+ else
+ console.stdin("Command #{Style.error(command)} not found.")
+ end
+ end
+
+ def self.find(command)
+ @commands.detect { |cmd| cmd.command == command.to_sym }
+ end
+
+ def self.list_commands
+ @commands
+ end
+
+ def initialize
+ @store = {}
+ @subcommands = []
+
+ setup
+ end
+
+ def setup
+ end
+
+ def subcommand(command, type)
+ if @subcommands.detect { |subcmd| subcmd.command == command.to_sym }
+ raise "Subcommand '#{command}' for '#{self.command}' already exists!"
+ end
+
+ @subcommands << SubCommand.new(self, command, type)
+ end
+
+ def get(key)
+ @store[key]
+ end
+
+ def set(key, value)
+ @store[key] = value
+ end
+
+ def group
+ raise NotImplementedError
+ end
+
+ def command
+ raise NotImplementedError
+ end
+
+ def handle(arguments, console)
+ raise NotImplementedError
+ end
+
+ def autocomplete(console)
+ split = console.text_input.text.split(" ")
+
+ if @subcommands.size.positive?
+ if !console.text_input.text.end_with?(" ") && split.size == 2
+ list = console.abbrev_search(@subcommands.map { |cmd| cmd.command.to_s }, split.last)
+
+ if list.size == 1
+ console.text_input.text = "#{split.first} #{list.first} "
+ else
+ return unless list.size.positive?
+
+ console.stdin(list.map { |cmd| Console::Style.highlight(cmd) }.join(", ").to_s)
+ end
+
+ # List available options on subcommand
+ elsif (console.text_input.text.end_with?(" ") && split.size == 2) || !console.text_input.text.end_with?(" ") && split.size == 3
+ subcommand = @subcommands.detect { |cmd| cmd.command.to_s == (split[1]) }
+
+ if subcommand
+ if split.size == 2
+ console.stdin("Available options: #{subcommand.values.map { |value| Console::Style.highlight(value) }.join(',')}")
+ else
+ list = console.abbrev_search(subcommand.values, split.last)
+ if list.size == 1
+ console.text_input.text = "#{split.first} #{split[1]} #{list.first} "
+ elsif list.size.positive?
+ console.stdin("Available options: #{list.map { |value| Console::Style.highlight(value) }.join(',')}")
+ end
+ end
+ end
+
+ # List available subcommands if command was entered and has only a space after it
+ elsif console.text_input.text.end_with?(" ") && split.size == 1
+ console.stdin("Available subcommands: #{@subcommands.map { |cmd| Console::Style.highlight(cmd.command) }.join(', ')}")
+ end
+ end
+ end
+
+ def handle_subcommand(arguments, console)
+ if arguments.size.zero?
+ console.stdin(usage)
+ return
+ end
+ subcommand = arguments.delete_at(0)
+
+ found_command = @subcommands.detect { |cmd| cmd.command == subcommand.to_sym }
+ if found_command
+ found_command.handle(arguments, console)
+ else
+ console.stdin("Unknown subcommand #{Style.error(subcommand)} for #{Style.highlight(command)}")
+ end
+ end
+
+ def usage
+ raise NotImplementedError
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/cyberarm_engine/console/commands/help_command.rb b/lib/cyberarm_engine/console/commands/help_command.rb
new file mode 100644
index 0000000..38a9c67
--- /dev/null
+++ b/lib/cyberarm_engine/console/commands/help_command.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module CyberarmEngine
+ class Console
+ class HelpCommand < CyberarmEngine::Console::Command
+ def group
+ :global
+ end
+
+ def command
+ :help
+ end
+
+ def handle(arguments, console)
+ console.stdin(usage(arguments.first))
+ end
+
+ def autocomplete(console)
+ split = console.text_input.text.split(" ")
+ if !console.text_input.text.start_with?(" ") && split.size == 2
+ list = console.abbrev_search(Command.list_commands.map { |cmd| cmd.command.to_s }, split.last)
+ if list.size == 1
+ console.text_input.text = "#{split.first} #{list.first} "
+ elsif list.size > 1
+ console.stdin(list.map { |cmd| Style.highlight(cmd) }.join(", "))
+ end
+ end
+ end
+
+ def usage(command = nil)
+ if command
+ if cmd = Command.find(command)
+ cmd.usage
+ else
+ "#{Style.error(command)} is not a command"
+ end
+ else
+ "Available commands:\n#{Command.list_commands.map { |cmd| Style.highlight(cmd.command).to_s }.join(', ')}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/cyberarm_engine/console/subcommand.rb b/lib/cyberarm_engine/console/subcommand.rb
new file mode 100644
index 0000000..97f1fcc
--- /dev/null
+++ b/lib/cyberarm_engine/console/subcommand.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+module CyberarmEngine
+ class Console
+ class Command
+ class SubCommand
+ def initialize(parent, command, type)
+ @parent = parent
+ @command = command
+ @type = type
+ end
+
+ attr_reader :command
+
+ def handle(arguments, console)
+ if arguments.size > 1
+ console.stdin("to many arguments for #{Style.highlight(command.to_s)}, got #{Style.error(arguments.size)} expected #{Style.notice(1)}.")
+ return
+ end
+
+ case @type
+ when :boolean
+ case arguments.last
+ when "", nil
+ var = @parent.get(command.to_sym) || false
+ console.stdin("#{command}: #{Style.highlight(var)}")
+ when "on"
+ var = @parent.set(command.to_sym, true)
+ console.stdin("#{command} => #{Style.highlight(var)}")
+ when "off"
+ var = @parent.set(command.to_sym, false)
+ console.stdin("#{command} => #{Style.highlight(var)}")
+ else
+ console.stdin("Invalid argument for #{Style.highlight(command.to_s)}, got #{Style.error(arguments.last)} expected #{Style.notice('on')}, or #{Style.notice('off')}.")
+ end
+ when :string
+ case arguments.last
+ when "", nil
+ var = @parent.get(command.to_sym) || "\"\""
+ console.stdin("#{command}: #{Style.highlight(var)}")
+ else
+ var = @parent.set(command.to_sym, arguments.last)
+ console.stdin("#{command} => #{Style.highlight(var)}")
+ end
+ when :integer
+ case arguments.last
+ when "", nil
+ var = @parent.get(command.to_sym) || "nil"
+ console.stdin("#{command}: #{Style.highlight(var)}")
+ else
+ begin
+ var = @parent.set(command.to_sym, Integer(arguments.last))
+ console.stdin("#{command} => #{Style.highlight(var)}")
+ rescue ArgumentError
+ console.stdin("Error: #{Style.error("Expected an integer, got '#{arguments.last}'")}")
+ end
+ end
+ when :decimal
+ case arguments.last
+ when "", nil
+ var = @parent.get(command.to_sym) || "nil"
+ console.stdin("#{command}: #{Style.highlight(var)}")
+ else
+ begin
+ var = @parent.set(command.to_sym, Float(arguments.last))
+ console.stdin("#{command} => #{Style.highlight(var)}")
+ rescue ArgumentError
+ console.stdin("Error: #{Style.error("Expected a decimal or integer, got '#{arguments.last}'")}")
+ end
+ end
+ else
+ raise RuntimeError
+ end
+ end
+
+ def values
+ case @type
+ when :boolean
+ %w[on off]
+ else
+ []
+ end
+ end
+
+ def usage
+ case @type
+ when :boolean
+ "#{Style.highlight(command)} #{Style.notice('[on|off]')}"
+ when :string
+ "#{Style.highlight(command)} #{Style.notice('[string]')}"
+ when :integer
+ "#{Style.highlight(command)} #{Style.notice('[0]')}"
+ when :decimal
+ "#{Style.highlight(command)} #{Style.notice('[0.0]')}"
+ end
+ end
+ end
+ end
+ end
+end
\ No newline at end of file