diff --git a/interface/AvaloniaUI/ViewModels/CharacterViewModel.cs b/interface/AvaloniaUI/ViewModels/CharacterViewModel.cs index 9cf6f9eb..347ecb5e 100644 --- a/interface/AvaloniaUI/ViewModels/CharacterViewModel.cs +++ b/interface/AvaloniaUI/ViewModels/CharacterViewModel.cs @@ -38,7 +38,7 @@ public partial class CharacterViewModel : ViewModelBase [ObservableProperty] private CharacterType characterType; - public string Coordinates => $"坐标 ({PosX / 1000},{PosY / 1000})"; + public string Coordinates => $"坐标 ({PosX / 1000.0:0.0},{PosY / 1000.0:0.0})"; public string HealthText => $"生命值 {Hp}"; diff --git a/interface/AvaloniaUI/ViewModels/MainWindowViewModel.cs b/interface/AvaloniaUI/ViewModels/MainWindowViewModel.cs index 85be7576..0bc4cc03 100644 --- a/interface/AvaloniaUI/ViewModels/MainWindowViewModel.cs +++ b/interface/AvaloniaUI/ViewModels/MainWindowViewModel.cs @@ -19,6 +19,8 @@ namespace THUAI9_Avalonia.ViewModels public partial class MainWindowViewModel : ViewModelBase { private const string DefaultServerAddress = "127.0.0.1:8888"; + private const long SpectatorTeamId = 0; + private const int SpectatorSideFlag = 0; private static readonly TimeSpan AutoReconnectInterval = TimeSpan.FromSeconds(2); [ObservableProperty] @@ -60,6 +62,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly Dictionary _team3CharacterIndex = new(); private readonly Dictionary _team4CharacterIndex = new(); private readonly MapDynamicStateManager _dynamicStateManager; + private readonly long _spectatorPlayerId = 2023 + Environment.ProcessId; private Channel? _channel; private AvailableService.AvailableServiceClient? _client; @@ -237,8 +240,17 @@ private async Task TryConnectOnceAsync(string serverAddress, CancellationT } _isConnected = true; - ConnectionStatus = "已连接"; - LogConsoleVM.AddLog("实时注册已禁用,当前客户端不会自动占用队伍", "INFO"); + bool streamStarted = await StartSpectatorStreamAsync(cancellationToken); + if (!streamStarted) + { + ReleaseConnectionResources(); + _isConnected = false; + ConnectionStatus = "等待服务器"; + return false; + } + + ConnectionStatus = "等待首帧"; + LogConsoleVM.AddLog("当前以 spectator 身份接入实时流,不占用任何队伍。", "INFO"); return true; } catch @@ -266,6 +278,48 @@ private void ReleaseConnectionResources() } } + private async Task StartSpectatorStreamAsync(CancellationToken cancellationToken) + { + if (_client == null) + { + return false; + } + + try + { + _stream?.Dispose(); + _stream = null; + _hasReceivedFirstFrame = false; + + var request = new RegisterFactoryMsg + { + TeamId = SpectatorTeamId, + PlayerId = _spectatorPlayerId, + SideFlag = SpectatorSideFlag + }; + + _stream = _client.RegisterFactory(request, cancellationToken: cancellationToken); + LogConsoleVM.AddLog($"已发起实时观战流注册:SpectatorId={_spectatorPlayerId}", "INFO"); + _ = ReceiveMessagesAsync(); + await Task.Yield(); + return true; + } + catch (RpcException ex) + { + LogConsoleVM.AddLog($"实时观战流注册失败:{ex.Status.StatusCode} - {ex.Status.Detail}", "ERROR"); + _stream?.Dispose(); + _stream = null; + return false; + } + catch (Exception ex) + { + LogConsoleVM.AddLog($"实时观战流注册失败:{ex.Message}", "ERROR"); + _stream?.Dispose(); + _stream = null; + return false; + } + } + private async Task ReceiveMessagesAsync() { try @@ -313,8 +367,8 @@ private void ProcessMessage(MessageToClient message) if (!_hasReceivedFirstFrame) { _hasReceivedFirstFrame = true; - ConnectionStatus = "已收到首帧"; - LogConsoleVM.AddLog("已收到首帧游戏消息", "SUCCESS"); + ConnectionStatus = "实时观战中"; + LogConsoleVM.AddLog("已收到首帧实时游戏消息", "SUCCESS"); } UpdateCharacters(message); @@ -363,18 +417,22 @@ private void UpdateCharacters(MessageToClient message) Hp = data.Hp, PosX = data.X, PosY = data.Y, - ActiveState = GetCharacterStateName(data.CharacterActiveState) + ActiveState = GetCharacterStateName(data.CharacterActiveState, keepMoving: false) }; - targetList.Add(newCharacter); + InsertCharacterSorted(targetList, newCharacter); targetIndex[data.Guid] = newCharacter; UpdateCharacterOnMap(data, newCharacter.MaxHp); continue; } - string activeState = GetCharacterStateName(data.CharacterActiveState); - bool visualChanged = existingCharacter.PosX != data.X + bool movedThisFrame = existingCharacter.PosX != data.X || existingCharacter.PosY != data.Y + ; + string activeState = GetCharacterStateName( + data.CharacterActiveState, + keepMoving: existingCharacter.ActiveState == "移动中" && movedThisFrame); + bool visualChanged = movedThisFrame || existingCharacter.Hp != data.Hp || existingCharacter.ActiveState != activeState || existingCharacter.TeamId != data.TeamId; @@ -399,9 +457,26 @@ private void UpdateCharacters(MessageToClient message) private void UpdateCharacterOnMap(MessageOfCharacter data, int maxHp) { - int gridX = data.X / 1000; - int gridY = data.Y / 1000; - _mapView?.UpdateCharacterOnMap(data.Guid, gridX, gridY, (int)data.TeamId, data.Hp, maxHp); + _mapView?.UpdateCharacterOnMap( + data.Guid, + data.X, + data.Y, + (int)data.TeamId, + data.Hp, + maxHp, + data.PlayerId, + data.CharacterType); + } + + private static void InsertCharacterSorted(ObservableCollection targetList, CharacterViewModel character) + { + int insertIndex = 0; + while (insertIndex < targetList.Count && targetList[insertIndex].CharacterId <= character.CharacterId) + { + insertIndex++; + } + + targetList.Insert(insertIndex, character); } private ObservableCollection? GetTeamList(long teamId) @@ -596,11 +671,11 @@ private string GetCharacterName(CharacterType type) }; } - private static string GetCharacterStateName(CharacterState state) + private static string GetCharacterStateName(CharacterState state, bool keepMoving) { return state switch { - CharacterState.None => "未知", + CharacterState.None => keepMoving ? "移动中" : "空闲", CharacterState.Idle => "空闲", CharacterState.Harvesting => "采集中", CharacterState.Attacking => "攻击中", diff --git a/interface/AvaloniaUI/ViewModels/MapDynamicStateManager.cs b/interface/AvaloniaUI/ViewModels/MapDynamicStateManager.cs index 1494a74b..e14e49c6 100644 --- a/interface/AvaloniaUI/ViewModels/MapDynamicStateManager.cs +++ b/interface/AvaloniaUI/ViewModels/MapDynamicStateManager.cs @@ -212,8 +212,8 @@ private void UpsertComputeCenter(MessageOfComputeCenter center, HashSet CellX = center.X / 1000, CellY = center.Y / 1000, Label = center.OwnerTeamId > 0 - ? $"队{center.OwnerTeamId}" - : center.OccupyProgress > 0 ? $"{center.OccupyProgress}%" : "算力", + ? center.OwnerTeamId.ToString(CultureInfo.InvariantCulture) + : center.OccupyProgress > 0 ? center.OccupyProgress.ToString(CultureInfo.InvariantCulture) : string.Empty, Tooltip = $"算力中心 #{center.CenterId}\n归属:{(center.OwnerTeamId > 0 ? GetTeamName(center.OwnerTeamId) : "中立")}\n占领进度:{center.OccupyProgress}", Background = center.OwnerTeamId > 0 ? GetTeamBrush(center.OwnerTeamId) : Brushes.LightBlue, BorderBrush = Brushes.White, @@ -234,7 +234,7 @@ private void UpsertMarket(MessageOfMarket market, HashSet seenMarkets) Kind = MapOverlayKind.Market, CellX = market.X / 1000, CellY = market.Y / 1000, - Label = market.PriceList.Count > 0 ? $"市场{market.PriceList.Count}" : "市场", + Label = string.Empty, Tooltip = BuildMarketTooltip(market), Background = Brushes.MediumPurple, BorderBrush = Brushes.White, diff --git a/interface/AvaloniaUI/ViewModels/MapViewModel.cs b/interface/AvaloniaUI/ViewModels/MapViewModel.cs index 8054de4d..b49081d1 100644 --- a/interface/AvaloniaUI/ViewModels/MapViewModel.cs +++ b/interface/AvaloniaUI/ViewModels/MapViewModel.cs @@ -101,7 +101,6 @@ private void UpdateCellType(int x, int y, PlaceType placeType) case PlaceType.Factory: cell.CellType = MapCellType.Factory; cell.DisplayColor = new SolidColorBrush(Colors.Cyan); - cell.DisplayText = "厂"; break; case PlaceType.Space: cell.CellType = MapCellType.Space; @@ -118,17 +117,14 @@ private void UpdateCellType(int x, int y, PlaceType placeType) case PlaceType.Resource: cell.CellType = MapCellType.Resource; cell.DisplayColor = new SolidColorBrush(Colors.Gold); - cell.DisplayText = "资"; break; case PlaceType.ComputeCenter: cell.CellType = MapCellType.ComputeCenter; cell.DisplayColor = new SolidColorBrush(Colors.LightBlue); - cell.DisplayText = "算"; break; case PlaceType.Market: cell.CellType = MapCellType.Market; cell.DisplayColor = new SolidColorBrush(Colors.LightYellow); - cell.DisplayText = "市"; break; default: cell.CellType = MapCellType.Space; diff --git a/interface/AvaloniaUI/Views/MapView.axaml.cs b/interface/AvaloniaUI/Views/MapView.axaml.cs index e5b1cf16..7dadeaae 100644 --- a/interface/AvaloniaUI/Views/MapView.axaml.cs +++ b/interface/AvaloniaUI/Views/MapView.axaml.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Shapes; using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; +using Protobuf; using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -17,13 +18,16 @@ public partial class MapView : UserControl private sealed class CharacterVisual { public required Grid Root { get; init; } - public required Ellipse Body { get; init; } + public required Border Body { get; init; } public required Border HpBar { get; init; } - public int GridX { get; set; } - public int GridY { get; set; } + public required TextBlock Label { get; init; } + public double GameX { get; set; } + public double GameY { get; set; } public int TeamId { get; set; } public int Hp { get; set; } public int MaxHp { get; set; } + public long PlayerId { get; set; } + public CharacterType CharacterType { get; set; } } private const int GridSize = 50; @@ -242,10 +246,8 @@ private void AddDynamicOverlayVisual(MapOverlayItem overlay) var border = new Border { - Width = 18, - Height = 18, - CornerRadius = new CornerRadius(4), - BorderThickness = new Thickness(1), + Width = CellSize, + Height = CellSize, Child = textBlock }; @@ -262,16 +264,11 @@ private void UpdateDynamicOverlayVisual(MapOverlayItem overlay) return; } - border.Background = overlay.Background; - border.BorderBrush = overlay.BorderBrush; - border.Opacity = overlay.Opacity; - border.CornerRadius = overlay.Kind switch - { - MapOverlayKind.Resource => new CornerRadius(9), - MapOverlayKind.ComputeCenter => new CornerRadius(3), - MapOverlayKind.Market => new CornerRadius(6), - _ => new CornerRadius(4) - }; + border.Background = Brushes.Transparent; + border.BorderBrush = Brushes.Transparent; + border.BorderThickness = new Thickness(0); + border.Opacity = 1; + border.CornerRadius = new CornerRadius(0); if (border.Child is TextBlock textBlock) { @@ -279,9 +276,11 @@ private void UpdateDynamicOverlayVisual(MapOverlayItem overlay) textBlock.Foreground = overlay.Foreground; } + border.Width = CellSize; + border.Height = CellSize; ToolTip.SetTip(border, overlay.Tooltip); - Canvas.SetLeft(border, overlay.CellY * CellSize + 1); - Canvas.SetTop(border, overlay.CellX * CellSize + 1); + Canvas.SetLeft(border, overlay.CellY * CellSize); + Canvas.SetTop(border, overlay.CellX * CellSize); } private void RemoveDynamicOverlayVisual(string key) @@ -298,98 +297,163 @@ private void RemoveDynamicOverlayVisual(string key) } } - public void UpdateCharacterOnMap(long guid, int gridX, int gridY, int teamId, int hp, int maxHp) + public void UpdateCharacterOnMap(long guid, int gameX, int gameY, int teamId, int hp, int maxHp, long playerId, CharacterType characterType) { if (_characterCanvas == null) { return; } - double x = gridY * CellSize + CellSize / 2; - double y = gridX * CellSize + CellSize / 2; - - var teamColor = teamId switch - { - 1 => Brushes.Red, - 2 => Brushes.Blue, - 3 => Brushes.Green, - 4 => Brushes.Orange, - _ => Brushes.Gray - }; + double x = gameY / 1000.0 * CellSize + CellSize / 2; + double y = gameX / 1000.0 * CellSize + CellSize / 2; + var teamColor = GetTeamBrush(teamId); if (_characterElements.TryGetValue(guid, out var visual)) { - if (visual.GridX != gridX || visual.GridY != gridY) + if (Math.Abs(visual.GameX - gameX) > double.Epsilon || Math.Abs(visual.GameY - gameY) > double.Epsilon) { - Canvas.SetLeft(visual.Root, x - 10); + Canvas.SetLeft(visual.Root, x - 8); Canvas.SetTop(visual.Root, y - 10); - visual.GridX = gridX; - visual.GridY = gridY; + visual.GameX = gameX; + visual.GameY = gameY; } - if (visual.TeamId != teamId) + if (visual.TeamId != teamId || visual.CharacterType != characterType) { - visual.Body.Fill = teamColor; + ApplyBodyStyle(visual.Body, characterType, teamColor); visual.TeamId = teamId; + visual.CharacterType = characterType; } if (visual.Hp != hp || visual.MaxHp != maxHp) { - visual.HpBar.Width = Math.Max(4, 20 * ((double)hp / Math.Max(maxHp, 1))); + visual.HpBar.Width = Math.Max(4, 24 * ((double)hp / Math.Max(maxHp, 1))); visual.Hp = hp; visual.MaxHp = maxHp; } + if (visual.PlayerId != playerId) + { + visual.Label.Text = $"P{playerId}"; + visual.PlayerId = playerId; + } + return; } - var body = new Ellipse + var body = new Border { - Width = 16, - Height = 16, - Fill = teamColor, - Stroke = Brushes.White, - StrokeThickness = 1 + Width = 10, + Height = 10, + BorderBrush = Brushes.White, + BorderThickness = new Thickness(1) + }; + ApplyBodyStyle(body, characterType, teamColor); + + var label = new TextBlock + { + Text = $"P{playerId}", + FontSize = 8, + FontWeight = FontWeight.Bold, + Foreground = teamColor, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; - var newCharacterGrid = new Grid(); - newCharacterGrid.Children.Add(body); - var newHpBarContainer = new Grid(); - var newHpBarBackground = new Border + var hpBarBackground = new Border { - Width = 20, + Width = 16, Height = 3, Background = Brushes.DarkGray, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; - var newHpBar = new Border + var hpBar = new Border { - Width = Math.Max(4, 20 * ((double)hp / Math.Max(maxHp, 1))), + Width = Math.Max(3, 16 * ((double)hp / Math.Max(maxHp, 1))), Height = 3, Background = Brushes.LimeGreen, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left }; - newHpBarContainer.Children.Add(newHpBarBackground); - newHpBarContainer.Children.Add(newHpBar); - newCharacterGrid.Children.Add(newHpBarContainer); - Canvas.SetLeft(newCharacterGrid, x - 10); - Canvas.SetTop(newCharacterGrid, y - 10); + var hpBarContainer = new Grid + { + Margin = new Thickness(0, 0, 0, 1) + }; + hpBarContainer.Children.Add(hpBarBackground); + hpBarContainer.Children.Add(hpBar); + + var root = new Grid + { + Width = 16, + Height = 22, + RowDefinitions = new RowDefinitions("Auto,Auto,*") + }; + Grid.SetRow(hpBarContainer, 0); + Grid.SetRow(body, 1); + Grid.SetRow(label, 2); + root.Children.Add(hpBarContainer); + root.Children.Add(body); + root.Children.Add(label); - _characterCanvas.Children.Add(newCharacterGrid); + Canvas.SetLeft(root, x - 8); + Canvas.SetTop(root, y - 10); + + _characterCanvas.Children.Add(root); _characterElements[guid] = new CharacterVisual { - Root = newCharacterGrid, + Root = root, Body = body, - HpBar = newHpBar, - GridX = gridX, - GridY = gridY, + HpBar = hpBar, + Label = label, + GameX = gameX, + GameY = gameY, TeamId = teamId, Hp = hp, - MaxHp = maxHp + MaxHp = maxHp, + PlayerId = playerId, + CharacterType = characterType }; } + private static IBrush GetTeamBrush(int teamId) + { + return teamId switch + { + 1 => Brushes.Red, + 2 => Brushes.Blue, + 3 => Brushes.Green, + 4 => Brushes.Orange, + _ => Brushes.Gray + }; + } + + private static void ApplyBodyStyle(Border body, CharacterType characterType, IBrush teamColor) + { + body.Background = teamColor; + switch (characterType) + { + case CharacterType.Drone: + body.CornerRadius = new CornerRadius(5); + body.Width = 10; + body.Height = 10; + break; + case CharacterType.Robot: + body.CornerRadius = new CornerRadius(2); + body.Width = 10; + body.Height = 10; + break; + case CharacterType.AutonomousCar: + body.CornerRadius = new CornerRadius(3); + body.Width = 14; + body.Height = 8; + break; + default: + body.CornerRadius = new CornerRadius(4); + body.Width = 10; + body.Height = 10; + break; + } + } + public void RemoveCharacterFromMap(long guid) { if (_characterCanvas == null)