diff --git a/.gitignore b/.gitignore index db869a5..1776656 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ pkg/* data/**/*.json data/settings.json data/simulator.rb -data/*.csv \ No newline at end of file +data/*.csv +media/sounds/* +!media/sounds/.gitkeep +media/particles/* +!media/particles/.gitkeep +media/music/* +!media/music/.gitkeep \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..ee86e6a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,28 @@ +GEM + remote: https://rubygems.org/ + specs: + clipboard (1.3.6) + cyberarm_engine (0.19.1) + clipboard (~> 1.3.5) + excon (~> 0.78.0) + gosu (~> 1.1) + gosu_more_drawables (~> 0.3) + excon (0.78.1) + ffi (1.15.4) + gosu (1.2.0) + gosu_more_drawables (0.3.1) + gosu_notifications (0.1.0) + ocra (1.3.11) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + clipboard + cyberarm_engine + ffi + gosu_notifications + ocra + +BUNDLED WITH + 2.2.19 diff --git a/lib/game_clock/clock.rb b/lib/game_clock/clock.rb new file mode 100644 index 0000000..507d699 --- /dev/null +++ b/lib/game_clock/clock.rb @@ -0,0 +1,70 @@ +module TAC + class PracticeGameClock + class Clock + CLOCK_SIZE = Gosu.screen_height + TITLE_SIZE = 128 + + attr_reader :title + + def initialize + @title = CyberarmEngine::Text.new("FIRST TECH CHALLENGE", size: TITLE_SIZE, text_shadow: true, y: 10, color: Gosu::Color::GRAY) + @title.x = $window.width / 2 - @title.width / 2 + + @text = CyberarmEngine::Text.new(":1234567890", size: CLOCK_SIZE, text_shadow: true, shadow_size: 2, shadow_color: Gosu::Color::GRAY) + @text.width # trigger font-eager loading + + @title.z, @text.z = -1, -1 + + @controller = nil + end + + def controller=(controller) + @controller = controller + end + + def draw + @title.draw + @text.draw + end + + def update + @title.x = $window.width / 2 - @title.width / 2 + + if @controller + @text.color = @controller.display_color + @text.text = clock_time(@controller.time_left) + else + @text.color = Gosu::Color::WHITE + @text.text = "0:00" + end + + @text.x = $window.width / 2 - @text.textobject.text_width("0:00") / 2 + @text.y = $window.height / 2 - @text.height / 2 + + @controller&.update + end + + def active? + if @controller + @controller.clock? || @controller.countdown? + else + false + end + end + + def value + @text.text + end + + def clock_time(time_left) + minutes = ((time_left + 0.5) / 60.0).floor + + seconds = time_left.round % 60 + seconds = "0#{seconds}" if seconds < 10 + + return "#{minutes}:#{seconds}" if time_left.round.even? + return "#{minutes}:#{seconds}" if time_left.round.odd? + end + end + end +end diff --git a/lib/game_clock/clock_controller.rb b/lib/game_clock/clock_controller.rb new file mode 100644 index 0000000..2e87cdc --- /dev/null +++ b/lib/game_clock/clock_controller.rb @@ -0,0 +1,136 @@ +module TAC + class PracticeGameClock + class ClockController + Event = Struct.new(:event, :trigger_after, :arguments) + + include EventHandlers + + def self.create_event(event, trigger_after, arguments = nil) + arguments = [arguments] unless arguments.is_a?(Array) || arguments == nil + + Event.new(event, trigger_after, arguments) + end + + AUTONOMOUS = [ + create_event(:change_clock, 0.0, "2:30"), + create_event(:change_countdown, 0.0, "0:03"), + create_event(:change_color, 0.0, :red), + create_event(:change_display, 0.0, :countdown), + create_event(:start_countdown, 0.0), + create_event(:play_sound, 0.0, :autonomous_countdown), + create_event(:change_color, 3.0, :white), + create_event(:change_display, 3.0, :clock), + create_event(:play_sound, 3.0, :autonomous_start), + create_event(:change_display, 3.0, :clock), + create_event(:stop_countdown, 3.0), + create_event(:start_clock, 3.0), + create_event(:play_sound, 33.0, :autonomous_ended), + create_event(:stop_clock, 33.0), + ].freeze + + PRE_TELEOP = [ + create_event(:change_color, 33.0, :orange), + create_event(:change_countdown, 33.0, "0:08"), + create_event(:change_display, 33.0, :countdown), + create_event(:start_countdown, 33.0), + create_event(:play_sound, 34.5, :teleop_pickup_controllers), + create_event(:change_color, 37.0, :red), + create_event(:play_sound, 38.0, :teleop_countdown), + create_event(:stop_countdown, 41.0), + ].freeze + + TELEOP_ENDGAME = [ + create_event(:change_color, 131.0, :white), + create_event(:change_clock, 131.0, "0:30"), + create_event(:start_clock, 131.0), + create_event(:play_sound, 131.0, :end_game), + create_event(:play_sound, 158.0, :autonomous_countdown), + create_event(:play_sound, 161.0, :end_match), + create_event(:stop_clock, 161.0), + ].freeze + + TELEOP = [ + create_event(:change_color, 41.0, :white), + create_event(:change_clock, 41.0, "2:00"), + create_event(:play_sound, 41.0, :teleop_started), + create_event(:change_display, 41.0, :clock), + create_event(:start_clock, 41.0), + TELEOP_ENDGAME + ].flatten.freeze + + FULL_TELEOP = [ + PRE_TELEOP, + TELEOP, + TELEOP_ENDGAME, + ].flatten.freeze + + FULL_MATCH = [ + # Autonomous + AUTONOMOUS, + FULL_TELEOP + ].flatten.freeze + + attr_reader :display_color + def initialize(elapsed_time = 0, events = []) + @events = events.dup + @last_update = Gosu.milliseconds + + @elapsed_time = elapsed_time + @display = :clock + + @clock_time = 0.0 + @countdown_time = 0.0 + + @clock_running = false + @countdown_running = false + + @display_color = Gosu::Color::WHITE + end + + def update + dt = (Gosu.milliseconds - @last_update) / 1000.0 + update_active_timer(dt) + + @events.select { |event| event.trigger_after <= @elapsed_time }.each do |event| + @events.delete(event) + + if event.arguments + self.send(event.event, *event.arguments) + else + self.send(event.event) + end + end + + @last_update = Gosu.milliseconds + end + + def update_active_timer(dt) + if @clock_running + @clock_time -= dt + elsif @countdown_running + @countdown_time -= dt + end + + @elapsed_time += dt + end + + def clock? + @clock_running + end + + def countdown? + @countdown_running + end + + def time_left + if @clock_running + return @clock_time + elsif @countdown_running + return @countdown_time + else + return 60 * 2 + 30 + end + end + end + end +end diff --git a/lib/game_clock/clock_proxy.rb b/lib/game_clock/clock_proxy.rb new file mode 100644 index 0000000..684890f --- /dev/null +++ b/lib/game_clock/clock_proxy.rb @@ -0,0 +1,100 @@ +module TAC + class PracticeGameClock + class ClockProxy + def initialize(clock, jukebox) + @clock = clock + @jukebox = jukebox + + @callbacks = {} + end + + def register(callback, method) + @callbacks[callback] = method + end + + def start_clock(mode) + return if @clock.active? || $window.current_state.is_a?(Randomizer) + + @clock.controller = case mode + when :full_match + ClockController.new(0, ClockController::FULL_MATCH) + when :autonomous + ClockController.new(0, ClockController::AUTONOMOUS) + when :full_teleop + ClockController.new(33.0, ClockController::FULL_TELEOP) + when :teleop_only + ClockController.new(41.0, ClockController::TELEOP) + when :endgame_only + ClockController.new(131.0, ClockController::TELEOP_ENDGAME) + else + nil + end + end + + def abort_clock + $window.current_state&.get_sample("media/fogblast.wav")&.play if @clock.active? + @clock.controller = nil + end + + def set_clock_title(string) + @clock.title.text = string.to_s + @clock.title.x = $window.width / 2 - @clock.title.width / 2 + end + + def get_clock_title(string) + @clock.title + end + + def jukebox_previous_track + @jukebox.previous_track + end + + def jukebox_next_track + @jukebox.next_track + end + + def jukebox_stop + @jukebox.stop + end + + def jukebox_play + @jukebox.play + end + + def jukebox_pause + @jukebox.pause + end + + def jukebox_set_volume(float) + @jukebox.set_volume(float) + end + + def jukebox_volume + @jukebox.volume + end + + def jukebox_current_track + @jukebox.now_playing + end + + def jukebox_set_sound_effects(boolean) + @jukebox.set_sfx(boolean) + end + + def jukebox_volume_changed(float) + @callbacks[:volume_changed]&.call(float) + end + + def jukebox_track_changed(name) + @callbacks[:track_changed]&.call(name) + end + + def randomizer_changed(boolean) + @callbacks[:randomizer_changed]&.call(boolean) + end + + def shutdown! + end + end + end +end diff --git a/lib/game_clock/event_handlers.rb b/lib/game_clock/event_handlers.rb new file mode 100644 index 0000000..b153ecd --- /dev/null +++ b/lib/game_clock/event_handlers.rb @@ -0,0 +1,89 @@ +module TAC + class PracticeGameClock + module EventHandlers + ### Clock ### + def start_clock + @clock_running = true + end + + def stop_clock + @clock_running = false + end + + def change_clock(value) + @clock_time = time_from_string(value) + end + + ### Countdown ### + def start_countdown + @countdown_running = true + end + + def stop_countdown + @countdown_running = false + end + + + def change_countdown(value) + @countdown_time = time_from_string(value) + end + + def change_display(display) + end + + def change_color(color) + out = case color + when :white + Gosu::Color::WHITE + when :orange + Gosu::Color.rgb(150, 75, 0) + when :red + Gosu::Color.rgb(150, 0, 0) + end + + @display_color = out + end + + private def time_from_string(string) + split = string.split(":") + minutes = (split.first.to_i) * 60 + seconds = (split.last.to_i) + + return minutes + seconds + end + + def play_sound(sound) + path = nil + case sound + when :autonomous_countdown + path = "media/sounds/3-2-1.wav" + when :autonomous_start + path = "media/sounds/charge.wav" + when :autonomous_ended + path = "media/sounds/endauto.wav" + when :teleop_pickup_controllers + path = "media/sounds/Pick_Up_Controllers.wav" + when :abort_match + path = "media/sounds/fogblast.wav" + when :teleop_countdown + path = "media/sounds/3-2-1.wav" + when :teleop_started + path = "media/sounds/firebell.wav" + when :end_game + path = "media/sounds/factwhistle.wav" + when :end_match + path = "media/sounds/endmatch.wav" + end + + path = "#{ROOT_PATH}/#{path}" + + if path && File.exist?(path) && !File.directory?(path) + Jukebox::SAMPLES[path] = Gosu::Sample.new(path) unless Jukebox::SAMPLES[path].is_a?(Gosu::Sample) + Jukebox::SAMPLES[path].play + else + warn "WARNING: Sample for #{sound.inspect} could not be found at '#{path}'" + end + end + end + end +end diff --git a/lib/game_clock/jukebox.rb b/lib/game_clock/jukebox.rb new file mode 100644 index 0000000..8477503 --- /dev/null +++ b/lib/game_clock/jukebox.rb @@ -0,0 +1,144 @@ +module TAC + class PracticeGameClock + class Jukebox + MUSIC = Dir.glob(ROOT_PATH + "/media/music/*.*").freeze + SAMPLES = {} + + if File.exist?(ROOT_PATH + "/media/sounds/skystone") + BEEPS_AND_BOOPS = Dir.glob(ROOT_PATH + "/media/sounds/skystone/*.*").freeze + end + + attr_reader :volume, :now_playing + + def initialize(clock) + @clock = clock + + @order = MUSIC.shuffle + @now_playing = "" + @playing = false + @song = nil + @volume = 1.0 + @last_check_time = Gosu.milliseconds + + @last_sfx_time = Gosu.milliseconds + @sfx_random_interval = generate_sfx_period + @play_sfx = true + + if defined?(BEEPS_AND_BOOPS) + BEEPS_AND_BOOPS.each do |beep| + SAMPLES[beep] = Gosu::Sample.new(beep) + end + end + end + + def update + return unless Gosu.milliseconds - @last_check_time >= 2000.0 + + @last_check_time = Gosu.milliseconds + + if @song && !@song.playing? && !@song.paused? + next_track if @playing + end + + if @play_sfx && defined?(BEEPS_AND_BOOPS) + play_sfx + end + end + + def play_sfx + if Gosu.milliseconds - @last_sfx_time >= @sfx_random_interval + @last_sfx_time = Gosu.milliseconds + @sfx_random_interval = generate_sfx_period + + pan = rand(0.49999..0.50001) + volume = rand(0.75..1.0) + speed = rand(0.5..1.25) + SAMPLES[BEEPS_AND_BOOPS.sample].play_pan(pan, volume, speed) unless @clock.active? + end + end + + def generate_sfx_period + # rand(15..120) * 1000.0 + rand(5..10) * 1000.0 + end + + def set_sfx(boolean) + @play_sfx = boolean + end + + def play_sfx? + @play_sfx + end + + def play + if @song && @song.paused? + @song.play + else + return false unless @order.size > 0 + + @current_song = @order.first + @song = Gosu::Song.new(@current_song) + @song.volume = @volume + @song.play + @now_playing = File.basename(@current_song) + @order.rotate!(1) + end + + @playing = true + end + + def pause + @playing = false + @song.pause if @song + end + + def song + @song + end + + def stop + @song.stop if @song + @playing = false + @now_playing = "" + end + + def previous_track + return false unless @order.size > 0 + + @song.stop if @song + @order.rotate!(-1) + @current_song = @order.first + @song = Gosu::Song.new(@current_song) + @song.volume = @volume + @song.play + + @playing = true + @now_playing = File.basename(@current_song) + end + + def next_track + return false unless @order.size > 0 + + @song.stop if @song + @current_song = @order.first + @song = Gosu::Song.new(@current_song) + @song.volume = @volume + @song.play + @order.rotate!(1) + + @playing = true + @now_playing = File.basename(@current_song) + end + + def current_track + @current_song + end + + def set_volume(float) + @volume = float + @volume = @volume.clamp(0.1, 1.0) + @song.volume = @volume if @song + end + end + end +end diff --git a/lib/game_clock/logger.rb b/lib/game_clock/logger.rb new file mode 100644 index 0000000..bdcca32 --- /dev/null +++ b/lib/game_clock/logger.rb @@ -0,0 +1,28 @@ +module TAC + class PracticeGameClock + class ClockNet + class Logger + def printer(message) + # return + puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")} #{message}" + end + + def i(tag, message) + printer("INFO #{tag}: #{message}") + end + + def d(tag, message) + printer("DEBUG #{tag}: #{message}") + end + + def e(tag, message) + printer("ERROR #{tag}: #{message}") + end + end + end + + def log + @logger ||= ClockNet::Logger.new + end + end +end diff --git a/lib/game_clock/net/client.rb b/lib/game_clock/net/client.rb new file mode 100644 index 0000000..e4d9dcf --- /dev/null +++ b/lib/game_clock/net/client.rb @@ -0,0 +1,162 @@ +require "securerandom" + +module TAC + class PracticeGameClock + class ClockNet + class Client + TAG = "ClockNet|Client" + CHUNK_SIZE = 4096 + PACKET_TAIL = "\r\n\n" + + attr_reader :uuid, :read_queue, :write_queue, :socket, + :packets_sent, :packets_received, + :data_sent, :data_received + attr_accessor :sync_interval, :last_socket_error, :socket_error + def initialize + @uuid = SecureRandom.uuid + @read_queue = [] + @write_queue = [] + + @sync_interval = 100 + + @last_socket_error = nil + @socket_error = false + @bound = false + + @packets_sent, @packets_received = 0, 0 + @data_sent, @data_received = 0, 0 + end + + def uuid=(id) + @uuid = id + end + + def socket=(socket) + @socket = socket + @bound = true + + listen + end + + def listen + Thread.new do + while connected? + # Read from socket + while message_in = read + if message_in.empty? + break + else + log.i(TAG, "Read: " + message_in) + + @read_queue << message_in + + @packets_received += 1 + @data_received += message_in.length + end + end + + sleep @sync_interval / 1000.0 + end + end + + Thread.new do + while connected? + # Write to socket + while message_out = @write_queue.shift + write(message_out) + + @packets_sent += 1 + @data_sent += message_out.to_s.length + log.i(TAG, "Write: " + message_out.to_s) + end + + sleep @sync_interval / 1000.0 + end + end + end + + def sync(block) + block.call + end + + def handle_read_queue + message = gets + + while message + puts(message) + + log.i(TAG, "Writing to Queue: " + message) + + message = gets + end + end + + def socket_error? + @socket_error + end + + def connected? + if closed? == true || closed? == nil + false + else + true + end + end + + def closed? + @socket.closed? if @socket + end + + def write(message) + begin + @socket.puts("#{message}#{PACKET_TAIL}") + rescue => error + @last_socket_error = error + @socket_error = true + log.e(TAG, error.message) + close + end + end + + def read + begin + message = @socket.gets + rescue => error + @last_socket_error = error + @socket_error = true + + message = "" + end + + + return message.strip + end + + def puts(message) + @write_queue << message + end + + def gets + @read_queue.shift + end + + def encode(message) + return message + end + + def decode(blob) + return blob + end + + def flush + @socket.flush if socket + end + + def close(reason = nil) + write(reason) if reason + @socket.close if @socket + end + end + end + end +end \ No newline at end of file diff --git a/lib/game_clock/net/connection.rb b/lib/game_clock/net/connection.rb new file mode 100644 index 0000000..80266d3 --- /dev/null +++ b/lib/game_clock/net/connection.rb @@ -0,0 +1,97 @@ +module TAC + class PracticeGameClock + class ClockNet + class Connection + TAG = "ClockNet|Connection" + attr_reader :hostname, :port, :client, :proxy_object + + def initialize(hostname: "localhost", port: 4567, proxy_object:) + @hostname = hostname + @port = port + @proxy_object = proxy_object + + @client = nil + + @last_sync_time = Gosu.milliseconds + @sync_interval = SYNC_INTERVAL + + @last_heartbeat_sent = Gosu.milliseconds + @heartbeat_interval = HEARTBEAT_INTERVAL + + @connection_handler = proc do + handle_connection + end + + @packet_handler = PacketHandler.new(host_is_a_connection: true, proxy_object: @proxy_object) + end + + def connect + return if @client + + @client = Client.new + + Thread.new do + begin + @client.socket = Socket.tcp(@hostname, @port, connect_timeout: 5) + @client.socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + log.i(TAG, "Connected to: #{@hostname}:#{@port}") + + while @client && @client.connected? + if Gosu.milliseconds > @last_sync_time + @sync_interval + @last_sync_time = Gosu.milliseconds + + @client.sync(@connection_handler) + end + end + + rescue => error + log.e(TAG, error) + + if @client + @client.close + @client.last_socket_error = error + @client.socket_error = true + end + end + end + end + + def handle_connection + if @client && @client.connected? + message = @client.gets + + @packet_handler.handle(message) if message + + if Gosu.milliseconds > @last_heartbeat_sent + @heartbeat_interval + @last_heartbeat_sent = Gosu.milliseconds + + @client.puts(PacketHandler.packet_heartbeat) + end + + sleep @sync_interval / 1000.0 + end + end + + def puts(packet) + @client.puts(packet) + end + + def gets + @client.gets + end + + def connected? + @client.connected? if @client + end + + def closed? + @client.closed? if @client + end + + def close + @client.close if @client + end + end + end + end +end diff --git a/lib/game_clock/net/packet.rb b/lib/game_clock/net/packet.rb new file mode 100644 index 0000000..f05d710 --- /dev/null +++ b/lib/game_clock/net/packet.rb @@ -0,0 +1,109 @@ +module TAC + class PracticeGameClock + class ClockNet + SYNC_INTERVAL = 250 + HEARTBEAT_INTERVAL = 1_500 + + class Packet + PROTOCOL_VERSION = 1 + PROTOCOL_SEPERATOR = "|" + PROTOCOL_HEARTBEAT = "heartbeat" + + PACKET_TYPES = { + handshake: 0, + heartbeat: 1, + error: 2, + shutdown: 3, + + start_clock: 10, + abort_clock: 11, + + set_clock_title: 20, + get_clock_title: 21, + clock_title: 22, + clock_time: 23, + + randomizer_visible: 27, + + jukebox_previous_track: 30, + jukebox_next_track: 31, + jukebox_stop: 32, + jukebox_play: 33, + jukebox_pause: 34, + jukebox_set_volume: 35, + jukebox_get_volume: 36, + jukebox_volume: 37, + jukebox_get_current_track: 38, + jukebox_current_track: 39, + jukebox_get_sound_effects: 40, + jukebox_set_sound_effects: 41, + jukebox_sound_effects: 42, + } + + def self.from_stream(message) + slice = message.split("|", 4) + + if slice.size < 4 + warn "Failed to split packet along first 4 " + PROTOCOL_SEPERATOR + ". Raw return: " + slice.to_s + return nil + end + + if slice.first != PROTOCOL_VERSION.to_s + warn "Incompatible protocol version received, expected: " + PROTOCOL_VERSION.to_s + " got: " + slice.first + return nil + end + + unless valid_packet_type?(Integer(slice[1])) + warn "Unknown packet type detected: #{slice[1]}" + return nil + end + + protocol_version = Integer(slice[0]) + type = PACKET_TYPES.key(Integer(slice[1])) + content_length = Integer(slice[2]) + body = slice[3] + + raise "Type is #{type.inspect} [#{type.class}]" unless type.is_a?(Symbol) + + return Packet.new(protocol_version, type, content_length, body) + end + + def self.create(packet_type, body) + Packet.new(PROTOCOL_VERSION, PACKET_TYPES.key(packet_type), body.length, body) + end + + def self.valid_packet_type?(packet_type) + PACKET_TYPES.values.find { |t| t == packet_type } + end + + attr_reader :protocol_version, :type, :content_length, :body + def initialize(protocol_version, type, content_length, body) + @protocol_version = protocol_version + @type = type + @content_length = content_length + @body = body + end + + def encode_header + string = "" + string += protocol_version.to_s + string += PROTOCOL_SEPERATOR + string += PACKET_TYPES[type].to_s + string += PROTOCOL_SEPERATOR + string += content_length.to_s + string += PROTOCOL_SEPERATOR + + return string + end + + def valid? + true + end + + def to_s + "#{encode_header}#{body}" + end + end + end + end +end diff --git a/lib/game_clock/net/packet_handler.rb b/lib/game_clock/net/packet_handler.rb new file mode 100644 index 0000000..1f3e628 --- /dev/null +++ b/lib/game_clock/net/packet_handler.rb @@ -0,0 +1,304 @@ +module TAC + class PracticeGameClock + class ClockNet + class PacketHandler + TAG = "ClockNet|PacketHandler" + def initialize(host_is_a_connection: false, proxy_object:) + @host_is_a_connection = host_is_a_connection + @proxy_object = proxy_object + end + + def handle(message) + packet = Packet.from_stream(message) + + if packet + log.i(TAG, "Received packet of type: #{packet.type}") + hand_off(packet) + else + log.d(TAG, "Rejected raw packet: #{message}") + end + end + + def hand_off(packet) + case packet.type + when :handshake + handle_handshake(packet) + when :heartbeat + handle_heartbeat(packet) + when :error + handle_error(packet) + + when :start_clock + handle_start_clock(packet) + when :abort_clock + handle_abort_clock(packet) + when :get_clock_title + handle_get_clock_title(packet) + when :set_clock_title + handle_set_clock_title(packet) + when :clock_title + handle_clock_title(packet) + when :jukebox_previous_track + handle_jukebox_previous_track(packet) + when :jukebox_next_track + handle_jukebox_next_track(packet) + when :jukebox_play + handle_jukebox_play(packet) + when :jukebox_pause + handle_jukebox_pause(packet) + when :jukebox_stop + handle_jukebox_stop(packet) + when :jukebox_set_volume + handle_jukebox_set_volume(packet) + when :jukebox_volume + handle_jukebox_volume(packet) + when :jukebox_set_sound_effects + handle_jukebox_set_sound_effects(packet) + when :jukebox_current_track + handle_jukebox_current_track(packet) + when :clock_time + handle_clock_time(packet) + when :randomizer_visible + handle_randomizer_visible(packet) + when :shutdown + handle_shutdown(packet) + else + log.d(TAG, "No hand off available for packet type: #{packet.type}") + end + end + + def handle_handshake(packet) + if @host_is_a_connection + end + end + + # TODO: Reset socket timeout + def handle_heartbeat(packet) + end + + # TODO: Handle errors + def handle_error(packet) + title, message = packet.body.split(Packet::PROTOCOL_SEPERATOR, 2) + log.e(TAG, "Remote error: #{title}: #{message}") + end + + def handle_start_clock(packet) + unless @host_is_a_connection + @proxy_object.start_clock(packet.body.to_sym) + end + end + + def handle_abort_clock(packet) + unless @host_is_a_connection + @proxy_object.abort_clock + end + end + + def handle_set_clock_title(packet) + unless @host_is_a_connection + title = packet.body + @proxy_object.set_clock_title(title) + end + end + + def handle_get_clock_title(packet) + unless @host_is_a_connection + $RemoteControl.server.active_client.puts(Packet.clock_title(@proxy_object.clock.title)) + end + end + + def handle_jukebox_previous_track(packet) + unless @host_is_a_connection + @proxy_object.jukebox_previous_track + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_current_track(@proxy_object.jukebox_current_track)) + end + end + + def handle_jukebox_next_track(packet) + unless @host_is_a_connection + @proxy_object.jukebox_next_track + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_current_track(@proxy_object.jukebox_current_track)) + end + end + + def handle_jukebox_play(packet) + unless @host_is_a_connection + @proxy_object.jukebox_play + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_current_track(@proxy_object.jukebox_current_track)) + end + end + + def handle_jukebox_pause(packet) + unless @host_is_a_connection + @proxy_object.jukebox_pause + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_current_track(@proxy_object.jukebox_current_track)) + end + end + + def handle_jukebox_stop(packet) + unless @host_is_a_connection + @proxy_object.jukebox_stop + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_current_track(@proxy_object.jukebox_current_track)) + end + end + + def handle_jukebox_set_volume(packet) + unless @host_is_a_connection + float = packet.body.to_f + float = float.clamp(0.0, 1.0) + + @proxy_object.jukebox_set_volume(float) + + float = @proxy_object.jukebox_volume + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_volume(float)) + end + end + + def handle_jukebox_get_volume(packet) + unless @host_is_a_connection + float = @proxy_object.jukebox_volume + + $RemoteControl.server.active_client.puts(PacketHandler.packet_jukebox_volume(float)) + end + end + + def handle_jukebox_volume(packet) + if @host_is_a_connection + float = packet.body.to_f + + @proxy_object.volume_changed(float) + end + end + + def handle_jukebox_set_sound_effects(packet) + unless @host_is_a_connection + boolean = packet.body == "true" + + @proxy_object.jukebox_set_sound_effects(boolean) + end + end + + def handle_jukebox_current_track(packet) + if @host_is_a_connection + @proxy_object.track_changed(packet.body) + end + end + + def handle_clock_time(packet) + if @host_is_a_connection + @proxy_object.clock_changed(packet.body) + end + end + + def handle_randomizer_visible(packet) + boolean = packet.body == "true" + + @proxy_object.randomizer_changed(boolean) + + unless @host_is_a_connection + # Send confirmation to client + $RemoteControl.server.active_client.puts(PacketHandler.packet_randomizer_visible(boolean)) + end + end + + def handle_shutdown(packet) + unless @host_is_a_connection + # $RemoteControl.server.close + # $window.close + Gosu::Song.current_song&.stop + exit + end + end + + def self.packet_handshake(client_uuid) + Packet.create(Packet::PACKET_TYPES[:handshake], client_uuid) + end + + def self.packet_heartbeat + Packet.create(Packet::PACKET_TYPES[:heartbeat], Packet::PROTOCOL_HEARTBEAT) + end + + def self.packet_error(error_code, message) + Packet.create(Packet::PACKET_TYPES[:error], error_code.to_s, message.to_s) + end + + def self.packet_start_clock(mode) + Packet.create(Packet::PACKET_TYPES[:start_clock], mode.to_s) + end + + def self.packet_abort_clock + Packet.create(Packet::PACKET_TYPES[:abort_clock], "") + end + + def self.packet_set_clock_title(string) + Packet.create(Packet::PACKET_TYPES[:set_clock_title], string.to_s) + end + + def self.packet_get_clock_title + Packet.create(Packet::PACKET_TYPES[:get_clock_title], "") + end + + def self.packet_clock_title(string) + Packet.create(Packet::PACKET_TYPES[:clock_title], string.to_s) + end + + def self.packet_jukebox_previous_track + Packet.create(Packet::PACKET_TYPES[:jukebox_previous_track], "") + end + + def self.packet_jukebox_next_track + Packet.create(Packet::PACKET_TYPES[:jukebox_next_track], "") + end + + def self.packet_jukebox_play + Packet.create(Packet::PACKET_TYPES[:jukebox_play], "") + end + + def self.packet_jukebox_pause + Packet.create(Packet::PACKET_TYPES[:jukebox_pause], "") + end + + def self.packet_jukebox_stop + Packet.create(Packet::PACKET_TYPES[:jukebox_stop], "") + end + + def self.packet_jukebox_set_volume(float) + Packet.create(Packet::PACKET_TYPES[:jukebox_set_volume], float.to_s) + end + + def self.packet_jukebox_get_volume + Packet.create(Packet::PACKET_TYPES[:jukebox_get_volume], "") + end + + def self.packet_jukebox_volume(float) + Packet.create(Packet::PACKET_TYPES[:jukebox_volume], float.to_s) + end + + def self.packet_jukebox_set_sound_effects(boolean) + Packet.create(Packet::PACKET_TYPES[:jukebox_set_sound_effects], boolean.to_s) + end + + def self.packet_jukebox_current_track(name) + Packet.create(Packet::PACKET_TYPES[:jukebox_current_track], name) + end + + def self.packet_clock_time(string) + Packet.create(Packet::PACKET_TYPES[:clock_time], string) + end + + def self.packet_randomizer_visible(boolean) + Packet.create(Packet::PACKET_TYPES[:randomizer_visible], boolean.to_s) + end + + def self.packet_shutdown + Packet.create(Packet::PACKET_TYPES[:shutdown], "") + end + end + end + end +end diff --git a/lib/game_clock/net/server.rb b/lib/game_clock/net/server.rb new file mode 100644 index 0000000..dff8afb --- /dev/null +++ b/lib/game_clock/net/server.rb @@ -0,0 +1,146 @@ +module TAC + class PracticeGameClock + class ClockNet + class Server + TAG = "ClockNet|Server" + attr_reader :active_client, + :packets_sent, :packets_received, :data_sent, :data_received, + :client_last_packets_sent, :client_last_packets_received, :client_last_data_sent, :client_last_data_received + def initialize(hostname: "localhost", port: 4567, proxy_object: ) + $server = self + + @hostname = hostname + @port = port + @proxy_object = proxy_object + + @socket = nil + @active_client = nil + @connection_attempts = 0 + @max_connection_attempts = 10 + + @packets_sent, @packets_received, @client_last_packets_sent, @client_last_packets_received = 0, 0, 0, 0 + @data_sent, @data_received, @client_last_data_sent, @client_last_data_received = 0, 0, 0, 0 + + @last_sync_time = Gosu.milliseconds + @sync_interval = SYNC_INTERVAL + + @last_heartbeat_sent = Gosu.milliseconds + @heartbeat_interval = HEARTBEAT_INTERVAL + + @client_handler_proc = proc do + handle_client + end + + @packet_handler = PacketHandler.new(proxy_object: @proxy_object) + end + + def start(run_on_main_thread: false) + thread = Thread.new do + while (!@socket && @connection_attempts < @max_connection_attempts) + begin + log.i(TAG, "Starting server...") + @socket = TCPServer.new(@port) + @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + rescue IOError => error + log.e(TAG, error) + + @connection_attempts += 1 + retry if @connection_attempts < @max_connection_attempts + end + end + + while @socket && !@socket.closed? + begin + run_server + rescue IOError => error + log.e(TAG, error) + @socket.close if @socket + end + end + end + + thread.join if run_on_main_thread + end + + def run_server + while !@socket.closed? + client = Client.new + client.sync_interval = @sync_interval + client.socket = @socket.accept + + if @active_client && @active_client.connected? + log.i(TAG, "Too many clients, already have one connected!") + client.close("Too many clients!") + else + @active_client = client + # TODO: Backup local config + # SEND CONFIG + + @active_client.puts(PacketHandler.packet_handshake(@active_client.uuid)) + + log.i(TAG, "Client connected!") + + Thread.new do + while @active_client && @active_client.connected? + if Gosu.milliseconds > @last_sync_time + @sync_interval + @last_sync_time = Gosu.milliseconds + + @active_client.sync(@client_handler_proc) + update_stats + end + end + + update_stats + @active_client = nil + + @client_last_packets_sent = 0 + @client_last_packets_received = 0 + @client_last_data_sent = 0 + @client_last_data_received = 0 + end + end + end + end + + def handle_client + if @active_client && @active_client.connected? + message = @active_client.gets + + if message && !message.empty? + @packet_handler.handle(message) + end + + if Gosu.milliseconds > @last_heartbeat_sent + @heartbeat_interval + @last_heartbeat_sent = Gosu.milliseconds + + @active_client.puts(PacketHandler.packet_heartbeat) + end + + sleep @sync_interval / 1000.0 + end + end + + def close + @socket.close + end + + private def update_stats + if @active_client + # NOTE: Sent and Received are reversed for Server stats + + @packets_sent += @active_client.packets_received - @client_last_packets_received + @packets_received += @active_client.packets_sent - @client_last_packets_sent + + @data_sent += @active_client.data_received - @client_last_data_received + @data_received += @active_client.data_sent - @client_last_data_sent + + @client_last_packets_sent = @active_client.packets_sent + @client_last_packets_received = @active_client.packets_received + @client_last_data_sent = @active_client.data_sent + @client_last_data_received = @active_client.data_received + end + end + end + end + end +end diff --git a/lib/game_clock/particle_emitter.rb b/lib/game_clock/particle_emitter.rb new file mode 100644 index 0000000..0c776e7 --- /dev/null +++ b/lib/game_clock/particle_emitter.rb @@ -0,0 +1,135 @@ +module TAC + class PracticeGameClock + class ParticleEmitter + def initialize(max_particles: 50, time_to_live: 30_000, interval: 1_500, z: -2) + @max_particles = max_particles + @time_to_live = time_to_live + @interval = interval + @z = -2 + + @particles = [] + @image_options = Dir.glob("#{ROOT_PATH}/media/particles/*.*") + @last_spawned = 0 + @clock_active = false + end + + def draw + @particles.each(&:draw) + end + + def update + @particles.each { |part| part.update($window.dt) } + @particles.delete_if { |part| part.die? } + + spawn_particles + end + + def spawn_particles + # !clock_active? && + if @particles.count < @max_particles && Gosu.milliseconds - @last_spawned >= @interval + screen_midpoint = CyberarmEngine::Vector.new($window.width / 2, $window.height / 2) + scale = rand(0.25..1.0) + image_name = @image_options.sample + + return unless image_name + + image = $window.current_state.get_image(image_name) + position = CyberarmEngine::Vector.new(0, 0) + + r = rand + if r < 0.25 # LEFT + position.x = -image.width * scale + position.y = rand(0..($window.height - image.height * scale)) + elsif r < 0.5 # RIGHT + position.x = $window.width + (image.width * scale) + position.y = rand(0..($window.height - image.height * scale)) + elsif r < 0.75 # TOP + position.x = rand(0..($window.width - image.width * scale)) + position.y = -image.height * scale + else #BOTTOM + position.x = rand(0..($window.width - image.width * scale)) + position.y = $window.height + image.height * scale + end + + position.x ||= 0 + position.y ||= 0 + + velocity = (screen_midpoint - position) + + @particles << Particle.new( + image: image, + position: position, + velocity: velocity, + time_to_live: @time_to_live, + speed: rand(24..128), + scale: scale, + clock_active: @clock_active, + z: @z + ) + + @last_spawned = Gosu.milliseconds + end + end + + def clock_active! + @clock_active = true + @particles.each(&:clock_active!) + end + + def clock_inactive! + @clock_active = false + @particles.each(&:clock_inactive!) + end + + def clock_active? + @clock_active + end + + class Particle + def initialize(image:, position:, velocity:, time_to_live:, speed:, z:, scale: 1.0, clock_active: false) + @image = image + @position = position + @velocity = velocity.normalized + @time_to_live = time_to_live + @speed = speed + @z = z + @scale = scale + + @born_at = Gosu.milliseconds + @born_time_to_live = time_to_live + @color = Gosu::Color.new(0xff_ffffff) + @clock_active = clock_active + end + + def draw + @image.draw(@position.x, @position.y, @z, @scale, @scale, @color) + end + + def update(dt) + @position += @velocity * @speed * dt + + @color.alpha = (255.0 * ratio).to_i.clamp(0, 255) + end + + def ratio + r = 1.0 - ((Gosu.milliseconds - @born_at) / @time_to_live.to_f) + @clock_active ? r.clamp(0.0, 0.5) : r + end + + def die? + ratio <= 0 + end + + def clock_active! + @clock_active = true + # @time_to_live = (Gosu.milliseconds - @born_at) + 1_000 + end + + def clock_inactive! + @clock_active = false + # @time_to_live = @born_time_to_live unless Gosu.milliseconds - @born_at < @time_to_live + end + end + end + end +end diff --git a/lib/game_clock/randomizer.rb b/lib/game_clock/randomizer.rb new file mode 100644 index 0000000..3eb428b --- /dev/null +++ b/lib/game_clock/randomizer.rb @@ -0,0 +1,127 @@ +require "securerandom" + +module TAC + class PracticeGameClock + class Randomizer < CyberarmEngine::GameState + def setup + @roll = SecureRandom.random_number(1..6) + + @position = CyberarmEngine::Vector.new + + @dimple_color = 0xff_008000 + @dimple_res = 36 + + @size = [window.width, window.height].min / 2.0 + + @rings = [] + + case @roll + when 1, 4 + when 2, 5 + @rings << Ring.new(position: CyberarmEngine::Vector.new(-@size, window.height * 0.9), speed: 512) + when 3, 6 + @rings << Ring.new(position: CyberarmEngine::Vector.new(-@size, window.height * 0.9), speed: 512) + @rings << Ring.new(position: CyberarmEngine::Vector.new(-@size * 1.25, window.height * 0.8), speed: 512) + @rings << Ring.new(position: CyberarmEngine::Vector.new(-@size * 1.50, window.height * 0.7), speed: 512) + @rings << Ring.new(position: CyberarmEngine::Vector.new(-@size * 1.75, window.height * 0.6), speed: 512) + end + end + + def draw + window.previous_state.draw + + Gosu.flush + + fill(0xdd_202020) + + Gosu.translate(window.width * 0.5 - @size * 0.5, 24) do + Gosu.draw_rect(0, 0, @size, @size, Gosu::Color::BLACK) + Gosu.draw_rect(12, 12, @size - 24, @size - 24, Gosu::Color::GRAY) + + self.send(:"dice_#{@roll}", @size) + end + + @rings.each { |r| r.draw(@size) } + end + + def dice_1(size) + Gosu.draw_circle(size / 2, size / 2, dimple(size), @dimple_res, @dimple_color) + end + + def dice_2(size) + Gosu.draw_circle(size * 0.25, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.75, dimple(size), @dimple_res, @dimple_color) + end + + def dice_3(size) + Gosu.draw_circle(size * 0.25, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.50, size * 0.50, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.75, dimple(size), @dimple_res, @dimple_color) + end + + def dice_4(size) + Gosu.draw_circle(size * 0.25, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.25, size * 0.75, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.75, dimple(size), @dimple_res, @dimple_color) + end + + def dice_5(size) + Gosu.draw_circle(size * 0.50, size * 0.50, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.25, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.25, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.25, size * 0.75, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.75, dimple(size), @dimple_res, @dimple_color) + end + + def dice_6(size) + Gosu.draw_circle(size * 0.25, size * 0.20, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.20, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.25, size * 0.50, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.50, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.25, size * 0.80, dimple(size), @dimple_res, @dimple_color) + Gosu.draw_circle(size * 0.75, size * 0.80, dimple(size), @dimple_res, @dimple_color) + end + + def dimple(size) + size / 9.0 + end + + def update + window.previous_state&.update_non_gui + + @rings.each { |r| r.update(window, @size) } + + @size = [window.width, window.height].min / 2.0 + end + + def button_down(id) + case id + when Gosu::MS_LEFT, Gosu::KB_ESCAPE, Gosu::KB_SPACE + pop_state + end + end + + class Ring + def initialize(position:, speed: 1) + @position = position + @speed = speed + @color = 0xff_ffaa00 + end + + def draw(size) + Gosu.translate(@position.x, @position.y) do + Gosu.draw_rect(0, 0, size, size * 0.10, @color) + end + end + + def update(window, size) + center = window.width * 0.5 - size * 0.5 + + @position.x += @speed * window.dt + @position.x = center if @position.x > center + end + end + end + end +end diff --git a/lib/game_clock/remote_control.rb b/lib/game_clock/remote_control.rb new file mode 100644 index 0000000..39c7284 --- /dev/null +++ b/lib/game_clock/remote_control.rb @@ -0,0 +1,270 @@ +module TAC + class PracticeGameClock + class RemoteControl + @@connection = nil + @@server = nil + + def self.connection + @@connection + end + + def self.connection=(connection) + @@connection = connection + end + + def self.server + @@server + end + + def self.server=(server) + @@server = server + end + + class NetConnect < CyberarmEngine::GuiState + def setup + theme(THEME) + + background Palette::TACNET_NOT_CONNECTED + + banner "ClockNet Remote Control", text_align: :center, width: 1.0 + flow(width: 1.0) do + stack(width: 0.25) {} + stack(width: 0.5) do + title "Hostname" + @hostname = edit_line "localhost", width: 1.0 + title "Port" + @port = edit_line "4567", width: 1.0 + + flow(width: 1.0, margin_top: 20) do + @back_button = button "Back", width: 0.5 do + window.pop_state + end + + @connect_button = button "Connect", width: 0.5 do + begin + @connect_button.enabled = false + @back_button.enabled = false + + @connection = ClockNet::Connection.new(hostname: @hostname.value, port: Integer(@port.value), proxy_object: RemoteProxy.new(window)) + + @connection.connect + RemoteControl.connection = @connection + end + end + end + end + end + end + + def update + super + + if RemoteControl.connection + push_state(Controller) if RemoteControl.connection.connected? + + RemoteControl.connection = nil if RemoteControl.connection.client.socket_error? + else + @back_button.enabled = true + @connect_button.enabled = true + end + end + end + + class Controller < CyberarmEngine::GuiState + def setup + theme(THEME) + + at_exit do + @connection&.close + end + + @jukebox_volume = 1.0 + @jukebox_sound_effects = true + @randomizer_visible = false + + RemoteControl.connection.proxy_object.register(:track_changed, method(:track_changed)) + RemoteControl.connection.proxy_object.register(:volume_changed, method(:volume_changed)) + RemoteControl.connection.proxy_object.register(:clock_changed, method(:clock_changed)) + RemoteControl.connection.proxy_object.register(:randomizer_changed, method(:randomizer_changed)) + + background Palette::TACNET_NOT_CONNECTED + + banner "ClockNet Remote Control", text_align: :center, width: 1.0 + + flow width: 1.0, height: 1.0 do + stack width: 0.5 do + title "Match", width: 1.0, text_align: :center + button "Start Match", width: 1.0, text_size: 48, margin_bottom: 50 do + start_clock(:full_match) + end + + title "Practice", width: 1.0, text_align: :center + button "Autonomous", width: 1.0 do + start_clock(:autonomous) + end + button "TeleOp with Countdown", width: 1.0 do + start_clock(:full_teleop) + end + button "TeleOp", width: 1.0 do + start_clock(:teleop_only) + end + button "TeleOp Endgame", width: 1.0, margin_bottom: 50 do + start_clock(:endgame_only) + end + + button "Abort Clock", width: 1.0 do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_abort_clock) + end + + button "Shutdown", width: 1.0, **DANGEROUS_BUTTON do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_shutdown) + sleep 1 # let packet escape before closing + exit + end + end + + stack width: 0.495 do + title "Clock Title", width: 1.0, text_align: :center + + stack width: 0.9, margin_left: 50 do + @title = edit_line "FIRST TECH CHALLENGE", width: 1.0 + + button "Update", width: 1.0, margin_bottom: 50 do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_set_clock_title(@title.value.strip)) + end + end + + title "JukeBox", width: 1.0, text_align: :center + stack width: 0.9, margin_left: 50 do + flow width: 1.0 do + tagline "Now Playing: " + @track_name = tagline "" + end + + flow width: 1.0 do + tagline "Volume: " + @volume = tagline "100%" + end + + flow width: 1.0 do + button get_image("#{ROOT_PATH}/media/icons/previous.png") do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_previous_track) + end + + button get_image("#{ROOT_PATH}/media/icons/right.png") do |button| + if @jukebox_playing + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_pause) + button.value = get_image("#{ROOT_PATH}/media/icons/right.png") + @jukebox_playing = false + else + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_play) + button.value = get_image("#{ROOT_PATH}/media/icons/pause.png") + @jukebox_playing = true + end + end + + button get_image("#{ROOT_PATH}/media/icons/stop.png") do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_stop) + end + + button get_image("#{ROOT_PATH}/media/icons/next.png") do + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_next_track) + end + + button get_image("#{ROOT_PATH}/media/icons/minus.png"), margin_left: 20 do + @jukebox_volume -= 0.1 + @jukebox_volume = 0.1 if @jukebox_volume < 0.1 + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_set_volume(@jukebox_volume)) + end + + button get_image("#{ROOT_PATH}/media/icons/plus.png") do + @jukebox_volume += 0.1 + @jukebox_volume = 0.1 if @jukebox_volume < 0.1 + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_set_volume(@jukebox_volume)) + end + + button get_image("#{ROOT_PATH}/media/icons/musicOn.png"), margin_left: 20, tip: "Toggle Sound Effects" do |button| + if @jukebox_sound_effects + button.value = get_image("#{ROOT_PATH}/media/icons/musicOff.png") + @jukebox_sound_effects = false + else + button.value = get_image("#{ROOT_PATH}/media/icons/musicOn.png") + @jukebox_sound_effects = true + end + + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_jukebox_set_sound_effects(@jukebox_sound_effects)) + end + end + + button "Open Music Library", width: 1.0 do + path = "#{ROOT_PATH}/media/music" + + if RUBY_PLATFORM.match(/ming|msys|cygwin/) + system("explorer \"#{path.gsub("/", "\\")}\"") + elsif RUBY_PLATFORM.match(/linux/) + system("xdg-open \"#{ROOT_PATH}/media/music\"") + else + # TODO. + end + end + end + + stack width: 0.9, margin_left: 50, margin_top: 20 do + flow width: 1.0 do + title "Clock: " + @clock_label = title "0:123456789" + @clock_label.width + @clock_label.value = "0:00" + end + + flow width: 1.0 do + title "Randomizer: " + @randomizer_label = title "Not Visible" + end + + button "Randomizer", width: 1.0, **DANGEROUS_BUTTON do + @randomizer_visible = !@randomizer_visible + + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_randomizer_visible(@randomizer_visible)) + end + end + end + end + end + + def update + super + + return if RemoteControl.connection.connected? + + # We've lost connection, unset window's connection object + # and send user back to connect screen to to attempt to + # reconnect + RemoteControl.connection = nil + pop_state + end + + def start_clock(mode) + RemoteControl.connection.puts(ClockNet::PacketHandler.packet_start_clock(mode.to_s)) + end + + def track_changed(name) + @track_name.value = name + end + + def volume_changed(float) + @volume.value = "#{float.round(1) * 100.0}%" + end + + def clock_changed(string) + @clock_label.value = string + end + + def randomizer_changed(boolean) + @randomizer_label.value = "Visible" if boolean + @randomizer_label.value = "Not Visible" unless boolean + end + end + end + end +end diff --git a/lib/game_clock/remote_proxy.rb b/lib/game_clock/remote_proxy.rb new file mode 100644 index 0000000..4b1d0f5 --- /dev/null +++ b/lib/game_clock/remote_proxy.rb @@ -0,0 +1,64 @@ +module TAC + class PracticeGameClock + class RemoteProxy + def initialize(window) + @window = window + + @callbacks = {} + end + + def register(callback, method) + @callbacks[callback] = method + end + + def start_clock(mode) + end + + def abort_clock + end + + def set_clock_title(string) + end + + def get_clock_title(string) + end + + def jukebox_previous_track + end + + def jukebox_next_track + end + + def jukebox_stop + end + + def jukebox_play + end + + def jukebox_pause + end + + def jukebox_sound_effects(boolean) + end + + def volume_changed(float) + @callbacks[:volume_changed]&.call(float) + end + + def track_changed(name) + @callbacks[:track_changed]&.call(name) + end + + def clock_changed(string) + @callbacks[:clock_changed]&.call(string) + end + + def randomizer_changed(boolean) + @callbacks[:randomizer_changed]&.call(boolean) + end + + def shutdown! + end + end + end +end diff --git a/lib/game_clock/theme.rb b/lib/game_clock/theme.rb new file mode 100644 index 0000000..e436cf5 --- /dev/null +++ b/lib/game_clock/theme.rb @@ -0,0 +1,73 @@ +module TAC + class PracticeGameClock + module Palette + TACNET_CONNECTED = Gosu::Color.new(0xff_008000) + TACNET_CONNECTING = Gosu::Color.new(0xff_ff8800) + TACNET_CONNECTION_ERROR = Gosu::Color.new(0xff_800000) + TACNET_NOT_CONNECTED = Gosu::Color.new(0xff_222222) + + TIMECRAFTERS_PRIMARY = Gosu::Color.new(0xff_008000) + TIMECRAFTERS_SECONDARY = Gosu::Color.new(0xff_006000) + TIMECRAFTERS_TERTIARY = Gosu::Color.new(0xff_00d000) + + BLUE_ALLIANCE = Gosu::Color.new(0xff_000080) + RED_ALLIANCE = Gosu::Color.new(0xff_800000) + + TACNET_PRIMARY = Gosu::Color.new(0xff000080) + TACNET_SECONDARY = Gosu::Color.new(0xff000060) + + GROUPS_PRIMARY = Gosu::Color.new(0xff_444444) + GROUPS_SECONDARY = Gosu::Color.new(0xff_444444) + + ACTIONS_PRIMARY = Gosu::Color.new(0xff_4444aa) + ACTIONS_SECONDARY = Gosu::Color.new(0xff_040404) + + VARIABLES_PRIMARY = Gosu::Color.new(0xff_660066) + VARIABLES_SECONDARY = Gosu::Color.new(0xff_440044) + + EDITOR_PRIMARY = Gosu::Color.new(0xff_446688) + EDITOR_SECONDARY = Gosu::Color.new(0xff_224466) + + ALERT = TACNET_CONNECTING + end + + THEME = { + TextBlock: { + font: "Canterell", + color: Gosu::Color.new(0xee_ffffff) + }, + Button: { + image_width: 40, + text_size: 40, + background: Palette::TIMECRAFTERS_PRIMARY, + border_thickness: 1, + border_color: Gosu::Color.new(0xff_111111), + hover: { + background: Palette::TIMECRAFTERS_SECONDARY, + }, + active: { + background: Palette::TIMECRAFTERS_TERTIARY, + } + }, + EditLine: { + caret_color: Gosu::Color.new(0xff_88ef90), + }, + ToggleButton: { + width: 18, + checkmark_image: "#{File.expand_path("..", __dir__)}/media/icons/checkmark.png", + }, + } + + DANGEROUS_BUTTON = { + background: 0xff_800000, + border_thickness: 1, + border_color: Gosu::Color.new(0xff_111111), + hover: { + background: 0xff_600000, + }, + active: { + background: 0xff_f00000, + } + } + end +end diff --git a/lib/game_clock/view.rb b/lib/game_clock/view.rb new file mode 100644 index 0000000..422325e --- /dev/null +++ b/lib/game_clock/view.rb @@ -0,0 +1,283 @@ +module TAC + class PracticeGameClock + class View < CyberarmEngine::GuiState + + attr_reader :clock + + def setup + window.show_cursor = true + + @remote_control_mode = @options[:remote_control_mode] + @escape_counter = 0 + + @background_image = get_image("#{ROOT_PATH}/media/background.png") + @mouse = Mouse.new(window) + @clock = Clock.new + @clock.controller = nil + @last_clock_display_value = @clock.value + + @particle_emitters = [ + PracticeGameClock::ParticleEmitter.new + ] + + @last_clock_state = @clock.active? + + theme(THEME) + + @menu_container = flow width: 1.0 do + stack(width: 0.35, padding: 5) do + background 0x55004159 + + title "Match", width: 1.0, text_align: :center + button "Start Match", width: 1.0, margin_bottom: 50 do + @clock_proxy.start_clock(:full_match) + end + + title "Practice", width: 1.0, text_align: :center + button "Autonomous", width: 1.0 do + @clock_proxy.start_clock(:autonomous) + end + + button "TeleOp with Countdown", width: 1.0 do + @clock_proxy.start_clock(:full_teleop) + end + + button "TeleOp", width: 1.0 do + @clock_proxy.start_clock(:teleop_only) + end + + button "TeleOp Endgame", width: 1.0 do + @clock_proxy.start_clock(:endgame_only) + end + + button "Abort Clock", width: 1.0, margin_top: 50 do + @clock_proxy.abort_clock + end + + button "Close", width: 1.0, **DANGEROUS_BUTTON do + if window.instance_variable_get(:"@states").size == 1 + window.close + else + @server&.close + + window.fullscreen = false + window.pop_state + end + end + end + + stack width: 0.4, padding_left: 50 do + background 0x55004159 + + flow do + label "♫ Now playing:" + @current_song_label = label "♫ ♫ ♫" + end + + flow do + label "Volume:" + @current_volume_label = label "100%" + end + + flow do + button get_image("#{ROOT_PATH}/media/icons/previous.png") do + @jukebox.previous_track + end + + button get_image("#{ROOT_PATH}/media/icons/pause.png") do |button| + if @jukebox.song && @jukebox.song.paused? + button.value = get_image("#{ROOT_PATH}/media/icons/right.png") + @jukebox.play + elsif !@jukebox.song + button.value = get_image("#{ROOT_PATH}/media/icons/right.png") + @jukebox.play + else + button.value = get_image("#{ROOT_PATH}/media/icons/pause.png") + @jukebox.pause + end + end + + button get_image("#{ROOT_PATH}/media/icons/stop.png") do + @jukebox.stop + end + + button get_image("#{ROOT_PATH}/media/icons/next.png") do + @jukebox.next_track + end + + button get_image("#{ROOT_PATH}/media/icons/minus.png"), margin_left: 20 do + @jukebox.set_volume(@jukebox.volume - 0.1) + end + + button get_image("#{ROOT_PATH}/media/icons/plus.png") do + @jukebox.set_volume(@jukebox.volume + 0.1) + end + + button "Open Music Library", margin_left: 50 do + if RUBY_PLATFORM.match(/ming|msys|cygwin/) + system("explorer #{ROOT_PATH}/media/music") + elsif RUBY_PLATFORM.match(/linux/) + system("xdg-open #{ROOT_PATH}/media/music") + else + # TODO. + end + end + + button get_image("#{ROOT_PATH}/media/icons/musicOn.png"), margin_left: 50, tip: "Toggle Sound Effects" do |button| + boolean = @jukebox.set_sfx(!@jukebox.play_sfx?) + + if boolean + button.value = get_image("#{ROOT_PATH}/media/icons/musicOn.png") + else + button.value = get_image("#{ROOT_PATH}/media/icons/musicOff.png") + end + end + end + + stack width: 1.0 do + button "Randomizer", width: 1.0, **DANGEROUS_BUTTON do + unless @clock.active? + push_state(Randomizer) + end + end + end + end + end + + @jukebox = Jukebox.new(@clock) + + @clock_proxy = ClockProxy.new(@clock, @jukebox) + + if @remote_control_mode + @server = ClockNet::Server.new(proxy_object: @clock_proxy) + @server.start + RemoteControl.server = @server + + @clock_proxy.register(:randomizer_changed, method(:randomizer_changed)) + end + end + + def draw + @background_image.draw(0, 0, -3) + @particle_emitters.each(&:draw) + @clock.draw + + super + end + + def update + super + @clock.update + @mouse.update + @jukebox.update + @particle_emitters.each(&:update) + + if @last_clock_state != @clock.active? + @particle_emitters.each { |emitter| @clock.active? ? emitter.clock_active! : emitter.clock_inactive! } + end + + if @remote_control_mode + @menu_container.hide + else + if @mouse.last_moved < 1.5 + @menu_container.show unless @menu_container.visible? + window.show_cursor = true + else + @menu_container.hide if @menu_container.visible? + window.show_cursor = false + end + end + + if @clock.value != @last_clock_display_value + @last_clock_display_value = @clock.value + + if @remote_control_mode && @server.active_client + @server.active_client.puts(ClockNet::PacketHandler.packet_clock_time(@last_clock_display_value)) + end + end + + if @last_track_name != @jukebox.current_track + track_changed(@jukebox.current_track) + end + + if @last_volume != @jukebox.volume + volume_changed(@jukebox.volume) + end + + @last_track_name = @jukebox.current_track + @last_volume = @jukebox.volume + @last_clock_state = @clock.active? + end + + def update_non_gui + @particle_emitters.each(&:update) + @jukebox.update + end + + def button_down(id) + super + + @mouse.button_down(id) + + case id + when Gosu::KB_ESCAPE + @escape_counter += 1 + + if @escape_counter >= 3 + @server&.close + + window.fullscreen = false + window.pop_state + end + else + @escape_counter = 0 + end + end + + def track_changed(name) + @current_song_label.value = File.basename(name) + end + + def volume_changed(float) + @current_volume_label.value = "#{(float.round(1) * 100.0).round}%" + end + + def randomizer_changed(boolean) + if boolean + push_state(Randomizer) + else + pop_state if current_state.is_a?(Randomizer) + end + end + + class Mouse + def initialize(window) + @window = window + @last_moved = 0 + + @last_position = CyberarmEngine::Vector.new(@window.mouse_x, @window.mouse_y) + end + + def update + position = CyberarmEngine::Vector.new(@window.mouse_x, @window.mouse_y) + + if @last_position != position + @last_position = position + @last_moved = Gosu.milliseconds + end + end + + def button_down(id) + case id + when Gosu::MS_LEFT, Gosu::MS_MIDDLE, Gosu::MS_RIGHT + @last_moved = Gosu.milliseconds + end + end + + def last_moved + (Gosu.milliseconds - @last_moved) / 1000.0 + end + end + end + end +end diff --git a/lib/pages/drive_team_rotation_generator.rb b/lib/pages/drive_team_rotation_generator.rb index 0a59859..bac8bf6 100644 --- a/lib/pages/drive_team_rotation_generator.rb +++ b/lib/pages/drive_team_rotation_generator.rb @@ -42,7 +42,7 @@ module TAC flow(width: 1.0, margin_bottom: 20) do @role_name = edit_line "", placeholder: "Add role", width: 0.9 - button get_image("#{TAC::ROOT_PATH}/media/icons/plus.png"), image_width: 0.1 do + button get_image("#{TAC::ROOT_PATH}/media/icons/plus.png"), image_width: 0.1, tip: "Add role" do if @role_name.value.strip.length.positive? @roles.push(@role_name.value.strip) @role_name.value = "" @@ -61,7 +61,7 @@ module TAC flow(width: 1.0, margin_bottom: 20) do @roster_name = edit_line "", placeholder: "Add name", width: 0.9 - button get_image("#{TAC::ROOT_PATH}/media/icons/plus.png"), image_width: 0.1 do + button get_image("#{TAC::ROOT_PATH}/media/icons/plus.png"), image_width: 0.1, tip: "Add name" do if @roster_name.value.strip.length.positive? @roster.push(@roster_name.value.strip) @roster_name.value = "" @@ -96,7 +96,7 @@ module TAC background i.even? ? 0xff_007000 : 0xff_006000 tagline name, width: 0.9 - button "X", width: 0.1, text_size: 18, **THEME_DANGER_BUTTON do + button get_image("#{TAC::ROOT_PATH}/media/icons/trashcan.png"), image_width: 0.1, tip: "Remove role", **THEME_DANGER_BUTTON do @roles.delete(name) populate_roles end @@ -112,7 +112,7 @@ module TAC background i.even? ? 0xff_007000 : 0xff_006000 tagline name, width: 0.9 - button "X", width: 0.1, text_size: 18, **THEME_DANGER_BUTTON do + button get_image("#{TAC::ROOT_PATH}/media/icons/trashcan.png"), image_width: 0.1, tip: "Remove name", **THEME_DANGER_BUTTON do @roster.delete(name) populate_roster end diff --git a/lib/pages/game_clock.rb b/lib/pages/game_clock.rb index a09e79c..0df68fe 100644 --- a/lib/pages/game_clock.rb +++ b/lib/pages/game_clock.rb @@ -2,14 +2,41 @@ module TAC class Pages class GameClock < Page def setup - header_bar("Practice Game Clock") + header_bar("Game Clock") body.clear do - stack(width: 1.0, height: 1.0) do - label TAC::NAME, width: 1.0, text_size: 48, text_align: :center + flow(width: 1.0, height: 1.0) do + @command_options = flow(width: 1.0) do + stack(width: 0.3) do + end - stack(width: 1.0, height: 8) do - background 0xff_006000 + stack(width: 0.4) do + banner "Choose Mode", width: 1.0, text_align: :center + title "Local", width: 1.0, text_align: :center + + button "Game Clock", width: 1.0 do + push_state(PracticeGameClock::View) + + window.fullscreen = true + end + + button "Dual Screen Game Clock", width: 1.0, enabled: false do + end + + title "Remote", width: 1.0, text_align: :center, margin_top: 32 + button "Game Clock Display", width: 1.0 do + push_state(PracticeGameClock::View, remote_control_mode: true) + + window.fullscreen = true + end + + button "Game Clock Remote Control", width: 1.0 do + push_state(PracticeGameClock::RemoteControl::NetConnect) + end + end + + stack(width: 0.3) do + end end end end diff --git a/lib/states/editor.rb b/lib/states/editor.rb index ee1250c..71a8df2 100644 --- a/lib/states/editor.rb +++ b/lib/states/editor.rb @@ -94,9 +94,13 @@ class Editor < CyberarmEngine::GuiState page(TAC::Pages::DriveTeamRotationGenerator) end - button get_image("#{TAC::ROOT_PATH}/media/icons/trophy.png"), margin: 4, tip: "Practice Game Clock", image_width: 1.0 do + button get_image("#{TAC::ROOT_PATH}/media/icons/trophy.png"), margin: 4, tip: "Game Clock", image_width: 1.0 do page(TAC::Pages::GameClock) end + + button get_image("#{TAC::ROOT_PATH}/media/icons/power.png"), margin: 4, tip: "Exit", image_width: 1.0, **TAC::THEME_DANGER_BUTTON do + window.close + end end @content = stack(width: window.width - @navigation.style.width, height: 1.0) do diff --git a/media/background.png b/media/background.png new file mode 100644 index 0000000..30e289b Binary files /dev/null and b/media/background.png differ diff --git a/media/background.svg b/media/background.svg new file mode 100644 index 0000000..c959e66 --- /dev/null +++ b/media/background.svg @@ -0,0 +1,6240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media/music/.gitkeep b/media/music/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/particles/.gitkeep b/media/particles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/media/sounds/.gitkeep b/media/sounds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/timecrafters_configuration_tool.rb b/timecrafters_configuration_tool.rb index a886bb0..39da3a9 100644 --- a/timecrafters_configuration_tool.rb +++ b/timecrafters_configuration_tool.rb @@ -51,6 +51,24 @@ require_relative "lib/tacnet/client" require_relative "lib/tacnet/connection" require_relative "lib/tacnet/server" +require_relative "lib/game_clock/view" +require_relative "lib/game_clock/clock" +require_relative "lib/game_clock/event_handlers" +require_relative "lib/game_clock/clock_controller" +require_relative "lib/game_clock/jukebox" +require_relative "lib/game_clock/theme" +require_relative "lib/game_clock/clock_proxy" +require_relative "lib/game_clock/logger" +require_relative "lib/game_clock/particle_emitter" +require_relative "lib/game_clock/randomizer" +require_relative "lib/game_clock/remote_control" +require_relative "lib/game_clock/remote_proxy" +require_relative "lib/game_clock/net/client" +require_relative "lib/game_clock/net/server" +require_relative "lib/game_clock/net/connection" +require_relative "lib/game_clock/net/packet_handler" +require_relative "lib/game_clock/net/packet" + # Thread.abort_on_exception = true USE_REDESIGN = ARGV.include?("--redesign")