Table top prediction (#44348)

* TabletopSystem-move-to-shared

* final refactor

* Update TabletopHologramComponent.cs

* Update AdminVerbSystem.Smites.cs

* merge

* line

* prediction fix

* Fix the scrunkly

* Also noticed this

---------

Co-authored-by: Pok245 <113675512+Pok27@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
This commit is contained in:
riccardi48 2026-06-27 12:17:13 +01:00 • committed by GitHub
parent ef367d2ea1
commit 95a5f7c7de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 882 additions and 905 deletions

View file

@ -27,13 +27,12 @@ namespace Content.Client.Tabletop
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IGameTiming _gameTiming = default!;
[Dependency] private AppearanceSystem _appearance = default!;
[Dependency] private SharedTransformSystem _transformSystem = default!;
[Dependency] private SharedTransformSystem _transform = default!;
[Dependency] private SpriteSystem _sprite = default!;
// Time in seconds to wait until sending the location of a dragged entity to the server again
private const float Delay = 1f / 10; // 10 Hz
private float _timePassed; // Time passed since last update sent to the server.
private EntityUid? _draggedEntity; // Entity being dragged
private ScalingViewport? _viewport; // Viewport currently being used
private BaseWindow? _window; // Current open tabletop window (only allow one at a time)
@ -60,8 +59,11 @@ namespace Content.Client.Tabletop
StopDragging(false);
}
public override void FrameUpdate(float frameTime)
public override void Update(float frameTime)
{
base.Update(frameTime);
if (!_gameTiming.IsFirstTimePredicted)
return;
if (_window == null)
return;
@ -101,17 +103,10 @@ namespace Content.Client.Tabletop
var clampedCoords = ClampPositionToViewport(coords, _viewport);
if (clampedCoords.Equals(MapCoordinates.Nullspace)) return;
// Move the entity locally every update
_transformSystem.SetWorldPosition(_draggedEntity.Value, clampedCoords.Position);
// Increment total time passed
_timePassed += frameTime;
// Only send new position to server when Delay is reached
if (_timePassed >= Delay && _table != null)
if (_table != null)
{
RaisePredictiveEvent(new TabletopMoveEvent(GetNetEntity(_draggedEntity.Value), clampedCoords, GetNetEntity(_table.Value)));
_timePassed -= Delay;
}
}
@ -260,7 +255,7 @@ namespace Content.Client.Tabletop
// Set the dragging player on the component to noone
if (broadcast && _draggedEntity != null && HasComp<TabletopDraggableComponent>(_draggedEntity.Value))
{
RaisePredictiveEvent(new TabletopMoveEvent(GetNetEntity(_draggedEntity.Value), Transforms.GetMapCoordinates(_draggedEntity.Value), GetNetEntity(_table!.Value)));
RaisePredictiveEvent(new TabletopMoveEvent(GetNetEntity(_draggedEntity.Value), _transform.GetMapCoordinates(_draggedEntity.Value), GetNetEntity(_table!.Value)));
RaisePredictiveEvent(new TabletopDraggingPlayerChangedEvent(GetNetEntity(_draggedEntity.Value), false));
}

View file

@ -16,11 +16,9 @@ using Content.Server.Speech.Components;
using Content.Shared.Speech.Components;
using Content.Server.Storage.EntitySystems;
using Content.Server.Tabletop;
using Content.Server.Tabletop.Components;
using Content.Shared.Actions;
using Content.Shared.Administration;
using Content.Shared.Administration.Components;
using Content.Shared.Administration.Systems;
using Content.Shared.Atmos.Components;
using Content.Shared.Body;
using Content.Shared.Body.Components;

View file

@ -1,41 +0,0 @@
using System.Numerics;
namespace Content.Server.Tabletop.Components
{
/// <summary>
/// A component that makes an object playable as a tabletop game.
/// </summary>
[RegisterComponent, Access(typeof(TabletopSystem))]
public sealed partial class TabletopGameComponent : Component
{
/// <summary>
/// The localized name of the board. Shown in the UI.
/// </summary>
[DataField]
public LocId BoardName { get; private set; } = "tabletop-default-board-name";
/// <summary>
/// The type of method used to set up a tabletop.
/// </summary>
[DataField(required: true)]
public TabletopSetup Setup { get; private set; } = new TabletopChessSetup();
/// <summary>
/// The size of the viewport being opened. Must match the board dimensions otherwise you'll get the space parallax (unless that's what you want).
/// </summary>
[DataField]
public Vector2i Size { get; private set; } = (300, 300);
/// <summary>
/// The zoom of the viewport camera.
/// </summary>
[DataField]
public Vector2 CameraZoom { get; private set; } = Vector2.One;
/// <summary>
/// The specific session of this tabletop.
/// </summary>
[ViewVariables]
public TabletopSession? Session { get; set; } = null;
}
}

View file

@ -1,12 +0,0 @@
namespace Content.Server.Tabletop.Components
{
/// <summary>
/// Component for marking an entity as currently playing a tabletop.
/// </summary>
[RegisterComponent, Access(typeof(TabletopSystem))]
public sealed partial class TabletopGamerComponent : Component
{
[DataField("tabletop")]
public EntityUid Tabletop { get; set; } = EntityUid.Invalid;
}
}

View file

@ -1,67 +0,0 @@
using JetBrains.Annotations;
namespace Content.Server.Tabletop
{
[UsedImplicitly]
public sealed partial class TabletopBackgammonSetup : TabletopSetup
{
[DataField("whitePiecePrototype")]
public string WhitePiecePrototype { get; private set; } = "WhiteTabletopPiece";
[DataField("blackPiecePrototype")]
public string BlackPiecePrototype { get; private set; } = "BlackTabletopPiece";
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position);
const float borderLengthX = 7.35f; //BORDER
const float borderLengthY = 5.60f; //BORDER
const float boardDistanceX = 1.25f;
const float pieceDistanceY = 0.80f;
float GetXPosition(float distanceFromSide, bool isLeftSide)
{
var pos = borderLengthX - (distanceFromSide * boardDistanceX);
return isLeftSide ? -pos : pos;
}
float GetYPosition(float positionNumber, bool isTop)
{
var pos = borderLengthY - (pieceDistanceY * positionNumber);
return isTop ? pos : -pos;
}
void AddPieces(
float distanceFromSide,
int numberOfPieces,
bool isBlackPiece,
bool isTop,
bool isLeftSide)
{
for (int i = 0; i < numberOfPieces; i++)
{
session.Entities.Add(entityManager.SpawnEntity(isBlackPiece ? BlackPiecePrototype : WhitePiecePrototype, session.Position.Offset(GetXPosition(distanceFromSide, isLeftSide), GetYPosition(i, isTop))));
}
}
// Top left
AddPieces(0, 5, true, true, true);
// top middle left
AddPieces(4, 3, false, true, true);
// top middle right
AddPieces(5, 5, false, true, false);
// top far right
AddPieces(0, 2, true, true, false);
// bottom left
AddPieces(0, 5, false, false, true);
// bottom middle left
AddPieces(4, 3, true, false, true);
// bottom middle right
AddPieces(5, 5, true, false, false);
// bottom far right
AddPieces(0, 2, false, false, false);
}
}
}

View file

@ -1,104 +0,0 @@
using JetBrains.Annotations;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Tabletop
{
[UsedImplicitly]
public sealed partial class TabletopCheckerSetup : TabletopSetup
{
[DataField("prototypePieceWhite", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypePieceWhite = default!;
[DataField("prototypeCrownWhite", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypeCrownWhite = default!;
[DataField("prototypePieceBlack", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypePieceBlack = default!;
[DataField("prototypeCrownBlack", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypeCrownBlack = default!;
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
session.Entities.Add(
entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(-1, 0))
);
SpawnPieces(session, entityManager, session.Position.Offset(-4.5f, 3.5f));
}
private void SpawnPieces(TabletopSession session, IEntityManager entityManager, MapCoordinates left)
{
static float GetOffset(float offset) => offset * 1f /* separation */;
Span<EntityUid> pieces = stackalloc EntityUid[42];
var pieceIndex = 0;
// Pieces
for (var offsetY = 0; offsetY < 3; offsetY++)
{
var checker = offsetY % 2;
for (var offsetX = 0; offsetX < 8; offsetX += 2)
{
// Prevents an extra piece on the middle row
if (checker + offsetX > 8) continue;
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceBlack,
left.Offset(GetOffset(offsetX + (1 - checker)), GetOffset(offsetY * -1))
);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceWhite,
left.Offset(GetOffset(offsetX + checker), GetOffset(offsetY - 7))
);
pieceIndex += 2;
}
}
const int NumCrowns = 3;
const float Overlap = 0.25f;
const float xOffset = 9f / 32;
const float xOffsetBlack = 9 + xOffset;
const float xOffsetWhite = 8 + xOffset;
// Crowns
for (var i = 0; i < NumCrowns; i++)
{
var step = -(Overlap * i);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypeCrownBlack,
left.Offset(GetOffset(xOffsetBlack), GetOffset(step))
);
pieces[pieceIndex + 1] = entityManager.SpawnEntity(
PrototypeCrownWhite,
left.Offset(GetOffset(xOffsetWhite), GetOffset(step))
);
pieceIndex += 2;
}
// Spares
for (var i = 0; i < 6; i++)
{
var step = -((Overlap * (NumCrowns + 2)) + (Overlap * i));
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceBlack,
left.Offset(GetOffset(xOffsetBlack), GetOffset(step))
);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceWhite,
left.Offset(GetOffset(xOffsetWhite), GetOffset(step))
);
pieceIndex += 2;
}
for (var i = 0; i < pieces.Length; i++)
{
session.Entities.Add(pieces[i]);
}
}
}
}

View file

@ -1,89 +0,0 @@
using JetBrains.Annotations;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Tabletop
{
[UsedImplicitly]
public sealed partial class TabletopChessSetup : TabletopSetup
{
// TODO: Un-hardcode the rest of entity prototype IDs, probably.
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var chessboard = entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(-1, 0));
session.Entities.Add(chessboard);
SpawnPieces(session, entityManager, session.Position.Offset(-4.5f, 3.5f));
}
private void SpawnPieces(TabletopSession session, IEntityManager entityManager, MapCoordinates topLeft, float separation = 1f)
{
var (mapId, x, y) = topLeft;
// Spawn all black pieces
SpawnPiecesRow(session, entityManager, "Black", topLeft, separation);
SpawnPawns(session, entityManager, "Black", new MapCoordinates(x, y - separation, mapId) , separation);
// Spawn all white pieces
SpawnPawns(session, entityManager, "White", new MapCoordinates(x, y - 6 * separation, mapId) , separation);
SpawnPiecesRow(session, entityManager, "White", new MapCoordinates(x, y - 7 * separation, mapId), separation);
// Extra queens
EntityUid tempQualifier = entityManager.SpawnEntity("BlackQueen", new MapCoordinates(x + 9 * separation + 9f / 32, y - 3 * separation, mapId));
session.Entities.Add(tempQualifier);
EntityUid tempQualifier1 = entityManager.SpawnEntity("WhiteQueen", new MapCoordinates(x + 9 * separation + 9f / 32, y - 4 * separation, mapId));
session.Entities.Add(tempQualifier1);
}
// TODO: refactor to load FEN instead
private void SpawnPiecesRow(TabletopSession session, IEntityManager entityManager, string color, MapCoordinates left, float separation = 1f)
{
const string piecesRow = "rnbqkbnr";
var (mapId, x, y) = left;
for (int i = 0; i < 8; i++)
{
switch (piecesRow[i])
{
case 'r':
EntityUid tempQualifier = entityManager.SpawnEntity(color + "Rook", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier);
break;
case 'n':
EntityUid tempQualifier1 = entityManager.SpawnEntity(color + "Knight", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier1);
break;
case 'b':
EntityUid tempQualifier2 = entityManager.SpawnEntity(color + "Bishop", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier2);
break;
case 'q':
EntityUid tempQualifier3 = entityManager.SpawnEntity(color + "Queen", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier3);
break;
case 'k':
EntityUid tempQualifier4 = entityManager.SpawnEntity(color + "King", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier4);
break;
}
}
}
// TODO: refactor to load FEN instead
private void SpawnPawns(TabletopSession session, IEntityManager entityManager, string color, MapCoordinates left, float separation = 1f)
{
var (mapId, x, y) = left;
for (int i = 0; i < 8; i++)
{
EntityUid tempQualifier = entityManager.SpawnEntity(color + "Pawn", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier);
}
}
}
}

View file

@ -1,14 +0,0 @@
using JetBrains.Annotations;
namespace Content.Server.Tabletop
{
[UsedImplicitly]
public sealed partial class TabletopEmptySetup : TabletopSetup
{
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(0, 0));
session.Entities.Add(board);
}
}
}

View file

@ -1,76 +0,0 @@
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Tabletop
{
[UsedImplicitly]
public sealed partial class TabletopParchisSetup : TabletopSetup
{
[DataField("redPiecePrototype", customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
public string RedPiecePrototype { get; private set; } = "RedTabletopPiece";
[DataField("greenPiecePrototype", customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
public string GreenPiecePrototype { get; private set; } = "GreenTabletopPiece";
[DataField("yellowPiecePrototype", customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
public string YellowPiecePrototype { get; private set; } = "YellowTabletopPiece";
[DataField("bluePiecePrototype", customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
public string BluePiecePrototype { get; private set; } = "BlueTabletopPiece";
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position);
const float x1 = 6.25f;
const float x2 = 4.25f;
const float y1 = 6.25f;
const float y2 = 4.25f;
var center = session.Position;
// Red pieces.
EntityUid tempQualifier = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x1, -y1));
session.Entities.Add(tempQualifier);
EntityUid tempQualifier1 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x1, -y2));
session.Entities.Add(tempQualifier1);
EntityUid tempQualifier2 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x2, -y1));
session.Entities.Add(tempQualifier2);
EntityUid tempQualifier3 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x2, -y2));
session.Entities.Add(tempQualifier3);
// Green pieces.
EntityUid tempQualifier4 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x1, -y1));
session.Entities.Add(tempQualifier4);
EntityUid tempQualifier5 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x1, -y2));
session.Entities.Add(tempQualifier5);
EntityUid tempQualifier6 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x2, -y1));
session.Entities.Add(tempQualifier6);
EntityUid tempQualifier7 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x2, -y2));
session.Entities.Add(tempQualifier7);
// Yellow pieces.
EntityUid tempQualifier8 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x1, y1));
session.Entities.Add(tempQualifier8);
EntityUid tempQualifier9 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x1, y2));
session.Entities.Add(tempQualifier9);
EntityUid tempQualifier10 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x2, y1));
session.Entities.Add(tempQualifier10);
EntityUid tempQualifier11 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x2, y2));
session.Entities.Add(tempQualifier11);
// Blue pieces.
EntityUid tempQualifier12 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x1, y1));
session.Entities.Add(tempQualifier12);
EntityUid tempQualifier13 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x1, y2));
session.Entities.Add(tempQualifier13);
EntityUid tempQualifier14 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x2, y1));
session.Entities.Add(tempQualifier14);
EntityUid tempQualifier15 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x2, y2));
session.Entities.Add(tempQualifier15);
}
}
}

View file

@ -1,32 +0,0 @@
using System.Numerics;
using Robust.Shared.Map;
using Robust.Shared.Player;
namespace Content.Server.Tabletop
{
/// <summary>
/// A class for storing data about a running tabletop game.
/// </summary>
public sealed class TabletopSession
{
/// <summary>
/// The center position of this session.
/// </summary>
public readonly MapCoordinates Position;
/// <summary>
/// The set of players currently playing this tabletop game.
/// </summary>
public readonly Dictionary<ICommonSession, TabletopSessionPlayerData> Players = new();
/// <summary>
/// All entities bound to this session. If you create an entity for this session, you have to add it here.
/// </summary>
public readonly HashSet<EntityUid> Entities = new();
public TabletopSession(MapId tabletopMap, Vector2 position)
{
Position = new MapCoordinates(position, tabletopMap);
}
}
}

View file

@ -1,10 +0,0 @@
namespace Content.Server.Tabletop
{
/// <summary>
/// A class that stores per-player data for tabletops.
/// </summary>
public sealed class TabletopSessionPlayerData
{
public EntityUid Camera { get; set; }
}
}

View file

@ -1,20 +0,0 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Tabletop
{
[ImplicitDataDefinitionForInheritors]
public abstract partial class TabletopSetup
{
/// <summary>
/// Method for setting up a tabletop. Use this to spawn the board and pieces, etc.
/// Make sure you add every entity you create to the Entities hashset in the session.
/// </summary>
/// <param name="session">Tabletop session to set up. You'll want to grab the tabletop center position here for spawning entities.</param>
/// <param name="entityManager">Dependency that can be used for spawning entities.</param>
public abstract void SetupTabletop(TabletopSession session, IEntityManager entityManager);
[DataField("boardPrototype", customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
public string BoardPrototype = default!;
}
}

View file

@ -1,152 +1,143 @@
using System.Numerics;
using Content.Server.Tabletop.Components;
using Content.Shared.Tabletop;
using Content.Shared.Tabletop.Components;
using Content.Shared.Tabletop.Events;
using Robust.Shared.Player;
using Robust.Shared.Utility;
namespace Content.Server.Tabletop
namespace Content.Server.Tabletop;
public sealed partial class TabletopSystem
{
public sealed partial class TabletopSystem
/// <summary>
/// Ensures that a <see cref="TabletopSession"/> exists on a <see cref="TabletopGameComponent"/>.
/// Creates it and sets it up if it doesn't.
/// </summary>
/// <param name="tabletop">The tabletop game in question.</param>
/// <returns>The session for the given tabletop game.</returns>
public TabletopSession EnsureSession(TabletopGameComponent tabletop)
{
/// <summary>
/// Ensures that a <see cref="TabletopSession"/> exists on a <see cref="TabletopGameComponent"/>.
/// Creates it and sets it up if it doesn't.
/// </summary>
/// <param name="tabletop">The tabletop game in question.</param>
/// <returns>The session for the given tabletop game.</returns>
public TabletopSession EnsureSession(TabletopGameComponent tabletop)
// We already have a session, return it.
// TODO: if tables are connected, treat them as a single entity. This can be done by sharing the session.
if (tabletop.Session != null)
return tabletop.Session;
// We make sure that the tabletop map exists before continuing.
EnsureTabletopMap();
// Create new session.
var session = new TabletopSession(TabletopMap, GetNextTabletopPosition());
tabletop.Session = session;
// Since this is the first time opening this session, set up the game.
tabletop.Setup.SetupTabletop(session, EntityManager);
Log.Info($"Created tabletop session number {tabletop} at position {session.Position}.");
return session;
}
/// <summary>
/// Cleans up a tabletop game session, deleting every entity in it.
/// </summary>
/// <param name="uid">The UID of the tabletop game entity.</param>
public void CleanupSession(EntityUid uid)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop))
return;
if (tabletop.Session is not { } session)
return;
foreach (var (player, _) in session.Players)
CloseSessionFor(player, uid);
foreach (var euid in session.Entities)
QueueDel(euid);
tabletop.Session = null;
}
public override void OpenSessionFor(ICommonSession player, EntityUid uid)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop) || player.AttachedEntity is not { Valid: true } attachedEntity)
return;
// Make sure we have a session, and add the player to it if not added already.
var session = EnsureSession(tabletop);
if (session.Players.ContainsKey(player))
return;
if (TryComp(attachedEntity, out TabletopGamerComponent? gamer))
CloseSessionFor(player, gamer.Tabletop, false);
// Set the entity as an absolute GAMER.
EnsureComp<TabletopGamerComponent>(attachedEntity).Tabletop = uid;
// Create a camera for the gamer to use.
var camera = CreateCamera(tabletop, player);
session.Players[player] = new TabletopSessionPlayerData { Camera = camera };
// Tell the gamer to open a viewport for the tabletop game.
RaiseNetworkEvent(new TabletopPlayEvent(GetNetEntity(uid), GetNetEntity(camera), Loc.GetString(tabletop.BoardName), tabletop.Size), player.Channel);
}
/// <summary>
/// Removes a player from a tabletop game session, and sends them a message so their tabletop window is closed.
/// </summary>
/// <param name="player">The player in question.</param>
/// <param name="uid">The UID of the tabletop game entity.</param>
/// <param name="removeGamerComponent">Whether to remove the <see cref="TabletopGamerComponent"/> from the player's attached entity.</param>
public void CloseSessionFor(ICommonSession player, EntityUid uid, bool removeGamerComponent = true)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
if (!session.Players.TryGetValue(player, out var data))
return;
if (removeGamerComponent && player.AttachedEntity is { } attachedEntity && TryComp(attachedEntity, out TabletopGamerComponent? gamer))
{
// We already have a session, return it
// TODO: if tables are connected, treat them as a single entity. This can be done by sharing the session.
if (tabletop.Session != null)
return tabletop.Session;
// We invalidate this to prevent an infinite feedback from removing the component.
gamer.Tabletop = EntityUid.Invalid;
// We make sure that the tabletop map exists before continuing.
EnsureTabletopMap();
// Create new session.
var session = new TabletopSession(TabletopMap, GetNextTabletopPosition());
tabletop.Session = session;
// Since this is the first time opening this session, set up the game
tabletop.Setup.SetupTabletop(session, EntityManager);
Log.Info($"Created tabletop session number {tabletop} at position {session.Position}.");
return session;
// You stop being a gamer.......
RemComp<TabletopGamerComponent>(attachedEntity);
}
/// <summary>
/// Cleans up a tabletop game session, deleting every entity in it.
/// </summary>
/// <param name="uid">The UID of the tabletop game entity.</param>
public void CleanupSession(EntityUid uid)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop))
return;
session.Players.Remove(player);
session.Entities.Remove(data.Camera);
if (tabletop.Session is not { } session)
return;
// Deleting the view subscriber automatically cleans up subscriptions, no need to do anything else.
QueueDel(data.Camera);
}
foreach (var (player, _) in session.Players)
{
CloseSessionFor(player, uid);
}
/// <summary>
/// A helper method that creates a camera for a specified player, in a tabletop game session.
/// </summary>
/// <param name="tabletop">The tabletop game component in question.</param>
/// <param name="player">The player in question.</param>
/// <param name="offset">An offset from the tabletop position for the camera. Zero by default.</param>
/// <returns>The UID of the camera entity.</returns>
private EntityUid CreateCamera(TabletopGameComponent tabletop, ICommonSession player, Vector2 offset = default)
{
DebugTools.AssertNotNull(tabletop.Session);
foreach (var euid in session.Entities)
{
QueueDel(euid);
}
var session = tabletop.Session!;
tabletop.Session = null;
}
// Spawn an empty entity at the coordinates.
var camera = EntityManager.SpawnEntity(null, session.Position.Offset(offset));
/// <summary>
/// Adds a player to a tabletop game session, sending a message so the tabletop window opens on their end.
/// </summary>
/// <param name="player">The player session in question.</param>
/// <param name="uid">The UID of the tabletop game entity.</param>
public void OpenSessionFor(ICommonSession player, EntityUid uid)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop) || player.AttachedEntity is not {Valid: true} attachedEntity)
return;
// Add an eye component and disable FOV.
var eyeComponent = EnsureComp<EyeComponent>(camera);
_eye.SetDrawFov(camera, false, eyeComponent);
_eye.SetZoom(camera, tabletop.CameraZoom, eyeComponent);
// Make sure we have a session, and add the player to it if not added already.
var session = EnsureSession(tabletop);
// Add the user to the view subscribers. If there is no player session, just skip this step.
_viewSubscriberSystem.AddViewSubscriber(camera, player);
if (session.Players.ContainsKey(player))
return;
if(TryComp(attachedEntity, out TabletopGamerComponent? gamer))
CloseSessionFor(player, gamer.Tabletop, false);
// Set the entity as an absolute GAMER.
EnsureComp<TabletopGamerComponent>(attachedEntity).Tabletop = uid;
// Create a camera for the gamer to use
var camera = CreateCamera(tabletop, player);
session.Players[player] = new TabletopSessionPlayerData { Camera = camera };
// Tell the gamer to open a viewport for the tabletop game
RaiseNetworkEvent(new TabletopPlayEvent(GetNetEntity(uid), GetNetEntity(camera), Loc.GetString(tabletop.BoardName), tabletop.Size), player.Channel);
}
/// <summary>
/// Removes a player from a tabletop game session, and sends them a message so their tabletop window is closed.
/// </summary>
/// <param name="player">The player in question.</param>
/// <param name="uid">The UID of the tabletop game entity.</param>
/// <param name="removeGamerComponent">Whether to remove the <see cref="TabletopGamerComponent"/> from the player's attached entity.</param>
public void CloseSessionFor(ICommonSession player, EntityUid uid, bool removeGamerComponent = true)
{
if (!TryComp(uid, out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
if (!session.Players.TryGetValue(player, out var data))
return;
if(removeGamerComponent && player.AttachedEntity is {} attachedEntity && TryComp(attachedEntity, out TabletopGamerComponent? gamer))
{
// We invalidate this to prevent an infinite feedback from removing the component.
gamer.Tabletop = EntityUid.Invalid;
// You stop being a gamer.......
RemComp<TabletopGamerComponent>(attachedEntity);
}
session.Players.Remove(player);
session.Entities.Remove(data.Camera);
// Deleting the view subscriber automatically cleans up subscriptions, no need to do anything else.
QueueDel(data.Camera);
}
/// <summary>
/// A helper method that creates a camera for a specified player, in a tabletop game session.
/// </summary>
/// <param name="tabletop">The tabletop game component in question.</param>
/// <param name="player">The player in question.</param>
/// <param name="offset">An offset from the tabletop position for the camera. Zero by default.</param>
/// <returns>The UID of the camera entity.</returns>
private EntityUid CreateCamera(TabletopGameComponent tabletop, ICommonSession player, Vector2 offset = default)
{
DebugTools.AssertNotNull(tabletop.Session);
var session = tabletop.Session!;
// Spawn an empty entity at the coordinates
var camera = EntityManager.SpawnEntity(null, session.Position.Offset(offset));
// Add an eye component and disable FOV
var eyeComponent = EnsureComp<EyeComponent>(camera);
_eye.SetDrawFov(camera, false, eyeComponent);
_eye.SetZoom(camera, tabletop.CameraZoom, eyeComponent);
// Add the user to the view subscribers. If there is no player session, just skip this step
_viewSubscriberSystem.AddViewSubscriber(camera, player);
return camera;
}
return camera;
}
}

View file

@ -1,202 +1,105 @@
using Content.Server.Hands.Systems;
using Content.Server.Popups;
using Content.Server.Tabletop.Components;
using Content.Shared.CCVar;
using Content.Shared.Hands.Components;
using JetBrains.Annotations;
using Content.Shared.Interaction;
using Content.Shared.Item;
using Content.Shared.Tabletop;
using Content.Shared.Tabletop.Components;
using Content.Shared.Tabletop.Events;
using Content.Shared.Verbs;
using JetBrains.Annotations;
using Robust.Server.GameObjects;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Player;
using Robust.Shared.Utility;
namespace Content.Server.Tabletop
namespace Content.Server.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopSystem : SharedTabletopSystem
{
[UsedImplicitly]
public sealed partial class TabletopSystem : SharedTabletopSystem
[Dependency] private EyeSystem _eye = default!;
[Dependency] private SharedMapSystem _map = default!;
[Dependency] private ViewSubscriberSystem _viewSubscriberSystem = default!;
public override void Initialize()
{
[Dependency] private SharedMapSystem _map = default!;
[Dependency] private EyeSystem _eye = default!;
[Dependency] private HandsSystem _hands = default!;
[Dependency] private ViewSubscriberSystem _viewSubscriberSystem = default!;
[Dependency] private PopupSystem _popupSystem = default!;
[Dependency] private IConfigurationManager _cfg = default!;
base.Initialize();
public override void Initialize()
SubscribeNetworkEvent<TabletopStopPlayingEvent>(OnStopPlaying);
SubscribeLocalEvent<TabletopGameComponent, ActivateInWorldEvent>(OnTabletopActivate);
SubscribeLocalEvent<TabletopGameComponent, ComponentShutdown>(OnGameShutdown);
SubscribeLocalEvent<TabletopGamerComponent, PlayerDetachedEvent>(OnPlayerDetached);
SubscribeLocalEvent<TabletopGamerComponent, ComponentShutdown>(OnGamerShutdown);
InitializeMap();
}
protected override void OnTabletopMove(TabletopMoveEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } playerSession)
return;
if (!TryComp(GetEntity(msg.TableUid), out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
// Check if player is actually playing at this table.
if (!session.Players.ContainsKey(playerSession))
return;
base.OnTabletopMove(msg, args);
}
private void OnTabletopActivate(Entity<TabletopGameComponent> ent, ref ActivateInWorldEvent args)
{
if (args.Handled || !args.Complex)
return;
// Check that a player is attached to the entity.
if (!TryComp(args.User, out ActorComponent? actor))
return;
OpenSessionFor(actor.PlayerSession, ent.Owner);
}
private void OnGameShutdown(Entity<TabletopGameComponent> ent, ref ComponentShutdown args)
{
CleanupSession(ent.Owner);
}
private void OnStopPlaying(TabletopStopPlayingEvent msg, EntitySessionEventArgs args)
{
CloseSessionFor(args.SenderSession, GetEntity(msg.TableUid));
}
private void OnPlayerDetached(Entity<TabletopGamerComponent> ent, ref PlayerDetachedEvent args)
{
if (ent.Comp.Tabletop.IsValid())
CloseSessionFor(args.Player, ent.Comp.Tabletop);
}
private void OnGamerShutdown(Entity<TabletopGamerComponent> ent, ref ComponentShutdown args)
{
if (!TryComp(ent.Owner, out ActorComponent? actor))
return;
if (ent.Comp.Tabletop.IsValid())
CloseSessionFor(actor.PlayerSession, ent.Comp.Tabletop);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<TabletopGamerComponent>();
while (query.MoveNext(out var uid, out var gamer))
{
base.Initialize();
SubscribeNetworkEvent<TabletopStopPlayingEvent>(OnStopPlaying);
SubscribeLocalEvent<TabletopGameComponent, ActivateInWorldEvent>(OnTabletopActivate);
SubscribeLocalEvent<TabletopGameComponent, ComponentShutdown>(OnGameShutdown);
SubscribeLocalEvent<TabletopGamerComponent, PlayerDetachedEvent>(OnPlayerDetached);
SubscribeLocalEvent<TabletopGamerComponent, ComponentShutdown>(OnGamerShutdown);
SubscribeLocalEvent<TabletopGameComponent, GetVerbsEvent<ActivationVerb>>(AddPlayGameVerb);
SubscribeLocalEvent<TabletopGameComponent, InteractUsingEvent>(OnInteractUsing);
if (!Exists(gamer.Tabletop))
continue;
SubscribeNetworkEvent<TabletopRequestTakeOut>(OnTabletopRequestTakeOut);
InitializeMap();
}
private void OnTabletopRequestTakeOut(TabletopRequestTakeOut msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } playerSession)
return;
var table = GetEntity(msg.TableUid);
if (!TryComp(table, out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
if (!msg.Entity.IsValid())
return;
var entity = GetEntity(msg.Entity);
if (!TryComp(entity, out TabletopHologramComponent? hologram))
{
_popupSystem.PopupEntity(Loc.GetString("tabletop-error-remove-non-hologram"), table, args.SenderSession);
return;
}
// Check if player is actually playing at this table
if (!session.Players.ContainsKey(playerSession))
return;
// Find the entity, remove it from the session and set it's position to the tabletop
session.Entities.TryGetValue(entity, out var result);
session.Entities.Remove(result);
QueueDel(result);
}
private void OnInteractUsing(EntityUid uid, TabletopGameComponent component, InteractUsingEvent args)
{
if (!_cfg.GetCVar(CCVars.GameTabletopPlace))
return;
if (!TryComp(args.User, out HandsComponent? hands))
return;
if (component.Session is not { } session)
return;
if (!_hands.TryGetActiveItem(uid, out var handEnt))
return;
if (!TryComp<ItemComponent>(handEnt, out var item))
return;
var meta = MetaData(handEnt.Value);
var protoId = meta.EntityPrototype?.ID;
var hologram = Spawn(protoId, session.Position.Offset(-1, 0));
// Make sure the entity can be dragged and can be removed, move it into the board game world and add it to the Entities hashmap
EnsureComp<TabletopDraggableComponent>(hologram);
EnsureComp<TabletopHologramComponent>(hologram);
session.Entities.Add(hologram);
_popupSystem.PopupEntity(Loc.GetString("tabletop-added-piece"), uid, args.User);
}
protected override void OnTabletopMove(TabletopMoveEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } playerSession)
return;
if (!TryComp(GetEntity(msg.TableUid), out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
// Check if player is actually playing at this table
if (!session.Players.ContainsKey(playerSession))
return;
base.OnTabletopMove(msg, args);
}
/// <summary>
/// Add a verb that allows the player to start playing a tabletop game.
/// </summary>
private void AddPlayGameVerb(EntityUid uid, TabletopGameComponent component, GetVerbsEvent<ActivationVerb> args)
{
if (!args.CanAccess || !args.CanInteract)
return;
if (!TryComp(args.User, out ActorComponent? actor))
return;
var playVerb = new ActivationVerb()
{
Text = Loc.GetString("tabletop-verb-play-game"),
Icon = new SpriteSpecifier.Texture(new ("/Textures/Interface/VerbIcons/die.svg.192dpi.png")),
Act = () => OpenSessionFor(actor.PlayerSession, uid)
};
args.Verbs.Add(playVerb);
}
private void OnTabletopActivate(EntityUid uid, TabletopGameComponent component, ActivateInWorldEvent args)
{
if (args.Handled || !args.Complex)
return;
// Check that a player is attached to the entity.
if (!TryComp(args.User, out ActorComponent? actor))
return;
OpenSessionFor(actor.PlayerSession, uid);
}
private void OnGameShutdown(EntityUid uid, TabletopGameComponent component, ComponentShutdown args)
{
CleanupSession(uid);
}
private void OnStopPlaying(TabletopStopPlayingEvent msg, EntitySessionEventArgs args)
{
CloseSessionFor(args.SenderSession, GetEntity(msg.TableUid));
}
private void OnPlayerDetached(EntityUid uid, TabletopGamerComponent component, PlayerDetachedEvent args)
{
if(component.Tabletop.IsValid())
CloseSessionFor(args.Player, component.Tabletop);
}
private void OnGamerShutdown(EntityUid uid, TabletopGamerComponent component, ComponentShutdown args)
{
if (!TryComp(uid, out ActorComponent? actor))
return;
if(component.Tabletop.IsValid())
CloseSessionFor(actor.PlayerSession, component.Tabletop);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<TabletopGamerComponent>();
while (query.MoveNext(out var uid, out var gamer))
{
if (!Exists(gamer.Tabletop))
continue;
if (!TryComp(uid, out ActorComponent? actor))
{
RemComp<TabletopGamerComponent>(uid);
return;
}
if (actor.PlayerSession.Status != SessionStatus.InGame || !CanSeeTable(uid, gamer.Tabletop))
CloseSessionFor(actor.PlayerSession, gamer.Tabletop);
RemCompDeferred<TabletopGamerComponent>(uid);
continue;
}
if (actor.PlayerSession.Status != SessionStatus.InGame || !CanSeeTable(uid, gamer.Tabletop))
CloseSessionFor(actor.PlayerSession, gamer.Tabletop);
}
}
}

View file

@ -0,0 +1,42 @@
using System.Numerics;
using Robust.Shared.GameStates;
namespace Content.Shared.Tabletop.Components;
/// <summary>
/// A component that makes an object playable as a tabletop game.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(SharedTabletopSystem))]
public sealed partial class TabletopGameComponent : Component
{
/// <summary>
/// The localized name of the board. Shown in the UI.
/// </summary>
[DataField]
public LocId BoardName { get; private set; } = "tabletop-default-board-name";
/// <summary>
/// The type of method used to set up a tabletop.
/// </summary>
[DataField(required: true)]
public TabletopSetup Setup { get; private set; } = new TabletopChessSetup();
/// <summary>
/// The size of the viewport being opened. Must match the board dimensions otherwise you'll get the space parallax (unless that's what you want).
/// </summary>
[DataField]
public Vector2i Size { get; private set; } = (300, 300);
/// <summary>
/// The zoom of the viewport camera.
/// </summary>
[DataField]
public Vector2 CameraZoom { get; private set; } = Vector2.One;
/// <summary>
/// The specific session of this tabletop.
/// </summary>
[ViewVariables]
public TabletopSession? Session { get; set; } = null;
}

View file

@ -0,0 +1,14 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Tabletop.Components;
/// <summary>
/// Component for marking an entity as currently playing a tabletop.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(SharedTabletopSystem))]
public sealed partial class TabletopGamerComponent : Component
{
[DataField]
public EntityUid Tabletop = EntityUid.Invalid;
}

View file

@ -1,10 +1,9 @@
namespace Content.Server.Tabletop;
using Robust.Shared.GameStates;
namespace Content.Shared.Tabletop.Components;
/// <summary>
/// This is used for tracking pieces that are simply "holograms" shown on the tabletop
/// </summary>
[RegisterComponent]
public sealed partial class TabletopHologramComponent : Component
{
}
[RegisterComponent, NetworkedComponent]
public sealed partial class TabletopHologramComponent : Component;

View file

@ -0,0 +1,13 @@
using Robust.Shared.Player;
namespace Content.Shared.Tabletop;
public abstract partial class SharedTabletopSystem
{
/// <summary>
/// Adds a player to a tabletop game session, sending a message so the tabletop window opens on their end.
/// </summary>
/// <param name="player">The player session in question.</param>
/// <param name="uid">The UID of the tabletop game entity.</param>
public virtual void OpenSessionFor(ICommonSession player, EntityUid uid) { }
}

View file

@ -1,123 +1,216 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Content.Shared.ActionBlocker;
using Content.Shared.CCVar;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Interaction;
using Content.Shared.Item;
using Content.Shared.Popups;
using Content.Shared.Tabletop.Components;
using Content.Shared.Tabletop.Events;
using Content.Shared.Verbs;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
namespace Content.Shared.Tabletop
namespace Content.Shared.Tabletop;
public abstract partial class SharedTabletopSystem : EntitySystem
{
public abstract partial class SharedTabletopSystem : EntitySystem
[Dependency] private ActionBlockerSystem _actionBlocker = default!;
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private SharedAppearanceSystem _appearance = default!;
[Dependency] private SharedHandsSystem _hands = default!;
[Dependency] private SharedInteractionSystem _interaction = default!;
[Dependency] private SharedMapSystem _map = default!;
[Dependency] private SharedPopupSystem _popup = default!;
[Dependency] private SharedTransformSystem _transforms = default!;
public override void Initialize()
{
[Dependency] protected ActionBlockerSystem ActionBlockerSystem = default!;
[Dependency] private SharedInteractionSystem _interactionSystem = default!;
[Dependency] private SharedAppearanceSystem _appearance = default!;
[Dependency] private SharedMapSystem _mapSystem = default!;
[Dependency] protected SharedTransformSystem Transforms = default!;
base.Initialize();
public override void Initialize()
{
SubscribeAllEvent<TabletopDraggingPlayerChangedEvent>(OnDraggingPlayerChanged);
SubscribeAllEvent<TabletopMoveEvent>(OnTabletopMove);
}
SubscribeAllEvent<TabletopDraggingPlayerChangedEvent>(OnDraggingPlayerChanged);
/// <summary>
/// Move an entity which is dragged by the user, but check if they are allowed to do so and to these coordinates
/// </summary>
protected virtual void OnTabletopMove(TabletopMoveEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { AttachedEntity: { } playerEntity } playerSession)
return;
SubscribeLocalEvent<TabletopGameComponent, GetVerbsEvent<ActivationVerb>>(AddPlayGameVerb);
SubscribeLocalEvent<TabletopGameComponent, InteractUsingEvent>(OnInteractUsing);
var table = GetEntity(msg.TableUid);
var moved = GetEntity(msg.MovedEntityUid);
SubscribeNetworkEvent<TabletopRequestTakeOut>(OnTabletopRequestTakeOut);
if (!CanSeeTable(playerEntity, table) || !CanDrag(playerEntity, moved, out _))
return;
// Move the entity and dirty it (we use the map ID from the entity so noone can try to be funny and move the item to another map)
var transform = Comp<TransformComponent>(moved);
Transforms.SetParent(moved, transform, _mapSystem.GetMapOrInvalid(transform.MapID));
Transforms.SetLocalPositionNoLerp(moved, msg.Coordinates.Position, transform);
}
private void OnDraggingPlayerChanged(TabletopDraggingPlayerChangedEvent msg, EntitySessionEventArgs args)
{
var dragged = GetEntity(msg.DraggedEntityUid);
if (!TryComp(dragged, out TabletopDraggableComponent? draggableComponent))
return;
draggableComponent.DraggingPlayer = msg.IsDragging ? args.SenderSession.UserId : null;
Dirty(dragged, draggableComponent);
if (!TryComp(dragged, out AppearanceComponent? appearance))
return;
if (draggableComponent.DraggingPlayer != null)
{
_appearance.SetData(dragged, TabletopItemVisuals.Scale, new Vector2(1.25f, 1.25f), appearance);
_appearance.SetData(dragged, TabletopItemVisuals.DrawDepth, (int) DrawDepth.DrawDepth.Items + 1, appearance);
}
else
{
_appearance.SetData(dragged, TabletopItemVisuals.Scale, Vector2.One, appearance);
_appearance.SetData(dragged, TabletopItemVisuals.DrawDepth, (int) DrawDepth.DrawDepth.Items, appearance);
}
}
[Serializable, NetSerializable]
public sealed class TabletopDraggableComponentState : ComponentState
{
public NetUserId? DraggingPlayer;
public TabletopDraggableComponentState(NetUserId? draggingPlayer)
{
DraggingPlayer = draggingPlayer;
}
}
[Serializable, NetSerializable]
public sealed class TabletopRequestTakeOut : EntityEventArgs
{
public NetEntity Entity;
public NetEntity TableUid;
}
#region Utility
/// <summary>
/// Whether the table exists, and the player can interact with it.
/// </summary>
/// <param name="playerEntity">The player entity to check.</param>
/// <param name="table">The table entity to check.</param>
protected bool CanSeeTable(EntityUid playerEntity, EntityUid? table)
{
// Table may have been deleted, hence TryComp
if (!TryComp(table, out MetaDataComponent? meta)
|| meta.EntityLifeStage >= EntityLifeStage.Terminating
|| (meta.Flags & MetaDataFlags.InContainer) == MetaDataFlags.InContainer)
{
return false;
}
return _interactionSystem.InRangeUnobstructed(playerEntity, table.Value) && ActionBlockerSystem.CanInteract(playerEntity, table);
}
protected bool CanDrag(EntityUid playerEntity, EntityUid target, [NotNullWhen(true)] out TabletopDraggableComponent? draggable)
{
if (!TryComp(target, out draggable))
return false;
// CanSeeTable checks interaction action blockers. So no need to check them here.
// If this ever changes, so that ghosts can spectate games, then the check needs to be moved here.
return TryComp(playerEntity, out HandsComponent? hands) && hands.Hands.Count > 0;
}
#endregion
SubscribeAllEvent<TabletopMoveEvent>(OnTabletopMove);
}
private void OnTabletopRequestTakeOut(TabletopRequestTakeOut msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } playerSession)
return;
var table = GetEntity(msg.TableUid);
if (!TryComp(table, out TabletopGameComponent? tabletop) || tabletop.Session is not { } session)
return;
if (!msg.Entity.IsValid())
return;
var entity = GetEntity(msg.Entity);
if (!HasComp<TabletopHologramComponent>(entity))
{
_popup.PopupEntity(Loc.GetString("tabletop-error-remove-non-hologram"), table, args.SenderSession);
return;
}
// Check if player is actually playing at this table.
if (!session.Players.ContainsKey(playerSession))
return;
// Find the entity, remove it from the session and set it's position to the tabletop.
session.Entities.TryGetValue(entity, out var result);
session.Entities.Remove(result);
PredictedQueueDel(result);
}
private void OnInteractUsing(Entity<TabletopGameComponent> ent, ref InteractUsingEvent args)
{
if (!_cfg.GetCVar(CCVars.GameTabletopPlace))
return;
if (!HasComp<HandsComponent>(args.User))
return;
if (ent.Comp.Session is not { } session)
return;
if (!_hands.TryGetActiveItem(ent.Owner, out var handEnt))
return;
if (!HasComp<ItemComponent>(handEnt))
return;
var meta = MetaData(handEnt.Value);
var protoId = meta.EntityPrototype?.ID;
var hologram = EntityManager.PredictedSpawn(protoId, session.Position.Offset(-1, 0));
// Make sure the entity can be dragged and can be removed, move it into the board game world and add it to the Entities hashmap.
EnsureComp<TabletopDraggableComponent>(hologram);
EnsureComp<TabletopHologramComponent>(hologram);
session.Entities.Add(hologram);
_popup.PopupClient(Loc.GetString("tabletop-added-piece"), ent.Owner, args.User);
}
/// <summary>
/// Add a verb that allows the player to start playing a tabletop game.
/// </summary>
private void AddPlayGameVerb(Entity<TabletopGameComponent> ent, ref GetVerbsEvent<ActivationVerb> args)
{
if (!args.CanAccess || !args.CanInteract)
return;
if (!TryComp(args.User, out ActorComponent? actor))
return;
var playVerb = new ActivationVerb()
{
Text = Loc.GetString("tabletop-verb-play-game"),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/die.svg.192dpi.png")),
Act = () => OpenSessionFor(actor.PlayerSession, ent.Owner)
};
args.Verbs.Add(playVerb);
}
/// <summary>
/// Move an entity which is dragged by the user, but check if they are allowed to do so and to these coordinates.
/// </summary>
protected virtual void OnTabletopMove(TabletopMoveEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession is not { AttachedEntity: { } playerEntity })
return;
var table = GetEntity(msg.TableUid);
var moved = GetEntity(msg.MovedEntityUid);
if (!CanSeeTable(playerEntity, table) || !CanDrag(playerEntity, moved, out _))
return;
// Move the entity and dirty it (we use the map ID from the entity so noone can try to be funny and move the item to another map)
var transform = Comp<TransformComponent>(moved);
_transforms.SetParent(moved, transform, _map.GetMapOrInvalid(transform.MapID));
_transforms.SetLocalPosition(moved, msg.Coordinates.Position, transform);
}
private void OnDraggingPlayerChanged(TabletopDraggingPlayerChangedEvent msg, EntitySessionEventArgs args)
{
var dragged = GetEntity(msg.DraggedEntityUid);
if (!TryComp(dragged, out TabletopDraggableComponent? draggableComponent))
return;
draggableComponent.DraggingPlayer = msg.IsDragging ? args.SenderSession.UserId : null;
Dirty(dragged, draggableComponent);
if (!TryComp(dragged, out AppearanceComponent? appearance))
return;
if (draggableComponent.DraggingPlayer != null)
{
_appearance.SetData(dragged, TabletopItemVisuals.Scale, new Vector2(1.25f, 1.25f), appearance);
_appearance.SetData(dragged, TabletopItemVisuals.DrawDepth, (int)DrawDepth.DrawDepth.Items + 1, appearance);
}
else
{
_appearance.SetData(dragged, TabletopItemVisuals.Scale, Vector2.One, appearance);
_appearance.SetData(dragged, TabletopItemVisuals.DrawDepth, (int)DrawDepth.DrawDepth.Items, appearance);
}
}
[Serializable, NetSerializable]
public sealed class TabletopDraggableComponentState(NetUserId? draggingPlayer) : ComponentState
{
public NetUserId? DraggingPlayer = draggingPlayer;
}
[Serializable, NetSerializable]
public sealed class TabletopRequestTakeOut : EntityEventArgs
{
public NetEntity Entity;
public NetEntity TableUid;
}
#region Utility
/// <summary>
/// Whether the table exists, and the player can interact with it.
/// </summary>
/// <param name="playerEntity">The player entity to check.</param>
/// <param name="table">The table entity to check.</param>
protected bool CanSeeTable(EntityUid playerEntity, EntityUid? table)
{
// Table may have been deleted, hence TryComp.
if (!TryComp(table, out MetaDataComponent? meta)
|| meta.EntityLifeStage >= EntityLifeStage.Terminating
|| (meta.Flags & MetaDataFlags.InContainer) == MetaDataFlags.InContainer)
{
return false;
}
return _interaction.InRangeUnobstructed(playerEntity, table.Value) && _actionBlocker.CanInteract(playerEntity, table);
}
protected bool CanDrag(EntityUid playerEntity, EntityUid target, [NotNullWhen(true)] out TabletopDraggableComponent? draggable)
{
if (!TryComp(target, out draggable))
return false;
// CanSeeTable checks interaction action blockers. So no need to check them here.
// If this ever changes, so that ghosts can spectate games, then the check needs to be moved here.
return TryComp(playerEntity, out HandsComponent? hands) && hands.Hands.Count > 0;
}
#endregion
}

View file

@ -0,0 +1,66 @@
using JetBrains.Annotations;
namespace Content.Shared.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopBackgammonSetup : TabletopSetup
{
[DataField]
public string WhitePiecePrototype { get; private set; } = "WhiteTabletopPiece";
[DataField]
public string BlackPiecePrototype { get; private set; } = "BlackTabletopPiece";
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position);
const float borderLengthX = 7.35f; //BORDER
const float borderLengthY = 5.60f; //BORDER
const float boardDistanceX = 1.25f;
const float pieceDistanceY = 0.80f;
float GetXPosition(float distanceFromSide, bool isLeftSide)
{
var pos = borderLengthX - distanceFromSide * boardDistanceX;
return isLeftSide ? -pos : pos;
}
float GetYPosition(float positionNumber, bool isTop)
{
var pos = borderLengthY - pieceDistanceY * positionNumber;
return isTop ? pos : -pos;
}
void AddPieces(
float distanceFromSide,
int numberOfPieces,
bool isBlackPiece,
bool isTop,
bool isLeftSide)
{
for (var i = 0; i < numberOfPieces; i++)
{
session.Entities.Add(entityManager.SpawnEntity(isBlackPiece ? BlackPiecePrototype : WhitePiecePrototype, session.Position.Offset(GetXPosition(distanceFromSide, isLeftSide), GetYPosition(i, isTop))));
}
}
// top left
AddPieces(0, 5, true, true, true);
// top middle left
AddPieces(4, 3, false, true, true);
// top middle right
AddPieces(5, 5, false, true, false);
// top far right
AddPieces(0, 2, true, true, false);
// bottom left
AddPieces(0, 5, false, false, true);
// bottom middle left
AddPieces(4, 3, true, false, true);
// bottom middle right
AddPieces(5, 5, true, false, false);
// bottom far right
AddPieces(0, 2, false, false, false);
}
}

View file

@ -0,0 +1,102 @@
using JetBrains.Annotations;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopCheckerSetup : TabletopSetup
{
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypePieceWhite = default!;
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypeCrownWhite = default!;
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypePieceBlack = default!;
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string PrototypeCrownBlack = default!;
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
session.Entities.Add(
entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(-1, 0))
);
SpawnPieces(session, entityManager, session.Position.Offset(-4.5f, 3.5f));
}
private void SpawnPieces(TabletopSession session, IEntityManager entityManager, MapCoordinates left)
{
static float GetOffset(float offset) => offset * 1f /* separation */;
var pieces = new EntityUid[42];
var pieceIndex = 0;
// Pieces.
for (var offsetY = 0; offsetY < 3; offsetY++)
{
var checker = offsetY % 2;
for (var offsetX = 0; offsetX < 8; offsetX += 2)
{
// Prevents an extra piece on the middle row.
if (checker + offsetX > 8) continue;
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceBlack,
left.Offset(GetOffset(offsetX + (1 - checker)), GetOffset(offsetY * -1))
);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceWhite,
left.Offset(GetOffset(offsetX + checker), GetOffset(offsetY - 7))
);
pieceIndex += 2;
}
}
const int numCrowns = 3;
const float overlap = 0.25f;
const float xOffset = 9f / 32;
const float xOffsetBlack = 9 + xOffset;
const float xOffsetWhite = 8 + xOffset;
// Crowns.
for (var i = 0; i < numCrowns; i++)
{
var step = -(overlap * i);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypeCrownBlack,
left.Offset(GetOffset(xOffsetBlack), GetOffset(step))
);
pieces[pieceIndex + 1] = entityManager.SpawnEntity(
PrototypeCrownWhite,
left.Offset(GetOffset(xOffsetWhite), GetOffset(step))
);
pieceIndex += 2;
}
// Spares.
for (var i = 0; i < 6; i++)
{
var step = -(overlap * (numCrowns + 2) + overlap * i);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceBlack,
left.Offset(GetOffset(xOffsetBlack), GetOffset(step))
);
pieces[pieceIndex] = entityManager.SpawnEntity(
PrototypePieceWhite,
left.Offset(GetOffset(xOffsetWhite), GetOffset(step))
);
pieceIndex += 2;
}
for (var i = 0; i < pieces.Length; i++)
{
session.Entities.Add(pieces[i]);
}
}
}

View file

@ -0,0 +1,85 @@
using JetBrains.Annotations;
using Robust.Shared.Map;
namespace Content.Shared.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopChessSetup : TabletopSetup
{
// TODO: Un-hardcode the rest of entity prototype IDs, probably.
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var chessboard = entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(-1, 0));
session.Entities.Add(chessboard);
SpawnPieces(session, entityManager, session.Position.Offset(-4.5f, 3.5f));
}
private void SpawnPieces(TabletopSession session, IEntityManager entityManager, MapCoordinates topLeft, float separation = 1f)
{
var (mapId, x, y) = topLeft;
// Spawn all black pieces.
SpawnPiecesRow(session, entityManager, "Black", topLeft, separation);
SpawnPawns(session, entityManager, "Black", new MapCoordinates(x, y - separation, mapId), separation);
// Spawn all white pieces.
SpawnPawns(session, entityManager, "White", new MapCoordinates(x, y - 6 * separation, mapId), separation);
SpawnPiecesRow(session, entityManager, "White", new MapCoordinates(x, y - 7 * separation, mapId), separation);
// Extra queens.
var tempQualifier = entityManager.SpawnEntity("BlackQueen", new MapCoordinates(x + 9 * separation + 9f / 32, y - 3 * separation, mapId));
session.Entities.Add(tempQualifier);
var tempQualifier1 = entityManager.SpawnEntity("WhiteQueen", new MapCoordinates(x + 9 * separation + 9f / 32, y - 4 * separation, mapId));
session.Entities.Add(tempQualifier1);
}
// TODO: refactor to load FEN instead
private void SpawnPiecesRow(TabletopSession session, IEntityManager entityManager, string color, MapCoordinates left, float separation = 1f)
{
const string piecesRow = "rnbqkbnr";
var (mapId, x, y) = left;
for (var i = 0; i < 8; i++)
{
switch (piecesRow[i])
{
case 'r':
var tempQualifier = entityManager.SpawnEntity(color + "Rook", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier);
break;
case 'n':
var tempQualifier1 = entityManager.SpawnEntity(color + "Knight", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier1);
break;
case 'b':
var tempQualifier2 = entityManager.SpawnEntity(color + "Bishop", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier2);
break;
case 'q':
var tempQualifier3 = entityManager.SpawnEntity(color + "Queen", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier3);
break;
case 'k':
var tempQualifier4 = entityManager.SpawnEntity(color + "King", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier4);
break;
}
}
}
// TODO: refactor to load FEN instead
private void SpawnPawns(TabletopSession session, IEntityManager entityManager, string color, MapCoordinates left, float separation = 1f)
{
var (mapId, x, y) = left;
for (var i = 0; i < 8; i++)
{
var tempQualifier = entityManager.SpawnEntity(color + "Pawn", new MapCoordinates(x + i * separation, y, mapId));
session.Entities.Add(tempQualifier);
}
}
}

View file

@ -0,0 +1,13 @@
using JetBrains.Annotations;
namespace Content.Shared.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopEmptySetup : TabletopSetup
{
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position.Offset(0, 0));
session.Entities.Add(board);
}
}

View file

@ -0,0 +1,74 @@
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Tabletop;
[UsedImplicitly]
public sealed partial class TabletopParchisSetup : TabletopSetup
{
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string RedPiecePrototype = "RedTabletopPiece";
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string GreenPiecePrototype = "GreenTabletopPiece";
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string YellowPiecePrototype = "YellowTabletopPiece";
[DataField(customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string BluePiecePrototype = "BlueTabletopPiece";
public override void SetupTabletop(TabletopSession session, IEntityManager entityManager)
{
var board = entityManager.SpawnEntity(BoardPrototype, session.Position);
const float x1 = 6.25f;
const float x2 = 4.25f;
const float y1 = 6.25f;
const float y2 = 4.25f;
var center = session.Position;
// Red pieces.
var tempQualifier = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x1, -y1));
session.Entities.Add(tempQualifier);
var tempQualifier1 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x1, -y2));
session.Entities.Add(tempQualifier1);
var tempQualifier2 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x2, -y1));
session.Entities.Add(tempQualifier2);
var tempQualifier3 = entityManager.SpawnEntity(RedPiecePrototype, center.Offset(-x2, -y2));
session.Entities.Add(tempQualifier3);
// Green pieces.
var tempQualifier4 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x1, -y1));
session.Entities.Add(tempQualifier4);
var tempQualifier5 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x1, -y2));
session.Entities.Add(tempQualifier5);
var tempQualifier6 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x2, -y1));
session.Entities.Add(tempQualifier6);
var tempQualifier7 = entityManager.SpawnEntity(GreenPiecePrototype, center.Offset(x2, -y2));
session.Entities.Add(tempQualifier7);
// Yellow pieces.
var tempQualifier8 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x1, y1));
session.Entities.Add(tempQualifier8);
var tempQualifier9 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x1, y2));
session.Entities.Add(tempQualifier9);
var tempQualifier10 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x2, y1));
session.Entities.Add(tempQualifier10);
var tempQualifier11 = entityManager.SpawnEntity(YellowPiecePrototype, center.Offset(x2, y2));
session.Entities.Add(tempQualifier11);
// Blue pieces.
var tempQualifier12 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x1, y1));
session.Entities.Add(tempQualifier12);
var tempQualifier13 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x1, y2));
session.Entities.Add(tempQualifier13);
var tempQualifier14 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x2, y1));
session.Entities.Add(tempQualifier14);
var tempQualifier15 = entityManager.SpawnEntity(BluePiecePrototype, center.Offset(-x2, y2));
session.Entities.Add(tempQualifier15);
}
}

View file

@ -0,0 +1,26 @@
using System.Numerics;
using Robust.Shared.Map;
using Robust.Shared.Player;
namespace Content.Shared.Tabletop;
/// <summary>
/// A class for storing data about a running tabletop game.
/// </summary>
public sealed class TabletopSession(MapId tabletopMap, Vector2 position)
{
/// <summary>
/// The center position of this session.
/// </summary>
public readonly MapCoordinates Position = new(position, tabletopMap);
/// <summary>
/// The set of players currently playing this tabletop game.
/// </summary>
public readonly Dictionary<ICommonSession, TabletopSessionPlayerData> Players = [];
/// <summary>
/// All entities bound to this session. If you create an entity for this session, you have to add it here.
/// </summary>
public readonly HashSet<EntityUid> Entities = [];
}

View file

@ -0,0 +1,9 @@
namespace Content.Shared.Tabletop;
/// <summary>
/// A class that stores per-player data for tabletops.
/// </summary>
public sealed class TabletopSessionPlayerData
{
public EntityUid Camera { get; set; }
}

View file

@ -0,0 +1,19 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Tabletop;
[ImplicitDataDefinitionForInheritors]
public abstract partial class TabletopSetup
{
/// <summary>
/// Method for setting up a tabletop. Use this to spawn the board and pieces, etc.
/// Make sure you add every entity you create to the Entities hashset in the session.
/// </summary>
/// <param name="session">Tabletop session to set up. You'll want to grab the tabletop center position here for spawning entities.</param>
/// <param name="entityManager">Dependency that can be used for spawning entities.</param>
public abstract void SetupTabletop(TabletopSession session, IEntityManager entityManager);
[DataField]
public EntProtoId BoardPrototype;
}