live player info in client
This commit is contained in:
parent
fb675e59ff
commit
a50a9770c9
|
@ -1,7 +1,8 @@
|
||||||
import {useQuery} from '@tanstack/react-query'
|
import {makeApiUrl, Scores} from './serverCalls';
|
||||||
import {makeApiUrl, Player} from './serverCalls';
|
import {Guid} from './Guid.ts';
|
||||||
import {Guid} from "./Guid.ts";
|
import Column from './components/Column.tsx';
|
||||||
import Column from "./components/Column.tsx";
|
import useWebSocket, {ReadyState} from 'react-use-websocket';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
function ScoreRow({name, value}: {
|
function ScoreRow({name, value}: {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -13,33 +14,66 @@ function ScoreRow({name, value}: {
|
||||||
</tr>;
|
</tr>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlayerInfo({playerId}: { playerId: Guid }) {
|
type Controls = {
|
||||||
const query = useQuery({
|
readonly forward: boolean;
|
||||||
queryKey: ['player'],
|
readonly backward: boolean;
|
||||||
refetchInterval: 1000,
|
readonly turnLeft: boolean;
|
||||||
queryFn: async () => {
|
readonly turnRight: boolean;
|
||||||
const url = makeApiUrl('/player');
|
readonly shoot: boolean;
|
||||||
url.searchParams.set('id', playerId);
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {method: 'GET'});
|
type PlayerInfoMessage = {
|
||||||
if (!response.ok)
|
readonly name: string;
|
||||||
throw new Error(`response failed with code ${response.status} (${response.status})${await response.text()}`)
|
readonly scores: Scores;
|
||||||
return await response.json() as Player;
|
readonly controls: Controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function controlsString(controls: Controls) {
|
||||||
|
let str = "";
|
||||||
|
if (controls.forward)
|
||||||
|
str += "▲";
|
||||||
|
if (controls.backward)
|
||||||
|
str += "▼";
|
||||||
|
if (controls.turnLeft)
|
||||||
|
str += "◄";
|
||||||
|
if (controls.turnRight)
|
||||||
|
str += "►";
|
||||||
|
if (controls.shoot)
|
||||||
|
str += "•";
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerInfo({playerId}: { playerId: Guid }) {
|
||||||
|
const [shouldSendMessage, setShouldSendMessage] = useState(true);
|
||||||
|
|
||||||
|
const url = makeApiUrl('/player');
|
||||||
|
url.searchParams.set('id', playerId);
|
||||||
|
|
||||||
|
const {lastJsonMessage, readyState, sendMessage} = useWebSocket<PlayerInfoMessage>(url.toString(), {
|
||||||
|
onMessage: () => setShouldSendMessage(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
return <Column className='PlayerInfo'>
|
useEffect(() => {
|
||||||
|
if (!shouldSendMessage || readyState !== ReadyState.OPEN)
|
||||||
|
return;
|
||||||
|
setShouldSendMessage(false);
|
||||||
|
sendMessage('');
|
||||||
|
}, [readyState, shouldSendMessage]);
|
||||||
|
|
||||||
|
if (!lastJsonMessage)
|
||||||
|
return <></>;
|
||||||
|
|
||||||
|
return <Column className="PlayerInfo">
|
||||||
<h3>
|
<h3>
|
||||||
{query.isPending && 'loading...'}
|
Playing as {lastJsonMessage.name}
|
||||||
{query.isSuccess && `Playing as ${query.data.name}`}
|
|
||||||
</h3>
|
</h3>
|
||||||
{query.isError && <p>{query.error.message}</p>}
|
<table>
|
||||||
{query.isSuccess && <table>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
<ScoreRow name='kills' value={query.data?.scores?.kills}/>
|
<ScoreRow name="controls" value={controlsString(lastJsonMessage.controls)}/>
|
||||||
<ScoreRow name='deaths' value={query.data?.scores?.deaths}/>
|
<ScoreRow name="kills" value={lastJsonMessage.scores.kills}/>
|
||||||
<ScoreRow name='walls' value={query.data?.scores?.wallsDestroyed}/>
|
<ScoreRow name="deaths" value={lastJsonMessage.scores.deaths}/>
|
||||||
|
<ScoreRow name="walls" value={lastJsonMessage.scores.wallsDestroyed}/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>}
|
</table>
|
||||||
</Column>;
|
</Column>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,14 +12,16 @@ export type ServerResponse<T> = {
|
||||||
successResult?: T;
|
successResult?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Scores = {
|
||||||
|
readonly kills: number;
|
||||||
|
readonly deaths: number;
|
||||||
|
readonly wallsDestroyed: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly id: Guid;
|
readonly id: Guid;
|
||||||
readonly scores: {
|
readonly scores: Scores;
|
||||||
readonly kills: number;
|
|
||||||
readonly deaths: number;
|
|
||||||
readonly wallsDestroyed: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NameId = {
|
export type NameId = {
|
||||||
|
|
|
@ -33,11 +33,18 @@ internal static class Endpoints
|
||||||
: Results.Unauthorized();
|
: Results.Unauthorized();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapGet("/player", ([FromQuery] Guid id) =>
|
app.MapGet("/player", async (HttpContext context, [FromQuery] Guid id) =>
|
||||||
playerService.TryGet(id, out var foundPlayer)
|
{
|
||||||
? Results.Ok((object?)foundPlayer)
|
if (!playerService.TryGet(id, out var foundPlayer))
|
||||||
: Results.NotFound()
|
return Results.NotFound();
|
||||||
);
|
|
||||||
|
if (!context.WebSockets.IsWebSocketRequest)
|
||||||
|
return Results.Ok((object?)foundPlayer);
|
||||||
|
|
||||||
|
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
|
await playerService.HandleClientAsync(ws, foundPlayer);
|
||||||
|
return Results.Empty;
|
||||||
|
});
|
||||||
|
|
||||||
app.MapGet("/scores", () => playerService.GetAll());
|
app.MapGet("/scores", () => playerService.GetAll());
|
||||||
|
|
||||||
|
|
|
@ -7,4 +7,5 @@ namespace TanksServer.Interactivity;
|
||||||
[JsonSerializable(typeof(Guid))]
|
[JsonSerializable(typeof(Guid))]
|
||||||
[JsonSerializable(typeof(NameId))]
|
[JsonSerializable(typeof(NameId))]
|
||||||
[JsonSerializable(typeof(IEnumerable<string>))]
|
[JsonSerializable(typeof(IEnumerable<string>))]
|
||||||
|
[JsonSerializable(typeof(PlayerInfo))]
|
||||||
internal sealed partial class AppSerializerContext : JsonSerializerContext;
|
internal sealed partial class AppSerializerContext : JsonSerializerContext;
|
||||||
|
|
|
@ -7,9 +7,12 @@ internal sealed class ByteChannelWebSocket(WebSocket socket, ILogger logger, int
|
||||||
{
|
{
|
||||||
private readonly byte[] _buffer = new byte[messageSize];
|
private readonly byte[] _buffer = new byte[messageSize];
|
||||||
|
|
||||||
public ValueTask SendAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
|
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
|
||||||
socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None);
|
socket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage, CancellationToken.None);
|
||||||
|
|
||||||
|
public ValueTask SendTextAsync(ReadOnlyMemory<byte> data, bool endOfMessage = true) =>
|
||||||
|
socket.SendAsync(data, WebSocketMessageType.Text, endOfMessage, CancellationToken.None);
|
||||||
|
|
||||||
public async IAsyncEnumerable<Memory<byte>> ReadAllAsync()
|
public async IAsyncEnumerable<Memory<byte>> ReadAllAsync()
|
||||||
{
|
{
|
||||||
while (socket.State is WebSocketState.Open or WebSocketState.CloseSent)
|
while (socket.State is WebSocketState.Open or WebSocketState.CloseSent)
|
||||||
|
|
|
@ -26,7 +26,7 @@ internal sealed class ClientScreenServerConnection(
|
||||||
|
|
||||||
if (!await _wantedFrames.WaitAsync(TimeSpan.Zero))
|
if (!await _wantedFrames.WaitAsync(TimeSpan.Zero))
|
||||||
{
|
{
|
||||||
logger.LogTrace("client does not want a frame yet");
|
Logger.LogTrace("client does not want a frame yet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,17 +35,17 @@ internal sealed class ClientScreenServerConnection(
|
||||||
if (_playerScreenData != null)
|
if (_playerScreenData != null)
|
||||||
RefreshPlayerSpecificData(gamePixelGrid);
|
RefreshPlayerSpecificData(gamePixelGrid);
|
||||||
|
|
||||||
logger.LogTrace("sending");
|
Logger.LogTrace("sending");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length);
|
Logger.LogTrace("sending {} bytes of pixel data", pixels.Data.Length);
|
||||||
await Socket.SendAsync(pixels.Data, _playerScreenData == null);
|
await Socket.SendBinaryAsync(pixels.Data, _playerScreenData == null);
|
||||||
if (_playerScreenData != null)
|
if (_playerScreenData != null)
|
||||||
await Socket.SendAsync(_playerScreenData.GetPacket());
|
await Socket.SendBinaryAsync(_playerScreenData.GetPacket());
|
||||||
}
|
}
|
||||||
catch (WebSocketException ex)
|
catch (WebSocketException ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "send failed");
|
Logger.LogWarning(ex, "send failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,5 +61,9 @@ internal sealed class ClientScreenServerConnection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void HandleMessage(Memory<byte> _) => _wantedFrames.Release();
|
protected override ValueTask HandleMessageAsync(Memory<byte> _)
|
||||||
|
{
|
||||||
|
_wantedFrames.Release();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,13 +7,11 @@ internal sealed class ControlsServer(
|
||||||
ILoggerFactory loggerFactory
|
ILoggerFactory loggerFactory
|
||||||
) : WebsocketServer<ControlsServerConnection>(logger)
|
) : WebsocketServer<ControlsServerConnection>(logger)
|
||||||
{
|
{
|
||||||
public async Task HandleClientAsync(WebSocket ws, Player player)
|
public Task HandleClientAsync(WebSocket ws, Player player)
|
||||||
{
|
{
|
||||||
logger.LogDebug("control client connected {}", player.Id);
|
logger.LogDebug("control client connected {}", player.Id);
|
||||||
var clientLogger = loggerFactory.CreateLogger<ControlsServerConnection>();
|
var clientLogger = loggerFactory.CreateLogger<ControlsServerConnection>();
|
||||||
var sock = new ControlsServerConnection(ws, clientLogger, player);
|
var sock = new ControlsServerConnection(ws, clientLogger, player);
|
||||||
await AddConnection(sock);
|
return HandleClientAsync(sock);
|
||||||
await sock.ReceiveAsync();
|
|
||||||
await RemoveConnection(sock);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,12 +23,12 @@ internal sealed class ControlsServerConnection(
|
||||||
Shoot = 0x05
|
Shoot = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void HandleMessage(Memory<byte> buffer)
|
protected override ValueTask HandleMessageAsync(Memory<byte> buffer)
|
||||||
{
|
{
|
||||||
var type = (MessageType)buffer.Span[0];
|
var type = (MessageType)buffer.Span[0];
|
||||||
var control = (InputType)buffer.Span[1];
|
var control = (InputType)buffer.Span[1];
|
||||||
|
|
||||||
logger.LogTrace("player input {} {} {}", player.Id, type, control);
|
Logger.LogTrace("player input {} {} {}", player.Id, type, control);
|
||||||
|
|
||||||
var isEnable = type switch
|
var isEnable = type switch
|
||||||
{
|
{
|
||||||
|
@ -59,5 +59,7 @@ internal sealed class ControlsServerConnection(
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException("invalid control type");
|
throw new ArgumentException("invalid control type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TanksServer.Interactivity;
|
||||||
|
|
||||||
|
internal sealed class PlayerInfoConnection(
|
||||||
|
Player player,
|
||||||
|
ILogger logger,
|
||||||
|
WebSocket rawSocket
|
||||||
|
) : WebsocketServerConnection(logger, new ByteChannelWebSocket(rawSocket, logger, 0)), IDisposable
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _wantedFrames = new(1);
|
||||||
|
private readonly AppSerializerContext _context = new(new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||||
|
private byte[] _lastMessage = [];
|
||||||
|
|
||||||
|
protected override ValueTask HandleMessageAsync(Memory<byte> buffer)
|
||||||
|
{
|
||||||
|
var response = GetMessageToSend();
|
||||||
|
if (response == null)
|
||||||
|
{
|
||||||
|
Logger.LogTrace("cannot respond directly, increasing wanted frames");
|
||||||
|
_wantedFrames.Release();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogTrace("responding directly");
|
||||||
|
return Socket.SendTextAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnGameTickAsync()
|
||||||
|
{
|
||||||
|
if (!await _wantedFrames.WaitAsync(TimeSpan.Zero))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var response = GetMessageToSend();
|
||||||
|
if (response == null)
|
||||||
|
{
|
||||||
|
_wantedFrames.Release();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogTrace("responding indirectly");
|
||||||
|
await Socket.SendTextAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? GetMessageToSend()
|
||||||
|
{
|
||||||
|
var info = new PlayerInfo(player.Name, player.Scores, player.Controls);
|
||||||
|
var response = JsonSerializer.SerializeToUtf8Bytes(info, _context.PlayerInfo);
|
||||||
|
|
||||||
|
if (response.SequenceEqual(_lastMessage))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return _lastMessage = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _wantedFrames.Dispose();
|
||||||
|
}
|
|
@ -1,30 +1,32 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Net.WebSockets;
|
||||||
using TanksServer.GameLogic;
|
using TanksServer.GameLogic;
|
||||||
|
|
||||||
namespace TanksServer.Interactivity;
|
namespace TanksServer.Interactivity;
|
||||||
|
|
||||||
internal sealed class PlayerServer(
|
internal sealed class PlayerServer(
|
||||||
ILogger<PlayerServer> logger,
|
ILogger<PlayerServer> logger,
|
||||||
|
ILogger<PlayerInfoConnection> connectionLogger,
|
||||||
TankSpawnQueue tankSpawnQueue
|
TankSpawnQueue tankSpawnQueue
|
||||||
)
|
) : WebsocketServer<PlayerInfoConnection>(logger), ITickStep
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, Player> _players = new();
|
private readonly ConcurrentDictionary<string, Player> _players = new();
|
||||||
|
|
||||||
public Player? GetOrAdd(string name, Guid id)
|
public Player? GetOrAdd(string name, Guid id)
|
||||||
{
|
{
|
||||||
Player AddAndSpawn()
|
var existingOrAddedPlayer = _players.GetOrAdd(name, _ => AddAndSpawn());
|
||||||
{
|
if (existingOrAddedPlayer.Id != id)
|
||||||
var player = new Player(name, id);
|
|
||||||
tankSpawnQueue.EnqueueForImmediateSpawn(player);
|
|
||||||
return player;
|
|
||||||
}
|
|
||||||
|
|
||||||
var player = _players.GetOrAdd(name, _ => AddAndSpawn());
|
|
||||||
if (player.Id != id)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
logger.LogInformation("player {} (re)joined", player.Id);
|
logger.LogInformation("player {} (re)joined", existingOrAddedPlayer.Id);
|
||||||
return player;
|
return existingOrAddedPlayer;
|
||||||
|
|
||||||
|
Player AddAndSpawn()
|
||||||
|
{
|
||||||
|
var newPlayer = new Player(name, id);
|
||||||
|
tankSpawnQueue.EnqueueForImmediateSpawn(newPlayer);
|
||||||
|
return newPlayer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TryGet(Guid? playerId, [MaybeNullWhen(false)] out Player foundPlayer)
|
public bool TryGet(Guid? playerId, [MaybeNullWhen(false)] out Player foundPlayer)
|
||||||
|
@ -42,4 +44,10 @@ internal sealed class PlayerServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<Player> GetAll() => _players.Values;
|
public IEnumerable<Player> GetAll() => _players.Values;
|
||||||
|
|
||||||
|
public Task HandleClientAsync(WebSocket webSocket, Player player)
|
||||||
|
=> HandleClientAsync(new PlayerInfoConnection(player, connectionLogger, webSocket));
|
||||||
|
|
||||||
|
public Task TickAsync(TimeSpan delta)
|
||||||
|
=> ParallelForEachConnectionAsync(connection => connection.OnGameTickAsync());
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ internal abstract class WebsocketServer<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Task AddConnection(T connection) => Locked(() =>
|
private Task AddConnectionAsync(T connection) => Locked(() =>
|
||||||
{
|
{
|
||||||
if (_closing)
|
if (_closing)
|
||||||
{
|
{
|
||||||
|
@ -47,7 +47,7 @@ internal abstract class WebsocketServer<T>(
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}, CancellationToken.None);
|
}, CancellationToken.None);
|
||||||
|
|
||||||
protected Task RemoveConnection(T connection) => Locked(() =>
|
private Task RemoveConnectionAsync(T connection) => Locked(() =>
|
||||||
{
|
{
|
||||||
_connections.Remove(connection);
|
_connections.Remove(connection);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
@ -55,9 +55,9 @@ internal abstract class WebsocketServer<T>(
|
||||||
|
|
||||||
protected async Task HandleClientAsync(T connection)
|
protected async Task HandleClientAsync(T connection)
|
||||||
{
|
{
|
||||||
await AddConnection(connection);
|
await AddConnectionAsync(connection);
|
||||||
await connection.ReceiveAsync();
|
await connection.ReceiveAsync();
|
||||||
await RemoveConnection(connection);
|
await RemoveConnectionAsync(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Locked(Func<Task> action, CancellationToken cancellationToken)
|
private async Task Locked(Func<Task> action, CancellationToken cancellationToken)
|
||||||
|
|
|
@ -2,22 +2,24 @@ namespace TanksServer.Interactivity;
|
||||||
|
|
||||||
internal abstract class WebsocketServerConnection(
|
internal abstract class WebsocketServerConnection(
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
ByteChannelWebSocket socket)
|
ByteChannelWebSocket socket
|
||||||
|
)
|
||||||
{
|
{
|
||||||
protected readonly ByteChannelWebSocket Socket = socket;
|
protected readonly ByteChannelWebSocket Socket = socket;
|
||||||
|
protected readonly ILogger Logger = logger;
|
||||||
|
|
||||||
public Task CloseAsync()
|
public Task CloseAsync()
|
||||||
{
|
{
|
||||||
logger.LogDebug("closing connection");
|
Logger.LogDebug("closing connection");
|
||||||
return Socket.CloseAsync();
|
return Socket.CloseAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReceiveAsync()
|
public async Task ReceiveAsync()
|
||||||
{
|
{
|
||||||
await foreach (var buffer in Socket.ReadAllAsync())
|
await foreach (var buffer in Socket.ReadAllAsync())
|
||||||
HandleMessage(buffer);
|
await HandleMessageAsync(buffer);
|
||||||
logger.LogTrace("done receiving");
|
Logger.LogTrace("done receiving");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract void HandleMessage(Memory<byte> buffer);
|
protected abstract ValueTask HandleMessageAsync(Memory<byte> buffer);
|
||||||
}
|
}
|
||||||
|
|
3
tanks-backend/TanksServer/Models/PlayerInfo.cs
Normal file
3
tanks-backend/TanksServer/Models/PlayerInfo.cs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
namespace TanksServer.Models;
|
||||||
|
|
||||||
|
internal sealed record class PlayerInfo(string Name, Scores Scores, PlayerControls Controls);
|
|
@ -77,6 +77,7 @@ public static class Program
|
||||||
builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<TankSpawnQueue>());
|
builder.Services.AddSingleton<ITickStep>(sp => sp.GetRequiredService<TankSpawnQueue>());
|
||||||
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
|
builder.Services.AddSingleton<ITickStep, SpawnPowerUp>();
|
||||||
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
|
builder.Services.AddSingleton<ITickStep, GeneratePixelsTickStep>();
|
||||||
|
builder.Services.AddSingleton<ITickStep, PlayerServer>(sp => sp.GetRequiredService<PlayerServer>());
|
||||||
|
|
||||||
builder.Services.AddSingleton<IDrawStep, DrawMapStep>();
|
builder.Services.AddSingleton<IDrawStep, DrawMapStep>();
|
||||||
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
|
builder.Services.AddSingleton<IDrawStep, DrawPowerUpsStep>();
|
||||||
|
|
Loading…
Reference in a new issue