diff --git a/entities.json b/entities.json index 7383399..aa69ea4 100644 --- a/entities.json +++ b/entities.json @@ -14,9 +14,12 @@ "Metadata/Chests/LeagueIncursion/EncounterChest", "Metadata/Chests/MossyChest11", "Metadata/Chests/MossyChest13", + "Metadata/Chests/MossyChest14", "Metadata/Chests/MossyChest20", "Metadata/Chests/MossyChest21", "Metadata/Chests/MossyChest26", + "Metadata/Chests/MuddyChest1", + "Metadata/Critters/BloodWorm/BloodWormBrown", "Metadata/Critters/Chicken/Chicken_kingsmarch", "Metadata/Critters/Crow/Crow", "Metadata/Critters/Ferret/Ferret", @@ -64,6 +67,8 @@ "Metadata/Monsters/Hags/Objects/BossRoomMinimapIcon", "Metadata/Monsters/Hags/UrchinHag1", "Metadata/Monsters/Hags/UrchinHagBoss", + "Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeEmerge1", + "Metadata/Monsters/HuhuGrub/HuhuGrubLarvaeRanged1", "Metadata/Monsters/InvisibleFire/MDCarrionCroneWave", "Metadata/Monsters/MonsterMods/OnDeathColdExplosionParent", "Metadata/Monsters/Urchins/MeleeUrchin1", @@ -148,6 +153,7 @@ "Metadata/Terrain/Tools/AudioTools/G1_2/ForestEntrance", "Metadata/Terrain/Tools/AudioTools/G1_2/HagArena", "Metadata/Terrain/Tools/AudioTools/G1_2/RiverRapidsMedium", + "Metadata/Terrain/Tools/AudioTools/G1_3/TunnelA", "Metadata/Terrain/Tools/AudioTools/G1_4/WitchHutIndoorAudio", "Metadata/Terrain/Tools/AudioTools/G1_Town/FurnaceFireAudio", "Metadata/Terrain/Tools/AudioTools/G1_Town/InsideWaterMillAudio" diff --git a/profiles/GooGoGaaGa_Default.json b/profiles/GooGoGaaGa_Default.json new file mode 100644 index 0000000..14b9900 --- /dev/null +++ b/profiles/GooGoGaaGa_Default.json @@ -0,0 +1,158 @@ +{ + "Name": "GooGoGaaGa_Default", + "CreatedAt": "2026-03-03T16:37:43.1785247Z", + "LastModified": "2026-03-03T16:37:43.1785248Z", + "Flasks": { + "LifeFlaskThreshold": 50, + "ManaFlaskThreshold": 50, + "FlaskCooldownMs": 4000, + "LifeFlaskScanCode": 2, + "ManaFlaskScanCode": 3 + }, + "Combat": { + "GlobalCooldownMs": 500, + "AttackRange": 600, + "SafeRange": 400, + "KiteEnabled": false, + "KiteRange": 300, + "KiteDelayMs": 200 + }, + "Skills": [ + { + "SlotIndex": 0, + "Label": "LMB", + "InputType": "LeftClick", + "ScanCode": 0, + "Priority": 0, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 1, + "Label": "RMB", + "InputType": "RightClick", + "ScanCode": 0, + "Priority": 1, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 2, + "Label": "MMB", + "InputType": "MiddleClick", + "ScanCode": 0, + "Priority": 2, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 3, + "Label": "Q", + "InputType": "KeyPress", + "ScanCode": 16, + "Priority": 3, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 4, + "Label": "E", + "InputType": "KeyPress", + "ScanCode": 18, + "Priority": 4, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 5, + "Label": "R", + "InputType": "KeyPress", + "ScanCode": 19, + "Priority": 5, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 6, + "Label": "T", + "InputType": "KeyPress", + "ScanCode": 20, + "Priority": 6, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 7, + "Label": "F", + "InputType": "KeyPress", + "ScanCode": 33, + "Priority": 7, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + } + ] +} \ No newline at end of file diff --git a/profiles/GooGoGaaGa_Default_Copy.json b/profiles/GooGoGaaGa_Default_Copy.json new file mode 100644 index 0000000..deebd77 --- /dev/null +++ b/profiles/GooGoGaaGa_Default_Copy.json @@ -0,0 +1,166 @@ +{ + "Name": "GooGoGaaGa_Default_Copy", + "CreatedAt": "2026-03-03T16:40:21.060139Z", + "LastModified": "2026-03-03T16:57:12.368683Z", + "Flasks": { + "LifeFlaskThreshold": 50, + "ManaFlaskThreshold": 50, + "FlaskCooldownMs": 4000, + "LifeFlaskScanCode": 2, + "ManaFlaskScanCode": 3 + }, + "Combat": { + "GlobalCooldownMs": 500, + "AttackRange": 600, + "SafeRange": 400, + "KiteEnabled": false, + "KiteRange": 300, + "KiteDelayMs": 200 + }, + "Skills": [ + { + "SlotIndex": 0, + "Label": "LMB", + "SkillName": "MeleeSpearOffHand", + "InputType": "LeftClick", + "ScanCode": 0, + "Priority": 2, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 10, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 1, + "Label": "RMB", + "SkillName": "SpearThrow", + "InputType": "RightClick", + "ScanCode": 0, + "Priority": 1, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 800, + "TargetSelection": "All", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 2, + "Label": "MMB", + "SkillName": "Twister", + "InputType": "MiddleClick", + "ScanCode": 0, + "Priority": 0, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 1000, + "TargetSelection": "All", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 3, + "Label": "Q", + "SkillName": null, + "InputType": "KeyPress", + "ScanCode": 16, + "Priority": 3, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 4, + "Label": "E", + "SkillName": null, + "InputType": "KeyPress", + "ScanCode": 18, + "Priority": 4, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 5, + "Label": "R", + "SkillName": null, + "InputType": "KeyPress", + "ScanCode": 19, + "Priority": 5, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 6, + "Label": "T", + "SkillName": null, + "InputType": "KeyPress", + "ScanCode": 20, + "Priority": 6, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + }, + { + "SlotIndex": 7, + "Label": "F", + "SkillName": null, + "InputType": "KeyPress", + "ScanCode": 33, + "Priority": 7, + "IsEnabled": true, + "CooldownMs": 300, + "RangeMin": 0, + "RangeMax": 600, + "TargetSelection": "Nearest", + "RequiresTarget": true, + "IsAura": false, + "IsMovementSkill": false, + "MinMonstersInRange": 1, + "MaintainPressed": false + } + ] +} \ No newline at end of file diff --git a/profiles/_assignments.json b/profiles/_assignments.json new file mode 100644 index 0000000..62b7268 --- /dev/null +++ b/profiles/_assignments.json @@ -0,0 +1,3 @@ +{ + "GooGoGaaGa": "GooGoGaaGa_Default_Copy" +} \ No newline at end of file diff --git a/src/Automata.Navigation/NavigationTypes.cs b/src/Automata.Navigation/NavigationTypes.cs index 8b2c77a..65080af 100644 --- a/src/Automata.Navigation/NavigationTypes.cs +++ b/src/Automata.Navigation/NavigationTypes.cs @@ -120,6 +120,9 @@ public class MinimapConfig // Explored radius: pixels around player position to mark as explored on world map public int ExploredRadius { get; set; } = 75; + // Beyond this canvas-pixel distance from the player, explored cells render as lighter gray + public int ExploredFarRadius { get; set; } = 120; + // Temporal smoothing: majority vote over ring buffer (walls only) public int TemporalFrameCount { get; set; } = 5; public int WallTemporalThreshold { get; set; } = 3; diff --git a/src/Automata.Navigation/WorldMap.cs b/src/Automata.Navigation/WorldMap.cs index 0206e4c..4435fd5 100644 --- a/src/Automata.Navigation/WorldMap.cs +++ b/src/Automata.Navigation/WorldMap.cs @@ -483,13 +483,21 @@ public class WorldMap : IDisposable var y0 = Math.Clamp(cy - half, 0, _canvasSize - viewSize); var roi = new Mat(_canvas, new Rect(x0, y0, viewSize, viewSize)); + var farR2 = _config.ExploredFarRadius * _config.ExploredFarRadius; + var nearColor = new Vec3b(104, 64, 31); + var farColor = new Vec3b(90, 90, 90); + using var colored = new Mat(viewSize, viewSize, MatType.CV_8UC3, new Scalar(23, 17, 13)); for (var r = 0; r < viewSize; r++) for (var c = 0; c < viewSize; c++) { var v = roi.At(r, c); if (v == (byte)MapCell.Explored) - colored.Set(r, c, new Vec3b(104, 64, 31)); + { + var dx = c - half; + var dy = r - half; + colored.Set(r, c, dx * dx + dy * dy > farR2 ? farColor : nearColor); + } else if (v == (byte)MapCell.Wall) colored.Set(r, c, new Vec3b(26, 45, 61)); else if (v == (byte)MapCell.Fog) diff --git a/src/Automata.Ui/ViewModels/MemoryViewModel.cs b/src/Automata.Ui/ViewModels/MemoryViewModel.cs index fc5d58c..9f21e9b 100644 --- a/src/Automata.Ui/ViewModels/MemoryViewModel.cs +++ b/src/Automata.Ui/ViewModels/MemoryViewModel.cs @@ -859,6 +859,13 @@ public partial class MemoryViewModel : ObservableObject if (e.HasPosition) parts.Add($"({e.X:F0},{e.Y:F0})"); + if (e.TransitionName is not null) + { + parts.Add(e.IsTargetable ? "targetable" : "NOT targetable"); + if (e.TransitionState >= 0) + parts.Add($"state:{e.TransitionState}"); + } + if (e.HasVitals) parts.Add($"HP:{e.LifeCurrent}/{e.LifeTotal}"); @@ -882,7 +889,11 @@ public partial class MemoryViewModel : ObservableObject needed.Add(("Path:", e.Path, true)); if (e.TransitionName is not null) + { needed.Add(("Destination:", e.TransitionName, true)); + needed.Add(("TransitionState:", e.TransitionState.ToString(), e.TransitionState >= 0)); + needed.Add(("IsTargetable:", e.IsTargetable.ToString(), e.IsTargetable)); + } if (e.HasPosition) needed.Add(("Pos:", $"({e.X:F1}, {e.Y:F1}, {e.Z:F1})", true)); @@ -1197,4 +1208,16 @@ public partial class MemoryViewModel : ObservableObject ScanResult = _reader.Diagnostics!.ScanActorDiff(); } + + [RelayCommand] + private void ScanQuestFlagsExecute() + { + if (_reader is null || !_reader.IsAttached) + { + ScanResult = "Error: not attached"; + return; + } + + ScanResult = _reader.Diagnostics!.ScanQuestFlags(); + } } diff --git a/src/Automata.Ui/ViewModels/RobotoViewModel.cs b/src/Automata.Ui/ViewModels/RobotoViewModel.cs index 1aa4310..20e083d 100644 --- a/src/Automata.Ui/ViewModels/RobotoViewModel.cs +++ b/src/Automata.Ui/ViewModels/RobotoViewModel.cs @@ -1,8 +1,12 @@ 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; @@ -62,6 +66,11 @@ public partial class EntityListItem : ObservableObject 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; @@ -84,15 +93,48 @@ public partial class RobotoViewModel : ObservableObject, IDisposable // 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 Entities { get; } = []; + // ── Profile Editor ── + [ObservableProperty] private string _characterName = "—"; + [ObservableProperty] private bool _hasProfile; + [ObservableProperty] private string _profileName = "—"; + [ObservableProperty] private string? _selectedProfile; + public ObservableCollection AvailableProfiles { get; } = []; + public ObservableCollection 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; + /// /// Thread-safe snapshot for the overlay layer (written on UI thread, read on overlay thread). /// @@ -103,14 +145,14 @@ public partial class RobotoViewModel : ObservableObject, IDisposable /// public static volatile GameDataCache? SharedCache; - public RobotoViewModel() + public RobotoViewModel(IClientLogWatcher logWatcher) { var config = new BotConfig(); var reader = new GameMemoryReader(); var humanizer = new Humanizer(config); var input = new InterceptionInputController(humanizer); - _engine = new BotEngine(config, reader, input); + _engine = new BotEngine(config, reader, input, humanizer); _engine.StatusChanged += status => { @@ -118,6 +160,17 @@ public partial class RobotoViewModel : ObservableObject, IDisposable }; _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] @@ -147,6 +200,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable EntityCount = "—"; HostileCount = "—"; TickInfo = "—"; + ApmInfo = "0"; NavMode = "Idle"; NavStatus = "—"; Entities.Clear(); @@ -167,8 +221,147 @@ public partial class RobotoViewModel : ObservableObject, IDisposable _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; @@ -197,6 +390,7 @@ public partial class RobotoViewModel : ObservableObject, IDisposable 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; @@ -207,6 +401,35 @@ public partial class RobotoViewModel : ObservableObject, IDisposable 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); } @@ -255,6 +478,220 @@ public partial class RobotoViewModel : ObservableObject, IDisposable } } + 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 "?"; diff --git a/src/Automata.Ui/ViewModels/SkillProfileViewModel.cs b/src/Automata.Ui/ViewModels/SkillProfileViewModel.cs new file mode 100644 index 0000000..cba7d65 --- /dev/null +++ b/src/Automata.Ui/ViewModels/SkillProfileViewModel.cs @@ -0,0 +1,92 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Roboto.Core; + +namespace Automata.Ui.ViewModels; + +public partial class SkillProfileViewModel : ObservableObject +{ + private readonly SkillProfile _model; + + public SkillProfileViewModel(SkillProfile model) + { + _model = model; + _slotIndex = model.SlotIndex; + _label = model.Label; + _skillName = model.SkillName; + _inputType = model.InputType; + _scanCode = model.ScanCode; + _priority = model.Priority; + _isEnabled = model.IsEnabled; + _cooldownMs = model.CooldownMs; + _rangeMin = model.RangeMin; + _rangeMax = model.RangeMax; + _targetSelection = model.TargetSelection; + _requiresTarget = model.RequiresTarget; + _isAura = model.IsAura; + _isMovementSkill = model.IsMovementSkill; + _minMonstersInRange = model.MinMonstersInRange; + _maintainPressed = model.MaintainPressed; + } + + public SkillProfile Model => _model; + + [ObservableProperty] private int _slotIndex; + [ObservableProperty] private string _label; + [ObservableProperty] private string? _skillName; + [ObservableProperty] private SkillInputType _inputType; + [ObservableProperty] private ushort _scanCode; + [ObservableProperty] private int _priority; + [ObservableProperty] private bool _isEnabled; + [ObservableProperty] private int _cooldownMs; + [ObservableProperty] private float _rangeMin; + [ObservableProperty] private float _rangeMax; + [ObservableProperty] private TargetSelection _targetSelection; + [ObservableProperty] private bool _requiresTarget; + [ObservableProperty] private bool _isAura; + [ObservableProperty] private bool _isMovementSkill; + [ObservableProperty] private int _minMonstersInRange; + [ObservableProperty] private bool _maintainPressed; + + // Available skill names from memory (shared, set by parent VM) + public ObservableCollection AvailableSkillNames { get; } = []; + + // UI expand/collapse + [ObservableProperty] private bool _isExpanded; + + // Write-through on property changes + partial void OnLabelChanged(string value) => _model.Label = value; + partial void OnSkillNameChanged(string? value) => _model.SkillName = value; + partial void OnInputTypeChanged(SkillInputType value) => _model.InputType = value; + partial void OnScanCodeChanged(ushort value) => _model.ScanCode = value; + partial void OnPriorityChanged(int value) => _model.Priority = value; + partial void OnIsEnabledChanged(bool value) => _model.IsEnabled = value; + partial void OnCooldownMsChanged(int value) => _model.CooldownMs = value; + partial void OnRangeMinChanged(float value) => _model.RangeMin = value; + partial void OnRangeMaxChanged(float value) => _model.RangeMax = value; + partial void OnTargetSelectionChanged(TargetSelection value) => _model.TargetSelection = value; + partial void OnRequiresTargetChanged(bool value) => _model.RequiresTarget = value; + partial void OnIsAuraChanged(bool value) => _model.IsAura = value; + partial void OnIsMovementSkillChanged(bool value) => _model.IsMovementSkill = value; + partial void OnMinMonstersInRangeChanged(int value) => _model.MinMonstersInRange = value; + partial void OnMaintainPressedChanged(bool value) => _model.MaintainPressed = value; + + // ComboBox binding sources + public static SkillInputType[] InputTypes { get; } = + Enum.GetValues(); + + public static TargetSelection[] TargetSelections { get; } = + Enum.GetValues(); + + /// + /// Strips "Player" suffix from a raw memory skill name for display. + /// e.g. "SpearThrowPlayer" → "SpearThrow", "WhirlingSlashPlayer" → "WhirlingSlash" + /// + public static string CleanSkillName(string? raw) + { + if (raw is null) return ""; + if (raw.EndsWith("Player", StringComparison.Ordinal)) + return raw[..^6]; + return raw; + } +} diff --git a/src/Automata.Ui/Views/MainWindow.axaml b/src/Automata.Ui/Views/MainWindow.axaml index 0a4a15c..cba5cc5 100644 --- a/src/Automata.Ui/Views/MainWindow.axaml +++ b/src/Automata.Ui/Views/MainWindow.axaml @@ -776,6 +776,8 @@ Padding="10,4" FontWeight="Bold" Margin="0,0,6,4" />