Update FieldItemEditor for 3.0.0 (#716)

Updates the Field Item Editor to render layers based on the entire map, and the per-patch positioning of each layer.
Import/export will gracefully handle upgrade/downgrade, and viewport import/export will gracefully update tiles rather than a per-acre basis.

Performance has also been slightly improved; no allocation is done anymore when updating the image.
This commit is contained in:
Kurt
2026-01-25 16:55:38 -06:00
committed by GitHub
parent 308e613633
commit b88c518d5c
138 changed files with 4910 additions and 3380 deletions

View File

@@ -16,6 +16,6 @@ public static int GetAcreTileColor(ushort acre, int x, int y)
var shift = (4 * ((y * 64) + x));
var ofs = baseOfs + shift;
var tile = AcreTiles[ofs];
return CollisionUtil.Dict[tile].ToArgb();
return TileCollisionUtil.Dict[tile].ToArgb();
}
}

View File

@@ -7,7 +7,7 @@
namespace NHSE.Core;
public class ItemMutator : BatchMutator<Item>
public sealed class ItemMutator : BatchMutator<Item>
{
public readonly ItemReflection Reflect = ItemReflection.Default;
private const char CONST_POINTER = '*';

View File

@@ -4,7 +4,7 @@
namespace NHSE.Core;
public class ItemProcessor(BatchMutator<Item> mut) : BatchProcessor<Item>(mut)
public sealed class ItemProcessor(BatchMutator<Item> mut) : BatchProcessor<Item>(mut)
{
protected override bool CanModify(Item item) => true;
protected override bool Finalize(Item item) => true;

View File

@@ -5,7 +5,7 @@
namespace NHSE.Core;
public class ItemReflection
public sealed class ItemReflection
{
public static ItemReflection Default { get; } = new();

View File

@@ -1,23 +1,13 @@
namespace NHSE.Core;
public sealed class FieldItemColumn
{
/// <summary> X Coordinate within the Field Item Layer </summary>
public readonly int X;
/// <summary> Y Coordinate within the Field Item Layer </summary>
public readonly int Y;
/// <summary> Offset relative to the start of the Field Item Layer </summary>
public readonly int Offset;
public readonly byte[] Data;
public FieldItemColumn(int x, int y, int offset, byte[] data)
{
X = x;
Y = y;
Offset = offset;
Data = data;
}
}
/// <summary>
/// Represents a list of item data tiles to be injected to a Field Item Layer.
/// </summary>
/// <remarks>
/// Extension tiles underneath the actual item root are included; extension tiles to the right are not.
/// </remarks>
/// <param name="RelativeX">X Coordinate within the Field Item Layer</param>
/// <param name="RelativeY">Y Coordinate within the Field Item Layer</param>
/// <param name="Offset">Offset relative to the start of the Field Item Layer</param>
/// <param name="Data">Data for this column</param>
public sealed record FieldItemColumn(int RelativeX, int RelativeY, int Offset, byte[] Data);

View File

@@ -7,10 +7,12 @@ namespace NHSE.Core;
/// <summary>
/// Converts <see cref="Item"/> into columns of writable Item tiles.
/// </summary>
public static class FieldItemDropper
/// <param name="AcreWidth">Field item acre width. 7 on pre-3.0.0 saves, 9 on 3.0.0+</param>
/// <param name="AcreHeight">Always 6.</param>
public sealed record FieldItemDropper(int AcreWidth, int AcreHeight = 6)
{
private const int MapHeight = FieldItemLayer.FieldItemHeight;
private const int MapWidth = FieldItemLayer.FieldItemWidth;
private int MapHeight => AcreWidth * LayerFieldItem.TilesPerAcreDim;
private int MapWidth => AcreHeight * LayerFieldItem.TilesPerAcreDim;
// Each dropped item is a 2x2 square, with the top left tile being the root node, and the other 3 being extensions pointing back to the root.
@@ -21,7 +23,7 @@ public static class FieldItemDropper
/// <param name="yCount">Count of items tall the overall spawn-rectangle is.</param>
/// <param name="borderX">Excluded outer tile count. Useful for enforcing that beach acre tiles are skipped.</param>
/// <param name="borderY">Excluded outer tile count. Useful for enforcing that beach acre tiles are skipped.</param>
public static bool CanFitDropped(int x, int y, int totalCount, int yCount, int borderX, int borderY)
public bool CanFitDropped(int x, int y, int totalCount, int yCount, int borderX, int borderY)
{
return CanFitDropped(x, y, totalCount, yCount, borderX, borderX, borderY, borderY);
}
@@ -39,7 +41,7 @@ public static bool CanFitDropped(int x, int y, int totalCount, int yCount, int b
/// <param name="topY">Excluded outer tile count. Useful for enforcing that beach acre tiles are skipped.</param>
/// <param name="botY">Excluded outer tile count. Useful for enforcing that beach acre tiles are skipped.</param>
/// <returns>True if can fit, false if not.</returns>
public static bool CanFitDropped(int x, int y, int totalCount, int yCount, int leftX, int rightX, int topY, int botY)
public bool CanFitDropped(int x, int y, int totalCount, int yCount, int leftX, int rightX, int topY, int botY)
{
var xCount = totalCount / yCount;
if (x < leftX || (x + (xCount * 2)) > MapWidth - rightX)
@@ -50,13 +52,13 @@ public static bool CanFitDropped(int x, int y, int totalCount, int yCount, int l
return totalCount < (MapHeight * MapWidth / 32);
}
public static IReadOnlyList<FieldItemColumn> InjectItemsAsDropped(int mapX, int mapY, IReadOnlyList<Item> item)
public IReadOnlyList<FieldItemColumn> InjectItemsAsDropped(int mapX, int mapY, IReadOnlyList<Item> item)
{
int yStride = (item.Count > 16) ? 16 : item.Count;
return InjectItemsAsDropped(mapX, mapY, item, yStride);
}
public static IReadOnlyList<FieldItemColumn> InjectItemsAsDropped(int mapX, int mapY, IReadOnlyList<Item> item, int yStride)
public IReadOnlyList<FieldItemColumn> InjectItemsAsDropped(int mapX, int mapY, IReadOnlyList<Item> item, int yStride)
{
var xStride = item.Count / yStride;
List<FieldItemColumn> result = new(yStride * xStride);
@@ -108,10 +110,7 @@ private static byte[] GetColumnExtension(ReadOnlySpan<Item> items)
return Item.SetArray(col);
}
private static int GetTileOffset(int x, int y)
{
return Item.SIZE * (y + (x * MapHeight));
}
private int GetTileOffset(int relX, int relY) => Item.SIZE * (relY + (relX * MapHeight));
private static Item GetDroppedItem(Item item)
{

View File

@@ -0,0 +1,141 @@
using System;
namespace NHSE.Core;
/// <summary>
/// Provides functionality to upgrade or downgrade field item flag data between different formats.
/// </summary>
public static class FieldItemFlagUpgrade
{
private const int ColumnCountOld = 7;
private const int ColumnCountNew = 9; // +1 column on each side
private const int RowCount = 6;
private const byte SizeDim = LayerFieldItem.TilesPerAcreDim;
// Tile dimensions across the entire map
private const int TileRowCount = RowCount * SizeDim; // 192 tile rows
private const int TilesPerRowOld = ColumnCountOld * SizeDim; // 224 tiles per row
private const int TilesPerRowNew = ColumnCountNew * SizeDim; // 288 tiles per row
// Bytes per tile-row (1 bit per tile, 8 tiles per byte)
private const int BytesPerRowOld = TilesPerRowOld / 8; // 28 bytes
private const int BytesPerRowNew = TilesPerRowNew / 8; // 36 bytes
// Bytes for one acre column per row (32 tiles / 8 bits per byte = 4 bytes)
private const int BytesPerAcreColumnPerRow = SizeDim / 8; // 4 bytes
// Total sizes
private const int FlagSizeOld = TileRowCount * BytesPerRowOld; // 5,376 bytes
private const int FlagSizeNew = TileRowCount * BytesPerRowNew; // 6,912 bytes
// Active flags are stored in row-major order.
// Since the upgrade adds a column on each side, it's not possible to simply prepend and append default columns.
// For each row of data, we need to add default flags at the start and end of the row.
// Repeat for all rows.
/// <summary>
/// Checks if an update is needed based on the current size and expected size.
/// </summary>
/// <param name="current">Current size of the data.</param>
/// <param name="expect">Desired size of the data.</param>
/// <returns><see langword="true"/> if an update is needed; otherwise <see langword="false"/>.</returns>
public static bool IsUpdateNeeded(long current, int expect) => expect switch
{
FlagSizeOld => current == FlagSizeNew,
FlagSizeNew => current == FlagSizeOld,
_ => false,
};
/// <summary>
/// Detects and performs an update on the field item flag data if needed.
/// </summary>
/// <param name="data">Data to update.</param>
/// <param name="expect">Desired size of the data.</param>
/// <returns><see langword="true"/> if an update was performed; otherwise <see langword="false"/>.</returns>
/// <remarks>Pre-check <see cref="IsUpdateNeeded(long, int)"/> to ensure a conversion is available.</remarks>
public static bool DetectUpdate(ref byte[] data, int expect)
{
if (expect == FlagSizeNew && data.Length == FlagSizeOld)
data = Inflate(data);
else if (expect == FlagSizeOld && data.Length == FlagSizeNew)
data = Deflate(data);
else // No change needed/supported.
return false;
return true;
}
/// <inheritdoc cref="Inflate(ReadOnlySpan{byte}, Span{byte})"/>
public static byte[] Inflate(ReadOnlySpan<byte> data)
{
if (data.Length != FlagSizeOld)
throw new ArgumentException($"Data length {data.Length} does not match expected old size {FlagSizeOld}.");
var result = new byte[FlagSizeNew];
Inflate(data, result);
return result;
}
/// <summary>
/// Inflates old field item flag data to the new format.
/// </summary>
/// <param name="data">Old field item flag data.</param>
/// <param name="result">Span to write new field item flag data to.</param>
/// <exception cref="ArgumentException"></exception>
public static void Inflate(ReadOnlySpan<byte> data, Span<byte> result)
{
if (data.Length != FlagSizeOld)
throw new ArgumentException($"Data length {data.Length} does not match expected old size {FlagSizeOld}.");
if (result.Length < FlagSizeNew)
throw new ArgumentException($"Result length {result.Length} is less than expected new size {FlagSizeNew}.");
// For each tile row:
// - 4 bytes of zeros for new left column (result is already zeroed)
// - Copy 28 bytes from old data (existing flags)
// - 4 bytes of zeros for new right column (result is already zeroed)
for (int row = 0; row < TileRowCount; row++)
{
var srcOffset = row * BytesPerRowOld;
var dstOffset = (row * BytesPerRowNew) + BytesPerAcreColumnPerRow;
data.Slice(srcOffset, BytesPerRowOld).CopyTo(result[dstOffset..]);
}
}
/// <inheritdoc cref="Deflate(ReadOnlySpan{byte}, Span{byte})"/>
public static byte[] Deflate(ReadOnlySpan<byte> data)
{
if (data.Length != FlagSizeNew)
throw new ArgumentException($"Data length {data.Length} does not match expected new size {FlagSizeNew}.");
var result = new byte[FlagSizeOld];
Deflate(data, result);
return result;
}
/// <summary>
/// Deflates new field item flag data to the old format.
/// </summary>
/// <param name="data">New field item flag data.</param>
/// <param name="result">Span to write old field item flag data to.</param>
/// <exception cref="ArgumentException"></exception>
public static void Deflate(ReadOnlySpan<byte> data, Span<byte> result)
{
if (data.Length != FlagSizeNew)
throw new ArgumentException($"Data length {data.Length} does not match expected new size {FlagSizeNew}.");
if (result.Length < FlagSizeOld)
throw new ArgumentException($"Result length {result.Length} is less than expected old size {FlagSizeOld}.");
// For each tile row:
// - Skip 4 bytes for new left column
// - Copy 28 bytes to result (existing flags)
// - Skip 4 bytes for new right column
for (int row = 0; row < TileRowCount; row++)
{
var srcOffset = (row * BytesPerRowNew) + BytesPerAcreColumnPerRow;
var dstOffset = row * BytesPerRowOld;
data.Slice(srcOffset, BytesPerRowOld).CopyTo(result[dstOffset..]);
}
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Runtime.InteropServices;
namespace NHSE.Core;
/// <summary>
/// Provides functionality to upgrade or downgrade field item data between different formats.
/// </summary>
public static class FieldItemUpgrade
{
private const int ColumnCountOld = 7;
private const int ColumnCountNew = 9; // +1 column on each side
private const int RowCount = 6;
private const byte SizeDim = LayerFieldItem.TilesPerAcreDim;
private const int TilesPerAcre = SizeDim * SizeDim;
private const int FieldItemSizeSingleColumn = RowCount * TilesPerAcre * Item.SIZE;
private const int FieldItemSizeOld = ColumnCountOld * FieldItemSizeSingleColumn;
private const int FieldItemSizeNew = ColumnCountNew * FieldItemSizeSingleColumn;
// Items are stored in column-major order.
// Since the upgrade adds a column on each side, it's easy to prepend and append default columns.
/// <summary>
/// Checks if an update is needed based on the current size and expected size.
/// </summary>
/// <param name="current">Current size of the data.</param>
/// <param name="expect">Desired size of the data.</param>
/// <returns><see langword="true"/> if an update is needed; otherwise <see langword="false"/>.</returns>
public static bool IsUpdateNeeded(long current, int expect) => expect switch
{
FieldItemSizeOld => current == FieldItemSizeNew,
FieldItemSizeNew => current == FieldItemSizeOld,
_ => false,
};
/// <summary>
/// Detects and performs an update on the field item data if needed.
/// </summary>
/// <param name="data">Data to update.</param>
/// <param name="expect">Desired size of the data.</param>
/// <returns><see langword="true"/> if an update was performed; otherwise <see langword="false"/>.</returns>
/// <remarks>Pre-check <see cref="IsUpdateNeeded(long, int)"/> to ensure a conversion is available.</remarks>
public static bool DetectUpdate(ref byte[] data, int expect)
{
if (expect == FieldItemSizeNew && data.Length == FieldItemSizeOld)
data = Inflate(data);
else if (expect == FieldItemSizeOld && data.Length == FieldItemSizeNew)
data = Deflate(data);
else // No change needed/supported.
return false;
return true;
}
/// <inheritdoc cref="Inflate(ReadOnlySpan{byte}, Span{byte})"/>
public static byte[] Inflate(ReadOnlySpan<byte> data)
{
if (data.Length != FieldItemSizeOld)
throw new ArgumentException($"Data length {data.Length} does not match expected old size {FieldItemSizeOld}.");
var result = new byte[FieldItemSizeNew];
Inflate(data, result);
return result;
}
/// <summary>
/// Inflates old field item data to the new format.
/// </summary>
/// <param name="data">Old field item data.</param>
/// <param name="result">Span to write new field item data to.</param>
/// <returns>New field item data.</returns>
/// <exception cref="ArgumentException"></exception>
public static void Inflate(ReadOnlySpan<byte> data, Span<byte> result)
{
if (data.Length != FieldItemSizeOld)
throw new ArgumentException($"Data length {data.Length} does not match expected old size {FieldItemSizeOld}.");
if (result.Length < FieldItemSizeNew)
throw new ArgumentException($"Result length {result.Length} is less than expected new size {FieldItemSizeNew}.");
// The first acre column is default field items.
// Then, the existing data is present.
// Finally, an acre column is default field items.
// Prepare a default column of no items.
Span<byte> defaultColumn = stackalloc byte[FieldItemSizeSingleColumn];
FillColumnWithItem(defaultColumn, Item.NONE);
// First default column
defaultColumn.CopyTo(result);
// Existing data
data.CopyTo(result[FieldItemSizeSingleColumn..]);
// Last default column
defaultColumn.CopyTo(result[^FieldItemSizeSingleColumn..]);
}
private static void FillColumnWithItem(Span<byte> defaultColumn, ulong tileValue)
{
if (!BitConverter.IsLittleEndian)
tileValue = System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(tileValue);
var cast = MemoryMarshal.Cast<byte, ulong>(defaultColumn);
foreach (ref var value in cast)
value = tileValue;
}
/// <inheritdoc cref="Deflate(ReadOnlySpan{byte}, Span{byte})"/>
public static byte[] Deflate(ReadOnlySpan<byte> data)
{
if (data.Length != FieldItemSizeNew)
throw new ArgumentException($"Data length {data.Length} does not match expected new size {FieldItemSizeNew}.");
return data.Slice(FieldItemSizeSingleColumn, FieldItemSizeOld).ToArray();
}
/// <summary>
/// Deflates new field item data to the old format.
/// </summary>
/// <param name="data">New field item data.</param>
/// <param name="result">Span to write old field item data to.</param>
/// <returns>Old field item data.</returns>
/// <exception cref="ArgumentException"></exception>
public static void Deflate(ReadOnlySpan<byte> data, Span<byte> result)
{
if (data.Length != FieldItemSizeNew)
throw new ArgumentException($"Data length {data.Length} does not match expected new size {FieldItemSizeNew}.");
if (result.Length < FieldItemSizeOld)
throw new ArgumentException($"Result length {result.Length} is less than expected old size {FieldItemSizeOld}.");
data.Slice(FieldItemSizeSingleColumn, FieldItemSizeOld).CopyTo(result);
}
}

View File

@@ -211,7 +211,7 @@ private static Item CreateItem(byte[] convert, int requestIndex, IConfigItem con
try
{
if (convert.Length != Item.SIZE)
throw new Exception();
throw new Exception($"Invalid item byte length (expected {Item.SIZE}, got {convert.Length}).");
item = convert.ToClass<Item>();
}
catch (Exception ex)

View File

@@ -165,7 +165,8 @@ public Museum Museum
set => value.Data.CopyTo(Data[Offsets.Museum..]);
}
public const int AcreWidth = 7 + (2 * 1); // 1 on each side cannot be traversed
// Acre Layout/Selection of which baselayer is selected for an acre.
private const int AcreWidth = 7 + (2 * 1); // 1 on each side cannot be traversed
private const int AcreHeight = 6 + (2 * 1); // 1 on each side cannot be traversed
private const int AcreMax = AcreWidth * AcreHeight;
private const int AcreSizeAll = AcreMax * 2;
@@ -193,29 +194,50 @@ public void SetAcreBytes(ReadOnlySpan<byte> data)
data.CopyTo(Data[Offsets.OutsideField..]);
}
public TerrainTile[] GetTerrainTiles() => TerrainTile.GetArray(Data.Slice(Offsets.LandMakingMap, MapGrid.MapTileCount16x16 * TerrainTile.SIZE));
#pragma warning disable CA1822 // Mark members as static
public byte FieldItemAcreWidth => Offsets.FieldItemAcreWidth; // 3.0.0 updated from 7 => 9
// ReSharper disable once MemberCanBeMadeStatic.Global
public byte FieldItemAcreHeight => 6; // always 6
private int FieldItemAcreCount => FieldItemAcreWidth * FieldItemAcreHeight;
#pragma warning restore CA1822 // Mark members as static
private const int TotalTerrainTileCount = LayerTerrain.TilesPerAcreDim * LayerTerrain.TilesPerAcreDim * (7 * 6);
private int TotalFieldItemTileCount => LayerFieldItem.TilesPerAcreDim * LayerFieldItem.TilesPerAcreDim * FieldItemAcreCount;
public TerrainTile[] GetTerrainTiles() => TerrainTile.GetArray(Data.Slice(Offsets.LandMakingMap, TotalTerrainTileCount * TerrainTile.SIZE));
public void SetTerrainTiles(IReadOnlyList<TerrainTile> array) => TerrainTile.SetArray(array).CopyTo(Data[Offsets.LandMakingMap..]);
public const int MapDesignNone = 0xF800;
public const ushort MapDesignNone = 0xF800;
public Memory<byte> MapDesignTileData => Raw.Slice(Offsets.MyDesignMap, 112 * 96 * sizeof(ushort));
public ushort[] GetMapDesignTiles() => MemoryMarshal.Cast<byte, ushort>(MapDesignTileData.Span).ToArray();
public void SetMapDesignTiles(ReadOnlySpan<ushort> value) => MemoryMarshal.Cast<ushort, byte>(value).CopyTo(MapDesignTileData.Span);
private const int FieldItemLayerSize = MapGrid.MapTileCount32x32 * Item.SIZE;
private const int FieldItemFlagSize = MapGrid.MapTileCount32x32 / 8; // bitflags
public void ClearDesignTiles()
{
var tiles = GetMapDesignTiles();
tiles.AsSpan().Fill(MapDesignNone);
SetMapDesignTiles(tiles);
}
private int FieldItemLayer1 => Offsets.FieldItem;
private int FieldItemLayer2 => Offsets.FieldItem + FieldItemLayerSize;
public int FieldItemFlag1 => Offsets.FieldItem + (FieldItemLayerSize * 2);
public int FieldItemFlag2 => Offsets.FieldItem + (FieldItemLayerSize * 2) + FieldItemFlagSize;
private int FieldItemLayerSize => TotalFieldItemTileCount * Item.SIZE;
private int FieldItemFlagSize => TotalFieldItemTileCount / sizeof(byte); // bitflags
private int FieldItemLayer0 => Offsets.FieldItem;
private int FieldItemLayer1 => Offsets.FieldItem + FieldItemLayerSize;
public int FieldItemFlag0 => Offsets.FieldItem + (FieldItemLayerSize * 2);
public int FieldItemFlag1 => Offsets.FieldItem + (FieldItemLayerSize * 2) + FieldItemFlagSize;
public Memory<byte> FieldItemFlag0Data => Data.Slice(FieldItemFlag0, FieldItemFlagSize).ToArray();
public Memory<byte> FieldItemFlag1Data => Data.Slice(FieldItemFlag1, FieldItemFlagSize).ToArray();
public Item[] GetFieldItemLayer0() => Item.GetArray(Data.Slice(FieldItemLayer0, FieldItemLayerSize));
public void SetFieldItemLayer0(IReadOnlyList<Item> array) => Item.SetArray(array).CopyTo(Data[FieldItemLayer0..]);
public Item[] GetFieldItemLayer1() => Item.GetArray(Data.Slice(FieldItemLayer1, FieldItemLayerSize));
public void SetFieldItemLayer1(IReadOnlyList<Item> array) => Item.SetArray(array).CopyTo(Data[FieldItemLayer1..]);
public Item[] GetFieldItemLayer2() => Item.GetArray(Data.Slice(FieldItemLayer2, FieldItemLayerSize));
public void SetFieldItemLayer2(IReadOnlyList<Item> array) => Item.SetArray(array).CopyTo(Data[FieldItemLayer2..]);
public ushort OutsideFieldTemplateUniqueId
{
get => ReadUInt16LittleEndian(Data[(Offsets.OutsideField + AcreSizeAll)..]);

View File

@@ -46,12 +46,12 @@ protected EncryptedFilePair(ISaveFileProvider provider, string name)
RawHeader = hd;
RawData = md;
Info = RawHeader[..FileHeaderInfo.SIZE].ToClass<FileHeaderInfo>();
Info = Header[..FileHeaderInfo.SIZE].ToArray().ToClass<FileHeaderInfo>();
}
public void Save(uint seed)
{
var encrypt = Encryption.Encrypt(RawData, seed, RawHeader);
var encrypt = Encryption.Encrypt(Data, seed, Header);
Provider.WriteFile(NameData, encrypt.Data.Span);
Provider.WriteFile(NameHeader, encrypt.Header.Span);
}

View File

@@ -11,11 +11,10 @@ namespace NHSE.Core;
/// Creates a HorizonSave from a file provider.
/// </remarks>
/// <param name="provider">Provider for reading/writing save files.</param>
public class HorizonSave(ISaveFileProvider provider)
public sealed class HorizonSave(ISaveFileProvider provider)
{
public readonly MainSave Main = new(provider);
public readonly Player[] Players = Player.ReadMany(provider);
private readonly ISaveFileProvider Provider = provider;
public readonly IReadOnlyList<Player> Players = Player.ReadMany(provider);
public override string ToString() => $"{Players[0].Personal.TownName} - {Players[0]}";
@@ -57,7 +56,7 @@ public void Save(uint seed)
pair.Save(seed);
}
}
Provider.Flush();
provider.Flush();
}
/// <summary>

View File

@@ -62,7 +62,7 @@ public abstract class MainSaveOffsets
public abstract int PlayerHouseSize { get; }
public abstract int PlayerRoomSize { get; }
public virtual int AcreColumnCount => 7;
public virtual byte FieldItemAcreWidth => 7;
public abstract IVillager ReadVillager(Memory<byte> data);
public abstract IVillagerHouse ReadVillagerHouse(Memory<byte> data);

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets10 : MainSaveOffsets
public sealed class MainSaveOffsets10 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x108;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets11 : MainSaveOffsets
public sealed class MainSaveOffsets11 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets110 : MainSaveOffsets
public sealed class MainSaveOffsets110 : MainSaveOffsets
{
public override int PatternCount => PatternCount2;

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
/// <remarks>Same as <see cref="MainSaveOffsets110"/></remarks>
public class MainSaveOffsets111 : MainSaveOffsets
public sealed class MainSaveOffsets111 : MainSaveOffsets
{
public override int PatternCount => PatternCount2;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets12 : MainSaveOffsets
public sealed class MainSaveOffsets12 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets13 : MainSaveOffsets
public sealed class MainSaveOffsets13 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets14 : MainSaveOffsets
public sealed class MainSaveOffsets14 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets15 : MainSaveOffsets
public sealed class MainSaveOffsets15 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets16 : MainSaveOffsets
public sealed class MainSaveOffsets16 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets17 : MainSaveOffsets
public sealed class MainSaveOffsets17 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
/// <remarks>Same as <see cref="MainSaveOffsets17"/></remarks>.
public class MainSaveOffsets18 : MainSaveOffsets
public sealed class MainSaveOffsets18 : MainSaveOffsets
{
#region GSaveLand
public const int GSaveLandStart = 0x110;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets19 : MainSaveOffsets
public sealed class MainSaveOffsets19 : MainSaveOffsets
{
public override int PatternCount => PatternCount2;

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
/// <remarks>Same as <see cref="MainSaveOffsets110"/></remarks>
public class MainSaveOffsets20 : MainSaveOffsets
public sealed class MainSaveOffsets20 : MainSaveOffsets
{
public override int PatternCount => PatternCount2;

View File

@@ -5,7 +5,7 @@ namespace NHSE.Core;
/// <summary>
/// <inheritdoc cref="MainSaveOffsets"/>
/// </summary>
public class MainSaveOffsets30 : MainSaveOffsets
public sealed class MainSaveOffsets30 : MainSaveOffsets
{
public override int PatternCount => PatternCount2;
@@ -35,7 +35,7 @@ public class MainSaveOffsets30 : MainSaveOffsets
public const int GSaveMainFieldStart = GSaveLandStart + 0x22f3f0;
// Map size increased to accommodate the Hotel, by adding 2 columns of acres!
public override int AcreColumnCount => 9; // from 7 to 9
public override byte FieldItemAcreWidth => 9; // from 7 to 9
// does this actually impact anything? We'll eventually find out if so; I hope it is just 2 columns of unused.
// Layer0: 54000 => 6C000

View File

@@ -8,7 +8,7 @@ namespace NHSE.Core;
/// Interact-able structure that can be entered by the player.
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = SIZE, Pack = 1)]
public class Building
public sealed class Building
{
public const int SIZE = 0x14;

View File

@@ -35,4 +35,13 @@ public enum BuildingType : ushort
Incline = 27,
ReddsTreasureTrawler = 28,
Studio = 29,
}
Hotel = 42,
}
public static class BuildingUtil
{
public static (int Width, int Height) GetDimensions(this BuildingType type) => type switch
{
_ => (2, 2),
};
}

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <summary>
/// Simple design pattern
/// </summary>
public class DesignPattern : IVillagerOrigin
public sealed class DesignPattern(Memory<byte> Raw) : IVillagerOrigin
{
public const int Width = 32;
public const int Height = 32;
@@ -20,11 +20,8 @@ public class DesignPattern : IVillagerOrigin
private const int PixelCount = 0x400; // Width * Height
//private const int PixelDataSize = PixelCount / 2; // 4bit|4bit pixel packing
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public DesignPattern(Memory<byte> data) => Raw = data;
public uint Hash
{
get => ReadUInt32LittleEndian(Data);
@@ -119,7 +116,13 @@ public static int GetColorOffset(int index)
/// </summary>
public byte[] GetBitmap()
{
byte[] data = new byte[4 * Width * Height];
var result = new byte[4 * Width * Height];
LoadBitmap(result);
return result;
}
public void LoadBitmap(Span<byte> data)
{
for (int i = 0; i < PixelCount; i++)
{
var choice = this[i];
@@ -132,7 +135,6 @@ public byte[] GetBitmap()
data[ofs + 0] = Data[palette + 2];
data[ofs + 3] = 0xFF; // opaque
}
return data;
}
/// <summary>
@@ -141,6 +143,12 @@ public byte[] GetBitmap()
public byte[] GetPaletteBitmap()
{
var result = new byte[3 * PaletteColorCount];
LoadPaletteBitmap(result);
return result;
}
public void LoadPaletteBitmap(Span<byte> result)
{
for (int i = 0; i < PaletteColorCount; i++)
{
var ofs = PaletteDataStart + (i * 3);
@@ -148,6 +156,5 @@ public byte[] GetPaletteBitmap()
result[(i * 3) + 1] = Data[ofs + 1];
result[(i * 3) + 0] = Data[ofs + 2];
}
return result;
}
}

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <summary>
/// Advanced design pattern with 4 sheets arranged in a square.
/// </summary>
public class DesignPatternPRO : IVillagerOrigin
public sealed class DesignPatternPRO(Memory<byte> Raw) : IVillagerOrigin
{
public const int Width = 32;
public const int Height = 32;
@@ -21,11 +21,8 @@ public class DesignPatternPRO : IVillagerOrigin
private const int PixelCount = 0x400; // Width * Height
private const int SheetDataSize = PixelCount / 2; // 4bit|4bit pixel packing
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public DesignPatternPRO(Memory<byte> data) => Raw = data;
public uint Hash
{
get => ReadUInt32LittleEndian(Data);
@@ -114,7 +111,13 @@ public static int GetColorOffset(int index)
/// </summary>
public byte[] GetBitmap(int sheet)
{
byte[] data = new byte[4 * Width * Height];
var result = new byte[4 * Width * Height];
LoadBitmap(sheet, result);
return result;
}
private void LoadBitmap(int sheet, Span<byte> data)
{
for (int i = 0; i < PixelCount; i++)
{
var choice = GetPixelAtIndex(sheet, i);
@@ -127,7 +130,6 @@ public byte[] GetBitmap(int sheet)
data[ofs + 0] = Data[palette + 2];
data[ofs + 3] = 0xFF; // opaque
}
return data;
}
/// <summary>
@@ -136,6 +138,12 @@ public byte[] GetBitmap(int sheet)
public byte[] GetPaletteBitmap()
{
var result = new byte[3 * PaletteColorCount];
LoadPaletteBitmap(result);
return result;
}
private void LoadPaletteBitmap(Span<byte> result)
{
for (int i = 0; i < PaletteColorCount; i++)
{
var ofs = PaletteDataStart + (i * 3);
@@ -143,6 +151,5 @@ public byte[] GetPaletteBitmap()
result[(i * 3) + 1] = Data[ofs + 1];
result[(i * 3) + 0] = Data[ofs + 2];
}
return result;
}
}

View File

@@ -75,7 +75,7 @@ public static void DumpPlayerHouses(this IReadOnlyList<IPlayerHouse> houses, IRe
/// <param name="path">Path to dump to</param>
public static void DumpPlayerHouses(this HorizonSave sav, string path)
{
var count = Math.Min(sav.Players.Length, MainSaveOffsets.PlayerCount);
var count = Math.Min(sav.Players.Count, MainSaveOffsets.PlayerCount);
for (int i = 0; i < count; i++)
{
var p = sav.Players[i];

View File

@@ -225,6 +225,7 @@ public void CopyFrom(Item item)
public static Item[] GetArray(ReadOnlySpan<byte> data) => data.GetArray<Item>(SIZE);
public static byte[] SetArray(IReadOnlyList<Item> data) => data.SetArray(SIZE);
public static byte[] SetArray(ReadOnlySpan<Item> data) => data.SetArray(SIZE);
public ushort GetWrappedItemName() => WrappingType switch
{

View File

@@ -3,10 +3,9 @@
namespace NHSE.Core;
public class ItemArrayEditor<T> where T : Item, ICopyableItem<T>
public sealed record ItemArrayEditor<T>(IReadOnlyList<T> Items)
where T : Item, ICopyableItem<T>
{
public readonly IReadOnlyList<T> Items;
public ItemArrayEditor(IReadOnlyList<T> items) => Items = items;
public int ItemSize => Items[0].Size;
public int TotalSize => Items.Count * ItemSize;

View File

@@ -5,38 +5,19 @@ namespace NHSE.Core;
/// <summary>
/// Metadata for an item's customization permissions
/// </summary>
public class ItemRemakeInfo
public sealed record ItemRemakeInfo(
short Index,
ushort ItemUniqueID,
sbyte ReBodyPatternNum,
byte[] ReBodyPatternColors0,
byte[] ReBodyPatternColors1,
byte[] ReFabricPatternColors0,
byte[] ReFabricPatternColors1,
bool ReFabricPattern0VisibleOff)
{
public const int BodyColorCountMax = 8;
public const int NoColor = (int)ItemCustomColor.None; // 14
public readonly short Index;
public readonly ushort ItemUniqueID;
public readonly sbyte ReBodyPatternNum; // count of body colors
public readonly byte[] ReBodyPatternColors0;
public readonly byte[] ReBodyPatternColors1;
public readonly byte[] ReFabricPatternColors0;
public readonly byte[] ReFabricPatternColors1;
public readonly bool ReFabricPattern0VisibleOff;
public ItemRemakeInfo(short index, ushort id, sbyte count, byte[] bc0, byte[] bc1, byte[] fc0, byte[] fc1, bool fp0)
{
Index = index;
ItemUniqueID = id;
ReBodyPatternNum = count;
ReBodyPatternColors0 = bc0;
ReBodyPatternColors1 = bc1;
ReFabricPatternColors0 = fc0;
ReFabricPatternColors1 = fc1;
ReFabricPattern0VisibleOff = fp0;
}
private const string Invalid = nameof(Invalid);
public bool HasBodyColor(int variant) => ReBodyPatternColors0[variant] != NoColor || ReBodyPatternColors1[variant] != NoColor;

View File

@@ -0,0 +1,37 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace NHSE.Core;
/// <summary>
/// Navigation metadata for acre coordinates.
/// </summary>
[DebuggerDisplay("{Name} ({X}, {Y})")]
public readonly record struct AcreCoordinate(char XChar, char YChar, byte X, byte Y)
{
public const int CountAcreExteriorWidth = 9;
public const int CountAcreExteriorHeight = 8;
/// <summary>
/// Entire grid including exterior acre coordinates (bordered by acres of deep sea->shoreline=>terrain).
/// </summary>
public static readonly AcreCoordinate[] Exterior = GetGridWithExterior(CountAcreExteriorWidth, CountAcreExteriorHeight);
public string Name => $"{YChar}{XChar}";
private static AcreCoordinate[] GetGridWithExterior([ConstantExpected] int width, [ConstantExpected] int height)
{
var result = new AcreCoordinate[width * height];
int i = 0;
for (byte y = 0; y < height; y++)
{
for (byte x = 0; x < width; x++)
{
var xn = (x == 0) ? '<' : x == width - 1 ? '>' : (char)('0' + x - 1);
var yn = (y == 0) ? '^' : y == height - 1 ? 'V' : (char)('A' + y - 1);
result[i++] = new AcreCoordinate(xn, yn, x, y);
}
}
return result;
}
}

View File

@@ -0,0 +1,14 @@
namespace NHSE.Core;
/// <summary>
/// Basic logic implementation for interacting with the manipulatable map grid.
/// </summary>
public abstract record AcreSelectionGrid(TileGridViewport TileInfo)
{
/// <summary>
/// Checks if the specified relative x/y coordinates are within the bounds of this layer.
/// </summary>
/// <param name="relX">The requested tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The requested tile's Y-coordinate, relative to the layer origin.</param>
public bool Contains(int relX, int relY) => TileInfo.Contains(relX, relY);
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
namespace NHSE.Core;
/// <summary>
/// Logic for interacting with a map's building layer.
/// </summary>
public interface ILayerBuilding
{
/// <summary>
/// Instantiated list of all buildings on the map.
/// </summary>
IReadOnlyList<Building> Buildings { get; init; }
/// <summary>
/// Converts relative building coordinates to absolute coordinates in the map grid.
/// </summary>
/// <param name="relX">Relative building X coordinate</param>
/// <param name="relY">Relative building Y coordinate</param>
/// <returns>Map absolute X/Y coordinates</returns>
(int X, int Y) GetCoordinatesAbsolute(ushort relX, ushort relY);
/// <summary>
/// Converts absolute map grid coordinates to relative building coordinates.
/// </summary>
/// <param name="absX">Map absolute X coordinate</param>
/// <param name="absY">Map absolute Y coordinate</param>
/// <returns>Relative building X/Y coordinates</returns>
(int X, int Y) GetCoordinatesRelative(int absX, int absY);
Building this[int i] { get; }
int Count { get; }
}

View File

@@ -0,0 +1,48 @@
using System;
namespace NHSE.Core;
/// <summary>
/// Logic for managing field item flags within a layer.
/// </summary>
public interface ILayerFieldItemFlag
{
/// <summary>
/// Gets the active state of the field item flag at the specified relative coordinates.
/// </summary>
/// <param name="relX">Relative X coordinate within the array.</param>
/// <param name="relY">Relative Y coordinate within the array.</param>
/// <returns>The active state of the field item flag.</returns>
bool GetIsActive(int relX, int relY);
/// <summary>
/// Sets the active state of the field item flag at the specified relative coordinates.
/// </summary>
/// <param name="relX">Relative X coordinate within the array.</param>
/// <param name="relY">Relative Y coordinate within the array.</param>
/// <param name="value">The active state to set.</param>
void SetIsActive(int relX, int relY, bool value = true);
bool this[int relX, int relY]
{
get => GetIsActive(relX, relY);
set => SetIsActive(relX, relY, value);
}
/// <summary>
/// Deactivates all field item flags.
/// </summary>
void DeactivateAll();
/// <summary>
/// Saves the (in)active flags into the provided destination span.
/// </summary>
/// <param name="dest">Destination span to save the flags into.</param>
void Save(Span<byte> dest);
/// <summary>
/// Imports the (in)active flags from the provided source span.
/// </summary>
/// <param name="src">Source span containing the flags to import.</param>
void Import(Span<byte> src);
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
namespace NHSE.Core;
public interface ILayerFieldItemSet
{
LayerFieldItem Layer0 { get; }
LayerFieldItem Layer1 { get; }
bool IsOccupied(int relX, int relY);
Item GetItem(int relX, int relY, bool baseLayer);
void SetItem(int relX, int relY, bool baseLayer, Item value);
Item this[int relX, int relY, bool baseLayer]
{
get => GetItem(relX, relY, baseLayer);
set => SetItem(relX, relY, baseLayer, value);
}
/// <summary>
/// Lists out all coordinates of tiles present in <see cref="Layer1"/> that don't have anything underneath in <see cref="Layer0"/> to support them.
/// </summary>
List<string> GetUnsupportedTiles(int totalWidth, int totalHeight);
List<string> GetUnsupportedTiles() => GetUnsupportedTiles(Layer0.TileInfo.TotalWidth, Layer0.TileInfo.TotalHeight);
}

View File

@@ -1,207 +0,0 @@
using System;
using System.Diagnostics;
namespace NHSE.Core;
public abstract class ItemLayer : MapGrid
{
public readonly Item[] Tiles;
protected ItemLayer(Item[] tiles, int w, int h) : this(tiles, w, h, w, h)
{
}
protected ItemLayer(Item[] tiles, int w, int h, int gw, int gh) : base(gw, gh, w, h)
{
Tiles = tiles;
Debug.Assert(MaxWidth * MaxHeight == tiles.Length);
}
public Item GetTile(in int x, in int y) => this[GetTileIndex(x, y)];
public Item this[int index]
{
get => Tiles[index];
set => Tiles[index] = value;
}
public byte[] DumpAll()
{
var result = new byte[Tiles.Length * Item.SIZE];
for (int i = 0; i < Tiles.Length; i++)
Tiles[i].ToBytesClass().CopyTo(result, i * Item.SIZE);
return result;
}
public void ImportAll(ReadOnlySpan<byte> data)
{
var tiles = Item.GetArray(data);
for (int i = 0; i < tiles.Length; i++)
Tiles[i].CopyFrom(tiles[i]);
}
public int RemoveAll(in int xmin, in int ymin, in int width, in int height, Func<Item, bool> criteria)
{
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (!criteria(t))
continue;
t.Delete();
count++;
}
}
return count;
}
public void DeleteExtensionTiles(Item tile, int x, int y)
{
GetTileWidthHeight(tile, x, y, out var w, out var h);
for (int ix = 0; ix < w; ix++)
{
for (int iy = 0; iy < h; iy++)
{
if (iy == 0 && ix == 0)
continue;
var t = GetTile(x + ix, y + iy);
t.Delete();
}
}
}
public void SetExtensionTiles(Item tile, in int x, in int y)
{
GetTileWidthHeight(tile, x, y, out var w, out var h);
for (byte ix = 0; ix < w; ix++)
{
for (byte iy = 0; iy < h; iy++)
{
if (iy == 0 && ix == 0)
continue;
var t = GetTile(x + ix, y + iy);
t.SetAsExtension(tile, ix, iy);
}
}
}
private void GetTileWidthHeight(Item tile, int x, int y, out int w, out int h)
{
var type = ItemInfo.GetItemSize(tile);
w = type.Width;
h = type.Height;
// Rotation
if ((tile.Rotation & 1) == 1)
(w, h) = (h, w);
// Clamp to grid bounds
if (x + w - 1 >= MaxWidth)
w = MaxWidth - x;
if (y + h - 1 >= MaxHeight)
h = MaxHeight - y;
}
/// <summary>
/// Checks if writing the <see cref="tile"/> at the specified <see cref="x"/> and <see cref="y"/> coordinates will overlap with any existing tiles.
/// </summary>
/// <returns>True if any tile will be overwritten, false if nothing is there.</returns>
public PlacedItemPermission IsOccupied(Item tile, in int x, in int y)
{
var type = ItemInfo.GetItemSize(tile);
var w = type.Width;
var h = type.Height;
if ((tile.Rotation & 1) == 1)
(w, h) = (h, w);
if (x + w - 1 >= MaxWidth)
return PlacedItemPermission.OutOfBounds;
if (y + h - 1 >= MaxHeight)
return PlacedItemPermission.OutOfBounds;
for (byte ix = 0; ix < w; ix++)
{
for (byte iy = 0; iy < h; iy++)
{
var t = GetTile(x + ix, y + iy);
if (!t.IsNone)
return PlacedItemPermission.Collision;
}
}
return PlacedItemPermission.NoCollision;
}
public int ReplaceAll(Item oldItem, Item newItem, in int xmin, in int ymin, in int width, in int height)
{
var sizeOld = ItemInfo.GetItemSize(oldItem);
var sizeNew = ItemInfo.GetItemSize(newItem);
if (sizeOld != sizeNew)
return -1;
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (!t.IsRoot)
continue;
if (!t.Equals(oldItem))
continue;
DeleteExtensionTiles(t, x, y);
t.CopyFrom(newItem);
SetExtensionTiles(t, x, y);
count++;
}
}
return count;
}
public int ClearDanglingExtensions(in int xmin, in int ymin, in int width, in int height)
{
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (IsValidExtension(t, x, y))
continue;
t.Delete();
count++;
}
}
return count;
}
private bool IsValidExtension(Item t, int x, int y)
{
if (!t.IsExtension)
return true;
var parentX = x - t.ExtensionX;
var parentY = y - t.ExtensionY;
try
{
var parent = GetTile(parentX, parentY);
if (parent.ItemId == t.ExtensionItemId)
return true;
}
catch
{
// corrupt?
}
return false;
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
namespace NHSE.Core;
/// <summary>
/// Logic for interacting with a map's building layer.
/// </summary>
public sealed record LayerBuilding : ILayerBuilding
{
public required IReadOnlyList<Building> Buildings { get; init; }
// Although there is terrain in the Top Row and Left Column, no buildings can be placed there.
// Buildings can only be placed below that line; per the map layout, there is 2 acres worth of buffer, then our origin starts.
/// <summary>
/// Compared to Item Tiles, building tiles are a 16x16 resolution (2x2 item tiles).
/// When converting between building coordinates and absolute coordinates, we need to account for this.
/// </summary>
private const int BuildingResolution = 2;
private const int BuildingTilesPerAcre = 16;
private const int AcreBufferEdge = 2;
public (int X, int Y) GetCoordinatesAbsolute(ushort relX, ushort relY)
{
int absX = (relX * BuildingResolution) + (AcreBufferEdge * BuildingTilesPerAcre);
int absY = (relY * BuildingResolution) + (AcreBufferEdge * BuildingTilesPerAcre);
return (absX, absY);
}
public (int X, int Y) GetCoordinatesRelative(int absX, int absY)
{
int relX = (absX - (AcreBufferEdge * BuildingTilesPerAcre)) / BuildingResolution;
int relY = (absY - (AcreBufferEdge * BuildingTilesPerAcre)) / BuildingResolution;
return (relX, relY);
}
public Building this[int i] => Buildings[i];
public int Count => Buildings.Count;
}

View File

@@ -3,47 +3,17 @@
namespace NHSE.Core;
public class FieldItemLayer : ItemLayer
public sealed record LayerFieldItem(Item[] Tiles, byte AcreWidth, byte AcreHeight)
: LayerItem(Tiles, GetViewport(AcreWidth, AcreHeight))
{
private static TileGridViewport GetViewport(byte width, byte height) => new(TilesPerAcreDim, TilesPerAcreDim, width, height);
public const int TilesPerAcreDim = 32;
public const int FieldItemWidth = TilesPerAcreDim * AcreWidth;
public const int FieldItemHeight = TilesPerAcreDim * AcreHeight;
public FieldItemLayer(Item[] tiles) : base(tiles, FieldItemWidth, FieldItemHeight, TilesPerAcreDim, TilesPerAcreDim)
{
}
public Item GetTile(int acreX, int acreY, int gridX, int gridY) => this[GetTileIndex(acreX, acreY, gridX, gridY)];
public Item GetAcreTile(int acreIndex, int tileIndex) => this[GetAcreTileIndex(acreIndex, tileIndex)];
public byte[] DumpAcre(int acre)
{
int count = GridTileCount;
var result = new byte[Item.SIZE * count];
for (int i = 0; i < count; i++)
{
var tile = GetAcreTile(acre, i);
var bytes = tile.ToBytesClass();
bytes.CopyTo(result, i * Item.SIZE);
}
return result;
}
public void ImportAcre(int acre, ReadOnlySpan<byte> data)
{
int count = GridTileCount;
var tiles = Item.GetArray(data);
for (int i = 0; i < count; i++)
{
var tile = GetAcreTile(acre, i);
tile.CopyFrom(tiles[i]);
}
}
public int ClearFieldPlanted(Func<FieldItemKind, bool> criteria) => ClearFieldPlanted(0, 0, MaxWidth, MaxHeight, criteria);
public int RemoveAll(Func<Item, bool> criteria) => RemoveAll(0, 0, MaxWidth, MaxHeight, criteria);
public int RemoveAll(HashSet<ushort> items) => RemoveAll(0, 0, MaxWidth, MaxHeight, z => items.Contains(z.DisplayItemId));
public int RemoveAll(ushort item) => RemoveAll(0, 0, MaxWidth, MaxHeight, z => z.DisplayItemId == item);
public int ClearFieldPlanted(Func<FieldItemKind, bool> criteria) => ClearFieldPlanted(0, 0, TileInfo.TotalWidth, TileInfo.TotalHeight, criteria);
public int RemoveAll(Func<Item, bool> criteria) => RemoveAll(0, 0, TileInfo.TotalWidth, TileInfo.TotalHeight, criteria);
public int RemoveAll(HashSet<ushort> items) => RemoveAll(0, 0, TileInfo.TotalWidth, TileInfo.TotalHeight, z => items.Contains(z.DisplayItemId));
public int RemoveAll(ushort item) => RemoveAll(0, 0, TileInfo.TotalWidth, TileInfo.TotalHeight, z => z.DisplayItemId == item);
public int ClearFieldPlanted(int xmin, int ymin, int width, int height, Func<FieldItemKind, bool> criteria)
{
@@ -54,6 +24,9 @@ public int ClearFieldPlanted(int xmin, int ymin, int width, int height, Func<Fie
{
for (int y = ymin; y < ymin + height; y++)
{
if (!Contains(x, y))
continue;
var t = GetTile(x, y);
var disp = t.DisplayItemId;
if (!fi.TryGetValue(disp, out var val))
@@ -75,6 +48,8 @@ public int ModifyAll(int xmin, int ymin, int width, int height, Func<Item, bool>
{
for (int y = ymin; y < ymin + height; y++)
{
if (!Contains(x, y))
continue;
var t = GetTile(x, y);
if (!criteria(t))
continue;
@@ -119,4 +94,10 @@ bool IsFlowerWaterable(Item item)
return ModifyAll(xmin, ymin, width, height, IsFlowerWaterable, z => z.Water(all));
}
public Item this[int relX, int relY]
{
get => GetTile(relX, relY);
set => SetTile(relX, relY, value);
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Diagnostics;
namespace NHSE.Core;
/// <summary>
/// Logic for managing field item flags within a layer.
/// </summary>
public sealed class LayerFieldItemFlag(Memory<byte> raw, int width, int height) : ILayerFieldItemFlag
{
public Span<byte> Data => raw.Span;
public bool GetIsActive(int relX, int relY)
=> FlagUtil.GetFlag(Data, GetLayerFlagIndex(relX, relY));
public void SetIsActive(int relX, int relY, bool value)
=> FlagUtil.SetFlag(Data, GetLayerFlagIndex(relX, relY), value);
public bool this[int relX, int relY]
{
get => GetIsActive(relX, relY);
set => SetIsActive(relX, relY, value);
}
/// <summary>
/// Although the Field Item Tiles are arranged y-column (y-x) based, the 'IsActive' flags are arranged x-row (x-y) based.
/// </summary>
private int GetLayerFlagIndex(int x, int y) => (y * width) + x;
public void DeactivateAll() => Data.Clear();
public void Save(Span<byte> dest) => Data.CopyTo(dest);
public void Import(Span<byte> src) => src.CopyTo(Data);
public bool IsInLayer(int tileX, int tileY) => !((uint)tileX >= width || (uint)tileY >= height);
/// <summary>
/// Diagnostic check of the active flags against the tiles.
/// </summary>
/// <param name="tiles">Tiles to check against.</param>
public void DebugCheckTileActiveFlags(LayerItem tiles)
{
// this doesn't set anything; just diagnostic
// Although the Tiles are arranged y-column (y-x) based, the 'isActive' flags are arranged x-row (x-y) based.
// We can turn the isActive flag off if the item is not a root or the item cannot be animated.
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
var tile = tiles.GetTile(x, y);
var isActive = GetIsActive(x, y);
if (!isActive)
continue;
bool empty = tile.IsNone;
if (empty)
Debug.WriteLine($"Flag at ({x},{y}) is not a root object.");
}
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Generic;
namespace NHSE.Core;
/// <summary>
/// Manages the <see cref="Item"/> data for the player's outside overworld.
/// </summary>
public sealed class LayerFieldItemSet : ILayerFieldItemSet
{
/// <summary>
/// Base layer of items
/// </summary>
public required LayerFieldItem Layer0 { get; init; }
/// <summary>
/// Layer of items that are supported by <see cref="Layer0"/>
/// </summary>
public required LayerFieldItem Layer1 { get; init; }
/// <summary>
/// Lists out all coordinates of tiles present in <see cref="Layer1"/> that don't have anything underneath in <see cref="Layer0"/> to support them.
/// </summary>
public List<string> GetUnsupportedTiles(int totalWidth, int totalHeight)
{
var result = new List<string>();
for (int x = 0; x < totalWidth; x++)
{
for (int y = 0; y < totalHeight; y++)
{
// If there is an item on this layer...
var tile = Layer1.GetTile(x, y);
if (tile.IsNone)
continue;
// Then there must be something underneath it.
var support = Layer0.GetTile(x, y);
if (!support.IsNone)
continue; // dunno how to check if the tile can actually have an item put on top of it...
result.Add($"{x:000},{y:000}");
}
}
return result;
}
public bool IsOccupied(int relX, int relY) => !Layer0.GetTile(relX, relY).IsNone || !Layer1.GetTile(relX, relY).IsNone;
public Item GetItem(int relX, int relY, bool baseLayer)
{
var layer = baseLayer ? Layer0 : Layer1;
return layer.GetTile(relX, relY);
}
public void SetItem(int relX, int relY, bool baseLayer, Item value)
{
var layer = baseLayer ? Layer0 : Layer1;
layer[relX, relY] = value;
}
}

View File

@@ -0,0 +1,414 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace NHSE.Core;
public abstract record LayerItem : AcreSelectionGrid
{
/// <summary>
/// All items in this layer, stored in column-major order.
/// </summary>
public Item[] Tiles { get; }
#pragma warning disable CA1857
protected LayerItem(Item[] tiles, [ConstantExpected] byte w, [ConstantExpected] byte h) : this(tiles, new(w, h, w, h))
#pragma warning restore CA1857
{
}
protected LayerItem(Item[] tiles, TileGridViewport tileTileInfo) : base(tileTileInfo)
{
Tiles = tiles;
Debug.Assert(TileInfo.TotalWidth * TileInfo.TotalHeight == tiles.Length);
}
/// <summary>
/// Retrieves the tile at the specified relative coordinates, or a default value if the coordinates are outside the
/// valid layer bounds.
/// </summary>
/// <param name="relX">The requested tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The requested tile's Y-coordinate, relative to the layer origin.</param>
/// <returns>The tile at the specified coordinates if they are within the layer; otherwise, a value indicating no item.</returns>
public Item GetTileSafe(in int relX, in int relY)
{
if (!Contains(relX, relY))
return Item.NO_ITEM;
return GetTile(relX, relY);
}
/// <summary>
/// Sets the tile at the specified relative coordinates if they are within the valid layer bounds.
/// </summary>
/// <param name="relX">The requested tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The requested tile's Y-coordinate, relative to the layer origin.</param>
/// <param name="tile">The tile to set at the specified coordinates.</param>
/// <returns>Returns true if the tile was set, false if the coordinates were out of bounds.</returns>
public bool SetTileSafe(in int relX, in int relY, Item tile)
{
if (!Contains(relX, relY))
return false;
SetTile(relX, relY, tile);
return true;
}
/// <summary>
/// Dumps the contents of an acre at the specified relative coordinates into a byte array.
/// </summary>
/// <param name="relX">The requested tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The requested tile's Y-coordinate, relative to the layer origin.</param>
/// <returns>A byte array containing the serialized data for the specified acre. The array length is determined by the number
/// of tiles in the view and the size of each item.</returns>
public byte[] DumpAcre(int relX, int relY)
{
int count = TileInfo.ViewCount;
var result = new byte[Item.SIZE * count];
DumpAcre(result, relX, relY);
return result;
}
/// <summary>
/// Writes the serialized data for an acre of tiles into the specified buffer, starting at the given relative X and Y coordinates.
/// </summary>
/// <remarks>
/// If a tile at the specified coordinates is out of range, a default item is written for that position.
/// The data is written in column-major order, with columns stored before rows.
/// </remarks>
/// <param name="result">The buffer that receives the serialized bytes for the acre. Must be large enough to hold all tile data.</param>
/// <param name="relX">The top-left tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The top-left tile's Y-coordinate, relative to the layer origin.</param>
public void DumpAcre(Span<byte> result, int relX, int relY)
{
// Store columns first. If the x,y is out of range, store default item.
int offset = 0;
for (int y = 0; y < TileInfo.ViewHeight; y++)
{
var tileY = relY + y;
for (int x = 0; x < TileInfo.ViewWidth; x++)
{
var tileX = relX + x;
var tile = GetTileSafe(tileX, tileY);
tile.ToBytesClass().CopyTo(result[offset..]);
offset += Item.SIZE;
}
}
}
/// <summary>
/// Imports a block of tile data into the map at the specified relative coordinates.
/// </summary>
/// <remarks>The method attempts to set each tile in the specified region. Only tiles that are valid and
/// within the map bounds are imported; others are ignored. The return value indicates how many tiles were actually
/// updated.</remarks>
/// <param name="data">A read-only span of bytes containing the tile data to import. The data must be formatted as expected by the tile
/// array parser.</param>
/// <param name="relX">The top-left tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The top-left tile's Y-coordinate, relative to the layer origin.</param>
/// <returns>The number of tiles that were successfully imported and set in the map.</returns>
public int ImportAcre(ReadOnlySpan<byte> data, int relX, int relY)
{
int count = 0;
int i = 0;
var tiles = Item.GetArray(data);
for (int y = 0; y < TileInfo.ViewHeight; y++)
{
var tileY = relY + y;
for (int x = 0; x < TileInfo.ViewWidth; x++)
{
var tileX = relX + x;
if (SetTileSafe(tileX, tileY, tiles[i++]))
count++;
}
}
return count;
}
/// <summary>
/// Retrieves the tile at the specified relative coordinates.
/// </summary>
/// <param name="relX">The X-coordinate of the tile, relative to the layer origin.</param>
/// <param name="relY">The Y-coordinate of the tile, relative to the layer origin.</param>
/// <returns>The tile at the specified coordinates.</returns>
public Item GetTile(in int relX, in int relY) => this[TileInfo.GetTileIndex(relX, relY)];
/// <summary>
/// Sets the tile at the specified relative X and Y coordinates to the given tile value.
/// </summary>
/// <param name="relX">The X-coordinate of the tile, relative to the layer origin.</param>
/// <param name="relY">The Y-coordinate of the tile, relative to the layer origin.</param>
/// <param name="tile">The tile value to assign at the specified coordinates.</param>
public void SetTile(in int relX, in int relY, Item tile) => this[TileInfo.GetTileIndex(relX, relY)] = tile;
public Item this[int index]
{
get => Tiles[index];
set => Tiles[index] = value;
}
/// <summary>
/// Serializes all tiles into a single byte array.
/// </summary>
/// <returns>
/// A byte array containing the serialized data of all tiles, concatenated in order.
/// The length of the array is equal to the number of tiles multiplied by the size of each item.
/// </returns>
public byte[] DumpAll()
{
var result = new byte[Tiles.Length * Item.SIZE];
DumpAll(result);
return result;
}
/// <summary>
/// Writes the byte representation of all tiles to the specified buffer.
/// </summary>
/// <param name="result">A buffer that receives the serialized bytes of all tiles.</param>
public void DumpAll(Span<byte> result)
{
for (int i = 0; i < Tiles.Length; i++)
Tiles[i].ToBytesClass().CopyTo(result[(i * Item.SIZE)..]);
}
/// <summary>
/// Imports all tiles from the provided byte data into the layer.
/// </summary>
/// <param name="data">A read-only span of bytes containing the tile data to import.</param>
public void ImportAll(ReadOnlySpan<byte> data)
{
var tiles = Item.GetArray(data);
for (int i = 0; i < tiles.Length; i++)
Tiles[i].CopyFrom(tiles[i]);
}
/// <summary>
/// Removes all items within the specified rectangular region that match the given criteria.
/// </summary>
/// <remarks>
/// The method evaluates the criteria for each item in the specified region and removes only those items for which the criteria returns <see langword="true"/>.
/// Items outside the specified region are not affected.
/// </remarks>
/// <param name="xmin">The relative x-coordinate of the upper-left corner of the region to search.</param>
/// <param name="ymin">The relative y-coordinate of the upper-left corner of the region to search.</param>
/// <param name="width">The width, in tiles, of the region to search. Must be greater than or equal to 0.</param>
/// <param name="height">The height, in tiles, of the region to search. Must be greater than or equal to 0.</param>
/// <param name="criteria">
/// A function that defines the condition each item must satisfy to be removed.
/// The function is invoked for each item in the region.
/// </param>
/// <returns>The number of items that were removed.</returns>
public int RemoveAll(in int xmin, in int ymin, in int width, in int height, Func<Item, bool> criteria)
{
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (!criteria(t))
continue;
t.Delete();
count++;
}
}
return count;
}
/// <summary>
/// Deletes all extension tiles associated with the specified tile at the given coordinates, except for the main (root) tile itself.
/// </summary>
/// <remarks>
/// This method removes only the extension tiles that are part of the same logical group as the specified main tile,
/// leaving the main tile itself intact.
/// Use this method to clean up extension tiles when the main tile is being modified or removed.
/// </remarks>
/// <param name="tile">The main tile whose extension tiles are to be deleted.</param>
/// <param name="x">The relative x-coordinate of the main (root) tile.</param>
/// <param name="y">The relative y-coordinate of the main (root) tile.</param>
public void DeleteExtensionTiles(Item tile, int x, int y)
{
GetTileWidthHeight(tile, x, y, out var w, out var h);
for (int ix = 0; ix < w; ix++)
{
for (int iy = 0; iy < h; iy++)
{
if (iy == 0 && ix == 0)
continue;
var t = GetTile(x + ix, y + iy);
t.Delete();
}
}
}
/// <summary>
/// Marks all tiles covered by the specified multi-tile item, except the origin tile,
/// as extension tiles associated with the given item at the specified coordinates.
/// </summary>
/// <remarks>
/// This method is typically used when placing a multi-tile item to ensure that all tiles it occupies,
/// except for the origin, are correctly marked as extensions.
/// The origin tile at the specified coordinates is not modified by this method.</remarks>
/// <param name="tile">The multi-tile item whose extension tiles are to be set.</param>
/// <param name="x">The relative x-coordinate of the main (root) tile.</param>
/// <param name="y">The relative y-coordinate of the main (root) tile.</param>
public void SetExtensionTiles(Item tile, in int x, in int y)
{
GetTileWidthHeight(tile, x, y, out var w, out var h);
for (byte ix = 0; ix < w; ix++)
{
for (byte iy = 0; iy < h; iy++)
{
if (iy == 0 && ix == 0)
continue;
var t = GetTile(x + ix, y + iy);
t.SetAsExtension(tile, ix, iy);
}
}
}
private void GetTileWidthHeight(Item tile, int x, int y, out int w, out int h)
{
var type = ItemInfo.GetItemSize(tile);
w = type.Width;
h = type.Height;
// Rotation
if ((tile.Rotation & 1) == 1)
(w, h) = (h, w);
// Clamp to grid bounds
if (x + w - 1 >= TileInfo.TotalWidth)
w = TileInfo.TotalWidth - x;
if (y + h - 1 >= TileInfo.TotalHeight)
h = TileInfo.TotalHeight - y;
}
/// <summary>
/// Checks if writing the <see cref="tile"/> at the specified <see cref="x"/> and <see cref="y"/> coordinates will overlap with any existing tiles.
/// </summary>
/// <returns>True if any tile will be overwritten, false if nothing is there.</returns>
public PlacedItemPermission IsOccupied(Item tile, in int x, in int y)
{
var type = ItemInfo.GetItemSize(tile);
var w = type.Width;
var h = type.Height;
if ((tile.Rotation & 1) == 1)
(w, h) = (h, w);
if (x + w - 1 >= TileInfo.TotalWidth)
return PlacedItemPermission.OutOfBounds;
if (y + h - 1 >= TileInfo.TotalHeight)
return PlacedItemPermission.OutOfBounds;
for (byte ix = 0; ix < w; ix++)
{
for (byte iy = 0; iy < h; iy++)
{
var t = GetTile(x + ix, y + iy);
if (!t.IsNone)
return PlacedItemPermission.Collision;
}
}
return PlacedItemPermission.NoCollision;
}
/// <summary>
/// Replaces all occurrences of a specified item with a new item within the defined rectangular region.
/// </summary>
/// <remarks>
/// Only root tiles that exactly match <paramref name="oldItem"/> are replaced.
/// No replacements are made if the item sizes differ.
/// </remarks>
/// <param name="oldItem">The item to search for and replace within the specified region.</param>
/// <param name="newItem">The item to use as a replacement for each occurrence of <paramref name="oldItem"/>.</param>
/// <param name="xmin">The relative x-coordinate of the upper-left corner of the region in which to perform replacements.</param>
/// <param name="ymin">The relative y-coordinate of the upper-left corner of the region in which to perform replacements.</param>
/// <param name="width">The width, in tiles, of the region in which to perform replacements. Must be greater than zero.</param>
/// <param name="height">The height, in tiles, of the region in which to perform replacements. Must be greater than zero.</param>
/// <returns>
/// The number of items replaced within the specified region,
/// or -1 if <paramref name="oldItem"/> and <paramref name="newItem"/> are incompatible.
/// </returns>
public int ReplaceAll(Item oldItem, Item newItem, in int xmin, in int ymin, in int width, in int height)
{
var sizeOld = ItemInfo.GetItemSize(oldItem);
var sizeNew = ItemInfo.GetItemSize(newItem);
if (sizeOld != sizeNew)
return -1;
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (!t.IsRoot)
continue;
if (!t.Equals(oldItem))
continue;
DeleteExtensionTiles(t, x, y);
t.CopyFrom(newItem);
SetExtensionTiles(t, x, y);
count++;
}
}
return count;
}
/// <summary>
/// Removes all invalid or dangling extensions within the specified rectangular region.
/// </summary>
/// <remarks>
/// Only extension tiles that are determined to be invalid or dangling are removed.
/// Valid extension tiles within the region are not changed.
/// </remarks>
/// <param name="xmin">The relative x-coordinate of the upper-left corner of the region in which to perform replacements.</param>
/// <param name="ymin">The relative y-coordinate of the upper-left corner of the region in which to perform replacements.</param>
/// <param name="width">The width of the region, in tiles. Must be greater than zero.</param>
/// <param name="height">The height of the region, in tiles. Must be greater than zero.</param>
/// <returns>The number of extensions that were removed from the specified region.</returns>
public int ClearDanglingExtensions(in int xmin, in int ymin, in int width, in int height)
{
int count = 0;
for (int x = xmin; x < xmin + width; x++)
{
for (int y = ymin; y < ymin + height; y++)
{
var t = GetTile(x, y);
if (IsValidExtension(t, x, y))
continue;
t.Delete();
count++;
}
}
return count;
}
private bool IsValidExtension(Item t, int x, int y)
{
if (!t.IsExtension)
return true;
var parentX = x - t.ExtensionX;
var parentY = y - t.ExtensionY;
try
{
var parent = GetTile(parentX, parentY);
if (parent.ItemId == t.ExtensionItemId)
return true;
}
catch
{
// corrupt?
}
return false;
}
}

View File

@@ -0,0 +1,129 @@
using System.Diagnostics.CodeAnalysis;
namespace NHSE.Core;
/// <summary>
/// Configures how a layer rests within the Map's grid, relative to a "chunk" or "acre".
/// </summary>
/// <param name="CountWidth">Number of acres in the width direction.</param>
/// <param name="CountHeight">Number of acres in the height direction.</param>
/// <param name="ShiftWidth">Horizontal acre shift from the map's origin.</param>
/// <param name="ShiftHeight">Vertical acre shift from the map's origin.</param>
/// <param name="TilesPerAcre">Number of tiles per acre in one dimension (16 or 32).</param>
/// <param name="TileBitShift">Bit shift value to convert between tiles and acres (4 for 16 tiles, 5 for 32 tiles).</param>
/// <param name="MetaTileSize">Size of tile compared to the smallest tile possible (2 for 16 tiles, 1 for 32 tiles).</param>
public readonly record struct LayerPositionConfig(
byte CountWidth, byte CountHeight,
byte ShiftWidth, byte ShiftHeight,
[ConstantExpected] byte TilesPerAcre, byte TileBitShift,
[ConstantExpected(Max = 2, Min = 1)] byte MetaTileSize)
{
// Maps in Animal Crossing: New Horizons are made up of acres that are 9 tiles wide and 8 tiles high.
// 5 columns in the center are land, surrounded by 2 tiles of beach and 2 tiles of sea on each side.
// 4 rows in the center are land, surrounded by 2 rows of beach and 2 rows of sea on each side.
// +-----------+
// | ~~~~~~~~~ |
// | ~*******~ |
// | ~*=====*~ |
// | ~*=====*~ |
// | ~*=====*~ |
// | ~*=====*~ |
// | ~*******~ |
// | ~~~~~~~~~ |
// +-----------+
// Main Island Map Config - True Dimensions
private const byte MapAcreWidth = 9; // 2 sea, 2 beach, 5 land
private const byte MapAcreHeight = 8; // 2 sea, 2 beach, 4 land
// Optimize some calculations away by using bit-shift instead of mul/div, as we're always a multiple of 2.
private const byte Grid32 = 32;
private const byte Grid16 = 16;
private const byte Shift32 = 5; // div32 is same as sh 5
private const byte Shift16 = 4; // div16 is same as sh 4
/// <summary>
/// Creates a new <see cref="LayerPositionConfig"/> instance, centering the layer within the acre.
/// </summary>
/// <param name="width">Width of the layer in acres.</param>
/// <param name="height">Height of the layer in acres.</param>
/// <param name="tilesPerAcre">Number of tiles per acre (16 or 32).</param>
/// <param name="metaTileSize">Size of tile compared to the smallest tile possible (2 for 16 tiles, 1 for 32 tiles).</param>
/// <param name="shiftW">Acre-wise horizontal shift from the map origin.</param>
/// <param name="shiftH">Acre-wise vertical shift from the map origin.</param>
/// <returns>A new <see cref="LayerPositionConfig"/> instance.</returns>
public static LayerPositionConfig Create(byte width, byte height,
[ConstantExpected(Min = Grid16, Max = Grid32)] byte tilesPerAcre,
[ConstantExpected(Min = 1, Max = 2)] byte metaTileSize, byte shiftW, byte shiftH)
{
var bitShift = tilesPerAcre == Grid16 ? Shift16 : Shift32;
#pragma warning disable CA1857
return new LayerPositionConfig(width, height, shiftW, shiftH, tilesPerAcre, bitShift, metaTileSize);
#pragma warning restore CA1857
}
/// <summary>
/// Calculates the absolute map coordinates based on the specified relative X and Y coordinates within the layer.
/// </summary>
/// <param name="relX">The relative X-coordinate within the layer.</param>
/// <param name="relY">The relative Y-coordinate within the layer.</param>
public (int X, int Y) GetCoordinatesAbsolute(int relX, int relY)
{
var absX = relX + ((ShiftWidth * MetaTileSize) << TileBitShift);
var absY = relY + ((ShiftHeight * MetaTileSize) << TileBitShift);
return (absX, absY);
}
/// <summary>
/// Gets the absolute coordinates of the layer's origin (0,0) in the map.
/// </summary>
public (int X, int Y) GetCoordinatesAbsolute() => GetCoordinatesAbsolute(0, 0);
/// <summary>
/// Calculates the relative coordinates within the layer, based on the specified absolute X and Y coordinates.
/// </summary>
/// <param name="absX">The absolute X coordinate to convert.</param>
/// <param name="absY">The absolute Y coordinate to convert.</param>
/// <returns>A tuple containing the X and Y coordinates relative to the layer.</returns>
public (int X, int Y) GetCoordinatesRelative(int absX, int absY)
{
var relX = absX - ((ShiftWidth * MetaTileSize) << TileBitShift);
var relY = absY - ((ShiftHeight * MetaTileSize) << TileBitShift);
return (relX, relY);
}
/// <summary>
/// Determines whether the specified absolute X and Y coordinates are within the valid bounds of the map.
/// </summary>
/// <param name="relX">The absolute X coordinate to validate. Must be within the horizontal bounds of the map.</param>
/// <param name="relY">The absolute Y coordinate to validate. Must be within the vertical bounds of the map.</param>
/// <returns><see langword="true"/> if the coordinates are valid; otherwise, <see langword="false"/>.</returns>
public bool IsCoordinateValidRelative(int relX, int relY)
{
if ((uint)relX >= CountWidth << TileBitShift)
return false;
if ((uint)relY >= CountHeight << TileBitShift)
return false;
return true;
}
/// <summary>
/// Layer total width in tiles.
/// </summary>
public int LayerTotalWidth => CountWidth * TilesPerAcre;
/// <summary>
/// Layer total height in tiles.
/// </summary>
public int LayerTotalHeight => CountHeight * TilesPerAcre;
/// <summary>
/// Gets the total width of the map, in tiles.
/// </summary>
public int MapTotalWidth => MapAcreWidth * TilesPerAcre;
/// <summary>
/// Gets the total height of the map, in tiles.
/// </summary>
public int MapTotalHeight => MapAcreHeight * TilesPerAcre;
}

View File

@@ -3,27 +3,27 @@
namespace NHSE.Core;
public class RoomItemLayer : ItemLayer
public sealed record LayerRoomItem : LayerItem
{
public const int SIZE = Width * Height * Item.SIZE;
private const int Width = 20;
private const int Height = 20;
private const byte Width = 20;
private const byte Height = 20;
public RoomItemLayer(ReadOnlySpan<byte> data) : this(Item.GetArray(data)) { }
public RoomItemLayer(Item[] tiles) : base(tiles, Width, Height) { }
public LayerRoomItem(ReadOnlySpan<byte> data) : this(Item.GetArray(data)) { }
public LayerRoomItem(Item[] tiles) : base(tiles, Width, Height) { }
public static RoomItemLayer[] GetArray(ReadOnlySpan<byte> data)
public static LayerRoomItem[] GetArray(ReadOnlySpan<byte> data)
{
var result = new RoomItemLayer[data.Length / SIZE];
var result = new LayerRoomItem[data.Length / SIZE];
for (int i = 0; i < result.Length; i++)
{
var slice = data.Slice(i * SIZE, SIZE);
result[i] = new RoomItemLayer(slice);
result[i] = new LayerRoomItem(slice);
}
return result;
}
public static byte[] SetArray(IReadOnlyList<RoomItemLayer> data)
public static byte[] SetArray(IReadOnlyList<LayerRoomItem> data)
{
var result = new byte[data.Count * SIZE];
for (int i = 0; i < data.Count; i++)

View File

@@ -0,0 +1,306 @@
using System;
using System.Diagnostics;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
/// <summary>
/// Grid of <see cref="TerrainTile"/>
/// </summary>
public sealed record LayerTerrain : AcreSelectionGrid
{
/// <summary>
/// Terrain tiles in this layer, stored in column-major order.
/// </summary>
public TerrainTile[] Tiles { get; }
/// <summary>
/// Gets the underlying memory buffer containing the base acre template data (such as sea, beach, interior).
/// </summary>
public Memory<byte> BaseAcres { get; }
/// <summary>
/// 16x16 tiles per acre.
/// </summary>
public const byte TilesPerAcreDim = 16;
/// <summary>
/// Interior acre-count width (without the deep-sea border).
/// </summary>
private const byte CountAcreWidth = 7;
/// <summary>
/// Interior acre-count height (without the deep-sea border).
/// </summary>
private const byte CountAcreHeight = 6;
private static TileGridViewport Viewport => new(TilesPerAcreDim, TilesPerAcreDim, CountAcreWidth, CountAcreHeight);
public LayerTerrain(TerrainTile[] tiles, Memory<byte> acres) : base(Viewport)
{
BaseAcres = acres;
Tiles = tiles;
Debug.Assert(TileInfo.TotalCount == tiles.Length);
}
/// <summary>
/// Gets the terrain tile at the specified coordinates.
/// </summary>
/// <param name="relX">The requested tile's X-coordinate, relative to the layer origin.</param>
/// <param name="relY">The requested tile's Y-coordinate, relative to the layer origin.</param>
/// <remarks>
/// An exception is thrown if the requested coordinates are out of the layer's range.
/// </remarks>
public TerrainTile GetTile(int relX, int relY) => this[TileInfo.GetTileIndex(relX, relY)];
private TerrainTile GetTileSafe(int relX, int relY)
{
if (Contains(relX, relY))
return new TerrainTile();
return GetTile(relX, relY);
}
private bool SetTileSafe(int relX, int relY, TerrainTile tile)
{
if (Contains(relX, relY))
return false;
GetTile(relX, relY).CopyFrom(tile);
return true;
}
public TerrainTile this[int index]
{
get => Tiles[index];
set => Tiles[index] = value;
}
/// <summary>
/// Flattens the terrain tiles into a contiguous byte array.
/// </summary>
public byte[] DumpAll() => TerrainTile.SetArray(Tiles);
/// <summary>
/// Imports terrain tiles from a contiguous byte array.
/// </summary>
/// <param name="data">Byte array containing terrain tile data.</param>
public void ImportAll(ReadOnlySpan<byte> data)
{
var tiles = TerrainTile.GetArray(data);
for (int i = 0; i < tiles.Length; i++)
Tiles[i].CopyFrom(tiles[i]);
}
/// <summary>
/// Retrieves the serialized data for the acre at the specified relative coordinates.
/// </summary>
/// <param name="relX">The relative X-coordinate of the top-left tile of the acre to dump.</param>
/// <param name="relY">The relative Y-coordinate of the top-left tile of the acre to dump.</param>
/// <returns>
/// A byte array containing the serialized terrain data of the specified acre.
/// The array length is determined by the terrain tile size and the view count.
/// </returns>
public byte[] DumpAcre(int relX, int relY)
{
int count = TileInfo.ViewCount;
var result = new byte[TerrainTile.SIZE * count];
DumpAcre(result, relX, relY);
return result;
}
/// <summary>
/// Writes the serialized data for an acre of tiles, starting at the specified relative coordinates, into the provided buffer.
/// </summary>
/// <remarks>
/// If a tile at the specified coordinates is out of range, a default value is written for that tile.
/// The data is written in column-major order, with each tile's bytes written sequentially into the buffer.
/// </remarks>
/// <param name="result">
/// The buffer that receives the serialized tile data.
/// Must be large enough to hold the data for the entire acre.
/// </param>
/// <param name="relX">The relative X-coordinate of the top-left tile of the acre to dump.</param>
/// <param name="relY">The relative Y-coordinate of the top-left tile of the acre to dump.</param>
public void DumpAcre(Span<byte> result, int relX, int relY)
{
// Store columns first. If the x,y is out of range, store default.
int offset = 0;
for (int y = 0; y < TileInfo.ViewHeight; y++)
{
var tileY = relY + y;
for (int x = 0; x < TileInfo.ViewWidth; x++)
{
var tileX = relX + x;
var tile = GetTileSafe(tileX, tileY);
tile.ToBytesClass().CopyTo(result[offset..]);
offset += Item.SIZE;
}
}
}
/// <summary>
/// Imports terrain tile data into the layer at the specified relative coordinates.
/// </summary>
/// <param name="data">
/// A read-only span of bytes containing the serialized terrain tile data to import.
/// The data must be in the format expected by TerrainTile.GetArray.
/// </param>
/// <param name="relX">The X-coordinate, relative to the layer origin, where the imported tiles will be placed.</param>
/// <param name="relY">The Y-coordinate, relative to the layer origin, where the imported tiles will be placed.</param>
/// <returns>The number of tiles successfully imported. Returns 0 if no tiles were imported.</returns>
public int ImportAcre(ReadOnlySpan<byte> data, int relX, int relY)
{
int count = 0;
int i = 0;
var tiles = TerrainTile.GetArray(data);
for (int y = 0; y < TileInfo.ViewHeight; y++)
{
var tileY = relY + y;
for (int x = 0; x < TileInfo.ViewWidth; x++)
{
var tileX = relX + x;
SetTileSafe(tileX, tileY, tiles[i++]);
}
}
return count;
}
/// <summary>
/// Sets all tiles to the specified tile.
/// </summary>
/// <param name="tile"></param>
/// <param name="interiorOnly">If true, only sets the interior tiles, skipping the outermost ring of beach/rock acres.</param>
public void SetAll(TerrainTile tile, in bool interiorOnly = true)
{
if (interiorOnly)
{
// skip outermost ring of tiles
int xmin = TileInfo.ViewWidth;
int ymin = TileInfo.ViewHeight;
int xmax = TileInfo.TotalWidth - TileInfo.ViewWidth;
int ymax = TileInfo.TotalHeight - TileInfo.ViewHeight;
for (int x = xmin; x < xmax; x++)
{
for (int y = ymin; y < ymax; y++)
GetTile(x, y).CopyFrom(tile);
}
}
else
{
foreach (var t in Tiles)
t.CopyFrom(tile);
}
}
/// <summary>
/// Sets all road tiles (not the terrain itself, but the road atop) to the specified tile.
/// </summary>
/// <param name="tile">Road tile info to copy from.</param>
/// <param name="interiorOnly">If true, only sets the interior tiles, skipping the outermost ring of beach/rock acres.</param>
public void SetAllRoad(TerrainTile tile, bool interiorOnly = true)
{
if (interiorOnly)
{
// skip outermost ring of tiles
int xmin = TileInfo.ViewWidth;
int ymin = TileInfo.ViewHeight;
int xmax = TileInfo.TotalWidth - TileInfo.ViewWidth;
int ymax = TileInfo.TotalHeight - TileInfo.ViewHeight;
for (int x = xmin; x < xmax; x++)
{
for (int y = ymin; y < ymax; y++)
GetTile(x, y).CopyRoadFrom(tile);
}
}
else
{
foreach (var t in Tiles)
t.CopyRoadFrom(tile);
}
}
/// <inheritdoc cref="GetTileColor(ushort,int,int,int,int)"/>
public int GetTileColor(int relX, int relY, int insideX, int insideY)
{
var acre = GetAcreTemplate(relX, relY);
return GetTileColor(acre, relX, relY, insideX, insideY);
}
/// <summary>
/// Gets the base acre tile color at the specified terrain coordinates.
/// </summary>
/// <remarks>
/// If the acre has a predefined appearance, that is used; otherwise, the terrain-based appearance is used.
/// </remarks>
/// <param name="acre">Base acre underneath the terrain tile.</param>
/// <param name="relX">Relative X coordinate in terrain tiles.</param>
/// <param name="relY">Relative Y coordinate in terrain tiles.</param>
/// <param name="insideX">Inside X coordinate of the terrain tile (16px max).</param>
/// <param name="insideY">Inside Y coordinate of the terrain tile (16px max).</param>
/// <returns>ARGB color value.</returns>
public int GetTileColor(ushort acre, int relX, int relY, int insideX, int insideY)
{
if (acre != 0) // predefined appearance
{
var c = AcreTileColor.GetAcreTileColor(acre, relX % 16, relY % 16);
if (c != -0x1000000) // transparent
return c;
}
// dynamic (terrain-based) appearance
var tile = GetTile(relX, relY);
return TerrainTileColor.GetTileColor(tile, insideX, insideY).ToArgb();
}
/// <summary>
/// Gets the base acre tile color at the specified terrain coordinates.
/// </summary>
/// <remarks>
/// If the acre has a predefined appearance, that is used; otherwise, the terrain-based appearance is used.
/// </remarks>
/// <param name="acre">Base acre underneath the terrain tile.</param>
/// <param name="tile">Terrain tile to render.</param>
/// <param name="relX">Relative X coordinate in terrain tiles.</param>
/// <param name="relY">Relative Y coordinate in terrain tiles.</param>
/// <param name="insideX">Inside X coordinate of the terrain tile (16px max).</param>
/// <param name="insideY">Inside Y coordinate of the terrain tile (16px max).</param>
/// <returns>ARGB color value.</returns>
public int GetTileColor(ushort acre, TerrainTile tile, int relX, int relY, int insideX, int insideY)
{
// If acre is entirely transparent (interior acre), dynamic (terrain-based) appearance
if (acre == 0)
return TerrainTileColor.GetTileColor(tile, insideX, insideY).ToArgb();
// For beaches, a slim edge is customizable (indicative by a transparent value).
// Check if pre-defined appearance governs
var color = AcreTileColor.GetAcreTileColor(acre, relX % 16, relY % 16);
if (color == -0x1000000) // transparent (dynamic)
return TerrainTileColor.GetTileColor(tile, insideX, insideY).ToArgb();
return color; // pre-defined appearance
}
/// <summary>
/// Gets the base acre template at the specified terrain coordinates.
/// </summary>
/// <param name="relX">Relative X coordinate in terrain tiles.</param>
/// <param name="relY">Relative Y coordinate in terrain tiles.</param>
/// <returns>Base acre underneath the terrain tile.</returns>
public ushort GetAcreTemplate(int relX, int relY)
{
// Acres are 16x16 tiles, and the acre data has a 1-acre deep-sea border around it.
var acreX = 1 + (relX / 16);
var acreY = 1 + (relY / 16);
var acreIndex = ((CountAcreWidth + 2) * acreY) + acreX;
var span = GetBaseAcreSpan(acreIndex);
return ReadUInt16LittleEndian(span);
}
/// <summary>
/// Gets the base acre template span at the specified index.
/// </summary>
/// <param name="index">Index of the acre.</param>
/// <returns>Span of bytes representing the base acre template.</returns>
public Span<byte> GetBaseAcreSpan(int index) => BaseAcres.Span.Slice(index * 2, 2);
}

View File

@@ -1,166 +0,0 @@
using System;
using System.Diagnostics;
using static System.Buffers.Binary.BinaryPrimitives;
namespace NHSE.Core;
/// <summary>
/// Grid of <see cref="TerrainTile"/>
/// </summary>
public class TerrainLayer : MapGrid
{
public TerrainTile[] Tiles { get; init; }
public byte[] BaseAcres { get; init; }
public TerrainLayer(TerrainTile[] tiles, byte[] acres) : base(16, 16, AcreWidth * 16, AcreHeight * 16)
{
BaseAcres = acres;
Tiles = tiles;
Debug.Assert(MaxTileCount == tiles.Length);
}
public TerrainTile GetTile(int x, int y) => this[GetTileIndex(x, y)];
public TerrainTile GetTile(int acreX, int acreY, int gridX, int gridY) => this[GetTileIndex(acreX, acreY, gridX, gridY)];
public TerrainTile GetAcreTile(int acreIndex, int tileIndex) => this[GetAcreTileIndex(acreIndex, tileIndex)];
public TerrainTile this[int index]
{
get => Tiles[index];
set => Tiles[index] = value;
}
public byte[] DumpAll()
{
var result = new byte[Tiles.Length * TerrainTile.SIZE];
for (int i = 0; i < Tiles.Length; i++)
Tiles[i].ToBytesClass().CopyTo(result, i * TerrainTile.SIZE);
return result;
}
public byte[] DumpAcre(int acre)
{
int count = GridTileCount;
var result = new byte[TerrainTile.SIZE * count];
for (int i = 0; i < count; i++)
{
var tile = GetAcreTile(acre, i);
var bytes = tile.ToBytesClass();
bytes.CopyTo(result, i * TerrainTile.SIZE);
}
return result;
}
public void ImportAll(ReadOnlySpan<byte> data)
{
var tiles = TerrainTile.GetArray(data);
for (int i = 0; i < tiles.Length; i++)
Tiles[i].CopyFrom(tiles[i]);
}
public void ImportAcre(int acre, ReadOnlySpan<byte> data)
{
int count = GridTileCount;
var tiles = TerrainTile.GetArray(data);
for (int i = 0; i < count; i++)
{
var tile = GetAcreTile(acre, i);
tile.CopyFrom(tiles[i]);
}
}
public void SetAll(TerrainTile tile, in bool interiorOnly)
{
if (interiorOnly)
{
// skip outermost ring of tiles
int xmin = GridWidth;
int ymin = GridHeight;
int xmax = MaxWidth - GridWidth;
int ymax = MaxHeight - GridHeight;
for (int x = xmin; x < xmax; x++)
{
for (int y = ymin; y < ymax; y++)
GetTile(x, y).CopyFrom(tile);
}
}
else
{
foreach (var t in Tiles)
t.CopyFrom(tile);
}
}
public void SetAllRoad(TerrainTile tile, in bool interiorOnly)
{
if (interiorOnly)
{
// skip outermost ring of tiles
int xmin = GridWidth;
int ymin = GridHeight;
int xmax = MaxWidth - GridWidth;
int ymax = MaxHeight - GridHeight;
for (int x = xmin; x < xmax; x++)
{
for (int y = ymin; y < ymax; y++)
GetTile(x, y).CopyRoadFrom(tile);
}
}
else
{
foreach (var t in Tiles)
t.CopyRoadFrom(tile);
}
}
public void GetBuildingCoordinate(ushort bx, ushort by, int scale, out int x, out int y)
{
// Although there is terrain in the Top Row and Left Column, no buildings can be placed there.
// Adjust the building coordinates down-right by an acre.
int buildingShift = GridWidth;
x = (int)(((bx / 2f) - buildingShift) * scale);
y = (int)(((by / 2f) - buildingShift) * scale);
}
public void GetBuildingRelativeCoordinates(int topX, int topY, int acreScale, ushort bx, ushort by, out int relX, out int relY)
{
GetBuildingCoordinate(bx, by, acreScale, out var x, out var y);
relX = x - (topX * acreScale);
relY = y - (topY * acreScale);
}
public bool IsWithinGrid(int acreScale, int relX, int relY)
{
if ((uint)relX >= GridWidth * acreScale)
return false;
if ((uint)relY >= GridHeight * acreScale)
return false;
return true;
}
public int GetTileColor(int x, in int y, int relativeX, int relativeY)
{
var acre = GetTileAcre(x, y);
if (acre != 0)
{
var c = AcreTileColor.GetAcreTileColor(acre, x % 16, y % 16);
if (c != -0x1000000) // transparent
return c;
}
var tile = GetTile(x, y);
return TerrainTileColor.GetTileColor(tile, relativeX, relativeY).ToArgb();
}
private ushort GetTileAcre(int x, int y)
{
var acreX = 1 + (x / 16);
var acreY = 1 + (y / 16);
var acreIndex = ((AcreWidth + 2) * acreY) + acreX;
var ofs = acreIndex * 2;
return ReadUInt16LittleEndian(BaseAcres.AsSpan(ofs));
}
}

View File

@@ -1,99 +0,0 @@
using System.Collections.Generic;
using System.Diagnostics;
namespace NHSE.Core;
/// <summary>
/// Manages the <see cref="Item"/> data for the player's outside overworld.
/// </summary>
public class FieldItemManager
{
/// <summary>
/// Base layer of items
/// </summary>
public readonly FieldItemLayer Layer1;
/// <summary>
/// Layer of items that are supported by <see cref="Layer1"/>
/// </summary>
public readonly FieldItemLayer Layer2;
/// <summary>
/// Reference to the save file that will be updated when <see cref="Save"/> is called.
/// </summary>
public readonly MainSave SAV;
public FieldItemManager(MainSave sav)
{
Layer1 = new FieldItemLayer(sav.GetFieldItemLayer1());
Layer2 = new FieldItemLayer(sav.GetFieldItemLayer2());
SAV = sav;
}
/// <summary>
/// Stores all values for the <see cref="FieldItemManager"/> back to the <see cref="SAV"/>.
/// </summary>
public void Save()
{
SAV.SetFieldItemLayer1(Layer1.Tiles);
SAV.SetFieldItemLayer2(Layer2.Tiles);
SetTileActiveFlags(Layer1, SAV.FieldItemFlag1);
SetTileActiveFlags(Layer2, SAV.FieldItemFlag2);
}
/// <summary>
/// Lists out all coordinates of tiles present in <see cref="Layer2"/> that don't have anything underneath in <see cref="Layer1"/> to support them.
/// </summary>
/// <returns></returns>
public List<string> GetUnsupportedTiles()
{
var result = new List<string>();
for (int x = 0; x < FieldItemLayer.FieldItemWidth; x++)
{
for (int y = 0; y < FieldItemLayer.FieldItemHeight; y++)
{
var tile = Layer2.GetTile(x, y);
if (tile.IsNone)
continue;
var support = Layer1.GetTile(x, y);
if (!support.IsNone)
continue; // dunno how to check if the tile can actually have an item put on top of it...
result.Add($"{x:000},{y:000}");
}
}
return result;
}
private void SetTileActiveFlags(ItemLayer tiles, int ofs)
{
// this doesn't set anything; just diagnostic
// Although the Tiles are arranged y-column (y-x) based, the 'isActive' flags are arranged x-row (x-y) based.
// We can turn the isActive flag off if the item is not a root or the item cannot be animated.
for (int x = 0; x < FieldItemLayer.FieldItemWidth; x++)
{
for (int y = 0; y < FieldItemLayer.FieldItemHeight; y++)
{
var tile = tiles.GetTile(x, y);
var isActive = GetIsActive(ofs, x, y);
if (!isActive)
continue;
bool empty = tile.IsNone;
if (empty)
Debug.WriteLine($"Flag at ({x},{y}) is not a root object.");
}
}
}
public bool GetIsActive(bool baseLayer, int x, int y) => GetIsActive(baseLayer ? SAV.FieldItemFlag1 : SAV.FieldItemFlag2, x, y);
public void SetIsActive(bool baseLayer, int x, int y, bool value) => SetIsActive(baseLayer ? SAV.FieldItemFlag1 : SAV.FieldItemFlag2, x, y, value);
private bool GetIsActive(int ofs, int x, int y) => FlagUtil.GetFlag(SAV.Data, ofs, (y * FieldItemLayer.FieldItemWidth) + x);
private void SetIsActive(int ofs, int x, int y, bool value) => FlagUtil.SetFlag(SAV.Data, ofs, (y * FieldItemLayer.FieldItemWidth) + x, value);
public bool IsOccupied(int x, int y) => !Layer1.GetTile(x, y).IsNone || !Layer2.GetTile(x, y).IsNone;
}

View File

@@ -0,0 +1,129 @@
namespace NHSE.Core;
public sealed class MapEditor
{
/// <summary>
/// Master interactor for mutating the map.
/// </summary>
public required MapMutator Mutator { get; init; }
/// <summary>
/// Amount of pixel upscaling compared to a 1px = 1 tile map.
/// </summary>
public int MapScale { get; set; } = 1;
/// <summary>
/// Amount of pixel upscaling compared to a 1px = 1 tile map.
/// </summary>
public int ViewScale { get; set; } = 16;
/// <summary>
/// Converts an upscaled coordinate to a tile coordinate.
/// </summary>
/// <param name="mX">X coordinate (mouse on upscaled image).</param>
/// <param name="mY">Y coordinate (mouse on upscaled image).</param>
public (int X, int Y) GetCursorCoordinates(in int mX, in int mY)
{
var x = mX / MapScale;
var y = mY / MapScale;
return (x, y);
}
/// <summary>
/// Creates a new instance of the MapEditor class initialized from the specified save file.
/// </summary>
/// <param name="sav">The save file containing the data used to initialize the MapEditor. Cannot be null.</param>
/// <returns>A MapEditor instance populated with data from the provided save file.</returns>
public static MapEditor FromSaveFile(MainSave sav)
=> new() { Mutator = MapMutator.FromSaveFile(sav) };
public int X => Mutator.View.X;
public int Y => Mutator.View.Y;
public LayerTerrain Terrain => Mutator.Manager.LayerTerrain;
public ILayerBuilding Buildings => Mutator.Manager.LayerBuildings;
public ILayerFieldItemSet Items => Mutator.Manager.FieldItems;
/// <summary>
/// Converts building map coordinates to view pixel coordinates.
/// </summary>
/// <param name="relX">Building map X coordinate.</param>
/// <param name="relY">Building map Y coordinate.</param>
/// <returns>View coordinates.</returns>
public (int X, int Y) GetViewCoordinatesBuilding(uint relX, uint relY)
=> GetViewCoordinates((int)relX, (int)relY, Mutator.Manager.ConfigBuildings);
public (int X, int Y) GetViewCoordinatesTerrain(int relX, int relY)
=> GetViewCoordinates(relX, relY, Mutator.Manager.ConfigTerrain);
public (int X, int Y) GetViewCoordinatesFieldItem(int relX, int relY)
=> GetViewCoordinates(relX, relY, Mutator.Manager.ConfigItems);
public (int X, int Y) GetViewCoordinates(int posX, int posY, LayerPositionConfig shifter)
{
// Get absolute coordinates from the layer.
var (x, y) = shifter.GetCoordinatesAbsolute(posX, posY);
// Shift to view coordinates
x -= X;
y -= Y;
// Scale to pixel coordinates
x *= ViewScale;
y *= ViewScale;
return (x, y);
}
/// <summary>
/// From a map pixel coordinate (<see cref="MapScale"/>), get the clamped map tile coordinate.
/// </summary>
/// <param name="x">Upscaled Map pixel X coordinate.</param>
/// <param name="y">Upscaled Map pixel Y coordinate.</param>
/// <param name="type">Option to adjust the coordinates to a desired type.</param>
/// <returns></returns>
public (int X, int Y) GetMapCoordinates(int x, int y, MapViewCoordinateRequest type)
{
x /= MapScale;
y /= MapScale;
if (x < 0) x = 0;
if (y < 0) y = 0;
// Adjust the view coordinate
if (type == MapViewCoordinateRequest.Centered)
{
// Reticle size is GridWidth, center = /2
var shift = (LayerFieldItem.TilesPerAcreDim * MapScale) / 2;
x -= shift;
y -= shift;
}
else if (type == MapViewCoordinateRequest.SnapAcre)
{
// Snap to the nearest acre
x -= x % LayerFieldItem.TilesPerAcreDim;
y -= y % LayerFieldItem.TilesPerAcreDim;
}
var view = Mutator.View;
// Clamp to viewport dimensions, and center to nearest acre if desired.
// Clamp to boundaries so that we always have a full grid to view.
return view.EnforceEdgeBuffer(x, y);
}
}
public enum MapViewCoordinateRequest
{
/// <summary>
/// No adjustment to the coordinates.
/// </summary>
None = 0,
/// <summary>
/// Snap the coordinates to the nearest acre boundary.
/// </summary>
SnapAcre,
/// <summary>
/// Center the view around the requested (x,y).
/// </summary>
Centered,
}

View File

@@ -0,0 +1,16 @@
namespace NHSE.Core;
public sealed class MapTileManager
{
public required LayerPositionConfig ConfigTerrain { get; init; }
public required LayerPositionConfig ConfigItems { get; init; }
public required LayerPositionConfig ConfigBuildings { get; init; }
public required ILayerFieldItemSet FieldItems { get; init; }
public required ILayerFieldItemFlag LayerItemFlag0 { get; init; }
public required ILayerFieldItemFlag LayerItemFlag1 { get; init; }
public required ILayerBuilding LayerBuildings { get; init; }
public required MapStatePlaza Plaza { get; init; }
public required LayerTerrain LayerTerrain { get; set; }
}

View File

@@ -0,0 +1,98 @@
using System;
namespace NHSE.Core;
public sealed record MapMutator
{
public MapViewState View { get; init; } = new();
public required MapTileManager Manager { get; init; }
// Mutability State Tracking
public uint ItemLayerIndex { get; set => field = value & 1; }
public LayerFieldItem CurrentLayer => ItemLayerIndex == 0 ? Manager.FieldItems.Layer0 : Manager.FieldItems.Layer1;
/// <inheritdoc cref="ModifyFieldItems(Func{int,int,int,int,int},in bool,LayerFieldItem)"/>
public int ModifyFieldItems(Func<int, int, int, int, int> action, in bool wholeMap)
=> ModifyFieldItems(action, wholeMap, CurrentLayer);
/// <inheritdoc cref="ReplaceFieldItems(Item,Item,bool,LayerFieldItem)"/>
public int ReplaceFieldItems(Item oldItem, Item newItem, in bool wholeMap)
=> ReplaceFieldItems(oldItem, newItem, wholeMap, CurrentLayer);
/// <summary>
/// Modifies field items in the specified <paramref name="layerField"/> using the provided <paramref name="action"/> function.
/// </summary>
/// <param name="action">Range selector (xmin, ymin, width, height) to use.</param>
/// <param name="wholeMap">If true, the modification is applied across the entire map; otherwise, only within the current view.</param>
/// <param name="layerField">The layer field item to perform the modification on.</param>
/// <returns>The number of items modified.</returns>
public int ModifyFieldItems(Func<int, int, int, int, int> action, in bool wholeMap, LayerFieldItem layerField)
{
int xMin, yMin, width, height;
if (wholeMap)
{
(xMin, yMin) = (0, 0);
var info = layerField.TileInfo;
(width, height) = info.DimTotal;
}
else
{
// Convert absolute to relative coordinates
(xMin, yMin) = Manager.ConfigItems.GetCoordinatesRelative(View.X, View.Y);
if (!Manager.ConfigItems.IsCoordinateValidRelative(xMin, yMin))
return 0;
var info = layerField.TileInfo;
(width, height) = info.DimAcre;
}
return action(xMin, yMin, width, height);
}
/// <summary>
/// Replaces all instances of <paramref name="oldItem"/> with <paramref name="newItem"/> in the specified <paramref name="layerField"/>.
/// </summary>
/// <param name="oldItem">Item to be replaced.</param>
/// <param name="newItem">Item to replace with.</param>
/// <param name="wholeMap">If true, the replacement is done across the entire map; otherwise, only within the current view.</param>
/// <param name="layerField">The layer field item to perform the replacement on.</param>
/// <returns>The number of items replaced.</returns>
private int ReplaceFieldItems(Item oldItem, Item newItem, bool wholeMap, LayerFieldItem layerField)
{
int xMin, yMin, width, height;
if (wholeMap)
{
(xMin, yMin) = (0, 0);
var info = layerField.TileInfo;
(width, height) = info.DimTotal;
}
else
{
// Convert absolute to relative coordinates
(xMin, yMin) = Manager.ConfigItems.GetCoordinatesRelative(View.X, View.Y);
if (!Manager.ConfigItems.IsCoordinateValidRelative(xMin, yMin))
return 0;
var info = layerField.TileInfo;
(width, height) = info.DimAcre;
}
return layerField.ReplaceAll(oldItem, newItem, xMin, yMin, width, height);
}
/// <summary>
/// Creates a <see cref="MapMutator"/> from the provided <see cref="MainSave"/> file.
/// </summary>
/// <param name="sav">The save file containing the data used to initialize the MapMutator. Cannot be null.</param>
/// <returns>A MapMutator instance populated with data from the provided save file.</returns>
public static MapMutator FromSaveFile(MainSave sav)
=> new() { Manager = MapTileManagerUtil.FromSaveFile(sav) };
/// <summary>
/// Creates a separate view-mutator with shared map objects.
/// </summary>
public MapMutator CreateCopy() => this with
{
View = View with { },
};
}

View File

@@ -0,0 +1,18 @@
namespace NHSE.Core;
/// <summary>
/// Value storage for plaza position in the map.
/// </summary>
public sealed class MapStatePlaza
{
/// <summary>
/// Plaza Position X coordinate.
/// </summary>
public required uint X { get; set; }
/// <summary>
/// Plaza Position Z coordinate.
/// </summary>
public required uint Z { get; set; }
}

View File

@@ -0,0 +1,42 @@
namespace NHSE.Core;
public static class MapTileManagerUtil
{
/// <summary>
/// Retrieves a <see cref="MapTileManager"/> from the provided <see cref="MainSave"/>.
/// </summary>
public static MapTileManager FromSaveFile(MainSave sav) => new()
{
ConfigTerrain = LayerPositionConfig.Create(7, 6, 16, 1, 1, 1),
ConfigBuildings = LayerPositionConfig.Create(9, 7, 16, 1, 0, 0),
ConfigItems = LayerPositionConfig.Create(sav.FieldItemAcreWidth, sav.FieldItemAcreHeight, 32, 1,
(byte)((9 - sav.FieldItemAcreWidth) / 2), 1),
LayerTerrain = new LayerTerrain(sav.GetTerrainTiles(), sav.GetAcreBytes()),
LayerBuildings = new LayerBuilding { Buildings = sav.Buildings },
FieldItems = new LayerFieldItemSet
{
Layer0 = new LayerFieldItem(sav.GetFieldItemLayer0(), sav.FieldItemAcreWidth, sav.FieldItemAcreHeight),
Layer1 = new LayerFieldItem(sav.GetFieldItemLayer1(), sav.FieldItemAcreWidth, sav.FieldItemAcreHeight),
},
LayerItemFlag0 = new LayerFieldItemFlag(sav.FieldItemFlag0Data, sav.FieldItemAcreWidth, sav.FieldItemAcreHeight),
LayerItemFlag1 = new LayerFieldItemFlag(sav.FieldItemFlag1Data, sav.FieldItemAcreWidth, sav.FieldItemAcreHeight),
Plaza = new MapStatePlaza { X = sav.EventPlazaLeftUpX, Z = sav.EventPlazaLeftUpZ },
};
/// <summary>
/// Sets the values from the provided <see cref="MapTileManager"/> into the provided <see cref="MainSave"/>.
/// </summary>
public static void SetManager(this MapTileManager mgr, MainSave sav)
{
sav.Buildings = mgr.LayerBuildings.Buildings;
sav.SetTerrainTiles(mgr.LayerTerrain.Tiles);
sav.SetFieldItemLayer0(mgr.FieldItems.Layer0.Tiles);
sav.SetFieldItemLayer1(mgr.FieldItems.Layer1.Tiles);
mgr.LayerItemFlag0.Save(sav.FieldItemFlag0Data.Span);
mgr.LayerItemFlag1.Save(sav.FieldItemFlag1Data.Span);
sav.SetAcreBytes(mgr.LayerTerrain.BaseAcres.Span);
sav.EventPlazaLeftUpX = mgr.Plaza.X;
sav.EventPlazaLeftUpZ = mgr.Plaza.Z;
}
}

View File

@@ -0,0 +1,154 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace NHSE.Core;
public sealed record MapViewState
{
private const int MaxX = LayerFieldItem.TilesPerAcreDim * AcreCoordinate.CountAcreExteriorWidth;
private const int MaxY = LayerFieldItem.TilesPerAcreDim * AcreCoordinate.CountAcreExteriorHeight;
private const int EdgeBuffer = LayerFieldItem.TilesPerAcreDim;
/// <summary>
/// Amount the X/Y coordinates change when using arrow movement.
/// </summary>
[Range(1, 8)] public uint ArrowViewInterval { get; set; } = 2;
[Range(0, 1)] public int ItemLayerIndex
{
get;
set
{
if (value is not (0 or 1))
throw new ArgumentOutOfRangeException(nameof(value), "Item layer index must be 0 or 1.");
field = value;
}
}
/// <summary>
/// Top-left-origin X coordinate of the view.
/// </summary>
public int X
{
get;
private set
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)value, (uint)MaxX);
field = value;
}
}
/// <summary>
/// Top-left-origin Y coordinate of the view.
/// </summary>
public int Y
{
get;
private set
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual((uint)value, (uint)MaxY);
field = value;
}
}
public bool CanUp => Y != 0;
public bool CanDown => Y != MaxY;
public bool CanLeft => X != 0;
public bool CanRight => X != MaxX;
/// <summary>
/// Moves the view up by <see cref="ArrowViewInterval"/> tiles.
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool ArrowUp()
{
if (!CanUp)
return false;
Y = Math.Max(0, Y - (int)ArrowViewInterval);
return true;
}
/// <summary>
/// Moves the view left by <see cref="ArrowViewInterval"/> tiles.
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool ArrowLeft()
{
if (!CanLeft)
return false;
X = Math.Max(0, X - (int)ArrowViewInterval);
return true;
}
/// <summary>
/// Moves the view right by <see cref="ArrowViewInterval"/> tiles.
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool ArrowRight()
{
if (!CanRight)
return false;
X = Math.Min(MaxX - EdgeBuffer, X + (int)ArrowViewInterval);
return true;
}
/// <summary>
/// Moves the view down by <see cref="ArrowViewInterval"/> tiles.
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool ArrowDown()
{
if (!CanDown)
return false;
Y = Math.Min(MaxY - EdgeBuffer, Y + (int)ArrowViewInterval);
return true;
}
/// <summary>
/// Applies the requested coordinates (sanity checked).
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool SetViewTo(in int absX, in int absY)
{
var (x, y) = (X, Y);
X = Math.Clamp(absX, 0, MaxX - EdgeBuffer);
Y = Math.Clamp(absY, 0, MaxY - EdgeBuffer);
return x != X || y != Y;
}
/// <summary>
/// Drags the view by the specified delta amounts.
/// </summary>
/// <returns><see langword="true"/> if the view changed; otherwise, <see langword="false"/>.</returns>
public bool DragView(int dX, int dY) => SetViewTo(X + dX, Y + dY);
/// <summary>
/// Sets the view to the top-left of the specified acre.
/// </summary>
/// <remarks>
/// Acres are ordered Y-down-first.
/// </remarks>
/// <param name="acre">Acre index to set the view to.</param>
public void SetViewToAcre(int acre)
{
var acreX = acre % (MaxX / EdgeBuffer);
var acreY = acre / (MaxX / EdgeBuffer);
SetViewTo(acreX * EdgeBuffer, acreY * EdgeBuffer);
}
public (int X, int Y) EnforceEdgeBuffer(int x, int y)
{
x = Math.Clamp(x, 0, MaxX - EdgeBuffer);
y = Math.Clamp(y, 0, MaxY - EdgeBuffer);
return (x, y);
}
public bool IsWithinView(int x, int y, int tileStride)
{
if (x < X || x >= X + tileStride)
return false;
if (y < Y || y >= Y + tileStride)
return false;
return true;
}
}

View File

@@ -1,6 +1,6 @@
namespace NHSE.Core;
public enum RoomLayerSurface
public enum RoomLayerSurface : byte
{
Floor = 0,
FloorSupported = 1,

View File

@@ -4,14 +4,18 @@
namespace NHSE.Core;
public class RoomItemManager
public sealed class RoomManager
{
public readonly RoomItemLayer[] Layers;
public readonly LayerRoomItem[] Layers;
public readonly IPlayerRoom Room;
/// <summary>
/// <see cref="RoomLayerSurface"/>
/// </summary>
private const int LayerCount = 8;
public RoomItemManager(IPlayerRoom room)
public RoomManager(IPlayerRoom room)
{
Layers = room.GetItemLayers();
Room = room;
@@ -20,14 +24,14 @@ public RoomItemManager(IPlayerRoom room)
public void Save() => Room.SetItemLayers(Layers);
public bool IsOccupied(int layer, int x, int y)
public bool IsOccupied(RoomLayerSurface layer, int x, int y)
{
if ((uint)layer >= LayerCount)
throw new ArgumentOutOfRangeException(nameof(layer));
var l = Layers[layer];
var l = Layers[(int)layer];
var tile = l.GetTile(x, y);
return !tile.IsNone || (layer == (int)RoomLayerSurface.FloorSupported && IsOccupied((int)RoomLayerSurface.Floor, x, y));
return !tile.IsNone || (layer == RoomLayerSurface.FloorSupported && IsOccupied(RoomLayerSurface.Floor, x, y));
}
public List<string> GetUnsupportedTiles()
@@ -35,9 +39,11 @@ public List<string> GetUnsupportedTiles()
var lBase = Layers[(int)RoomLayerSurface.Floor];
var lSupport = Layers[(int)RoomLayerSurface.FloorSupported];
var result = new List<string>();
for (int x = 0; x < lBase.MaxWidth; x++)
var (width, height) = lBase.TileInfo.DimTotal;
for (int x = 0; x < width; x++)
{
for (int y = 0; y < lBase.MaxHeight; y++)
for (int y = 0; y < height; y++)
{
var tile = lSupport.GetTile(x, y);
if (tile.IsNone)

View File

@@ -1,41 +0,0 @@
namespace NHSE.Core;
/// <summary>
/// Basic logic implementation for interacting with the manipulatable map grid.
/// </summary>
public abstract class MapGrid : TileGrid
{
public static readonly AcreCoordinate[] Acres = AcreCoordinate.GetGrid(AcreWidth, AcreHeight);
public const int AcreWidth = 7;
public const int AcreHeight = 6;
public const int AcreCount = AcreWidth * AcreHeight;
public const int MapTileCount16x16 = 16 * 16 * AcreCount;
public const int MapTileCount32x32 = 32 * 32 * AcreCount;
protected MapGrid(int gw, int gh, int mw, int mh) : base(gw, gh, mw, mh) { }
protected int GetTileIndex(int acreX, int acreY, int gridX, int gridY)
{
var x = (acreX * GridWidth) + gridX;
var y = (acreY * GridHeight) + gridY;
return GetTileIndex(x, y);
}
protected int GetAcreTileIndex(int acreIndex, int tileIndex)
{
var acre = Acres[acreIndex];
var x = (tileIndex % GridWidth);
var y = (tileIndex / GridHeight);
return GetTileIndex(acre.X, acre.Y, x, y);
}
public int GetAcre(int x, int y) => (x / GridWidth) + ((y / GridHeight) * AcreWidth);
public void GetViewAnchorCoordinates(int acre, out int x, out int y)
{
x = (acre % AcreWidth) * GridWidth;
y = (acre / AcreWidth) * GridHeight;
}
}

View File

@@ -1,7 +1,4 @@
using System.Collections.Generic;
using System.Drawing;
namespace NHSE.Core;
namespace NHSE.Core;
public enum OutsideAcre : ushort
{
@@ -223,38 +220,4 @@ public enum OutsideAcre : ushort
FldOutEGarden00 = 315,
FldOutNGardenLFront00 = 316,
FldOutNGardenRFront00 = 317,
}
public static class CollisionUtil
{
public static readonly Dictionary<byte, Color> Dict = new()
{
{00, Color.FromArgb( 70, 120, 64)}, // Grass
{01, Color.FromArgb(128, 215, 195)}, // River
{03, Color.FromArgb(192, 192, 192)}, // Stone
{04, Color.FromArgb(240, 230, 170)}, // Sand
{05, Color.FromArgb(128, 215, 195)}, // Sea
{06, Color.FromArgb(255, 128, 128)}, // Wood
{07, Color.FromArgb(0 , 0, 0)}, // Null
{08, Color.FromArgb(32 , 32, 32)}, // Building
{09, Color.FromArgb(255, 0, 0)}, // ??
{10, Color.FromArgb(48 , 48, 48)}, // Door
{12, Color.FromArgb(128, 215, 195)}, // Water at mouths of river
{15, Color.FromArgb(128, 215, 195)}, // Strip of water between river mouth and river
{22, Color.FromArgb(190, 98, 98)}, // Wood (thin)
{28, Color.FromArgb(255, 0, 0)}, // ?? this one isn't even in ColGroundAttributeParam...
{29, Color.FromArgb(232, 222, 162)}, // Edge of beach, next to sea
{41, Color.FromArgb(118, 122, 132)}, // Rocks at top of map
{42, Color.FromArgb(128, 133, 147)}, // Taller regions, rocks at top of map
{43, Color.Cyan}, // Tide pool
{44, Color.FromArgb( 62, 112, 56)}, // Edge connecting grass and beach
{45, Color.FromArgb(118, 122, 132)}, // Some kind of rock
{46, Color.FromArgb(120, 207, 187)}, // Edge of sea, next to beach
{47, Color.FromArgb(128, 128, 0)}, // Sandstone
{49, Color.FromArgb(190, 98, 98)}, // Pier
{51, Color.FromArgb(32 , 152, 32)}, // "Grass-growing building"??
{70, Color.FromArgb(109, 113, 124)}, // Kapp'n's island rock
{149, Color.FromArgb(179, 207, 252)}, // Ice (traversable)
{150, Color.FromArgb(61 , 119, 212)}, // Ice (tall, with collision)
};
}

View File

@@ -1,47 +0,0 @@
namespace NHSE.Core;
/// <summary>
/// Navigation metadata for acre coordinates.
/// </summary>
public class AcreCoordinate
{
public readonly string Name;
public readonly int X, Y;
public AcreCoordinate(int x, int y) : this((char)('0' + x), (char)('A' + y), x, y) { }
public AcreCoordinate(char xName, char yName, int x, int y)
{
Name = $"{yName}{xName}";
X = x;
Y = y;
}
public static AcreCoordinate[] GetGrid(int width, int height)
{
var result = new AcreCoordinate[width * height];
int i = 0;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
result[i++] = new AcreCoordinate(x, y);
}
return result;
}
public static AcreCoordinate[] GetGridWithExterior(int width, int height)
{
var result = new AcreCoordinate[width * height];
int i = 0;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
var xn = (x == 0) ? '<' : x == width - 1 ? '>' : (char)('0' + x - 1);
var yn = (y == 0) ? '^' : y == height - 1 ? 'V' : (char)('A' + y - 1);
result[i++] = new AcreCoordinate(xn, yn, x, y);
}
}
return result;
}
}

View File

@@ -3,20 +3,20 @@
/// <summary>
/// Flagging various issues when trying to place an item.
/// </summary>
public enum PlacedItemPermission
public enum PlacedItemPermission : byte
{
/// <summary>
/// Item does not have any of its tiles overlapping with any other items.
/// </summary>
NoCollision,
NoCollision = 0,
/// <summary>
/// Item tiles are overlapping with another item.
/// </summary>
Collision,
Collision = 1,
/// <summary>
/// Item tiles would overflow out-of-bounds.
/// </summary>
OutOfBounds,
OutOfBounds = 2,
}

View File

@@ -9,7 +9,7 @@ namespace NHSE.Core;
/// Represents a Terraform-able terrain tile.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public class TerrainTile
public sealed class TerrainTile
{
// tile[2] (u16 model, u16 variation, u16 angle)
// u16 elevation
@@ -76,9 +76,9 @@ public void CopyRoadFrom(TerrainTile tile)
LandMakingAngleRoad = tile.LandMakingAngleRoad;
}
public bool Rotate() => UnitModelRoad != 0 ? RotateRoad() : RotateTerrain();
public bool TryRotate() => UnitModelRoad != 0 ? TryRotateRoad() : TryRotateTerrain();
private bool RotateTerrain()
private bool TryRotateTerrain()
{
if (UnitModel == TerrainUnitModel.Base)
return false;
@@ -88,7 +88,7 @@ private bool RotateTerrain()
return true;
}
private bool RotateRoad()
private bool TryRotateRoad()
{
var rot = LandMakingAngleRoad;
rot = (ushort) ((rot + 1) & 3);

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Drawing;
namespace NHSE.Core;
public static class TileCollisionUtil
{
public static readonly Dictionary<byte, Color> Dict = new()
{
{00, Color.FromArgb( 70, 120, 64)}, // Grass
{01, Color.FromArgb(128, 215, 195)}, // River
{03, Color.FromArgb(192, 192, 192)}, // Stone
{04, Color.FromArgb(240, 230, 170)}, // Sand
{05, Color.FromArgb(128, 215, 195)}, // Sea
{06, Color.FromArgb(255, 128, 128)}, // Wood
{07, Color.FromArgb(0 , 0, 0)}, // Null
{08, Color.FromArgb(32 , 32, 32)}, // Building
{09, Color.FromArgb(255, 0, 0)}, // ??
{10, Color.FromArgb(48 , 48, 48)}, // Door
{12, Color.FromArgb(128, 215, 195)}, // Water at mouths of river
{15, Color.FromArgb(128, 215, 195)}, // Strip of water between river mouth and river
{22, Color.FromArgb(190, 98, 98)}, // Wood (thin)
{28, Color.FromArgb(255, 0, 0)}, // ?? this one isn't even in ColGroundAttributeParam...
{29, Color.FromArgb(232, 222, 162)}, // Edge of beach, next to sea
{41, Color.FromArgb(118, 122, 132)}, // Rocks at top of map
{42, Color.FromArgb(128, 133, 147)}, // Taller regions, rocks at top of map
{43, Color.Cyan}, // Tide pool
{44, Color.FromArgb( 62, 112, 56)}, // Edge connecting grass and beach
{45, Color.FromArgb(118, 122, 132)}, // Some kind of rock
{46, Color.FromArgb(120, 207, 187)}, // Edge of sea, next to beach
{47, Color.FromArgb(128, 128, 0)}, // Sandstone
{49, Color.FromArgb(190, 98, 98)}, // Pier
{51, Color.FromArgb(32 , 152, 32)}, // "Grass-growing building"??
{70, Color.FromArgb(109, 113, 124)}, // Kapp'n's island rock
{149, Color.FromArgb(179, 207, 252)}, // Ice (traversable)
{150, Color.FromArgb(61 , 119, 212)}, // Ice (tall, with collision)
};
}

View File

@@ -1,76 +0,0 @@
using System;
namespace NHSE.Core;
/// <summary>
/// Basic logic implementation for interacting with the manipulatable tile grid.
/// </summary>
/// <remarks>
/// Certain <see cref="TileGrid"/> use this as a viewport on a subsection of the entire tile-set.
/// </remarks>
public abstract class TileGrid
{
/// <summary> Amount of viewable tiles wide </summary>
public readonly int GridWidth;
/// <summary> Amount of viewable tiles high </summary>
public readonly int GridHeight;
/// <summary> Max amount of tiles wide the entire grid is </summary>
public readonly int MaxWidth;
/// <summary> Max amount of tiles high the entire grid is </summary>
public readonly int MaxHeight;
protected TileGrid(in int gw, in int gh, in int mw, in int mh)
{
GridWidth = gw;
GridHeight = gh;
MaxWidth = mw;
MaxHeight = mh;
}
/// <summary>
/// Amount of tiles present in the grid.
/// </summary>
public int GridTileCount => GridWidth * GridHeight;
/// <summary>
/// Amount of ALL tiles present in the entire grid (including the grid).
/// </summary>
public int MaxTileCount => MaxWidth * MaxHeight;
protected int GetTileIndex(in int x, in int y) => (MaxHeight * x) + y;
public void ClampCoordinatesInsideGrid(ref int x, ref int y) => ClampCoordinatesTo(ref x, ref y, MaxWidth - 1, MaxHeight - 1);
public void ClampCoordinatesTopLeft(ref int x, ref int y)
{
int maxX = MaxWidth - GridWidth;
int maxY = MaxHeight - GridHeight;
ClampCoordinatesTo(ref x, ref y, maxX, maxY);
}
private static void ClampCoordinatesTo(ref int x, ref int y, int maxX, int maxY)
{
x = Math.Max(0, Math.Min(x, maxX));
y = Math.Max(0, Math.Min(y, maxY));
}
public void GetViewAnchorCoordinates(ref int x, ref int y, in bool centerReticle)
{
// If we aren't snapping the reticle to the nearest acre
// we want to put the middle of the reticle rectangle where the cursor is.
// Adjust the view coordinate
if (!centerReticle)
{
// Reticle size is GridWidth, center = /2
x -= GridWidth / 2;
y -= GridWidth / 2;
}
// Clamp to viewport dimensions, and center to nearest acre if desired.
// Clamp to boundaries so that we always have 16x16 to view.
ClampCoordinatesTopLeft(ref x, ref y);
}
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace NHSE.Core;
/// <summary>
/// Basic configuration of a narrow view for interacting with the larger manipulatable tile grid.
/// </summary>
/// <param name="ViewWidth">Viewable amount of viewable tiles wide</param>
/// <param name="ViewHeight">Viewable amount of viewable tiles high</param>
/// <param name="Columns">Columns of view available</param>
/// <param name="Rows">Rows of view available</param>
public readonly record struct TileGridViewport([ConstantExpected] byte ViewWidth, [ConstantExpected] byte ViewHeight, byte Columns, byte Rows)
{
/// <summary>
/// Total width of the entire grid (including the view).
/// </summary>
public int TotalWidth => Columns * ViewWidth;
/// <summary>
/// Total height of the entire grid (including the view).
/// </summary>
public int TotalHeight => Rows * ViewHeight;
/// <summary>
/// Amount of tiles present in the grid.
/// </summary>
public int ViewCount => ViewWidth * ViewHeight;
/// <summary>
/// Amount of ALL tiles present in the entire grid (including the grid).
/// </summary>
public int TotalCount => TotalWidth * TotalHeight;
/// <summary>
/// Gets the dimensions of the viewable area (an acre-worth).
/// </summary>
public (int X, int Y) DimAcre => (ViewWidth, ViewHeight);
/// <summary>
/// Gets the total dimensions of the entire grid (including the view).
/// </summary>
public (int X, int Y) DimTotal => (TotalWidth, TotalHeight);
/// <summary>
/// Gets the absolute index of the absolute tile in the grid based on the x/y coordinates.
/// </summary>
/// <param name="relX">Relative X-coordinate of the tile in the grid</param>
/// <param name="relY">Relative Y-coordinate of the tile in the grid</param>
/// <returns>Absolute index of the tile in the grid</returns>
public int GetTileIndex(in int relX, in int relY) => (TotalHeight * relX) + relY;
/// <summary>
/// Clamps the specified relative X and Y coordinates so that they remain within the valid bounds of the area.
/// </summary>
/// <remarks>
/// Use this method to prevent coordinates from exceeding the valid area,
/// which may help avoid out-of-bounds errors when working with grid-based data or images.
/// </remarks>
/// <param name="relX">The relative X coordinate to clamp.</param>
/// <param name="relY">The relative Y coordinate to clamp.</param>
public void ClampInside(ref int relX, ref int relY)
=> ClampCoordinatesTo(ref relX, ref relY, TotalWidth - 1, TotalHeight - 1);
private static void ClampCoordinatesTo(ref int relX, ref int relY, int maxX, int maxY)
{
relX = Math.Clamp(relX, 0, maxX);
relY = Math.Clamp(relY, 0, maxY);
}
/// <summary>
/// Determines whether the specified relative coordinates are within the bounds of the area.
/// </summary>
/// <param name="relX">The horizontal coordinate, relative to the left edge of the area.</param>
/// <param name="relY">The vertical coordinate, relative to the top edge of the area.</param>
/// <returns><see langword="true"/> if the specified coordinates are within the bounds; otherwise, <see langword="false"/>.</returns>
public bool Contains(int relX, int relY) => !((uint)relX >= TotalWidth || (uint)relY >= TotalHeight);
}

View File

@@ -4,7 +4,7 @@
namespace NHSE.Core;
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public class GSaveFg
public sealed class GSaveFg
{
public const int SIZE = 0x928;
private const int _7b9816fbCount = 0x900;

View File

@@ -13,49 +13,14 @@ public struct GSavePlayerManpu : IReactionStore
private const int MaxCount = 64;
private const int WheelCount = 8;
/// <summary>
/// List of known Reaction IDs
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, SizeConst = MaxCount)]
public Reaction[] ManpuBit { get; set; }
/// <summary>
/// Emotions that are currently bound to the Reaction Wheel.
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, SizeConst = WheelCount)]
public Reaction[] UIList { get; set; }
/// <summary>
/// Flags indicating if a Reaction (at the same index?) is newly learned or not.
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.I1, SizeConst = MaxCount)]
public bool[] NewFlag { get; set; }
public void AddMissingReactions()
{
var all = Enum.GetValues<Reaction>();
foreach (var react in all)
AddReaction(react);
}
// returns true if failed
public bool AddReaction(Reaction react)
{
if (react.ToString().StartsWith("UNUSED"))
return true;
var index = Array.IndexOf(ManpuBit, react);
if (index >= 0)
return false;
var empty = EmptyIndex;
if (empty < 0)
return true;
ManpuBit[empty] = react;
return false;
}
private readonly int EmptyIndex => Array.FindIndex(ManpuBit, z => z == 0);
}
/// <summary>
@@ -68,56 +33,64 @@ public struct GSavePlayerManpu15 : IReactionStore
private const int MaxCount = 256; // up from 64
private const int WheelCount = 8;
/// <summary>
/// List of known Reaction IDs
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, SizeConst = MaxCount)]
public Reaction[] ManpuBit { get; set; }
/// <summary>
/// Emotions that are currently bound to the Reaction Wheel.
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, SizeConst = WheelCount)]
public Reaction[] UIList { get; set; }
/// <summary>
/// Flags indicating if a Reaction (at the same index?) is newly learned or not.
/// </summary>
[field: MarshalAs(UnmanagedType.ByValArray, ArraySubType = UnmanagedType.I1, SizeConst = MaxCount)]
public bool[] NewFlag { get; set; }
public void AddMissingReactions()
{
var all = Enum.GetValues<Reaction>();
foreach (var react in all)
AddReaction(react);
}
// returns true if failed
public bool AddReaction(Reaction react)
{
if (react.ToString().StartsWith("UNUSED"))
return true;
var index = Array.IndexOf(ManpuBit, react);
if (index >= 0)
return false;
var empty = EmptyIndex;
if (empty < 0)
return true;
ManpuBit[empty] = react;
return false;
}
private readonly int EmptyIndex => Array.FindIndex(ManpuBit, z => z == 0);
}
public interface IReactionStore
{
/// <summary>
/// List of Reaction IDs the player currently knows.
/// </summary>
Reaction[] ManpuBit { get; set; }
/// <summary>
/// Emotions that are currently bound to the Reaction Wheel.
/// </summary>
Reaction[] UIList { get; set; }
/// <summary>
/// Flags indicating if a Reaction (at the same index?) is newly learned or not.
/// </summary>
bool[] NewFlag { get; set; }
bool AddReaction(Reaction react);
void AddMissingReactions();
/// <summary>
/// Adds all possible reaction values from <see cref="Reaction"/>'s defined list.
/// </summary>
void AddMissingReactions()
{
var all = Enum.GetValues<Reaction>();
foreach (var react in all)
TryAddReaction(react);
}
/// <summary>
/// Attempts to add the <see cref="react"/> to the list of reactions.
/// </summary>
/// <param name="react">Reaction to add to list</param>
bool TryAddReaction(Reaction react)
{
if (react.ToString().StartsWith("UNUSED"))
return false; // shouldn't add
if (ManpuBit.Contains(react))
return true; // already have
var empty = EmptyIndex;
if (empty < 0)
return true; // full? already have
ManpuBit[empty] = react;
return true;
}
/// <summary>
/// First empty index within the array of reactions.
/// </summary>
int EmptyIndex => ManpuBit.IndexOf(Reaction.None);
}

View File

@@ -5,7 +5,7 @@
namespace NHSE.Core;
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public class GSaveVisitorNpc
public sealed class GSaveVisitorNpc
{
public const int SIZE = 0x78;
private const int Days = 7;
@@ -46,7 +46,7 @@ public struct V3f
public float Y { get; set; }
public float Z { get; set; }
public override string ToString() => $"({X},{Y},{Z})";
public readonly override string ToString() => $"({X},{Y},{Z})";
}
public enum VisitorNPC

View File

@@ -1,59 +0,0 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace NHSE.Core;
public class MapManager : MapTerrainStructure
{
public readonly FieldItemManager Items;
public int MapLayer { get; set; } // 0 or 1
public MapManager(MainSave sav) : base(sav)
{
Items = new FieldItemManager(sav);
}
public FieldItemLayer CurrentLayer => MapLayer == 0 ? Items.Layer1 : Items.Layer2;
public static void ClearDesignTiles(MainSave sav)
{
var tiles = sav.GetMapDesignTiles();
for (int i = 0; i < tiles.Length; i++)
tiles[i] = MainSave.MapDesignNone;
sav.SetMapDesignTiles(tiles);
}
public static byte[] ExportDesignTiles(MainSave sav)
{
var tiles = sav.GetMapDesignTiles();
var result = new byte[tiles.Length * 2];
Buffer.BlockCopy(tiles, 0, result, 0, result.Length);
return result;
}
public static void ImportDesignTiles(MainSave sav, ReadOnlySpan<byte> result)
{
var tiles = sav.GetMapDesignTiles();
MemoryMarshal.Cast<byte, ushort>(result).CopyTo(tiles);
sav.SetMapDesignTiles(tiles);
}
}
public class MapTerrainStructure
{
public readonly TerrainLayer Terrain;
public readonly IReadOnlyList<Building> Buildings;
public uint PlazaX { get; set; }
public uint PlazaY { get; set; }
public MapTerrainStructure(MainSave sav)
{
Terrain = new TerrainLayer(sav.GetTerrainTiles(), sav.GetAcreBytes());
Buildings = sav.Buildings;
PlazaX = sav.EventPlazaLeftUpX;
PlazaY = sav.EventPlazaLeftUpZ;
}
}

View File

@@ -1,107 +0,0 @@
using System;
namespace NHSE.Core;
public class MapView
{
private const int ViewInterval = 2;
public readonly MapManager Map;
public int MapScale { get; } = 1;
public int AcreScale { get; }
public int TerrainScale => AcreScale * 2;
// Top Left Anchor Coordinates
public int X { get; set; }
public int Y { get; set; }
protected MapView(MapManager m, int scale = 16)
{
AcreScale = scale;
Map = m;
}
public bool CanUp => Y != 0;
public bool CanDown => Y < Map.CurrentLayer.MaxHeight - Map.CurrentLayer.GridHeight;
public bool CanLeft => X != 0;
public bool CanRight => X < Map.CurrentLayer.MaxWidth - Map.CurrentLayer.GridWidth;
public bool ArrowUp()
{
if (Y <= 0)
return false;
Y -= ViewInterval;
return true;
}
public bool ArrowLeft()
{
if (X <= 0)
return false;
X -= ViewInterval;
return true;
}
public bool ArrowRight()
{
if (X >= Map.CurrentLayer.MaxWidth - 2)
return false;
X += ViewInterval;
return true;
}
public bool ArrowDown()
{
if (Y >= Map.CurrentLayer.MaxHeight - ViewInterval)
return false;
Y += ViewInterval;
return true;
}
public bool SetViewTo(in int x, in int y)
{
var info = Map.CurrentLayer;
var newX = Math.Max(0, Math.Min(x, info.MaxWidth - info.GridWidth));
var newY = Math.Max(0, Math.Min(y, info.MaxHeight - info.GridHeight));
bool diff = X != newX || Y != newY;
X = newX;
Y = newY;
return diff;
}
public void SetViewToAcre(in int acre)
{
var layer = Map.Items.Layer1;
layer.GetViewAnchorCoordinates(acre, out var x, out var y);
SetViewTo(x, y);
}
public int ModifyFieldItems(Func<int, int, int, int, int> action, in bool wholeMap)
{
var layer = Map.CurrentLayer;
return wholeMap
? action(0, 0, layer.MaxWidth, layer.MaxHeight)
: action(X, Y, layer.GridWidth, layer.GridHeight);
}
public int ReplaceFieldItems(Item oldItem, Item newItem, in bool wholeMap)
{
var layer = Map.CurrentLayer;
return wholeMap
? layer.ReplaceAll(oldItem, newItem, 0, 0, layer.MaxWidth, layer.MaxHeight)
: layer.ReplaceAll(oldItem, newItem, X, Y, layer.GridWidth, layer.GridHeight);
}
public void GetCursorCoordinates(in int mX, in int mY, out int x, out int y)
{
x = mX / MapScale;
y = mY / MapScale;
}
public void GetViewAnchorCoordinates(int mX, int mY, out int x, out int y, bool centerReticle)
{
GetCursorCoordinates(mX, mY, out x, out y);
var layer = Map.Items.Layer1;
layer.GetViewAnchorCoordinates(ref x, ref y, centerReticle);
}
}

View File

@@ -4,15 +4,12 @@
namespace NHSE.Core;
public class Museum
public sealed class Museum(Memory<byte> raw)
{
public const int SIZE = 0x3404;
public const int EntryCount = 1024;
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public Museum(Memory<byte> data) => Raw = data;
public Span<byte> Data => raw.Span;
public int MuseumLevel
{

View File

@@ -5,27 +5,17 @@
namespace NHSE.Core;
public class MuseumEditor
public sealed record MuseumEditor(Museum Museum)
{
public readonly Museum Museum;
public readonly GSaveDate[] Dates;
public readonly Item[] Items;
public readonly byte[] Players;
public MuseumEditor(Museum museum)
{
Museum = museum;
Dates = museum.GetDates();
Items = museum.GetItems();
Players = museum.GetPlayers();
}
public readonly GSaveDate[] Dates = Museum.GetDates();
public readonly Item[] Items = Museum.GetItems();
public readonly byte[] Players = Museum.GetPlayers();
public void Save()
{
var museum = Museum;
museum.SetDates(Dates);
museum.SetItems(Items);
museum.SetPlayers(Players);
Museum.SetDates(Dates);
Museum.SetItems(Items);
Museum.SetPlayers(Players);
}
public IEnumerable<string> GetDonationSummary(GameStrings str)

View File

@@ -3,16 +3,14 @@
namespace NHSE.Core;
public class RecipeBook
public sealed class RecipeBook(Memory<byte> raw)
{
private const int BitFlagArraySize = 0x100;
private const int BitFlagArrayCount = 4;
public const int SIZE = BitFlagArraySize * BitFlagArrayCount;
public const ushort RecipeCount = BitFlagArraySize * 8;
private readonly Memory<byte> Raw;
private Span<byte> Data => Raw.Span;
public RecipeBook(Memory<byte> raw) => Raw = raw;
private Span<byte> Data => raw.Span;
public void Save(Span<byte> data) => Data.CopyTo(data);

View File

@@ -7,7 +7,7 @@ namespace NHSE.Core;
/// <summary>
/// Used for allowing a struct to be mutated in a PropertyGrid.
/// </summary>
public class ValueTypeTypeConverter : ExpandableObjectConverter
public sealed class ValueTypeTypeConverter : ExpandableObjectConverter
{
public override bool GetCreateInstanceSupported(ITypeDescriptorContext? context) => true;

View File

@@ -6,51 +6,24 @@ namespace NHSE.Core;
/// <summary>
/// Multi-milestone definition for tracking game-play achievements.
/// </summary>
public class LifeSupportAchievement : INamedValue
public sealed record LifeSupportAchievement(
ushort Index,
byte AchievementCount,
uint Threshold1,
uint Threshold2,
uint Threshold3,
uint Threshold4,
uint Threshold5,
short FlagLand,
short FlagPlayer,
string Name)
: INamedValue
{
/// <summary>
/// Amount of milestones an achievement can have.
/// </summary>
public const int MilestoneMax = 6;
public readonly short FlagLand;
public readonly short FlagPlayer;
public ushort Index { get; }
public string Name { get; }
/// <summary> Total number of milestones for this achievement type. </summary>
public readonly int AchievementCount;
/// <summary> First Milestone's Satisfaction Threshold </summary>
public readonly uint Threshold1;
/// <summary> Second Milestone's Satisfaction Threshold </summary>
public readonly uint Threshold2;
/// <summary> Third Milestone's Satisfaction Threshold </summary>
public readonly uint Threshold3;
/// <summary> Fourth Milestone's Satisfaction Threshold </summary>
public readonly uint Threshold4;
/// <summary> Fifth Milestone's Satisfaction Threshold </summary>
public readonly uint Threshold5;
public LifeSupportAchievement(ushort index, byte max, uint t1, uint t2, uint t3, uint t4, uint t5, short land, short player, string name)
{
Index = index;
AchievementCount = max;
Threshold1 = t1;
Threshold2 = t2;
Threshold3 = t3;
Threshold4 = t4;
Threshold5 = t5;
FlagLand = land;
FlagPlayer = player;
Name = name;
}
public uint MaxThreshold => Math.Max(Threshold1, Math.Max(Threshold2, Math.Max(Threshold3, Math.Max(Threshold4, Threshold5))));
/// <summary>

View File

@@ -4,7 +4,7 @@
namespace NHSE.Core;
[StructLayout(LayoutKind.Sequential, Size = SIZE)]
public class TurnipStonk // GSaveShopKabu
public sealed class TurnipStonk // GSaveShopKabu
{
public const int SIZE = 0x44;

View File

@@ -3,14 +3,11 @@
namespace NHSE.Core;
public class GSaveMemory : IVillagerOrigin
public sealed class GSaveMemory(Memory<byte> raw) : IVillagerOrigin
{
public const int SIZE = 0x5F0;
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public GSaveMemory(Memory<byte> data) => Raw = data;
public Span<byte> Data => raw.Span;
public GSavePlayerId PlayerId
{

View File

@@ -8,6 +8,6 @@ public interface IPlayerRoom
byte[] Write();
string Extension { get; }
RoomItemLayer[] GetItemLayers();
void SetItemLayers(IReadOnlyList<RoomItemLayer> value);
LayerRoomItem[] GetItemLayers();
void SetItemLayers(IReadOnlyList<LayerRoomItem> value);
}

View File

@@ -4,15 +4,12 @@
namespace NHSE.Core;
public class PlayerHouse1 : IPlayerHouse
public class PlayerHouse1(Memory<byte> raw) : IPlayerHouse
{
public const int SIZE = 0x26400;
public virtual string Extension => "nhph";
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public PlayerHouse1(Memory<byte> data) => Raw = data;
public Span<byte> Data => raw.Span;
public byte[] Write() => Data.ToArray();

View File

@@ -2,13 +2,11 @@
namespace NHSE.Core;
public class PlayerHouse2 : PlayerHouse1
public sealed class PlayerHouse2(Memory<byte> raw) : PlayerHouse1(raw)
{
public new const int SIZE = 0x28A28;
public override string Extension => "nhph2";
public PlayerHouse2(Memory<byte> data) : base(data) { }
public override IPlayerRoom GetRoom(int roomIndex)
{
if ((uint)roomIndex >= MaxRoom)

View File

@@ -3,15 +3,12 @@
namespace NHSE.Core;
public class PlayerRoom1 : IPlayerRoom
public class PlayerRoom1(Memory<byte> raw) : IPlayerRoom
{
public const int SIZE = 0x65C8;
public virtual string Extension => "nhpr";
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public PlayerRoom1(Memory<byte> data) => Raw = data;
public Span<byte> Data => raw.Span;
public byte[] Write() => Data.ToArray();
@@ -23,8 +20,8 @@ public class PlayerRoom1 : IPlayerRoom
*/
public const int LayerCount = 8;
public RoomItemLayer[] GetItemLayers() => RoomItemLayer.GetArray(Data[..(LayerCount * RoomItemLayer.SIZE)].ToArray());
public void SetItemLayers(IReadOnlyList<RoomItemLayer> value) => RoomItemLayer.SetArray(value).AsSpan().CopyTo(Data);
public LayerRoomItem[] GetItemLayers() => LayerRoomItem.GetArray(Data[..(LayerCount * LayerRoomItem.SIZE)].ToArray());
public void SetItemLayers(IReadOnlyList<LayerRoomItem> value) => LayerRoomItem.SetArray(value).CopyTo(Data);
public bool GetIsActive(int layer, int x, int y) => FlagUtil.GetFlag(Data, 0x6400 + (layer * 0x34), (y * 20) + x);
public void SetIsActive(int layer, int x, int y, bool value = true) => FlagUtil.SetFlag(Data, 0x6400 + (layer * 0x34), (y * 20) + x, value);

View File

@@ -4,12 +4,10 @@
namespace NHSE.Core;
public class PlayerRoom2 : PlayerRoom1
public sealed class PlayerRoom2(Memory<byte> raw) : PlayerRoom1(raw)
{
public new const int SIZE = 0x6C24;
public new virtual string Extension => "nhpr2";
public PlayerRoom2(Memory<byte> data) : base(data) { }
public override string Extension => "nhpr2";
/*
s_665e9093 ExtraEffectLayerList[2]; // @0x65c8 size 0x320, align 2
@@ -49,7 +47,7 @@ public GSaveMusicBoxInfo MusicBoxInfo
// 3 bytes padding
public s_e13a81f4 _cfb139b9
public s_e13a81f4 Unk_cfb139b9
{
get => Data.Slice(0x6C10, s_e13a81f4.SIZE).ToStructure<s_e13a81f4>();
set => value.ToBytes().CopyTo(Data[0x6C10..]);

View File

@@ -8,14 +8,12 @@ namespace NHSE.Core;
/// <summary>
/// Villager object format from 1.0 to update 1.4
/// </summary>
public sealed class Villager1 : IVillager
public sealed class Villager1(Memory<byte> raw) : IVillager
{
public const int SIZE = 0x12AB0;
public string Extension => "nhv";
public readonly Memory<byte> Raw;
public Span<byte> Data => Raw.Span;
public Villager1(Memory<byte> data) => Raw = data;
public Span<byte> Data => raw.Span;
public byte[] Write() => Data.ToArray();
public byte Species { get => Data[0]; set => Data[0] = value; }
@@ -34,7 +32,7 @@ public GSaveMemory GetMemory(int index)
if ((uint) index >= PlayerMemoryCount)
throw new ArgumentOutOfRangeException(nameof(index));
var bytes = Raw.Slice(0x4 + (index * GSaveMemory.SIZE), GSaveMemory.SIZE);
var bytes = raw.Slice(0x4 + (index * GSaveMemory.SIZE), GSaveMemory.SIZE);
return new GSaveMemory(bytes);
}
@@ -115,7 +113,7 @@ public GSaveRoomFloorWall Room
public DesignPatternPRO Design
{
get => new(Raw.Slice(0x12128, DesignPatternPRO.SIZE));
get => new(raw.Slice(0x12128, DesignPatternPRO.SIZE));
set => value.Data.CopyTo(Data[0x12128..]);
}

View File

@@ -3,15 +3,13 @@
namespace NHSE.Core;
public class VillagerHouse1 : IVillagerHouse
public class VillagerHouse1(Memory<byte> raw) : IVillagerHouse
{
public const int SIZE = 0x1D4;
public const int ItemCount = 36;
public virtual string Extension => "nhvh";
public readonly Memory<byte> Raw;
public VillagerHouse1(Memory<byte> raw) => Raw = raw;
public Span<byte> Data => Raw.Span;
public Span<byte> Data => raw.Span;
public byte[] Write() => Data.ToArray();

View File

@@ -5,13 +5,11 @@
namespace NHSE.Core;
public class VillagerHouse2 : VillagerHouse1
public sealed class VillagerHouse2(Memory<byte> raw) : VillagerHouse1(raw)
{
public new const int SIZE = 0x12E8;
public override string Extension => "nhvh2";
public VillagerHouse2(Memory<byte> data) : base(data) { }
// 0x1D4-0x12DB -- 0x1108 sized structure
// 0x12DC -- 8 byte item
// 0x12E4 -- 1 byte

View File

@@ -6,7 +6,7 @@ namespace NHSE.Core;
/// <summary>
/// Key Value pair for a displayed <see cref="T:System.String" /> and underlying <see cref="T:System.Int32" /> value.
/// </summary>
public record ComboItem(string Text, int Value);
public sealed record ComboItem(string Text, int Value);
public static class ComboItemUtil
{
@@ -25,10 +25,10 @@ public static List<ComboItem> GetArray(ReadOnlySpan<string> items)
return result;
}
public static List<ComboItem> GetArray<T>(Type t) where T : struct, IFormattable
public static List<ComboItem> GetArray<T>() where T : struct, Enum, IFormattable
{
var names = Enum.GetNames(t);
var values = (T[])Enum.GetValues(t);
var names = Enum.GetNames<T>();
var values = Enum.GetValues<T>();
var acres = new List<ComboItem>(names.Length);
for (int i = 0; i < names.Length; i++)

View File

@@ -21,4 +21,19 @@ public static void SetFlag(Span<byte> arr, int offset, int bitIndex, bool value)
arr[offset] &= (byte)~(1 << bitIndex);
arr[offset] |= (byte)((value ? 1 : 0) << bitIndex);
}
public static bool GetFlag(ReadOnlySpan<byte> arr, int bitIndex)
{
var b = arr[bitIndex >> 3];
var mask = 1 << (bitIndex & 7);
return (b & mask) != 0;
}
public static void SetFlag(Span<byte> arr, int bitIndex, bool value)
{
var offset = bitIndex >> 3;
bitIndex &= 7; // ensure bit access is 0-7
arr[offset] &= (byte)~(1 << bitIndex);
arr[offset] |= (byte)((value ? 1 : 0) << bitIndex);
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace NHSE.Core;
@@ -82,6 +83,14 @@ public static class StructConverter
return result;
}
public static byte[] SetArray<T>(this ReadOnlySpan<T> data, int size) where T : class
{
var result = new byte[data.Length * size];
for (int i = 0; i < data.Length; i++)
data[i].ToBytesClass().CopyTo(result, i * size);
return result;
}
public static byte[] SetArrayStructure<T>(this ReadOnlySpan<T> data, int size) where T : struct
{
var result = new byte[data.Length * size];

View File

@@ -3,12 +3,8 @@
namespace NHSE.Injection;
public class AutoInjector
public sealed record AutoInjector(IDataInjector Injector, Action<InjectionResult> DoRead, Action<InjectionResult> DoWrite)
{
public readonly IDataInjector Injector;
private readonly Action<InjectionResult> AfterRead;
private readonly Action<InjectionResult> AfterWrite;
public bool AutoInjectEnabled { private get; set; }
public bool ValidateEnabled
@@ -17,13 +13,6 @@ public bool ValidateEnabled
set => Injector.ValidateEnabled = value;
}
public AutoInjector(IDataInjector inj, Action<InjectionResult> read, Action<InjectionResult> write)
{
Injector = inj;
AfterRead = read;
AfterWrite = write;
}
public void Validate() => Injector.Validate();
public InjectionResult Read(bool force = false)
@@ -34,7 +23,7 @@ public InjectionResult Read(bool force = false)
try
{
var result = Injector.Read();
AfterRead(result);
DoRead(result);
return result;
}
catch (IndexOutOfRangeException ex)
@@ -51,7 +40,7 @@ public InjectionResult Write(bool force = false)
try
{
var result = Injector.Write();
AfterWrite(result);
DoWrite(result);
return result;
}
catch (IndexOutOfRangeException ex)

View File

@@ -1,35 +1,27 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using NHSE.Core;
namespace NHSE.Injection;
public class PocketInjector : IDataInjector
public sealed class PocketInjector(IReadOnlyList<Item> items, IRAMReadWriter bot) : IDataInjector
{
private readonly IReadOnlyList<Item> Items;
private readonly IRAMReadWriter Bot;
public bool Connected => Bot.Connected;
public bool Connected => bot.Connected;
private byte[]? LastData { get; set; }
public uint WriteOffset { private get; set; }
public bool ValidateEnabled { get; set; } = true;
public bool SpoofInventoryWrite { get; set; }
private static readonly Item DroppableOnlyItem = new(0x9C9); // Gold nugget
public PocketInjector(IReadOnlyList<Item> items, IRAMReadWriter bot)
{
Items = items;
Bot = bot;
}
private static readonly Item DroppableOnlyItem = new(0x9C9); // Gold nugget
public bool ReadValidate(out byte[] data)
{
PlayerItemSet.GetOffsetLength(WriteOffset, out var offset, out var size);
data = Bot.ReadBytes(offset, size);
data = bot.ReadBytes(offset, size);
return Validate(data);
}
private byte[]? LastData;
public InjectionResult Read()
{
if (!ReadValidate(out var data))
@@ -38,7 +30,7 @@ public InjectionResult Read()
if (LastData?.SequenceEqual(data) == true)
return InjectionResult.Same;
PlayerItemSet.ReadPlayerInventory(data, Items);
PlayerItemSet.ReadPlayerInventory(data, items);
LastData = data;
@@ -52,9 +44,9 @@ public InjectionResult Write()
var orig = (byte[])data.Clone();
var items = !SpoofInventoryWrite ? Items : Enumerable.Repeat(DroppableOnlyItem, Items.Count).ToArray();
var items1 = !SpoofInventoryWrite ? items : Enumerable.Repeat(DroppableOnlyItem, items.Count).ToArray();
PlayerItemSet.WritePlayerInventory(data, items);
PlayerItemSet.WritePlayerInventory(data, items1);
if (data.SequenceEqual(orig))
return InjectionResult.Same;
@@ -63,7 +55,7 @@ public InjectionResult Write()
if (size != data.Length)
return InjectionResult.FailBadSize;
Bot.WriteBytes(data, offset);
bot.WriteBytes(data, offset);
LastData = data;

View File

@@ -7,44 +7,35 @@ public static class Decoder
private static bool IsNum(char c) => (uint)(c - '0') <= 9;
private static bool IsHexUpper(char c) => (uint)(c - 'A') <= 5;
public static byte[] ConvertHexByteStringToBytes(byte[] bytes)
public static byte[] ConvertHexByteStringToBytes(ReadOnlySpan<byte> bytes)
{
var dest = new byte[bytes.Length / 2];
for (int i = 0; i < dest.Length; i++)
{
int ofs = i * 2;
var _0 = (char)bytes[ofs + 0];
var _1 = (char)bytes[ofs + 1];
dest[i] = DecodeTuple(_0, _1);
}
LoadHexBytesTo(bytes, dest, 2);
return dest;
}
public static void LoadHexBytesTo(ReadOnlySpan<byte> str, Span<byte> dest, int tupleSize)
{
// The input string is 2-char hex values optionally separated.
// The destination array should always be larger or equal than the bytes written. Let the runtime bounds check us.
// Iterate through the string without allocating.
for (int i = 0, j = 0; i < str.Length; i += tupleSize)
dest[j++] = DecodeTuple((char)str[i + 0], (char)str[i + 1]);
}
private static byte DecodeTuple(char _0, char _1)
{
byte result;
if (IsNum(_0))
result = (byte)((_0 - '0') << 4);
else if (IsHexUpper(_0))
result = (byte)((_0 - 'A' + 10) << 4);
else
return (byte)(DecodeChar(_0) << 4 | DecodeChar(_1));
static int DecodeChar(char x)
{
if (char.IsAsciiDigit(x))
return (byte)(x - '0');
if (char.IsAsciiHexDigitUpper(x))
return (byte)(x - 'A' + 10);
if (char.IsAsciiHexDigitLower(x))
return (byte)(x - 'a' + 10);
throw new ArgumentOutOfRangeException(nameof(_0));
if (IsNum(_1))
result |= (byte)(_1 - '0');
else if (IsHexUpper(_1))
result |= (byte)(_1 - 'A' + 10);
else
throw new ArgumentOutOfRangeException(nameof(_1));
return result;
}
public static byte[] StringToByteArray(string hex)
{
int NumberChars = hex.Length;
byte[] bytes = new byte[NumberChars / 2];
for (int i = 0; i < NumberChars; i += 2)
bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
return bytes;
}
}
}

View File

@@ -3,7 +3,7 @@
namespace NHSE.Injection;
public class SysBot : IRAMReadWriter
public sealed class SysBot : IRAMReadWriter
{
public string IP = "192.168.1.65";
public int Port = 6000;

View File

@@ -6,7 +6,7 @@
namespace NHSE.Injection;
public class USBBot : IRAMReadWriter
public sealed class USBBot : IRAMReadWriter
{
private UsbDevice? SwDevice;
private UsbEndpointReader? reader;

View File

@@ -7,7 +7,7 @@
namespace NHSE.Parsing;
public class BCSV
public sealed class BCSV
{
public static readonly BCSVEnumDictionary EnumLookup = new(Resources.specs_130.Split('\n'));
public static bool DecodeColumnNames { private get; set; } = true;

View File

@@ -5,9 +5,9 @@
namespace NHSE.Parsing;
public class BCSVEnumDictionary
public sealed class BCSVEnumDictionary
{
private readonly Dictionary<uint, string> Lookup = [];
private readonly Dictionary<uint, string> _lookup = [];
public BCSVEnumDictionary(IEnumerable<string> lines)
{
@@ -35,7 +35,7 @@ private void AddColumnName(string trim)
var slice = value.AsSpan(2, value.Length - 3);
var hex = StringUtil.GetHexValue(slice);
if (Lookup.TryGetValue(hex, out var exist))
if (_lookup.TryGetValue(hex, out var exist))
{
if (exist == name)
return;
@@ -43,7 +43,7 @@ private void AddColumnName(string trim)
return;
}
Lookup.Add(hex, name);
_lookup.Add(hex, name);
}
private void AddEnumName(string trim)
@@ -56,7 +56,7 @@ private void AddEnumName(string trim)
return;
var hash = CRC32.Compute(text);
if (Lookup.TryGetValue(hash, out var exist))
if (_lookup.TryGetValue(hash, out var exist))
{
if (exist == text)
return;
@@ -64,10 +64,10 @@ private void AddEnumName(string trim)
return;
}
Lookup.Add(hash, text);
_lookup.Add(hash, text);
}
public IEnumerable<string> Dump() => Lookup.Select(z => $"{z.Key:X8}\t{z.Value}");
public IEnumerable<string> Dump() => _lookup.Select(z => $"{z.Key:X8}\t{z.Value}");
public string this[uint key] => Lookup.TryGetValue(key, out var val) ? val : $"0x{key:X8}";
public string this[uint key] => _lookup.TryGetValue(key, out var val) ? val : $"0x{key:X8}";
}

View File

@@ -1,16 +1,6 @@
namespace NHSE.Parsing;
public class BCSVFieldParam
public sealed record BCSVFieldParam(uint ColumnKey, int Offset, int Index)
{
public const int SIZE = 8;
public readonly uint ColumnKey;
public readonly int Offset;
public readonly int Index;
public BCSVFieldParam(uint key, int offset, int index)
{
ColumnKey = key;
Offset = offset;
Index = index;
}
}

View File

@@ -14,7 +14,7 @@ public static class GameMSBTDumper
/// <param name="dest">Destination folder where the dumps will be saved.</param>
/// <param name="csv">Convert all <see cref="MSBT"/> files to CSV for easy viewing.</param>
/// <param name="delim">Delimiter when exporting the <see cref="csv"/> files</param>
public static void UpdateDumps(string root, string dest, bool csv = true, char delim = '\t')
public static void UpdateDumps(string root, string dest, bool csv = false, char delim = '\t')
{
if (csv)
UpdateCSV(root, Path.Combine(dest, "csv"), delim);
@@ -74,7 +74,7 @@ public static string[] GetArtList(string msgPath)
result.Add($"{itemID:00000}, // {Text}{fake}");
}
result.Sort();
return result.ToArray();
return [..result];
}
public static string[] GetVillagerPhraseResource(string msgPath)
@@ -82,7 +82,8 @@ public static string[] GetVillagerPhraseResource(string msgPath)
var file = Path.Combine(msgPath, "Npc", "STR_NNpcPhrase.msbt");
var list = GetLabelList(file);
var normal = list.Select(z => $"{z.Label}\t{z.Text}").Order();
return normal.ToArray();
return [..normal];
}
public static string[] GetVillagerListResource(string msgPath)
@@ -95,7 +96,7 @@ public static string[] GetVillagerListResource(string msgPath)
list = GetLabelList(file);
var special = list.Select(z => $"{z.Label}\t{z.Text}").Order();
return normal.Concat(special).ToArray();
return [..normal, ..special];
}
public static Dictionary<ushort, string> GetItemList(string msgPath, string language)

View File

@@ -2,7 +2,7 @@
namespace NHSE.Parsing;
public class LBL1() : MSBTSection(string.Empty, [])
public sealed class LBL1() : MSBTSection(string.Empty, [])
{
public uint NumberOfGroups;

Some files were not shown because too many files have changed in this diff Show More