Add networked Fighter3D minigame

This commit is contained in:
ud18010
2026-08-10 16:47:16 +08:00
parent bff617091d
commit ef7f24738e
53 changed files with 5458 additions and 19 deletions
@@ -0,0 +1,109 @@
using Fighter3D.Core;
using XWorld.Framework;
namespace XWorld.Server.Host.Tests
{
public class Fighter3DLogicTests
{
private static FighterState NewState()
{
return new FighterLogic().CreateInitial(
new RoomConfig { GameId = "fighter3d", Version = 1, Seed = 1, PlayerCount = 2 },
new DeterministicRandom(1));
}
[Fact]
public void InitialState_StartsTwoFightersMirrored()
{
var state = NewState();
Assert.Equal(FighterPhase.Running, state.Phase);
Assert.Equal(1000, state.Fighters[0].Hp);
Assert.Equal(1000, state.Fighters[1].Hp);
Assert.True(state.Fighters[0].X < state.Fighters[1].X);
Assert.Equal(1, state.Fighters[0].Facing);
Assert.Equal(-1, state.Fighters[1].Facing);
}
[Fact]
public void Movement_ClampsToStageBounds()
{
var logic = new FighterLogic();
var state = NewState();
state = logic.Step(state, FighterInput.Controls(0, -1, FighterButtons.None), null).State;
for (int i = 0; i < 600; i++)
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
Assert.Equal(-FighterLogic.StageHalfWidth, state.Fighters[0].X, 3);
}
[Fact]
public void LightAttack_HitsOpponentAndAppliesStun()
{
var logic = new FighterLogic();
var state = NewState();
state.Fighters[0].X = -0.4f;
state.Fighters[1].X = 0.4f;
state = logic.Step(state, FighterInput.Controls(0, 0, FighterButtons.Light), null).State;
for (int i = 0; i < 10; i++)
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
Assert.True(state.Fighters[1].Hp < 1000);
Assert.Equal(FighterAction.HitStun, state.Fighters[1].Action);
}
[Fact]
public void HoldingAway_BlocksIncomingAttack()
{
var logic = new FighterLogic();
var state = NewState();
state.Fighters[0].X = -0.4f;
state.Fighters[1].X = 0.4f;
state = logic.Step(state, FighterInput.Controls(1, 1, FighterButtons.None), null).State;
state = logic.Step(state, FighterInput.Controls(0, 0, FighterButtons.Light), null).State;
for (int i = 0; i < 10; i++)
state = logic.Step(state, FighterInput.Tick(1f / FighterLogic.TickRate), null).State;
Assert.Equal(1000, state.Fighters[1].Hp);
Assert.Equal(FighterAction.BlockStun, state.Fighters[1].Action);
}
[Fact]
public void Timeout_WinnerIsHigherHealthSeat()
{
var logic = new FighterLogic();
var state = NewState();
state.Fighters[1].Hp = 900;
state = logic.Step(state, FighterInput.Tick(FighterLogic.RoundSeconds), null).State;
Assert.Equal(FighterPhase.Finished, state.Phase);
Assert.Equal(0, state.WinnerSeat);
}
[Fact]
public void EncodeDecode_RoundTripsSnapshot()
{
var logic = new FighterLogic();
var state = NewState();
state.Tick = 123;
state.ElapsedSeconds = 2.5f;
state.Fighters[0].Hp = 777;
state.Fighters[1].Action = FighterAction.BlockStun;
state.Fighters[1].ActionFrame = 6;
state.WinnerSeat = 1;
var back = logic.Decode(logic.Encode(state));
Assert.Equal(123, back.Tick);
Assert.Equal(2.5f, back.ElapsedSeconds, 3);
Assert.Equal(777, back.Fighters[0].Hp);
Assert.Equal(FighterAction.BlockStun, back.Fighters[1].Action);
Assert.Equal(6, back.Fighters[1].ActionFrame);
Assert.Equal(1, back.WinnerSeat);
}
}
}
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Text;
using Fighter3D.Core;
using Fighter3D.Server;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Host.Tests
{
public class Fighter3DServerRoomTests
{
private sealed class FakeOutput : IRoomOutput
{
public readonly List<NetMessage> Broadcasts = new List<NetMessage>();
public void Broadcast(NetMessage message) => Broadcasts.Add(message);
public void SendTo(int playerId, NetMessage message) { }
}
private static readonly IReadOnlyList<PlayerInfo> TwoHumans = new[]
{
new PlayerInfo { PlayerId = 10, Name = "A", IsAI = false },
new PlayerInfo { PlayerId = 20, Name = "B", IsAI = false },
};
private static (RoomCtx Ctx, FakeOutput Output, bool[] Ended, RoomEndResult[] Result) BuildCtx()
{
var output = new FakeOutput();
var ended = new bool[1];
var result = new RoomEndResult[1];
var ctx = new RoomCtx(
new DeterministicRandom(42),
new ManualClock(),
new ConsoleLogger(),
new InMemoryStorage(),
output,
r => { ended[0] = true; result[0] = r; });
return (ctx, output, ended, result);
}
private static NetMessage ControlsMsg(int axis, FighterButtons buttons)
{
var writer = new PacketWriter();
writer.WriteVarInt(axis);
writer.WriteByte((byte)buttons);
return new NetMessage(FighterServerRoom.InputOpcode, writer.ToArray());
}
private static FighterState LastSnapshot(FakeOutput output)
{
var last = output.Broadcasts[output.Broadcasts.Count - 1];
Assert.Equal(FighterServerRoom.SnapshotOpcode, last.Opcode);
return new FighterLogic().Decode(last.Payload);
}
[Fact]
public void OnRoomStart_BroadcastsInitialSnapshot()
{
var (ctx, output, _, _) = BuildCtx();
var room = new FighterServerRoom();
room.OnRoomStart(TwoHumans, ctx);
Assert.NotEmpty(output.Broadcasts);
var snapshot = LastSnapshot(output);
Assert.Equal(FighterPhase.Running, snapshot.Phase);
}
[Fact]
public void RepeatedHeavyAttacks_CanKoAndEndRoomWithWinnerPid()
{
var (ctx, output, ended, result) = BuildCtx();
var room = new FighterServerRoom();
room.OnRoomStart(TwoHumans, ctx);
room.OnMessage(10, ControlsMsg(0, FighterButtons.Heavy));
for (int i = 0; i < 800 && !ended[0]; i++)
room.OnTick(1f / 20f);
Assert.True(ended[0]);
Assert.NotNull(result[0]);
Assert.Equal(10, result[0].WinnerPlayerId);
Assert.Contains("Fighter3D", Encoding.UTF8.GetString(result[0].ResultBlob));
var snapshot = LastSnapshot(output);
Assert.Equal(FighterPhase.Finished, snapshot.Phase);
Assert.Equal(0, snapshot.WinnerSeat);
}
}
}
@@ -23,6 +23,8 @@
<ProjectReference Include="..\PublishTool\PublishTool.csproj" />
<ProjectReference Include="..\games-src\RPS\RPS.Core\RPS.Core.csproj" />
<ProjectReference Include="..\games-src\RPS\RPS.Server\RPS.Server.csproj" />
<ProjectReference Include="..\games-src\Fighter3D\Fighter3D.Core\Fighter3D.Core.csproj" />
<ProjectReference Include="..\games-src\Fighter3D\Fighter3D.Server\Fighter3D.Server.csproj" />
</ItemGroup>
@@ -33,4 +35,4 @@
</Content>
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>Fighter3D.Core</AssemblyName>
<RootNamespace>Fighter3D.Core</RootNamespace>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\..\Client\Assets\MiniGames\Fighter3D\scripts~\Core\**\*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
<AssemblyName>Fighter3D.Server</AssemblyName>
<RootNamespace>Fighter3D.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Framework.Shared\Framework.Shared.csproj">
<Private>false</Private>
</ProjectReference>
<ProjectReference Include="..\Fighter3D.Core\Fighter3D.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Text;
using Fighter3D.Core;
using XWorld.Framework;
using XWorld.Framework.Protocol;
namespace Fighter3D.Server
{
public sealed class FighterServerRoom : IGameServerRoom
{
public const ushort InputOpcode = 1;
public const ushort SnapshotOpcode = 100;
private readonly FighterLogic _logic = new FighterLogic();
private readonly Dictionary<int, int> _seatOfPlayer = new Dictionary<int, int>();
private readonly int[] _playerOfSeat = new int[2];
private IRoomCtx _ctx;
private FighterState _state;
private bool _ended;
public void OnRoomStart(IReadOnlyList<PlayerInfo> players, IRoomCtx ctx)
{
_ctx = ctx;
_state = _logic.CreateInitial(
new RoomConfig { GameId = "fighter3d", Version = 1, Seed = 1, PlayerCount = players.Count },
ctx.Random);
for (int i = 0; i < players.Count && i < 2; i++)
{
_seatOfPlayer[players[i].PlayerId] = i;
_playerOfSeat[i] = players[i].PlayerId;
}
Broadcast();
}
public void OnMessage(int playerId, NetMessage message)
{
if (_ended || message.Opcode != InputOpcode)
return;
if (!_seatOfPlayer.TryGetValue(playerId, out int seat))
return;
try
{
var reader = new PacketReader(message.Payload);
int axis = reader.ReadVarInt();
var buttons = (FighterButtons)reader.ReadByte();
_state = _logic.Step(_state, FighterInput.Controls(seat, axis, buttons), _ctx.Random).State;
}
catch (System.Exception ex)
{
_ctx.Logger.Error($"fighter3d input decode failed: {ex.Message}");
}
}
public void OnTick(float deltaTime)
{
if (_ended)
return;
var result = _logic.Step(_state, FighterInput.Tick(deltaTime), _ctx.Random);
_state = result.State;
Broadcast();
if (_state.Phase == FighterPhase.Finished)
EndRoomOnce();
}
public void OnRoomEnd()
{
_ctx?.Logger.Info("fighter3d room end");
}
private void Broadcast()
{
_ctx.Broadcast(new NetMessage(SnapshotOpcode, _logic.Encode(_state)));
}
private void EndRoomOnce()
{
if (_ended)
return;
_ended = true;
int winnerPid = _state.WinnerSeat >= 0 && _state.WinnerSeat < 2
? _playerOfSeat[_state.WinnerSeat]
: 0;
string text = _state.WinnerSeat == 2
? "Fighter3D draw"
: $"Fighter3D winner seat {_state.WinnerSeat}";
_ctx.EndRoom(new RoomEndResult
{
WinnerPlayerId = winnerPid,
ResultBlob = Encoding.UTF8.GetBytes(text)
});
}
}
}