Skip to content

Commit c4758b9

Browse files
authored
feat(game): map's server_attr + mob ai improvements (#339)
* feat(game): map's server_attr + mob ai improvements * refactor(game): use Coordinates record for attr checks
1 parent 8a5da17 commit c4758b9

15 files changed

Lines changed: 643 additions & 55 deletions

File tree

src/CorePluginAPI/Coordinates.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
namespace QuantumCore.API;
1+
using System.Numerics;
2+
3+
namespace QuantumCore.API;
24

35
public record struct Coordinates(uint X, uint Y)
46
{
57
public override string ToString() => $"({X}, {Y})";
68

79
public static Coordinates operator *(Coordinates a, uint multiplier) => new(a.X * multiplier, a.Y * multiplier);
810
public static Coordinates operator +(Coordinates a, Coordinates b) => new(a.X + b.X, a.Y + b.Y);
11+
12+
public static Coordinates operator +(Coordinates a, Vector2 delta)
13+
{
14+
return new Coordinates(checked((uint)(a.X + delta.X)), checked((uint)(a.Y + delta.Y)));
15+
}
16+
17+
// throw on underflow with checked()
18+
public static Coordinates operator -(Coordinates a, Coordinates b) => new(checked(a.X - b.X), checked(a.Y - b.Y));
919
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace QuantumCore.API.Core.Models;
2+
3+
[Flags]
4+
public enum EMapAttribute : uint
5+
{
6+
None = 0,
7+
Block = 1 << 0, // collision detection
8+
Water = 1 << 1,
9+
NonPvp = 1 << 2, // AKA "BAN_PK"
10+
Object = 1 << 7, // building
11+
}

src/Game.Benchmarks/Benchmarks/WorldUpdateBenchmark.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public void GlobalSetup()
5858
provider.GetRequiredService<ICacheManager>(), callInfo.Arg<IWorld>(),
5959
provider.GetRequiredService<ILogger<Map>>(),
6060
provider.GetRequiredService<ISpawnPointProvider>(),
61+
provider.GetRequiredService<IMapAttributeProvider>(),
6162
provider.GetRequiredService<IDropProvider>(),
6263
provider.GetRequiredService<IItemManager>(),
6364
provider.GetRequiredService<IServerBase>(),
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.Numerics;
2+
using QuantumCore.API;
3+
using QuantumCore.API.Core.Models;
4+
using QuantumCore.API.Game.World;
5+
using QuantumCore.Game.World;
6+
7+
namespace QuantumCore.Game.Extensions;
8+
9+
internal static class MapAttributeExtensions
10+
{
11+
public static bool PositionIsAttr(this IEntity entity, EMapAttribute flags)
12+
{
13+
return entity.Map is Map localMap && localMap.IsAttr(entity.Coordinates(), flags);
14+
}
15+
16+
// TODO: optimize / improve this function for better AI following around obstacles
17+
public static bool IsAttrOnStraightPathTo(this IEntity entity, Coordinates endCoords, EMapAttribute flags)
18+
{
19+
if (entity.Map is not Map localMap)
20+
return false;
21+
22+
// cast to int since delta might be negative
23+
var dx = (int)endCoords.X - (int)entity.Coordinates().X;
24+
var dy = (int)endCoords.Y - (int)entity.Coordinates().Y;
25+
if (dx == 0 && dy == 0)
26+
{
27+
return entity.PositionIsAttr(flags);
28+
}
29+
30+
const int Samples = 100;
31+
for (var i = 1; i <= Samples; i++)
32+
{
33+
var t = (float)i / Samples;
34+
var sampleDelta = new Vector2(dx * t, dy * t);
35+
36+
if (localMap.IsAttr(entity.Coordinates() + sampleDelta, flags))
37+
{
38+
return true;
39+
}
40+
}
41+
42+
return false;
43+
}
44+
45+
public static Coordinates Coordinates(this IEntity entity)
46+
{
47+
// TODO: entity position should be directly refactored as Coordinate instead?
48+
return new Coordinates((uint)entity.PositionX, (uint)entity.PositionY);
49+
}
50+
}

src/Libraries/Game.Server/Extensions/ServiceExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public static IServiceCollection AddGameServices(this IServiceCollection service
4545
services.AddSingleton<IParserService, ParserService>();
4646
services.AddSingleton<ISpawnGroupProvider, SpawnGroupProvider>();
4747
services.AddSingleton<ISpawnPointProvider, SpawnPointProvider>();
48+
services.AddSingleton<IMapAttributeProvider, MapAttributeProvider>();
4849
services.AddSingleton<IJobManager, JobManager>();
4950
services.AddSingleton<IStructuredFileProvider, StructuredFileProvider>();
5051
services.AddScoped<IAtlasProvider, AtlasProvider>();

src/Libraries/Game.Server/Services/AtlasProvider.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ private record AtlasValue(string MapName, Coordinates Position, uint Width, uint
3131
private readonly IFileProvider _fileProvider;
3232
private readonly IServerBase _server;
3333
private readonly IServiceProvider _serviceProvider;
34+
private readonly IMapAttributeProvider _attributeProvider;
3435

3536
/// <summary>
3637
/// Regex for parsing lines in the atlas info
@@ -42,7 +43,8 @@ private record AtlasValue(string MapName, Coordinates Position, uint Width, uint
4243
public AtlasProvider(IConfiguration configuration, IMonsterManager monsterManager,
4344
IAnimationManager animationManager, ISpawnPointProvider spawnPointProvider,
4445
ICacheManager cacheManager, ILogger<AtlasProvider> logger, IItemManager itemManager,
45-
IFileProvider fileProvider, IServerBase server, IServiceProvider serviceProvider)
46+
IFileProvider fileProvider, IServerBase server, IServiceProvider serviceProvider,
47+
IMapAttributeProvider attributeProvider)
4648
{
4749
_configuration = configuration;
4850
_monsterManager = monsterManager;
@@ -54,6 +56,7 @@ public AtlasProvider(IConfiguration configuration, IMonsterManager monsterManage
5456
_fileProvider = fileProvider;
5557
_server = server;
5658
_serviceProvider = serviceProvider;
59+
_attributeProvider = attributeProvider;
5760
}
5861

5962
public async Task<IEnumerable<IMap>> GetAsync(IWorld world)
@@ -121,8 +124,8 @@ public async Task<IEnumerable<IMap>> GetAsync(IWorld world)
121124
{
122125
townCoordsDict.TryGetValue(mapName, out var coords);
123126

124-
map = new Map(_monsterManager, _animationManager, _cacheManager, world, _logger,
125-
_spawnPointProvider, _serviceProvider.GetRequiredService<IDropProvider>(), _itemManager, _server,
127+
map = new Map(_monsterManager, _animationManager, _cacheManager, world, _logger, _spawnPointProvider,
128+
_attributeProvider, _serviceProvider.GetRequiredService<IDropProvider>(), _itemManager, _server,
126129
mapName, position,
127130
width,
128131
height, coords, _serviceProvider);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using QuantumCore.API;
2+
using QuantumCore.API.Core.Models;
3+
4+
namespace QuantumCore.Game.Services;
5+
6+
public interface IMapAttributeProvider
7+
{
8+
Task<IMapAttributeSet?> GetAttributesAsync(string mapName, Coordinates position, uint mapWidth, uint mapHeight,
9+
CancellationToken cancellationToken = default);
10+
}
11+
12+
public interface IMapAttributeSet
13+
{
14+
EMapAttribute GetAttribute(Coordinates coords);
15+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
using System.Buffers.Binary;
2+
using System.Numerics;
3+
using EnumsNET;
4+
using Microsoft.Extensions.FileProviders;
5+
using Microsoft.Extensions.Logging;
6+
using QuantumCore.API;
7+
using QuantumCore.API.Core.Models;
8+
using QuantumCore.Core.Types;
9+
using QuantumCore.Game.World;
10+
11+
namespace QuantumCore.Game.Services;
12+
13+
internal sealed class MapAttributeProvider : IMapAttributeProvider
14+
{
15+
private readonly IFileProvider _fileProvider;
16+
private readonly ILogger<MapAttributeProvider> _logger;
17+
18+
public MapAttributeProvider(IFileProvider fileProvider, ILogger<MapAttributeProvider> logger)
19+
{
20+
_fileProvider = fileProvider;
21+
_logger = logger;
22+
}
23+
24+
public async Task<IMapAttributeSet?> GetAttributesAsync(string mapName, Coordinates position, uint mapWidth,
25+
uint mapHeight, CancellationToken cancellationToken = default)
26+
{
27+
var attrPath = $"maps/{mapName}/server_attr";
28+
var file = _fileProvider.GetFileInfo(attrPath);
29+
if (!file.Exists)
30+
{
31+
_logger.LogDebug("No server_attr file found for map {Map}", mapName);
32+
return null;
33+
}
34+
35+
_logger.LogDebug("Loading cell attributes from file {ServerAttrPath}", attrPath);
36+
37+
await using var stream = file.CreateReadStream();
38+
39+
// header contains length and width in sectree units (which are squares of 128x128 attr cells)
40+
var headerBuffer = new byte[2 * sizeof(int)];
41+
await stream.ReadExactlyAsync(headerBuffer, cancellationToken);
42+
var sectreesWidth = BinaryPrimitives.ReadInt32LittleEndian(headerBuffer.AsSpan(0 * sizeof(int), sizeof(int)));
43+
var sectreesHeight = BinaryPrimitives.ReadInt32LittleEndian(headerBuffer.AsSpan(1 * sizeof(int), sizeof(int)));
44+
45+
var expectedSectreesWidth = (int)(mapWidth * Map.MapUnit / MapAttributeSet.SectreeSize);
46+
var expectedSectreesHeight = (int)(mapHeight * Map.MapUnit / MapAttributeSet.SectreeSize);
47+
if (sectreesWidth != expectedSectreesWidth || sectreesHeight != expectedSectreesHeight)
48+
{
49+
_logger.LogWarning(
50+
"{ServerAttrPath} dimensions ({SectreeWidth}x{SectreeHeight}) do not match atlasinfo.txt dimensions ({ExpectedWidth}x{ExpectedHeight})",
51+
attrPath, sectreesWidth, sectreesHeight, expectedSectreesWidth, expectedSectreesHeight);
52+
}
53+
54+
var sectrees = new MapAttributeSectree?[sectreesHeight, sectreesWidth];
55+
var lzoDecompressor = new Lzo(MapAttributeSet.CellsPerSectree * sizeof(uint));
56+
var intBuffer = new byte[4];
57+
58+
// info for debug logging only
59+
long cellsWithKnownFlags = 0;
60+
var unknownFlagCounts = new Dictionary<int, long>();
61+
var flagCountsDebugInfo = Enum.GetValues<EMapAttribute>()
62+
.Where(x => x != EMapAttribute.None)
63+
.ToDictionary(attr => attr, _ => 0L);
64+
65+
for (var y = 0; y < sectreesHeight; y++)
66+
{
67+
for (var x = 0; x < sectreesWidth; x++)
68+
{
69+
await stream.ReadExactlyAsync(intBuffer, cancellationToken);
70+
var blockSize = BinaryPrimitives.ReadInt32LittleEndian(intBuffer);
71+
72+
var compressedSectree = new byte[blockSize];
73+
await stream.ReadExactlyAsync(compressedSectree, cancellationToken);
74+
75+
var decompressedSectree = lzoDecompressor.DecodeRaw(compressedSectree);
76+
if (decompressedSectree.Length != MapAttributeSet.CellsPerSectree * sizeof(uint))
77+
{
78+
_logger.LogWarning("{ServerAttrPath} failed to decode sectree ({X}, {Y}): unexpected size {DecompressedSectorLength}", attrPath, x, y, decompressedSectree.Length);
79+
sectrees[y, x] = MapAttributeSectree.Empty;
80+
continue;
81+
}
82+
83+
var allCellsAttrs = new EMapAttribute[MapAttributeSet.CellsPerSectree];
84+
for (var i = 0; i < allCellsAttrs.Length; i++)
85+
{
86+
var rawCellFlags = BinaryPrimitives.ReadUInt32LittleEndian(
87+
decompressedSectree.AsSpan(i * sizeof(uint), sizeof(uint))
88+
);
89+
// all set bits of `rawCellFlags` are preserved when cast to enum - individual flags can be extracted with HasFlag()
90+
var cellAttrFlags = (EMapAttribute)rawCellFlags;
91+
allCellsAttrs[i] = cellAttrFlags;
92+
93+
// saving debug info if server_attr is corrupted or wrong format
94+
if (_logger.IsEnabled(LogLevel.Debug)) {
95+
var cellHasValidFlag = false;
96+
foreach (var flag in flagCountsDebugInfo.Keys.ToList())
97+
{
98+
if (cellAttrFlags.HasFlag(flag))
99+
{
100+
flagCountsDebugInfo[flag]++;
101+
cellHasValidFlag = true;
102+
}
103+
}
104+
105+
if (cellHasValidFlag) cellsWithKnownFlags++;
106+
107+
var unknownFlags = cellAttrFlags &
108+
~flagCountsDebugInfo.Keys.Aggregate(EMapAttribute.None,
109+
(acc, attr) => acc | attr);
110+
if (unknownFlags != EMapAttribute.None)
111+
{
112+
var remaining = (uint)unknownFlags;
113+
while (remaining != 0)
114+
{
115+
var lsb = remaining & ~(remaining - 1);
116+
var bitPosition = BitOperations.TrailingZeroCount(lsb);
117+
unknownFlagCounts.TryGetValue(bitPosition, out var count);
118+
unknownFlagCounts[bitPosition] = count + 1;
119+
remaining &= remaining - 1;
120+
}
121+
}
122+
}
123+
}
124+
125+
sectrees[y, x] = new MapAttributeSectree(allCellsAttrs);
126+
}
127+
}
128+
129+
if (_logger.IsEnabled(LogLevel.Debug))
130+
{
131+
var totalCells = (long)sectreesHeight * sectreesWidth * MapAttributeSet.CellsPerSectree;
132+
var percentage = 100.0 * cellsWithKnownFlags / totalCells;
133+
_logger.LogDebug( "Loaded {ServerAttrPath} {CellSize}x{CellSize2} cell attributes: {Summary} (cells with known flags: {NonZero}/{Total} = {Percentage:F2}%)",
134+
attrPath, MapAttributeSet.CellSize, MapAttributeSet.CellSize,
135+
string.Join(" ", flagCountsDebugInfo.Select(kv => $"{kv.Key}={kv.Value}")),
136+
cellsWithKnownFlags, totalCells, percentage);
137+
138+
if (unknownFlagCounts.Count > 0)
139+
{
140+
_logger.LogWarning("{ServerAttrPath} contains cells with unknown flag bits (LSB being bit 0): {UnknownSummary}",
141+
attrPath, string.Join(", ", unknownFlagCounts.Select(kv => $"bit {kv.Key} found in {kv.Value} cells")));
142+
}
143+
}
144+
145+
return new MapAttributeSet(sectreesWidth, sectreesHeight, position, sectrees);
146+
}
147+
148+
private sealed class MapAttributeSet : IMapAttributeSet
149+
{
150+
internal const int SectreeSize = 6400; // 64m x 64m
151+
internal const int CellSize = 50; // 50cm x 50cm - basically a quarter of the size of a full world cell (2m x 2m)
152+
internal const int CellsPerAxis = SectreeSize / CellSize;
153+
internal const int CellsPerSectree = CellsPerAxis * CellsPerAxis;
154+
155+
private readonly MapAttributeSectree?[,] _sectreesAttrs;
156+
private readonly int _sectreesWidth;
157+
private readonly int _sectreesHeight;
158+
private readonly Coordinates _baseCoords;
159+
160+
public MapAttributeSet(int sectreesWidth, int sectreesHeight, Coordinates basePosition,
161+
MapAttributeSectree?[,] sectreesAttrs)
162+
{
163+
_sectreesWidth = sectreesWidth;
164+
_sectreesHeight = sectreesHeight;
165+
_baseCoords = basePosition;
166+
_sectreesAttrs = sectreesAttrs;
167+
}
168+
169+
public EMapAttribute GetAttribute(Coordinates coords)
170+
{
171+
if (!TryLocate(coords, out var locatedSectreeAttrs, out var cellX, out var cellY) || locatedSectreeAttrs is null)
172+
{
173+
return EMapAttribute.None;
174+
}
175+
176+
return locatedSectreeAttrs.Get(cellX, cellY);
177+
}
178+
179+
private bool TryLocate(Coordinates coords, out MapAttributeSectree? sectreeAttrs, out int cellX, out int cellY)
180+
{
181+
sectreeAttrs = null;
182+
cellX = 0;
183+
cellY = 0;
184+
185+
var relativeCoords = coords - _baseCoords;
186+
187+
// find which sectree covers the x y relative map coords
188+
var sectreeIndexX = relativeCoords.X / SectreeSize;
189+
var sectreeIndexY = relativeCoords.Y / SectreeSize;
190+
if (sectreeIndexX >= _sectreesWidth || sectreeIndexY >= _sectreesHeight)
191+
{
192+
return false;
193+
}
194+
sectreeAttrs = _sectreesAttrs[sectreeIndexY, sectreeIndexX];
195+
196+
// find which cell of the sectree covers the x y relative map coords
197+
cellX = (int)((relativeCoords.X % SectreeSize) / CellSize);
198+
cellY = (int)((relativeCoords.Y % SectreeSize) / CellSize);
199+
200+
return cellX < CellsPerAxis && cellY < CellsPerAxis;
201+
}
202+
}
203+
204+
private sealed class MapAttributeSectree(EMapAttribute[] values)
205+
{
206+
public static MapAttributeSectree Empty { get; } = new(new EMapAttribute[MapAttributeSet.CellsPerSectree]);
207+
208+
public EMapAttribute Get(int x, int y)
209+
{
210+
return values[y * MapAttributeSet.CellsPerAxis + x];
211+
}
212+
}
213+
}

src/Libraries/Game.Server/Types/Lzo.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ public byte[] Decode(byte[] data)
2727
return buffer;
2828
}
2929

30+
public byte[] DecodeRaw(byte[] data)
31+
{
32+
using var src = new MemoryStream(data);
33+
var buffer = new byte[_size];
34+
Decompress(src, buffer);
35+
return buffer;
36+
}
37+
3038
private int ConsumeZeroByteLength(MemoryStream src)
3139
{
3240
var pos = src.Position;
@@ -254,4 +262,4 @@ private void Decompress(MemoryStream src, byte[] dest)
254262
}
255263
}
256264
}
257-
}
265+
}

0 commit comments

Comments
 (0)