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}."); + } + } }