diff --git a/TanksServer/ClientScreenServer.cs b/TanksServer/ClientScreenServer.cs index 685ad19..5821bf2 100644 --- a/TanksServer/ClientScreenServer.cs +++ b/TanksServer/ClientScreenServer.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Diagnostics; using System.Net.WebSockets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -12,27 +14,36 @@ internal sealed class ClientScreenServer( PixelDrawer drawer ) : IHostedLifecycleService, ITickStep { - private readonly List _connections = new(); + private readonly ConcurrentDictionary _connections = new(); + private bool _closing; public Task HandleClient(WebSocket socket) { + if (_closing) + { + logger.LogWarning("ignoring request because connections are closing"); + return Task.CompletedTask; + } + logger.LogDebug("HandleClient"); var connection = new ClientScreenServerConnection(socket, loggerFactory.CreateLogger(), this); - _connections.Add(connection); + var added = _connections.TryAdd(connection, 0); + Debug.Assert(added); return connection.Done; } public Task StoppingAsync(CancellationToken cancellationToken) { logger.LogInformation("closing connections"); - return Task.WhenAll(_connections.Select(c => c.CloseAsync())); + _closing = true; + return Task.WhenAll(_connections.Keys.Select(c => c.CloseAsync())); } public Task TickAsync() { logger.LogTrace("Sending buffer to {} clients", _connections.Count); - return Task.WhenAll(_connections.Select(c => c.SendAsync(drawer.LastFrame))); + return Task.WhenAll(_connections.Keys.Select(c => c.SendAsync(drawer.LastFrame))); } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -40,35 +51,62 @@ internal sealed class ClientScreenServer( public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - private void Remove(ClientScreenServerConnection connection) => _connections.Remove(connection); - - private sealed class ClientScreenServerConnection( - WebSocket webSocket, - ILogger logger, - ClientScreenServer server - ) : EasyWebSocket(webSocket, logger, ArraySegment.Empty) + + private void Remove(ClientScreenServerConnection connection) => _connections.TryRemove(connection, out _); + + private sealed class ClientScreenServerConnection: IDisposable { - private bool _wantsNewFrame = true; + private readonly ByteChannelWebSocket _channel; + private readonly SemaphoreSlim _wantedFrames = new(1); + private readonly ClientScreenServer _server; + private readonly ILogger _logger; - public Task SendAsync(DisplayPixelBuffer buf) + public ClientScreenServerConnection(WebSocket webSocket, + ILogger logger, + ClientScreenServer server) { - if (!_wantsNewFrame) - return Task.CompletedTask; - _wantsNewFrame = false; - return TrySendAsync(buf.Data); + _server = server; + _logger = logger; + _channel = new(webSocket, logger, 0); + Done = ReceiveAsync(); } - protected override Task ReceiveAsync(ArraySegment buffer) + public async Task SendAsync(DisplayPixelBuffer buf) { - _wantsNewFrame = true; - return Task.CompletedTask; + if (await _wantedFrames.WaitAsync(TimeSpan.Zero)) + { + _logger.LogTrace("sending"); + await _channel.Writer.WriteAsync(buf.Data); + } + else + { + _logger.LogTrace("client does not want a frame yet"); + } } - protected override Task ClosingAsync() + private async Task ReceiveAsync() { - server.Remove(this); - return Task.CompletedTask; + await foreach (var _ in _channel.Reader.ReadAllAsync()) + { + _wantedFrames.Release(); + } + + _logger.LogTrace("done receiving"); + _server.Remove(this); + } + + public Task CloseAsync() + { + _logger.LogDebug("closing connection"); + return _channel.CloseAsync(); + } + + public Task Done { get; } + + public void Dispose() + { + _wantedFrames.Dispose(); + Done.Dispose(); } } } diff --git a/TanksServer/ControlsServer.cs b/TanksServer/ControlsServer.cs index 770e46b..8f5d250 100644 --- a/TanksServer/ControlsServer.cs +++ b/TanksServer/ControlsServer.cs @@ -36,11 +36,25 @@ internal sealed class ControlsServer(ILogger logger, ILoggerFact _connections.Remove(connection); } - private sealed class ControlsServerConnection( - WebSocket socket, ILogger logger, - ControlsServer server, Player player) - : EasyWebSocket(socket, logger, new byte[2]) + private sealed class ControlsServerConnection { + private readonly ByteChannelWebSocket _binaryWebSocket; + private readonly ILogger _logger; + private readonly ControlsServer _server; + private readonly Player _player; + + public ControlsServerConnection(WebSocket socket, ILogger logger, + ControlsServer server, Player player) + { + _logger = logger; + _server = server; + _player = player; + _binaryWebSocket = new(socket, logger, 2); + Done = ReceiveAsync(); + } + + public Task Done { get; } + private enum MessageType : byte { Enable = 0x01, @@ -56,48 +70,47 @@ internal sealed class ControlsServer(ILogger logger, ILoggerFact Shoot = 0x05 } - protected override Task ReceiveAsync(ArraySegment buffer) + private async Task ReceiveAsync() { - var type = (MessageType)buffer[0]; - var control = (InputType)buffer[1]; - - Logger.LogTrace("player input {} {} {}", player.Id, type, control); - - var isEnable = type switch + await foreach (var buffer in _binaryWebSocket.Reader.ReadAllAsync()) { - MessageType.Enable => true, - MessageType.Disable => false, - _ => throw new ArgumentException("invalid message type") - }; + var type = (MessageType)buffer[0]; + var control = (InputType)buffer[1]; - switch (control) - { - case InputType.Forward: - player.Controls.Forward = isEnable; - break; - case InputType.Backward: - player.Controls.Backward = isEnable; - break; - case InputType.Left: - player.Controls.TurnLeft = isEnable; - break; - case InputType.Right: - player.Controls.TurnRight = isEnable; - break; - case InputType.Shoot: - player.Controls.Shoot = isEnable; - break; - default: - throw new ArgumentException("invalid control type"); + _logger.LogTrace("player input {} {} {}", _player.Id, type, control); + + var isEnable = type switch + { + MessageType.Enable => true, + MessageType.Disable => false, + _ => throw new ArgumentException("invalid message type") + }; + + switch (control) + { + case InputType.Forward: + _player.Controls.Forward = isEnable; + break; + case InputType.Backward: + _player.Controls.Backward = isEnable; + break; + case InputType.Left: + _player.Controls.TurnLeft = isEnable; + break; + case InputType.Right: + _player.Controls.TurnRight = isEnable; + break; + case InputType.Shoot: + _player.Controls.Shoot = isEnable; + break; + default: + throw new ArgumentException("invalid control type"); + } } - return Task.CompletedTask; + _server.Remove(this); } - protected override Task ClosingAsync() - { - server.Remove(this); - return Task.CompletedTask; - } + public Task CloseAsync() => _binaryWebSocket.CloseAsync(); } } diff --git a/TanksServer/Helpers/ByteChannelWebSocket.cs b/TanksServer/Helpers/ByteChannelWebSocket.cs new file mode 100644 index 0000000..d6051c5 --- /dev/null +++ b/TanksServer/Helpers/ByteChannelWebSocket.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Net.WebSockets; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace TanksServer.Helpers; + +/// +/// Hacky class for easier semantics +/// +internal sealed class ByteChannelWebSocket : Channel +{ + private readonly ILogger _logger; + private readonly WebSocket _socket; + private readonly Task _backgroundDone; + private readonly byte[] _buffer; + + private readonly Channel _outgoing = Channel.CreateUnbounded(); + private readonly Channel _incoming = Channel.CreateUnbounded(); + + public ByteChannelWebSocket(WebSocket socket, ILogger logger, int messageSize) + { + _socket = socket; + _logger = logger; + _buffer = new byte[messageSize]; + _backgroundDone = Task.WhenAll(ReadLoopAsync(), WriteLoopAsync()); + + Reader = _incoming.Reader; + Writer = _outgoing.Writer; + } + + private async Task ReadLoopAsync() + { + while (true) + { + if (_socket.State is not (WebSocketState.Open or WebSocketState.CloseSent)) + break; + + var response = await _socket.ReceiveAsync(_buffer, CancellationToken.None); + if (response.MessageType == WebSocketMessageType.Close) + { + if (_socket.State == WebSocketState.CloseReceived) + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, + CancellationToken.None); + break; + } + + if (response.Count != _buffer.Length) + { + await _socket.CloseAsync( + WebSocketCloseStatus.InvalidPayloadData, + "response has unexpected size", + CancellationToken.None); + break; + } + + await _incoming.Writer.WriteAsync(_buffer.ToArray()); + } + + if (_socket.State != WebSocketState.Closed) + Debugger.Break(); + + _incoming.Writer.Complete(); + } + + private async Task WriteLoopAsync() + { + await foreach (var data in _outgoing.Reader.ReadAllAsync()) + { + _logger.LogTrace("sending {} bytes of data", data.Length); + try + { + await _socket.SendAsync(data, WebSocketMessageType.Binary, true, CancellationToken.None); + } + catch (WebSocketException wsEx) + { + _logger.LogDebug(wsEx, "send failed"); + } + } + + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + } + + public async Task CloseAsync() + { + _logger.LogDebug("closing socket"); + _outgoing.Writer.Complete(); + await _backgroundDone; + } +} diff --git a/TanksServer/Helpers/EasyWebSocket.cs b/TanksServer/Helpers/EasyWebSocket.cs deleted file mode 100644 index cb7d4bc..0000000 --- a/TanksServer/Helpers/EasyWebSocket.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Net.WebSockets; -using Microsoft.Extensions.Logging; - -namespace TanksServer.Helpers; - -/// -/// Hacky class for easier semantics -/// -internal abstract class EasyWebSocket -{ - private readonly TaskCompletionSource _completionSource = new(); - - protected readonly ILogger Logger; - private readonly WebSocket _socket; - private readonly Task _readLoop; - private readonly ArraySegment _buffer; - private int _closed; - - protected EasyWebSocket(WebSocket socket, ILogger logger, ArraySegment buffer) - { - _socket = socket; - Logger = logger; - _buffer = buffer; - _readLoop = ReadLoopAsync(); - } - - public Task Done => _completionSource.Task; - - private async Task ReadLoopAsync() - { - do - { - var response = await _socket.ReceiveAsync(_buffer, CancellationToken.None); - if (response.CloseStatus.HasValue) - break; - - await ReceiveAsync(_buffer[..response.Count]); - } while (_socket.State == WebSocketState.Open); - } - - protected abstract Task ReceiveAsync(ArraySegment buffer); - protected abstract Task ClosingAsync(); - - protected async Task TrySendAsync(byte[] data) - { - if (_socket.State != WebSocketState.Open) - await CloseAsync(); - - Logger.LogTrace("sending {} bytes of data", _buffer.Count); - - try - { - await _socket.SendAsync(data, WebSocketMessageType.Binary, true, CancellationToken.None); - } - catch (WebSocketException wsEx) - { - Logger.LogDebug(wsEx, "send failed"); - } - } - - public async Task CloseAsync( - WebSocketCloseStatus status = WebSocketCloseStatus.NormalClosure, - string? description = null - ) - { - if (Interlocked.Exchange(ref _closed, 1) == 1) - return; - Logger.LogDebug("closing socket"); - await _socket.CloseAsync(status, description, CancellationToken.None); - await _readLoop; - await ClosingAsync(); - _completionSource.SetResult(); - } -} diff --git a/TanksServer/Models/FloatPosition.cs b/TanksServer/Models/FloatPosition.cs new file mode 100644 index 0000000..8a5c64d --- /dev/null +++ b/TanksServer/Models/FloatPosition.cs @@ -0,0 +1,3 @@ +namespace TanksServer.Models; + +internal record struct FloatPosition(double X, double Y); diff --git a/TanksServer/Models/Tank.cs b/TanksServer/Models/Tank.cs index 71eef56..ecf6d8a 100644 --- a/TanksServer/Models/Tank.cs +++ b/TanksServer/Models/Tank.cs @@ -1,8 +1,16 @@ namespace TanksServer.Models; -internal sealed class Tank(Player player, PixelPosition spawnPosition) +internal sealed class Tank(Player player, FloatPosition spawnPosition) { + private double _rotation; + public Player Owner { get; } = player; - public int Rotation { get; set; } - public PixelPosition Position { get; set; } = spawnPosition; + + public double Rotation + { + get => _rotation; + set => _rotation = value % 16d; + } + + public FloatPosition Position { get; set; } = spawnPosition; } diff --git a/TanksServer/Models/TanksConfiguration.cs b/TanksServer/Models/TanksConfiguration.cs new file mode 100644 index 0000000..b067d04 --- /dev/null +++ b/TanksServer/Models/TanksConfiguration.cs @@ -0,0 +1,9 @@ +namespace TanksServer.Models; + +public class TanksConfiguration +{ + public double MoveSpeed { get; set; } = 1.4; + public double TurnSpeed { get; set; } = 0.4; + public double ShootDelayMs { get; set; } = 0.4 * 1000; + public double BulletSpeed { get; set; } = 8; +} diff --git a/TanksServer/Program.cs b/TanksServer/Program.cs index 17e13fa..67b730a 100644 --- a/TanksServer/Program.cs +++ b/TanksServer/Program.cs @@ -74,23 +74,25 @@ internal static class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddHostedService(); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - - builder.Services.AddSingleton(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(); return builder.Build(); diff --git a/TanksServer/Services/GameTickService.cs b/TanksServer/Services/GameTickService.cs index 8c39497..93406ca 100644 --- a/TanksServer/Services/GameTickService.cs +++ b/TanksServer/Services/GameTickService.cs @@ -20,7 +20,7 @@ internal sealed class GameTickService(IEnumerable steps) : IHostedSer { foreach (var step in _steps) await step.TickAsync(); - await Task.Delay(1000); + await Task.Delay(1000/25); } } diff --git a/TanksServer/Services/PixelDrawer.cs b/TanksServer/Services/PixelDrawer.cs index b5fed90..17631cc 100644 --- a/TanksServer/Services/PixelDrawer.cs +++ b/TanksServer/Services/PixelDrawer.cs @@ -75,14 +75,17 @@ internal sealed class PixelDrawer : ITickStep { foreach (var tank in _tanks) { + var pos = new PixelPosition((int)tank.Position.X, (int)tank.Position.Y); + var rotationVariant = (int)Math.Floor(tank.Rotation); for (var dy = 0; dy < MapService.TileSize; dy++) { - var rowStartIndex = (tank.Position.Y + dy) * MapService.PixelsPerRow; + var rowStartIndex = (pos.Y + dy) * MapService.PixelsPerRow; for (var dx = 0; dx < MapService.TileSize; dx++) { - var i = rowStartIndex + tank.Position.X + dx; - buf.Pixels[i] = TankSpriteAt(dx, dy, tank.Rotation); + var i = rowStartIndex + pos.X + dx; + if (TankSpriteAt(dx, dy, rotationVariant)) + buf.Pixels[i] = true; } } } @@ -91,8 +94,13 @@ internal sealed class PixelDrawer : ITickStep private bool TankSpriteAt(int dx, int dy, int tankRotation) { var x = tankRotation % 4 * (MapService.TileSize + 1); - var y = tankRotation / 4 * (MapService.TileSize + 1); - return _tankSprite[(y + dy) * _tankSpriteWidth + x + dx]; + var y = (int)Math.Floor(tankRotation / 4d) * (MapService.TileSize + 1); + var index = (y + dy) * _tankSpriteWidth + x + dx; + + if (index < 0 || index > _tankSprite.Length) + Debugger.Break(); + + return _tankSprite[index]; } private static DisplayPixelBuffer CreateGameFieldPixelBuffer() diff --git a/TanksServer/Services/SpawnQueue.cs b/TanksServer/Services/SpawnQueue.cs index 40ae9dd..4f7ead1 100644 --- a/TanksServer/Services/SpawnQueue.cs +++ b/TanksServer/Services/SpawnQueue.cs @@ -21,7 +21,7 @@ internal sealed class SpawnQueue(TankManager tanks, MapService map) : ITickStep return Task.CompletedTask; } - private PixelPosition ChooseSpawnPosition() + private FloatPosition ChooseSpawnPosition() { List candidates = new(); @@ -33,12 +33,12 @@ internal sealed class SpawnQueue(TankManager tanks, MapService map) : ITickStep if (map.IsCurrentlyWall(tile)) continue; - // TODO: check tanks + // TODO: check tanks and bullets candidates.Add(tile); } var chosenTile = candidates[Random.Shared.Next(candidates.Count)]; - return new PixelPosition( + return new FloatPosition( chosenTile.X * MapService.TileSize, chosenTile.Y * MapService.TileSize ); diff --git a/TanksServer/Services/TankManager.cs b/TanksServer/Services/TankManager.cs index 3022403..9b08ca2 100644 --- a/TanksServer/Services/TankManager.cs +++ b/TanksServer/Services/TankManager.cs @@ -1,20 +1,70 @@ using System.Collections; using System.Collections.Concurrent; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using TanksServer.Models; namespace TanksServer.Services; -internal sealed class TankManager(ILogger logger) : IEnumerable +internal sealed class TankManager(ILogger logger, IOptions options) + : ITickStep, IEnumerable { private readonly ConcurrentBag _tanks = new(); + private readonly TanksConfiguration _config = options.Value; public void Add(Tank tank) { - logger.LogInformation("Tank added"); + logger.LogInformation("Tank added for player {}", tank.Owner.Id); _tanks.Add(tank); } + public Task TickAsync() + { + foreach (var tank in _tanks) + { + TryMoveTank(tank); + } + + return Task.CompletedTask; + } + + private bool TryMoveTank(Tank tank) + { + logger.LogTrace("moving tank for player {}", tank.Owner.Id); + var player = tank.Owner; + + // move turret + if (player.Controls.TurnLeft) Rotate(tank, -_config.TurnSpeed); + if (player.Controls.TurnRight) Rotate(tank, +_config.TurnSpeed); + + if (player.Controls is { Forward: false, Backward: false }) + return false; + + var direction = player.Controls.Forward ? 1 : -1; + var angle = tank.Rotation / 16d * 2d * Math.PI; + var newX = tank.Position.X + Math.Sin(angle) * direction * _config.MoveSpeed; + var newY = tank.Position.Y - Math.Cos(angle) * direction * _config.MoveSpeed; + + return TryMove(tank, newX, newY) + || TryMove(tank, newX, tank.Position.Y) + || TryMove(tank, tank.Position.X, newY); + } + + private static bool TryMove(Tank tank, double newX, double newY) + { + // TODO implement + + tank.Position = new FloatPosition(newX, newY); + return true; + } + + private void Rotate(Tank t, double speed) + { + var newRotation = (t.Rotation + speed + 16) % 16; + logger.LogTrace("rotating tank for {} from {} to {}", t.Owner.Id, t.Rotation, newRotation); + t.Rotation = newRotation; + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public IEnumerator GetEnumerator() => _tanks.GetEnumerator(); } diff --git a/TanksServer/appsettings.Development.json b/TanksServer/appsettings.Development.json deleted file mode 100644 index bf10e05..0000000 --- a/TanksServer/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft.AspNetCore": "Information" - } - } -} diff --git a/TanksServer/appsettings.json b/TanksServer/appsettings.json index 525e690..f95f3c0 100644 --- a/TanksServer/appsettings.json +++ b/TanksServer/appsettings.json @@ -2,7 +2,8 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "TanksServer": "Trace" } }, "AllowedHosts": "*", diff --git a/tank-frontend/src/ClientScreen.tsx b/tank-frontend/src/ClientScreen.tsx index f97b2a2..2e5d36e 100644 --- a/tank-frontend/src/ClientScreen.tsx +++ b/tank-frontend/src/ClientScreen.tsx @@ -20,8 +20,6 @@ function drawPixelsToCanvas(pixels: Uint8Array, canvas: HTMLCanvasElement) { const imageData = drawContext.getImageData(0, 0, canvas.width, canvas.height, {colorSpace: 'srgb'}); const data = imageData.data; - console.log('draw', {width: canvas.width, height: canvas.height, dataLength: data.byteLength}); - for (let y = 0; y < canvas.height; y++) { const rowStartPixelIndex = y * pixelsPerRow; for (let x = 0; x < canvas.width; x++) { diff --git a/tank-frontend/src/Controls.tsx b/tank-frontend/src/Controls.tsx index 4eb3c91..5e72fc4 100644 --- a/tank-frontend/src/Controls.tsx +++ b/tank-frontend/src/Controls.tsx @@ -42,7 +42,6 @@ export default function Controls({playerId}: { return; const message = new Uint8Array([typeCode, value]); - console.log('input', message); sendMessage(message); }; diff --git a/tank-frontend/src/controls.js b/tank-frontend/src/controls.js deleted file mode 100644 index 18df178..0000000 --- a/tank-frontend/src/controls.js +++ /dev/null @@ -1,75 +0,0 @@ -import './controls.css'; - -const body = document.querySelector('body'); -const splash = document.querySelector('.splash'); - -if (!splash || !body) - throw new Error('required element not found'); - -splash.addEventListener('transitionend', function () { - body.classList.remove('was-killed'); -}); - -const connection = new WebSocket(`ws://${window.location.hostname}:3000`); -connection.binaryType = 'blob'; - -connection.onmessage = function (message) { - message = JSON.parse(message.data); - console.log('got message', {message}); - if (message.type === 'shot') - body.classList.add('was-killed'); -}; - -connection.onerror = event => { - console.log('error', event); - alert('connection error'); -}; - -connection.onclose = event => { - console.log('closed', event); - alert('connection closed - maybe a player with this name is already connected'); -}; - -const keyEventListener = (type) => (event) => { - if (event.defaultPrevented) - return; - - const controls = { - 'ArrowLeft': 'left', - 'ArrowUp': 'up', - 'ArrowRight': 'right', - 'ArrowDown': 'down', - 'Space': 'shoot', - 'KeyW': 'up', - 'KeyA': 'left', - 'KeyS': 'down', - 'KeyD': 'right', - }; - - const value = controls[event.code]; - if (!value) - return; - - send({type, value}); -}; - -connection.onopen = () => { - let name = getPlayerName(); - send({type: 'name', value: name}); - - window.onkeyup = keyEventListener('input-off'); - window.onkeydown = keyEventListener('input-on'); - - console.log('connection opened, game ready'); -}; - -function getPlayerName() { - let name; - while (!name) - name = prompt('Player Name'); - return name; -} - -function send(obj) { - connection.send(JSON.stringify(obj)); -} diff --git a/tank-frontend/vite.config.ts b/tank-frontend/vite.config.ts index 5a33944..6e81bac 100644 --- a/tank-frontend/vite.config.ts +++ b/tank-frontend/vite.config.ts @@ -1,7 +1,12 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], -}) + plugins: [react()], + + build: { + outDir: '../TanksServer/client', + emptyOutDir: true + } +});