729 lines
24 KiB
C#
729 lines
24 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using Avalonia;
|
|
using Avalonia.Media.Imaging;
|
|
using Roboto.Memory;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Automata.GameLog;
|
|
using Roboto.Core;
|
|
using Roboto.Data;
|
|
using Roboto.Engine;
|
|
using Roboto.Input;
|
|
using Roboto.Navigation;
|
|
|
|
namespace Automata.Ui.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Thread-safe snapshot read by the overlay layer each frame.
|
|
/// </summary>
|
|
public sealed class EntityOverlayData
|
|
{
|
|
/// <summary>Player position at time the entity list was built (cold tick, 10Hz).</summary>
|
|
public Vector2 SnapshotPlayerPosition;
|
|
public float SnapshotPlayerZ;
|
|
public EntityOverlayEntry[] Entries = [];
|
|
}
|
|
|
|
public readonly struct EntityOverlayEntry
|
|
{
|
|
public readonly float X, Y;
|
|
public readonly string Label;
|
|
|
|
public EntityOverlayEntry(float x, float y, string label)
|
|
{
|
|
X = x;
|
|
Y = y;
|
|
Label = label;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// View model item for entity checkbox list.
|
|
/// </summary>
|
|
public partial class EntityListItem : ObservableObject
|
|
{
|
|
public uint Id { get; }
|
|
public string Label { get; }
|
|
public string Category { get; }
|
|
public string Distance { get; set; }
|
|
public float X { get; set; }
|
|
public float Y { get; set; }
|
|
|
|
[ObservableProperty] private bool _isChecked;
|
|
|
|
public EntityListItem(uint id, string label, string category, float distance, float x, float y)
|
|
{
|
|
Id = id;
|
|
Label = label;
|
|
Category = category;
|
|
Distance = $"{distance:F0}";
|
|
X = x;
|
|
Y = y;
|
|
}
|
|
}
|
|
|
|
public partial class RobotoViewModel : ObservableObject, IDisposable
|
|
{
|
|
[LibraryImport("user32.dll")]
|
|
private static partial short GetAsyncKeyState(int vKey);
|
|
private const int VK_END = 0x23;
|
|
private bool _endWasDown;
|
|
|
|
private readonly BotEngine _engine;
|
|
private long _lastUiUpdate;
|
|
private bool _disposed;
|
|
|
|
[ObservableProperty] private string _statusText = "Stopped";
|
|
[ObservableProperty] private bool _isRunning;
|
|
|
|
// Player state
|
|
[ObservableProperty] private string _playerPosition = "—";
|
|
[ObservableProperty] private string _playerLife = "—";
|
|
[ObservableProperty] private string _playerMana = "—";
|
|
[ObservableProperty] private string _playerEs = "—";
|
|
|
|
// Game state
|
|
[ObservableProperty] private string _areaInfo = "—";
|
|
[ObservableProperty] private string _dangerLevel = "—";
|
|
[ObservableProperty] private string _entityCount = "—";
|
|
[ObservableProperty] private string _hostileCount = "—";
|
|
[ObservableProperty] private string _tickInfo = "—";
|
|
|
|
// Systems
|
|
[ObservableProperty] private string _systemsInfo = "—";
|
|
[ObservableProperty] private string _apmInfo = "0";
|
|
|
|
// Navigation
|
|
[ObservableProperty] private string _navMode = "Idle";
|
|
[ObservableProperty] private string _navStatus = "—";
|
|
|
|
// Terrain minimap
|
|
[ObservableProperty] private Bitmap? _terrainImage;
|
|
private byte[]? _terrainBasePixels;
|
|
private byte[]? _minimapBuffer;
|
|
private int _terrainWidth, _terrainHeight;
|
|
private uint _terrainAreaHash;
|
|
private const int MinimapViewSize = 400;
|
|
private const float WorldToGrid = 23.0f / 250.0f;
|
|
private const float Cos45 = 0.70710678f;
|
|
private const float Sin45 = 0.70710678f;
|
|
|
|
// Entity list for checkbox UI
|
|
[ObservableProperty] private bool _showAllEntities;
|
|
public ObservableCollection<EntityListItem> Entities { get; } = [];
|
|
|
|
// ── Profile Editor ──
|
|
[ObservableProperty] private string _characterName = "—";
|
|
[ObservableProperty] private bool _hasProfile;
|
|
[ObservableProperty] private string _profileName = "—";
|
|
[ObservableProperty] private string? _selectedProfile;
|
|
public ObservableCollection<string> AvailableProfiles { get; } = [];
|
|
public ObservableCollection<SkillProfileViewModel> SkillProfiles { get; } = [];
|
|
|
|
// Flask settings
|
|
[ObservableProperty] private float _lifeFlaskThreshold = 50f;
|
|
[ObservableProperty] private float _manaFlaskThreshold = 50f;
|
|
[ObservableProperty] private int _flaskCooldownMs = 4000;
|
|
|
|
// Combat settings
|
|
[ObservableProperty] private int _globalCooldownMs = 500;
|
|
[ObservableProperty] private float _attackRange = 600f;
|
|
[ObservableProperty] private float _safeRange = 400f;
|
|
[ObservableProperty] private bool _kiteEnabled;
|
|
[ObservableProperty] private float _kiteRange = 300f;
|
|
[ObservableProperty] private int _kiteDelayMs = 200;
|
|
|
|
/// <summary>
|
|
/// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread).
|
|
/// </summary>
|
|
public static volatile EntityOverlayData? OverlayData;
|
|
|
|
/// <summary>
|
|
/// Shared GameDataCache for the overlay layer to read camera/player data directly.
|
|
/// </summary>
|
|
public static volatile GameDataCache? SharedCache;
|
|
|
|
public RobotoViewModel(IClientLogWatcher logWatcher)
|
|
{
|
|
var config = new BotConfig();
|
|
var reader = new GameMemoryReader();
|
|
var humanizer = new Humanizer(config);
|
|
// Try Interception driver first, fall back to SendInput
|
|
var interception = new InterceptionInputController(humanizer);
|
|
IInputController input;
|
|
if (interception.Initialize())
|
|
{
|
|
input = interception;
|
|
}
|
|
else
|
|
{
|
|
var sendInput = new SendInputController(humanizer);
|
|
sendInput.Initialize();
|
|
input = sendInput;
|
|
}
|
|
|
|
_engine = new BotEngine(config, reader, input, humanizer);
|
|
|
|
_engine.StatusChanged += status =>
|
|
{
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText = status);
|
|
};
|
|
|
|
_engine.StateUpdated += OnStateUpdated;
|
|
|
|
_engine.ProfileChanged += profile =>
|
|
{
|
|
if (!_suppressProfileChanged)
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => PopulateFromProfile(profile));
|
|
};
|
|
|
|
// Bridge area name from log watcher to engine
|
|
logWatcher.AreaEntered += area => _engine.SetCurrentAreaName(area);
|
|
if (logWatcher.CurrentArea is { Length: > 0 } current)
|
|
_engine.SetCurrentAreaName(current);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Start()
|
|
{
|
|
if (_engine.IsRunning) return;
|
|
var ok = _engine.Start();
|
|
IsRunning = _engine.IsRunning;
|
|
if (ok)
|
|
SharedCache = _engine.Cache;
|
|
else
|
|
StatusText = _engine.Status;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Stop()
|
|
{
|
|
_engine.Stop();
|
|
IsRunning = false;
|
|
StatusText = "Stopped";
|
|
PlayerPosition = "—";
|
|
PlayerLife = "—";
|
|
PlayerMana = "—";
|
|
PlayerEs = "—";
|
|
AreaInfo = "—";
|
|
DangerLevel = "—";
|
|
EntityCount = "—";
|
|
HostileCount = "—";
|
|
TickInfo = "—";
|
|
ApmInfo = "0";
|
|
NavMode = "Idle";
|
|
NavStatus = "—";
|
|
Entities.Clear();
|
|
OverlayData = null;
|
|
SharedCache = null;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void Explore()
|
|
{
|
|
if (!_engine.IsRunning) return;
|
|
_engine.Nav.Explore();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void StopNav()
|
|
{
|
|
_engine.Nav.Stop();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void SaveProfile()
|
|
{
|
|
var profile = _engine.ActiveProfile;
|
|
if (profile is null) return;
|
|
|
|
// Write VM fields back to profile model
|
|
profile.Flasks.LifeFlaskThreshold = LifeFlaskThreshold;
|
|
profile.Flasks.ManaFlaskThreshold = ManaFlaskThreshold;
|
|
profile.Flasks.FlaskCooldownMs = FlaskCooldownMs;
|
|
profile.Combat.GlobalCooldownMs = GlobalCooldownMs;
|
|
profile.Combat.AttackRange = AttackRange;
|
|
profile.Combat.SafeRange = SafeRange;
|
|
profile.Combat.KiteEnabled = KiteEnabled;
|
|
profile.Combat.KiteRange = KiteRange;
|
|
profile.Combat.KiteDelayMs = KiteDelayMs;
|
|
|
|
// Skill write-through is handled by SkillProfileViewModel partial methods
|
|
|
|
_engine.Profiles.Save(profile);
|
|
|
|
// Apply to engine without rebuilding UI (same profile object, just re-apply to systems)
|
|
_suppressProfileChanged = true;
|
|
_engine.ApplyProfile(profile);
|
|
_suppressProfileChanged = false;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DuplicateProfile()
|
|
{
|
|
var profile = _engine.ActiveProfile;
|
|
if (profile is null) return;
|
|
|
|
var newName = $"{profile.Name}_Copy";
|
|
var dupe = _engine.Profiles.Duplicate(profile.Name, newName);
|
|
if (dupe is null) return;
|
|
|
|
RefreshAvailableProfiles();
|
|
_suppressProfileSwitch = true;
|
|
SelectedProfile = newName;
|
|
_suppressProfileSwitch = false;
|
|
SwitchToProfile(newName);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void DeleteProfile()
|
|
{
|
|
var charName = _engine.Cache.CharacterName;
|
|
if (charName is null || SelectedProfile is null) return;
|
|
|
|
// Don't allow deleting the last profile
|
|
if (AvailableProfiles.Count <= 1) return;
|
|
|
|
var toDelete = SelectedProfile;
|
|
_engine.Profiles.Delete(toDelete);
|
|
RefreshAvailableProfiles();
|
|
|
|
// If we deleted the active profile, switch to the first available
|
|
if (_engine.ActiveProfile?.Name == toDelete && AvailableProfiles.Count > 0)
|
|
{
|
|
var fallback = AvailableProfiles[0];
|
|
_suppressProfileSwitch = true;
|
|
SelectedProfile = fallback;
|
|
_suppressProfileSwitch = false;
|
|
SwitchToProfile(fallback);
|
|
}
|
|
}
|
|
|
|
// When the user picks a different profile from the ComboBox, switch immediately
|
|
private bool _suppressProfileSwitch;
|
|
private bool _suppressProfileChanged;
|
|
partial void OnSelectedProfileChanged(string? value)
|
|
{
|
|
if (_suppressProfileSwitch || value is null) return;
|
|
|
|
var charName = _engine.Cache.CharacterName;
|
|
if (charName is null) return;
|
|
|
|
// Don't re-switch if it's already the active profile
|
|
if (_engine.ActiveProfile?.Name == value) return;
|
|
|
|
SwitchToProfile(value);
|
|
}
|
|
|
|
private void SwitchToProfile(string profileName)
|
|
{
|
|
var charName = _engine.Cache.CharacterName;
|
|
if (charName is null) return;
|
|
|
|
_engine.Profiles.AssignToCharacter(charName, profileName);
|
|
var profile = _engine.Profiles.LoadForCharacter(charName);
|
|
_engine.ApplyProfile(profile);
|
|
}
|
|
|
|
private void PopulateFromProfile(CharacterProfile profile)
|
|
{
|
|
HasProfile = true;
|
|
ProfileName = profile.Name;
|
|
|
|
// Flask settings
|
|
LifeFlaskThreshold = profile.Flasks.LifeFlaskThreshold;
|
|
ManaFlaskThreshold = profile.Flasks.ManaFlaskThreshold;
|
|
FlaskCooldownMs = profile.Flasks.FlaskCooldownMs;
|
|
|
|
// Combat settings
|
|
GlobalCooldownMs = profile.Combat.GlobalCooldownMs;
|
|
AttackRange = profile.Combat.AttackRange;
|
|
SafeRange = profile.Combat.SafeRange;
|
|
KiteEnabled = profile.Combat.KiteEnabled;
|
|
KiteRange = profile.Combat.KiteRange;
|
|
KiteDelayMs = profile.Combat.KiteDelayMs;
|
|
|
|
// Skills
|
|
SkillProfiles.Clear();
|
|
foreach (var skill in profile.Skills)
|
|
SkillProfiles.Add(new SkillProfileViewModel(skill));
|
|
|
|
RefreshAvailableProfiles();
|
|
_suppressProfileSwitch = true;
|
|
SelectedProfile = profile.Name;
|
|
_suppressProfileSwitch = false;
|
|
}
|
|
|
|
private void RefreshAvailableProfiles()
|
|
{
|
|
AvailableProfiles.Clear();
|
|
foreach (var name in _engine.Profiles.ListProfiles())
|
|
AvailableProfiles.Add(name);
|
|
}
|
|
|
|
private void OnStateUpdated()
|
|
{
|
|
// Emergency stop: END key
|
|
var endDown = (GetAsyncKeyState(VK_END) & 0x8000) != 0;
|
|
if (endDown && !_endWasDown)
|
|
{
|
|
Serilog.Log.Warning("END pressed — emergency stop");
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(Stop);
|
|
}
|
|
_endWasDown = endDown;
|
|
|
|
// Throttle UI updates to ~10Hz
|
|
var now = Environment.TickCount64;
|
|
if (now - _lastUiUpdate < 100) return;
|
|
_lastUiUpdate = now;
|
|
|
|
var state = _engine.CurrentState;
|
|
if (state is null) return;
|
|
|
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateFromState(state));
|
|
}
|
|
|
|
private void UpdateFromState(GameState state)
|
|
{
|
|
var p = state.Player;
|
|
PlayerPosition = p.HasPosition
|
|
? $"({p.Position.X:F1}, {p.Position.Y:F1})"
|
|
: "—";
|
|
PlayerLife = p.LifeTotal > 0 ? $"{p.LifeCurrent}/{p.LifeTotal} ({p.LifePercent:F0}%)" : "—";
|
|
PlayerMana = p.ManaTotal > 0 ? $"{p.ManaCurrent}/{p.ManaTotal} ({p.ManaPercent:F0}%)" : "—";
|
|
PlayerEs = p.EsTotal > 0 ? $"{p.EsCurrent}/{p.EsTotal} ({p.EsPercent:F0}%)" : "—";
|
|
|
|
AreaInfo = state.AreaHash != 0
|
|
? $"Level {state.AreaLevel} (0x{state.AreaHash:X8})"
|
|
: "—";
|
|
DangerLevel = state.Danger.ToString();
|
|
EntityCount = $"{state.Entities.Count} total";
|
|
HostileCount = $"{state.HostileMonsters.Count} hostile";
|
|
TickInfo = $"Tick {state.TickNumber}, dt={state.DeltaTime * 1000:F0}ms";
|
|
ApmInfo = $"{_engine.CurrentApm} / {250}";
|
|
|
|
// Systems status
|
|
var systems = _engine.Systems;
|
|
var enabled = systems.Count(s => s.IsEnabled);
|
|
SystemsInfo = $"{enabled}/{systems.Count} active";
|
|
|
|
// Navigation
|
|
NavMode = _engine.Nav.Mode.ToString();
|
|
NavStatus = _engine.Nav.Status;
|
|
|
|
// Character name
|
|
if (p.CharacterName is { Length: > 0 })
|
|
CharacterName = p.CharacterName;
|
|
|
|
// Populate available skill names from memory (stripped of "Player" suffix)
|
|
if (p.Skills.Count > 0)
|
|
{
|
|
var names = p.Skills
|
|
.Where(s => s.Name is { Length: > 0 })
|
|
.Select(s => SkillProfileViewModel.CleanSkillName(s.Name))
|
|
.Distinct()
|
|
.OrderBy(n => n)
|
|
.ToList();
|
|
|
|
foreach (var vm in SkillProfiles)
|
|
{
|
|
var current = vm.AvailableSkillNames;
|
|
if (current.Count != names.Count || !current.SequenceEqual(names))
|
|
{
|
|
current.Clear();
|
|
foreach (var n in names)
|
|
current.Add(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Terrain minimap
|
|
UpdateTerrainMinimap(state);
|
|
|
|
// Entity list
|
|
UpdateEntityList(state);
|
|
}
|
|
|
|
private void UpdateEntityList(GameState state)
|
|
{
|
|
// Build lookup of currently checked IDs
|
|
var checkedIds = new HashSet<uint>();
|
|
foreach (var item in Entities)
|
|
if (item.IsChecked) checkedIds.Add(item.Id);
|
|
|
|
// Rebuild the list
|
|
Entities.Clear();
|
|
foreach (var e in state.Entities)
|
|
{
|
|
var shortLabel = e.Category == EntityCategory.AreaTransition && e.TransitionName is not null
|
|
? $"AreaTransition — {e.TransitionName}"
|
|
: GetShortLabel(e.Path);
|
|
var item = new EntityListItem(e.Id, shortLabel, e.Category.ToString(), e.DistanceToPlayer, e.Position.X, e.Position.Y);
|
|
if (checkedIds.Contains(e.Id))
|
|
item.IsChecked = true;
|
|
Entities.Add(item);
|
|
}
|
|
|
|
// Build overlay snapshot from checked (or all) entities
|
|
var showAll = ShowAllEntities;
|
|
var overlayEntries = new List<EntityOverlayEntry>();
|
|
foreach (var item in Entities)
|
|
{
|
|
if (!showAll && !item.IsChecked) continue;
|
|
overlayEntries.Add(new EntityOverlayEntry(item.X, item.Y, item.Label));
|
|
}
|
|
|
|
if (overlayEntries.Count > 0)
|
|
{
|
|
OverlayData = new EntityOverlayData
|
|
{
|
|
SnapshotPlayerPosition = state.Player.Position,
|
|
SnapshotPlayerZ = state.Player.Z,
|
|
Entries = overlayEntries.ToArray(),
|
|
};
|
|
}
|
|
else
|
|
{
|
|
OverlayData = null;
|
|
}
|
|
}
|
|
|
|
private void UpdateTerrainMinimap(GameState state)
|
|
{
|
|
if (state.IsLoading || state.Terrain is null)
|
|
{
|
|
_terrainBasePixels = null;
|
|
_terrainAreaHash = 0;
|
|
var old = TerrainImage;
|
|
TerrainImage = null;
|
|
old?.Dispose();
|
|
return;
|
|
}
|
|
|
|
var terrain = state.Terrain;
|
|
var w = terrain.Width;
|
|
var h = terrain.Height;
|
|
|
|
// Invalidate cache on area change or terrain resize
|
|
if ((state.AreaHash != 0 && state.AreaHash != _terrainAreaHash)
|
|
|| w != _terrainWidth || h != _terrainHeight)
|
|
{
|
|
_terrainBasePixels = null;
|
|
_terrainAreaHash = 0;
|
|
var old = TerrainImage;
|
|
TerrainImage = null;
|
|
old?.Dispose();
|
|
}
|
|
|
|
// Build base pixels from terrain (walkable/blocked only — explored overlay applied per frame)
|
|
if (_terrainBasePixels is null)
|
|
{
|
|
_terrainWidth = w;
|
|
_terrainHeight = h;
|
|
_terrainAreaHash = state.AreaHash;
|
|
_terrainBasePixels = new byte[w * h * 4];
|
|
}
|
|
|
|
// Rebuild base pixels each frame to include explored overlay
|
|
var basePixels = _terrainBasePixels;
|
|
var exploredGrid = _engine.Nav.ExploredGrid;
|
|
var ew = _engine.Nav.ExploredWidth;
|
|
var eh = _engine.Nav.ExploredHeight;
|
|
|
|
for (var y = 0; y < h; y++)
|
|
{
|
|
var srcY = h - 1 - y; // flip Y: game Y-up → bitmap Y-down
|
|
for (var x = 0; x < w; x++)
|
|
{
|
|
var i = (y * w + x) * 4;
|
|
if (terrain.IsWalkable(x, srcY))
|
|
{
|
|
// Check explored status
|
|
var isExplored = exploredGrid is not null
|
|
&& x < ew && srcY < eh
|
|
&& exploredGrid[srcY * ew + x];
|
|
|
|
var gray = isExplored ? (byte)0x80 : (byte)0x30;
|
|
basePixels[i] = gray; // B
|
|
basePixels[i + 1] = gray; // G
|
|
basePixels[i + 2] = gray; // R
|
|
basePixels[i + 3] = 0xFF; // A
|
|
}
|
|
else
|
|
{
|
|
basePixels[i] = 0x00;
|
|
basePixels[i + 1] = 0x00;
|
|
basePixels[i + 2] = 0x00;
|
|
basePixels[i + 3] = 0xFF;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (w < 2 || h < 2) return;
|
|
|
|
// Player grid position — center of the output
|
|
float pgx, pgy;
|
|
if (state.Player.HasPosition)
|
|
{
|
|
pgx = state.Player.Position.X * WorldToGrid;
|
|
pgy = h - 1 - state.Player.Position.Y * WorldToGrid;
|
|
}
|
|
else
|
|
{
|
|
pgx = w * 0.5f;
|
|
pgy = h * 0.5f;
|
|
}
|
|
|
|
var bufSize = MinimapViewSize * MinimapViewSize * 4;
|
|
if (_minimapBuffer is null || _minimapBuffer.Length != bufSize)
|
|
_minimapBuffer = new byte[bufSize];
|
|
var buf = _minimapBuffer;
|
|
Array.Clear(buf, 0, bufSize);
|
|
|
|
var cx = MinimapViewSize * 0.5f;
|
|
var cy = MinimapViewSize * 0.5f;
|
|
|
|
// Sample terrain with -45° rotation (nearest-neighbor, unsafe)
|
|
unsafe
|
|
{
|
|
fixed (byte* srcPtr = basePixels, dstPtr = buf)
|
|
{
|
|
var srcInt = (int*)srcPtr;
|
|
var dstInt = (int*)dstPtr;
|
|
for (var ry = 0; ry < MinimapViewSize; ry++)
|
|
{
|
|
var dy = ry - cy;
|
|
var baseX = -dy * Sin45 + pgx;
|
|
var baseY = dy * Cos45 + pgy;
|
|
for (var rx = 0; rx < MinimapViewSize; rx++)
|
|
{
|
|
var dx = rx - cx;
|
|
var sx = (int)(dx * Cos45 + baseX);
|
|
var sy = (int)(dx * Sin45 + baseY);
|
|
if ((uint)sx >= (uint)w || (uint)sy >= (uint)h) continue;
|
|
|
|
dstInt[ry * MinimapViewSize + rx] = srcInt[sy * w + sx];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw entity dots
|
|
var stride = MinimapViewSize * 4;
|
|
foreach (var e in state.Entities)
|
|
{
|
|
if (e.Position == Vector2.Zero) continue;
|
|
|
|
byte db, dg, dr;
|
|
int dotRadius;
|
|
switch (e.Category)
|
|
{
|
|
case EntityCategory.Monster when e.IsAlive:
|
|
db = 0x44; dg = 0x44; dr = 0xFF; dotRadius = 3; break; // red
|
|
case EntityCategory.Npc:
|
|
db = 0x00; dg = 0x8C; dr = 0xFF; dotRadius = 3; break; // orange
|
|
case EntityCategory.AreaTransition:
|
|
db = 0xFF; dg = 0xFF; dr = 0x00; dotRadius = 4; break; // cyan
|
|
case EntityCategory.Chest when !e.IsAlive: // opened chests
|
|
continue;
|
|
case EntityCategory.Chest:
|
|
db = 0x00; dg = 0xD4; dr = 0xFF; dotRadius = 3; break; // gold
|
|
default:
|
|
continue;
|
|
}
|
|
|
|
var edx = e.Position.X * WorldToGrid - pgx;
|
|
var edy = (h - 1 - e.Position.Y * WorldToGrid) - pgy;
|
|
var esx = (int)(edx * Cos45 + edy * Sin45 + cx);
|
|
var esy = (int)(-edx * Sin45 + edy * Cos45 + cy);
|
|
|
|
if (esx >= 0 && esx < MinimapViewSize && esy >= 0 && esy < MinimapViewSize)
|
|
DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, esx, esy, dotRadius, db, dg, dr);
|
|
}
|
|
|
|
// Draw nav path as green line
|
|
var navPath = _engine.Nav.CurrentPath;
|
|
if (navPath is { Count: > 1 })
|
|
{
|
|
for (var i = 0; i < navPath.Count; i++)
|
|
{
|
|
var px = navPath[i].X * WorldToGrid - pgx;
|
|
var py = (h - 1 - navPath[i].Y * WorldToGrid) - pgy;
|
|
var ex = (int)(px * Cos45 + py * Sin45 + cx);
|
|
var ey = (int)(-px * Sin45 + py * Cos45 + cy);
|
|
|
|
if (ex >= 0 && ex < MinimapViewSize && ey >= 0 && ey < MinimapViewSize)
|
|
DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, ex, ey, 2, 0x50, 0xB9, 0x3F);
|
|
}
|
|
}
|
|
|
|
// Draw player dot at center (white)
|
|
if (state.Player.HasPosition)
|
|
DrawDot(buf, stride, MinimapViewSize, MinimapViewSize, (int)cx, (int)cy, 4, 0xFF, 0xFF, 0xFF);
|
|
|
|
// Create WriteableBitmap
|
|
var bmp = new WriteableBitmap(
|
|
new PixelSize(MinimapViewSize, MinimapViewSize),
|
|
new Avalonia.Vector(96, 96),
|
|
Avalonia.Platform.PixelFormat.Bgra8888,
|
|
Avalonia.Platform.AlphaFormat.Premul);
|
|
|
|
using (var fb = bmp.Lock())
|
|
{
|
|
Marshal.Copy(buf, 0, fb.Address, buf.Length);
|
|
}
|
|
|
|
var old2 = TerrainImage;
|
|
TerrainImage = bmp;
|
|
old2?.Dispose();
|
|
}
|
|
|
|
private static void DrawDot(byte[] buf, int stride, int bw, int bh, int cx, int cy, int radius, byte b, byte g, byte r)
|
|
{
|
|
var outer = radius + 1;
|
|
for (var dy = -outer; dy <= outer; dy++)
|
|
{
|
|
for (var dx = -outer; dx <= outer; dx++)
|
|
{
|
|
var sx = cx + dx;
|
|
var sy = cy + dy;
|
|
if (sx < 0 || sx >= bw || sy < 0 || sy >= bh) continue;
|
|
|
|
var dist = MathF.Sqrt(dx * dx + dy * dy);
|
|
if (dist > radius + 0.5f) continue;
|
|
|
|
var alpha = Math.Clamp(radius + 0.5f - dist, 0f, 1f);
|
|
var i = sy * stride + sx * 4;
|
|
buf[i] = (byte)(b * alpha + buf[i] * (1 - alpha));
|
|
buf[i + 1] = (byte)(g * alpha + buf[i + 1] * (1 - alpha));
|
|
buf[i + 2] = (byte)(r * alpha + buf[i + 2] * (1 - alpha));
|
|
buf[i + 3] = (byte)(Math.Max(alpha, buf[i + 3] / 255f) * 255);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetShortLabel(string? path)
|
|
{
|
|
if (path is null) return "?";
|
|
// Strip @N instance suffix
|
|
var atIdx = path.LastIndexOf('@');
|
|
if (atIdx > 0) path = path[..atIdx];
|
|
// Take last segment
|
|
var slashIdx = path.LastIndexOf('/');
|
|
return slashIdx >= 0 ? path[(slashIdx + 1)..] : path;
|
|
}
|
|
|
|
public void Shutdown()
|
|
{
|
|
_engine.Stop();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_engine.Dispose();
|
|
}
|
|
}
|