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/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/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..177f151 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!;
@@ -1170,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 58e0edb..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
@@ -70,6 +79,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.
@@ -141,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
@@ -150,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.
@@ -175,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();
@@ -252,6 +270,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);
@@ -332,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;
}
@@ -367,6 +388,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();
@@ -411,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));
@@ -506,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)
{
@@ -679,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);
}
@@ -687,8 +748,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 +760,7 @@ private void EvaluateRound()
RoundResult = result;
_status.SetStatus(RoundOverText(result.WinningTeam));
_rematch.Visible = IsLobbyHost;
+ ShowVictoryScreen(result.WinningTeam);
}
}
@@ -714,6 +777,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 +787,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/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/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/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 3fec6a2..a604d97 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()
{
@@ -452,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}.");
+ }
+ }
}