diff --git a/project.godot b/project.godot index 1639448..0958641 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="connect4-moderator-observer" -run/main_scene="uid://cr8fi0e4r88s8" +run/main_scene="uid://dcx5nvs0pa7me" config/features=PackedStringArray("4.5", "C#", "Forward Plus") config/icon="res://icon.svg" diff --git a/scenes/bracket_view.tscn b/scenes/bracket_view.tscn new file mode 100644 index 0000000..563fcc4 --- /dev/null +++ b/scenes/bracket_view.tscn @@ -0,0 +1,23 @@ +[gd_scene load_steps=2 format=3 uid="uid://rl33x81cxlh0"] + +[ext_resource type="Script" uid="uid://dm25u0a2lqk2x" path="res://scripts/BracketScene.cs" id="1_dvj3m"] + +[node name="BracketView" type="Control" node_paths=PackedStringArray("players")] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_dvj3m") +players = NodePath("Tree") + +[node name="Tree" type="Tree" parent="."] +layout_mode = 1 +anchors_preset = 9 +anchor_bottom = 1.0 +offset_right = 348.0 +grow_vertical = 2 +columns = 3 +column_titles_visible = true +scroll_horizontal_enabled = false diff --git a/scenes/main_menu.tscn b/scenes/main_menu.tscn new file mode 100644 index 0000000..cca38e9 --- /dev/null +++ b/scenes/main_menu.tscn @@ -0,0 +1,63 @@ +[gd_scene load_steps=4 format=3 uid="uid://dcx5nvs0pa7me"] + +[ext_resource type="Script" uid="uid://bk22f71oximjk" path="res://scripts/AddressUI.cs" id="1_l6cm7"] +[ext_resource type="Script" uid="uid://cpjbiqn26khck" path="res://scripts/ConnectButtonUI.cs" id="2_ekxnf"] +[ext_resource type="PackedScene" uid="uid://rl33x81cxlh0" path="res://scenes/bracket_view.tscn" id="3_bqqt6"] + +[node name="Control" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Address" type="TextEdit" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -250.0 +offset_top = -20.0 +offset_right = 250.0 +offset_bottom = 20.0 +grow_horizontal = 2 +grow_vertical = 2 +placeholder_text = "Server Address" +script = ExtResource("1_l6cm7") + +[node name="Button" type="Button" parent="." node_paths=PackedStringArray("addressUI", "errorLabel")] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -250.0 +offset_top = 27.25 +offset_right = 250.0 +offset_bottom = 67.25 +grow_horizontal = 2 +grow_vertical = 2 +text = "Connect" +script = ExtResource("2_ekxnf") +addressUI = NodePath("../Address") +errorLabel = NodePath("../Label") +nextScene = ExtResource("3_bqqt6") + +[node name="Label" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -68.5 +offset_top = 75.149994 +offset_right = 68.5 +offset_bottom = 98.149994 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_colors/font_color = Color(1, 0, 0, 1) diff --git a/scripts/AddressUI.cs b/scripts/AddressUI.cs new file mode 100644 index 0000000..8a88187 --- /dev/null +++ b/scripts/AddressUI.cs @@ -0,0 +1,10 @@ +using Godot; +using System; + +public partial class AddressUI : TextEdit +{ + public override void _Ready() + { + Text = Connection.WS_DEFAULT_ADDRESS; + } +} diff --git a/scripts/AddressUI.cs.uid b/scripts/AddressUI.cs.uid new file mode 100644 index 0000000..acd32ae --- /dev/null +++ b/scripts/AddressUI.cs.uid @@ -0,0 +1 @@ +uid://bk22f71oximjk diff --git a/scripts/BracketScene.cs b/scripts/BracketScene.cs new file mode 100644 index 0000000..4bbb9e6 --- /dev/null +++ b/scripts/BracketScene.cs @@ -0,0 +1,46 @@ +using Godot; +using System; +using System.Collections.Generic; + +public partial class BracketScene : Control +{ + [Export] public Tree players; + + public override void _Ready() + { + players.SetColumnTitle(0, "Name"); + players.SetColumnTitle(1, "Ready"); + players.SetColumnTitle(2, "Playing"); + + Connection.Instance.OnUpdatedPlayers += UpdatePlayers; + Connection.Instance.OnBecomeAdmin += BecomeAdmin; + Connection.Instance.OnWatchGameAck += TransitionToBoard; + } + + public override void _ExitTree() + { + Connection.Instance.OnUpdatedPlayers -= UpdatePlayers; + } + + private void UpdatePlayers(List playerList) + { + players.Clear(); + foreach (var playerData in playerList) + { + var item = players.CreateItem(); + item.SetText(0, playerData.username); + item.SetText(1, playerData.isReady ? "Yes" : "No"); + item.SetText(2, playerData.isPlaying ? "Yes" : "No"); + } + } + + private void BecomeAdmin() + { + // TODO + } + + private void TransitionToBoard() + { + // TODO + } +} diff --git a/scripts/BracketScene.cs.uid b/scripts/BracketScene.cs.uid new file mode 100644 index 0000000..a71c2d3 --- /dev/null +++ b/scripts/BracketScene.cs.uid @@ -0,0 +1 @@ +uid://dm25u0a2lqk2x diff --git a/scripts/ConnectButtonUI.cs b/scripts/ConnectButtonUI.cs new file mode 100644 index 0000000..989115a --- /dev/null +++ b/scripts/ConnectButtonUI.cs @@ -0,0 +1,23 @@ +using Godot; +using System; + +public partial class ConnectButtonUI : Button +{ + [Export] public TextEdit addressUI; + [Export] public Label errorLabel; + [Export] public PackedScene nextScene; + + public override void _Pressed() + { + if (Connection.Instance.Connect(addressUI.Text)) + { + GD.Print("Success!"); + GetTree().ChangeSceneToPacked(nextScene); + } + else + { + errorLabel.Text = "Couldn't connect to server!"; + } + base._Pressed(); + } +} diff --git a/scripts/ConnectButtonUI.cs.uid b/scripts/ConnectButtonUI.cs.uid new file mode 100644 index 0000000..7cd5d5f --- /dev/null +++ b/scripts/ConnectButtonUI.cs.uid @@ -0,0 +1 @@ +uid://cpjbiqn26khck diff --git a/scripts/Connection.cs b/scripts/Connection.cs index fa1b1fd..f7d1d23 100644 --- a/scripts/Connection.cs +++ b/scripts/Connection.cs @@ -1,16 +1,16 @@ using Godot; using System; using System.Collections.Generic; -using System.Net.WebSockets; using System.Threading; public partial class Connection : Node { - private const string WS_DEFAULT_ADDRESS = "wss://connect4.abunchofknowitalls.com"; + public const string WS_DEFAULT_ADDRESS = "wss://connect4.abunchofknowitalls.com"; public static Connection Instance { get; private set; } private WebSocketPeer _webSocket = new WebSocketPeer(); + private bool _firstConnect = true; private Thread _gameListThread; private bool _gameListThreadRunning; @@ -23,6 +23,7 @@ public partial class Connection : Node public event Action OnGameTerminated; public event Action OnOpponentMove; + public event Action OnWatchGameAck; public event Action OnObserveWin; public event Action OnObserveDraw; public event Action OnObserveTerminated; @@ -30,28 +31,56 @@ public partial class Connection : Node public event Action> OnUpdatedMatches; public event Action> OnUpdatedPlayers; public event Action> OnTournamentEnd; + public event Action OnBecomeAdmin; + // Already prints to console public event Action OnError; public bool IsAdmin { get; private set; } public bool IsPlayer { get; private set; } + + public MatchData CurrentObservingMatch { get; private set; } private bool IsSocketOpen => _webSocket.GetReadyState() == WebSocketPeer.State.Open; public override void _Ready() { Instance = this; + _webSocket.SetHeartbeatInterval(5.0); + _webSocket.HeartbeatInterval = 5.0; } - public void Connect(string address) + public bool Connect(string address) { - Error error = _webSocket.ConnectToUrl(address); - while (error != Error.Ok) + if (_webSocket.GetReadyState() == WebSocketPeer.State.Open) { - // TODO: back off so we don't DDOS - error = _webSocket.ConnectToUrl(address); + return false; } + + Error error = _webSocket.ConnectToUrl(address); + if (error != Error.Ok) + { + return false; + } + + _webSocket.Poll(); + + while (_webSocket.GetReadyState() == WebSocketPeer.State.Connecting) + { + _webSocket.Poll(); + Thread.Sleep(TimeSpan.FromMilliseconds(17)); + } + + if (_webSocket.GetReadyState() != WebSocketPeer.State.Open) + { + return false; + } + + _webSocket.SetHeartbeatInterval(5.0); + _webSocket.HeartbeatInterval = 5.0; + _firstConnect = false; + return true; } public override void _ExitTree() @@ -59,11 +88,11 @@ public partial class Connection : Node StopGameListRefreshLoop(); } - public override void _Process(double delta) + public override void _PhysicsProcess(double delta) { _webSocket.Poll(); WebSocketPeer.State state = _webSocket.GetReadyState(); - if (state == WebSocketPeer.State.Closing || state == WebSocketPeer.State.Closed) + if (state == WebSocketPeer.State.Closed && !_firstConnect) { StopGameListRefreshLoop(); GetTree().Quit(); @@ -99,10 +128,12 @@ public partial class Connection : Node SendCommand("CONNECT", clientId); } + public void SendReady() { SendCommand("READY"); } + public void SendPlay(int column) { SendCommand("PLAY", column.ToString()); @@ -113,6 +144,7 @@ public partial class Connection : Node { SendCommand("GAME", "LIST"); } + private void StartGameListRefreshLoop() { if (_gameListThreadRunning) @@ -139,31 +171,35 @@ public partial class Connection : Node }; _gameListThread.Start(); } + private void StopGameListRefreshLoop() + { + if (!_gameListThreadRunning) { - if (!_gameListThreadRunning) - { - return; - } - - _gameListThreadRunning = false; - _gameListThread?.Join(); - _gameListThread = null; + return; } + + _gameListThreadRunning = false; + _gameListThread?.Join(); + _gameListThread = null; + } + public void UpdatePlayerList() { SendCommand("PLAYER", "LIST"); } + public void SendWatchGame(int matchID) { SendCommand("GAME", "WATCH:" + matchID); } + public void AdminAuth(string password) - { - if (IsAdmin) return; - SendCommand("ADMIN", "AUTH:" + password); - } - + { + if (IsAdmin) return; + SendCommand("ADMIN", "AUTH:" + password); + } + // Admin commands public void KickPlayer(string playerId) @@ -171,16 +207,19 @@ public partial class Connection : Node if (!IsAdmin) return; SendCommand("ADMIN", "KICK:" + playerId); } + public void StartTournament() { if (!IsAdmin) return; SendCommand("TOURNAMENT", "START"); } + public void TerminateGame() { if (!IsAdmin) return; SendCommand("GAME", "TERMINATE"); } + public void SetTournamentWait(float waitTime) { if (!IsAdmin) return; @@ -232,12 +271,14 @@ public partial class Connection : Node IsPlayer = true; OnConnected?.Invoke(); } + break; case "READY": if (body.Equals("ACK", StringComparison.OrdinalIgnoreCase)) { OnReadyAcknowledged?.Invoke(); } + break; case "GAME": HandleGameMessage(body); @@ -254,12 +295,15 @@ public partial class Connection : Node { GD.PrintErr($"Invalid opponent column: {body}"); } + break; case "ADMIN": if (body == "AUTH:ACK") { IsAdmin = true; + OnBecomeAdmin?.Invoke(); } + break; case "TOURNAMENT": HandleTournamentMessage(body); @@ -273,6 +317,7 @@ public partial class Connection : Node break; } } + private void HandleTournamentMessage(string body) { if (string.IsNullOrWhiteSpace(body)) @@ -295,11 +340,13 @@ public partial class Connection : Node string[] data = entry.Split(','); playerScoreboard.Add((data[0], int.Parse(data[1]))); } + OnTournamentEnd?.Invoke(playerScoreboard); break; } } } + private void HandlePlayerList(string body) { if (string.IsNullOrWhiteSpace(body)) @@ -316,17 +363,26 @@ public partial class Connection : Node case "LIST": { List players = new List(); + + if (segments.Length < 2) + { + OnUpdatedPlayers?.Invoke(players); + break; + } + string[] entries = segments[1].Split("|"); foreach (string entry in entries) { string[] data = entry.Split(','); - players.Add(new PlayerData(data[0], bool.Parse(data[1]), bool.Parse(data[2]))); + players.Add(new PlayerData(data[0], bool.Parse(data[1]), bool.Parse(data[2]))); } + OnUpdatedPlayers?.Invoke(players); break; } } } + private void HandleGameMessage(string body) { if (string.IsNullOrWhiteSpace(body)) @@ -381,20 +437,34 @@ public partial class Connection : Node break; case "LIST": List matches = new List(); + + if (segments.Length < 2) + { + OnUpdatedMatches?.Invoke(matches); + break; + } + string[] entries = segments[1].Split("|"); foreach (string entry in entries) { string[] data = entry.Split(','); matches.Add(new MatchData(int.Parse(data[0]), data[1], data[2])); } + OnUpdatedMatches?.Invoke(matches); break; + case "WATCH": + string[] game = segments[2].Split(","); + CurrentObservingMatch = new MatchData(int.Parse(game[0]), game[1], game[2]); + OnWatchGameAck?.Invoke(); + break; default: GD.Print($"Unhandled GAME message: {body}"); break; } } } + private void HandleErrorMessage(string body) { if (string.IsNullOrWhiteSpace(body)) @@ -409,4 +479,4 @@ public partial class Connection : Node OnError?.Invoke(code, detail); } -} +} \ No newline at end of file