From 8c57885495616550bac96463ccf11889b4df9a40 Mon Sep 17 00:00:00 2001 From: Daniel Machado Date: Fri, 17 Jul 2026 07:55:48 +0200 Subject: [PATCH 1/2] Net victory screen: real end-of-match screen on both roles, no wire change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Networked matches now end with the victory screen v2 instead of just the winner banner, on host and guest alike: - VictoryScreen: the solo arena's screen extracted into one reusable component (ribbon + IS VICTORIOUS!, nav-switched ranking sheets on the plaque, styled button bar); Arena3DScene now feeds it its BattleStats sheets and buttons — same nodes, same look. - NetMatchStats (pure C#, TDD): both roles book the same per-tank hp stream (host from its world each tick, guest from every snapshot), so final standing, damage taken and repairs derive identically on every peer — zero protocol change, no stats bytes on the wire. - NetArena3DScene: on round decide both roles raise the screen ranked from that book; the card carries the online affordances (lobby host: Rematch sending the same lobby command; everyone: Leave) and the corner buttons hide beneath it. Return-to-room on the rematch reset unchanged. - Locale: stats.standing + net.team_label (EN/ES/DK). - Tests: 6 xUnit NetMatchStats + 3 GoDotTest end-of-match scene tests; full suites green (xUnit 695, GoDotTest 190). Co-Authored-By: Claude Fable 5 --- client/i18n/strings.csv | 2 + client/src/GameLogic/NetMatchStats.cs | 102 ++++ client/src/GameLogic/NetMatchStats.cs.uid | 1 + client/src/Presentation/Arena/Arena3DScene.cs | 438 ++---------------- .../src/Presentation/Arena/NetArena3DScene.cs | 97 +++- .../src/Presentation/Arena/VictoryScreen.cs | 434 +++++++++++++++++ .../Presentation/Arena/VictoryScreen.cs.uid | 1 + client/tests/GameLogic/NetMatchStatsTests.cs | 103 ++++ .../tests/GameLogic/NetMatchStatsTests.cs.uid | 1 + .../Presentation/NetArena3DSceneTests.cs | 128 +++++ 10 files changed, 908 insertions(+), 399 deletions(-) create mode 100644 client/src/GameLogic/NetMatchStats.cs create mode 100644 client/src/GameLogic/NetMatchStats.cs.uid create mode 100644 client/src/Presentation/Arena/VictoryScreen.cs create mode 100644 client/src/Presentation/Arena/VictoryScreen.cs.uid create mode 100644 client/tests/GameLogic/NetMatchStatsTests.cs create mode 100644 client/tests/GameLogic/NetMatchStatsTests.cs.uid diff --git a/client/i18n/strings.csv b/client/i18n/strings.csv index ad0488e..a359070 100644 --- a/client/i18n/strings.csv +++ b/client/i18n/strings.csv @@ -135,6 +135,8 @@ editor.theme_parkinglot,"Parking Lot","Aparcamiento","Parkeringsplads" editor.spawn,"Spawn","Aparición","Spawn" editor.scale,"Size","Tamaño","Størrelse" stats.repairs,"Repairs","Reparaciones","Reparationer" +stats.standing,"Standing","Clasificación","Stilling" +net.team_label,"Team {0}","Equipo {0}","Hold {0}" stats.assists,"Assists","Asistencias","Assists" editor.assets,"Assets","Recursos","Aktiver" editor.search,"Search...","Buscar...","Søg..." diff --git a/client/src/GameLogic/NetMatchStats.cs b/client/src/GameLogic/NetMatchStats.cs new file mode 100644 index 0000000..2ca041e --- /dev/null +++ b/client/src/GameLogic/NetMatchStats.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace TankGame.GameLogic; + +/// The networked match's stat book — the net victory screen's data source. No stats travel +/// on the wire: both roles feed the same per-tank hp stream (the host from its authoritative world +/// each tick, a guest from every snapshot's tank states), so damage taken, repairs and the final +/// standing all derive identically on every peer from observed hp deltas. Pure C#. +public sealed class NetMatchStats +{ + /// One slot's running tally. Mutable bookkeeping the screen reads at match end. + public sealed class SlotTally + { + public byte Slot { get; init; } + public string Name { get; set; } = string.Empty; + public int Team { get; set; } + public int Hp { get; internal set; } + public bool Alive { get; internal set; } = true; + public int DamageTaken { get; internal set; } + public int Repairs { get; internal set; } + + /// When this tank fell, as a monotonically increasing sequence — higher means it + /// outlived more of the field. Zero while alive. + internal int EliminatedAt { get; set; } + + internal bool Baselined { get; set; } + } + + private readonly Dictionary _bySlot = new(); + private readonly List _ordered = new(); // registration order = the roster's seat order + private int _eliminationSeq; + + /// Every observed slot's tally, in registration order. + public IReadOnlyList Tallies => _ordered; + + /// Seats a slot with its roster name and team before play (both roles derive the same + /// roster, so both books carry the same rows). + public void Register(byte slot, string name, int team) + { + var tally = GetOrCreate(slot); + tally.Name = name; + tally.Team = team; + } + + /// One hp sighting for a slot. The first sighting is the baseline; every later drop is + /// damage taken, every rise a repair, and the drop to zero stamps the elimination order. + public void Observe(byte slot, int hp) + { + var tally = GetOrCreate(slot); + if (!tally.Baselined) + { + tally.Baselined = true; + tally.Hp = hp; + tally.Alive = hp > 0; + return; + } + + if (hp < tally.Hp) + { + tally.DamageTaken += tally.Hp - hp; + } + else if (hp > tally.Hp) + { + tally.Repairs += hp - tally.Hp; + } + + tally.Hp = hp; + if (tally.Alive && hp <= 0) + { + tally.Alive = false; + tally.EliminatedAt = ++_eliminationSeq; + } + } + + /// The final standing: survivors first (healthiest on top), then the fallen ranked by + /// how long they lasted — a later death places higher. + public IReadOnlyList Standings() + => _ordered + .OrderByDescending(t => t.Alive) + .ThenByDescending(t => t.Alive ? t.Hp : t.EliminatedAt) + .ToList(); + + private SlotTally GetOrCreate(byte slot) + { + if (!_bySlot.TryGetValue(slot, out var tally)) + { + // A slot the roster never named (a stray snapshot) still gets a readable row. + tally = new SlotTally + { + Slot = slot, + Name = string.Format(CultureInfo.InvariantCulture, "Tank {0}", slot + 1), + Team = slot, + }; + _bySlot[slot] = tally; + _ordered.Add(tally); + } + + return tally; + } +} diff --git a/client/src/GameLogic/NetMatchStats.cs.uid b/client/src/GameLogic/NetMatchStats.cs.uid new file mode 100644 index 0000000..0fe4c38 --- /dev/null +++ b/client/src/GameLogic/NetMatchStats.cs.uid @@ -0,0 +1 @@ +uid://c1ob3bglya8yv diff --git a/client/src/Presentation/Arena/Arena3DScene.cs b/client/src/Presentation/Arena/Arena3DScene.cs index a859248..52eacef 100644 --- a/client/src/Presentation/Arena/Arena3DScene.cs +++ b/client/src/Presentation/Arena/Arena3DScene.cs @@ -87,9 +87,6 @@ public partial class Arena3DScene : Node3D /// True once the victory screen is up: the world stops stepping. public bool IsMatchOver { get; private set; } - private const string BackdropPath = "res://src/Presentation/Arena/ui/victory_bg.png"; - private const int MaxRows = 8; // the 4v4 tank cap — up to eight ranked rows, one per tank - // The ranking views, switched with the nav arrows (owner ask 2026-06-11/14): each is a full // ranking of every tank by one metric. // LowerIsBetter flips the ranking: deaths and damage taken rank ascending (fewer/less is better), @@ -104,31 +101,12 @@ private static readonly (string TitleKey, Func Value ("stats.assists", t => t.Assists, false), }; - // The victory screen is composed from real UI controls over a generated celebration backdrop - // (owner feedback 2026-06-14: the old screen floated text over a baked mock-up, which fought the - // live content). These colours echo the backdrop so the built ribbon/plaque/plates/badges match it. - private static readonly Color RibbonWood = new(0.45f, 0.27f, 0.12f); - private static readonly Color Gold = new(1f, 0.82f, 0.28f); - private static readonly Color PlaqueMetal = new(0.15f, 0.16f, 0.19f, 0.95f); - private static readonly Color PlateGold = new(0.84f, 0.62f, 0.16f); - private static readonly Color PlateSilver = new(0.57f, 0.60f, 0.63f); - private static readonly Color PlateInk = new(0.13f, 0.08f, 0.02f); - private static readonly Color AwardRed = new(0.62f, 0.10f, 0.08f); - private static readonly Color[] BadgeColours = { new(0.18f, 0.45f, 0.86f), new(0.80f, 0.17f, 0.15f) }; - - private int _viewIndex; - private Texture2D _bgArt = null!; - private Label _winnerName = null!; - private Label _viewTitle = null!; - private VBoxContainer _leaderboardRows = null!; - - /// The end of the match (owner ask 2026-06-11/14, rebuilt): freeze the world under a - /// generated celebration backdrop and compose the victory screen from real UI controls — a wood - /// ribbon with the champion's name and a big "IS VICTORIOUS!", a nav row whose arrows switch the - /// ranking sheet shown on a pill, a metal plaque of up to eight numbered gold/silver rows (one per - /// tank: badge, name, award tags, value), and styled New Game / Main Menu / Exit. Native - /// containers centre and resize it; nothing is anchored to baked artwork. Public so a test can - /// drive it without fighting a whole battle. + private VictoryScreen? _victory; + + /// The end of the match (owner ask 2026-06-11/14, rebuilt): freeze the world and show + /// the shared — the champion's ribbon, the nav-switched ranking + /// sheets built from this match's (award tags included), and styled + /// New Game / Main Menu / Exit. Public so a test can drive it without fighting a whole battle. public void ShowMatchOver(MatchResult result) { if (IsMatchOver) @@ -139,329 +117,49 @@ public void ShowMatchOver(MatchResult result) IsMatchOver = true; _sfx.PlayUi(SfxKind.Victory); - var viewport = GetViewport().GetVisibleRect().Size; - var cardWidth = Mathf.Min(viewport.X * 0.94f, 760f); - - var layer = new CanvasLayer { Name = "GameOverLayer" }; - - _bgArt = GD.Load(BackdropPath); - var backdrop = new TextureRect - { - Name = "Backdrop", - Texture = _bgArt, - StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered, - MouseFilter = Control.MouseFilterEnum.Ignore, - }; - backdrop.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); - layer.AddChild(backdrop); - - var scrim = new ColorRect - { - Name = "Scrim", - Color = new Color(0f, 0f, 0f, 0.30f), // darken the busy art so the plaque text reads - MouseFilter = Control.MouseFilterEnum.Ignore, - }; - scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); - layer.AddChild(scrim); - - // A centred portrait card: the container hierarchy positions and re-centres everything, so a - // window resize needs no manual maths (the font sizes, picked from the viewport, stay put). - var holder = new CenterContainer { Name = "CardHolder", MouseFilter = Control.MouseFilterEnum.Ignore }; - holder.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); - layer.AddChild(holder); - - var card = new VBoxContainer { Name = "VictoryCard" }; - card.AddThemeConstantOverride("separation", (int)(viewport.Y * 0.012f)); - holder.AddChild(card); - - card.AddChild(BuildTitleBlock(result, viewport)); - card.AddChild(BuildNavRow(viewport)); - card.AddChild(BuildPlaque(viewport, cardWidth)); - card.AddChild(BuildGameOverButtons(viewport)); - - AddChild(layer); - - _viewIndex = 0; - RebuildLeaderboard(viewport); - } - - // The wood ribbon carrying the winner's name, with a big gold "IS VICTORIOUS!" beneath it (hidden - // on a draw). Real controls, so the text never collides with baked art. - private Control BuildTitleBlock(MatchResult result, Vector2 viewport) - { - var champion = BattleAwards.Champion(Stats.Tallies, result.WinningTeam); - - var block = new VBoxContainer { Name = "TitleBlock", SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter }; - block.AddThemeConstantOverride("separation", (int)(viewport.Y * 0.006f)); - - var ribbon = new PanelContainer { Name = "Ribbon", SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter }; - ribbon.AddThemeStyleboxOverride("panel", RibbonStyle()); - _winnerName = new Label - { - Name = "WinnerName", - Text = champion?.Name ?? TranslationServer.Translate("hud.draw"), - HorizontalAlignment = HorizontalAlignment.Center, - }; - ApplyFont(_winnerName, (int)(viewport.Y * 0.032f), Gold, outline: (int)(viewport.Y * 0.004f)); - ribbon.AddChild(_winnerName); - block.AddChild(ribbon); - - var victorious = new Label - { - Name = "Victorious", - Text = TranslationServer.Translate("hud.victorious"), - HorizontalAlignment = HorizontalAlignment.Center, - Visible = champion is not null, - }; - ApplyFont(victorious, (int)(viewport.Y * 0.058f), new Color(1f, 0.78f, 0.16f), outline: (int)(viewport.Y * 0.006f)); - block.AddChild(victorious); - return block; - } - - // The ranking-sheet navigator: < [current sheet name] > — the arrows switch the sheet. - private Control BuildNavRow(Vector2 viewport) - { - var nav = new HBoxContainer { Name = "NavRow", Alignment = BoxContainer.AlignmentMode.Center }; - nav.AddThemeConstantOverride("separation", (int)(viewport.X * 0.02f)); - nav.SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter; - - var prevBtn = NavButton("PrevView", "<", viewport, () => { _sfx.PlayUi(SfxKind.UiClick); SwitchView(-1); }); - prevBtn.MouseEntered += () => _sfx.PlayHover(); - nav.AddChild(prevBtn); - - var pill = new PanelContainer { Name = "ViewPill" }; - pill.AddThemeStyleboxOverride("panel", PillStyle()); - _viewTitle = new Label - { - Name = "ViewTitle", - HorizontalAlignment = HorizontalAlignment.Center, - CustomMinimumSize = new Vector2(viewport.X * 0.34f, 0f), // stable width so the arrows do not shift - }; - ApplyFont(_viewTitle, (int)(viewport.Y * 0.028f), new Color(1f, 0.96f, 0.80f), outline: (int)(viewport.Y * 0.003f)); - pill.AddChild(_viewTitle); - nav.AddChild(pill); - - var nextBtn = NavButton("NextView", ">", viewport, () => { _sfx.PlayUi(SfxKind.UiClick); SwitchView(1); }); - nextBtn.MouseEntered += () => _sfx.PlayHover(); - nav.AddChild(nextBtn); - return nav; - } - - private static Button NavButton(string name, string glyph, Vector2 viewport, Action onPressed) - { - var button = new Button { Name = name, Text = glyph }; - button.AddThemeFontSizeOverride("font_size", (int)(viewport.Y * 0.030f)); - button.AddThemeColorOverride("font_color", new Color(1f, 1f, 1f)); - button.AddThemeStyleboxOverride("normal", NavStyle(new Color(0.16f, 0.40f, 0.80f))); - button.AddThemeStyleboxOverride("hover", NavStyle(new Color(0.24f, 0.50f, 0.92f))); - button.AddThemeStyleboxOverride("pressed", NavStyle(new Color(0.12f, 0.30f, 0.62f))); - button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); - button.Pressed += onPressed; - return button; - } - - // The plaque: a dark metal panel holding the ranked rows (filled by RebuildLeaderboard) inside a - // fixed-height scroll window — about four rows show at once, and at the 4v4 cap of eight tanks the - // rest scroll into view (owner ask 2026-06-14). - private Control BuildPlaque(Vector2 viewport, float cardWidth) - { - var plaque = new PanelContainer { Name = "Plaque", CustomMinimumSize = new Vector2(cardWidth, 0f) }; - plaque.AddThemeStyleboxOverride("panel", PlaqueStyle()); - - var scroll = new ScrollContainer - { - Name = "LeaderboardScroll", - CustomMinimumSize = new Vector2(0f, (viewport.Y * 0.205f) + 44f), // ~four rows tall - HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, - VerticalScrollMode = ScrollContainer.ScrollMode.Auto, - }; - _leaderboardRows = new VBoxContainer { Name = "LeaderboardRows", SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - _leaderboardRows.AddThemeConstantOverride("separation", (int)(viewport.Y * 0.008f)); - scroll.AddChild(_leaderboardRows); - plaque.AddChild(scroll); - return plaque; - } - - private static void ApplyFont(Label label, int size, Color colour, int outline = 0, bool shadow = true) - { - label.AddThemeFontSizeOverride("font_size", Mathf.Max(1, size)); - label.AddThemeColorOverride("font_color", colour); - if (outline > 0) - { - label.AddThemeColorOverride("font_outline_color", new Color(0.12f, 0.07f, 0.02f)); - label.AddThemeConstantOverride("outline_size", outline); - } - - if (shadow) - { - label.AddThemeColorOverride("font_shadow_color", new Color(0f, 0f, 0f, 0.45f)); - label.AddThemeConstantOverride("shadow_offset_x", 2); - label.AddThemeConstantOverride("shadow_offset_y", 2); - } - } - - private static StyleBoxFlat Rounded(Color fill, Color border, int borderWidth, int radius) - { - return new StyleBoxFlat - { - BgColor = fill, - BorderColor = border, - BorderWidthLeft = borderWidth, - BorderWidthRight = borderWidth, - BorderWidthTop = borderWidth, - BorderWidthBottom = borderWidth, - CornerRadiusTopLeft = radius, - CornerRadiusTopRight = radius, - CornerRadiusBottomLeft = radius, - CornerRadiusBottomRight = radius, - }; - } - - private static StyleBoxFlat RibbonStyle() - { - var s = Rounded(RibbonWood, new Color(0.64f, 0.41f, 0.18f), 4, 16); - s.ContentMarginLeft = 44; - s.ContentMarginRight = 44; - s.ContentMarginTop = 8; - s.ContentMarginBottom = 10; - s.ShadowColor = new Color(0f, 0f, 0f, 0.5f); - s.ShadowSize = 6; - return s; - } - - private static StyleBoxFlat PillStyle() - { - var s = Rounded(new Color(0.12f, 0.30f, 0.62f), Gold, 3, 24); - s.ContentMarginLeft = 24; - s.ContentMarginRight = 24; - s.ContentMarginTop = 8; - s.ContentMarginBottom = 8; - return s; - } - - private static StyleBoxFlat NavStyle(Color fill) - { - var s = Rounded(fill, Gold, 3, 14); - s.ContentMarginLeft = 20; - s.ContentMarginRight = 20; - s.ContentMarginTop = 4; - s.ContentMarginBottom = 6; - s.ShadowColor = new Color(0f, 0f, 0f, 0.4f); - s.ShadowSize = 3; - return s; - } - - private static StyleBoxFlat PlaqueStyle() - { - var s = Rounded(PlaqueMetal, new Color(0.46f, 0.48f, 0.52f), 4, 16); - s.ContentMarginLeft = 14; - s.ContentMarginRight = 14; - s.ContentMarginTop = 14; - s.ContentMarginBottom = 14; - s.ShadowColor = new Color(0f, 0f, 0f, 0.55f); - s.ShadowSize = 8; - return s; - } - - /// Switches the leaderboard sheets along the ring (the arrows - /// call it with ±1). Public so tests can drive the navigation. - public void SwitchView(int delta) - { - var count = LeaderboardViews.Length; - _viewIndex = ((_viewIndex + delta) % count + count) % count; - RebuildLeaderboard(GetViewport().GetVisibleRect().Size); - } - - // Fills the plaque with one ranked row per tank (rank 1 at the top), up to the eight-tank cap. - // Each row is a gold/silver plate carrying a coloured number badge, the tank name, any award tags - // and the metric value — all real controls in an HBox, so names and numbers never clip. - private void RebuildLeaderboard(Vector2 viewport) - { - var view = LeaderboardViews[_viewIndex]; - _viewTitle.Text = view.TitleKey; - var value = view.Value; - - foreach (var child in _leaderboardRows.GetChildren()) - { - child.Free(); // immediate, so a same-frame re-switch rebuilds a clean list - } - var awards = BattleAwards.Compute(Stats.Tallies); - var rank = 0; - foreach (var tally in LeaderboardOrder.Rank(Stats.Tallies, value, view.LowerIsBetter)) + var sheets = new List(LeaderboardViews.Length); + foreach (var (titleKey, value, lowerIsBetter) in LeaderboardViews) { - if (rank >= MaxRows) - { - break; - } - - var plate = new PanelContainer { Name = $"Row{rank + 1}" }; - plate.AddThemeStyleboxOverride("panel", PlateStyle(rank % 2 == 0 ? PlateGold : PlateSilver)); - - var row = new HBoxContainer(); - row.AddThemeConstantOverride("separation", (int)(viewport.X * 0.015f)); - - var badge = new PanelContainer { Name = "Badge" }; - badge.AddThemeStyleboxOverride("panel", BadgeStyle(BadgeColours[rank % 2])); - var badgeSize = (int)(viewport.Y * 0.044f); - badge.CustomMinimumSize = new Vector2(badgeSize, badgeSize); - var number = new Label - { - Text = (rank + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }; - ApplyFont(number, (int)(viewport.Y * 0.026f), new Color(1f, 1f, 1f), outline: 2); - badge.AddChild(number); - row.AddChild(badge); - - var name = new Label - { - Text = tally.Name, - VerticalAlignment = VerticalAlignment.Center, - SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, - }; - ApplyFont(name, (int)(viewport.Y * 0.024f), PlateInk, shadow: false); - row.AddChild(name); - - var tags = string.Join(" ", awards - .Where(a => ReferenceEquals(a.Winner, tally)) - .Select(a => TranslationServer.Translate(AwardKey(a.Kind)).ToString())); - if (tags.Length > 0) + var rows = new List(); + foreach (var tally in LeaderboardOrder.Rank(Stats.Tallies, value, lowerIsBetter)) { - var honours = new Label { Text = tags, VerticalAlignment = VerticalAlignment.Center }; - ApplyFont(honours, (int)(viewport.Y * 0.019f), AwardRed, shadow: false); - row.AddChild(honours); + var tags = string.Join(" ", awards + .Where(a => ReferenceEquals(a.Winner, tally)) + .Select(a => TranslationServer.Translate(AwardKey(a.Kind)).ToString())); + rows.Add(new VictoryScreen.Row(tally.Name, + value(tally).ToString(System.Globalization.CultureInfo.InvariantCulture), tags)); } - var amountLabel = new Label - { - Text = value(tally).ToString(System.Globalization.CultureInfo.InvariantCulture), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Center, - CustomMinimumSize = new Vector2(viewport.X * 0.10f, 0f), - }; - ApplyFont(amountLabel, (int)(viewport.Y * 0.024f), PlateInk, shadow: false); - row.AddChild(amountLabel); - - plate.AddChild(row); - _leaderboardRows.AddChild(plate); - rank++; + sheets.Add(new VictoryScreen.Sheet(titleKey, rows)); } - } - private static StyleBoxFlat PlateStyle(Color fill) - { - var s = Rounded(fill, new Color(0f, 0f, 0f, 0.25f), 2, 8); - s.ContentMarginLeft = 12; - s.ContentMarginRight = 14; - s.ContentMarginTop = 5; - s.ContentMarginBottom = 5; - return s; + var champion = BattleAwards.Champion(Stats.Tallies, result.WinningTeam); + _victory = VictoryScreen.Build( + GetViewport().GetVisibleRect().Size, + champion?.Name, + sheets, + new VictoryScreen.ButtonSpec[] + { + new("NewGame", "gameover.new_game", () => + { + var custom = GameSetup.CustomMap; // StartNewMatch clears it; a custom-map rematch keeps its map + GameSetup.StartNewMatch(GameSetup.Mode); + GameSetup.CustomMap = custom; + GetTree().ReloadCurrentScene(); + }), + new("BackToMenu", "pause.main_menu", + () => GetTree().ChangeSceneToFile("res://src/Presentation/Title.tscn")), + new("ExitGame", "pause.exit", () => PlatformExit.Run(GetTree())), + }, + onClick: () => _sfx.PlayUi(SfxKind.UiClick), + onHover: () => _sfx.PlayHover()); + AddChild(_victory); } - // A high corner radius clamps to a circle at the badge's square size. - private static StyleBoxFlat BadgeStyle(Color fill) => Rounded(fill, new Color(1f, 1f, 1f, 0.9f), 3, 100); + /// Switches the leaderboard sheets along the ring (the arrows + /// call it with ±1). Public so tests can drive the navigation. + public void SwitchView(int delta) => _victory?.SwitchSheet(delta); private static string AwardKey(AwardKind kind) => kind switch { @@ -471,62 +169,6 @@ private static StyleBoxFlat PlateStyle(Color fill) _ => "award.bullet_sponge", }; - private HBoxContainer BuildGameOverButtons(Vector2 viewport) - { - var bar = new HBoxContainer { Name = "GameOverButtons", Alignment = BoxContainer.AlignmentMode.Center }; - bar.AddThemeConstantOverride("separation", (int)(viewport.X * 0.015f)); - - var newGame = StyledButton("NewGame", "gameover.new_game", viewport); - newGame.Pressed += () => - { - _sfx.PlayUi(SfxKind.UiClick); - var custom = GameSetup.CustomMap; // StartNewMatch clears it; a custom-map rematch keeps its map - GameSetup.StartNewMatch(GameSetup.Mode); - GameSetup.CustomMap = custom; - GetTree().ReloadCurrentScene(); - }; - newGame.MouseEntered += () => _sfx.PlayHover(); - bar.AddChild(newGame); - - var menu = StyledButton("BackToMenu", "pause.main_menu", viewport); - menu.Pressed += () => { _sfx.PlayUi(SfxKind.UiClick); GetTree().ChangeSceneToFile("res://src/Presentation/Title.tscn"); }; - menu.MouseEntered += () => _sfx.PlayHover(); - bar.AddChild(menu); - - var exit = StyledButton("ExitGame", "pause.exit", viewport); - exit.Pressed += () => { _sfx.PlayUi(SfxKind.UiClick); PlatformExit.Run(GetTree()); }; - exit.MouseEntered += () => _sfx.PlayHover(); - bar.AddChild(exit); - return bar; - } - - // A wood-and-gold button matching the celebration art: gold border, dark fill, drop shadow. Text - // is a locale key — Godot's Control auto-translation resolves it through the TranslationServer. - private static Button StyledButton(string name, string textKey, Vector2 viewport) - { - var button = new Button { Name = name, Text = textKey, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; - button.AddThemeFontSizeOverride("font_size", (int)(viewport.Y * 0.026f)); - button.AddThemeColorOverride("font_color", new Color(1f, 0.95f, 0.80f)); - button.AddThemeColorOverride("font_hover_color", new Color(1f, 1f, 0.92f)); - button.AddThemeStyleboxOverride("normal", ButtonStyle(new Color(0.30f, 0.18f, 0.08f))); - button.AddThemeStyleboxOverride("hover", ButtonStyle(new Color(0.40f, 0.25f, 0.11f))); - button.AddThemeStyleboxOverride("pressed", ButtonStyle(new Color(0.22f, 0.13f, 0.05f))); - button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); - return button; - } - - private static StyleBoxFlat ButtonStyle(Color fill) - { - var s = Rounded(fill, Gold, 2, 8); - s.ShadowColor = new Color(0f, 0f, 0f, 0.5f); - s.ShadowSize = 4; - s.ContentMarginLeft = 18; - s.ContentMarginRight = 18; - s.ContentMarginTop = 10; - s.ContentMarginBottom = 12; - return s; - } - private (int X, int Y) _playerSpawn; private IReadOnlyList<(int X, int Y)> _enemySpawns = Array.Empty<(int, int)>(); private Camera3D _camera = null!; diff --git a/client/src/Presentation/Arena/NetArena3DScene.cs b/client/src/Presentation/Arena/NetArena3DScene.cs index 58e0edb..b680c45 100644 --- a/client/src/Presentation/Arena/NetArena3DScene.cs +++ b/client/src/Presentation/Arena/NetArena3DScene.cs @@ -70,6 +70,12 @@ public partial class NetArena3DScene : Node3D private readonly List<(byte Slot, ITank Tank)> _rosterTanks = new(); private readonly List<(int Team, bool Alive)> _roundStatus = new(); + // The net match's stat book (no stats on the wire): the host feeds it from its authoritative + // world each tick, a guest from every snapshot — both derive the identical leaderboard. + private readonly NetMatchStats _netStats = new(); + private VictoryScreen? _victory; + private CanvasLayer _leaveLayer = null!; + /// The decided round (FFA: last tank standing; Team: last team standing). The host /// evaluates it each authoritative tick; a guest derives the same verdict from the snapshot's /// tank states, so both roles show the winner banner. Null while the round is being fought. @@ -252,6 +258,7 @@ private void ReturnToRoom(LobbyView view) private void BuildLeaveButton() { var layer = new CanvasLayer { Name = "LeaveLayer", Layer = 3 }; + _leaveLayer = layer; var leave = new Button { Name = "LeaveButton", Text = "net.leave" }; leave.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopRight); leave.Position += new Vector2(-16f, 16f); @@ -367,6 +374,11 @@ private void OnWelcome(byte slot) // match (issue #4). Every peer derives the same spawn list + seed, so host and guest agree. _matchSeed = NetworkSession.StartedLobby?.Seed ?? 0; _spawns = Shuffled(_spawns, _matchSeed); + foreach (var seat in _roster) + { + _netStats.Register(seat.Slot, seat.Name, seat.Team); // both roles book the same rows + } + if (slot == HostSlot) { BecomeHost(); @@ -687,8 +699,9 @@ private void SyncLocalFromPrediction() private void EvaluateRound() { _roundStatus.Clear(); - foreach (var (_, tank) in _rosterTanks) + foreach (var (slot, tank) in _rosterTanks) { + _netStats.Observe(slot, tank.Hp); // the host's side of the shared stat book _roundStatus.Add((tank.Team, tank.IsAlive)); } @@ -698,6 +711,7 @@ private void EvaluateRound() RoundResult = result; _status.SetStatus(RoundOverText(result.WinningTeam)); _rematch.Visible = IsLobbyHost; + ShowVictoryScreen(result.WinningTeam); } } @@ -714,6 +728,7 @@ private void DetectRoundOverFromSnapshot(SnapshotFrame snapshot) _roundStatus.Clear(); foreach (var state in snapshot.Tanks) { + _netStats.Observe(state.Slot, state.Hp); // the guest's side of the shared stat book _roundStatus.Add((state.Team, state.Hp > 0)); } @@ -723,7 +738,87 @@ private void DetectRoundOverFromSnapshot(SnapshotFrame snapshot) RoundResult = result; _status.SetStatus(RoundOverText(result.WinningTeam)); _rematch.Visible = IsLobbyHost; + ShowVictoryScreen(result.WinningTeam); + } + } + + /// The real end of a networked match (multiplayer plan: "net victory screen"): the same + /// the solo arena shows, on BOTH roles, ranked from the shared stat + /// book — final standing, damage taken, and repairs, all derived from the hp streams each peer + /// already observes, so nothing new travels on the wire. The card carries the online affordances + /// (host: Rematch + Leave; guest: Leave) and the corner copies hide beneath it. + private void ShowVictoryScreen(int winningTeam) + { + if (_victory is not null) + { + return; + } + + var standings = _netStats.Standings(); + var standingRows = new List(standings.Count); + foreach (var tally in standings) + { + // The value column shows the hit points the tank went out with — the winner's margin. + standingRows.Add(new VictoryScreen.Row(tally.Name, tally.Hp.ToString(CultureInfo.InvariantCulture))); + } + + var sheets = new List + { + new("stats.standing", standingRows), + RankedSheet("stats.taken", t => t.DamageTaken, lowerIsBetter: true), + RankedSheet("stats.repairs", t => t.Repairs, lowerIsBetter: false), + }; + + var buttons = new List(); + if (IsLobbyHost) + { + buttons.Add(new VictoryScreen.ButtonSpec("VictoryRematch", "net.rematch", + () => _transport.SendLobby(LobbyProtocol.EncodeRematch()))); + } + + buttons.Add(new VictoryScreen.ButtonSpec("VictoryLeave", "net.leave", LeaveMatch)); + + _victory = VictoryScreen.Build( + GetViewport().GetVisibleRect().Size, ChampionName(winningTeam), sheets, buttons); + AddChild(_victory); + _leaveLayer.Visible = false; // the screen's buttons take over — no doubled corner controls + } + + private VictoryScreen.Sheet RankedSheet( + string titleKey, System.Func value, bool lowerIsBetter) + { + var rows = new List(); + foreach (var tally in LeaderboardOrder.Rank(_netStats.Tallies, value, lowerIsBetter)) + { + rows.Add(new VictoryScreen.Row(tally.Name, value(tally).ToString(CultureInfo.InvariantCulture))); + } + + return new VictoryScreen.Sheet(titleKey, rows); + } + + // The ribbon's headliner: the winner's name when the winning team is a single tank (always, in + // FFA), "Team N" for a squad, and null (= the ribbon says draw) when nobody survived. + private string? ChampionName(int winningTeam) + { + if (winningTeam == LastStanding.NoWinner) + { + return null; } + + string? sole = null; + var teamSize = 0; + foreach (var seat in _roster) + { + if (seat.Team == winningTeam) + { + sole = seat.Name; + teamSize++; + } + } + + return teamSize == 1 && sole is not null + ? sole + : string.Format(CultureInfo.InvariantCulture, Tr("net.team_label"), winningTeam + 1); } // "{name} wins!" when one tank owns the winning team (always true in FFA), "Team N wins!" diff --git a/client/src/Presentation/Arena/VictoryScreen.cs b/client/src/Presentation/Arena/VictoryScreen.cs new file mode 100644 index 0000000..7e88764 --- /dev/null +++ b/client/src/Presentation/Arena/VictoryScreen.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using Godot; + +namespace TankGame.Presentation; + +/// The victory screen v2 (owner ask 2026-06-11/14), extracted so the solo arena and the +/// networked match share one screen: real UI controls composed over the generated celebration +/// backdrop — a wood ribbon with the champion's name and a big "IS VICTORIOUS!", a nav row whose +/// arrows switch the ranking sheet shown on a pill, a metal plaque of up to eight numbered +/// gold/silver rows, and a styled button bar. The caller supplies the data: the champion's name +/// (null on a draw), the pre-ranked sheets, and the buttons with their actions — the screen owns +/// only presentation. Native containers centre and resize everything; nothing anchors to baked +/// artwork. +public partial class VictoryScreen : CanvasLayer +{ + /// One ranked plate: the tank's name, the metric value already formatted, and any + /// award tags ("" for none). + public sealed record Row(string Name, string Value, string Tags = ""); + + /// One ranking sheet: its pill title (a locale key) and its rows, best first. + public sealed record Sheet(string TitleKey, IReadOnlyList Rows); + + /// A bottom-bar button: node name, label locale key, and the press action. + public sealed record ButtonSpec(string Name, string TextKey, Action Pressed); + + private const string BackdropPath = "res://src/Presentation/Arena/ui/victory_bg.png"; + private const int MaxRows = 8; // the 4v4 tank cap — up to eight ranked rows, one per tank + + // These colours echo the generated backdrop so the built ribbon/plaque/plates/badges match it. + private static readonly Color RibbonWood = new(0.45f, 0.27f, 0.12f); + private static readonly Color Gold = new(1f, 0.82f, 0.28f); + private static readonly Color PlaqueMetal = new(0.15f, 0.16f, 0.19f, 0.95f); + private static readonly Color PlateGold = new(0.84f, 0.62f, 0.16f); + private static readonly Color PlateSilver = new(0.57f, 0.60f, 0.63f); + private static readonly Color PlateInk = new(0.13f, 0.08f, 0.02f); + private static readonly Color AwardRed = new(0.62f, 0.10f, 0.08f); + private static readonly Color[] BadgeColours = { new(0.18f, 0.45f, 0.86f), new(0.80f, 0.17f, 0.15f) }; + + private IReadOnlyList _sheets = Array.Empty(); + private Action? _onClick; + private Action? _onHover; + private int _viewIndex; + private Vector2 _viewport; + private Texture2D _bgArt = null!; + private Label _viewTitle = null!; + private VBoxContainer _leaderboardRows = null!; + + /// Builds the whole screen. null = a draw (the ribbon + /// says so and the "IS VICTORIOUS!" line hides). / + /// are optional UI-sound hooks — a scene without a sound pool passes null. + public static VictoryScreen Build( + Vector2 viewport, + string? championName, + IReadOnlyList sheets, + IReadOnlyList buttons, + Action? onClick = null, + Action? onHover = null) + { + var screen = new VictoryScreen + { + Name = "GameOverLayer", + _sheets = sheets, + _onClick = onClick, + _onHover = onHover, + _viewport = viewport, + }; + + screen._bgArt = GD.Load(BackdropPath); + var backdrop = new TextureRect + { + Name = "Backdrop", + Texture = screen._bgArt, + StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered, + MouseFilter = Control.MouseFilterEnum.Ignore, + }; + backdrop.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + screen.AddChild(backdrop); + + var scrim = new ColorRect + { + Name = "Scrim", + Color = new Color(0f, 0f, 0f, 0.30f), // darken the busy art so the plaque text reads + MouseFilter = Control.MouseFilterEnum.Ignore, + }; + scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + screen.AddChild(scrim); + + // A centred portrait card: the container hierarchy positions and re-centres everything, so a + // window resize needs no manual maths (the font sizes, picked from the viewport, stay put). + var holder = new CenterContainer { Name = "CardHolder", MouseFilter = Control.MouseFilterEnum.Ignore }; + holder.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + screen.AddChild(holder); + + var card = new VBoxContainer { Name = "VictoryCard" }; + card.AddThemeConstantOverride("separation", (int)(viewport.Y * 0.012f)); + holder.AddChild(card); + + var cardWidth = Mathf.Min(viewport.X * 0.94f, 760f); + card.AddChild(screen.BuildTitleBlock(championName)); + card.AddChild(screen.BuildNavRow()); + card.AddChild(screen.BuildPlaque(cardWidth)); + card.AddChild(screen.BuildButtons(buttons)); + + screen._viewIndex = 0; + screen.RebuildLeaderboard(); + return screen; + } + + // The wood ribbon carrying the winner's name, with a big gold "IS VICTORIOUS!" beneath it (hidden + // on a draw). Real controls, so the text never collides with baked art. + private Control BuildTitleBlock(string? championName) + { + var block = new VBoxContainer { Name = "TitleBlock", SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter }; + block.AddThemeConstantOverride("separation", (int)(_viewport.Y * 0.006f)); + + var ribbon = new PanelContainer { Name = "Ribbon", SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter }; + ribbon.AddThemeStyleboxOverride("panel", RibbonStyle()); + var winnerName = new Label + { + Name = "WinnerName", + Text = championName ?? TranslationServer.Translate("hud.draw"), + HorizontalAlignment = HorizontalAlignment.Center, + }; + ApplyFont(winnerName, (int)(_viewport.Y * 0.032f), Gold, outline: (int)(_viewport.Y * 0.004f)); + ribbon.AddChild(winnerName); + block.AddChild(ribbon); + + var victorious = new Label + { + Name = "Victorious", + Text = TranslationServer.Translate("hud.victorious"), + HorizontalAlignment = HorizontalAlignment.Center, + Visible = championName is not null, + }; + ApplyFont(victorious, (int)(_viewport.Y * 0.058f), new Color(1f, 0.78f, 0.16f), outline: (int)(_viewport.Y * 0.006f)); + block.AddChild(victorious); + return block; + } + + // The ranking-sheet navigator: < [current sheet name] > — the arrows switch the sheet. + private Control BuildNavRow() + { + var nav = new HBoxContainer { Name = "NavRow", Alignment = BoxContainer.AlignmentMode.Center }; + nav.AddThemeConstantOverride("separation", (int)(_viewport.X * 0.02f)); + nav.SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter; + + nav.AddChild(NavButton("PrevView", "<", () => { _onClick?.Invoke(); SwitchSheet(-1); })); + + var pill = new PanelContainer { Name = "ViewPill" }; + pill.AddThemeStyleboxOverride("panel", PillStyle()); + _viewTitle = new Label + { + Name = "ViewTitle", + HorizontalAlignment = HorizontalAlignment.Center, + CustomMinimumSize = new Vector2(_viewport.X * 0.34f, 0f), // stable width so the arrows do not shift + }; + ApplyFont(_viewTitle, (int)(_viewport.Y * 0.028f), new Color(1f, 0.96f, 0.80f), outline: (int)(_viewport.Y * 0.003f)); + pill.AddChild(_viewTitle); + nav.AddChild(pill); + + nav.AddChild(NavButton("NextView", ">", () => { _onClick?.Invoke(); SwitchSheet(1); })); + return nav; + } + + private Button NavButton(string name, string glyph, Action onPressed) + { + var button = new Button { Name = name, Text = glyph }; + button.AddThemeFontSizeOverride("font_size", (int)(_viewport.Y * 0.030f)); + button.AddThemeColorOverride("font_color", new Color(1f, 1f, 1f)); + button.AddThemeStyleboxOverride("normal", NavStyle(new Color(0.16f, 0.40f, 0.80f))); + button.AddThemeStyleboxOverride("hover", NavStyle(new Color(0.24f, 0.50f, 0.92f))); + button.AddThemeStyleboxOverride("pressed", NavStyle(new Color(0.12f, 0.30f, 0.62f))); + button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); + button.Pressed += onPressed; + if (_onHover is { } hover) + { + button.MouseEntered += () => hover(); + } + + return button; + } + + // The plaque: a dark metal panel holding the ranked rows (filled by RebuildLeaderboard) inside a + // fixed-height scroll window — about four rows show at once, and at the 4v4 cap of eight tanks the + // rest scroll into view (owner ask 2026-06-14). + private Control BuildPlaque(float cardWidth) + { + var plaque = new PanelContainer { Name = "Plaque", CustomMinimumSize = new Vector2(cardWidth, 0f) }; + plaque.AddThemeStyleboxOverride("panel", PlaqueStyle()); + + var scroll = new ScrollContainer + { + Name = "LeaderboardScroll", + CustomMinimumSize = new Vector2(0f, (_viewport.Y * 0.205f) + 44f), // ~four rows tall + HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, + VerticalScrollMode = ScrollContainer.ScrollMode.Auto, + }; + _leaderboardRows = new VBoxContainer { Name = "LeaderboardRows", SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + _leaderboardRows.AddThemeConstantOverride("separation", (int)(_viewport.Y * 0.008f)); + scroll.AddChild(_leaderboardRows); + plaque.AddChild(scroll); + return plaque; + } + + /// Switches the leaderboard sheets along the ring (the arrows + /// call it with ±1). Public so tests and the owning scene can drive the navigation. + public void SwitchSheet(int delta) + { + var count = _sheets.Count; + if (count == 0) + { + return; + } + + _viewIndex = ((_viewIndex + delta) % count + count) % count; + RebuildLeaderboard(); + } + + // Fills the plaque with one ranked row per tank (rank 1 at the top), up to the eight-tank cap. + // Each row is a gold/silver plate carrying a coloured number badge, the tank name, any award tags + // and the value — all real controls in an HBox, so names and numbers never clip. + private void RebuildLeaderboard() + { + var sheet = _sheets[_viewIndex]; + _viewTitle.Text = sheet.TitleKey; + + foreach (var child in _leaderboardRows.GetChildren()) + { + child.Free(); // immediate, so a same-frame re-switch rebuilds a clean list + } + + var rank = 0; + foreach (var entry in sheet.Rows) + { + if (rank >= MaxRows) + { + break; + } + + var plate = new PanelContainer { Name = $"Row{rank + 1}" }; + plate.AddThemeStyleboxOverride("panel", PlateStyle(rank % 2 == 0 ? PlateGold : PlateSilver)); + + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", (int)(_viewport.X * 0.015f)); + + var badge = new PanelContainer { Name = "Badge" }; + badge.AddThemeStyleboxOverride("panel", BadgeStyle(BadgeColours[rank % 2])); + var badgeSize = (int)(_viewport.Y * 0.044f); + badge.CustomMinimumSize = new Vector2(badgeSize, badgeSize); + var number = new Label + { + Text = (rank + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + ApplyFont(number, (int)(_viewport.Y * 0.026f), new Color(1f, 1f, 1f), outline: 2); + badge.AddChild(number); + row.AddChild(badge); + + var name = new Label + { + Text = entry.Name, + VerticalAlignment = VerticalAlignment.Center, + SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, + }; + ApplyFont(name, (int)(_viewport.Y * 0.024f), PlateInk, shadow: false); + row.AddChild(name); + + if (entry.Tags.Length > 0) + { + var honours = new Label { Text = entry.Tags, VerticalAlignment = VerticalAlignment.Center }; + ApplyFont(honours, (int)(_viewport.Y * 0.019f), AwardRed, shadow: false); + row.AddChild(honours); + } + + var amountLabel = new Label + { + Text = entry.Value, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + CustomMinimumSize = new Vector2(_viewport.X * 0.10f, 0f), + }; + ApplyFont(amountLabel, (int)(_viewport.Y * 0.024f), PlateInk, shadow: false); + row.AddChild(amountLabel); + + plate.AddChild(row); + _leaderboardRows.AddChild(plate); + rank++; + } + } + + private HBoxContainer BuildButtons(IReadOnlyList buttons) + { + var bar = new HBoxContainer { Name = "GameOverButtons", Alignment = BoxContainer.AlignmentMode.Center }; + bar.AddThemeConstantOverride("separation", (int)(_viewport.X * 0.015f)); + + foreach (var spec in buttons) + { + var button = StyledButton(spec.Name, spec.TextKey); + var pressed = spec.Pressed; + button.Pressed += () => { _onClick?.Invoke(); pressed(); }; + if (_onHover is { } hover) + { + button.MouseEntered += () => hover(); + } + + bar.AddChild(button); + } + + return bar; + } + + // A wood-and-gold button matching the celebration art: gold border, dark fill, drop shadow. Text + // is a locale key — Godot's Control auto-translation resolves it through the TranslationServer. + private Button StyledButton(string name, string textKey) + { + var button = new Button { Name = name, Text = textKey, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + button.AddThemeFontSizeOverride("font_size", (int)(_viewport.Y * 0.026f)); + button.AddThemeColorOverride("font_color", new Color(1f, 0.95f, 0.80f)); + button.AddThemeColorOverride("font_hover_color", new Color(1f, 1f, 0.92f)); + button.AddThemeStyleboxOverride("normal", ButtonStyle(new Color(0.30f, 0.18f, 0.08f))); + button.AddThemeStyleboxOverride("hover", ButtonStyle(new Color(0.40f, 0.25f, 0.11f))); + button.AddThemeStyleboxOverride("pressed", ButtonStyle(new Color(0.22f, 0.13f, 0.05f))); + button.AddThemeStyleboxOverride("focus", new StyleBoxEmpty()); + return button; + } + + private static void ApplyFont(Label label, int size, Color colour, int outline = 0, bool shadow = true) + { + label.AddThemeFontSizeOverride("font_size", Mathf.Max(1, size)); + label.AddThemeColorOverride("font_color", colour); + if (outline > 0) + { + label.AddThemeColorOverride("font_outline_color", new Color(0.12f, 0.07f, 0.02f)); + label.AddThemeConstantOverride("outline_size", outline); + } + + if (shadow) + { + label.AddThemeColorOverride("font_shadow_color", new Color(0f, 0f, 0f, 0.45f)); + label.AddThemeConstantOverride("shadow_offset_x", 2); + label.AddThemeConstantOverride("shadow_offset_y", 2); + } + } + + private static StyleBoxFlat Rounded(Color fill, Color border, int borderWidth, int radius) + { + return new StyleBoxFlat + { + BgColor = fill, + BorderColor = border, + BorderWidthLeft = borderWidth, + BorderWidthRight = borderWidth, + BorderWidthTop = borderWidth, + BorderWidthBottom = borderWidth, + CornerRadiusTopLeft = radius, + CornerRadiusTopRight = radius, + CornerRadiusBottomLeft = radius, + CornerRadiusBottomRight = radius, + }; + } + + private static StyleBoxFlat RibbonStyle() + { + var s = Rounded(RibbonWood, new Color(0.64f, 0.41f, 0.18f), 4, 16); + s.ContentMarginLeft = 44; + s.ContentMarginRight = 44; + s.ContentMarginTop = 8; + s.ContentMarginBottom = 10; + s.ShadowColor = new Color(0f, 0f, 0f, 0.5f); + s.ShadowSize = 6; + return s; + } + + private static StyleBoxFlat PillStyle() + { + var s = Rounded(new Color(0.12f, 0.30f, 0.62f), Gold, 3, 24); + s.ContentMarginLeft = 24; + s.ContentMarginRight = 24; + s.ContentMarginTop = 8; + s.ContentMarginBottom = 8; + return s; + } + + private static StyleBoxFlat NavStyle(Color fill) + { + var s = Rounded(fill, Gold, 3, 14); + s.ContentMarginLeft = 20; + s.ContentMarginRight = 20; + s.ContentMarginTop = 4; + s.ContentMarginBottom = 6; + s.ShadowColor = new Color(0f, 0f, 0f, 0.4f); + s.ShadowSize = 3; + return s; + } + + private static StyleBoxFlat PlaqueStyle() + { + var s = Rounded(PlaqueMetal, new Color(0.46f, 0.48f, 0.52f), 4, 16); + s.ContentMarginLeft = 14; + s.ContentMarginRight = 14; + s.ContentMarginTop = 14; + s.ContentMarginBottom = 14; + s.ShadowColor = new Color(0f, 0f, 0f, 0.55f); + s.ShadowSize = 8; + return s; + } + + private static StyleBoxFlat PlateStyle(Color fill) + { + var s = Rounded(fill, new Color(0f, 0f, 0f, 0.25f), 2, 8); + s.ContentMarginLeft = 12; + s.ContentMarginRight = 14; + s.ContentMarginTop = 5; + s.ContentMarginBottom = 5; + return s; + } + + // A high corner radius clamps to a circle at the badge's square size. + private static StyleBoxFlat BadgeStyle(Color fill) => Rounded(fill, new Color(1f, 1f, 1f, 0.9f), 3, 100); + + private static StyleBoxFlat ButtonStyle(Color fill) + { + var s = Rounded(fill, Gold, 2, 8); + s.ShadowColor = new Color(0f, 0f, 0f, 0.5f); + s.ShadowSize = 4; + s.ContentMarginLeft = 18; + s.ContentMarginRight = 18; + s.ContentMarginTop = 10; + s.ContentMarginBottom = 12; + return s; + } +} diff --git a/client/src/Presentation/Arena/VictoryScreen.cs.uid b/client/src/Presentation/Arena/VictoryScreen.cs.uid new file mode 100644 index 0000000..434c24f --- /dev/null +++ b/client/src/Presentation/Arena/VictoryScreen.cs.uid @@ -0,0 +1 @@ +uid://ctkgfk0s3rpf7 diff --git a/client/tests/GameLogic/NetMatchStatsTests.cs b/client/tests/GameLogic/NetMatchStatsTests.cs new file mode 100644 index 0000000..3f50ef6 --- /dev/null +++ b/client/tests/GameLogic/NetMatchStatsTests.cs @@ -0,0 +1,103 @@ +using TankGame.GameLogic; +using Xunit; + +namespace TankGame.Tests.GameLogic; + +// The networked match's stat book: both roles feed it the same per-tank hp stream (the host from +// its authoritative world each tick, a guest from every snapshot), so both derive the identical +// leaderboard without any stats bytes on the wire — damage taken, repairs, and the standing all +// fall out of observed hp deltas. +public class NetMatchStatsTests +{ + [Fact] + public void FirstObservation_IsTheBaseline_NotDamage() + { + var stats = new NetMatchStats(); + stats.Register(0, "Ada", 0); + + stats.Observe(0, 5); // joined the stream mid-match at 5 hp — nothing observed yet + + Assert.Equal(0, stats.Tallies[0].DamageTaken); + Assert.Equal(0, stats.Tallies[0].Repairs); + } + + [Fact] + public void HpDrops_AccumulateAsDamageTaken_AndRises_AsRepairs() + { + var stats = new NetMatchStats(); + stats.Register(0, "Ada", 0); + + stats.Observe(0, 8); + stats.Observe(0, 5); // -3 + stats.Observe(0, 7); // +2 (repair pickup) + stats.Observe(0, 6); // -1 + + Assert.Equal(4, stats.Tallies[0].DamageTaken); + Assert.Equal(2, stats.Tallies[0].Repairs); + Assert.Equal(6, stats.Tallies[0].Hp); + Assert.True(stats.Tallies[0].Alive); + } + + [Fact] + public void Standings_RankSurvivorsFirst_ThenLaterDeaths() + { + var stats = new NetMatchStats(); + stats.Register(0, "Ada", 0); + stats.Register(1, "Bea", 1); + stats.Register(2, "Cid", 2); + + stats.Observe(0, 8); + stats.Observe(1, 8); + stats.Observe(2, 8); + + stats.Observe(1, 0); // Bea falls first + stats.Observe(2, 0); // Cid falls second — outlived Bea + + var standings = stats.Standings(); + Assert.Equal("Ada", standings[0].Name); // the survivor wins + Assert.Equal("Cid", standings[1].Name); // died later = placed higher + Assert.Equal("Bea", standings[2].Name); + Assert.False(standings[2].Alive); + } + + [Fact] + public void Standings_BreakSurvivorTies_ByRemainingHp() + { + var stats = new NetMatchStats(); + stats.Register(0, "Ada", 0); + stats.Register(1, "Bea", 0); // same team survives together + + stats.Observe(0, 8); + stats.Observe(1, 8); + stats.Observe(0, 3); + + var standings = stats.Standings(); + Assert.Equal("Bea", standings[0].Name); // healthier survivor tops the sheet + Assert.Equal("Ada", standings[1].Name); + } + + [Fact] + public void UnregisteredSlot_AutoRegisters_SoAStraySnapshotNeverCrashesTheScreen() + { + var stats = new NetMatchStats(); + + stats.Observe(3, 8); + stats.Observe(3, 6); + + Assert.Single(stats.Tallies); + Assert.Equal(2, stats.Tallies[0].DamageTaken); + Assert.False(string.IsNullOrEmpty(stats.Tallies[0].Name)); + } + + [Fact] + public void Register_KeepsTheRosterOrder_AndNamesTeams() + { + var stats = new NetMatchStats(); + stats.Register(1, "Bea", 1); + stats.Register(0, "Ada", 0); + + Assert.Equal("Bea", stats.Tallies[0].Name); + Assert.Equal(1, stats.Tallies[0].Team); + Assert.Equal("Ada", stats.Tallies[1].Name); + } +} diff --git a/client/tests/GameLogic/NetMatchStatsTests.cs.uid b/client/tests/GameLogic/NetMatchStatsTests.cs.uid new file mode 100644 index 0000000..e4626f4 --- /dev/null +++ b/client/tests/GameLogic/NetMatchStatsTests.cs.uid @@ -0,0 +1 @@ +uid://d0hw3avcllm4s diff --git a/client/tests/Presentation/NetArena3DSceneTests.cs b/client/tests/Presentation/NetArena3DSceneTests.cs index 3fec6a2..9825c9e 100644 --- a/client/tests/Presentation/NetArena3DSceneTests.cs +++ b/client/tests/Presentation/NetArena3DSceneTests.cs @@ -428,6 +428,134 @@ public void WaitingPush_ReturnsToTheRoom_KeepingTheTransport() } } + // The real end of a networked match (net victory screen): a GUEST whose deciding snapshot + // arrives must show the full victory screen v2 — the winner's ribbon, the standing sheet ranked + // from the hp stream it observed, and a Leave button on the card — with the corner buttons + // hidden beneath it (the card's buttons take over). + [Test] + public void GuestRoundDecided_ShowsTheVictoryScreen_RankedFromObservedHp() + { + _transport.DeliverWelcome(1); + _transport.DeliverSnapshot(new SnapshotFrame(1, 0, + new List { new(0, 96f, 160f, 0f, 0f, 8, 0), new(1, 200f, 96f, 0f, 0f, 8, 1) }, + new List())); + _transport.DeliverSnapshot(new SnapshotFrame(2, 0, + new List { new(0, 96f, 160f, 0f, 0f, 8, 0), new(1, 200f, 96f, 0f, 0f, 0, 1) }, + new List())); + + if (_scene.FindChild("VictoryCard", recursive: true, owned: false) is null) + { + throw new Exception("A decided round must show the victory screen on the guest."); + } + + var winner = _scene.FindChild("WinnerName", recursive: true, owned: false) as Label + ?? throw new Exception("The victory screen must carry the winner's ribbon."); + if (winner.Text != _scene.Tanks[0].DisplayName || winner.Text.Length == 0) + { + throw new Exception($"The ribbon must name the surviving tank; got '{winner.Text}'."); + } + + var title = _scene.FindChild("ViewTitle", recursive: true, owned: false) as Label + ?? throw new Exception("The victory screen must name its ranking sheet."); + if (title.Text != "stats.standing") + { + throw new Exception($"The net board opens on the final standing; got '{title.Text}'."); + } + + var rows = _scene.FindChild("LeaderboardRows", recursive: true, owned: false) as Control + ?? throw new Exception("The victory screen must show the ranked rows."); + if (rows.GetChildCount() != 2) + { + throw new Exception($"Both tanks must rank on the sheet; saw {rows.GetChildCount()} rows."); + } + + if (_scene.FindChild("VictoryLeave", recursive: true, owned: false) is not Button) + { + throw new Exception("A guest must keep a Leave affordance on the victory screen."); + } + + if (_scene.FindChild("VictoryRematch", recursive: true, owned: false) is not null) + { + throw new Exception("A non-host must not be offered Rematch."); + } + + var corner = _scene.FindChild("LeaveLayer", recursive: true, owned: false) as CanvasLayer + ?? throw new Exception("The corner button layer must still exist."); + if (corner.Visible) + { + throw new Exception("The corner buttons must hide once the victory screen carries them."); + } + } + + // The HOST reaches the same screen from its authoritative world: when its last opponent falls, + // the next tick decides the round and raises the victory screen there too. + [Test] + public void HostRoundDecided_ShowsTheVictoryScreen() + { + _transport.DeliverWelcome(0); + + _scene.Tanks[1].TakeDamage(8); // the guest tank falls (net tanks have a single life) + _scene.Tick(0.05f); // the deciding authoritative tick + + if (_scene.RoundResult is not { Decided: true }) + { + throw new Exception("Downing the only opponent must decide the round on the host."); + } + + if (_scene.FindChild("VictoryCard", recursive: true, owned: false) is null) + { + throw new Exception("A decided round must show the victory screen on the host."); + } + + if (_scene.FindChild("VictoryLeave", recursive: true, owned: false) is not Button) + { + throw new Exception("The host must keep a Leave affordance on the victory screen."); + } + } + + // Requirement: the online rematch keeps working from the new screen — the LOBBY host's victory + // card carries a Rematch button whose press sends the same lobby command as the old corner one. + [Test] + public void VictoryScreenRematch_SendsTheRematchCommand_ForTheLobbyHost() + { + NetworkSession.StartedLobby = new LobbyView(TankGame.Domain.Net.GameMode.Ffa, LobbyPhase.Started, 1, 0, + new List { new(0, "Ada", 0, true, true), new(1, "Bea", 1, true, true) }); + NetworkSession.LocalSlot = 1; // this client is the lobby host (slot 1), playing as a guest + var scene = GD.Load("res://src/Presentation/Arena/NetArena3D.tscn") + .Instantiate(); + TestScene.AddChild(scene); + + try + { + // Bea (slot 1) falls — the round is decided. + _transport.DeliverSnapshot(new SnapshotFrame(1, 0, + new List { new(0, 96f, 160f, 0f, 0f, 8, 0), new(1, 200f, 96f, 0f, 0f, 0, 1) }, + new List())); + + var winner = scene.FindChild("WinnerName", recursive: true, owned: false) as Label + ?? throw new Exception("The victory screen must show on the lobby host."); + if (winner.Text != "Ada") + { + throw new Exception($"The ribbon must carry the roster name of the survivor; got '{winner.Text}'."); + } + + var rematch = scene.FindChild("VictoryRematch", recursive: true, owned: false) as Button + ?? throw new Exception("The lobby host's victory screen must offer Rematch."); + rematch.EmitSignal(BaseButton.SignalName.Pressed); + + if (_transport.SentLobby.Count == 0 + || !System.MemoryExtensions.SequenceEqual( + _transport.SentLobby[^1], LobbyProtocol.EncodeRematch())) + { + throw new Exception("Pressing the card's Rematch must send the rematch lobby command."); + } + } + finally + { + scene.Free(); + } + } + [Test] public void HostTick_DrivesTheGuestTank_FromARelayedInput() { From 6ef28e7b3172f98d037ca3f4b6be69231fce70f6 Mon Sep 17 00:00:00 2001 From: Daniel Machado Date: Fri, 17 Jul 2026 08:13:55 +0200 Subject: [PATCH 2/2] Teleport pads work in networked matches (Cliffs cross-layer pair) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The net arena never gave its host tanks a teleporter, so pads that work in solo play were inert cabling in a networked match. Now: - AuthoredTeleporter (GameLogic): the shared pad derivation — authored cell links + grid layers -> world-space Teleporter pads — used by both Arena3DScene (refactored, no behaviour change) and NetArena3DScene, so every peer derives identical pads from the shared map resolution. ZERO wire change: warps ride the existing snapshot position/layer. - NetArena3DScene: host tanks get the teleporter (warps resolve inside the authoritative World.Step), pad cooldowns age per fixed tick, both roles render the pad rings, and SyncLocalFromPrediction now copies the predicted Layer so a guest's own cross-layer warp lifts its tank view. - Guest prediction needs no fix: Reconcile hard-snaps to authority and replays unacked inputs from the destination, so a teleport-sized jump lands cleanly (characterised in PredictedTankTests). Tests: xUnit AuthoredTeleporterTests (3) + PredictedTank teleport-jump reconcile; GoDotTest: net Cliffs pad rings, end-to-end relayed-tank warp across the map and up a layer, guest own-layer sync. Co-Authored-By: Claude Fable 5 --- client/src/GameLogic/AuthoredTeleporter.cs | 36 ++++ .../src/GameLogic/AuthoredTeleporter.cs.uid | 1 + client/src/Presentation/Arena/Arena3DScene.cs | 21 +-- .../src/Presentation/Arena/NetArena3DScene.cs | 51 +++++- .../GameLogic/AuthoredTeleporterTests.cs | 69 ++++++++ .../GameLogic/AuthoredTeleporterTests.cs.uid | 1 + client/tests/GameLogic/PredictedTankTests.cs | 25 +++ .../Presentation/NetArena3DSceneTests.cs | 164 ++++++++++++++++++ 8 files changed, 353 insertions(+), 15 deletions(-) create mode 100644 client/src/GameLogic/AuthoredTeleporter.cs create mode 100644 client/src/GameLogic/AuthoredTeleporter.cs.uid create mode 100644 client/tests/GameLogic/AuthoredTeleporterTests.cs create mode 100644 client/tests/GameLogic/AuthoredTeleporterTests.cs.uid diff --git a/client/src/GameLogic/AuthoredTeleporter.cs b/client/src/GameLogic/AuthoredTeleporter.cs new file mode 100644 index 0000000..da9bd2a --- /dev/null +++ b/client/src/GameLogic/AuthoredTeleporter.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Numerics; +using TankGame.Domain; + +namespace TankGame.GameLogic; + +/// Builds the for a map's authored pad links (a custom map's, or +/// Cliffs' cross-layer pair): each cell becomes a world-space pad on whatever elevation layer the +/// grid gives that cell — the layer is derived, never authored separately, so pad data stays plain +/// cells and cannot disagree with the map. Shared by the local and networked arenas: pads are static +/// map features every peer derives identically from the same resolved map, so nothing about them +/// travels on the wire. The returned pad list is in link order (each link's A then B), matching +/// , so views built in this order mirror state by index. +public static class AuthoredTeleporter +{ + public static (Teleporter Teleporter, IReadOnlyList Pads) Build( + IReadOnlyList links, IWallGrid grid, float tileSize, Vector2 origin, float padRadius) + { + var pairs = new List<(TeleportPad, TeleportPad)>(links.Count); + var pads = new List(links.Count * 2); + foreach (var link in links) + { + var a = PadAt(link.AX, link.AY, grid, tileSize, origin); + var b = PadAt(link.BX, link.BY, grid, tileSize, origin); + pairs.Add((a, b)); + pads.Add(a); + pads.Add(b); + } + + return (new Teleporter(pairs, padRadius), pads); + } + + private static TeleportPad PadAt(int x, int y, IWallGrid grid, float tileSize, Vector2 origin) => new( + new Vector2(origin.X + ((x + 0.5f) * tileSize), origin.Y + ((y + 0.5f) * tileSize)), + grid.LayerAt(x, y)); +} diff --git a/client/src/GameLogic/AuthoredTeleporter.cs.uid b/client/src/GameLogic/AuthoredTeleporter.cs.uid new file mode 100644 index 0000000..6601371 --- /dev/null +++ b/client/src/GameLogic/AuthoredTeleporter.cs.uid @@ -0,0 +1 @@ +uid://xetjssd4o6se diff --git a/client/src/Presentation/Arena/Arena3DScene.cs b/client/src/Presentation/Arena/Arena3DScene.cs index 52eacef..177f151 100644 --- a/client/src/Presentation/Arena/Arena3DScene.cs +++ b/client/src/Presentation/Arena/Arena3DScene.cs @@ -812,22 +812,15 @@ private void BuildTeleporter(int widthCells, int heightCells) AddPadView(padB); } - // Build the teleporter from the authored pad pairs (cells → world centres). The rings are added in - // the same link order the Teleporter holds, so the scene can mirror pad state to them by index. + // Build the teleporter from the authored pad pairs via the shared derivation (cells → world + // centres, layers from the grid) — the same one the networked arena uses, so both resolve the + // identical pads from the same map. The rings are added in the Teleporter's link order, so the + // scene can mirror pad state to them by index. private void BuildAuthoredTeleporter() { - var links = new List<(TeleportPad, TeleportPad)>(_authoredPads.Count); - var pads = new List(_authoredPads.Count * 2); - foreach (var link in _authoredPads) - { - var padA = PadAt(link.AX, link.AY); - var padB = PadAt(link.BX, link.BY); - links.Add((padA, padB)); - pads.Add(padA); - pads.Add(padB); - } - - _teleporter = new Teleporter(links, TeleportPadRadius); + var (teleporter, pads) = AuthoredTeleporter.Build( + _authoredPads, _grid, TileSize, GridOrigin, TeleportPadRadius); + _teleporter = teleporter; foreach (var pad in pads) { AddPadView(pad); diff --git a/client/src/Presentation/Arena/NetArena3DScene.cs b/client/src/Presentation/Arena/NetArena3DScene.cs index b680c45..2be1d86 100644 --- a/client/src/Presentation/Arena/NetArena3DScene.cs +++ b/client/src/Presentation/Arena/NetArena3DScene.cs @@ -31,6 +31,7 @@ public partial class NetArena3DScene : Node3D private const float PickupRadius = 28f; private const int TankMaxHp = 8; private const byte HostSlot = 0; + private const float TeleportPadRadius = 40f; // a tank centred within this of a pad warps (as solo) // The guest spawns where the local game's Player 2 does (mirrors the 2D net scene). private static readonly (int X, int Y) GuestSpawn = (25, 7); @@ -51,6 +52,14 @@ public partial class NetArena3DScene : Node3D private readonly Dictionary _tanks = new(); private readonly Dictionary _tankViews = new(); + + // The map's teleport pads (Cliffs' cross-layer pair): static map features every member derives + // from the same resolved map, so nothing about them travels on the wire. The host's authoritative + // tanks consult the Teleporter inside World.Step and the warped positions/layers ride the normal + // snapshot; a guest renders the same rings and mirrors the outcomes. + private Teleporter _teleporter = null!; + private readonly List _padViews = new(); + private IReadOnlyList _authoredPads = System.Array.Empty(); private float _accumulator; private byte? _localSlot; private int _matchSeed; // the lobby's match seed: drives the spawn shuffle and per-bot AI seeding @@ -147,6 +156,7 @@ public override void _Ready() sandbags = cliffs.Sandbags; _primarySpawn = cliffs.PlayerSpawn; _secondarySpawn = cliffs.EnemySpawns[0]; + _authoredPads = cliffs.Pads; // the cross-layer valley↔plateau pair (teleport pads T3) break; case NetMapPick.BuiltIn builtIn when ArenaBuilders.TryGet(builtIn.ArenaId, out var builder): // A themed code arena (Forest/Volcano/City/…): every member builds the identical layout @@ -156,6 +166,7 @@ public override void _Ready() sandbags = themed.Sandbags; _primarySpawn = themed.PlayerSpawn; _secondarySpawn = themed.EnemySpawns[0]; + _authoredPads = themed.Pads; // themed arenas may author pad pairs too break; default: // Desert War (and the safe fallback for any unrecognised pick): a seeded generation. @@ -181,6 +192,7 @@ public override void _Ready() _grid = _level.BuildGrid(); _arena = new GridArena(_grid, TileSize, GridOrigin); + BuildTeleporter(); // before any tank exists — the host's tanks consult it from tick one BuildEnvironment(); BuildGround(); @@ -339,7 +351,9 @@ private void StepFixedTick() } // Host: the world reads every input source (keyboard + relayed + AI) and the session broadcasts. + _teleporter.Step(TickSeconds); // pad cooldowns age in sim time (tanks warp inside world.Step) _session.Step(TickSeconds); + UpdatePadViews(); EvaluateRound(); return; } @@ -423,7 +437,7 @@ private void BecomeHost() : FireInterval * DifficultyPreset.For(GameSetup.BotDifficulty).FireIntervalScale; var tank = new Tank(input, _world, _arena, CellCentre(spawn.X, spawn.Y), TankSpeed, fireInterval, ProjectileSpeed, maxHp: TankMaxHp, team: seat.Team, - displayName: seat.Name); + teleporter: _teleporter, displayName: seat.Name); ai?.Bind(tank); _tanks[seat.Slot] = tank; _rosterTanks.Add((seat.Slot, tank)); @@ -518,6 +532,40 @@ private void OnEntityDespawned(IEntity entity) } } + // Both roles derive the map's teleport pads from the shared map resolution (the same derivation + // the solo arena uses), so the rings sit identically on every peer with nothing on the wire. + // Only the host's tanks consult the Teleporter — a guest's copy exists for the ring views (its + // cooldowns never fire, so a guest's rings read "ready"; the warp itself arrives via snapshot). + private void BuildTeleporter() + { + var (teleporter, pads) = AuthoredTeleporter.Build( + _authoredPads, _grid, TileSize, GridOrigin, TeleportPadRadius); + _teleporter = teleporter; + foreach (var pad in pads) + { + var view = new TeleportPad3DView { Name = "TeleportPad" }; + view.Configure(GroundProjection.ToWorld(pad.Position, pad.Layer), TeleportPadRadius); + AddChild(view); + _padViews.Add(view); + } + } + + // Host only: mirror the authoritative pad cooldowns onto the rings (dim while dormant), in the + // shared link order both hold. + private void UpdatePadViews() + { + if (_padViews.Count == 0) + { + return; + } + + var statuses = _teleporter.PadStatuses(); + for (var i = 0; i < _padViews.Count && i < statuses.Count; i++) + { + _padViews[i].SetState(statuses[i].Ready, statuses[i].CooldownFraction); + } + } + // A guest's authoritative snapshot: reconcile the prediction, mirror every other slot, apply walls. private void OnSnapshot(SnapshotFrame snapshot) { @@ -691,6 +739,7 @@ private void SyncLocalFromPrediction() tank.TurretRotation = _predicted.TurretRotation; tank.Hp = _predicted.Hp; tank.Team = _predicted.Team; + tank.Layer = _predicted.Layer; // a cross-layer teleport must lift the guest's own tank too _tankViews[slot].ApplyTeamTint(tank.Team); } diff --git a/client/tests/GameLogic/AuthoredTeleporterTests.cs b/client/tests/GameLogic/AuthoredTeleporterTests.cs new file mode 100644 index 0000000..52d92a9 --- /dev/null +++ b/client/tests/GameLogic/AuthoredTeleporterTests.cs @@ -0,0 +1,69 @@ +using System.Numerics; +using TankGame.Domain; +using TankGame.GameLogic; +using Xunit; + +namespace TankGame.Tests.GameLogic; + +// The shared pad derivation both arenas use (local Arena3DScene and the networked NetArena3DScene): +// authored cell links become world-space pads whose elevation layer comes from the grid, so the +// same resolved map yields the identical Teleporter on every peer — nothing about pads on the wire. +public class AuthoredTeleporterTests +{ + private const float TileSize = 64f; + private const float PadRadius = 40f; + + [Fact] + public void Build_DerivesCliffsPads_AtCellCentres_WithGridLayers() + { + var cliffs = CliffsArena.Create(); + var grid = cliffs.Map.BuildGrid(); + + var (_, pads) = AuthoredTeleporter.Build( + cliffs.Pads, grid, TileSize, Vector2.Zero, PadRadius); + + Assert.Equal(2, pads.Count); + // Cliffs authors one cross-layer link: (2,2) on the valley floor ↔ (22,18) on the plateau. + Assert.Equal(new Vector2(160f, 160f), pads[0].Position); + Assert.Equal(0, pads[0].Layer); + Assert.Equal(new Vector2(1440f, 1184f), pads[1].Position); + Assert.Equal(1, pads[1].Layer); // the layer is derived from the grid, never authored + + // Both scenes rely on link order (A then B) to mirror PadStatuses by index. + var (teleporter, _) = AuthoredTeleporter.Build( + cliffs.Pads, grid, TileSize, Vector2.Zero, PadRadius); + var statuses = teleporter.PadStatuses(); + Assert.Equal(pads[0].Position, statuses[0].Position); + Assert.Equal(pads[1].Position, statuses[1].Position); + } + + [Fact] + public void Build_CliffsTeleporter_WarpsCrossLayer_ValleyToPlateau() + { + var cliffs = CliffsArena.Create(); + var grid = cliffs.Map.BuildGrid(); + var (teleporter, pads) = AuthoredTeleporter.Build( + cliffs.Pads, grid, TileSize, Vector2.Zero, PadRadius); + + // A tank on the valley pad (layer 0) warps up onto the plateau pad (layer 1)… + Assert.True(teleporter.TryTeleport(pads[0].Position, 0, out var destination, out var layer)); + Assert.Equal(pads[1].Position, destination); + Assert.Equal(1, layer); + + // …and arrives on a dormant pad (both ends cool down), so it does not bounce straight back. + Assert.False(teleporter.TryTeleport(destination, layer, out _, out _)); + } + + [Fact] + public void Build_WithNoLinks_YieldsAnEmptyTeleporter() + { + var cliffs = CliffsArena.Create(); + var grid = cliffs.Map.BuildGrid(); + + var (teleporter, pads) = AuthoredTeleporter.Build( + System.Array.Empty(), grid, TileSize, Vector2.Zero, PadRadius); + + Assert.Empty(pads); + Assert.False(teleporter.TryTeleport(new Vector2(160f, 160f), 0, out _, out _)); + } +} diff --git a/client/tests/GameLogic/AuthoredTeleporterTests.cs.uid b/client/tests/GameLogic/AuthoredTeleporterTests.cs.uid new file mode 100644 index 0000000..5fba629 --- /dev/null +++ b/client/tests/GameLogic/AuthoredTeleporterTests.cs.uid @@ -0,0 +1 @@ +uid://cdr0yhvhxgxkc diff --git a/client/tests/GameLogic/PredictedTankTests.cs b/client/tests/GameLogic/PredictedTankTests.cs index 8662b00..b656353 100644 --- a/client/tests/GameLogic/PredictedTankTests.cs +++ b/client/tests/GameLogic/PredictedTankTests.cs @@ -165,6 +165,31 @@ public void Reconcile_AppliesServerCorrection_WhenPredictionDiverged() Assert.Equal(5f + (StepDistance * 2f), tank.Position.X, precision: 3); } + [Fact] + public void Reconcile_TeleportSizedJump_SnapsCleanly_AndReplaysFromTheDestination() + { + // Net teleport pads: the host warps the guest's tank across the map (and up a layer) inside + // its authoritative world. The guest does NOT predict the warp — the next snapshot carries + // the teleport-sized jump, reconcile snaps to it outright (no smoothing to fight), and the + // still-unacknowledged inputs replay from the DESTINATION, so prediction never rubber-bands + // back through the pad. + var tank = new PredictedTank(1, new OpenArena(), new Vector2(160f, 160f)); + tank.Predict(Move(1, 1f, 0f)); + tank.Predict(Move(2, 1f, 0f)); + tank.Predict(Move(3, 1f, 0f)); // still in flight when the warp lands host-side + + // The host acked seq 2 and teleported the tank to the far pad, one layer up. + tank.Reconcile(SnapshotWith(ackSeq: 2, slot: 1, x: 1440f, y: 1184f, layer: 1)); + + Assert.Equal(1440f + StepDistance, tank.Position.X, precision: 3); // seq 3 replays from the pad + Assert.Equal(1184f, tank.Position.Y, precision: 3); + Assert.Equal(1, tank.Layer); + + // The next predicted tick continues from the destination — no pull back toward the source pad. + tank.Predict(Move(4, 1f, 0f)); + Assert.Equal(1440f + (StepDistance * 2f), tank.Position.X, precision: 3); + } + [Fact] public void Predict_StopsAtAWall_LikeTheServer() { diff --git a/client/tests/Presentation/NetArena3DSceneTests.cs b/client/tests/Presentation/NetArena3DSceneTests.cs index 9825c9e..a604d97 100644 --- a/client/tests/Presentation/NetArena3DSceneTests.cs +++ b/client/tests/Presentation/NetArena3DSceneTests.cs @@ -580,4 +580,168 @@ public void HostTick_DrivesTheGuestTank_FromARelayedInput() throw new Exception($"The broadcast snapshot must ack the applied guest input; got {last.AckSeq}."); } } + + // ── Teleport pads in the net world (multiplayer plan: "Cliffs teleport pads") ── + // Pads are static map features every member derives from the same map resolution, so nothing + // about them travels on the wire: the host's authoritative tanks warp inside World.Step and the + // new position/layer rides the normal snapshot; every peer renders the same rings. + + private const float NetTile = 64f; + + private static System.Numerics.Vector2 NetCellCentre(int x, int y) => + new((x + 0.5f) * NetTile, (y + 0.5f) * NetTile); + + private static LobbyView CliffsLobby(int seed) + { + // Eight humans fill every seat — no AI, so the host world only moves tanks we drive. + var players = new List(); + for (var slot = 0; slot < LobbyProtocol.MaxPlayers; slot++) + { + players.Add(new LobbyPlayer(slot, $"P{slot}", slot, true, true)); + } + + return new LobbyView(TankGame.Domain.Net.GameMode.Ffa, LobbyPhase.Started, 0, 0, players, + Map: "CliffsAndValleys", Seed: seed); + } + + // Cliffs authors a cross-layer pad pair: the net scene must derive the same two rings the solo + // arena shows — one down on the valley floor, one up at plateau height — on every role, with + // nothing on the wire (a guest builds them straight from the shared map resolution too). + [Test] + public void CliffsNetMatch_BuildsTheCrossLayerPadRings() + { + NetworkSession.StartedLobby = CliffsLobby(seed: 1); + NetworkSession.LocalSlot = 1; // a guest — the role with no world must still show the rings + var scene = GD.Load("res://src/Presentation/Arena/NetArena3D.tscn") + .Instantiate(); + TestScene.AddChild(scene); + + try + { + var ground = 0; + var raised = 0; + foreach (var child in scene.GetChildren()) + { + if (child is TeleportPad3DView view) + { + if (view.Position.Y >= GroundProjection.LayerHeight - 0.5f) + { + raised++; + } + else + { + ground++; + } + } + } + + if (ground != 1 || raised != 1) + { + throw new Exception( + $"A Cliffs net match must place the cross-layer pad pair (one valley ring, one plateau ring); saw {ground} ground + {raised} raised."); + } + } + finally + { + scene.Free(); + } + } + + // End-to-end host-side warp: a guest tank relayed onto the valley pad (cell 2,2) must come out + // on the plateau pad (cell 22,18) — across the map AND one layer up — through the authoritative + // world alone. Deterministic: every seat is human, so no AI moves or shoots. + [Test] + public void CliffsNetHost_WarpsARelayedTank_AcrossTheMapAndUpALayer() + { + NetworkSession.StartedLobby = CliffsLobby(seed: 1); + NetworkSession.LocalSlot = 0; // this client hosts the authoritative world + var scene = GD.Load("res://src/Presentation/Arena/NetArena3D.tscn") + .Instantiate(); + TestScene.AddChild(scene); + + try + { + // The spawn shuffle is seeded by the lobby seed, so WHICH slot starts at the pad-adjacent + // primary spawn (1,1) is fixed but opaque — find it. It must be a guest (relayed input); + // if a shuffle change ever hands it to slot 0, pick a different seed above. + byte? padSlot = null; + foreach (var (slot, seated) in scene.Tanks) + { + if (seated.Position == NetCellCentre(1, 1)) + { + padSlot = slot; + } + } + + if (padSlot is not byte driven) + { + throw new Exception("Expected a tank on the Cliffs primary spawn (1,1)."); + } + + if (driven == 0) + { + throw new Exception("Seed 1 put the HOST on the pad-adjacent spawn — choose another seed."); + } + + // Drive diagonally from (1,1) toward the valley pad at (2,2); ~6 ticks reach its trigger + // radius, the warp fires inside World.Step, and the remaining ticks roll on the plateau. + _transport.DeliverInput(new InputFrame(Seq: 1, MoveX: 0.707f, MoveY: 0.707f, Aim: 0f, + Buttons: 0, Slot: driven)); + for (var i = 0; i < 12; i++) + { + scene.Tick(0.05f); + } + + var tank = scene.Tanks[driven]; + var padB = NetCellCentre(22, 18); + var distance = System.Numerics.Vector2.Distance(tank.Position, padB); + if (distance > 200f) + { + throw new Exception( + $"The relayed tank must warp to the plateau pad {padB}; ended {distance:0} away at {tank.Position}."); + } + + if (tank.Layer != 1) + { + throw new Exception($"The warp must lift the tank to the plateau layer; got layer {tank.Layer}."); + } + + // The warped position rides the normal snapshot — no new wire section for teleports. + var last = _transport.Broadcast[^1]; + foreach (var state in last.Tanks) + { + if (state.Slot == driven && state.Layer != 1) + { + throw new Exception("The broadcast snapshot must carry the warped tank's new layer."); + } + } + } + finally + { + scene.Free(); + } + } + + // The guest's own tank teleports host-side; the snapshot brings the jump back. Reconcile snaps + // the prediction to the pad, and the local view-model must follow — including the LAYER, or a + // cross-layer warp would leave the guest's own tank rendered down in the valley. + [Test] + public void GuestSnapshot_LiftsItsOwnTank_ToTheTeleportedLayer() + { + _transport.DeliverWelcome(1); + _transport.DeliverSnapshot(new SnapshotFrame(1, 0, + new List { new(1, 1440f, 1184f, 0f, 0f, 8, 1, Shield: 0, Layer: 1) }, + new List())); + + var own = _scene.Tanks[1]; + if (Math.Abs(own.Position.X - 1440f) > 0.01f || Math.Abs(own.Position.Y - 1184f) > 0.01f) + { + throw new Exception($"The guest's own tank must snap to the authoritative warp; got {own.Position}."); + } + + if (own.Layer != 1) + { + throw new Exception($"The guest's own tank must follow the warp's layer; got {own.Layer}."); + } + } }