simulation done
This commit is contained in:
parent
0e7de0a5f3
commit
05bbcb244f
55 changed files with 4367 additions and 756 deletions
188
docs/architecture-overview.md
Normal file
188
docs/architecture-overview.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Nexus — Architecture Overview
|
||||
|
||||
## What Is This
|
||||
|
||||
A modular C# automation framework for POE2. The system reads game memory, builds a structured game state, runs AI systems (combat, navigation, threat assessment), and emits input commands. A separate trade pipeline monitors the trade site via a Node.js daemon and executes buy flows.
|
||||
|
||||
When the real game isn't available, a standalone **Simulator** replaces the memory layer with a procedural game world — same bot systems, same interfaces, zero game dependency.
|
||||
|
||||
## Solution Structure
|
||||
|
||||
```
|
||||
Nexus.sln (net8.0-windows10.0.19041.0)
|
||||
│
|
||||
├── Core Layer (shared types, no dependencies)
|
||||
│ └── Nexus.Core
|
||||
│
|
||||
├── Infrastructure Layer (reads from external sources)
|
||||
│ ├── Nexus.GameOffsets Pure offset structs for memory reading
|
||||
│ ├── Nexus.Memory Process memory reading (RPM, pattern scan)
|
||||
│ ├── Nexus.Screen Screen capture, OCR, grid detection
|
||||
│ ├── Nexus.Log Client.txt game log watcher
|
||||
│ └── Nexus.Input Win32 SendInput / Interception driver
|
||||
│
|
||||
├── Data Layer (transforms raw data into typed game state)
|
||||
│ └── Nexus.Data EntityMapper, EntityClassifier, GameDataCache, MemoryPoller, GameStateEnricher
|
||||
│
|
||||
├── Logic Layer (AI systems that decide what to do)
|
||||
│ ├── Nexus.Systems ThreatSystem, MovementSystem, CombatSystem, ResourceSystem, LootSystem
|
||||
│ ├── Nexus.Engine BotEngine (orchestrator), AreaProgressionSystem, MovementKeyTracker
|
||||
│ └── Nexus.Pathfinding NavigationController, A* PathFinder
|
||||
│
|
||||
├── Game Interaction Layer (acts on the game)
|
||||
│ ├── Nexus.Game Window focus, input sending, clipboard
|
||||
│ ├── Nexus.Items Item parsing via Sidekick
|
||||
│ ├── Nexus.Inventory Stash/inventory grid management
|
||||
│ ├── Nexus.Navigation Minimap-based real-time navigation
|
||||
│ └── Nexus.Trade Trade daemon IPC (Node.js Playwright)
|
||||
│
|
||||
├── Orchestration Layer (top-level coordination)
|
||||
│ ├── Nexus.Bot BotOrchestrator, Trade/Mapping/Crafting executors
|
||||
│ └── Nexus.Ui Avalonia 11.2 desktop GUI (entry point)
|
||||
│
|
||||
└── Testing Layer
|
||||
└── Nexus.Simulator Standalone game world for bot testing
|
||||
```
|
||||
|
||||
## Dependency Flow
|
||||
|
||||
```
|
||||
Nexus.Core
|
||||
│
|
||||
├── Nexus.GameOffsets ──→ Nexus.Memory ──→ Nexus.Data ──→ Nexus.Engine
|
||||
│ │ │
|
||||
├── Nexus.Input │ Nexus.Systems
|
||||
│ │ │
|
||||
├── Nexus.Screen ◄───────────────────────────────┘ │
|
||||
├── Nexus.Game │
|
||||
├── Nexus.Log │
|
||||
│ │
|
||||
├── Nexus.Pathfinding ◄─────────────────────────────────────────┘
|
||||
│
|
||||
├── Nexus.Items, Nexus.Inventory, Nexus.Navigation, Nexus.Trade
|
||||
│
|
||||
├── Nexus.Bot (consumes all above)
|
||||
│
|
||||
├── Nexus.Ui (DI hub, entry point, consumes all)
|
||||
│
|
||||
└── Nexus.Simulator (Core, Data, Systems, Pathfinding — NOT Memory/Input/Screen)
|
||||
```
|
||||
|
||||
## Data Flow — Per Tick
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MemoryPoller Thread (60Hz hot / 10Hz cold) │
|
||||
│ │
|
||||
│ Hot tick (4 RPM calls): │
|
||||
│ Camera matrix, Player position, Player vitals, Loading state │
|
||||
│ │
|
||||
│ Cold tick (full hierarchical read): │
|
||||
│ Entity tree traversal → classification → EntitySnapshot[] │
|
||||
│ Terrain grid, Skills, Quests, UI elements │
|
||||
│ │
|
||||
│ Writes to GameDataCache (volatile references, lock-free) │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ BotEngine Logic Thread (25Hz default) │
|
||||
│ │
|
||||
│ 1. Read latest GameState from cache │
|
||||
│ 2. GameStateEnricher → NearestEnemies, ThreatMap, DangerLevel │
|
||||
│ 3. Clear ActionQueue │
|
||||
│ 4. NavigationController.Update() → path, DesiredDirection │
|
||||
│ 5. Run systems in priority order: │
|
||||
│ ThreatSystem (50) → MovementSystem (100) → │
|
||||
│ AreaProgressionSystem (199) → NavigationSystem (200) → │
|
||||
│ CombatSystem (300) → ResourceSystem (400) → LootSystem (500) │
|
||||
│ 6. ActionQueue.Resolve() → conflict resolution │
|
||||
│ 7. ExecuteActions() → IInputController │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ IInputController (Win32 SendInput or Interception driver) │
|
||||
│ │
|
||||
│ WASD keys (scan codes), mouse movement (Bézier curves), │
|
||||
│ skill casts, flask presses, clicks │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Action Resolution
|
||||
|
||||
Systems submit actions to a shared `ActionQueue`. The queue resolves conflicts:
|
||||
|
||||
| Action Type | Behavior |
|
||||
|-------------|----------|
|
||||
| FlaskAction | Always passes through |
|
||||
| MoveAction (priority ≤ 10) | Urgent flee — blocks CastAction |
|
||||
| MoveAction (priority > 10) | Normal move — allows CastAction alongside |
|
||||
| CastAction | Passes unless blocked by urgent flee |
|
||||
| Other (Key, Click, Chat) | Always passes through |
|
||||
|
||||
Priority values: lower number = higher priority. ThreatSystem uses priority 5 for emergency flee (blocks all casting).
|
||||
|
||||
## System Priority Table
|
||||
|
||||
| Priority | System | Purpose |
|
||||
|----------|--------|---------|
|
||||
| 50 | ThreatSystem | Emergency flee on High/Critical danger |
|
||||
| 100 | MovementSystem | Soft avoidance via inverse-square repulsion |
|
||||
| 199 | AreaProgressionSystem | Quest-driven area traversal and transitions |
|
||||
| 200 | NavigationSystem | Submits NavigationController's direction |
|
||||
| 300 | CombatSystem | Skill rotation, target selection, kiting |
|
||||
| 400 | ResourceSystem | Flask usage on life/mana thresholds |
|
||||
| 500 | LootSystem | Item pickup (stub) |
|
||||
|
||||
## Thread Model
|
||||
|
||||
| Thread | Rate | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| MemoryPoller | 60Hz hot, 10Hz cold | Read game memory → GameDataCache |
|
||||
| BotEngine Logic | 25Hz | Run AI systems → emit actions |
|
||||
| Render (Simulator only) | vsync | ImGui + Veldrid drawing |
|
||||
| Trade Daemon | event-driven | Node.js Playwright → stdin/stdout JSON IPC |
|
||||
| Log Watcher | 200ms poll | Client.txt → area/whisper/trade events |
|
||||
|
||||
Cross-thread safety: GameDataCache uses `volatile` references. No locks — writer (MemoryPoller) atomically swaps reference types, readers (BotEngine) get consistent snapshots.
|
||||
|
||||
## Simulator Architecture
|
||||
|
||||
When the real game isn't available, `Nexus.Simulator` replaces the memory pipeline:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Nexus.Simulator.exe │
|
||||
│ │
|
||||
│ ┌──────────┐ GameState ┌──────────────────┐ │
|
||||
│ │ SimWorld │───────────────►│ GameDataCache │ │
|
||||
│ │ (terrain, │ SimPoller + │ (same as prod) │ │
|
||||
│ │ enemies, │ StateBuilder └────────┬─────────┘ │
|
||||
│ │ player) │ │ │
|
||||
│ └─────┬────┘ ┌────────▼─────────┐ │
|
||||
│ │ │ Bot Systems │ │
|
||||
│ │◄─────────────────────│ (unchanged) │ │
|
||||
│ │ SimInputController └──────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────▼───────────────────────────────────────┐ │
|
||||
│ │ ImGui + Veldrid Renderer (isometric 2D) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Bot systems don't know they're in a simulation — they see identical `GameState` objects and emit actions to `IInputController`.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Immutable records (GameState, EntitySnapshot) | Thread-safe sharing without locks |
|
||||
| Priority-based ActionQueue | Natural conflict resolution without hardcoded if-else |
|
||||
| Two-tier memory polling (hot/cold) | Balance responsiveness (position at 60Hz) with CPU cost (entities at 10Hz) |
|
||||
| Scan codes over virtual keys | Games read hardware scan codes, not VK codes |
|
||||
| Separate Memory → Data layers | Memory reads raw bytes; Data interprets and classifies. Clean testability. |
|
||||
| IInputController interface | Swap real Win32 input for simulated input without changing bot logic |
|
||||
| BFS exploration + A* pathfinding | BFS finds what to explore; A* finds how to get there |
|
||||
| CharacterProfile auto-detection | Automatically applies combat/flask settings per character name |
|
||||
| External daemons (Trade, OCR) | Isolate browser/OCR concerns from main process |
|
||||
158
docs/core.md
Normal file
158
docs/core.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Nexus.Core — Shared Types & Abstractions
|
||||
|
||||
The foundation layer. Every other project depends on Core. Contains no logic — only types, interfaces, configuration, and utilities.
|
||||
|
||||
## GameState
|
||||
|
||||
Central immutable snapshot updated once per tick. All systems read from this; none mutate it.
|
||||
|
||||
```
|
||||
GameState
|
||||
├── Timing: TickNumber, DeltaTime, TimestampMs
|
||||
├── Player: PlayerState
|
||||
│ ├── CharacterName, Position, Z, HasPosition
|
||||
│ ├── Life/Mana/ES: Current, Total, Percent (derived)
|
||||
│ ├── ActionId (actor animation state)
|
||||
│ ├── Skills: SkillState[] (cooldowns, charges, cast state)
|
||||
│ ├── Flasks: FlaskState[] (charges, active, cooldown)
|
||||
│ └── Buffs: Buff[] (name, duration, charges, isDebuff)
|
||||
├── Entities: EntitySnapshot[] (all game entities)
|
||||
├── HostileMonsters: EntitySnapshot[] (alive monsters, filtered)
|
||||
├── NearbyLoot: EntitySnapshot[] (world items, filtered)
|
||||
├── NearestEnemies: EntitySnapshot[] (sorted by distance — enriched)
|
||||
├── Terrain: WalkabilitySnapshot (grid, offsets)
|
||||
├── Area: AreaHash, AreaLevel, CurrentAreaName
|
||||
├── UI: IsLoading, IsEscapeOpen, CameraMatrix
|
||||
├── Quests: ActiveQuests[], UiQuests[], Quests[] (enriched with paths)
|
||||
├── Threats: ThreatMap (zone buckets, centroid, rarity flags)
|
||||
├── Danger: DangerLevel (Safe/Low/Medium/High/Critical — enriched)
|
||||
└── GroundEffects: GroundEffect[] (hazard positions)
|
||||
```
|
||||
|
||||
## EntitySnapshot
|
||||
|
||||
Immutable record for any game entity:
|
||||
|
||||
- **Identity**: Id (uint), Path, Metadata, Category (EntityCategory enum)
|
||||
- **Spatial**: Position (Vector2), Z, DistanceToPlayer
|
||||
- **Combat**: IsAlive, LifeCurrent/Total, IsTargetable, ThreatLevel, Rarity, ModNames
|
||||
- **State**: ActionId, IsAttacking, IsMoving, Components (HashSet)
|
||||
- **Special**: TransitionName/State, ItemBaseName, IsQuestItem, LabelOffset
|
||||
|
||||
**EntityCategory** (17 types): Unknown, Player, Monster, Npc, WorldItem, Chest, Shrine, Portal, AreaTransition, Effect, Terrain, MiscObject, Waypoint, Door, Doodad, TownPortal, Critter
|
||||
|
||||
**MonsterRarity**: White, Magic, Rare, Unique
|
||||
|
||||
**MonsterThreatLevel**: None, Normal, Magic, Rare, Unique
|
||||
|
||||
## Actions
|
||||
|
||||
Polymorphic action system. All inherit from `BotAction` with a `Priority` field.
|
||||
|
||||
| Action | Fields | Purpose |
|
||||
|--------|--------|---------|
|
||||
| MoveAction | Direction (Vector2) | WASD movement |
|
||||
| CastAction | SkillScanCode, TargetScreenPos or TargetEntityId | Skill usage |
|
||||
| FlaskAction | FlaskScanCode | Flask consumption |
|
||||
| KeyAction | ScanCode, Type (Press/Down/Up) | Raw keyboard |
|
||||
| ClickAction | ScreenPosition, Type (Left/Right/Middle) | Mouse click |
|
||||
| ChatAction | Message | Send chat message |
|
||||
| WaitAction | DurationMs | Delay |
|
||||
|
||||
## ActionQueue
|
||||
|
||||
Manages conflict resolution between competing system outputs.
|
||||
|
||||
**Resolve() rules:**
|
||||
1. FlaskActions always pass through
|
||||
2. Get highest-priority MoveAction and CastAction
|
||||
3. If MoveAction priority ≤ 10 (urgent flee): include move, **block** cast
|
||||
4. Else: include both move and cast
|
||||
5. All other action types pass through
|
||||
|
||||
## ISystem Interface
|
||||
|
||||
```csharp
|
||||
public interface ISystem
|
||||
{
|
||||
int Priority { get; } // Execution order (lower = first)
|
||||
string Name { get; }
|
||||
bool IsEnabled { get; set; }
|
||||
void Update(GameState state, ActionQueue actions);
|
||||
}
|
||||
```
|
||||
|
||||
**SystemPriority constants**: Threat=50, Movement=100, Navigation=200, Combat=300, Resource=400, Loot=500
|
||||
|
||||
## IInputController Interface
|
||||
|
||||
Abstraction over Win32 input. Two implementations: SendInputController (vanilla), InterceptionInputController (driver).
|
||||
|
||||
```csharp
|
||||
public interface IInputController
|
||||
{
|
||||
bool IsInitialized { get; }
|
||||
void KeyDown(ushort scanCode);
|
||||
void KeyUp(ushort scanCode);
|
||||
void KeyPress(ushort scanCode, int holdMs = 50);
|
||||
void MouseMoveTo(int x, int y);
|
||||
void SmoothMoveTo(int x, int y); // Bézier curve interpolation
|
||||
void LeftClick(int x, int y);
|
||||
void RightClick(int x, int y);
|
||||
void MiddleClick(int x, int y);
|
||||
void LeftDown(); void LeftUp();
|
||||
void RightDown(); void RightUp();
|
||||
}
|
||||
```
|
||||
|
||||
## WalkabilitySnapshot
|
||||
|
||||
Grid-based terrain with offset support for infinite expansion:
|
||||
|
||||
```csharp
|
||||
public record WalkabilitySnapshot
|
||||
{
|
||||
public int Width, Height;
|
||||
public byte[] Data; // Row-major; 0=wall, nonzero=walkable
|
||||
public int OffsetX, OffsetY; // Absolute grid coords of top-left corner
|
||||
|
||||
public bool IsWalkable(int gx, int gy)
|
||||
{
|
||||
var lx = gx - OffsetX; // Absolute → local
|
||||
var ly = gy - OffsetY;
|
||||
if (out of bounds) return false;
|
||||
return Data[ly * Width + lx] != 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Coordinate conversion: `WorldToGrid = 23f / 250f ≈ 0.092`
|
||||
- World → Grid: `gx = (int)(worldX * WorldToGrid)`
|
||||
- Grid → World: `worldX = gx / WorldToGrid`
|
||||
|
||||
## Configuration
|
||||
|
||||
**BotConfig** — Static bot parameters:
|
||||
- Tick rates: LogicTickRateHz=60, MemoryPollRateHz=30
|
||||
- Movement: SafeDistance=400, RepulsionWeight=1.5, WaypointReachedDistance=80
|
||||
- Humanization: MinReactionDelayMs=50, MaxReactionDelayMs=150, ClickJitterRadius=3, MaxApm=250
|
||||
|
||||
**CharacterProfile** — Per-character settings:
|
||||
- Skills (8 slots: LMB, RMB, MMB, Q, E, R, T, F) with priority, cooldown, range, target selection
|
||||
- Flasks (thresholds, scan codes, cooldown)
|
||||
- Combat (global cooldown, attack/safe/kite ranges)
|
||||
|
||||
**SkillProfile** — Per-skill configuration:
|
||||
- InputType (KeyPress/LeftClick/RightClick/MiddleClick)
|
||||
- TargetSelection (Nearest/All/Rarest/MagicPlus/RarePlus/UniqueOnly)
|
||||
- RequiresTarget, IsAura, IsMovementSkill, MaintainPressed
|
||||
- MinMonstersInRange (AOE threshold)
|
||||
|
||||
## Utilities
|
||||
|
||||
- **WorldToScreen.Project()** — Matrix projection: world coords → screen coords via camera matrix
|
||||
- **TerrainQuery.HasLineOfSight()** — Bresenham line walk on walkability grid
|
||||
- **TerrainQuery.FindWalkableDirection()** — Rotates direction ±45°/90°/135°/180° to find clear path
|
||||
- **Helpers.Sleep()** — Task delay with ±10% variance
|
||||
- **DangerLevel**: Safe, Low, Medium, High, Critical
|
||||
- **ThreatMap**: TotalHostiles, CloseRange(<300), MidRange(300-600), FarRange(600-1200), ClosestDistance, ThreatCentroid, HasRareOrUnique
|
||||
229
docs/data-and-memory.md
Normal file
229
docs/data-and-memory.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# Nexus.Data & Nexus.Memory — The Data Pipeline
|
||||
|
||||
## Overview
|
||||
|
||||
Two-layer architecture: **Memory** reads raw bytes from the game process; **Data** interprets, classifies, and caches them. This separation keeps memory reading logic free of business rules and makes the data layer testable independently.
|
||||
|
||||
```
|
||||
Game Process (RPM)
|
||||
│
|
||||
▼
|
||||
Nexus.Memory (raw reads, no business logic)
|
||||
│ GameMemoryReader → hierarchical state tree
|
||||
│ EntityList → red-black tree traversal
|
||||
│ ComponentReader → ECS component extraction
|
||||
│
|
||||
▼
|
||||
Nexus.Data (interpretation, classification, caching)
|
||||
│ MemoryPoller → two-tier event loop
|
||||
│ EntityMapper → Memory.Entity → Core.EntitySnapshot
|
||||
│ EntityClassifier → path + components → EntityCategory
|
||||
│ GameStateEnricher → derived threat/danger metrics
|
||||
│
|
||||
▼
|
||||
GameDataCache (volatile references, lock-free)
|
||||
│
|
||||
▼
|
||||
Bot Systems (read-only consumers)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GameDataCache — Single Source of Truth
|
||||
|
||||
Thread-safe, volatile reference holder. Writer: MemoryPoller thread. Readers: bot systems.
|
||||
|
||||
**Hot fields** (updated at 60Hz — 4 lightweight RPM calls):
|
||||
- CameraMatrix (64 bytes)
|
||||
- PlayerPosition (X, Y, Z)
|
||||
- PlayerVitals (HP, mana, ES current/max)
|
||||
- IsLoading, IsEscapeOpen
|
||||
|
||||
**Cold fields** (updated at 10Hz — full hierarchical read):
|
||||
- Entities, HostileMonsters, NearbyLoot
|
||||
- Terrain (WalkabilitySnapshot)
|
||||
- AreaHash, AreaLevel, CurrentAreaName
|
||||
- Quest data (linked lists, UI groups, state entries)
|
||||
- LatestState (complete GameState)
|
||||
|
||||
**Slow fields** (updated at 1Hz):
|
||||
- Character name
|
||||
- Quest linked lists, quest state entries
|
||||
|
||||
No locks — relies on volatile reference semantics for atomic swaps.
|
||||
|
||||
---
|
||||
|
||||
## MemoryPoller — Two-Tier Event Loop
|
||||
|
||||
Owns the memory-reading background thread.
|
||||
|
||||
### Hot Tick (60Hz)
|
||||
|
||||
4 pre-resolved RPM calls using cached addresses:
|
||||
1. Camera matrix (64 bytes from cached address)
|
||||
2. Player position (12 bytes: X, Y, Z)
|
||||
3. Player vitals (24 bytes: HP, mana, ES)
|
||||
4. Loading/escape state (pointer dereference + int)
|
||||
|
||||
No allocations, no GC. Targeting ~3-5ms per tick.
|
||||
|
||||
### Cold Tick (10Hz, every 6th hot tick)
|
||||
|
||||
Full hierarchical read:
|
||||
1. `GameMemoryReader.ReadSnapshot()` — cascades through state tree
|
||||
2. Re-resolve hot addresses via `ResolveHotAddresses()`
|
||||
3. `BuildGameState()` — map entities, filter lists, process quests
|
||||
4. `GameStateEnricher.Enrich()` — compute derived fields
|
||||
5. Update all cache fields
|
||||
|
||||
### BuildGameState() Flow
|
||||
|
||||
```
|
||||
ReadSnapshot() → GameStateSnapshot (raw)
|
||||
│
|
||||
├── Entity mapping:
|
||||
│ for each entity in snapshot:
|
||||
│ EntityMapper.MapEntity(entity, playerPos) → EntitySnapshot
|
||||
│
|
||||
├── Filter into:
|
||||
│ - HostileMonsters (Category==Monster && IsAlive)
|
||||
│ - NearbyLoot (Category==WorldItem)
|
||||
│ - All entities
|
||||
│
|
||||
├── Quest processing:
|
||||
│ - Filter active (StateId > 0)
|
||||
│ - Resolve state text via QuestStateLookup
|
||||
│ - Convert to QuestProgress, QuestInfo
|
||||
│
|
||||
└── Returns GameState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GameMemoryReader — Hierarchical State Tree
|
||||
|
||||
Top-level orchestrator. Creates sub-readers on `Attach()`:
|
||||
|
||||
```
|
||||
GameStates (top)
|
||||
└── InGameState
|
||||
├── AreaInstance
|
||||
│ ├── EntityList (MSVC std::map red-black tree)
|
||||
│ ├── PlayerSkills (Actor component)
|
||||
│ ├── QuestStates (dat file entries)
|
||||
│ └── Terrain (walkability grid)
|
||||
├── UIElements (quest linked lists, UI tree)
|
||||
└── WorldData (camera matrix)
|
||||
```
|
||||
|
||||
Each `RemoteObject` caches its data and depends on parent for context. Single `Update()` call cascades through tree.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **ProcessMemory**: P/Invoke wrapper for ReadProcessMemory. Tracks reads/sec and KB/sec.
|
||||
- **MemoryContext**: Shared state — process handle, offsets, module base, pattern scanner.
|
||||
- **ComponentReader**: Reads ECS components (Life, Render, Mods, etc.) from entities.
|
||||
- **MsvcStringReader**: Reads MSVC std::wstring (SSO-aware: inline if capacity ≤ 8, heap pointer otherwise).
|
||||
- **PatternScanner**: AOB scan for resolving base addresses.
|
||||
|
||||
---
|
||||
|
||||
## EntityList — Tree Traversal
|
||||
|
||||
Reads entities from AreaInstance's MSVC std::map (red-black tree, in-order traversal).
|
||||
|
||||
**Tree node layout:**
|
||||
```
|
||||
+0x00: left child ptr
|
||||
+0x08: parent ptr
|
||||
+0x10: right child ptr
|
||||
+0x28: entity pointer
|
||||
```
|
||||
|
||||
**Optimization**: Tree order is cached — re-walked only when entity count changes.
|
||||
|
||||
**Per-entity reads:**
|
||||
1. Path (EntityDetails → std::wstring)
|
||||
2. Skip low-priority types (effects, terrain, critters — no components read)
|
||||
3. Position (Render component: X, Y, Z)
|
||||
4. Component lookup (STL hash map: name → index)
|
||||
5. Component data:
|
||||
- Targetable (bool flag)
|
||||
- Mods/ObjectMagicProperties (rarity)
|
||||
- Life (HP, dynamic — re-read every frame for monsters)
|
||||
- Actor (action ID)
|
||||
- WorldItem (inner entity for ground loot)
|
||||
- AreaTransition (destination area)
|
||||
|
||||
**Caching strategy:**
|
||||
- Stable per entity: path, component list, targetable, rarity, transition name
|
||||
- Dynamic (re-read every frame): monster HP, action ID
|
||||
|
||||
---
|
||||
|
||||
## EntityClassifier — Path + Components → Category
|
||||
|
||||
Single source of truth for entity classification.
|
||||
|
||||
1. **Path-based** (primary): Parses `Metadata/[Category]/...` path segments
|
||||
2. **Component override**: Monster, Chest, Shrine, Waypoint, AreaTransition, Portal, TownPortal, NPC, Player
|
||||
|
||||
Output: `EntityCategory` (Core enum, 17 types)
|
||||
|
||||
---
|
||||
|
||||
## EntityMapper — Memory.Entity → Core.EntitySnapshot
|
||||
|
||||
Transforms raw memory data to enriched snapshots:
|
||||
|
||||
```
|
||||
Memory.Entity (raw)
|
||||
│
|
||||
├── Copy: ID, path, metadata, position, Z, vitals, components, mods
|
||||
├── Classify: EntityClassifier.Classify(path, components) → EntityCategory
|
||||
├── Threat level: Rarity → MonsterThreatLevel
|
||||
├── Area name: AreaNameLookup.Resolve() for transitions
|
||||
├── Distance: Vector2.Distance(position, playerPos)
|
||||
└── Alive state: HasVitals ? LifeCurrent > 0 : true
|
||||
│
|
||||
▼
|
||||
Core.EntitySnapshot (public, classified, enriched)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GameStateEnricher — Derived Metrics
|
||||
|
||||
Computed once per cold tick, before systems run.
|
||||
|
||||
**NearestEnemies**: HostileMonsters sorted by distance to player.
|
||||
|
||||
**ThreatMap**:
|
||||
- TotalHostiles, CloseRange (<300u), MidRange (300-600u), FarRange (600-1200u)
|
||||
- ClosestDistance, ThreatCentroid (position average), HasRareOrUnique
|
||||
|
||||
**DangerLevel** — Weighted threat score:
|
||||
```
|
||||
score = Σ (distance_weight × rarity_multiplier)
|
||||
|
||||
Distance weights: <200u = 3×, <400u = 2×, else = 1×
|
||||
Rarity multipliers: Unique=5×, Rare=3×, Magic=1.5×, White=1×
|
||||
|
||||
Life override: HP < 30% → Critical, HP < 50% → High
|
||||
Score thresholds: ≥15 = Critical, ≥8 = High, ≥4 = Medium, >0 = Low, 0 = Safe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Architectural Patterns
|
||||
|
||||
| Pattern | Implementation |
|
||||
|---------|---------------|
|
||||
| Lock-free cross-thread | Volatile references in GameDataCache; no locks needed |
|
||||
| Two-tier polling | Hot (4 RPM, 60Hz) + Cold (full read, 10Hz) |
|
||||
| Hierarchical caching | Each RemoteObject caches data, re-reads only on change |
|
||||
| Entity caching | Stable data cached per entity/zone; dynamic data (HP) re-read per frame |
|
||||
| Separation of concerns | Memory: raw bytes. Data: interpretation + classification |
|
||||
| Area name resolution | AreaNameLookup loads areas.json, caches ID → display name |
|
||||
| Area graph | BFS pathfinding for quest progression ordering |
|
||||
197
docs/engine-and-systems.md
Normal file
197
docs/engine-and-systems.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Nexus.Engine & Nexus.Systems — Bot Brain
|
||||
|
||||
## BotEngine (Orchestrator)
|
||||
|
||||
The main loop. Owns systems, navigation, profiles, and action execution.
|
||||
|
||||
### Logic Loop (25Hz, background thread)
|
||||
|
||||
```
|
||||
1. Wait for MemoryPoller to provide latest GameState
|
||||
2. CheckCharacterProfile() → auto-load profile if character changed
|
||||
3. GameStateEnricher.Enrich() → compute NearestEnemies, ThreatMap, DangerLevel
|
||||
4. Clear ActionQueue
|
||||
5. NavigationController.Update(state) → compute path, set DesiredDirection
|
||||
6. Run all ISystem implementations in priority order
|
||||
7. NavigationSystem submits MoveAction if DesiredDirection is set
|
||||
8. ActionQueue.Resolve() → merge conflicts
|
||||
9. ExecuteActions() → emit key/mouse/click commands via IInputController
|
||||
```
|
||||
|
||||
### Action Execution
|
||||
|
||||
| Action | Execution |
|
||||
|--------|-----------|
|
||||
| MoveAction | Direction → MovementKeyTracker → WASD key state changes (delta-based) |
|
||||
| CastAction | SmoothMoveTo target + key press. Re-projects moving entities. Adds ±30-50px jitter. |
|
||||
| FlaskAction | Direct key press |
|
||||
| KeyAction | Press/Down/Up operations |
|
||||
| ClickAction | Left/Right/Middle click at screen position |
|
||||
|
||||
### MovementKeyTracker
|
||||
|
||||
Converts world-space direction vectors to WASD keys for isometric camera:
|
||||
|
||||
```
|
||||
1. Rotate direction 45° to align with isometric axes
|
||||
2. sx = dir.X * cos(45°) - dir.Y * sin(45°)
|
||||
3. sy = dir.X * sin(45°) + dir.Y * cos(45°)
|
||||
4. W if sy > 0.3, S if sy < -0.3, D if sx > 0.3, A if sx < -0.3
|
||||
5. Only emit key changes (delta-based — no redundant KeyDown/KeyUp)
|
||||
```
|
||||
|
||||
### Mouse Drift (Navigation)
|
||||
|
||||
During navigation, lazily repositions the mouse toward enemy clusters:
|
||||
- Projects enemy centroid ahead of player movement
|
||||
- Applies ±25° angular offset for organic appearance
|
||||
- Fires every 800-1500ms (randomized)
|
||||
|
||||
### Safety
|
||||
|
||||
- Releases all held keys when loading screen or escape menu detected
|
||||
- CombatSystem's `ReleaseAllHeld()` called on state transitions
|
||||
|
||||
---
|
||||
|
||||
## Systems
|
||||
|
||||
### ThreatSystem (Priority 50)
|
||||
|
||||
Emergency threat response. Runs first, only acts on elevated danger.
|
||||
|
||||
| Danger | Response |
|
||||
|--------|----------|
|
||||
| Safe / Low | No action |
|
||||
| Medium | No action (MovementSystem handles soft avoidance) |
|
||||
| High | Flee toward safety (priority 50, allows casting) |
|
||||
| Critical or point-blank (<150 units) | **Urgent flee (priority 5) — blocks all casting** |
|
||||
|
||||
**Flee direction**: `Player.Position - ThreatCentroid`, validated against terrain via `FindWalkableDirection()`.
|
||||
|
||||
### MovementSystem (Priority 100)
|
||||
|
||||
Continuous soft avoidance via **inverse-square repulsion field**.
|
||||
|
||||
For each hostile monster within SafeDistance (400 units):
|
||||
```
|
||||
force += (playerPos - enemyPos) / distanceSquared * RepulsionWeight
|
||||
```
|
||||
Normalizes sum, validates against terrain, submits as lower-priority MoveAction.
|
||||
|
||||
Effect: Player gently drifts away from enemies without hard fleeing.
|
||||
|
||||
### AreaProgressionSystem (Priority 199)
|
||||
|
||||
High-level area traversal. Runs before NavigationSystem to take precedence.
|
||||
|
||||
**State machine (7 phases):**
|
||||
```
|
||||
Exploring → Looting → NavigatingToChest → InteractingChest →
|
||||
NavigatingToTransition → Interacting → TalkingToNpc
|
||||
```
|
||||
|
||||
**Exploration strategy:**
|
||||
1. Check for elite enemies (Rare/Unique within 800u) → yield to combat
|
||||
2. Check for quest chests → navigate and interact
|
||||
3. Check for loot (if danger ≤ Low) → pick up within 600u
|
||||
4. Once fully explored → find area transition matching quest target
|
||||
5. In towns with active quest → talk to NPC
|
||||
|
||||
**Quest integration**: Queries active quests for target areas. Prioritizes tracked quests, then lowest act, then shortest path. Blacklists failed transitions after 5s timeout.
|
||||
|
||||
**Navigation delegation**: Uses `NavigationController.NavigateToEntity()` and `.Explore()`. Sets targets and yields until reached.
|
||||
|
||||
### NavigationSystem (Priority 200)
|
||||
|
||||
Ultra-thin passthrough. If `NavigationController.DesiredDirection` is set, submits a MoveAction. All actual pathfinding logic lives in NavigationController (see [pathfinding.md](pathfinding.md)).
|
||||
|
||||
### CombatSystem (Priority 300)
|
||||
|
||||
Skill rotation and target selection. Hot-swappable via CharacterProfile.
|
||||
|
||||
**Rotation loop:**
|
||||
```
|
||||
1. Check global cooldown (skip if recently cast)
|
||||
2. For each skill in priority order:
|
||||
a. Check per-skill cooldown
|
||||
b. Match skill to memory via slot index (fallback to name)
|
||||
c. If aura: cast once per zone
|
||||
d. If damage: find target → submit CastAction
|
||||
3. Release held keys for skills without valid targets
|
||||
```
|
||||
|
||||
**Target selection pipeline:**
|
||||
```
|
||||
1. Filter by TargetSelection (Nearest, Rarest, MagicPlus, RarePlus, UniqueOnly)
|
||||
2. Filter by range (SkillProfile.RangeMin/RangeMax)
|
||||
3. Filter by line-of-sight (terrain query)
|
||||
4. Check MinMonstersInRange (AOE threshold)
|
||||
5. Pick best: Rarest mode → prefer higher rarity then nearer; others → nearest
|
||||
6. Project to screen coordinates
|
||||
```
|
||||
|
||||
**Skill input types:**
|
||||
- LeftClick/RightClick/MiddleClick: Direct click at target position
|
||||
- KeyPress with MaintainPressed: Hold key continuously
|
||||
- KeyPress normal: Single tap
|
||||
|
||||
**Kiting/orbit (during global cooldown):**
|
||||
- Computes enemy centroid
|
||||
- Moves perpendicular to centroid (orbital movement)
|
||||
- Applies radial bias to maintain ideal distance
|
||||
- Flips orbit direction if terrain blocks path
|
||||
- Persists orbit sign across ticks for smooth motion
|
||||
|
||||
**Cooldown management:**
|
||||
- Per-skill: `max(skill.CooldownMs, globalCd + 50)` for rotation
|
||||
- MaintainPressed skills: use skill.CooldownMs directly
|
||||
- Area reset: clears aura tracking, resets orbit
|
||||
|
||||
### ResourceSystem (Priority 400)
|
||||
|
||||
Flask automation based on life/mana thresholds.
|
||||
|
||||
```
|
||||
if LifePercent < LifeFlaskThreshold (50%) && cooldown expired → FlaskAction
|
||||
if ManaPercent < ManaFlaskThreshold (50%) && cooldown expired → FlaskAction
|
||||
```
|
||||
|
||||
Flask cooldown: 4000ms default. Hot-swappable on character profile change.
|
||||
|
||||
### LootSystem (Priority 500)
|
||||
|
||||
Stub — disabled by default. Item pickup logic handled by AreaProgressionSystem's looting phase.
|
||||
|
||||
---
|
||||
|
||||
## System Interaction Diagram
|
||||
|
||||
```
|
||||
GameState (read-only, shared)
|
||||
│
|
||||
├─→ ThreatSystem ──→ MoveAction (priority 5 or 50)
|
||||
│ [blocks casting if priority ≤ 10]
|
||||
│
|
||||
├─→ MovementSystem ──→ MoveAction (priority 100)
|
||||
│ [soft repulsion, overridable]
|
||||
│
|
||||
├─→ AreaProgressionSystem ──→ NavigateTo/Explore commands
|
||||
│ [drives NavigationController]
|
||||
│
|
||||
├─→ NavigationSystem ──→ MoveAction (priority 200)
|
||||
│ [passthrough from NavigationController]
|
||||
│
|
||||
├─→ CombatSystem ──→ CastAction (priority 300)
|
||||
│ [skill rotation + target selection]
|
||||
│
|
||||
├─→ ResourceSystem ──→ FlaskAction (priority 400)
|
||||
│ [always passes through]
|
||||
│
|
||||
└─→ ActionQueue.Resolve()
|
||||
│
|
||||
├── Highest MoveAction wins
|
||||
├── CastAction passes unless blocked by urgent flee
|
||||
├── FlaskAction always passes
|
||||
└──→ ExecuteActions() → IInputController
|
||||
```
|
||||
207
docs/infrastructure.md
Normal file
207
docs/infrastructure.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Infrastructure & Game Interaction Projects
|
||||
|
||||
## Nexus.GameOffsets — Memory Layout Definitions
|
||||
|
||||
Pure offset structs for POE2 game memory. No logic, no reading — just struct layouts.
|
||||
|
||||
**Contents:**
|
||||
- **Entities/**: `EntityStruct`, `EntityDetails`, `ComponentLookup`, `ComponentNameAndIndex`, `ItemStruct`, `EntityTreeNode`
|
||||
- **Components/** (22 structs): Actor, Animated, Buffs, Chest, Life, Mods, Player, Positioned, Render, Stats, Targetable, Transitionable, WorldItem, etc.
|
||||
- **States/**: `InGameState`, `AreaInstance`, `AreaLoading`, `ServerData`, `WorldData`, `Inventory`, `ImportantUiElements`
|
||||
- **Natives/**: C++ STL memory layouts — `StdVector`, `StdMap`, `StdList`, `StdBucket`, `StdWString`, `StdTuple`
|
||||
|
||||
**Key offsets:**
|
||||
- Actor skills: 0xB00 (ActiveSkillsVector), 0xB18 (CooldownsVector)
|
||||
- UIElement: 0x10 (Children), 0x98 (StringId), 0x180 (Flags), 0x448 (Text)
|
||||
- Entity: 0x80 (ID), 0x84 (Flags), 0x08 (EntityDetails)
|
||||
|
||||
**Dependencies**: None. Used by Memory and Data layers.
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Screen — Screen Capture, OCR & Detection
|
||||
|
||||
Screen capture, OCR, image processing, grid/item detection, loot label detection.
|
||||
|
||||
### Core Components
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| ScreenReader | Main facade — OCR, template matching, screenshot, diff OCR |
|
||||
| IScreenCapture | Desktop duplication or GDI capture |
|
||||
| IOcrEngine | Interface for OCR backends (Win native, EasyOCR, OneOCR, WinOCR) |
|
||||
| PythonOcrBridge | Calls Python script via subprocess for EasyOCR/YOLO |
|
||||
|
||||
### Grid & Item Detection
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| GridReader | Reads stash/inventory grids (12-col 70×70px or 24-col 35×35px) |
|
||||
| GridHandler | Template matching for cell occupancy, item size detection |
|
||||
| TemplateMatchHandler | NCC-based visual matching (find identical items in grid) |
|
||||
| DetectGridHandler | Edge detection to find grid boundaries |
|
||||
|
||||
### Detection Systems
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| EnemyDetector | YOLO/ONNX object detection for enemy positions |
|
||||
| BossDetector | Boss-specific recognition |
|
||||
| HudReader | HUD element OCR (HP bar, mana, buffs) |
|
||||
| GameStateDetector | Main menu vs in-game state |
|
||||
| ScreenReader.DetectLootLabels() | Three-pass loot detection (polygon, contour, yellow text) |
|
||||
|
||||
### Frame Pipeline
|
||||
|
||||
Pub-sub for screen frames: `FramePipeline` distributes captured frames to multiple `IFrameConsumer` implementations (GameState, Enemy, Boss detectors, Minimap, Navigation).
|
||||
|
||||
**Used by**: Bot, Navigation, Inventory, Ui
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Game — Game Interaction
|
||||
|
||||
Low-level game control — window focus, input sending, clipboard operations.
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| GameController | Main facade — focus, chat, input, shortcuts |
|
||||
| InputSender | Win32 SendInput (scan codes), Bézier mouse movement, Ctrl+click |
|
||||
| WindowManager | SetForegroundWindow (with alt-key trick), GetWindowRect |
|
||||
| ClipboardHelper | System clipboard read/write |
|
||||
|
||||
**Key operations:**
|
||||
- `FocusWindow()` — SetForegroundWindow + alt-key trick (required for background processes)
|
||||
- `CtrlRightClick()` — buying from seller stash
|
||||
- `MoveMouse()` — Bézier curve smooth move
|
||||
- `MoveMouseInstant()` — direct teleport (no interpolation)
|
||||
- `TypeText()`, `SelectAll()`, `Paste()` — clipboard operations
|
||||
|
||||
**Used by**: Inventory, Trade, Items, Navigation, Bot
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Log — Game Log Watcher
|
||||
|
||||
Parses Client.txt game log at 200ms poll intervals.
|
||||
|
||||
| Event | Pattern |
|
||||
|-------|---------|
|
||||
| AreaEntered | `[SCENE] Set Source [AreaName]` or `You have entered AreaName` |
|
||||
| WhisperReceived | Incoming whisper messages |
|
||||
| WhisperSent | Outgoing whisper messages |
|
||||
| TradeAccepted | Trade completion |
|
||||
| PartyJoined/Left | Party state changes |
|
||||
| LineReceived | Raw log lines |
|
||||
|
||||
`CurrentArea` detected from log tail on startup. Used by Bot (reset navigation on area change), Inventory (wait for area transitions), Navigation.
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Trade — Trade Daemon IPC
|
||||
|
||||
Manages trade search monitoring via external Node.js Playwright daemon.
|
||||
|
||||
### TradeDaemonBridge
|
||||
|
||||
Spawns `node tools/trade-daemon/daemon.mjs`, communicates via stdin/stdout JSON.
|
||||
|
||||
**Commands (→ daemon):**
|
||||
- `start`, `addSearch`, `addDiamondSearch`
|
||||
- `pauseSearch`, `clickTravel`
|
||||
- `openScrapPage`, `reloadScrapPage`, `closeScrapPage`
|
||||
|
||||
**Events (← daemon):**
|
||||
- `newListings` → `NewListings(searchId, items[])`
|
||||
- `diamondListings` → `DiamondListings(searchId, pricedItems[])`
|
||||
- `wsClose` → websocket disconnection
|
||||
|
||||
**Trade flow**: Website "Travel to Hideout" button → stash opens → Ctrl+right-click to buy → `/hideout` to go home → store items
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Items — Item Parsing
|
||||
|
||||
Parse item text from clipboard (Ctrl+C) using Sidekick item parser library.
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| ItemReader | Move to item → Ctrl+C → read clipboard → parse |
|
||||
| SidekickBootstrapper | Initialize Sidekick parser on first use |
|
||||
|
||||
**Used by**: Bot (identify items during scraping)
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Inventory — Stash & Grid Management
|
||||
|
||||
Scan player inventory, track item placement, deposit to stash.
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| InventoryManager | Main interface — scan, deposit, clear |
|
||||
| InventoryTracker | Cell occupancy matrix + item metadata |
|
||||
| StashCalibrator | Grid boundary calibration via edge detection |
|
||||
|
||||
**Key operations:**
|
||||
- `ScanInventory()` → screenshot + grid scan → populate tracker
|
||||
- `DepositItemsToStash()` → find stash NPC → click items with Shift+Ctrl
|
||||
- `DepositAllToOpenStash()` → scan → click first occupied → repeat
|
||||
- `ClearToStash()` → scan → deposit all → return to hideout
|
||||
- `EnsureAtOwnHideout()` → `/hideout` command if needed
|
||||
|
||||
**Grid calibration (2560×1440):**
|
||||
- Cell sizes: 70×70px (12-col) or 35×35px (24-col), all 840px wide
|
||||
- Inventory (12×5): origin (1696, 788)
|
||||
- Stash 12×12: origin (23, 169) or (23, 216) in folder
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Navigation — Minimap-Based Movement
|
||||
|
||||
Real-time navigation using minimap image matching + pathfinding. Separate from Nexus.Pathfinding (which is grid-based A*).
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| NavigationExecutor | State machine: Capture → Process → Plan → Move → Stuck |
|
||||
| MinimapCapture | Frame pipeline consumer — wall color detection, checkpoint detection |
|
||||
| WorldMap | Position matching via cross-correlation, canvas stitching |
|
||||
| StuckDetector | No-progress detection |
|
||||
| WallColorTracker | Learns wall palette from initial spawn |
|
||||
|
||||
**Flow**: Capture minimap → detect position via wall color stitching → pathfind → send WASD keys
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Bot — Top-Level Orchestration
|
||||
|
||||
Central coordinator that wires everything together.
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| BotOrchestrator | DI container, state machine, frame pipeline management |
|
||||
| TradeExecutor | Single trade flow (navigate → buy → deposit) |
|
||||
| MappingExecutor | Map exploration (navigate + loot) |
|
||||
| KulemakExecutor | Boss fight with arena mechanics |
|
||||
| CraftingExecutor | Crafting bench operations |
|
||||
| DiamondExecutor | Diamond trade handling |
|
||||
| ScrapExecutor | Vendor scrapping |
|
||||
| TradeQueue | FIFO queue of trade tasks |
|
||||
| LinkManager | Trade search management |
|
||||
|
||||
**Bot modes**: Trading, Mapping, Crafting (via BotMode enum)
|
||||
|
||||
---
|
||||
|
||||
## Nexus.Ui — Avalonia Desktop Application
|
||||
|
||||
Entry point executable. Avalonia 11.2 + CommunityToolkit.MVVM + FluentTheme.
|
||||
|
||||
**App.xaml.cs** wires all DI:
|
||||
- Services: ConfigStore, GameController, ScreenReader, ClientLogWatcher, TradeMonitor, InventoryManager
|
||||
- Bot: FramePipelineService, LinkManager, TradeExecutor, TradeQueue, BotOrchestrator, ModPoolService
|
||||
- ViewModels: Main, Debug, Settings, Mapping, Atlas, Crafting, Memory, Nexus, ObjectBrowser
|
||||
|
||||
**Additional dependencies**: Vortice.Direct2D1 (overlay rendering), Microsoft.Extensions.DependencyInjection
|
||||
|
||||
**Views**: MainWindow, DebugWindow, SettingsWindow, MappingWindow, etc. with MVVM bindings.
|
||||
67
docs/input.md
Normal file
67
docs/input.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Nexus.Input — Input Controllers & Humanization
|
||||
|
||||
## IInputController Implementations
|
||||
|
||||
### SendInputController (Default, No Driver)
|
||||
|
||||
Uses Win32 `SendInput` API with **KEYEVENTF_SCANCODE** flag. Games read hardware scan codes, not virtual key codes.
|
||||
|
||||
- **KeyDown/KeyUp**: Raw keyboard scan code via SendInput struct
|
||||
- **KeyPress**: Down → Sleep(holdMs) → Up with humanization
|
||||
- **SmoothMoveTo**: Cubic Bézier curve interpolation (10-40 steps) with random perpendicular spread
|
||||
- **MouseMoveTo**: Direct `SetCursorPos()` (instant teleport)
|
||||
- **Clicks**: Smooth move to target → humanized delay → click
|
||||
|
||||
### InterceptionInputController (Driver-Based)
|
||||
|
||||
Uses Interception keyboard/mouse driver for lower-level control:
|
||||
- Delegates to `KeyboardHook` and `MouseHook` via InputInterceptor COM library
|
||||
- Same smooth movement and humanization as SendInput
|
||||
- Returns false from `Initialize()` if driver not installed (graceful fallback)
|
||||
|
||||
### SimInputController (Simulator)
|
||||
|
||||
Implements `IInputController` but doesn't make Win32 calls. Instead:
|
||||
- **WASD** → Tracks held state, converts to direction vector with 45° isometric rotation
|
||||
- **Skills** → Queues skill casts to SimWorld via `QueueSkill()`
|
||||
- **Mouse** → Tracks screen position, converts to world coords via inverse camera matrix
|
||||
- **Visualization** → Maintains flash timers (0.15s) for InputOverlayRenderer
|
||||
|
||||
## Scan Codes
|
||||
|
||||
```
|
||||
Movement: W=0x11 A=0x1E S=0x1F D=0x20
|
||||
Skills: Q=0x10 E=0x12 R=0x13 T=0x14
|
||||
Numbers: 1=0x02 2=0x03 3=0x04 4=0x05 5=0x06
|
||||
Modifiers: LShift=0x2A LCtrl=0x1D LAlt=0x38
|
||||
Other: Space=0x39 Enter=0x1C Escape=0x01 Slash=0x35
|
||||
```
|
||||
|
||||
## Humanizer
|
||||
|
||||
Anti-detection layer applied to all input operations.
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| GaussianDelay(baseMs) | Adds gaussian noise (Box-Muller transform), clamped to [50ms, 150ms] |
|
||||
| JitterPosition(x, y) | Random pixel offset within ClickJitterRadius (3px) |
|
||||
| ShouldThrottle() | Tracks actions in 60-second rolling window, blocks if APM > MaxApm (250) |
|
||||
| RecordAction() | Enqueues timestamp for APM tracking |
|
||||
| RandomizedInterval(baseMs) | Adds ±20% jitter to poll intervals |
|
||||
|
||||
## MovementKeyTracker
|
||||
|
||||
Converts normalized direction vectors to WASD key state for isometric camera:
|
||||
|
||||
```
|
||||
Rotate direction 45°:
|
||||
sx = dir.X * cos(45°) - dir.Y * sin(45°)
|
||||
sy = dir.X * sin(45°) + dir.Y * cos(45°)
|
||||
|
||||
Key mapping:
|
||||
W if sy > 0.3, S if sy < -0.3
|
||||
D if sx > 0.3, A if sx < -0.3
|
||||
|
||||
Delta-based: only sends KeyDown/KeyUp when state changes.
|
||||
Supports holding multiple keys (W+D for diagonal).
|
||||
```
|
||||
153
docs/pathfinding.md
Normal file
153
docs/pathfinding.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Nexus.Pathfinding — Navigation & Exploration
|
||||
|
||||
## Overview
|
||||
|
||||
Two classes: **NavigationController** (state machine — decides *where* to go) and **PathFinder** (A* algorithm — decides *how* to get there).
|
||||
|
||||
---
|
||||
|
||||
## NavigationController
|
||||
|
||||
### Modes
|
||||
|
||||
| Mode | Trigger | Behavior |
|
||||
|------|---------|----------|
|
||||
| Idle | `Stop()` | No movement |
|
||||
| NavigatingToPosition | `NavigateTo(pos)` | Path to fixed world coordinates |
|
||||
| NavigatingToEntity | `NavigateToEntity(id)` | Chase a moving entity (re-targets each tick) |
|
||||
| Exploring | `Explore()` | BFS frontier exploration of unmapped terrain |
|
||||
|
||||
### Update Loop (called every tick)
|
||||
|
||||
```
|
||||
1. Area change detection → clear path, explored grid, stuck history
|
||||
2. EnsureExploredGrid() → allocate/resize to match terrain (preserves old data on expansion)
|
||||
3. MarkExplored(playerPos) → mark cells near player as visited (radius 150 grid cells)
|
||||
4. ResolveGoal() → get target position based on mode
|
||||
5. If no goal and Exploring → PickExploreTarget() via BFS
|
||||
6. Reach detection → within WaypointReachedDistance (80u), clear goal or stop
|
||||
7. Stuck detection → if < 30u movement in 60 ticks (~1s), repath or pick new target
|
||||
8. Pathfinding → A* from player to goal (with explored grid bias in explore mode)
|
||||
9. Waypoint advancement → advance index as player reaches each waypoint
|
||||
10. Output → DesiredDirection (normalized vector to next waypoint)
|
||||
```
|
||||
|
||||
### Explored Grid
|
||||
|
||||
Parallel bool array matching terrain dimensions. Tracks which cells the player has visited.
|
||||
|
||||
- **Mark radius**: 150 grid cells (~1630 world units) — circular region around player
|
||||
- **Preservation**: On terrain expansion, overlapping explored data is copied to new grid
|
||||
- **Offset-aware**: Uses same OffsetX/OffsetY as terrain for absolute grid coordinates
|
||||
|
||||
### BFS Exploration (PickExploreTarget)
|
||||
|
||||
When Exploring mode needs a new goal:
|
||||
|
||||
1. **BFS frontier search** (up to 100,000 iterations)
|
||||
- 8-directional BFS outward from player
|
||||
- Finds nearest unexplored walkable cell
|
||||
- Returns that cell as world coordinates
|
||||
|
||||
2. **Random distant target** (if BFS finds nothing)
|
||||
- 20 attempts at random directions, 1500-3500 world units away
|
||||
- Pushes player toward terrain edges where expansion triggers
|
||||
|
||||
3. **Edge fallback** (if random fails)
|
||||
- Heads toward nearest terrain boundary (10 cells from edge)
|
||||
- Guarantees continued exploration with infinite terrain
|
||||
|
||||
4. **Exploration complete** (only if all fallbacks fail)
|
||||
- Sets `IsExplorationComplete = true`
|
||||
- Prevents expensive re-BFS every tick
|
||||
- Reset on area change
|
||||
|
||||
### Stuck Detection
|
||||
|
||||
- **Window**: Last 60 positions (~1 second at 60Hz)
|
||||
- **Threshold**: Must move at least 30 world units in window
|
||||
- **Grace period**: 120 ticks (2 seconds) after picking new explore target
|
||||
- **On stuck while exploring**: Mark failed goal as explored, pick new target, set grace period
|
||||
- **On stuck otherwise**: Repath
|
||||
|
||||
### Path Failure Handling
|
||||
|
||||
- **Explored bias fallback**: If A* with explored grid bias fails, retry without bias (bias can make distant targets unreachable)
|
||||
- **Cooldown**: 3 seconds before retrying after path failure (prevents CPU burn on impossible paths)
|
||||
|
||||
---
|
||||
|
||||
## PathFinder — A* Implementation
|
||||
|
||||
### Signature
|
||||
|
||||
```csharp
|
||||
public static List<Vector2>? FindPath(
|
||||
WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid,
|
||||
bool[]? exploredGrid, int exploredWidth, int exploredHeight,
|
||||
int exploredOffsetX, int exploredOffsetY)
|
||||
```
|
||||
|
||||
Returns world-coordinate waypoints or null if unreachable.
|
||||
|
||||
### Movement Model
|
||||
|
||||
- **8-directional grid**: Cardinal + diagonal
|
||||
- **Costs**: Cardinal = 1.0, Diagonal = √2 ≈ 1.414
|
||||
- **Explored penalty**: ×1.5 multiplier for explored cells (biases paths through unexplored territory)
|
||||
|
||||
### Heuristic
|
||||
|
||||
```
|
||||
h = max(dx, dy) + 0.414 * min(dx, dy)
|
||||
```
|
||||
Diagonal/Chebyshev-based. Admissible and consistent.
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Snap to walkable**: If start/goal in wall, BFS search for nearest walkable cell (radius up to 20)
|
||||
2. **A* search** (budget: 200,000 iterations):
|
||||
- Priority queue ordered by f = g + h
|
||||
- 8 neighbors per expansion
|
||||
- **Corner-cut check**: Diagonals require at least one adjacent cardinal cell walkable
|
||||
- **Explored grid bias**: Multiply step cost by 1.5 for explored cells
|
||||
- Track `bestNode` (closest reachable) for fallback
|
||||
3. **Path reconstruction**: Backtrack via cameFrom map
|
||||
4. **Simplification**: Remove collinear waypoints, keep only turning points
|
||||
5. **Fallback**: If goal unreachable but bestNode is meaningfully closer (within 80% of starting heuristic), path to closest reachable cell
|
||||
|
||||
### Data Structures
|
||||
|
||||
| Structure | Type | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Open set | PriorityQueue<(int,int), float> | Nodes to expand, ordered by f-score |
|
||||
| Closed set | HashSet<(int,int)> | Already expanded nodes |
|
||||
| gScore | Dictionary<(int,int), float> | Best known cost to each node |
|
||||
| cameFrom | Dictionary<(int,int), (int,int)> | Backtracking map |
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
```
|
||||
AreaProgressionSystem
|
||||
│ .Explore() / .NavigateTo() / .NavigateToEntity()
|
||||
▼
|
||||
NavigationController
|
||||
│ .Update(GameState) → computes path, sets DesiredDirection
|
||||
│ calls PathFinder.FindPath() for A* routing
|
||||
▼
|
||||
NavigationSystem
|
||||
│ reads DesiredDirection → submits MoveAction
|
||||
▼
|
||||
ActionQueue → BotEngine → MovementKeyTracker → WASD keys
|
||||
```
|
||||
|
||||
### Coordinate Systems
|
||||
|
||||
| Space | Example | Conversion |
|
||||
|-------|---------|------------|
|
||||
| World | (1517, 4491) | Raw game units |
|
||||
| Grid | (139, 413) | world × WorldToGrid (23/250) |
|
||||
| Local grid | (139-ox, 413-oy) | grid - terrain offset |
|
||||
| Screen | project via CameraMatrix | WorldToScreen.Project() |
|
||||
180
docs/simulator.md
Normal file
180
docs/simulator.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Nexus.Simulator — Standalone Game World
|
||||
|
||||
## Purpose
|
||||
|
||||
Test bot systems (combat, navigation, threat assessment) without the real game. Replaces the memory-reading pipeline with a procedural game world. Bot systems run unmodified — they see identical `GameState` objects and emit actions to `IInputController`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SimWorld (game tick loop)
|
||||
│
|
||||
├── SimPoller (60Hz background thread)
|
||||
│ ├── FlushToWorld() → transfer input to SimWorld
|
||||
│ ├── Tick(dt) → advance simulation
|
||||
│ ├── SimStateBuilder.Build() → SimWorld → GameState
|
||||
│ └── Push to GameDataCache
|
||||
│
|
||||
├── SimInputController (captures bot actions)
|
||||
│ ├── WASD → MoveDirection vector (45° isometric conversion)
|
||||
│ ├── Skills → QueueSkill(scanCode, targetWorldPos)
|
||||
│ ├── Mouse → track position, screen↔world conversion
|
||||
│ └── Flash timers for input visualization
|
||||
│
|
||||
├── Bot Logic Thread (60Hz)
|
||||
│ ├── GameStateEnricher.Enrich(state)
|
||||
│ ├── All 6 systems: Threat, Movement, Navigation, Combat, Resource, Loot
|
||||
│ ├── NavigationController.Update()
|
||||
│ └── ExecuteActions() → SimInputController
|
||||
│
|
||||
└── Render Thread (ImGui + Veldrid)
|
||||
├── TerrainRenderer (diamond cells, isometric)
|
||||
├── EntityRenderer (player, enemies, health bars)
|
||||
├── EffectRenderer (melee cones, AOE circles, projectile lines)
|
||||
├── PathRenderer (A* waypoints)
|
||||
├── InputOverlayRenderer (keyboard + mouse state)
|
||||
└── DebugPanel (system toggles, stats, spawn controls)
|
||||
```
|
||||
|
||||
## SimWorld — Game Loop
|
||||
|
||||
### Tick (dt-based, 60Hz)
|
||||
|
||||
```
|
||||
1. CheckAndExpandTerrain() → expand when player within 50 cells of edge
|
||||
2. MovePlayer(dt) → WASD direction × speed × dt, collision with terrain
|
||||
3. ProcessSkills() → dequeue skill casts, dispatch by scan code
|
||||
4. UpdateProjectiles(dt) → move, check terrain/enemy collisions
|
||||
5. UpdateEffects(dt) → decay visual effects (0.3s duration)
|
||||
6. UpdateEnemies(dt) → AI state machine per enemy
|
||||
7. UpdateRespawns(dt) → cull far enemies, spawn new groups
|
||||
```
|
||||
|
||||
### Terrain
|
||||
|
||||
- Procedural: all walkable with scattered obstacles (rock clusters, wall segments, pillars)
|
||||
- 500×500 initial grid, `WorldToGrid = 23/250`
|
||||
- **Infinite expansion**: Expands 250 cells per side when player within 50 cells of edge
|
||||
- Preserves existing data via array copy with offset adjustment
|
||||
|
||||
### Player
|
||||
|
||||
- Position (Vector2), Health/Mana with regen (5 HP/s, 10 MP/s)
|
||||
- Move speed: 400 world units/s
|
||||
- Collision: slide-along-X / slide-along-Y fallback if direct move blocked
|
||||
|
||||
### Skills
|
||||
|
||||
| Scan Code | Type | Behavior |
|
||||
|-----------|------|----------|
|
||||
| Q (0x10), R (0x13) | AOE | Damage all enemies within 250u of target position |
|
||||
| E (0x12), T (0x14) | Projectile | Spawn projectile, 1200 speed, 800 range, 80u hit radius |
|
||||
| LMB, RMB | Melee | 150u cone, 120° angle from player toward target |
|
||||
|
||||
Base damage: 200 per hit. Configurable via SimConfig.
|
||||
|
||||
### Enemy AI
|
||||
|
||||
```
|
||||
State machine per SimEnemy:
|
||||
|
||||
Idle → wander randomly within 200u of spawn, new target every 2-5s
|
||||
│ player within 600u (aggro range)
|
||||
▼
|
||||
Chasing → move toward player at 75% player speed
|
||||
│ player within 100u (attack range)
|
||||
▼
|
||||
Attacking → stand still, deal 30 damage every 1.5s
|
||||
│ player escapes attack range
|
||||
▼ back to Chasing
|
||||
│ health ≤ 0
|
||||
▼
|
||||
Dead → visible for 2s → queue for respawn (5s delay)
|
||||
```
|
||||
|
||||
### Enemy Spawning
|
||||
|
||||
- **Groups**: 3-7 enemies per spawn, leader keeps rolled rarity, rest are Normal
|
||||
- **Rarity distribution**: 70% Normal, 20% Magic, 8% Rare, 2% Unique
|
||||
- **HP multipliers**: Magic=1.5×, Rare=3×, Unique=5× base (200)
|
||||
- **Spawn ring**: 800-2000 world units from player
|
||||
- **Direction bias**: ±90° cone ahead of player's movement direction
|
||||
- **Culling**: Remove enemies > 3000u from player
|
||||
- **Population**: Maintain 25 enemies, spawn new groups as needed
|
||||
|
||||
## Bridge Layer
|
||||
|
||||
### SimPoller
|
||||
|
||||
Replaces MemoryPoller. Background thread at 60Hz:
|
||||
1. `FlushToWorld()` — transfer accumulated input
|
||||
2. `world.Tick(dt)` — advance simulation (dt clamped to 0.1s max)
|
||||
3. `SimStateBuilder.Build()` — convert to GameState
|
||||
4. Push to GameDataCache (same fields as production)
|
||||
|
||||
### SimStateBuilder
|
||||
|
||||
Converts SimWorld state → GameState:
|
||||
- Each SimEnemy → EntitySnapshot (with rarity, threat level, AI state, HP)
|
||||
- SimPlayer → PlayerState (position, vitals, skills)
|
||||
- Camera matrix: orthographic projection (12800×7200 world units → 2560×1440 screen)
|
||||
|
||||
### SimInputController
|
||||
|
||||
Implements IInputController, captures actions instead of sending Win32 input:
|
||||
- WASD → direction vector (with 45° isometric inversion)
|
||||
- Skills → `SimWorld.QueueSkill(scanCode, worldPos)`
|
||||
- Mouse → screen position tracking, inverse camera transform for world coords
|
||||
- Input visualization: flash timers for keyboard/mouse overlay
|
||||
|
||||
## Rendering
|
||||
|
||||
### ViewTransform (Isometric Camera)
|
||||
|
||||
45° counter-clockwise rotation matching the game's camera:
|
||||
|
||||
```
|
||||
World → Grid: gx = worldX × WorldToGrid
|
||||
Grid → Screen: rx = (gx - gy) × cos(45°)
|
||||
ry = -(gx + gy) × cos(45°)
|
||||
Screen = canvasOrigin + viewOffset + (rx, ry) × zoom
|
||||
```
|
||||
|
||||
### Renderers
|
||||
|
||||
| Renderer | Draws |
|
||||
|----------|-------|
|
||||
| TerrainRenderer | Diamond cells (rotated grid), explored overlay, minimap |
|
||||
| EntityRenderer | Player (green circle), enemies (colored by rarity), health/mana bars |
|
||||
| EffectRenderer | Melee cones (red triangle fan), AOE circles (blue), projectile lines (cyan) |
|
||||
| PathRenderer | Cyan waypoint lines and dots from A* path |
|
||||
| InputOverlayRenderer | Keyboard (3 rows: 1-5, QWERT, ASDF) + mouse (L/R/M buttons) |
|
||||
| DebugPanel | Pause/speed, player stats, enemy counts, system toggles, threat info |
|
||||
|
||||
### VeldridImGuiRenderer
|
||||
|
||||
Custom ImGui backend for Veldrid 4.9.0 + D3D11:
|
||||
- HLSL shaders compiled at runtime via D3DCompiler P/Invoke
|
||||
- Dynamic vertex/index buffers, font texture from ImGui atlas
|
||||
- Alpha blending pipeline with scissor rect support
|
||||
|
||||
## SimConfig
|
||||
|
||||
```
|
||||
Terrain: 500×500, WorldToGrid=23/250, ExpandThreshold=50, ExpandAmount=250
|
||||
Player: Speed=400, HP=1000, MP=500, HPRegen=5/s, MPRegen=10/s
|
||||
Enemies: Count=25, Aggro=600u, Attack=100u, Speed=75%, HP=200, Damage=30
|
||||
Spawning: Ring=800-2000u, Groups=3-7, Cull=3000u
|
||||
Skills: Melee=150u/120°, AOE=250u, Projectile=1200speed/800range, Damage=200
|
||||
Rarity: Normal=70%, Magic=20%, Rare=8%, Unique=2%
|
||||
Simulation: SpeedMultiplier=1×, Pauseable
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
dotnet run --project src/Nexus.Simulator
|
||||
```
|
||||
|
||||
Dependencies: Core, Data, Systems, Pathfinding, ImGui.NET, Veldrid, Veldrid.StartupUtilities
|
||||
Does NOT depend on: Memory, Input, Screen, Game, Bot, Ui, Trade
|
||||
95
docs/test.html
Normal file
95
docs/test.html
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hold Timer</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 24px;
|
||||
padding: 30px 60px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#result {
|
||||
margin-top: 24px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
#live {
|
||||
margin-top: 12px;
|
||||
font-size: 20px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<button id="btn">PRESS AND HOLD</button>
|
||||
<div id="result">Last hold: 0 ms</div>
|
||||
<div id="live">Current hold: 0 ms</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const btn = document.getElementById("btn");
|
||||
const result = document.getElementById("result");
|
||||
const live = document.getElementById("live");
|
||||
|
||||
let startTime = 0;
|
||||
let holding = false;
|
||||
let rafId = 0;
|
||||
|
||||
function updateLive() {
|
||||
if (!holding) return;
|
||||
const now = performance.now();
|
||||
live.textContent = "Current hold: " + (now - startTime).toFixed(2) + " ms";
|
||||
rafId = requestAnimationFrame(updateLive);
|
||||
}
|
||||
|
||||
function startHold() {
|
||||
if (holding) return;
|
||||
holding = true;
|
||||
startTime = performance.now();
|
||||
updateLive();
|
||||
}
|
||||
|
||||
function endHold() {
|
||||
if (!holding) return;
|
||||
holding = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
const duration = performance.now() - startTime;
|
||||
result.textContent = "Last hold: " + duration.toFixed(2) + " ms";
|
||||
live.textContent = "Current hold: 0 ms";
|
||||
}
|
||||
|
||||
btn.addEventListener("mousedown", startHold);
|
||||
btn.addEventListener("mouseup", endHold);
|
||||
btn.addEventListener("mouseleave", endHold);
|
||||
|
||||
btn.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
startHold();
|
||||
}, { passive: false });
|
||||
|
||||
btn.addEventListener("touchend", endHold);
|
||||
btn.addEventListener("touchcancel", endHold);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -9,7 +9,7 @@ Size=219,425
|
|||
Collapsed=0
|
||||
|
||||
[Window][Simulator]
|
||||
Pos=11,220
|
||||
Pos=341,232
|
||||
Size=1200,681
|
||||
Collapsed=0
|
||||
|
||||
|
|
|
|||
52
src/Nexus.Core/ActionExecutor.cs
Normal file
52
src/Nexus.Core/ActionExecutor.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace Nexus.Core;
|
||||
|
||||
public static class ActionExecutor
|
||||
{
|
||||
public static void Execute(List<BotAction> resolved, IInputController input,
|
||||
MovementKeyTracker moveTracker, MovementBlender blender, Vector2? playerPos = null)
|
||||
{
|
||||
if (!input.IsInitialized) return;
|
||||
|
||||
// Discrete actions
|
||||
foreach (var action in resolved)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case FlaskAction flask:
|
||||
input.KeyPress(flask.FlaskScanCode);
|
||||
break;
|
||||
|
||||
case CastAction cast:
|
||||
if (cast.TargetScreenPos.HasValue)
|
||||
input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y);
|
||||
input.KeyPress(cast.SkillScanCode);
|
||||
break;
|
||||
|
||||
case ClickAction click:
|
||||
var cx = (int)click.ScreenPosition.X;
|
||||
var cy = (int)click.ScreenPosition.Y;
|
||||
switch (click.Type)
|
||||
{
|
||||
case ClickType.Left: input.LeftClick(cx, cy); break;
|
||||
case ClickType.Right: input.RightClick(cx, cy); break;
|
||||
case ClickType.Middle: input.MiddleClick(cx, cy); break;
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyAction key:
|
||||
switch (key.Type)
|
||||
{
|
||||
case KeyActionType.Press: input.KeyPress(key.ScanCode); break;
|
||||
case KeyActionType.Down: input.KeyDown(key.ScanCode); break;
|
||||
case KeyActionType.Up: input.KeyUp(key.ScanCode); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// WASD movement (delta-based held keys)
|
||||
moveTracker.Apply(input, blender.Direction, playerPos);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +33,11 @@ public class ActionQueue
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve conflicts and return the final action list:
|
||||
/// Resolve conflicts and return the final action list.
|
||||
/// Movement is handled by MovementBlender — only non-movement actions remain here.
|
||||
/// 1. FlaskActions always pass through
|
||||
/// 2. Get highest-priority MoveAction + CastAction
|
||||
/// 3. Urgent move (priority ≤ 10) → include move, BLOCK cast (flee)
|
||||
/// 4. Normal → include both cast + move
|
||||
/// 5. All other actions pass through
|
||||
/// 2. Get highest-priority CastAction
|
||||
/// 3. All other actions pass through
|
||||
/// </summary>
|
||||
public List<BotAction> Resolve()
|
||||
{
|
||||
|
|
@ -51,21 +50,9 @@ public class ActionQueue
|
|||
resolved.Add(action);
|
||||
}
|
||||
|
||||
var bestMove = GetHighestPriority<MoveAction>();
|
||||
var bestCast = GetHighestPriority<CastAction>();
|
||||
|
||||
if (bestMove is not null)
|
||||
{
|
||||
resolved.Add(bestMove);
|
||||
|
||||
// Urgent flee (priority ≤ 10) blocks casting
|
||||
if (bestMove.Priority > 10 && bestCast is not null)
|
||||
resolved.Add(bestCast);
|
||||
}
|
||||
else if (bestCast is not null)
|
||||
{
|
||||
if (bestCast is not null)
|
||||
resolved.Add(bestCast);
|
||||
}
|
||||
|
||||
// Pass through everything else (Key, Click, Chat, Wait) except types already handled
|
||||
foreach (var action in _actions)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ public class BotConfig
|
|||
public int MemoryPollRateHz { get; set; } = 30;
|
||||
|
||||
// Movement
|
||||
public float SafeDistance { get; set; } = 400f;
|
||||
public float RepulsionWeight { get; set; } = 1.5f;
|
||||
public float SafeDistance { get; set; } = 500f;
|
||||
public float RepulsionWeight { get; set; } = 0.5f;
|
||||
public float WaypointReachedDistance { get; set; } = 80f;
|
||||
|
||||
// Navigation
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ public interface ISystem
|
|||
int Priority { get; }
|
||||
string Name { get; }
|
||||
bool IsEnabled { get; set; }
|
||||
void Update(GameState state, ActionQueue actions);
|
||||
void Update(GameState state, ActionQueue actions, MovementBlender movement);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public static class Logging
|
|||
public static void Setup()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console(
|
||||
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.File("logs/poe2trade-.log",
|
||||
|
|
|
|||
231
src/Nexus.Core/MovementBlender.cs
Normal file
231
src/Nexus.Core/MovementBlender.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace Nexus.Core;
|
||||
|
||||
public readonly record struct MovementIntent(
|
||||
int Layer,
|
||||
Vector2 Direction,
|
||||
float Override = 0f,
|
||||
string? Source = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Blends movement contributions from multiple systems using priority-layered attenuation.
|
||||
/// Higher-priority layers (lower number) attenuate lower-priority ones via their Override factor.
|
||||
/// Applies terrain validation once on the blended result. WASD hysteresis handles smoothing.
|
||||
/// </summary>
|
||||
public sealed class MovementBlender
|
||||
{
|
||||
private readonly List<MovementIntent> _intents = new();
|
||||
|
||||
// Stuck detection
|
||||
private Vector2 _lastResolvePos;
|
||||
private int _stuckFrames;
|
||||
private const int StuckFrameThreshold = 30; // ~0.5s at 60Hz
|
||||
private const float StuckMovePerFrame = 3f; // must move > 3 world units per frame to count as moving
|
||||
|
||||
// EMA smoothing to dampen terrain validation jitter.
|
||||
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
|
||||
// terrain probe noise from bypassing the EMA via the snap threshold.
|
||||
private Vector2? _smoothedDirection;
|
||||
private Vector2? _lastIntentDir; // pre-terrain direction from previous frame
|
||||
private const float SmoothingAlpha = 0.12f; // 12% new, 88% previous
|
||||
|
||||
// Terrain validation cache — prevents re-probing within the same grid cell,
|
||||
// breaking the position↔direction feedback loop that causes zigzag oscillation
|
||||
private Vector2 _cachedTerrainInputDir;
|
||||
private Vector2 _cachedTerrainResult;
|
||||
private int _cachedTerrainGridX = int.MinValue;
|
||||
private int _cachedTerrainGridY = int.MinValue;
|
||||
|
||||
public Vector2? Direction { get; private set; }
|
||||
public Vector2? RawDirection { get; private set; }
|
||||
|
||||
/// <summary>True when layer 0 (critical flee) was submitted — blocks casting.</summary>
|
||||
public bool IsUrgentFlee { get; private set; }
|
||||
|
||||
/// <summary>True when the player hasn't moved for several frames — orbit/herd suppressed.</summary>
|
||||
public bool IsStuck { get; private set; }
|
||||
|
||||
/// <summary>Snapshot of intents from the last Resolve() call, for diagnostic logging.</summary>
|
||||
public IReadOnlyList<MovementIntent> LastIntents => _lastIntents;
|
||||
private List<MovementIntent> _lastIntents = new();
|
||||
|
||||
public void Submit(MovementIntent intent) => _intents.Add(intent);
|
||||
|
||||
/// <summary>
|
||||
/// Clears intents for a new frame. Called at the top of each logic tick.
|
||||
/// </summary>
|
||||
public void Clear() => _intents.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Blends all submitted intents and validates against terrain.
|
||||
/// Applies EMA smoothing after terrain validation to dampen probe jitter.
|
||||
/// </summary>
|
||||
public void Resolve(WalkabilitySnapshot? terrain, Vector2 playerPos, float worldToGrid)
|
||||
{
|
||||
IsUrgentFlee = false;
|
||||
|
||||
// ── Stuck detection ──
|
||||
// If player barely moves for ~0.5s, suppress orbit/herd so navigation can guide out
|
||||
var moved = Vector2.Distance(playerPos, _lastResolvePos);
|
||||
if (moved < StuckMovePerFrame)
|
||||
_stuckFrames++;
|
||||
else
|
||||
_stuckFrames = Math.Max(0, _stuckFrames - 3); // recover 3x faster than building up
|
||||
_lastResolvePos = playerPos;
|
||||
IsStuck = _stuckFrames > StuckFrameThreshold;
|
||||
|
||||
if (IsStuck)
|
||||
{
|
||||
// Keep only flee (L0, L1) and navigation (L3) — drop orbit (L2) and herd (L4)
|
||||
_intents.RemoveAll(i => i.Layer == 2 || i.Layer == 4);
|
||||
}
|
||||
|
||||
_lastIntents = new List<MovementIntent>(_intents);
|
||||
|
||||
if (_intents.Count == 0)
|
||||
{
|
||||
RawDirection = null;
|
||||
Direction = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for urgent flee (layer 0)
|
||||
foreach (var intent in _intents)
|
||||
{
|
||||
if (intent.Layer == 0)
|
||||
{
|
||||
IsUrgentFlee = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Group by layer, sum within layer, track max override per layer
|
||||
var layers = new SortedDictionary<int, (Vector2 Sum, float MaxOverride)>();
|
||||
foreach (var intent in _intents)
|
||||
{
|
||||
if (layers.TryGetValue(intent.Layer, out var existing))
|
||||
layers[intent.Layer] = (existing.Sum + intent.Direction, Math.Max(existing.MaxOverride, intent.Override));
|
||||
else
|
||||
layers[intent.Layer] = (intent.Direction, intent.Override);
|
||||
}
|
||||
|
||||
// Blend across layers with priority-based attenuation
|
||||
var attenuation = 1f;
|
||||
var result = Vector2.Zero;
|
||||
|
||||
foreach (var (_, (sum, maxOverride)) in layers)
|
||||
{
|
||||
result += sum * attenuation;
|
||||
attenuation *= (1f - maxOverride);
|
||||
}
|
||||
|
||||
if (result.LengthSquared() < 0.0001f)
|
||||
{
|
||||
RawDirection = null;
|
||||
Direction = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize the blended result
|
||||
var rawDir = Vector2.Normalize(result);
|
||||
var intentDir = rawDir; // save pre-terrain direction for snap decision
|
||||
|
||||
// Terrain validation with grid-cell caching.
|
||||
// Re-probe only when the raw direction changes (>~14°) or the player enters a new grid cell.
|
||||
// This prevents the feedback loop: direction jitter → zigzag movement → crosses cell boundary → more jitter.
|
||||
if (terrain is not null)
|
||||
{
|
||||
var gx = (int)(playerPos.X * worldToGrid);
|
||||
var gy = (int)(playerPos.Y * worldToGrid);
|
||||
var dirSimilar = Vector2.Dot(rawDir, _cachedTerrainInputDir) > 0.97f;
|
||||
var sameCell = gx == _cachedTerrainGridX && gy == _cachedTerrainGridY;
|
||||
|
||||
if (dirSimilar && sameCell)
|
||||
{
|
||||
rawDir = _cachedTerrainResult;
|
||||
}
|
||||
else
|
||||
{
|
||||
var preTerrainDir = rawDir;
|
||||
rawDir = TerrainQuery.FindWalkableDirection(terrain, playerPos, rawDir, worldToGrid);
|
||||
_cachedTerrainInputDir = preTerrainDir;
|
||||
_cachedTerrainResult = rawDir;
|
||||
_cachedTerrainGridX = gx;
|
||||
_cachedTerrainGridY = gy;
|
||||
}
|
||||
}
|
||||
|
||||
RawDirection = rawDir;
|
||||
|
||||
// EMA smoothing. Snap decision based on whether the INTENT (pre-terrain) changed,
|
||||
// not the terrain output. This prevents terrain probe noise (which can produce 90°+ swings)
|
||||
// from bypassing the EMA via the snap threshold.
|
||||
if (_smoothedDirection.HasValue)
|
||||
{
|
||||
var intentChanged = _lastIntentDir.HasValue &&
|
||||
Vector2.Dot(_lastIntentDir.Value, intentDir) < 0f;
|
||||
|
||||
if (intentChanged)
|
||||
{
|
||||
// Genuine intent reversal (flee, new waypoint) — snap immediately
|
||||
}
|
||||
else
|
||||
{
|
||||
// Intent is stable — all direction change is terrain noise, always smooth
|
||||
var smoothed = Vector2.Lerp(_smoothedDirection.Value, rawDir, SmoothingAlpha);
|
||||
if (smoothed.LengthSquared() > 0.0001f)
|
||||
rawDir = Vector2.Normalize(smoothed);
|
||||
}
|
||||
}
|
||||
|
||||
_smoothedDirection = rawDir;
|
||||
_lastIntentDir = intentDir;
|
||||
Direction = rawDir;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full reset — call on area change or loading screen.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_intents.Clear();
|
||||
_lastIntents.Clear();
|
||||
Direction = null;
|
||||
RawDirection = null;
|
||||
IsUrgentFlee = false;
|
||||
IsStuck = false;
|
||||
_stuckFrames = 0;
|
||||
_lastResolvePos = Vector2.Zero;
|
||||
_smoothedDirection = null;
|
||||
_lastIntentDir = null;
|
||||
_cachedTerrainGridX = int.MinValue;
|
||||
_cachedTerrainGridY = int.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compact diagnostic string: lists active intents and final direction.
|
||||
/// Example: "Orbit(L2,0.0) Navigation(L3,0.0) Herd(L4,0.2) → (0.71,-0.31)"
|
||||
/// </summary>
|
||||
public string DiagnosticSummary()
|
||||
{
|
||||
if (_lastIntents.Count == 0)
|
||||
return "none";
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var intent in _lastIntents)
|
||||
{
|
||||
var dir = intent.Direction;
|
||||
var mag = dir.Length();
|
||||
parts.Add($"{intent.Source ?? "?"}(L{intent.Layer},ovr={intent.Override:F1},mag={mag:F2})");
|
||||
}
|
||||
|
||||
var dirStr = Direction.HasValue
|
||||
? $"({Direction.Value.X:F2},{Direction.Value.Y:F2})"
|
||||
: "null";
|
||||
|
||||
var stuckStr = IsStuck ? " [STUCK]" : "";
|
||||
return string.Join(" + ", parts) + " → " + dirStr + stuckStr;
|
||||
}
|
||||
}
|
||||
122
src/Nexus.Core/MovementKeyTracker.cs
Normal file
122
src/Nexus.Core/MovementKeyTracker.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System.Numerics;
|
||||
using Serilog;
|
||||
|
||||
namespace Nexus.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Translates a movement direction vector into WASD key presses.
|
||||
/// Applies 45° rotation to account for isometric camera (W+A = one world axis).
|
||||
/// Tracks which keys are currently held and only sends changes (delta).
|
||||
/// Enforces a minimum hold duration (55±10ms gaussian) on every key press.
|
||||
/// </summary>
|
||||
public sealed class MovementKeyTracker
|
||||
{
|
||||
private bool _wHeld, _aHeld, _sHeld, _dHeld;
|
||||
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
|
||||
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
|
||||
private Vector2? _lastPlayerPos;
|
||||
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
// 45° rotation constants
|
||||
private const float Cos45 = 0.70710678f;
|
||||
private const float Sin45 = 0.70710678f;
|
||||
|
||||
// Hysteresis: higher threshold to press, lower to release — prevents oscillation
|
||||
private const float PressThreshold = 0.35f;
|
||||
private const float ReleaseThreshold = 0.15f;
|
||||
|
||||
/// <summary>
|
||||
/// Apply a movement direction. Null or zero direction releases all keys.
|
||||
/// Direction is in world space; we rotate 45° for the isometric camera before mapping to WASD.
|
||||
/// Uses hysteresis to prevent key oscillation.
|
||||
/// </summary>
|
||||
public void Apply(IInputController input, Vector2? direction, Vector2? playerPos = null)
|
||||
{
|
||||
_lastPlayerPos = playerPos;
|
||||
|
||||
bool wantW, wantA, wantS, wantD;
|
||||
|
||||
if (direction is { } dir && dir.LengthSquared() > 0.001f)
|
||||
{
|
||||
// Rotate 45° for isometric camera alignment
|
||||
var sx = dir.X * Cos45 - dir.Y * Sin45;
|
||||
var sy = dir.X * Sin45 + dir.Y * Cos45;
|
||||
|
||||
// Hysteresis: different thresholds for press vs release
|
||||
wantW = _wHeld ? sy > ReleaseThreshold : sy > PressThreshold;
|
||||
wantS = _sHeld ? sy < -ReleaseThreshold : sy < -PressThreshold;
|
||||
wantD = _dHeld ? sx > ReleaseThreshold : sx > PressThreshold;
|
||||
wantA = _aHeld ? sx < -ReleaseThreshold : sx < -PressThreshold;
|
||||
}
|
||||
else
|
||||
{
|
||||
wantW = wantA = wantS = wantD = false;
|
||||
}
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, wantW, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.A, ref _aHeld, ref _aDownAt, ref _aMinHold, wantA, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.S, ref _sHeld, ref _sDownAt, ref _sMinHold, wantS, now, _lastPlayerPos);
|
||||
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, wantD, now, _lastPlayerPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release all movement keys immediately (bypasses min hold — for shutdown/area change).
|
||||
/// </summary>
|
||||
public void ReleaseAll(IInputController input)
|
||||
{
|
||||
if (_wHeld) { input.KeyUp(ScanCodes.W); _wHeld = false; }
|
||||
if (_aHeld) { input.KeyUp(ScanCodes.A); _aHeld = false; }
|
||||
if (_sHeld) { input.KeyUp(ScanCodes.S); _sHeld = false; }
|
||||
if (_dHeld) { input.KeyUp(ScanCodes.D); _dHeld = false; }
|
||||
}
|
||||
|
||||
private static string KeyName(ushort scanCode) => scanCode switch
|
||||
{
|
||||
0x11 => "W", 0x1E => "A", 0x1F => "S", 0x20 => "D", _ => $"0x{scanCode:X2}"
|
||||
};
|
||||
|
||||
private static void SetKey(IInputController input, ushort scanCode,
|
||||
ref bool held, ref long downAt, ref int minHold, bool want, long now, Vector2? pos)
|
||||
{
|
||||
if (want && !held)
|
||||
{
|
||||
input.KeyDown(scanCode);
|
||||
held = true;
|
||||
downAt = now;
|
||||
minHold = HoldMs();
|
||||
if (pos.HasValue)
|
||||
Log.Information("[WASD] {Key} DOWN (minHold={MinHold}ms) pos=({X:F0},{Y:F0})",
|
||||
KeyName(scanCode), minHold, pos.Value.X, pos.Value.Y);
|
||||
else
|
||||
Log.Information("[WASD] {Key} DOWN (minHold={MinHold}ms)", KeyName(scanCode), minHold);
|
||||
}
|
||||
else if (!want && held)
|
||||
{
|
||||
var elapsed = now - downAt;
|
||||
if (elapsed < minHold) return; // enforce minimum hold
|
||||
input.KeyUp(scanCode);
|
||||
held = false;
|
||||
if (pos.HasValue)
|
||||
Log.Information("[WASD] {Key} UP (held={Elapsed}ms, min={MinHold}ms) pos=({X:F0},{Y:F0})",
|
||||
KeyName(scanCode), elapsed, minHold, pos.Value.X, pos.Value.Y);
|
||||
else
|
||||
Log.Information("[WASD] {Key} UP (held={Elapsed}ms, min={MinHold}ms)", KeyName(scanCode), elapsed, minHold);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gaussian hold duration peaked at 55ms, range [44, 76].</summary>
|
||||
private static int HoldMs()
|
||||
{
|
||||
double u, v, s;
|
||||
do
|
||||
{
|
||||
u = Rng.NextDouble() * 2.0 - 1.0;
|
||||
v = Rng.NextDouble() * 2.0 - 1.0;
|
||||
s = u * u + v * v;
|
||||
} while (s >= 1.0 || s == 0.0);
|
||||
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
|
||||
}
|
||||
}
|
||||
56
src/Nexus.Core/ScanCodes.cs
Normal file
56
src/Nexus.Core/ScanCodes.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
namespace Nexus.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Hardware scan codes for keyboard input.
|
||||
/// </summary>
|
||||
public static class ScanCodes
|
||||
{
|
||||
// WASD movement
|
||||
public const ushort W = 0x11;
|
||||
public const ushort A = 0x1E;
|
||||
public const ushort S = 0x1F;
|
||||
public const ushort D = 0x20;
|
||||
|
||||
// Number row
|
||||
public const ushort Key1 = 0x02;
|
||||
public const ushort Key2 = 0x03;
|
||||
public const ushort Key3 = 0x04;
|
||||
public const ushort Key4 = 0x05;
|
||||
public const ushort Key5 = 0x06;
|
||||
public const ushort Key6 = 0x07;
|
||||
public const ushort Key7 = 0x08;
|
||||
public const ushort Key8 = 0x09;
|
||||
public const ushort Key9 = 0x0A;
|
||||
public const ushort Key0 = 0x0B;
|
||||
|
||||
// Modifiers
|
||||
public const ushort LShift = 0x2A;
|
||||
public const ushort RShift = 0x36;
|
||||
public const ushort LCtrl = 0x1D;
|
||||
public const ushort LAlt = 0x38;
|
||||
|
||||
// Common keys
|
||||
public const ushort Escape = 0x01;
|
||||
public const ushort Tab = 0x0F;
|
||||
public const ushort Space = 0x39;
|
||||
public const ushort Enter = 0x1C;
|
||||
public const ushort Backspace = 0x0E;
|
||||
|
||||
// Function keys
|
||||
public const ushort F1 = 0x3B;
|
||||
public const ushort F2 = 0x3C;
|
||||
public const ushort F3 = 0x3D;
|
||||
public const ushort F4 = 0x3E;
|
||||
public const ushort F5 = 0x3F;
|
||||
|
||||
// Letters (commonly used)
|
||||
public const ushort Q = 0x10;
|
||||
public const ushort E = 0x12;
|
||||
public const ushort R = 0x13;
|
||||
public const ushort T = 0x14;
|
||||
public const ushort I = 0x17;
|
||||
public const ushort F = 0x21;
|
||||
|
||||
// Slash (for chat commands like /hideout)
|
||||
public const ushort Slash = 0x35;
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ public static class TerrainQuery
|
|||
/// </summary>
|
||||
public static Vector2 FindWalkableDirection(
|
||||
WalkabilitySnapshot terrain, Vector2 playerPos, Vector2 desiredDir, float worldToGrid,
|
||||
float probeDistance = 200f)
|
||||
float probeDistance = 60f)
|
||||
{
|
||||
if (IsDirectionClear(terrain, playerPos, desiredDir, worldToGrid, probeDistance))
|
||||
return desiredDir;
|
||||
|
|
@ -67,15 +67,59 @@ public static class TerrainQuery
|
|||
private static bool IsDirectionClear(
|
||||
WalkabilitySnapshot terrain, Vector2 origin, Vector2 dir, float worldToGrid, float distance)
|
||||
{
|
||||
var endpoint = origin + dir * distance;
|
||||
// Check near (2-3 grid cells), mid, and far probes
|
||||
var nearpoint = origin + dir * 30f;
|
||||
var midpoint = origin + dir * (distance * 0.5f);
|
||||
var endpoint = origin + dir * distance;
|
||||
|
||||
int nx = (int)(nearpoint.X * worldToGrid);
|
||||
int ny = (int)(nearpoint.Y * worldToGrid);
|
||||
int mx = (int)(midpoint.X * worldToGrid);
|
||||
int my = (int)(midpoint.Y * worldToGrid);
|
||||
int ex = (int)(endpoint.X * worldToGrid);
|
||||
int ey = (int)(endpoint.Y * worldToGrid);
|
||||
|
||||
return terrain.IsWalkable(mx, my) && terrain.IsWalkable(ex, ey);
|
||||
return terrain.IsWalkable(nx, ny) && terrain.IsWalkable(mx, my) && terrain.IsWalkable(ex, ey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes 8 directions around the player for nearby walls.
|
||||
/// Returns a normalized push-away vector, or Zero if no walls are close.
|
||||
/// </summary>
|
||||
public static Vector2 ComputeWallRepulsion(WalkabilitySnapshot terrain, Vector2 playerPos, float worldToGrid)
|
||||
{
|
||||
const float probeNear = 25f; // ~2-3 grid cells
|
||||
const float probeFar = 60f; // ~5-6 grid cells
|
||||
|
||||
var push = Vector2.Zero;
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var angle = i * MathF.PI / 4f;
|
||||
var dir = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
|
||||
|
||||
// Near probe — strong push
|
||||
var near = playerPos + dir * probeNear;
|
||||
var nx = (int)(near.X * worldToGrid);
|
||||
var ny = (int)(near.Y * worldToGrid);
|
||||
if (!terrain.IsWalkable(nx, ny))
|
||||
{
|
||||
push -= dir * 1.0f;
|
||||
continue; // don't double-count
|
||||
}
|
||||
|
||||
// Far probe — gentle push
|
||||
var far = playerPos + dir * probeFar;
|
||||
var fx = (int)(far.X * worldToGrid);
|
||||
var fy = (int)(far.Y * worldToGrid);
|
||||
if (!terrain.IsWalkable(fx, fy))
|
||||
push -= dir * 0.4f;
|
||||
}
|
||||
|
||||
if (push.LengthSquared() < 0.0001f)
|
||||
return Vector2.Zero;
|
||||
|
||||
return Vector2.Normalize(push);
|
||||
}
|
||||
|
||||
private static Vector2 Rotate(Vector2 v, float degrees)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,23 @@ public record WalkabilitySnapshot
|
|||
public int Height { get; init; }
|
||||
public byte[] Data { get; init; } = [];
|
||||
|
||||
public bool IsWalkable(int x, int y)
|
||||
/// <summary>
|
||||
/// Absolute grid X coordinate of the top-left corner of Data.
|
||||
/// Grid coord gx maps to local index gx - OffsetX.
|
||||
/// </summary>
|
||||
public int OffsetX { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute grid Y coordinate of the top-left corner of Data.
|
||||
/// </summary>
|
||||
public int OffsetY { get; init; }
|
||||
|
||||
public bool IsWalkable(int gx, int gy)
|
||||
{
|
||||
if (x < 0 || x >= Width || y < 0 || y >= Height)
|
||||
var lx = gx - OffsetX;
|
||||
var ly = gy - OffsetY;
|
||||
if (lx < 0 || lx >= Width || ly < 0 || ly >= Height)
|
||||
return false;
|
||||
return Data[y * Width + x] != 0;
|
||||
return Data[ly * Width + lx] != 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,26 +63,50 @@ public static class GameStateEnricher
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes danger using a weighted threat score.
|
||||
/// Computes danger using effective HP (life + energy shield) and a weighted threat score.
|
||||
/// Close enemies count more, rares/uniques escalate significantly.
|
||||
/// Hysteresis: de-escalation requires a larger margin than escalation to prevent oscillation.
|
||||
/// </summary>
|
||||
private static DangerLevel _previousDanger = DangerLevel.Safe;
|
||||
private static float _smoothedThreatScore;
|
||||
private static long _lastEscalationMs;
|
||||
|
||||
private static DangerLevel ComputeDangerLevel(GameState state)
|
||||
{
|
||||
if (state.Player.LifePercent < 30f) return DangerLevel.Critical;
|
||||
if (state.Player.LifePercent < 50f) return DangerLevel.High;
|
||||
// Effective HP = life + ES combined
|
||||
var effectiveHp = state.Player.LifeCurrent + state.Player.EsCurrent;
|
||||
var effectiveMax = state.Player.LifeTotal + state.Player.EsTotal;
|
||||
var effectivePercent = effectiveMax > 0 ? (float)effectiveHp / effectiveMax * 100f : 0f;
|
||||
|
||||
// Weighted threat score: proximity × rarity multiplier
|
||||
// Pure life check — if ES is gone and life is low, it's critical (no hysteresis)
|
||||
if (state.Player.LifePercent < 25f)
|
||||
{
|
||||
_previousDanger = DangerLevel.Critical;
|
||||
return DangerLevel.Critical;
|
||||
}
|
||||
if (effectivePercent < 35f)
|
||||
{
|
||||
_previousDanger = DangerLevel.Critical;
|
||||
return DangerLevel.Critical;
|
||||
}
|
||||
if (effectivePercent < 50f)
|
||||
{
|
||||
var hpLevel = DangerLevel.High;
|
||||
if (hpLevel < _previousDanger)
|
||||
hpLevel = _previousDanger; // don't de-escalate from HP alone
|
||||
_previousDanger = hpLevel;
|
||||
return hpLevel;
|
||||
}
|
||||
|
||||
// Weighted threat score: smooth distance falloff × rarity multiplier
|
||||
var threatScore = 0f;
|
||||
foreach (var m in state.HostileMonsters)
|
||||
{
|
||||
var d = m.DistanceToPlayer;
|
||||
if (d > 800f) continue;
|
||||
|
||||
// Distance weight: closer = more dangerous
|
||||
float distWeight;
|
||||
if (d < 200f) distWeight = 3f;
|
||||
else if (d < 400f) distWeight = 2f;
|
||||
else distWeight = 1f;
|
||||
// Smooth distance weight: linear falloff from 3.0 at d=0 to 0.5 at d=800
|
||||
var distWeight = 3f - 2.5f * (d / 800f);
|
||||
|
||||
// Rarity multiplier
|
||||
var rarityMul = m.Rarity switch
|
||||
|
|
@ -96,10 +120,48 @@ public static class GameStateEnricher
|
|||
threatScore += distWeight * rarityMul;
|
||||
}
|
||||
|
||||
if (threatScore >= 15f) return DangerLevel.Critical;
|
||||
if (threatScore >= 8f) return DangerLevel.High;
|
||||
if (threatScore >= 4f) return DangerLevel.Medium;
|
||||
if (threatScore > 0f) return DangerLevel.Low;
|
||||
return DangerLevel.Safe;
|
||||
// EMA smoothing — prevents single-frame score spikes from causing oscillation.
|
||||
// Snap upward (escalation is instant), smooth downward (de-escalation is gradual).
|
||||
const float deescalationAlpha = 0.08f;
|
||||
if (threatScore >= _smoothedThreatScore)
|
||||
_smoothedThreatScore = threatScore; // snap up — react instantly to new threats
|
||||
else
|
||||
_smoothedThreatScore += (threatScore - _smoothedThreatScore) * deescalationAlpha;
|
||||
threatScore = _smoothedThreatScore;
|
||||
|
||||
// Escalation thresholds
|
||||
var level = DangerLevel.Safe;
|
||||
if (threatScore >= 15f) level = DangerLevel.Critical;
|
||||
else if (threatScore >= 8f) level = DangerLevel.High;
|
||||
else if (threatScore >= 4f) level = DangerLevel.Medium;
|
||||
else if (threatScore > 0f) level = DangerLevel.Low;
|
||||
|
||||
// Hysteresis: minimum hold time + score margins prevent oscillation
|
||||
var now = Environment.TickCount64;
|
||||
if (level != _previousDanger)
|
||||
{
|
||||
// Hold any level for at least 1.5 seconds before allowing ANY transition
|
||||
if (now - _lastEscalationMs < 1500)
|
||||
{
|
||||
level = _previousDanger;
|
||||
}
|
||||
else if (level < _previousDanger)
|
||||
{
|
||||
// Score-based hysteresis — only drop one level at a time
|
||||
if (_previousDanger >= DangerLevel.Critical)
|
||||
level = DangerLevel.High;
|
||||
else if (_previousDanger >= DangerLevel.High)
|
||||
level = DangerLevel.Medium;
|
||||
else if (_previousDanger >= DangerLevel.Medium && threatScore >= 2f)
|
||||
level = DangerLevel.Medium;
|
||||
}
|
||||
}
|
||||
|
||||
// Track any transition
|
||||
if (level != _previousDanger)
|
||||
_lastEscalationMs = now;
|
||||
|
||||
_previousDanger = level;
|
||||
return level;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,10 +73,12 @@ public sealed partial class InterceptionInputController : IInputController, IDis
|
|||
if (_humanizer is not null)
|
||||
{
|
||||
if (_humanizer.ShouldThrottle()) return;
|
||||
holdMs = _humanizer.GaussianDelay(holdMs);
|
||||
_humanizer.RecordAction();
|
||||
}
|
||||
_keyboard?.SimulateKeyPress((KeyCode)scanCode, holdMs);
|
||||
var hold = HoldMs();
|
||||
Log.Information("[Key] 0x{ScanCode:X2} DOWN (hold={HoldMs}ms)", scanCode, hold);
|
||||
_keyboard?.SimulateKeyPress((KeyCode)scanCode, hold);
|
||||
Log.Information("[Key] 0x{ScanCode:X2} UP", scanCode);
|
||||
}
|
||||
|
||||
public void MouseMoveTo(int x, int y)
|
||||
|
|
@ -150,7 +152,10 @@ public sealed partial class InterceptionInputController : IInputController, IDis
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
_mouse?.SimulateLeftButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
||||
var hold = HoldMs();
|
||||
Log.Information("[Click] Left DOWN (hold={HoldMs}ms)", hold);
|
||||
_mouse?.SimulateLeftButtonClick(hold);
|
||||
Log.Information("[Click] Left UP");
|
||||
}
|
||||
|
||||
public void RightClick(int x, int y)
|
||||
|
|
@ -164,7 +169,10 @@ public sealed partial class InterceptionInputController : IInputController, IDis
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
_mouse?.SimulateRightButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
||||
var hold = HoldMs();
|
||||
Log.Information("[Click] Right DOWN (hold={HoldMs}ms)", hold);
|
||||
_mouse?.SimulateRightButtonClick(hold);
|
||||
Log.Information("[Click] Right UP");
|
||||
}
|
||||
|
||||
public void MiddleClick(int x, int y)
|
||||
|
|
@ -178,7 +186,24 @@ public sealed partial class InterceptionInputController : IInputController, IDis
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
_mouse?.SimulateMiddleButtonClick(_humanizer?.GaussianDelay(50) ?? 50);
|
||||
var hold = HoldMs();
|
||||
Log.Information("[Click] Middle DOWN (hold={HoldMs}ms)", hold);
|
||||
_mouse?.SimulateMiddleButtonClick(hold);
|
||||
Log.Information("[Click] Middle UP");
|
||||
}
|
||||
|
||||
/// <summary>Gaussian hold duration peaked at 55ms, range [44, 76].</summary>
|
||||
private static int HoldMs()
|
||||
{
|
||||
double u, v, s;
|
||||
do
|
||||
{
|
||||
u = Rng.NextDouble() * 2.0 - 1.0;
|
||||
v = Rng.NextDouble() * 2.0 - 1.0;
|
||||
s = u * u + v * v;
|
||||
} while (s >= 1.0 || s == 0.0);
|
||||
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
|
||||
}
|
||||
|
||||
public void LeftDown()
|
||||
|
|
|
|||
|
|
@ -1,56 +1,44 @@
|
|||
namespace Nexus.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Hardware scan codes for keyboard input via Interception driver.
|
||||
/// </summary>
|
||||
public static class ScanCodes
|
||||
// ScanCodes has moved to Nexus.Core. This file re-exports for backward compatibility.
|
||||
// ReSharper disable once CheckNamespace
|
||||
namespace Nexus.Input
|
||||
{
|
||||
// WASD movement
|
||||
public const ushort W = 0x11;
|
||||
public const ushort A = 0x1E;
|
||||
public const ushort S = 0x1F;
|
||||
public const ushort D = 0x20;
|
||||
|
||||
// Number row
|
||||
public const ushort Key1 = 0x02;
|
||||
public const ushort Key2 = 0x03;
|
||||
public const ushort Key3 = 0x04;
|
||||
public const ushort Key4 = 0x05;
|
||||
public const ushort Key5 = 0x06;
|
||||
public const ushort Key6 = 0x07;
|
||||
public const ushort Key7 = 0x08;
|
||||
public const ushort Key8 = 0x09;
|
||||
public const ushort Key9 = 0x0A;
|
||||
public const ushort Key0 = 0x0B;
|
||||
|
||||
// Modifiers
|
||||
public const ushort LShift = 0x2A;
|
||||
public const ushort RShift = 0x36;
|
||||
public const ushort LCtrl = 0x1D;
|
||||
public const ushort LAlt = 0x38;
|
||||
|
||||
// Common keys
|
||||
public const ushort Escape = 0x01;
|
||||
public const ushort Tab = 0x0F;
|
||||
public const ushort Space = 0x39;
|
||||
public const ushort Enter = 0x1C;
|
||||
public const ushort Backspace = 0x0E;
|
||||
|
||||
// Function keys
|
||||
public const ushort F1 = 0x3B;
|
||||
public const ushort F2 = 0x3C;
|
||||
public const ushort F3 = 0x3D;
|
||||
public const ushort F4 = 0x3E;
|
||||
public const ushort F5 = 0x3F;
|
||||
|
||||
// Letters (commonly used)
|
||||
public const ushort Q = 0x10;
|
||||
public const ushort E = 0x12;
|
||||
public const ushort R = 0x13;
|
||||
public const ushort T = 0x14;
|
||||
public const ushort I = 0x17;
|
||||
public const ushort F = 0x21;
|
||||
|
||||
// Slash (for chat commands like /hideout)
|
||||
public const ushort Slash = 0x35;
|
||||
/// <summary>Re-exports Nexus.Core.ScanCodes for backward compatibility.</summary>
|
||||
public static class ScanCodes
|
||||
{
|
||||
public const ushort W = Core.ScanCodes.W;
|
||||
public const ushort A = Core.ScanCodes.A;
|
||||
public const ushort S = Core.ScanCodes.S;
|
||||
public const ushort D = Core.ScanCodes.D;
|
||||
public const ushort Key1 = Core.ScanCodes.Key1;
|
||||
public const ushort Key2 = Core.ScanCodes.Key2;
|
||||
public const ushort Key3 = Core.ScanCodes.Key3;
|
||||
public const ushort Key4 = Core.ScanCodes.Key4;
|
||||
public const ushort Key5 = Core.ScanCodes.Key5;
|
||||
public const ushort Key6 = Core.ScanCodes.Key6;
|
||||
public const ushort Key7 = Core.ScanCodes.Key7;
|
||||
public const ushort Key8 = Core.ScanCodes.Key8;
|
||||
public const ushort Key9 = Core.ScanCodes.Key9;
|
||||
public const ushort Key0 = Core.ScanCodes.Key0;
|
||||
public const ushort LShift = Core.ScanCodes.LShift;
|
||||
public const ushort RShift = Core.ScanCodes.RShift;
|
||||
public const ushort LCtrl = Core.ScanCodes.LCtrl;
|
||||
public const ushort LAlt = Core.ScanCodes.LAlt;
|
||||
public const ushort Escape = Core.ScanCodes.Escape;
|
||||
public const ushort Tab = Core.ScanCodes.Tab;
|
||||
public const ushort Space = Core.ScanCodes.Space;
|
||||
public const ushort Enter = Core.ScanCodes.Enter;
|
||||
public const ushort Backspace = Core.ScanCodes.Backspace;
|
||||
public const ushort F1 = Core.ScanCodes.F1;
|
||||
public const ushort F2 = Core.ScanCodes.F2;
|
||||
public const ushort F3 = Core.ScanCodes.F3;
|
||||
public const ushort F4 = Core.ScanCodes.F4;
|
||||
public const ushort F5 = Core.ScanCodes.F5;
|
||||
public const ushort Q = Core.ScanCodes.Q;
|
||||
public const ushort E = Core.ScanCodes.E;
|
||||
public const ushort R = Core.ScanCodes.R;
|
||||
public const ushort T = Core.ScanCodes.T;
|
||||
public const ushort I = Core.ScanCodes.I;
|
||||
public const ushort F = Core.ScanCodes.F;
|
||||
public const ushort Slash = Core.ScanCodes.Slash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,15 @@ public sealed partial class SendInputController : IInputController
|
|||
if (_humanizer is not null)
|
||||
{
|
||||
if (_humanizer.ShouldThrottle()) return;
|
||||
holdMs = _humanizer.GaussianDelay(holdMs);
|
||||
_humanizer.RecordAction();
|
||||
}
|
||||
|
||||
var hold = HoldMs();
|
||||
Log.Information("[Key] 0x{ScanCode:X2} DOWN (hold={HoldMs}ms)", scanCode, hold);
|
||||
KeyDown(scanCode);
|
||||
Thread.Sleep(holdMs);
|
||||
Thread.Sleep(hold);
|
||||
KeyUp(scanCode);
|
||||
Log.Information("[Key] 0x{ScanCode:X2} UP", scanCode);
|
||||
}
|
||||
|
||||
// ── Mouse movement ──
|
||||
|
|
@ -113,7 +115,7 @@ public sealed partial class SendInputController : IInputController
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
MouseClick(MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, _humanizer?.GaussianDelay(50) ?? 50);
|
||||
MouseClick(MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, HoldMs());
|
||||
}
|
||||
|
||||
public void RightClick(int x, int y)
|
||||
|
|
@ -127,7 +129,7 @@ public sealed partial class SendInputController : IInputController
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
MouseClick(MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, _humanizer?.GaussianDelay(50) ?? 50);
|
||||
MouseClick(MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, HoldMs());
|
||||
}
|
||||
|
||||
public void MiddleClick(int x, int y)
|
||||
|
|
@ -141,7 +143,7 @@ public sealed partial class SendInputController : IInputController
|
|||
}
|
||||
SmoothMoveTo(x, y);
|
||||
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10);
|
||||
MouseClick(MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, _humanizer?.GaussianDelay(50) ?? 50);
|
||||
MouseClick(MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, HoldMs());
|
||||
}
|
||||
|
||||
public void LeftDown()
|
||||
|
|
@ -170,13 +172,38 @@ public sealed partial class SendInputController : IInputController
|
|||
|
||||
// ── Private helpers ──
|
||||
|
||||
/// <summary>Gaussian hold duration peaked at 55ms, range [44, 76].</summary>
|
||||
private static int HoldMs()
|
||||
{
|
||||
double u, v, s;
|
||||
do
|
||||
{
|
||||
u = Rng.NextDouble() * 2.0 - 1.0;
|
||||
v = Rng.NextDouble() * 2.0 - 1.0;
|
||||
s = u * u + v * v;
|
||||
} while (s >= 1.0 || s == 0.0);
|
||||
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
|
||||
}
|
||||
|
||||
private static string ClickName(uint downFlag) => downFlag switch
|
||||
{
|
||||
MOUSEEVENTF_LEFTDOWN => "Left",
|
||||
MOUSEEVENTF_RIGHTDOWN => "Right",
|
||||
MOUSEEVENTF_MIDDLEDOWN => "Middle",
|
||||
_ => "?"
|
||||
};
|
||||
|
||||
private void MouseClick(uint downFlag, uint upFlag, int holdMs)
|
||||
{
|
||||
var name = ClickName(downFlag);
|
||||
Log.Information("[Click] {Button} DOWN (hold={HoldMs}ms)", name, holdMs);
|
||||
var down = MakeMouseInput(downFlag);
|
||||
var up = MakeMouseInput(upFlag);
|
||||
SendInput(1, [down], INPUT_SIZE);
|
||||
Thread.Sleep(holdMs);
|
||||
SendInput(1, [up], INPUT_SIZE);
|
||||
Log.Information("[Click] {Button} UP", name);
|
||||
}
|
||||
|
||||
private static double EaseInOutQuad(double t) =>
|
||||
|
|
|
|||
|
|
@ -28,16 +28,20 @@ public sealed class NavigationController
|
|||
// Explored grid — tracks which terrain cells the player has visited
|
||||
private bool[]? _exploredGrid;
|
||||
private int _exploredWidth, _exploredHeight;
|
||||
private int _exploredOffsetX, _exploredOffsetY;
|
||||
private const int ExploreMarkRadius = 150; // grid cells (~1630 world units)
|
||||
|
||||
// Stuck detection: rolling window of recent positions
|
||||
private readonly Queue<Vector2> _positionHistory = new();
|
||||
private const int StuckWindowSize = 10;
|
||||
private const float StuckThreshold = 5f;
|
||||
private const int StuckWindowSize = 120; // ~2 seconds at 60Hz
|
||||
private const float StuckThreshold = 50f; // must move at least 50 world units in that window
|
||||
|
||||
// Path failure cooldown — don't retry immediately when pathfinding fails
|
||||
private long _pathFailCooldownMs;
|
||||
|
||||
// Grace period after picking a new explore target — don't check stuck immediately
|
||||
private int _stuckGraceTicks;
|
||||
|
||||
public NavMode Mode { get; private set; } = NavMode.Idle;
|
||||
public Vector2? DesiredDirection { get; private set; }
|
||||
public IReadOnlyList<Vector2>? CurrentPath => _path;
|
||||
|
|
@ -45,6 +49,8 @@ public sealed class NavigationController
|
|||
public bool[]? ExploredGrid => _exploredGrid;
|
||||
public int ExploredWidth => _exploredWidth;
|
||||
public int ExploredHeight => _exploredHeight;
|
||||
public int ExploredOffsetX => _exploredOffsetX;
|
||||
public int ExploredOffsetY => _exploredOffsetY;
|
||||
|
||||
/// <summary>
|
||||
/// True when BFS exploration finds no more unexplored walkable cells in the current area.
|
||||
|
|
@ -136,39 +142,14 @@ public sealed class NavigationController
|
|||
IsExplorationComplete = false;
|
||||
}
|
||||
|
||||
// Allocate explored grid on first tick with terrain, after area change,
|
||||
// or when terrain dimensions change (prevents bounds mismatch crash)
|
||||
// Allocate or resize explored grid to match terrain (preserving old data on expansion)
|
||||
var terrain = state.Terrain;
|
||||
if (terrain is not null &&
|
||||
(_exploredGrid is null || terrain.Width != _exploredWidth || terrain.Height != _exploredHeight))
|
||||
{
|
||||
_exploredWidth = terrain.Width;
|
||||
_exploredHeight = terrain.Height;
|
||||
_exploredGrid = new bool[_exploredWidth * _exploredHeight];
|
||||
}
|
||||
if (terrain is not null)
|
||||
EnsureExploredGrid(terrain);
|
||||
|
||||
// Mark cells near player as explored
|
||||
if (_exploredGrid is not null && terrain is not null)
|
||||
{
|
||||
var pgx = (int)(playerPos.X * _config.WorldToGrid);
|
||||
var pgy = (int)(playerPos.Y * _config.WorldToGrid);
|
||||
var r = ExploreMarkRadius;
|
||||
var r2 = r * r;
|
||||
var minX = Math.Max(0, pgx - r);
|
||||
var maxX = Math.Min(_exploredWidth - 1, pgx + r);
|
||||
var minY = Math.Max(0, pgy - r);
|
||||
var maxY = Math.Min(_exploredHeight - 1, pgy + r);
|
||||
for (var y = minY; y <= maxY; y++)
|
||||
{
|
||||
var dy = y - pgy;
|
||||
for (var x = minX; x <= maxX; x++)
|
||||
{
|
||||
var dx = x - pgx;
|
||||
if (dx * dx + dy * dy <= r2)
|
||||
_exploredGrid[y * _exploredWidth + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
MarkExplored(playerPos);
|
||||
|
||||
// Resolve goal based on mode
|
||||
var goal = ResolveGoal(state);
|
||||
|
|
@ -211,8 +192,11 @@ public sealed class NavigationController
|
|||
if (_positionHistory.Count > StuckWindowSize)
|
||||
_positionHistory.Dequeue();
|
||||
|
||||
if (_stuckGraceTicks > 0)
|
||||
_stuckGraceTicks--;
|
||||
|
||||
var isStuck = false;
|
||||
if (_positionHistory.Count >= StuckWindowSize && _path is not null)
|
||||
if (_stuckGraceTicks <= 0 && _positionHistory.Count >= StuckWindowSize && _path is not null)
|
||||
{
|
||||
var oldest = _positionHistory.Peek();
|
||||
if (Vector2.Distance(oldest, playerPos) < StuckThreshold)
|
||||
|
|
@ -221,10 +205,16 @@ public sealed class NavigationController
|
|||
if (Mode == NavMode.Exploring)
|
||||
{
|
||||
Log.Information("NavigationController: stuck while exploring, picking new target");
|
||||
|
||||
// Mark cells around the failed goal as explored so BFS won't pick the same target
|
||||
if (_goalPosition.HasValue && _exploredGrid is not null)
|
||||
MarkExplored(_goalPosition.Value);
|
||||
|
||||
_goalPosition = null;
|
||||
_path = null;
|
||||
_waypointIndex = 0;
|
||||
_positionHistory.Clear();
|
||||
_stuckGraceTicks = 120; // 2 seconds grace for next target
|
||||
return;
|
||||
}
|
||||
Log.Debug("NavigationController: stuck detected, repathing");
|
||||
|
|
@ -264,7 +254,7 @@ public sealed class NavigationController
|
|||
|
||||
_path = Mode == NavMode.Exploring
|
||||
? PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid,
|
||||
_exploredGrid, _exploredWidth, _exploredHeight)
|
||||
_exploredGrid, _exploredWidth, _exploredHeight, _exploredOffsetX, _exploredOffsetY)
|
||||
: PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid);
|
||||
_waypointIndex = 0;
|
||||
_pathTimestampMs = now;
|
||||
|
|
@ -321,18 +311,54 @@ public sealed class NavigationController
|
|||
// Diagnostic: log every ~60 ticks (once per second at 60Hz)
|
||||
if (state.TickNumber % 60 == 0)
|
||||
{
|
||||
var gx = (int)(playerPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(playerPos.Y * _config.WorldToGrid);
|
||||
var walkable = state.Terrain?.IsWalkable(gx, gy) ?? false;
|
||||
var gx2 = (int)(playerPos.X * _config.WorldToGrid);
|
||||
var gy2 = (int)(playerPos.Y * _config.WorldToGrid);
|
||||
var walkable = state.Terrain?.IsWalkable(gx2, gy2) ?? false;
|
||||
Log.Information(
|
||||
"NAV DIAG: playerWorld=({Px:F0},{Py:F0}) playerGrid=({Gx},{Gy}) walkable={W} " +
|
||||
"waypointWorld=({Tx:F0},{Ty:F0}) dir=({Dx:F2},{Dy:F2})",
|
||||
playerPos.X, playerPos.Y, gx, gy, walkable,
|
||||
playerPos.X, playerPos.Y, gx2, gy2, walkable,
|
||||
target.X, target.Y,
|
||||
DesiredDirection.Value.X, DesiredDirection.Value.Y);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureExploredGrid(WalkabilitySnapshot terrain)
|
||||
{
|
||||
var needsResize = _exploredGrid is null
|
||||
|| terrain.Width != _exploredWidth || terrain.Height != _exploredHeight
|
||||
|| terrain.OffsetX != _exploredOffsetX || terrain.OffsetY != _exploredOffsetY;
|
||||
|
||||
if (!needsResize) return;
|
||||
|
||||
var newGrid = new bool[terrain.Width * terrain.Height];
|
||||
|
||||
// Preserve old explored data in overlapping region
|
||||
if (_exploredGrid is not null)
|
||||
{
|
||||
var overlapMinX = Math.Max(terrain.OffsetX, _exploredOffsetX);
|
||||
var overlapMinY = Math.Max(terrain.OffsetY, _exploredOffsetY);
|
||||
var overlapMaxX = Math.Min(terrain.OffsetX + terrain.Width, _exploredOffsetX + _exploredWidth);
|
||||
var overlapMaxY = Math.Min(terrain.OffsetY + terrain.Height, _exploredOffsetY + _exploredHeight);
|
||||
|
||||
for (var ay = overlapMinY; ay < overlapMaxY; ay++)
|
||||
for (var ax = overlapMinX; ax < overlapMaxX; ax++)
|
||||
{
|
||||
var oldLx = ax - _exploredOffsetX;
|
||||
var oldLy = ay - _exploredOffsetY;
|
||||
var newLx = ax - terrain.OffsetX;
|
||||
var newLy = ay - terrain.OffsetY;
|
||||
newGrid[newLy * terrain.Width + newLx] = _exploredGrid[oldLy * _exploredWidth + oldLx];
|
||||
}
|
||||
}
|
||||
|
||||
_exploredGrid = newGrid;
|
||||
_exploredWidth = terrain.Width;
|
||||
_exploredHeight = terrain.Height;
|
||||
_exploredOffsetX = terrain.OffsetX;
|
||||
_exploredOffsetY = terrain.OffsetY;
|
||||
}
|
||||
|
||||
private Vector2? ResolveGoal(GameState state)
|
||||
{
|
||||
switch (Mode)
|
||||
|
|
@ -365,22 +391,26 @@ public sealed class NavigationController
|
|||
if (state.Terrain is null || _exploredGrid is null) return null;
|
||||
|
||||
var terrain = state.Terrain;
|
||||
// Bail if terrain dimensions don't match the allocated grid (area transition in progress)
|
||||
if (terrain.Width != _exploredWidth || terrain.Height != _exploredHeight) return null;
|
||||
|
||||
var gridToWorld = 1f / _config.WorldToGrid;
|
||||
var playerPos = state.Player.Position;
|
||||
var ox = terrain.OffsetX;
|
||||
var oy = terrain.OffsetY;
|
||||
var w = terrain.Width;
|
||||
var h = terrain.Height;
|
||||
|
||||
var startGx = Math.Clamp((int)(playerPos.X * _config.WorldToGrid), 0, w - 1);
|
||||
var startGy = Math.Clamp((int)(playerPos.Y * _config.WorldToGrid), 0, h - 1);
|
||||
// Player in local grid coords
|
||||
var pgx = (int)(playerPos.X * _config.WorldToGrid);
|
||||
var pgy = (int)(playerPos.Y * _config.WorldToGrid);
|
||||
var startLx = Math.Clamp(pgx - ox, 0, w - 1);
|
||||
var startLy = Math.Clamp(pgy - oy, 0, h - 1);
|
||||
|
||||
// BFS outward from player to find nearest unexplored walkable cell
|
||||
var visited = new bool[w * h];
|
||||
var queue = new Queue<(int x, int y)>();
|
||||
queue.Enqueue((startGx, startGy));
|
||||
visited[startGy * w + startGx] = true;
|
||||
var queue = new Queue<(int lx, int ly)>();
|
||||
queue.Enqueue((startLx, startLy));
|
||||
visited[startLy * w + startLx] = true;
|
||||
|
||||
var iterations = 0;
|
||||
const int maxIterations = 100_000;
|
||||
|
|
@ -388,13 +418,15 @@ public sealed class NavigationController
|
|||
while (queue.Count > 0 && iterations++ < maxIterations)
|
||||
{
|
||||
var (cx, cy) = queue.Dequeue();
|
||||
var ax = cx + ox;
|
||||
var ay = cy + oy;
|
||||
|
||||
// Found an unexplored walkable cell
|
||||
if (terrain.IsWalkable(cx, cy) && !_exploredGrid[cy * w + cx])
|
||||
if (terrain.IsWalkable(ax, ay) && !IsExploredAt(ax, ay))
|
||||
{
|
||||
var worldPos = new Vector2(cx * gridToWorld, cy * gridToWorld);
|
||||
var worldPos = new Vector2(ax * gridToWorld, ay * gridToWorld);
|
||||
_goalPosition = worldPos;
|
||||
Log.Debug("BFS frontier: target ({Gx},{Gy}) after {Iter} iterations", cx, cy, iterations);
|
||||
Log.Debug("BFS frontier: target ({Gx},{Gy}) after {Iter} iterations", ax, ay, iterations);
|
||||
return worldPos;
|
||||
}
|
||||
|
||||
|
|
@ -406,17 +438,101 @@ public sealed class NavigationController
|
|||
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
|
||||
var idx = ny * w + nx;
|
||||
if (visited[idx]) continue;
|
||||
if (!terrain.IsWalkable(nx, ny)) continue;
|
||||
if (!terrain.IsWalkable(nx + ox, ny + oy)) continue;
|
||||
visited[idx] = true;
|
||||
queue.Enqueue((nx, ny));
|
||||
}
|
||||
}
|
||||
|
||||
// Don't declare exploration complete — with infinite terrain, new cells appear at edges.
|
||||
// Pick a random distant target to push toward terrain boundaries where expansion triggers.
|
||||
var randomTarget = PickRandomDistantTarget(playerPos, terrain, gridToWorld);
|
||||
if (randomTarget is not null)
|
||||
{
|
||||
_goalPosition = randomTarget;
|
||||
Log.Information("BFS frontier: no unexplored cells nearby, roaming to ({X:F0},{Y:F0})",
|
||||
randomTarget.Value.X, randomTarget.Value.Y);
|
||||
return randomTarget;
|
||||
}
|
||||
|
||||
Log.Information("BFS frontier: no unexplored cells found — exploration complete");
|
||||
IsExplorationComplete = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
private Vector2? PickRandomDistantTarget(Vector2 playerPos, WalkabilitySnapshot terrain, float gridToWorld)
|
||||
{
|
||||
// Try random directions at a moderate distance — aim for terrain edges
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
var angle = _rng.NextSingle() * MathF.Tau;
|
||||
var dist = 1500f + _rng.NextSingle() * 2000f; // 1500-3500 world units away
|
||||
var target = playerPos + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * dist;
|
||||
var gx = (int)(target.X * _config.WorldToGrid);
|
||||
var gy = (int)(target.Y * _config.WorldToGrid);
|
||||
if (terrain.IsWalkable(gx, gy))
|
||||
return target;
|
||||
}
|
||||
|
||||
// Fallback: pick a point toward the nearest terrain edge (guaranteed to push toward expansion)
|
||||
var pgx = (int)(playerPos.X * _config.WorldToGrid);
|
||||
var pgy = (int)(playerPos.Y * _config.WorldToGrid);
|
||||
var ox = terrain.OffsetX;
|
||||
var oy = terrain.OffsetY;
|
||||
var distToLeft = pgx - ox;
|
||||
var distToRight = (ox + terrain.Width) - pgx;
|
||||
var distToTop = pgy - oy;
|
||||
var distToBottom = (oy + terrain.Height) - pgy;
|
||||
|
||||
// Find the nearest edge and go toward it
|
||||
var minEdgeDist = Math.Min(Math.Min(distToLeft, distToRight), Math.Min(distToTop, distToBottom));
|
||||
int tgx, tgy;
|
||||
if (minEdgeDist == distToLeft)
|
||||
{ tgx = ox + 10; tgy = pgy; }
|
||||
else if (minEdgeDist == distToRight)
|
||||
{ tgx = ox + terrain.Width - 10; tgy = pgy; }
|
||||
else if (minEdgeDist == distToTop)
|
||||
{ tgx = pgx; tgy = oy + 10; }
|
||||
else
|
||||
{ tgx = pgx; tgy = oy + terrain.Height - 10; }
|
||||
|
||||
if (terrain.IsWalkable(tgx, tgy))
|
||||
return new Vector2(tgx * gridToWorld, tgy * gridToWorld);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void MarkExplored(Vector2 worldPos)
|
||||
{
|
||||
if (_exploredGrid is null) return;
|
||||
var gx = (int)(worldPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(worldPos.Y * _config.WorldToGrid);
|
||||
var r = ExploreMarkRadius;
|
||||
var r2 = r * r;
|
||||
for (var dy = -r; dy <= r; dy++)
|
||||
for (var dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (dx * dx + dy * dy > r2) continue;
|
||||
SetExploredAt(gx + dx, gy + dy);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetExploredAt(int gx, int gy)
|
||||
{
|
||||
var lx = gx - _exploredOffsetX;
|
||||
var ly = gy - _exploredOffsetY;
|
||||
if (lx >= 0 && lx < _exploredWidth && ly >= 0 && ly < _exploredHeight)
|
||||
_exploredGrid![ly * _exploredWidth + lx] = true;
|
||||
}
|
||||
|
||||
private bool IsExploredAt(int gx, int gy)
|
||||
{
|
||||
var lx = gx - _exploredOffsetX;
|
||||
var ly = gy - _exploredOffsetY;
|
||||
if (lx < 0 || lx >= _exploredWidth || ly < 0 || ly >= _exploredHeight) return false;
|
||||
return _exploredGrid![ly * _exploredWidth + lx];
|
||||
}
|
||||
|
||||
private static readonly int[] _bfsDx = [-1, 0, 1, 0, -1, -1, 1, 1];
|
||||
private static readonly int[] _bfsDy = [0, -1, 0, 1, -1, 1, -1, 1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,24 +12,27 @@ public static class PathFinder
|
|||
|
||||
/// <summary>
|
||||
/// A* pathfinding on WalkabilitySnapshot. Returns world-coord waypoints or null if no path.
|
||||
/// When exploredGrid is provided, explored cells cost 3x more — biasing paths through unexplored territory.
|
||||
/// When exploredGrid is provided, explored cells cost 1.5x more — biasing paths through unexplored territory.
|
||||
/// </summary>
|
||||
public static List<Vector2>? FindPath(
|
||||
WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid,
|
||||
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0)
|
||||
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0,
|
||||
int exploredOffsetX = 0, int exploredOffsetY = 0)
|
||||
{
|
||||
var w = terrain.Width;
|
||||
var h = terrain.Height;
|
||||
var ox = terrain.OffsetX;
|
||||
var oy = terrain.OffsetY;
|
||||
var gridToWorld = 1f / worldToGrid;
|
||||
|
||||
var startGx = Math.Clamp((int)(start.X * worldToGrid), 0, w - 1);
|
||||
var startGy = Math.Clamp((int)(start.Y * worldToGrid), 0, h - 1);
|
||||
var goalGx = Math.Clamp((int)(goal.X * worldToGrid), 0, w - 1);
|
||||
var goalGy = Math.Clamp((int)(goal.Y * worldToGrid), 0, h - 1);
|
||||
var startGx = Math.Clamp((int)(start.X * worldToGrid), ox, ox + w - 1);
|
||||
var startGy = Math.Clamp((int)(start.Y * worldToGrid), oy, oy + h - 1);
|
||||
var goalGx = Math.Clamp((int)(goal.X * worldToGrid), ox, ox + w - 1);
|
||||
var goalGy = Math.Clamp((int)(goal.Y * worldToGrid), oy, oy + h - 1);
|
||||
|
||||
// Snap to nearest walkable if start/goal are in walls
|
||||
(startGx, startGy) = SnapToWalkable(terrain, startGx, startGy, w, h);
|
||||
(goalGx, goalGy) = SnapToWalkable(terrain, goalGx, goalGy, w, h);
|
||||
(startGx, startGy) = SnapToWalkable(terrain, startGx, startGy);
|
||||
(goalGx, goalGy) = SnapToWalkable(terrain, goalGx, goalGy);
|
||||
|
||||
var startNode = (startGx, startGy);
|
||||
var goalNode = (goalGx, goalGy);
|
||||
|
|
@ -78,7 +81,7 @@ public static class PathFinder
|
|||
var nx = current.x + Dx[i];
|
||||
var ny = current.y + Dy[i];
|
||||
|
||||
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
|
||||
// IsWalkable handles offset and bounds
|
||||
if (!terrain.IsWalkable(nx, ny)) continue;
|
||||
|
||||
var neighbor = (nx, ny);
|
||||
|
|
@ -93,8 +96,15 @@ public static class PathFinder
|
|||
}
|
||||
|
||||
var stepCost = Cost[i];
|
||||
if (exploredGrid is not null && nx < exploredWidth && ny < exploredHeight && exploredGrid[ny * exploredWidth + nx])
|
||||
stepCost *= 1.5f;
|
||||
if (exploredGrid is not null)
|
||||
{
|
||||
var elx = nx - exploredOffsetX;
|
||||
var ely = ny - exploredOffsetY;
|
||||
if (elx >= 0 && elx < exploredWidth && ely >= 0 && ely < exploredHeight
|
||||
&& exploredGrid[ely * exploredWidth + elx])
|
||||
stepCost *= 1.5f;
|
||||
}
|
||||
|
||||
var tentativeG = currentG + stepCost;
|
||||
|
||||
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue))
|
||||
|
|
@ -129,7 +139,7 @@ public static class PathFinder
|
|||
return Math.Max(dx, dy) + 0.414f * Math.Min(dx, dy);
|
||||
}
|
||||
|
||||
private static (int, int) SnapToWalkable(WalkabilitySnapshot terrain, int gx, int gy, int w, int h)
|
||||
private static (int, int) SnapToWalkable(WalkabilitySnapshot terrain, int gx, int gy)
|
||||
{
|
||||
if (terrain.IsWalkable(gx, gy)) return (gx, gy);
|
||||
|
||||
|
|
@ -143,7 +153,7 @@ public static class PathFinder
|
|||
if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
|
||||
var nx = gx + dx;
|
||||
var ny = gy + dy;
|
||||
if (nx >= 0 && nx < w && ny >= 0 && ny < h && terrain.IsWalkable(nx, ny))
|
||||
if (terrain.IsWalkable(nx, ny))
|
||||
return (nx, ny);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using Nexus.Core;
|
||||
using Nexus.Simulator.World;
|
||||
using Serilog;
|
||||
|
||||
namespace Nexus.Simulator.Bridge;
|
||||
|
||||
|
|
@ -22,8 +23,27 @@ public class SimInputController : IInputController
|
|||
// Camera matrix for screen→world conversion
|
||||
private Matrix4x4? _cameraMatrix;
|
||||
|
||||
// Input visualization tracking
|
||||
private readonly Dictionary<ushort, float> _keyTimers = new();
|
||||
private readonly float[] _mouseTimers = new float[3];
|
||||
private const float FlashDuration = 0.3f;
|
||||
|
||||
// Smooth mouse interpolation
|
||||
private Vector2 _mouseMoveStartPos;
|
||||
private Vector2 _mouseTargetPos;
|
||||
private float _mouseMoveProgress = 1f; // 1 = arrived
|
||||
private const float MouseMoveSpeed = 6f; // interpolation speed (higher = faster)
|
||||
|
||||
public bool IsInitialized => true;
|
||||
|
||||
/// <summary>
|
||||
/// The bot's current mouse position in screen coordinates (for mock cursor rendering).
|
||||
/// </summary>
|
||||
public Vector2 MouseScreenPos
|
||||
{
|
||||
get { lock (_lock) return _mouseScreenPos; }
|
||||
}
|
||||
|
||||
public SimInputController(SimWorld world)
|
||||
{
|
||||
_world = world;
|
||||
|
|
@ -74,6 +94,11 @@ public class SimInputController : IInputController
|
|||
|
||||
// IInputController implementation — captures actions, no actual Win32 calls
|
||||
|
||||
private static string KeyName(ushort sc) => sc switch
|
||||
{
|
||||
0x11 => "W", 0x1E => "A", 0x1F => "S", 0x20 => "D", _ => $"0x{sc:X2}"
|
||||
};
|
||||
|
||||
public void KeyDown(ushort scanCode)
|
||||
{
|
||||
lock (_lock)
|
||||
|
|
@ -85,6 +110,7 @@ public class SimInputController : IInputController
|
|||
case 0x1F: _sHeld = true; break; // S
|
||||
case 0x20: _dHeld = true; break; // D
|
||||
}
|
||||
_keyTimers[scanCode] = float.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,11 +125,16 @@ public class SimInputController : IInputController
|
|||
case 0x1F: _sHeld = false; break;
|
||||
case 0x20: _dHeld = false; break;
|
||||
}
|
||||
_keyTimers.Remove(scanCode);
|
||||
}
|
||||
}
|
||||
|
||||
public void KeyPress(ushort scanCode, int holdMs = 50)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keyTimers[scanCode] = FlashDuration;
|
||||
}
|
||||
// Queue as skill cast
|
||||
var target = ScreenToWorld(_mouseScreenPos);
|
||||
_world.QueueSkill(scanCode, target);
|
||||
|
|
@ -114,7 +145,15 @@ public class SimInputController : IInputController
|
|||
lock (_lock) { _mouseScreenPos = new Vector2(x, y); }
|
||||
}
|
||||
|
||||
public void SmoothMoveTo(int x, int y) => MouseMoveTo(x, y);
|
||||
public void SmoothMoveTo(int x, int y)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_mouseMoveStartPos = _mouseScreenPos;
|
||||
_mouseTargetPos = new Vector2(x, y);
|
||||
_mouseMoveProgress = 0f;
|
||||
}
|
||||
}
|
||||
public void MouseMoveBy(int dx, int dy)
|
||||
{
|
||||
lock (_lock) { _mouseScreenPos += new Vector2(dx, dy); }
|
||||
|
|
@ -122,14 +161,17 @@ public class SimInputController : IInputController
|
|||
|
||||
public void LeftClick(int x, int y)
|
||||
{
|
||||
Log.Information("[Click] Left at ({X},{Y})", x, y);
|
||||
lock (_lock) { _mouseTimers[0] = FlashDuration; }
|
||||
MouseMoveTo(x, y);
|
||||
var target = ScreenToWorld(new Vector2(x, y));
|
||||
// LMB = default attack / melee
|
||||
_world.QueueSkill(0, target);
|
||||
}
|
||||
|
||||
public void RightClick(int x, int y)
|
||||
{
|
||||
Log.Information("[Click] Right at ({X},{Y})", x, y);
|
||||
lock (_lock) { _mouseTimers[1] = FlashDuration; }
|
||||
MouseMoveTo(x, y);
|
||||
var target = ScreenToWorld(new Vector2(x, y));
|
||||
_world.QueueSkill(1, target);
|
||||
|
|
@ -137,13 +179,93 @@ public class SimInputController : IInputController
|
|||
|
||||
public void MiddleClick(int x, int y)
|
||||
{
|
||||
Log.Information("[Click] Middle at ({X},{Y})", x, y);
|
||||
lock (_lock) { _mouseTimers[2] = FlashDuration; }
|
||||
MouseMoveTo(x, y);
|
||||
var target = ScreenToWorld(new Vector2(x, y));
|
||||
_world.QueueSkill(2, target);
|
||||
}
|
||||
|
||||
public void LeftDown() { }
|
||||
public void LeftUp() { }
|
||||
public void RightDown() { }
|
||||
public void RightUp() { }
|
||||
public void LeftDown() { lock (_lock) { _mouseTimers[0] = float.MaxValue; } }
|
||||
public void LeftUp() { lock (_lock) { _mouseTimers[0] = 0; } }
|
||||
public void RightDown() { lock (_lock) { _mouseTimers[1] = float.MaxValue; } }
|
||||
public void RightUp() { lock (_lock) { _mouseTimers[1] = 0; } }
|
||||
|
||||
/// <summary>
|
||||
/// Decrement flash timers and interpolate mouse. Call once per frame with frame delta time.
|
||||
/// </summary>
|
||||
public void UpdateTimers(float dt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Key flash timers
|
||||
var expired = new List<ushort>();
|
||||
var updates = new List<(ushort key, float time)>();
|
||||
|
||||
foreach (var kvp in _keyTimers)
|
||||
{
|
||||
if (kvp.Value < 1000f) // not held (held = MaxValue)
|
||||
{
|
||||
var t = kvp.Value - dt;
|
||||
if (t <= 0) expired.Add(kvp.Key);
|
||||
else updates.Add((kvp.Key, t));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var k in expired) _keyTimers.Remove(k);
|
||||
foreach (var (k, t) in updates) _keyTimers[k] = t;
|
||||
|
||||
// Mouse button flash timers
|
||||
for (var i = 0; i < 3; i++)
|
||||
if (_mouseTimers[i] > 0 && _mouseTimers[i] < 1000f)
|
||||
_mouseTimers[i] = MathF.Max(0, _mouseTimers[i] - dt);
|
||||
|
||||
// Smooth mouse interpolation toward target
|
||||
if (_mouseMoveProgress < 1f)
|
||||
{
|
||||
_mouseMoveProgress = MathF.Min(1f, _mouseMoveProgress + dt * MouseMoveSpeed);
|
||||
// Ease-out: fast start, slow end
|
||||
var t = 1f - MathF.Pow(1f - _mouseMoveProgress, 3f);
|
||||
_mouseScreenPos = Vector2.Lerp(_mouseMoveStartPos, _mouseTargetPos, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of currently active keys and mouse buttons for visualization.
|
||||
/// </summary>
|
||||
public InputSnapshot GetInputSnapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var keys = new HashSet<ushort>();
|
||||
foreach (var (k, t) in _keyTimers)
|
||||
if (t > 0) keys.Add(k);
|
||||
return new InputSnapshot(keys, _mouseTimers[0] > 0, _mouseTimers[1] > 0, _mouseTimers[2] > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct InputSnapshot
|
||||
{
|
||||
private readonly HashSet<ushort>? _activeKeys;
|
||||
private readonly bool _leftMouse, _rightMouse, _middleMouse;
|
||||
|
||||
public InputSnapshot(HashSet<ushort> activeKeys, bool left, bool right, bool middle)
|
||||
{
|
||||
_activeKeys = activeKeys;
|
||||
_leftMouse = left;
|
||||
_rightMouse = right;
|
||||
_middleMouse = middle;
|
||||
}
|
||||
|
||||
public bool IsKeyActive(ushort scanCode) => _activeKeys?.Contains(scanCode) ?? false;
|
||||
|
||||
public bool IsMouseActive(int button) => button switch
|
||||
{
|
||||
0 => _leftMouse,
|
||||
1 => _rightMouse,
|
||||
2 => _middleMouse,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ public sealed class SimPoller : IDisposable
|
|||
_world.Player.Position.X, _world.Player.Position.Y, 0f);
|
||||
_cache.PlayerVitals = new PlayerVitalsData(
|
||||
_world.Player.Health, _world.Player.MaxHealth,
|
||||
_world.Player.Mana, _world.Player.MaxMana, 0, 0);
|
||||
_world.Player.Mana, _world.Player.MaxMana,
|
||||
_world.Player.Es, _world.Player.MaxEs);
|
||||
_cache.IsLoading = false;
|
||||
_cache.IsEscapeOpen = false;
|
||||
_cache.Entities = state.Entities;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ public static class SimStateBuilder
|
|||
LifeTotal = player.MaxHealth,
|
||||
ManaCurrent = player.Mana,
|
||||
ManaTotal = player.MaxMana,
|
||||
EsCurrent = player.Es,
|
||||
EsTotal = player.MaxEs,
|
||||
Skills = BuildSkillStates(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,25 +9,45 @@ public class SimConfig
|
|||
|
||||
// Player
|
||||
public float PlayerMoveSpeed { get; set; } = 400f;
|
||||
public int PlayerMaxHealth { get; set; } = 1000;
|
||||
public int PlayerMaxHealth { get; set; } = 800;
|
||||
public int PlayerMaxMana { get; set; } = 500;
|
||||
public float PlayerHealthRegen { get; set; } = 5f;
|
||||
public int PlayerMaxEs { get; set; } = 400;
|
||||
public float PlayerHealthRegen { get; set; } = 8f;
|
||||
public float PlayerManaRegen { get; set; } = 10f;
|
||||
public float PlayerEsRegen { get; set; } = 30f; // ES recharge rate (per second, once recharging)
|
||||
public float PlayerEsRechargeDelay { get; set; } = 2f; // Seconds after last damage before ES recharges
|
||||
|
||||
// Enemies
|
||||
public int TargetEnemyCount { get; set; } = 25;
|
||||
// Enemies — melee
|
||||
public int TargetEnemyCount { get; set; } = 50;
|
||||
public float EnemyAggroRange { get; set; } = 600f;
|
||||
public float EnemyAttackRange { get; set; } = 100f;
|
||||
public float EnemyMeleeAttackRange { get; set; } = 100f;
|
||||
public float EnemyMoveSpeedFactor { get; set; } = 0.75f;
|
||||
public int EnemyBaseHealth { get; set; } = 200;
|
||||
public int EnemyAttackDamage { get; set; } = 30;
|
||||
public float EnemyAttackCooldown { get; set; } = 1.5f;
|
||||
public int EnemyMeleeBaseDamage { get; set; } = 60;
|
||||
public float EnemyMeleeAttackCooldown { get; set; } = 1.2f;
|
||||
|
||||
// Enemies — ranged
|
||||
public float RangedEnemyChance { get; set; } = 0.30f; // 30% of enemies are ranged
|
||||
public float EnemyRangedAttackRange { get; set; } = 500f;
|
||||
public float EnemyRangedPreferredRange { get; set; } = 350f;
|
||||
public int EnemyRangedBaseDamage { get; set; } = 40;
|
||||
public float EnemyRangedAttackCooldown { get; set; } = 2.0f;
|
||||
public float EnemyProjectileSpeed { get; set; } = 800f;
|
||||
public float EnemyProjectileHitRadius { get; set; } = 40f;
|
||||
|
||||
// Enemies — general
|
||||
public float EnemyDespawnTime { get; set; } = 2f;
|
||||
public float EnemyRespawnTime { get; set; } = 5f;
|
||||
public float EnemyWanderRadius { get; set; } = 200f;
|
||||
public float EnemySpawnMinDist { get; set; } = 800f;
|
||||
public float EnemySpawnMaxDist { get; set; } = 2000f;
|
||||
public float EnemyCullDist { get; set; } = 3000f;
|
||||
public int EnemyGroupMin { get; set; } = 3;
|
||||
public int EnemyGroupMax { get; set; } = 7;
|
||||
public float EnemyGroupSpread { get; set; } = 120f;
|
||||
|
||||
// Skills
|
||||
public float MeleeRange { get; set; } = 150f;
|
||||
// Player skills
|
||||
public float MeleeRange { get; set; } = 350f;
|
||||
public float MeleeConeAngle { get; set; } = 120f;
|
||||
public float AoeRadius { get; set; } = 250f;
|
||||
public float ProjectileSpeed { get; set; } = 1200f;
|
||||
|
|
@ -35,6 +55,10 @@ public class SimConfig
|
|||
public float ProjectileHitRadius { get; set; } = 80f;
|
||||
public int SkillBaseDamage { get; set; } = 200;
|
||||
|
||||
// Terrain expansion
|
||||
public int ExpandThreshold { get; set; } = 50;
|
||||
public int ExpandAmount { get; set; } = 250;
|
||||
|
||||
// Simulation
|
||||
public float SpeedMultiplier { get; set; } = 1f;
|
||||
public bool IsPaused { get; set; }
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ using Veldrid.Sdl2;
|
|||
using Veldrid.StartupUtilities;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
Log.Information("Nexus Simulator starting...");
|
||||
|
|
@ -36,24 +36,7 @@ var poller = new SimPoller(world, input, cache, simConfig);
|
|||
var nav = new NavigationController(botConfig);
|
||||
|
||||
// ── Create systems (same as BotEngine, minus AreaProgression) ──
|
||||
var systems = new List<ISystem>
|
||||
{
|
||||
new ThreatSystem { WorldToGrid = botConfig.WorldToGrid },
|
||||
new MovementSystem
|
||||
{
|
||||
SafeDistance = botConfig.SafeDistance,
|
||||
RepulsionWeight = botConfig.RepulsionWeight,
|
||||
WorldToGrid = botConfig.WorldToGrid,
|
||||
},
|
||||
new NavigationSystem
|
||||
{
|
||||
WorldToGrid = botConfig.WorldToGrid,
|
||||
WaypointReachedDistance = botConfig.WaypointReachedDistance,
|
||||
},
|
||||
new CombatSystem(botConfig),
|
||||
new ResourceSystem(botConfig),
|
||||
new LootSystem(),
|
||||
};
|
||||
var systems = SystemFactory.CreateSystems(botConfig, nav);
|
||||
|
||||
// Apply a default profile with configured skills
|
||||
var profile = new CharacterProfile
|
||||
|
|
@ -61,15 +44,15 @@ var profile = new CharacterProfile
|
|||
Name = "SimPlayer",
|
||||
Skills =
|
||||
[
|
||||
new() { SlotIndex = 0, Label = "LMB", InputType = SkillInputType.LeftClick, Priority = 0, RangeMax = 150f, RequiresTarget = true },
|
||||
new() { SlotIndex = 1, Label = "RMB", InputType = SkillInputType.RightClick, Priority = 1, RangeMax = 150f, RequiresTarget = true },
|
||||
new() { SlotIndex = 3, Label = "Q", InputType = SkillInputType.KeyPress, ScanCode = 0x10, Priority = 3, RangeMax = 600f, CooldownMs = 500, MinMonstersInRange = 2 },
|
||||
new() { SlotIndex = 4, Label = "E", InputType = SkillInputType.KeyPress, ScanCode = 0x12, Priority = 4, RangeMax = 800f, CooldownMs = 300 },
|
||||
new() { SlotIndex = 0, Label = "LMB", InputType = SkillInputType.LeftClick, Priority = 0, RangeMax = 350f },
|
||||
new() { SlotIndex = 1, Label = "RMB", InputType = SkillInputType.RightClick, Priority = 1, RangeMax = 350f, CooldownMs = 800 },
|
||||
new() { SlotIndex = 3, Label = "Q", InputType = SkillInputType.KeyPress, ScanCode = 0x10, Priority = 3, RangeMax = 350f, CooldownMs = 2000, MinMonstersInRange = 3 },
|
||||
new() { SlotIndex = 4, Label = "E", InputType = SkillInputType.KeyPress, ScanCode = 0x12, Priority = 4, RangeMax = 800f, CooldownMs = 1500 },
|
||||
],
|
||||
Combat = new CombatSettings
|
||||
{
|
||||
GlobalCooldownMs = 400,
|
||||
AttackRange = 600f,
|
||||
AttackRange = 350f,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -88,7 +71,10 @@ nav.Explore();
|
|||
|
||||
// ── Bot logic thread ──
|
||||
var actionQueue = new ActionQueue();
|
||||
var movementBlender = new MovementBlender();
|
||||
var moveTracker = new MovementKeyTracker();
|
||||
var botRunning = true;
|
||||
var lastStatusLogMs = 0L;
|
||||
|
||||
var botThread = new Thread(() =>
|
||||
{
|
||||
|
|
@ -102,26 +88,36 @@ var botThread = new Thread(() =>
|
|||
var state = cache.LatestState;
|
||||
if (state is not null && !state.IsLoading && !state.IsEscapeOpen)
|
||||
{
|
||||
// Enrich
|
||||
GameStateEnricher.Enrich(state);
|
||||
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
||||
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
|
||||
|
||||
// Clear and run systems
|
||||
actionQueue.Clear();
|
||||
nav.Update(state);
|
||||
|
||||
foreach (var sys in systems)
|
||||
// Periodic status log (every 2 seconds)
|
||||
var nowMs = Environment.TickCount64;
|
||||
if (nowMs - lastStatusLogMs >= 2000)
|
||||
{
|
||||
if (sys.IsEnabled)
|
||||
sys.Update(state, actionQueue);
|
||||
lastStatusLogMs = nowMs;
|
||||
var p = state.Player;
|
||||
var enemySnapshot = world.Enemies.ToArray();
|
||||
var melee = enemySnapshot.Count(e => e.IsAlive && !e.IsRanged);
|
||||
var ranged = enemySnapshot.Count(e => e.IsAlive && e.IsRanged);
|
||||
var actions = string.Join(",", resolved.Select(a => a switch
|
||||
{
|
||||
CastAction c => $"Cast({c.SkillScanCode:X2})",
|
||||
FlaskAction => "Flask",
|
||||
ClickAction => "Click",
|
||||
KeyAction k => $"Key({k.ScanCode:X2})",
|
||||
_ => a.GetType().Name,
|
||||
}));
|
||||
if (actions.Length == 0) actions = "none";
|
||||
|
||||
Log.Information(
|
||||
"Status: HP={HP}/{MaxHP} ES={ES}/{MaxES} Mana={MP}/{MaxMP} Danger={Danger} " +
|
||||
"Enemies={Total}({Melee}m/{Ranged}r) Nav={NavMode} Actions=[{Actions}] " +
|
||||
"Move=[{Blender}]",
|
||||
p.LifeCurrent, p.LifeTotal, p.EsCurrent, p.EsTotal, p.ManaCurrent, p.ManaTotal,
|
||||
state.Danger, melee + ranged, melee, ranged,
|
||||
nav.Mode, actions, movementBlender.DiagnosticSummary());
|
||||
}
|
||||
|
||||
// Nav direction
|
||||
if (nav.DesiredDirection.HasValue)
|
||||
actionQueue.Submit(new MoveAction(SystemPriority.Navigation, nav.DesiredDirection.Value));
|
||||
|
||||
// Resolve and execute
|
||||
var resolved = actionQueue.Resolve();
|
||||
ExecuteActions(resolved, state, input);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
@ -163,7 +159,7 @@ var imguiRenderer = new VeldridImGuiRenderer(
|
|||
gd, gd.MainSwapchain.Framebuffer.OutputDescription,
|
||||
window.Width, window.Height);
|
||||
|
||||
var renderer = new SimRenderer(simConfig, world, nav, systems);
|
||||
var renderer = new SimRenderer(simConfig, world, nav, systems, input);
|
||||
var cl = gd.ResourceFactory.CreateCommandList();
|
||||
|
||||
window.Resized += () =>
|
||||
|
|
@ -187,7 +183,8 @@ while (window.Exists)
|
|||
|
||||
imguiRenderer.Update(deltaSeconds, snapshot);
|
||||
|
||||
// Render sim world
|
||||
// Update input flash timers & render sim world
|
||||
input.UpdateTimers(deltaSeconds);
|
||||
renderer.Render(cache.LatestState);
|
||||
|
||||
cl.Begin();
|
||||
|
|
@ -208,57 +205,3 @@ imguiRenderer.Dispose();
|
|||
gd.Dispose();
|
||||
Log.Information("Nexus Simulator stopped.");
|
||||
|
||||
// ── Helper: Execute bot actions via SimInputController ──
|
||||
static void ExecuteActions(List<BotAction> resolved, GameState state, SimInputController input)
|
||||
{
|
||||
foreach (var action in resolved)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case MoveAction move:
|
||||
// MovementKeyTracker equivalent — convert direction to WASD key presses
|
||||
// The SimInputController interprets these directly
|
||||
const float cos45 = 0.70710678f;
|
||||
const float sin45 = 0.70710678f;
|
||||
var sx = move.Direction.X * cos45 - move.Direction.Y * sin45;
|
||||
var sy = move.Direction.X * sin45 + move.Direction.Y * cos45;
|
||||
|
||||
const float threshold = 0.3f;
|
||||
if (sy > threshold) input.KeyDown(0x11); else input.KeyUp(0x11); // W
|
||||
if (sy < -threshold) input.KeyDown(0x1F); else input.KeyUp(0x1F); // S
|
||||
if (sx > threshold) input.KeyDown(0x20); else input.KeyUp(0x20); // D
|
||||
if (sx < -threshold) input.KeyDown(0x1E); else input.KeyUp(0x1E); // A
|
||||
break;
|
||||
|
||||
case CastAction cast:
|
||||
if (cast.TargetScreenPos.HasValue)
|
||||
input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y);
|
||||
input.KeyPress(cast.SkillScanCode);
|
||||
break;
|
||||
|
||||
case FlaskAction flask:
|
||||
input.KeyPress(flask.FlaskScanCode);
|
||||
break;
|
||||
|
||||
case ClickAction click:
|
||||
var cx = (int)click.ScreenPosition.X;
|
||||
var cy = (int)click.ScreenPosition.Y;
|
||||
switch (click.Type)
|
||||
{
|
||||
case ClickType.Left: input.LeftClick(cx, cy); break;
|
||||
case ClickType.Right: input.RightClick(cx, cy); break;
|
||||
case ClickType.Middle: input.MiddleClick(cx, cy); break;
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyAction key:
|
||||
switch (key.Type)
|
||||
{
|
||||
case KeyActionType.Press: input.KeyPress(key.ScanCode); break;
|
||||
case KeyActionType.Down: input.KeyDown(key.ScanCode); break;
|
||||
case KeyActionType.Up: input.KeyUp(key.ScanCode); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,12 +64,13 @@ public class DebugPanel
|
|||
// Enemy stats
|
||||
if (ImGui.CollapsingHeader("Enemies", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
var alive = _world.Enemies.Count(e => e.IsAlive);
|
||||
var dead = _world.Enemies.Count(e => !e.IsAlive);
|
||||
var chasing = _world.Enemies.Count(e => e.AiState == EnemyAiState.Chasing);
|
||||
var attacking = _world.Enemies.Count(e => e.AiState == EnemyAiState.Attacking);
|
||||
var enemies = _world.Enemies.ToArray(); // snapshot — list mutated by SimPoller thread
|
||||
var alive = enemies.Count(e => e.IsAlive);
|
||||
var dead = enemies.Count(e => !e.IsAlive);
|
||||
var chasing = enemies.Count(e => e.AiState == EnemyAiState.Chasing);
|
||||
var attacking = enemies.Count(e => e.AiState == EnemyAiState.Attacking);
|
||||
|
||||
ImGui.Text($"Total: {_world.Enemies.Count} Alive: {alive} Dead: {dead}");
|
||||
ImGui.Text($"Total: {enemies.Length} Alive: {alive} Dead: {dead}");
|
||||
ImGui.Text($"Chasing: {chasing} Attacking: {attacking}");
|
||||
|
||||
ImGui.Separator();
|
||||
|
|
|
|||
|
|
@ -6,25 +6,24 @@ namespace Nexus.Simulator.Rendering;
|
|||
|
||||
public static class EffectRenderer
|
||||
{
|
||||
public static void DrawEffects(ImDrawListPtr drawList, List<SimSkillEffect> effects,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
|
||||
public static void DrawEffects(ImDrawListPtr drawList, IReadOnlyList<SimSkillEffect> effects, ViewTransform vt)
|
||||
{
|
||||
foreach (var effect in effects)
|
||||
{
|
||||
var alpha = (byte)(255 * (1f - effect.Progress));
|
||||
var originScreen = canvasOrigin + viewOffset + effect.Origin * worldToGrid * zoom;
|
||||
var targetScreen = canvasOrigin + viewOffset + effect.TargetPosition * worldToGrid * zoom;
|
||||
var originScreen = vt.WorldToScreen(effect.Origin);
|
||||
var targetScreen = vt.WorldToScreen(effect.TargetPosition);
|
||||
|
||||
switch (effect.Type)
|
||||
{
|
||||
case SkillEffectType.Melee:
|
||||
DrawMeleeCone(drawList, originScreen, targetScreen,
|
||||
effect.Radius * worldToGrid * zoom, effect.ConeAngle, alpha);
|
||||
effect.Radius * vt.WorldScale, effect.ConeAngle, alpha);
|
||||
break;
|
||||
|
||||
case SkillEffectType.Aoe:
|
||||
DrawAoeCircle(drawList, targetScreen,
|
||||
effect.Radius * worldToGrid * zoom, alpha);
|
||||
effect.Radius * vt.WorldScale, alpha);
|
||||
break;
|
||||
|
||||
case SkillEffectType.Projectile:
|
||||
|
|
@ -34,15 +33,25 @@ public static class EffectRenderer
|
|||
}
|
||||
}
|
||||
|
||||
public static void DrawProjectiles(ImDrawListPtr drawList, List<SimProjectile> projectiles,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
|
||||
public static void DrawProjectiles(ImDrawListPtr drawList, IReadOnlyList<SimProjectile> projectiles, ViewTransform vt)
|
||||
{
|
||||
foreach (var proj in projectiles)
|
||||
{
|
||||
var pos = canvasOrigin + viewOffset + proj.Position * worldToGrid * zoom;
|
||||
var radius = proj.HitRadius * worldToGrid * zoom * 0.3f;
|
||||
drawList.AddCircleFilled(pos, Math.Max(3f, radius), 0xFF00DDFF);
|
||||
drawList.AddCircle(pos, Math.Max(4f, radius + 1), 0xFF00AAFF);
|
||||
var pos = vt.WorldToScreen(proj.Position);
|
||||
var radius = proj.HitRadius * vt.WorldScale * 0.3f;
|
||||
|
||||
if (proj.IsEnemyProjectile)
|
||||
{
|
||||
// Red/orange for enemy projectiles
|
||||
drawList.AddCircleFilled(pos, Math.Max(3f, radius), 0xFF3344FF);
|
||||
drawList.AddCircle(pos, Math.Max(4f, radius + 1), 0xFF0000FF);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cyan for player projectiles
|
||||
drawList.AddCircleFilled(pos, Math.Max(3f, radius), 0xFF00DDFF);
|
||||
drawList.AddCircle(pos, Math.Max(4f, radius + 1), 0xFF00AAFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,33 +7,40 @@ namespace Nexus.Simulator.Rendering;
|
|||
|
||||
public static class EntityRenderer
|
||||
{
|
||||
public static void DrawPlayer(ImDrawListPtr drawList, SimPlayer player,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
|
||||
public static void DrawPlayer(ImDrawListPtr drawList, SimPlayer player, ViewTransform vt)
|
||||
{
|
||||
var gridPos = player.Position * worldToGrid;
|
||||
var screenPos = canvasOrigin + viewOffset + gridPos * zoom;
|
||||
var screenPos = vt.WorldToScreen(player.Position);
|
||||
|
||||
var radius = 8f;
|
||||
drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green
|
||||
drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00);
|
||||
|
||||
// Health bar above player
|
||||
DrawHealthBar(drawList, screenPos - new Vector2(15, radius + 8), 30, 4,
|
||||
var barY = radius + 8;
|
||||
|
||||
// ES bar (purple, above health)
|
||||
if (player.MaxEs > 0)
|
||||
{
|
||||
DrawHealthBar(drawList, screenPos - new Vector2(15, barY), 30, 3,
|
||||
player.Es, player.MaxEs, 0xFFFF8800); // Cyan/purple in ABGR
|
||||
barY += 5;
|
||||
}
|
||||
|
||||
// Health bar
|
||||
DrawHealthBar(drawList, screenPos - new Vector2(15, barY), 30, 4,
|
||||
player.Health, player.MaxHealth, 0xFF00DD00);
|
||||
barY += 6;
|
||||
|
||||
// Mana bar
|
||||
DrawHealthBar(drawList, screenPos - new Vector2(15, radius + 14), 30, 3,
|
||||
DrawHealthBar(drawList, screenPos - new Vector2(15, barY), 30, 3,
|
||||
player.Mana, player.MaxMana, 0xFFDD6600);
|
||||
}
|
||||
|
||||
public static void DrawEnemies(ImDrawListPtr drawList, List<SimEnemy> enemies,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid,
|
||||
Vector2 canvasMin, Vector2 canvasMax)
|
||||
public static void DrawEnemies(ImDrawListPtr drawList, IReadOnlyList<SimEnemy> enemies,
|
||||
ViewTransform vt, Vector2 canvasMin, Vector2 canvasMax)
|
||||
{
|
||||
foreach (var enemy in enemies)
|
||||
{
|
||||
var gridPos = enemy.Position * worldToGrid;
|
||||
var screenPos = canvasOrigin + viewOffset + gridPos * zoom;
|
||||
var screenPos = vt.WorldToScreen(enemy.Position);
|
||||
|
||||
// Cull off-screen
|
||||
if (screenPos.X < canvasMin.X - 20 || screenPos.X > canvasMax.X + 20 ||
|
||||
|
|
@ -54,12 +61,30 @@ public static class EntityRenderer
|
|||
radius *= 0.7f;
|
||||
}
|
||||
|
||||
drawList.AddCircleFilled(screenPos, radius, color);
|
||||
if (enemy.IsRanged)
|
||||
{
|
||||
// Diamond shape for ranged enemies
|
||||
var s = radius * 1.2f;
|
||||
var pts = new[]
|
||||
{
|
||||
screenPos + new Vector2(0, -s),
|
||||
screenPos + new Vector2(s, 0),
|
||||
screenPos + new Vector2(0, s),
|
||||
screenPos + new Vector2(-s, 0),
|
||||
};
|
||||
drawList.AddQuadFilled(pts[0], pts[1], pts[2], pts[3], color);
|
||||
}
|
||||
else
|
||||
{
|
||||
drawList.AddCircleFilled(screenPos, radius, color);
|
||||
}
|
||||
|
||||
if (enemy.AiState == EnemyAiState.Chasing)
|
||||
drawList.AddCircle(screenPos, radius + 2, 0xFF0000FF); // Red ring when chasing
|
||||
else if (enemy.AiState == EnemyAiState.Attacking)
|
||||
drawList.AddCircle(screenPos, radius + 3, 0xFF0000FF, 0, 3f);
|
||||
else if (enemy.AiState == EnemyAiState.Retreating)
|
||||
drawList.AddCircle(screenPos, radius + 2, 0xFF00AAAA); // Teal ring when retreating
|
||||
|
||||
// Health bar
|
||||
if (enemy.IsAlive && enemy.Health < enemy.MaxHealth)
|
||||
|
|
|
|||
155
src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs
Normal file
155
src/Nexus.Simulator/Rendering/InputOverlayRenderer.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using System.Numerics;
|
||||
using ImGuiNET;
|
||||
using Nexus.Simulator.Bridge;
|
||||
|
||||
namespace Nexus.Simulator.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a keyboard + mouse + mousepad overlay showing which keys/buttons are currently pressed.
|
||||
/// Keys are white normally, yellow when active.
|
||||
/// </summary>
|
||||
public static class InputOverlayRenderer
|
||||
{
|
||||
private const float KeySize = 26f;
|
||||
private const float Gap = 2f;
|
||||
private const float Stride = KeySize + Gap;
|
||||
|
||||
// Colors (ABGR)
|
||||
private const uint White = 0xFFFFFFFF;
|
||||
private const uint Yellow = 0xFF00FFFF;
|
||||
private const uint DarkBg = 0xFF1E1E1E;
|
||||
private const uint ActiveBg = 0xFF1A5C8C;
|
||||
private const uint Outline = 0xFF555555;
|
||||
private const uint DimText = 0xFFAAAAAA;
|
||||
private const uint ScrollBg = 0xFF333333;
|
||||
private const uint CursorDot = 0xFF00DDFF; // Cyan dot for cursor position
|
||||
private const uint CrosshairColor = 0x44FFFFFF; // Dim crosshair
|
||||
|
||||
// Keyboard rows: (label, scanCode, column offset)
|
||||
private static readonly (string L, ushort S, float C)[] Row0 =
|
||||
[("1", 0x02, 0), ("2", 0x03, 1), ("3", 0x04, 2), ("4", 0x05, 3), ("5", 0x06, 4)];
|
||||
|
||||
private static readonly (string L, ushort S, float C)[] Row1 =
|
||||
[("Q", 0x10, 0.25f), ("W", 0x11, 1.25f), ("E", 0x12, 2.25f), ("R", 0x13, 3.25f), ("T", 0x14, 4.25f)];
|
||||
|
||||
private static readonly (string L, ushort S, float C)[] Row2 =
|
||||
[("A", 0x1E, 0.5f), ("S", 0x1F, 1.5f), ("D", 0x20, 2.5f), ("F", 0x21, 3.5f)];
|
||||
|
||||
// Screen dimensions the bot thinks it has
|
||||
private const float ScreenW = 2560f;
|
||||
private const float ScreenH = 1440f;
|
||||
|
||||
public static void Draw(ImDrawListPtr drawList, InputSnapshot input, Vector2 mouseScreenPos,
|
||||
Vector2 canvasOrigin, Vector2 canvasSize)
|
||||
{
|
||||
var padSize = 80f;
|
||||
var mouseH = 64f;
|
||||
var kbH = 3 * Stride;
|
||||
var totalH = kbH + 6 + mouseH + 6 + padSize;
|
||||
var origin = canvasOrigin + new Vector2(15, canvasSize.Y - totalH - 15);
|
||||
|
||||
// Keyboard
|
||||
DrawKeyRow(drawList, origin, Row0, 0, input);
|
||||
DrawKeyRow(drawList, origin, Row1, 1, input);
|
||||
DrawKeyRow(drawList, origin, Row2, 2, input);
|
||||
|
||||
// Mouse to the right of keyboard
|
||||
var kbW = 4.25f * Stride + KeySize;
|
||||
var mouseOrigin = origin + new Vector2(kbW + 12, (kbH - mouseH) / 2);
|
||||
DrawMouse(drawList, input, mouseOrigin);
|
||||
|
||||
// Mousepad below keyboard + mouse row
|
||||
var padOrigin = origin + new Vector2(0, kbH + 6);
|
||||
DrawMousepad(drawList, mouseScreenPos, input, padOrigin, kbW + 12 + 44);
|
||||
}
|
||||
|
||||
private static void DrawKeyRow(ImDrawListPtr drawList, Vector2 origin,
|
||||
(string L, ushort S, float C)[] keys, int row, InputSnapshot input)
|
||||
{
|
||||
foreach (var (label, scan, col) in keys)
|
||||
{
|
||||
var pos = origin + new Vector2(col * Stride, row * Stride);
|
||||
var on = input.IsKeyActive(scan);
|
||||
|
||||
drawList.AddRectFilled(pos, pos + new Vector2(KeySize), on ? ActiveBg : DarkBg, 3f);
|
||||
drawList.AddRect(pos, pos + new Vector2(KeySize), on ? Yellow : Outline, 3f);
|
||||
|
||||
var ts = ImGui.CalcTextSize(label);
|
||||
drawList.AddText(pos + (new Vector2(KeySize) - ts) * 0.5f, on ? Yellow : White, label);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawMouse(ImDrawListPtr drawList, InputSnapshot input, Vector2 o)
|
||||
{
|
||||
const float w = 44, h = 64, hw = w / 2, bh = 26;
|
||||
|
||||
// Body
|
||||
drawList.AddRectFilled(o, o + new Vector2(w, h), DarkBg, 10f);
|
||||
drawList.AddRect(o, o + new Vector2(w, h), Outline, 10f);
|
||||
|
||||
// Left button highlight
|
||||
var lOn = input.IsMouseActive(0);
|
||||
if (lOn)
|
||||
drawList.AddRectFilled(o + new Vector2(2, 2), o + new Vector2(hw - 1, bh), ActiveBg, 4f);
|
||||
|
||||
// Right button highlight
|
||||
var rOn = input.IsMouseActive(1);
|
||||
if (rOn)
|
||||
drawList.AddRectFilled(o + new Vector2(hw + 1, 2), o + new Vector2(w - 2, bh), ActiveBg, 4f);
|
||||
|
||||
// Dividers
|
||||
drawList.AddLine(o + new Vector2(hw, 2), o + new Vector2(hw, bh), Outline);
|
||||
drawList.AddLine(o + new Vector2(4, bh), o + new Vector2(w - 4, bh), Outline);
|
||||
|
||||
// Scroll wheel
|
||||
var mOn = input.IsMouseActive(2);
|
||||
var sw = new Vector2(8, 14);
|
||||
var sp = o + new Vector2((w - sw.X) / 2, (bh - sw.Y) / 2);
|
||||
drawList.AddRectFilled(sp, sp + sw, mOn ? ActiveBg : ScrollBg, 3f);
|
||||
drawList.AddRect(sp, sp + sw, mOn ? Yellow : Outline, 3f);
|
||||
|
||||
// Labels
|
||||
DrawCentered(drawList, "L", o + new Vector2(hw / 2, bh / 2), lOn ? Yellow : DimText);
|
||||
DrawCentered(drawList, "R", o + new Vector2(hw + hw / 2, bh / 2), rOn ? Yellow : DimText);
|
||||
}
|
||||
|
||||
private static void DrawMousepad(ImDrawListPtr drawList, Vector2 mouseScreenPos,
|
||||
InputSnapshot input, Vector2 origin, float width)
|
||||
{
|
||||
// Mousepad: maps the bot's 2560x1440 screen space to a small rectangle
|
||||
var aspect = ScreenH / ScreenW;
|
||||
var padW = width;
|
||||
var padH = padW * aspect;
|
||||
|
||||
// Background
|
||||
drawList.AddRectFilled(origin, origin + new Vector2(padW, padH), DarkBg, 4f);
|
||||
drawList.AddRect(origin, origin + new Vector2(padW, padH), Outline, 4f);
|
||||
|
||||
// Crosshair at center
|
||||
var center = origin + new Vector2(padW, padH) * 0.5f;
|
||||
drawList.AddLine(center - new Vector2(8, 0), center + new Vector2(8, 0), CrosshairColor);
|
||||
drawList.AddLine(center - new Vector2(0, 8), center + new Vector2(0, 8), CrosshairColor);
|
||||
|
||||
// Map mouse screen position to pad coordinates
|
||||
var nx = Math.Clamp(mouseScreenPos.X / ScreenW, 0f, 1f);
|
||||
var ny = Math.Clamp(mouseScreenPos.Y / ScreenH, 0f, 1f);
|
||||
var dotPos = origin + new Vector2(nx * padW, ny * padH);
|
||||
|
||||
// Cursor dot — changes color on click
|
||||
var anyClick = input.IsMouseActive(0) || input.IsMouseActive(1) || input.IsMouseActive(2);
|
||||
var dotColor = anyClick ? Yellow : CursorDot;
|
||||
drawList.AddCircleFilled(dotPos, 4f, dotColor);
|
||||
drawList.AddCircle(dotPos, 5f, anyClick ? Yellow : Outline);
|
||||
|
||||
// Coordinates text
|
||||
var coordText = $"{mouseScreenPos.X:F0},{mouseScreenPos.Y:F0}";
|
||||
var ts = ImGui.CalcTextSize(coordText);
|
||||
drawList.AddText(origin + new Vector2(padW - ts.X - 4, padH - ts.Y - 2), DimText, coordText);
|
||||
}
|
||||
|
||||
private static void DrawCentered(ImDrawListPtr drawList, string text, Vector2 center, uint color)
|
||||
{
|
||||
var ts = ImGui.CalcTextSize(text);
|
||||
drawList.AddText(center - ts * 0.5f, color, text);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,32 +6,23 @@ namespace Nexus.Simulator.Rendering;
|
|||
|
||||
public static class PathRenderer
|
||||
{
|
||||
public static void Draw(ImDrawListPtr drawList, NavigationController nav,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
|
||||
public static void Draw(ImDrawListPtr drawList, NavigationController nav, ViewTransform vt)
|
||||
{
|
||||
var path = nav.CurrentPath;
|
||||
if (path is null || path.Count < 2) return;
|
||||
|
||||
for (var i = 0; i < path.Count - 1; i++)
|
||||
{
|
||||
var a = canvasOrigin + viewOffset + path[i] * worldToGrid * zoom;
|
||||
var b = canvasOrigin + viewOffset + path[i + 1] * worldToGrid * zoom;
|
||||
var a = vt.WorldToScreen(path[i]);
|
||||
var b = vt.WorldToScreen(path[i + 1]);
|
||||
drawList.AddLine(a, b, 0xFFFFFF00, 2f); // Cyan
|
||||
}
|
||||
|
||||
// Draw waypoint dots
|
||||
foreach (var wp in path)
|
||||
{
|
||||
var pos = canvasOrigin + viewOffset + wp * worldToGrid * zoom;
|
||||
var pos = vt.WorldToScreen(wp);
|
||||
drawList.AddCircleFilled(pos, 3f, 0xFFFFFF00);
|
||||
}
|
||||
}
|
||||
|
||||
public static void DrawExploredOverlay(ImDrawListPtr drawList,
|
||||
bool[]? exploredGrid, int exploredWidth, int exploredHeight,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, Vector2 canvasSize)
|
||||
{
|
||||
// Already handled in TerrainRenderer via brightness difference
|
||||
// This method is a placeholder for additional explore visualization
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,35 @@ using System.Numerics;
|
|||
using ImGuiNET;
|
||||
using Nexus.Core;
|
||||
using Nexus.Pathfinding;
|
||||
using Nexus.Simulator.Bridge;
|
||||
using Nexus.Simulator.Config;
|
||||
using Nexus.Simulator.World;
|
||||
|
||||
namespace Nexus.Simulator.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Main renderer: draws the top-down game world viewport using ImGui draw lists.
|
||||
/// Main renderer: draws the 45°-rotated isometric game world viewport using ImGui draw lists.
|
||||
/// </summary>
|
||||
public class SimRenderer
|
||||
{
|
||||
private readonly SimConfig _config;
|
||||
private readonly SimWorld _world;
|
||||
private readonly NavigationController _nav;
|
||||
private readonly SimInputController _input;
|
||||
private readonly DebugPanel _debugPanel;
|
||||
|
||||
// Camera
|
||||
private Vector2 _viewOffset;
|
||||
private float _zoom = 2f; // pixels per grid cell
|
||||
private const float C = 0.70710678f;
|
||||
|
||||
public SimRenderer(SimConfig config, SimWorld world, NavigationController nav,
|
||||
IReadOnlyList<ISystem> systems)
|
||||
IReadOnlyList<ISystem> systems, SimInputController input)
|
||||
{
|
||||
_config = config;
|
||||
_world = world;
|
||||
_nav = nav;
|
||||
_input = input;
|
||||
_debugPanel = new DebugPanel(config, world, nav, systems);
|
||||
|
||||
// Center view on player
|
||||
|
|
@ -53,31 +57,39 @@ public class SimRenderer
|
|||
CenterOnPlayer(canvasSize);
|
||||
|
||||
var drawList = ImGui.GetWindowDrawList();
|
||||
var vt = new ViewTransform(canvasOrigin, _viewOffset, _zoom, _config.WorldToGrid);
|
||||
|
||||
// Clip to canvas
|
||||
drawList.PushClipRect(canvasOrigin, canvasOrigin + canvasSize);
|
||||
|
||||
// 1. Terrain
|
||||
TerrainRenderer.Draw(drawList, _world.Terrain, _viewOffset, _zoom, canvasOrigin, canvasSize,
|
||||
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight);
|
||||
TerrainRenderer.Draw(drawList, _world.Terrain, vt, canvasSize,
|
||||
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight,
|
||||
_nav.ExploredOffsetX, _nav.ExploredOffsetY);
|
||||
|
||||
// 2. Path
|
||||
PathRenderer.Draw(drawList, _nav, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid);
|
||||
PathRenderer.Draw(drawList, _nav, vt);
|
||||
|
||||
// 3. Effects
|
||||
EffectRenderer.DrawEffects(drawList, _world.ActiveEffects, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid);
|
||||
EffectRenderer.DrawProjectiles(drawList, _world.Projectiles, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid);
|
||||
// 3. Effects — snapshot shared lists to avoid concurrent modification from SimPoller thread
|
||||
var effects = _world.ActiveEffects.ToArray();
|
||||
var projectiles = _world.Projectiles.ToArray();
|
||||
var enemies = _world.Enemies.ToArray();
|
||||
|
||||
EffectRenderer.DrawEffects(drawList, effects, vt);
|
||||
EffectRenderer.DrawProjectiles(drawList, projectiles, vt);
|
||||
|
||||
// 4. Enemies
|
||||
EntityRenderer.DrawEnemies(drawList, _world.Enemies, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid,
|
||||
canvasOrigin, canvasOrigin + canvasSize);
|
||||
EntityRenderer.DrawEnemies(drawList, enemies, vt, canvasOrigin, canvasOrigin + canvasSize);
|
||||
|
||||
// 5. Player
|
||||
EntityRenderer.DrawPlayer(drawList, _world.Player, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid);
|
||||
EntityRenderer.DrawPlayer(drawList, _world.Player, vt);
|
||||
|
||||
// 6. Mock cursor — shows where the bot's mouse is pointing in the world
|
||||
DrawMockCursor(drawList, vt);
|
||||
|
||||
drawList.PopClipRect();
|
||||
|
||||
// Minimap (bottom-right corner)
|
||||
// Minimap (bottom-right corner, top-down view)
|
||||
var minimapSize = 150f;
|
||||
var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10);
|
||||
var playerGridPos = _world.Player.Position * _config.WorldToGrid;
|
||||
|
|
@ -86,14 +98,22 @@ public class SimRenderer
|
|||
// HUD text
|
||||
DrawHud(drawList, canvasOrigin, state);
|
||||
|
||||
// Input overlay (keyboard + mouse + mousepad)
|
||||
InputOverlayRenderer.Draw(drawList, _input.GetInputSnapshot(), _input.MouseScreenPos,
|
||||
canvasOrigin, canvasSize);
|
||||
|
||||
ImGui.End();
|
||||
}
|
||||
|
||||
private void CenterOnPlayer(Vector2? canvasSize = null)
|
||||
{
|
||||
var cs = canvasSize ?? new Vector2(1200, 900);
|
||||
var playerGrid = _world.Player.Position * _config.WorldToGrid;
|
||||
_viewOffset = cs * 0.5f - playerGrid * _zoom;
|
||||
var gx = _world.Player.Position.X * _config.WorldToGrid;
|
||||
var gy = _world.Player.Position.Y * _config.WorldToGrid;
|
||||
// Rotated grid position
|
||||
var rx = (gx - gy) * C;
|
||||
var ry = -(gx + gy) * C;
|
||||
_viewOffset = cs * 0.5f - new Vector2(rx, ry) * _zoom;
|
||||
}
|
||||
|
||||
private void HandleInput(Vector2 canvasOrigin, Vector2 canvasSize)
|
||||
|
|
@ -102,7 +122,7 @@ public class SimRenderer
|
|||
|
||||
var io = ImGui.GetIO();
|
||||
|
||||
// Scroll to zoom
|
||||
// Scroll to zoom (works in rotated space — no change needed)
|
||||
if (io.MouseWheel != 0)
|
||||
{
|
||||
var mousePos = io.MousePos - canvasOrigin;
|
||||
|
|
@ -115,6 +135,37 @@ public class SimRenderer
|
|||
}
|
||||
}
|
||||
|
||||
private void DrawMockCursor(ImDrawListPtr drawList, ViewTransform vt)
|
||||
{
|
||||
// Convert the bot's mouse screen position to world, then to our viewport
|
||||
var mouseWorld = _world.MouseWorldPos;
|
||||
var screenPos = vt.WorldToScreen(mouseWorld);
|
||||
|
||||
const float s = 12f; // cursor size
|
||||
var col = 0xFFFFFFFF; // white
|
||||
var shadow = 0xAA000000; // black shadow
|
||||
|
||||
// Arrow cursor shape (triangle + tail)
|
||||
var tip = screenPos;
|
||||
var left = tip + new Vector2(0, s);
|
||||
var right = tip + new Vector2(s * 0.55f, s * 0.75f);
|
||||
var mid = tip + new Vector2(s * 0.2f, s * 0.65f);
|
||||
var tailEnd = tip + new Vector2(s * 0.55f, s * 1.1f);
|
||||
var tailRight = tip + new Vector2(s * 0.4f, s * 0.95f);
|
||||
|
||||
// Shadow (offset by 1px)
|
||||
var off = new Vector2(1, 1);
|
||||
drawList.AddTriangleFilled(tip + off, left + off, right + off, shadow);
|
||||
drawList.AddTriangleFilled(mid + off, tailEnd + off, tailRight + off, shadow);
|
||||
|
||||
// Cursor body
|
||||
drawList.AddTriangleFilled(tip, left, right, col);
|
||||
drawList.AddTriangleFilled(mid, tailEnd, tailRight, col);
|
||||
|
||||
// Outline
|
||||
drawList.AddTriangle(tip, left, right, 0xFF000000);
|
||||
}
|
||||
|
||||
private void DrawHud(ImDrawListPtr drawList, Vector2 canvasOrigin, GameState? state)
|
||||
{
|
||||
var textPos = canvasOrigin + new Vector2(10, 10);
|
||||
|
|
|
|||
|
|
@ -7,23 +7,38 @@ namespace Nexus.Simulator.Rendering;
|
|||
public static class TerrainRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws the walkability grid as colored rectangles on the ImGui draw list.
|
||||
/// Draws the walkability grid as rotated diamond cells on the ImGui draw list.
|
||||
/// Only draws cells visible in the current viewport for performance.
|
||||
/// </summary>
|
||||
public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
|
||||
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, Vector2 canvasSize,
|
||||
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0)
|
||||
ViewTransform vt, Vector2 canvasSize,
|
||||
bool[]? exploredGrid = null, int exploredWidth = 0, int exploredHeight = 0,
|
||||
int exploredOffsetX = 0, int exploredOffsetY = 0)
|
||||
{
|
||||
var cellSize = zoom;
|
||||
if (cellSize < 0.5f) return; // Too zoomed out to draw individual cells
|
||||
var cellSize = vt.Zoom;
|
||||
if (cellSize < 0.5f) return;
|
||||
|
||||
// Visible range in grid coords
|
||||
var minGx = Math.Max(0, (int)(-viewOffset.X / cellSize));
|
||||
var minGy = Math.Max(0, (int)(-viewOffset.Y / cellSize));
|
||||
var maxGx = Math.Min(terrain.Width - 1, (int)((-viewOffset.X + canvasSize.X) / cellSize));
|
||||
var maxGy = Math.Min(terrain.Height - 1, (int)((-viewOffset.Y + canvasSize.Y) / cellSize));
|
||||
var ox = terrain.OffsetX;
|
||||
var oy = terrain.OffsetY;
|
||||
|
||||
// Skip pixels if too many cells
|
||||
// Compute visible grid bounds from screen corners (inverse transform)
|
||||
var c0 = vt.ScreenToGrid(0, 0);
|
||||
var c1 = vt.ScreenToGrid(canvasSize.X, 0);
|
||||
var c2 = vt.ScreenToGrid(canvasSize.X, canvasSize.Y);
|
||||
var c3 = vt.ScreenToGrid(0, canvasSize.Y);
|
||||
|
||||
var minGx = (int)MathF.Floor(Min4(c0.X, c1.X, c2.X, c3.X));
|
||||
var maxGx = (int)MathF.Ceiling(Max4(c0.X, c1.X, c2.X, c3.X));
|
||||
var minGy = (int)MathF.Floor(Min4(c0.Y, c1.Y, c2.Y, c3.Y));
|
||||
var maxGy = (int)MathF.Ceiling(Max4(c0.Y, c1.Y, c2.Y, c3.Y));
|
||||
|
||||
// Clamp to terrain bounds
|
||||
minGx = Math.Max(ox, minGx);
|
||||
maxGx = Math.Min(ox + terrain.Width - 1, maxGx);
|
||||
minGy = Math.Max(oy, minGy);
|
||||
maxGy = Math.Min(oy + terrain.Height - 1, maxGy);
|
||||
|
||||
// Skip cells if too many
|
||||
var step = 1;
|
||||
if (cellSize < 2f) step = 4;
|
||||
else if (cellSize < 4f) step = 2;
|
||||
|
|
@ -31,9 +46,6 @@ public static class TerrainRenderer
|
|||
for (var gy = minGy; gy <= maxGy; gy += step)
|
||||
for (var gx = minGx; gx <= maxGx; gx += step)
|
||||
{
|
||||
var screenX = canvasOrigin.X + viewOffset.X + gx * cellSize;
|
||||
var screenY = canvasOrigin.Y + viewOffset.Y + gy * cellSize;
|
||||
|
||||
var w = terrain.IsWalkable(gx, gy);
|
||||
uint color;
|
||||
|
||||
|
|
@ -43,23 +55,26 @@ public static class TerrainRenderer
|
|||
}
|
||||
else
|
||||
{
|
||||
var elx = gx - exploredOffsetX;
|
||||
var ely = gy - exploredOffsetY;
|
||||
var explored = exploredGrid is not null
|
||||
&& gx < exploredWidth && gy < exploredHeight
|
||||
&& exploredGrid[gy * exploredWidth + gx];
|
||||
&& elx >= 0 && elx < exploredWidth && ely >= 0 && ely < exploredHeight
|
||||
&& exploredGrid[ely * exploredWidth + elx];
|
||||
|
||||
color = explored ? 0xFF3D3D5C : 0xFF2A2A3F; // Brighter if explored
|
||||
color = explored ? 0xFF3D3D5C : 0xFF2A2A3F;
|
||||
}
|
||||
|
||||
var size = cellSize * step;
|
||||
drawList.AddRectFilled(
|
||||
new Vector2(screenX, screenY),
|
||||
new Vector2(screenX + size, screenY + size),
|
||||
color);
|
||||
// Draw diamond (rotated grid cell)
|
||||
var p0 = vt.GridToScreen(gx, gy); // top
|
||||
var p1 = vt.GridToScreen(gx + step, gy); // right
|
||||
var p2 = vt.GridToScreen(gx + step, gy + step); // bottom
|
||||
var p3 = vt.GridToScreen(gx, gy + step); // left
|
||||
drawList.AddQuadFilled(p0, p1, p2, p3, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws a minimap in the corner.
|
||||
/// Draws a minimap in the corner (top-down, no rotation).
|
||||
/// </summary>
|
||||
public static void DrawMinimap(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
|
||||
Vector2 playerGridPos, Vector2 minimapOrigin, float minimapSize)
|
||||
|
|
@ -73,22 +88,23 @@ public static class TerrainRenderer
|
|||
minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale),
|
||||
0xFF0A0A15);
|
||||
|
||||
// Draw walkable cells (sampled)
|
||||
// Draw walkable cells (sampled) — local coords
|
||||
var step = Math.Max(1, terrain.Width / 200);
|
||||
for (var gy = 0; gy < terrain.Height; gy += step)
|
||||
for (var gx = 0; gx < terrain.Width; gx += step)
|
||||
for (var ly = 0; ly < terrain.Height; ly += step)
|
||||
for (var lx = 0; lx < terrain.Width; lx += step)
|
||||
{
|
||||
if (!terrain.IsWalkable(gx, gy)) continue;
|
||||
var px = minimapOrigin.X + gx * scale;
|
||||
var py = minimapOrigin.Y + gy * scale;
|
||||
if (!terrain.IsWalkable(lx + terrain.OffsetX, ly + terrain.OffsetY)) continue;
|
||||
var px = minimapOrigin.X + lx * scale;
|
||||
var py = minimapOrigin.Y + ly * scale;
|
||||
drawList.AddRectFilled(
|
||||
new Vector2(px, py),
|
||||
new Vector2(px + scale * step, py + scale * step),
|
||||
0xFF2A2A3F);
|
||||
}
|
||||
|
||||
// Player dot
|
||||
var playerPx = minimapOrigin + playerGridPos * scale;
|
||||
// Player dot — convert absolute grid pos to local
|
||||
var playerLocalPos = playerGridPos - new Vector2(terrain.OffsetX, terrain.OffsetY);
|
||||
var playerPx = minimapOrigin + playerLocalPos * scale;
|
||||
drawList.AddCircleFilled(playerPx, 3f, 0xFF00FF00);
|
||||
|
||||
// Border
|
||||
|
|
@ -96,4 +112,10 @@ public static class TerrainRenderer
|
|||
minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale),
|
||||
0xFF666666);
|
||||
}
|
||||
|
||||
private static float Min4(float a, float b, float c, float d)
|
||||
=> MathF.Min(MathF.Min(a, b), MathF.Min(c, d));
|
||||
|
||||
private static float Max4(float a, float b, float c, float d)
|
||||
=> MathF.Max(MathF.Max(a, b), MathF.Max(c, d));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,11 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
private Texture _fontTexture = null!;
|
||||
private TextureView _fontTextureView = null!;
|
||||
private ResourceSet _fontResourceSet = null!;
|
||||
private ResourceLayout _layout = null!;
|
||||
private ResourceLayout _projSamplerLayout = null!;
|
||||
private ResourceLayout _textureLayout = null!;
|
||||
private Pipeline _pipeline = null!;
|
||||
private Shader[] _shaders = null!;
|
||||
private ResourceSet _projSamplerSet = null!;
|
||||
|
||||
private int _windowWidth;
|
||||
private int _windowHeight;
|
||||
|
|
@ -49,7 +50,6 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
io.DisplayFramebufferScale = Vector2.One;
|
||||
|
||||
CreateDeviceResources(gd, outputDescription);
|
||||
SetupKeyMappings();
|
||||
}
|
||||
|
||||
public void WindowResized(int width, int height)
|
||||
|
|
@ -84,14 +84,11 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
_indexBuffer = factory.CreateBuffer(new BufferDescription(2000, BufferUsage.IndexBuffer | BufferUsage.Dynamic));
|
||||
_projMatrixBuffer = factory.CreateBuffer(new BufferDescription(64, BufferUsage.UniformBuffer | BufferUsage.Dynamic));
|
||||
|
||||
// Create shaders using HLSL
|
||||
// Create shaders
|
||||
_shaders = CreateShaders(gd);
|
||||
|
||||
// Font texture
|
||||
RecreateFontDeviceTexture(gd);
|
||||
|
||||
// Resource layouts
|
||||
_layout = factory.CreateResourceLayout(new ResourceLayoutDescription(
|
||||
_projSamplerLayout = factory.CreateResourceLayout(new ResourceLayoutDescription(
|
||||
new ResourceLayoutElementDescription("ProjectionMatrixBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex),
|
||||
new ResourceLayoutElementDescription("MainSampler", ResourceKind.Sampler, ShaderStages.Fragment)));
|
||||
|
||||
|
|
@ -126,12 +123,19 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise,
|
||||
true, true),
|
||||
PrimitiveTopology = PrimitiveTopology.TriangleList,
|
||||
ResourceLayouts = [_layout, _textureLayout],
|
||||
ResourceLayouts = [_projSamplerLayout, _textureLayout],
|
||||
ShaderSet = new ShaderSetDescription([vertexLayout], _shaders),
|
||||
Outputs = outputDescription,
|
||||
};
|
||||
|
||||
_pipeline = factory.CreateGraphicsPipeline(ref pipelineDesc);
|
||||
|
||||
// Cached resource set for projection + sampler
|
||||
_projSamplerSet = factory.CreateResourceSet(new ResourceSetDescription(
|
||||
_projSamplerLayout, _projMatrixBuffer, gd.PointSampler));
|
||||
|
||||
// Font texture (MUST be after _textureLayout is created)
|
||||
RecreateFontDeviceTexture(gd);
|
||||
}
|
||||
|
||||
private void RecreateFontDeviceTexture(GraphicsDevice gd)
|
||||
|
|
@ -141,6 +145,7 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
|
||||
_fontTexture?.Dispose();
|
||||
_fontTextureView?.Dispose();
|
||||
_fontResourceSet?.Dispose();
|
||||
|
||||
_fontTexture = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D(
|
||||
(uint)width, (uint)height, 1, 1,
|
||||
|
|
@ -155,34 +160,18 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
io.Fonts.SetTexID(_fontAtlasId);
|
||||
io.Fonts.ClearTexData();
|
||||
|
||||
// Create resource set for font
|
||||
if (_textureLayout is not null)
|
||||
{
|
||||
_fontResourceSet?.Dispose();
|
||||
_fontResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
|
||||
_textureLayout, _fontTextureView));
|
||||
_resourceSets[_fontAtlasId] = _fontResourceSet;
|
||||
}
|
||||
_fontResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
|
||||
_textureLayout, _fontTextureView));
|
||||
_resourceSets[_fontAtlasId] = _fontResourceSet;
|
||||
}
|
||||
|
||||
private Shader[] CreateShaders(GraphicsDevice gd)
|
||||
{
|
||||
// For D3D11, use HLSL compiled at runtime
|
||||
// For other backends, we'd need SPIRV
|
||||
var backend = gd.BackendType;
|
||||
if (gd.BackendType != GraphicsBackend.Direct3D11)
|
||||
throw new NotSupportedException($"Backend {gd.BackendType} not supported. Use Direct3D11.");
|
||||
|
||||
byte[] vertexShaderBytes;
|
||||
byte[] fragmentShaderBytes;
|
||||
|
||||
if (backend == GraphicsBackend.Direct3D11)
|
||||
{
|
||||
vertexShaderBytes = CompileHlsl(VertexShaderHlsl, "main", "vs_5_0");
|
||||
fragmentShaderBytes = CompileHlsl(FragmentShaderHlsl, "main", "ps_5_0");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Backend {backend} not supported. Use Direct3D11.");
|
||||
}
|
||||
var vertexShaderBytes = CompileHlsl(VertexShaderHlsl, "main", "vs_5_0");
|
||||
var fragmentShaderBytes = CompileHlsl(FragmentShaderHlsl, "main", "ps_5_0");
|
||||
|
||||
var vertexShader = gd.ResourceFactory.CreateShader(new ShaderDescription(
|
||||
ShaderStages.Vertex, vertexShaderBytes, "main"));
|
||||
|
|
@ -200,6 +189,8 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
var totalVtxSize = (uint)(drawData.TotalVtxCount * Unsafe.SizeOf<ImDrawVert>());
|
||||
var totalIdxSize = (uint)(drawData.TotalIdxCount * sizeof(ushort));
|
||||
|
||||
if (totalVtxSize == 0 || totalIdxSize == 0) return;
|
||||
|
||||
if (totalVtxSize > _vertexBuffer.SizeInBytes)
|
||||
{
|
||||
_vertexBuffer.Dispose();
|
||||
|
|
@ -219,16 +210,15 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
for (var i = 0; i < drawData.CmdListsCount; i++)
|
||||
{
|
||||
var cmdList = drawData.CmdLists[i];
|
||||
cl.UpdateBuffer(_vertexBuffer, vtxOffset,
|
||||
cmdList.VtxBuffer.Data, (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>()));
|
||||
cl.UpdateBuffer(_indexBuffer, idxOffset,
|
||||
cmdList.IdxBuffer.Data, (uint)(cmdList.IdxBuffer.Size * sizeof(ushort)));
|
||||
vtxOffset += (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>());
|
||||
idxOffset += (uint)(cmdList.IdxBuffer.Size * sizeof(ushort));
|
||||
var vtxSize = (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>());
|
||||
var idxSize = (uint)(cmdList.IdxBuffer.Size * sizeof(ushort));
|
||||
cl.UpdateBuffer(_vertexBuffer, vtxOffset, cmdList.VtxBuffer.Data, vtxSize);
|
||||
cl.UpdateBuffer(_indexBuffer, idxOffset, cmdList.IdxBuffer.Data, idxSize);
|
||||
vtxOffset += vtxSize;
|
||||
idxOffset += idxSize;
|
||||
}
|
||||
|
||||
// Update projection matrix
|
||||
var io = ImGui.GetIO();
|
||||
var mvp = Matrix4x4.CreateOrthographicOffCenter(
|
||||
drawData.DisplayPos.X,
|
||||
drawData.DisplayPos.X + drawData.DisplaySize.X,
|
||||
|
|
@ -240,14 +230,11 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
cl.SetVertexBuffer(0, _vertexBuffer);
|
||||
cl.SetIndexBuffer(_indexBuffer, IndexFormat.UInt16);
|
||||
cl.SetPipeline(_pipeline);
|
||||
|
||||
// Create main resource set
|
||||
var mainResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
|
||||
_layout, _projMatrixBuffer, gd.PointSampler));
|
||||
cl.SetGraphicsResourceSet(0, mainResourceSet);
|
||||
cl.SetGraphicsResourceSet(0, _projSamplerSet);
|
||||
|
||||
// Draw
|
||||
var clipOff = drawData.DisplayPos;
|
||||
var clipScale = drawData.FramebufferScale;
|
||||
vtxOffset = 0;
|
||||
idxOffset = 0;
|
||||
for (var n = 0; n < drawData.CmdListsCount; n++)
|
||||
|
|
@ -263,23 +250,23 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
cl.SetGraphicsResourceSet(1, rs);
|
||||
}
|
||||
|
||||
var clipRect = pcmd.ClipRect;
|
||||
cl.SetScissorRect(0,
|
||||
(uint)(clipRect.X - clipOff.X),
|
||||
(uint)(clipRect.Y - clipOff.Y),
|
||||
(uint)(clipRect.Z - clipRect.X),
|
||||
(uint)(clipRect.W - clipRect.Y));
|
||||
var clipX = (uint)Math.Max(0, (pcmd.ClipRect.X - clipOff.X) * clipScale.X);
|
||||
var clipY = (uint)Math.Max(0, (pcmd.ClipRect.Y - clipOff.Y) * clipScale.Y);
|
||||
var clipW = (uint)Math.Max(0, (pcmd.ClipRect.Z - pcmd.ClipRect.X) * clipScale.X);
|
||||
var clipH = (uint)Math.Max(0, (pcmd.ClipRect.W - pcmd.ClipRect.Y) * clipScale.Y);
|
||||
|
||||
if (clipW == 0 || clipH == 0) continue;
|
||||
|
||||
cl.SetScissorRect(0, clipX, clipY, clipW, clipH);
|
||||
|
||||
cl.DrawIndexed(pcmd.ElemCount, 1,
|
||||
pcmd.IdxOffset + idxOffset,
|
||||
(int)(pcmd.VtxOffset + vtxOffset),
|
||||
(int)(pcmd.VtxOffset + vtxOffset / (uint)Unsafe.SizeOf<ImDrawVert>()),
|
||||
0);
|
||||
}
|
||||
vtxOffset += (uint)cmdList.VtxBuffer.Size;
|
||||
vtxOffset += (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>());
|
||||
idxOffset += (uint)cmdList.IdxBuffer.Size;
|
||||
}
|
||||
|
||||
mainResourceSet.Dispose();
|
||||
}
|
||||
|
||||
private void UpdateInput(InputSnapshot snapshot)
|
||||
|
|
@ -327,11 +314,6 @@ public sealed class VeldridImGuiRenderer : IDisposable
|
|||
_ => ImGuiKey.None,
|
||||
};
|
||||
|
||||
private void SetupKeyMappings()
|
||||
{
|
||||
// ImGui.NET 1.91+ uses the key event API directly, no need for explicit mappings
|
||||
}
|
||||
|
||||
// ── HLSL Shader Sources ──
|
||||
|
||||
private const string VertexShaderHlsl = @"
|
||||
|
|
@ -387,7 +369,7 @@ float4 main(PS_INPUT input) : SV_Target
|
|||
[DllImport("d3dcompiler_47.dll", CallingConvention = CallingConvention.StdCall)]
|
||||
private static extern int D3DCompile(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string pSrcData,
|
||||
int srcDataSize,
|
||||
nint srcDataSize,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? pSourceName,
|
||||
IntPtr pDefines,
|
||||
IntPtr pInclude,
|
||||
|
|
@ -398,46 +380,40 @@ float4 main(PS_INPUT input) : SV_Target
|
|||
out IntPtr ppCode,
|
||||
out IntPtr ppErrorMsgs);
|
||||
|
||||
[DllImport("d3dcompiler_47.dll", CallingConvention = CallingConvention.StdCall)]
|
||||
private static extern IntPtr D3DGetBlobPart(IntPtr pSrcData, int srcDataSize, int part, uint flags, out IntPtr ppPart);
|
||||
|
||||
// ID3DBlob vtable offsets
|
||||
private static IntPtr BlobGetBufferPointer(IntPtr blob)
|
||||
{
|
||||
var vtable = Marshal.ReadIntPtr(blob);
|
||||
var getBufferPtr = Marshal.ReadIntPtr(vtable, 3 * IntPtr.Size); // IUnknown (3 methods) + GetBufferPointer
|
||||
var del = Marshal.GetDelegateForFunctionPointer<GetBufferPointerDelegate>(getBufferPtr);
|
||||
return del(blob);
|
||||
var fn = Marshal.ReadIntPtr(vtable, 3 * IntPtr.Size);
|
||||
return Marshal.GetDelegateForFunctionPointer<BlobBufferPointerFn>(fn)(blob);
|
||||
}
|
||||
|
||||
private static int BlobGetBufferSize(IntPtr blob)
|
||||
private static nint BlobGetBufferSize(IntPtr blob)
|
||||
{
|
||||
var vtable = Marshal.ReadIntPtr(blob);
|
||||
var getBufferSize = Marshal.ReadIntPtr(vtable, 4 * IntPtr.Size); // IUnknown (3 methods) + GetBufferPointer + GetBufferSize
|
||||
var del = Marshal.GetDelegateForFunctionPointer<GetBufferSizeDelegate>(getBufferSize);
|
||||
return del(blob);
|
||||
var fn = Marshal.ReadIntPtr(vtable, 4 * IntPtr.Size);
|
||||
return Marshal.GetDelegateForFunctionPointer<BlobBufferSizeFn>(fn)(blob);
|
||||
}
|
||||
|
||||
private static void BlobRelease(IntPtr blob)
|
||||
{
|
||||
var vtable = Marshal.ReadIntPtr(blob);
|
||||
var release = Marshal.ReadIntPtr(vtable, 2 * IntPtr.Size); // IUnknown::Release
|
||||
var del = Marshal.GetDelegateForFunctionPointer<ReleaseDelegate>(release);
|
||||
del(blob);
|
||||
var fn = Marshal.ReadIntPtr(vtable, 2 * IntPtr.Size);
|
||||
Marshal.GetDelegateForFunctionPointer<BlobReleaseFn>(fn)(blob);
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate IntPtr GetBufferPointerDelegate(IntPtr self);
|
||||
private delegate IntPtr BlobBufferPointerFn(IntPtr self);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int GetBufferSizeDelegate(IntPtr self);
|
||||
private delegate nint BlobBufferSizeFn(IntPtr self);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
private delegate int ReleaseDelegate(IntPtr self);
|
||||
private delegate int BlobReleaseFn(IntPtr self);
|
||||
|
||||
private static byte[] CompileHlsl(string source, string entryPoint, string target)
|
||||
{
|
||||
var hr = D3DCompile(source, source.Length, null, IntPtr.Zero, IntPtr.Zero,
|
||||
var hr = D3DCompile(source, (nint)source.Length, null, IntPtr.Zero, IntPtr.Zero,
|
||||
entryPoint, target, 0, 0, out var codeBlob, out var errorBlob);
|
||||
|
||||
if (hr != 0 || codeBlob == IntPtr.Zero)
|
||||
|
|
@ -456,7 +432,7 @@ float4 main(PS_INPUT input) : SV_Target
|
|||
BlobRelease(errorBlob);
|
||||
|
||||
var bufferPtr = BlobGetBufferPointer(codeBlob);
|
||||
var bufferSize = BlobGetBufferSize(codeBlob);
|
||||
var bufferSize = (int)BlobGetBufferSize(codeBlob);
|
||||
var result = new byte[bufferSize];
|
||||
Marshal.Copy(bufferPtr, result, 0, bufferSize);
|
||||
BlobRelease(codeBlob);
|
||||
|
|
@ -471,11 +447,13 @@ float4 main(PS_INPUT input) : SV_Target
|
|||
_fontTexture?.Dispose();
|
||||
_fontTextureView?.Dispose();
|
||||
_fontResourceSet?.Dispose();
|
||||
_projSamplerSet?.Dispose();
|
||||
_pipeline?.Dispose();
|
||||
_layout?.Dispose();
|
||||
_projSamplerLayout?.Dispose();
|
||||
_textureLayout?.Dispose();
|
||||
foreach (var shader in _shaders)
|
||||
shader?.Dispose();
|
||||
if (_shaders is not null)
|
||||
foreach (var shader in _shaders)
|
||||
shader?.Dispose();
|
||||
foreach (var rs in _resourceSets.Values)
|
||||
rs?.Dispose();
|
||||
}
|
||||
|
|
|
|||
62
src/Nexus.Simulator/Rendering/ViewTransform.cs
Normal file
62
src/Nexus.Simulator/Rendering/ViewTransform.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace Nexus.Simulator.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the 45° rotated isometric camera transform.
|
||||
/// Grid coordinates are rotated so that WASD maps to screen up/left/down/right.
|
||||
/// </summary>
|
||||
public readonly struct ViewTransform
|
||||
{
|
||||
private readonly Vector2 _canvasOrigin;
|
||||
private readonly Vector2 _viewOffset;
|
||||
private readonly float _zoom;
|
||||
private readonly float _worldToGrid;
|
||||
private const float C = 0.70710678f; // cos(45°) = 1/√2
|
||||
|
||||
public ViewTransform(Vector2 canvasOrigin, Vector2 viewOffset, float zoom, float worldToGrid)
|
||||
{
|
||||
_canvasOrigin = canvasOrigin;
|
||||
_viewOffset = viewOffset;
|
||||
_zoom = zoom;
|
||||
_worldToGrid = worldToGrid;
|
||||
}
|
||||
|
||||
public float Zoom => _zoom;
|
||||
public float WorldToGrid => _worldToGrid;
|
||||
|
||||
/// <summary>
|
||||
/// Scale factor from world units to screen pixels (distance-preserving rotation).
|
||||
/// </summary>
|
||||
public float WorldScale => _worldToGrid * _zoom;
|
||||
|
||||
/// <summary>
|
||||
/// Convert world position to screen position.
|
||||
/// </summary>
|
||||
public Vector2 WorldToScreen(Vector2 worldPos)
|
||||
{
|
||||
var gx = worldPos.X * _worldToGrid;
|
||||
var gy = worldPos.Y * _worldToGrid;
|
||||
return GridToScreen(gx, gy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert absolute grid position to screen position.
|
||||
/// </summary>
|
||||
public Vector2 GridToScreen(float gx, float gy)
|
||||
{
|
||||
var rx = (gx - gy) * C;
|
||||
var ry = -(gx + gy) * C;
|
||||
return _canvasOrigin + _viewOffset + new Vector2(rx, ry) * _zoom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert screen-relative position (relative to canvas origin) to absolute grid coords.
|
||||
/// </summary>
|
||||
public Vector2 ScreenToGrid(float sx, float sy)
|
||||
{
|
||||
var rx = (sx - _viewOffset.X) / _zoom;
|
||||
var ry = (sy - _viewOffset.Y) / _zoom;
|
||||
return new Vector2((rx - ry) * C, -(rx + ry) * C);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,16 @@ public enum EnemyAiState
|
|||
Idle,
|
||||
Chasing,
|
||||
Attacking,
|
||||
Retreating, // Ranged enemy backing away to maintain distance
|
||||
Dead,
|
||||
}
|
||||
|
||||
public enum EnemyType
|
||||
{
|
||||
Melee,
|
||||
Ranged,
|
||||
}
|
||||
|
||||
public class SimEnemy
|
||||
{
|
||||
private static uint _nextId = 1000;
|
||||
|
|
@ -20,14 +27,22 @@ public class SimEnemy
|
|||
public int Health { get; set; }
|
||||
public int MaxHealth { get; set; }
|
||||
public MonsterRarity Rarity { get; set; }
|
||||
public EnemyType Type { get; set; }
|
||||
public EnemyAiState AiState { get; set; } = EnemyAiState.Idle;
|
||||
public float MoveSpeed { get; set; }
|
||||
|
||||
// Damage (scaled by rarity)
|
||||
public int AttackDamage { get; set; }
|
||||
|
||||
// Timers
|
||||
public float AttackCooldownRemaining { get; set; }
|
||||
public float DespawnTimer { get; set; }
|
||||
public float RespawnTimer { get; set; }
|
||||
|
||||
// Ranged-specific
|
||||
public float PreferredRange { get; set; } // Distance ranged enemies try to maintain
|
||||
public float AttackRange { get; set; } // Max attack range (melee=100, ranged=500)
|
||||
|
||||
// Wander
|
||||
public Vector2 WanderTarget { get; set; }
|
||||
public float WanderTimer { get; set; }
|
||||
|
|
@ -35,13 +50,16 @@ public class SimEnemy
|
|||
|
||||
public bool IsAlive => Health > 0;
|
||||
public bool IsAttacking => AiState == EnemyAiState.Attacking;
|
||||
public bool IsRanged => Type == EnemyType.Ranged;
|
||||
|
||||
public SimEnemy(Vector2 position, MonsterRarity rarity, int baseHealth, float moveSpeed)
|
||||
public SimEnemy(Vector2 position, MonsterRarity rarity, EnemyType type,
|
||||
int baseHealth, int baseDamage, float moveSpeed)
|
||||
{
|
||||
Id = Interlocked.Increment(ref _nextId);
|
||||
Position = position;
|
||||
SpawnPosition = position;
|
||||
Rarity = rarity;
|
||||
Type = type;
|
||||
MoveSpeed = moveSpeed;
|
||||
|
||||
var hpMultiplier = rarity switch
|
||||
|
|
@ -53,6 +71,15 @@ public class SimEnemy
|
|||
};
|
||||
MaxHealth = (int)(baseHealth * hpMultiplier);
|
||||
Health = MaxHealth;
|
||||
|
||||
var dmgMultiplier = rarity switch
|
||||
{
|
||||
MonsterRarity.Magic => 1.5f,
|
||||
MonsterRarity.Rare => 2.5f,
|
||||
MonsterRarity.Unique => 4f,
|
||||
_ => 1f,
|
||||
};
|
||||
AttackDamage = (int)(baseDamage * dmgMultiplier);
|
||||
}
|
||||
|
||||
public void TakeDamage(int damage)
|
||||
|
|
|
|||
|
|
@ -9,31 +9,44 @@ public class SimPlayer
|
|||
public int MaxHealth { get; set; }
|
||||
public int Mana { get; set; }
|
||||
public int MaxMana { get; set; }
|
||||
public int Es { get; set; }
|
||||
public int MaxEs { get; set; }
|
||||
public float MoveSpeed { get; set; }
|
||||
public float HealthRegen { get; set; }
|
||||
public float ManaRegen { get; set; }
|
||||
public float EsRegen { get; set; }
|
||||
public float EsRechargeDelay { get; set; }
|
||||
|
||||
// Accumulate fractional regen
|
||||
private float _healthRegenAccum;
|
||||
private float _manaRegenAccum;
|
||||
private float _esRegenAccum;
|
||||
private float _timeSinceLastDamage;
|
||||
|
||||
public SimPlayer(int maxHealth, int maxMana, float moveSpeed, float healthRegen, float manaRegen)
|
||||
public SimPlayer(int maxHealth, int maxMana, int maxEs,
|
||||
float moveSpeed, float healthRegen, float manaRegen,
|
||||
float esRegen, float esRechargeDelay)
|
||||
{
|
||||
MaxHealth = maxHealth;
|
||||
MaxMana = maxMana;
|
||||
MaxEs = maxEs;
|
||||
Health = maxHealth;
|
||||
Mana = maxMana;
|
||||
Es = maxEs;
|
||||
MoveSpeed = moveSpeed;
|
||||
HealthRegen = healthRegen;
|
||||
ManaRegen = manaRegen;
|
||||
EsRegen = esRegen;
|
||||
EsRechargeDelay = esRechargeDelay;
|
||||
_timeSinceLastDamage = esRechargeDelay; // Start with ES recharging
|
||||
}
|
||||
|
||||
public void Update(float dt)
|
||||
{
|
||||
// Regenerate
|
||||
_healthRegenAccum += HealthRegen * dt;
|
||||
_manaRegenAccum += ManaRegen * dt;
|
||||
_timeSinceLastDamage += dt;
|
||||
|
||||
// Health regen (always active)
|
||||
_healthRegenAccum += HealthRegen * dt;
|
||||
if (_healthRegenAccum >= 1f)
|
||||
{
|
||||
var amount = (int)_healthRegenAccum;
|
||||
|
|
@ -41,18 +54,58 @@ public class SimPlayer
|
|||
_healthRegenAccum -= amount;
|
||||
}
|
||||
|
||||
// Mana regen
|
||||
_manaRegenAccum += ManaRegen * dt;
|
||||
if (_manaRegenAccum >= 1f)
|
||||
{
|
||||
var amount = (int)_manaRegenAccum;
|
||||
Mana = Math.Min(MaxMana, Mana + amount);
|
||||
_manaRegenAccum -= amount;
|
||||
}
|
||||
|
||||
// ES recharge (after delay since last damage)
|
||||
if (_timeSinceLastDamage >= EsRechargeDelay && Es < MaxEs)
|
||||
{
|
||||
_esRegenAccum += EsRegen * dt;
|
||||
if (_esRegenAccum >= 1f)
|
||||
{
|
||||
var amount = (int)_esRegenAccum;
|
||||
Es = Math.Min(MaxEs, Es + amount);
|
||||
_esRegenAccum -= amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void TakeDamage(int damage)
|
||||
{
|
||||
_timeSinceLastDamage = 0f;
|
||||
_esRegenAccum = 0f;
|
||||
|
||||
// ES absorbs damage first
|
||||
if (Es > 0)
|
||||
{
|
||||
if (damage <= Es)
|
||||
{
|
||||
Es -= damage;
|
||||
return;
|
||||
}
|
||||
damage -= Es;
|
||||
Es = 0;
|
||||
}
|
||||
|
||||
Health = Math.Max(0, Health - damage);
|
||||
}
|
||||
|
||||
public bool IsAlive => Health > 0;
|
||||
|
||||
/// <summary>Effective HP percentage considering both ES and Life.</summary>
|
||||
public float EffectiveHpPercent
|
||||
{
|
||||
get
|
||||
{
|
||||
var totalMax = MaxHealth + MaxEs;
|
||||
if (totalMax == 0) return 0f;
|
||||
return (float)(Health + Es) / totalMax * 100f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ public class SimProjectile
|
|||
public int Damage { get; set; }
|
||||
public float DistanceTraveled { get; set; }
|
||||
public bool IsExpired { get; set; }
|
||||
public bool IsEnemyProjectile { get; set; }
|
||||
|
||||
public SimProjectile(Vector2 origin, Vector2 direction, float speed, float maxRange, float hitRadius, int damage)
|
||||
public SimProjectile(Vector2 origin, Vector2 direction, float speed, float maxRange, float hitRadius, int damage,
|
||||
bool isEnemyProjectile = false)
|
||||
{
|
||||
Position = origin;
|
||||
Direction = Vector2.Normalize(direction);
|
||||
|
|
@ -21,6 +23,7 @@ public class SimProjectile
|
|||
MaxRange = maxRange;
|
||||
HitRadius = hitRadius;
|
||||
Damage = damage;
|
||||
IsEnemyProjectile = isEnemyProjectile;
|
||||
}
|
||||
|
||||
public void Update(float dt)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Numerics;
|
||||
using Nexus.Core;
|
||||
using Nexus.Simulator.Config;
|
||||
using Serilog;
|
||||
|
||||
namespace Nexus.Simulator.World;
|
||||
|
||||
|
|
@ -33,8 +34,9 @@ public class SimWorld
|
|||
var gridToWorld = 1f / config.WorldToGrid;
|
||||
var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain);
|
||||
Player = new SimPlayer(
|
||||
config.PlayerMaxHealth, config.PlayerMaxMana,
|
||||
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen)
|
||||
config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxEs,
|
||||
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen,
|
||||
config.PlayerEsRegen, config.PlayerEsRechargeDelay)
|
||||
{
|
||||
Position = new Vector2(sx * gridToWorld, sy * gridToWorld),
|
||||
};
|
||||
|
|
@ -51,6 +53,7 @@ public class SimWorld
|
|||
Player.Position = new Vector2(sx * gridToWorld, sy * gridToWorld);
|
||||
Player.Health = Player.MaxHealth;
|
||||
Player.Mana = Player.MaxMana;
|
||||
Player.Es = Player.MaxEs;
|
||||
Enemies.Clear();
|
||||
Projectiles.Clear();
|
||||
ActiveEffects.Clear();
|
||||
|
|
@ -70,13 +73,16 @@ public class SimWorld
|
|||
dt *= _config.SpeedMultiplier;
|
||||
TickNumber++;
|
||||
|
||||
// 0. Expand terrain if player near edge
|
||||
CheckAndExpandTerrain();
|
||||
|
||||
// 1. Move player
|
||||
MovePlayer(dt);
|
||||
|
||||
// 2. Process queued skills
|
||||
ProcessSkills();
|
||||
|
||||
// 3. Update projectiles
|
||||
// 3. Update projectiles (player + enemy)
|
||||
UpdateProjectiles(dt);
|
||||
|
||||
// 4. Update skill effects
|
||||
|
|
@ -92,45 +98,62 @@ public class SimWorld
|
|||
Player.Update(dt);
|
||||
}
|
||||
|
||||
// Pre-computed 8 cardinal directions for fallback movement
|
||||
private static readonly Vector2[] Cardinals =
|
||||
[
|
||||
new(1, 0), new(-1, 0), new(0, 1), new(0, -1),
|
||||
Vector2.Normalize(new(1, 1)), Vector2.Normalize(new(1, -1)),
|
||||
Vector2.Normalize(new(-1, 1)), Vector2.Normalize(new(-1, -1)),
|
||||
];
|
||||
|
||||
private void MovePlayer(float dt)
|
||||
{
|
||||
if (MoveDirection.LengthSquared() < 0.001f) return;
|
||||
|
||||
var dir = Vector2.Normalize(MoveDirection);
|
||||
var newPos = Player.Position + dir * Player.MoveSpeed * dt;
|
||||
var step = Player.MoveSpeed * dt;
|
||||
|
||||
// Terrain collision
|
||||
// Try full direction
|
||||
if (TryMove(dir, step)) return;
|
||||
|
||||
// Try axis-aligned slides
|
||||
if (MathF.Abs(dir.X) > 0.01f && TryMove(new Vector2(dir.X > 0 ? 1 : -1, 0), step)) return;
|
||||
if (MathF.Abs(dir.Y) > 0.01f && TryMove(new Vector2(0, dir.Y > 0 ? 1 : -1), step)) return;
|
||||
|
||||
// Try perpendicular directions (wall slide along corners)
|
||||
var perp1 = new Vector2(-dir.Y, dir.X);
|
||||
var perp2 = new Vector2(dir.Y, -dir.X);
|
||||
if (TryMove(perp1, step * 0.5f)) return;
|
||||
if (TryMove(perp2, step * 0.5f)) return;
|
||||
|
||||
// All preferred directions failed — try all 8 cardinals at reduced step
|
||||
// Sort by dot product with desired direction (prefer closest to intended direction)
|
||||
foreach (var cardinal in Cardinals)
|
||||
{
|
||||
if (TryMove(cardinal, step * 0.5f)) return;
|
||||
}
|
||||
|
||||
// Absolute last resort: try tiny steps in all 8 directions to nudge out
|
||||
foreach (var cardinal in Cardinals)
|
||||
{
|
||||
if (TryMove(cardinal, step * 0.15f)) return;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryMove(Vector2 dir, float step)
|
||||
{
|
||||
var newPos = Player.Position + dir * step;
|
||||
var gx = (int)(newPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(newPos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
Player.Position = newPos;
|
||||
else
|
||||
{
|
||||
// Try sliding along X
|
||||
var slideX = new Vector2(Player.Position.X + dir.X * Player.MoveSpeed * dt, Player.Position.Y);
|
||||
var sgx = (int)(slideX.X * _config.WorldToGrid);
|
||||
var sgy = (int)(slideX.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(sgx, sgy))
|
||||
{
|
||||
Player.Position = slideX;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try sliding along Y
|
||||
var slideY = new Vector2(Player.Position.X, Player.Position.Y + dir.Y * Player.MoveSpeed * dt);
|
||||
sgx = (int)(slideY.X * _config.WorldToGrid);
|
||||
sgy = (int)(slideY.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(sgx, sgy))
|
||||
Player.Position = slideY;
|
||||
}
|
||||
if (!Terrain.IsWalkable(gx, gy)) return false;
|
||||
Player.Position = newPos;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ProcessSkills()
|
||||
{
|
||||
while (_skillQueue.TryDequeue(out var skill))
|
||||
{
|
||||
// Determine skill type based on scan code slot
|
||||
// Slots 0-1 (LMB/RMB) = melee, 3 (Q) = AOE, 4 (E) = projectile, else = melee
|
||||
var type = GetSkillType(skill.scanCode);
|
||||
var targetPos = skill.targetWorldPos;
|
||||
|
||||
|
|
@ -196,6 +219,8 @@ public class SimWorld
|
|||
}
|
||||
|
||||
enemy.TakeDamage(_config.SkillBaseDamage);
|
||||
if (!enemy.IsAlive)
|
||||
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -211,12 +236,15 @@ public class SimWorld
|
|||
};
|
||||
ActiveEffects.Add(effect);
|
||||
|
||||
// Apply AOE damage
|
||||
foreach (var enemy in Enemies)
|
||||
{
|
||||
if (!enemy.IsAlive) continue;
|
||||
if (Vector2.Distance(enemy.Position, targetPos) <= _config.AoeRadius)
|
||||
{
|
||||
enemy.TakeDamage(_config.SkillBaseDamage);
|
||||
if (!enemy.IsAlive)
|
||||
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,12 +254,9 @@ public class SimWorld
|
|||
if (dir.LengthSquared() < 1f) dir = Vector2.UnitX;
|
||||
|
||||
var projectile = new SimProjectile(
|
||||
Player.Position,
|
||||
dir,
|
||||
_config.ProjectileSpeed,
|
||||
_config.ProjectileRange,
|
||||
_config.ProjectileHitRadius,
|
||||
_config.SkillBaseDamage);
|
||||
Player.Position, dir,
|
||||
_config.ProjectileSpeed, _config.ProjectileRange,
|
||||
_config.ProjectileHitRadius, _config.SkillBaseDamage);
|
||||
|
||||
Projectiles.Add(projectile);
|
||||
|
||||
|
|
@ -261,17 +286,35 @@ public class SimWorld
|
|||
proj.IsExpired = true;
|
||||
}
|
||||
|
||||
// Check enemy hits
|
||||
if (!proj.IsExpired)
|
||||
{
|
||||
foreach (var enemy in Enemies)
|
||||
if (proj.IsEnemyProjectile)
|
||||
{
|
||||
if (!enemy.IsAlive) continue;
|
||||
if (Vector2.Distance(enemy.Position, proj.Position) <= proj.HitRadius)
|
||||
// Enemy projectile hits player
|
||||
if (Vector2.Distance(Player.Position, proj.Position) <= proj.HitRadius)
|
||||
{
|
||||
enemy.TakeDamage(proj.Damage);
|
||||
Player.TakeDamage(proj.Damage);
|
||||
Log.Information("Damage: -{Dmg} projectile HP={HP}/{MaxHP} ES={ES}/{MaxES}",
|
||||
proj.Damage, Player.Health, Player.MaxHealth, Player.Es, Player.MaxEs);
|
||||
if (!Player.IsAlive)
|
||||
Log.Warning("PLAYER DIED to projectile");
|
||||
proj.IsExpired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Player projectile hits enemies
|
||||
foreach (var enemy in Enemies)
|
||||
{
|
||||
if (!enemy.IsAlive) continue;
|
||||
if (Vector2.Distance(enemy.Position, proj.Position) <= proj.HitRadius)
|
||||
{
|
||||
enemy.TakeDamage(proj.Damage);
|
||||
if (!enemy.IsAlive)
|
||||
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
|
||||
proj.IsExpired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -302,7 +345,6 @@ public class SimWorld
|
|||
enemy.DespawnTimer -= dt;
|
||||
if (enemy.DespawnTimer <= 0)
|
||||
{
|
||||
// Queue respawn
|
||||
_respawnQueue.Add((_config.EnemyRespawnTime, enemy.Rarity));
|
||||
Enemies.RemoveAt(i);
|
||||
}
|
||||
|
|
@ -315,53 +357,148 @@ public class SimWorld
|
|||
|
||||
var dist = Vector2.Distance(enemy.Position, Player.Position);
|
||||
|
||||
if (dist <= _config.EnemyAttackRange && Player.IsAlive)
|
||||
if (!Player.IsAlive)
|
||||
{
|
||||
// In attack range
|
||||
enemy.AiState = EnemyAiState.Attacking;
|
||||
if (enemy.AttackCooldownRemaining <= 0)
|
||||
{
|
||||
Player.TakeDamage(_config.EnemyAttackDamage);
|
||||
enemy.AttackCooldownRemaining = _config.EnemyAttackCooldown;
|
||||
}
|
||||
}
|
||||
else if (dist <= _config.EnemyAggroRange && Player.IsAlive)
|
||||
{
|
||||
// Chase player
|
||||
enemy.AiState = EnemyAiState.Chasing;
|
||||
var dir = Vector2.Normalize(Player.Position - enemy.Position);
|
||||
var newPos = enemy.Position + dir * enemy.MoveSpeed * dt;
|
||||
|
||||
var gx = (int)(newPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(newPos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
enemy.Position = newPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Idle: random wander
|
||||
enemy.AiState = EnemyAiState.Idle;
|
||||
enemy.WanderTimer -= dt;
|
||||
if (enemy.WanderTimer <= 0)
|
||||
{
|
||||
// Pick new wander target
|
||||
var angle = _rng.NextSingle() * MathF.Tau;
|
||||
var dist2 = _rng.NextSingle() * _config.EnemyWanderRadius;
|
||||
enemy.WanderTarget = enemy.SpawnPosition + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * dist2;
|
||||
enemy.WanderTimer = 2f + _rng.NextSingle() * 3f;
|
||||
}
|
||||
UpdateWander(enemy, dt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Vector2.Distance(enemy.Position, enemy.WanderTarget) > 10f)
|
||||
{
|
||||
var dir = Vector2.Normalize(enemy.WanderTarget - enemy.Position);
|
||||
var newPos = enemy.Position + dir * enemy.MoveSpeed * 0.3f * dt;
|
||||
var gx = (int)(newPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(newPos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
enemy.Position = newPos;
|
||||
}
|
||||
if (enemy.IsRanged)
|
||||
UpdateRangedEnemy(enemy, dist, dt);
|
||||
else
|
||||
UpdateMeleeEnemy(enemy, dist, dt);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMeleeEnemy(SimEnemy enemy, float dist, float dt)
|
||||
{
|
||||
if (dist <= enemy.AttackRange)
|
||||
{
|
||||
// In melee range — attack
|
||||
enemy.AiState = EnemyAiState.Attacking;
|
||||
if (enemy.AttackCooldownRemaining <= 0)
|
||||
{
|
||||
Player.TakeDamage(enemy.AttackDamage);
|
||||
Log.Information("Damage: -{Dmg} melee ({Rarity}) HP={HP}/{MaxHP} ES={ES}/{MaxES}",
|
||||
enemy.AttackDamage, enemy.Rarity, Player.Health, Player.MaxHealth, Player.Es, Player.MaxEs);
|
||||
if (!Player.IsAlive)
|
||||
Log.Warning("PLAYER DIED to melee {Rarity} #{Id}", enemy.Rarity, enemy.Id);
|
||||
enemy.AttackCooldownRemaining = _config.EnemyMeleeAttackCooldown;
|
||||
}
|
||||
}
|
||||
else if (dist <= _config.EnemyAggroRange)
|
||||
{
|
||||
// Chase player
|
||||
enemy.AiState = EnemyAiState.Chasing;
|
||||
MoveToward(enemy, Player.Position, enemy.MoveSpeed, dt);
|
||||
}
|
||||
else
|
||||
{
|
||||
enemy.AiState = EnemyAiState.Idle;
|
||||
UpdateWander(enemy, dt);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateRangedEnemy(SimEnemy enemy, float dist, float dt)
|
||||
{
|
||||
if (dist > _config.EnemyAggroRange)
|
||||
{
|
||||
// Out of aggro range — idle wander
|
||||
enemy.AiState = EnemyAiState.Idle;
|
||||
UpdateWander(enemy, dt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dist <= enemy.AttackRange && enemy.AttackCooldownRemaining <= 0)
|
||||
{
|
||||
// In range and ready to fire — shoot projectile
|
||||
enemy.AiState = EnemyAiState.Attacking;
|
||||
FireEnemyProjectile(enemy);
|
||||
enemy.AttackCooldownRemaining = _config.EnemyRangedAttackCooldown;
|
||||
return;
|
||||
}
|
||||
|
||||
// Too close — retreat to preferred range
|
||||
if (dist < enemy.PreferredRange * 0.7f)
|
||||
{
|
||||
enemy.AiState = EnemyAiState.Retreating;
|
||||
MoveAwayFrom(enemy, Player.Position, enemy.MoveSpeed, dt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Too far — close in to attack range
|
||||
if (dist > enemy.AttackRange)
|
||||
{
|
||||
enemy.AiState = EnemyAiState.Chasing;
|
||||
MoveToward(enemy, Player.Position, enemy.MoveSpeed * 0.8f, dt);
|
||||
return;
|
||||
}
|
||||
|
||||
// At good range, waiting for cooldown — strafe laterally
|
||||
enemy.AiState = EnemyAiState.Chasing;
|
||||
var toPlayer = Vector2.Normalize(Player.Position - enemy.Position);
|
||||
var strafe = new Vector2(-toPlayer.Y, toPlayer.X); // perpendicular
|
||||
// Alternate strafe direction based on enemy ID
|
||||
if (enemy.Id % 2 == 0) strafe = -strafe;
|
||||
MoveToward(enemy, enemy.Position + strafe * 100f, enemy.MoveSpeed * 0.5f, dt);
|
||||
}
|
||||
|
||||
private void FireEnemyProjectile(SimEnemy enemy)
|
||||
{
|
||||
var dir = Player.Position - enemy.Position;
|
||||
if (dir.LengthSquared() < 1f) return;
|
||||
|
||||
var proj = new SimProjectile(
|
||||
enemy.Position, dir,
|
||||
_config.EnemyProjectileSpeed,
|
||||
_config.EnemyRangedAttackRange * 1.5f,
|
||||
_config.EnemyProjectileHitRadius,
|
||||
enemy.AttackDamage,
|
||||
isEnemyProjectile: true);
|
||||
|
||||
Projectiles.Add(proj);
|
||||
}
|
||||
|
||||
private void MoveToward(SimEnemy enemy, Vector2 target, float speed, float dt)
|
||||
{
|
||||
var dir = target - enemy.Position;
|
||||
if (dir.LengthSquared() < 1f) return;
|
||||
dir = Vector2.Normalize(dir);
|
||||
|
||||
var newPos = enemy.Position + dir * speed * dt;
|
||||
var gx = (int)(newPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(newPos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
enemy.Position = newPos;
|
||||
}
|
||||
|
||||
private void MoveAwayFrom(SimEnemy enemy, Vector2 target, float speed, float dt)
|
||||
{
|
||||
var dir = enemy.Position - target;
|
||||
if (dir.LengthSquared() < 1f) return;
|
||||
dir = Vector2.Normalize(dir);
|
||||
|
||||
var newPos = enemy.Position + dir * speed * dt;
|
||||
var gx = (int)(newPos.X * _config.WorldToGrid);
|
||||
var gy = (int)(newPos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
enemy.Position = newPos;
|
||||
}
|
||||
|
||||
private void UpdateWander(SimEnemy enemy, float dt)
|
||||
{
|
||||
enemy.WanderTimer -= dt;
|
||||
if (enemy.WanderTimer <= 0)
|
||||
{
|
||||
var angle = _rng.NextSingle() * MathF.Tau;
|
||||
var dist2 = _rng.NextSingle() * _config.EnemyWanderRadius;
|
||||
enemy.WanderTarget = enemy.SpawnPosition + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * dist2;
|
||||
enemy.WanderTimer = 2f + _rng.NextSingle() * 3f;
|
||||
}
|
||||
|
||||
if (Vector2.Distance(enemy.Position, enemy.WanderTarget) > 10f)
|
||||
MoveToward(enemy, enemy.WanderTarget, enemy.MoveSpeed * 0.3f, dt);
|
||||
}
|
||||
|
||||
private void UpdateRespawns(float dt)
|
||||
|
|
@ -371,54 +508,131 @@ public class SimWorld
|
|||
var (timer, rarity) = _respawnQueue[i];
|
||||
timer -= dt;
|
||||
if (timer <= 0)
|
||||
{
|
||||
SpawnEnemy(rarity);
|
||||
_respawnQueue.RemoveAt(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
_respawnQueue[i] = (timer, rarity);
|
||||
}
|
||||
}
|
||||
|
||||
// Cull enemies too far from player
|
||||
for (var i = Enemies.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var dist = Vector2.Distance(Enemies[i].Position, Player.Position);
|
||||
if (dist > _config.EnemyCullDist)
|
||||
Enemies.RemoveAt(i);
|
||||
}
|
||||
|
||||
// Maintain population
|
||||
var aliveCount = Enemies.Count(e => e.IsAlive) + _respawnQueue.Count;
|
||||
var aliveCount = Enemies.Count(e => e.IsAlive);
|
||||
while (aliveCount < _config.TargetEnemyCount)
|
||||
{
|
||||
SpawnEnemy(RollRarity());
|
||||
aliveCount++;
|
||||
var spawned = SpawnGroup(RollRarity());
|
||||
aliveCount += spawned;
|
||||
if (spawned == 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SpawnEnemyAt(Vector2 worldPos, MonsterRarity rarity)
|
||||
{
|
||||
var enemy = new SimEnemy(worldPos, rarity, _config.EnemyBaseHealth,
|
||||
_config.PlayerMoveSpeed * _config.EnemyMoveSpeedFactor)
|
||||
var type = _rng.NextSingle() < _config.RangedEnemyChance ? EnemyType.Ranged : EnemyType.Melee;
|
||||
var baseDmg = type == EnemyType.Ranged ? _config.EnemyRangedBaseDamage : _config.EnemyMeleeBaseDamage;
|
||||
|
||||
var enemy = new SimEnemy(worldPos, rarity, type,
|
||||
_config.EnemyBaseHealth, baseDmg,
|
||||
Player.MoveSpeed * _config.EnemyMoveSpeedFactor)
|
||||
{
|
||||
WanderTarget = worldPos,
|
||||
WanderTimer = _rng.NextSingle() * 3f,
|
||||
AttackRange = type == EnemyType.Ranged ? _config.EnemyRangedAttackRange : _config.EnemyMeleeAttackRange,
|
||||
PreferredRange = type == EnemyType.Ranged ? _config.EnemyRangedPreferredRange : 0f,
|
||||
};
|
||||
Enemies.Add(enemy);
|
||||
}
|
||||
|
||||
private void SpawnEnemies(int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
SpawnEnemy(RollRarity());
|
||||
while (count > 0)
|
||||
{
|
||||
var spawned = SpawnGroup(RollRarity());
|
||||
if (spawned == 0) break;
|
||||
count -= spawned;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnEnemy(MonsterRarity rarity)
|
||||
private int SpawnGroup(MonsterRarity leaderRarity)
|
||||
{
|
||||
var gridToWorld = 1f / _config.WorldToGrid;
|
||||
var pos = TerrainGenerator.FindRandomWalkable(Terrain, _rng);
|
||||
if (pos is null) return;
|
||||
var center = FindSpawnNearPlayer();
|
||||
if (center is null) return 0;
|
||||
|
||||
var worldPos = new Vector2(pos.Value.x * gridToWorld, pos.Value.y * gridToWorld);
|
||||
var groupSize = _rng.Next(_config.EnemyGroupMin, _config.EnemyGroupMax + 1);
|
||||
var spawned = 0;
|
||||
|
||||
// Don't spawn too close to player
|
||||
if (Vector2.Distance(worldPos, Player.Position) < 300f) return;
|
||||
for (var i = 0; i < groupSize; i++)
|
||||
{
|
||||
var rarity = i == 0 ? leaderRarity : MonsterRarity.White;
|
||||
var offset = i == 0
|
||||
? Vector2.Zero
|
||||
: new Vector2(
|
||||
(_rng.NextSingle() - 0.5f) * 2f * _config.EnemyGroupSpread,
|
||||
(_rng.NextSingle() - 0.5f) * 2f * _config.EnemyGroupSpread);
|
||||
|
||||
SpawnEnemyAt(worldPos, rarity);
|
||||
var pos = center.Value + offset;
|
||||
var gx = (int)(pos.X * _config.WorldToGrid);
|
||||
var gy = (int)(pos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
{
|
||||
SpawnEnemyAt(pos, rarity);
|
||||
spawned++;
|
||||
}
|
||||
}
|
||||
|
||||
return spawned;
|
||||
}
|
||||
|
||||
private Vector2? FindSpawnNearPlayer()
|
||||
{
|
||||
var baseAngle = MoveDirection.LengthSquared() > 0.01f
|
||||
? MathF.Atan2(MoveDirection.Y, MoveDirection.X)
|
||||
: _rng.NextSingle() * MathF.Tau;
|
||||
|
||||
for (var attempt = 0; attempt < 30; attempt++)
|
||||
{
|
||||
var angle = baseAngle + (_rng.NextSingle() - 0.5f) * MathF.PI;
|
||||
var dist = _config.EnemySpawnMinDist + _rng.NextSingle() * (_config.EnemySpawnMaxDist - _config.EnemySpawnMinDist);
|
||||
var pos = Player.Position + new Vector2(MathF.Cos(angle), MathF.Sin(angle)) * dist;
|
||||
|
||||
var gx = (int)(pos.X * _config.WorldToGrid);
|
||||
var gy = (int)(pos.Y * _config.WorldToGrid);
|
||||
if (Terrain.IsWalkable(gx, gy))
|
||||
return pos;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CheckAndExpandTerrain()
|
||||
{
|
||||
var gx = (int)(Player.Position.X * _config.WorldToGrid);
|
||||
var gy = (int)(Player.Position.Y * _config.WorldToGrid);
|
||||
var t = Terrain;
|
||||
|
||||
var distLeft = gx - t.OffsetX;
|
||||
var distRight = (t.OffsetX + t.Width - 1) - gx;
|
||||
var distTop = gy - t.OffsetY;
|
||||
var distBottom = (t.OffsetY + t.Height - 1) - gy;
|
||||
|
||||
var amt = _config.ExpandAmount;
|
||||
var expandLeft = distLeft < _config.ExpandThreshold ? amt : 0;
|
||||
var expandRight = distRight < _config.ExpandThreshold ? amt : 0;
|
||||
var expandTop = distTop < _config.ExpandThreshold ? amt : 0;
|
||||
var expandBottom = distBottom < _config.ExpandThreshold ? amt : 0;
|
||||
|
||||
if (expandLeft > 0 || expandRight > 0 || expandTop > 0 || expandBottom > 0)
|
||||
{
|
||||
Terrain = TerrainGenerator.Expand(Terrain, expandLeft, expandRight, expandTop, expandBottom, _rng);
|
||||
Serilog.Log.Information(
|
||||
"Terrain expanded: {W}x{H} offset=({Ox},{Oy})",
|
||||
Terrain.Width, Terrain.Height, Terrain.OffsetX, Terrain.OffsetY);
|
||||
}
|
||||
}
|
||||
|
||||
private MonsterRarity RollRarity()
|
||||
|
|
|
|||
|
|
@ -1,103 +1,189 @@
|
|||
using System.Numerics;
|
||||
using Nexus.Core;
|
||||
|
||||
namespace Nexus.Simulator.World;
|
||||
|
||||
public static class TerrainGenerator
|
||||
{
|
||||
private record Room(int X, int Y, int Width, int Height)
|
||||
// Permutation table for Perlin noise (fixed seed for deterministic terrain)
|
||||
private static readonly int[] Perm;
|
||||
|
||||
static TerrainGenerator()
|
||||
{
|
||||
public int CenterX => X + Width / 2;
|
||||
public int CenterY => Y + Height / 2;
|
||||
var p = new int[256];
|
||||
for (var i = 0; i < 256; i++) p[i] = i;
|
||||
var rng = new Random(42);
|
||||
for (var i = 255; i > 0; i--)
|
||||
{
|
||||
var j = rng.Next(i + 1);
|
||||
(p[i], p[j]) = (p[j], p[i]);
|
||||
}
|
||||
Perm = new int[512];
|
||||
for (var i = 0; i < 512; i++) Perm[i] = p[i & 255];
|
||||
}
|
||||
|
||||
public static WalkabilitySnapshot Generate(int width, int height, int? seed = null)
|
||||
{
|
||||
var rng = seed.HasValue ? new Random(seed.Value) : new Random();
|
||||
var data = new byte[width * height]; // 0 = wall by default
|
||||
var data = new byte[width * height];
|
||||
FillNoiseRegion(data, width, height, 0, 0, 0, 0, width, height);
|
||||
|
||||
var rooms = new List<Room>();
|
||||
var attempts = 0;
|
||||
var targetRooms = 15 + rng.Next(10);
|
||||
// Clear spawn area at center
|
||||
FillCircle(data, width, height, width / 2, height / 2, 20, 1);
|
||||
|
||||
while (rooms.Count < targetRooms && attempts < 500)
|
||||
{
|
||||
attempts++;
|
||||
var rw = rng.Next(20, 60);
|
||||
var rh = rng.Next(20, 60);
|
||||
var rx = rng.Next(2, width - rw - 2);
|
||||
var ry = rng.Next(2, height - rh - 2);
|
||||
return new WalkabilitySnapshot { Width = width, Height = height, Data = data };
|
||||
}
|
||||
|
||||
var candidate = new Room(rx, ry, rw, rh);
|
||||
/// <summary>
|
||||
/// Expands the terrain by adding cells on each side. Old data is preserved.
|
||||
/// New regions use the same Perlin noise with absolute coordinates for seamless tiling.
|
||||
/// </summary>
|
||||
public static WalkabilitySnapshot Expand(WalkabilitySnapshot old, int left, int right, int top, int bottom, Random? rng = null)
|
||||
{
|
||||
var newW = old.Width + left + right;
|
||||
var newH = old.Height + top + bottom;
|
||||
var data = new byte[newW * newH];
|
||||
var newOffsetX = old.OffsetX - left;
|
||||
var newOffsetY = old.OffsetY - top;
|
||||
|
||||
// Check overlap with existing rooms (with margin)
|
||||
var overlaps = false;
|
||||
foreach (var existing in rooms)
|
||||
{
|
||||
if (candidate.X - 3 < existing.X + existing.Width &&
|
||||
candidate.X + candidate.Width + 3 > existing.X &&
|
||||
candidate.Y - 3 < existing.Y + existing.Height &&
|
||||
candidate.Y + candidate.Height + 3 > existing.Y)
|
||||
{
|
||||
overlaps = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fill entire new grid with noise (absolute coordinates ensure seamless expansion)
|
||||
FillNoiseRegion(data, newW, newH, newOffsetX, newOffsetY, 0, 0, newW, newH);
|
||||
|
||||
if (!overlaps)
|
||||
rooms.Add(candidate);
|
||||
}
|
||||
|
||||
// Carve rooms
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
for (var y = room.Y; y < room.Y + room.Height; y++)
|
||||
for (var x = room.X; x < room.X + room.Width; x++)
|
||||
data[y * width + x] = 1;
|
||||
}
|
||||
|
||||
// Connect rooms with corridors
|
||||
for (var i = 1; i < rooms.Count; i++)
|
||||
{
|
||||
var a = rooms[i - 1];
|
||||
var b = rooms[i];
|
||||
CarveCorridorL(data, width, a.CenterX, a.CenterY, b.CenterX, b.CenterY, rng);
|
||||
}
|
||||
|
||||
// Also connect last to first for a loop
|
||||
if (rooms.Count > 2)
|
||||
{
|
||||
var first = rooms[0];
|
||||
var last = rooms[^1];
|
||||
CarveCorridorL(data, width, first.CenterX, first.CenterY, last.CenterX, last.CenterY, rng);
|
||||
}
|
||||
|
||||
// Add some random extra connections
|
||||
var extraConnections = rng.Next(3, 7);
|
||||
for (var i = 0; i < extraConnections; i++)
|
||||
{
|
||||
var a = rooms[rng.Next(rooms.Count)];
|
||||
var b = rooms[rng.Next(rooms.Count)];
|
||||
if (a != b)
|
||||
CarveCorridorL(data, width, a.CenterX, a.CenterY, b.CenterX, b.CenterY, rng);
|
||||
}
|
||||
// Overwrite the old region with preserved data
|
||||
for (var y = 0; y < old.Height; y++)
|
||||
Array.Copy(old.Data, y * old.Width, data, (y + top) * newW + left, old.Width);
|
||||
|
||||
return new WalkabilitySnapshot
|
||||
{
|
||||
Width = width,
|
||||
Height = height,
|
||||
Width = newW,
|
||||
Height = newH,
|
||||
Data = data,
|
||||
OffsetX = newOffsetX,
|
||||
OffsetY = newOffsetY,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a walkable position near the center of the terrain.
|
||||
/// Fills a region of the data array using Perlin noise at absolute grid coordinates.
|
||||
/// Combines corridor noise (abs of fractal noise — zero crossings form paths)
|
||||
/// with room noise (low-frequency — creates larger open areas).
|
||||
/// </summary>
|
||||
private static void FillNoiseRegion(byte[] data, int dataWidth, int dataHeight,
|
||||
int offsetX, int offsetY, int regionX, int regionY, int regionW, int regionH)
|
||||
{
|
||||
// Corridor noise: abs(fractal) creates paths along zero crossings
|
||||
const float corridorScale = 0.04f;
|
||||
const float corridorThreshold = 0.28f; // Width of corridors (wider = more open)
|
||||
|
||||
// Room noise: low-frequency blobs create open areas
|
||||
const float roomScale = 0.015f;
|
||||
const float roomThreshold = 0.10f; // Lower = larger rooms
|
||||
|
||||
// Detail noise: adds roughness to walls
|
||||
const float detailScale = 0.12f;
|
||||
const float detailWeight = 0.03f;
|
||||
|
||||
for (var ly = regionY; ly < regionY + regionH; ly++)
|
||||
for (var lx = regionX; lx < regionX + regionW; lx++)
|
||||
{
|
||||
if (lx < 0 || lx >= dataWidth || ly < 0 || ly >= dataHeight) continue;
|
||||
|
||||
float ax = lx + offsetX;
|
||||
float ay = ly + offsetY;
|
||||
|
||||
// Corridor network: abs creates linear paths along noise zero-crossings
|
||||
var corridor = MathF.Abs(FractalNoise(ax * corridorScale, ay * corridorScale, 3));
|
||||
|
||||
// Rooms: large smooth blobs (offset coordinates to decorrelate from corridors)
|
||||
var room = FractalNoise(ax * roomScale + 500f, ay * roomScale + 500f, 2);
|
||||
|
||||
// Detail: roughen edges
|
||||
var detail = Perlin(ax * detailScale + 200f, ay * detailScale + 200f) * detailWeight;
|
||||
|
||||
// Walkable if on a corridor path OR inside a room
|
||||
var walkable = (corridor + detail) < corridorThreshold || room > roomThreshold;
|
||||
|
||||
data[ly * dataWidth + lx] = walkable ? (byte)1 : (byte)0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multi-octave fractal Brownian motion noise.
|
||||
/// </summary>
|
||||
private static float FractalNoise(float x, float y, int octaves)
|
||||
{
|
||||
var value = 0f;
|
||||
var amplitude = 1f;
|
||||
var freq = 1f;
|
||||
var maxAmp = 0f;
|
||||
|
||||
for (var i = 0; i < octaves; i++)
|
||||
{
|
||||
value += Perlin(x * freq, y * freq) * amplitude;
|
||||
maxAmp += amplitude;
|
||||
amplitude *= 0.5f;
|
||||
freq *= 2f;
|
||||
}
|
||||
|
||||
return value / maxAmp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classic 2D Perlin noise. Returns values roughly in [-1, 1].
|
||||
/// </summary>
|
||||
private static float Perlin(float x, float y)
|
||||
{
|
||||
var xi = (int)MathF.Floor(x) & 255;
|
||||
var yi = (int)MathF.Floor(y) & 255;
|
||||
var xf = x - MathF.Floor(x);
|
||||
var yf = y - MathF.Floor(y);
|
||||
|
||||
var u = Fade(xf);
|
||||
var v = Fade(yf);
|
||||
|
||||
var aa = Perm[Perm[xi] + yi];
|
||||
var ab = Perm[Perm[xi] + yi + 1];
|
||||
var ba = Perm[Perm[xi + 1] + yi];
|
||||
var bb = Perm[Perm[xi + 1] + yi + 1];
|
||||
|
||||
var x1 = Lerp(Grad(aa, xf, yf), Grad(ba, xf - 1, yf), u);
|
||||
var x2 = Lerp(Grad(ab, xf, yf - 1), Grad(bb, xf - 1, yf - 1), u);
|
||||
return Lerp(x1, x2, v);
|
||||
}
|
||||
|
||||
private static float Fade(float t) => t * t * t * (t * (t * 6 - 15) + 10);
|
||||
|
||||
private static float Lerp(float a, float b, float t) => a + t * (b - a);
|
||||
|
||||
private static float Grad(int hash, float x, float y)
|
||||
{
|
||||
var h = hash & 3;
|
||||
var u = h < 2 ? x : y;
|
||||
var v = h < 2 ? y : x;
|
||||
return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v);
|
||||
}
|
||||
|
||||
private static void FillCircle(byte[] data, int width, int height, int cx, int cy, int radius, byte value)
|
||||
{
|
||||
var r2 = radius * radius;
|
||||
for (var dy = -radius; dy <= radius; dy++)
|
||||
for (var dx = -radius; dx <= radius; dx++)
|
||||
{
|
||||
if (dx * dx + dy * dy > r2) continue;
|
||||
var x = cx + dx;
|
||||
var y = cy + dy;
|
||||
if (x >= 0 && x < width && y >= 0 && y < height)
|
||||
data[y * width + x] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a walkable position near the center. Returns absolute grid coords.
|
||||
/// </summary>
|
||||
public static (int x, int y) FindSpawnPosition(WalkabilitySnapshot terrain)
|
||||
{
|
||||
var cx = terrain.Width / 2;
|
||||
var cy = terrain.Height / 2;
|
||||
var cx = terrain.OffsetX + terrain.Width / 2;
|
||||
var cy = terrain.OffsetY + terrain.Height / 2;
|
||||
|
||||
// Spiral outward
|
||||
for (var r = 0; r < Math.Max(terrain.Width, terrain.Height); r++)
|
||||
{
|
||||
for (var dx = -r; dx <= r; dx++)
|
||||
|
|
@ -115,61 +201,19 @@ public static class TerrainGenerator
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a random walkable position.
|
||||
/// Finds a random walkable position. Returns absolute grid coords.
|
||||
/// </summary>
|
||||
public static (int x, int y)? FindRandomWalkable(WalkabilitySnapshot terrain, Random rng, int maxAttempts = 200)
|
||||
{
|
||||
for (var i = 0; i < maxAttempts; i++)
|
||||
{
|
||||
var x = rng.Next(terrain.Width);
|
||||
var y = rng.Next(terrain.Height);
|
||||
if (terrain.IsWalkable(x, y))
|
||||
return (x, y);
|
||||
var lx = rng.Next(terrain.Width);
|
||||
var ly = rng.Next(terrain.Height);
|
||||
var ax = lx + terrain.OffsetX;
|
||||
var ay = ly + terrain.OffsetY;
|
||||
if (terrain.IsWalkable(ax, ay))
|
||||
return (ax, ay);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void CarveCorridorL(byte[] data, int width, int x1, int y1, int x2, int y2, Random rng)
|
||||
{
|
||||
var corridorWidth = 2 + rng.Next(2);
|
||||
|
||||
// L-shaped: horizontal then vertical (or vice versa)
|
||||
if (rng.Next(2) == 0)
|
||||
{
|
||||
CarveHorizontal(data, width, x1, x2, y1, corridorWidth);
|
||||
CarveVertical(data, width, y1, y2, x2, corridorWidth);
|
||||
}
|
||||
else
|
||||
{
|
||||
CarveVertical(data, width, y1, y2, x1, corridorWidth);
|
||||
CarveHorizontal(data, width, x1, x2, y2, corridorWidth);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CarveHorizontal(byte[] data, int width, int x1, int x2, int y, int thickness)
|
||||
{
|
||||
var xMin = Math.Min(x1, x2);
|
||||
var xMax = Math.Max(x1, x2);
|
||||
for (var x = xMin; x <= xMax; x++)
|
||||
for (var dy = 0; dy < thickness; dy++)
|
||||
{
|
||||
var ry = y + dy;
|
||||
if (ry >= 0 && ry < data.Length / width && x >= 0 && x < width)
|
||||
data[ry * width + x] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CarveVertical(byte[] data, int width, int y1, int y2, int x, int thickness)
|
||||
{
|
||||
var height = data.Length / width;
|
||||
var yMin = Math.Min(y1, y2);
|
||||
var yMax = Math.Max(y1, y2);
|
||||
for (var y = yMin; y <= yMax; y++)
|
||||
for (var dx = 0; dx < thickness; dx++)
|
||||
{
|
||||
var rx = x + dx;
|
||||
if (y >= 0 && y < height && rx >= 0 && rx < width)
|
||||
data[y * width + rx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
582
src/Nexus.Systems/AreaProgressionSystem.cs
Normal file
582
src/Nexus.Systems/AreaProgressionSystem.cs
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
using System.Numerics;
|
||||
using Nexus.Core;
|
||||
using Nexus.Data;
|
||||
using Nexus.Pathfinding;
|
||||
using Serilog;
|
||||
|
||||
namespace Nexus.Systems;
|
||||
|
||||
public sealed class AreaProgressionSystem : ISystem
|
||||
{
|
||||
private enum Phase { Exploring, Looting, NavigatingToChest, InteractingChest, NavigatingToTransition, Interacting, TalkingToNpc }
|
||||
|
||||
private readonly AreaGraph _graph;
|
||||
private readonly NavigationController _nav;
|
||||
private readonly BotConfig _config;
|
||||
|
||||
private readonly HashSet<string> _visitedAreas = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<uint> _blacklistedTransitions = new();
|
||||
private string? _currentAreaId;
|
||||
private string? _targetAreaId;
|
||||
private string? _targetTransitionName;
|
||||
private uint _targetTransitionEntityId;
|
||||
private Phase _phase = Phase.Exploring;
|
||||
private uint _lastAreaHash;
|
||||
private long _lastTransitionCheckMs;
|
||||
private long _interactStartMs;
|
||||
private uint _lootTargetEntityId;
|
||||
private long _lootStartMs;
|
||||
private uint _chestTargetEntityId;
|
||||
private long _chestInteractStartMs;
|
||||
private readonly HashSet<uint> _interactedChests = new();
|
||||
private bool _currentAreaExplored;
|
||||
private bool _questDriven;
|
||||
|
||||
public int Priority => SystemPriority.Navigation - 1;
|
||||
public string Name => "Progression";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public string? CurrentAreaId => _currentAreaId;
|
||||
public string? TargetAreaId => _targetAreaId;
|
||||
public IReadOnlySet<string> VisitedAreas => _visitedAreas;
|
||||
public string PhaseName => _phase.ToString();
|
||||
public string? TargetTransitionName => _targetTransitionName;
|
||||
public bool IsLootingActive => _phase == Phase.Looting;
|
||||
public bool IsQuestDriven => _questDriven;
|
||||
public string? QuestTargetName => _targetAreaId is not null ? _graph.GetById(_targetAreaId)?.Name : null;
|
||||
|
||||
public AreaProgressionSystem(BotConfig config, NavigationController nav, AreaGraph graph)
|
||||
{
|
||||
_config = config;
|
||||
_nav = nav;
|
||||
_graph = graph;
|
||||
}
|
||||
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
// Need area name from log watcher
|
||||
if (state.CurrentAreaName is null) return;
|
||||
|
||||
// Resolve current area
|
||||
var node = _graph.GetByName(state.CurrentAreaName);
|
||||
if (node is null) return;
|
||||
|
||||
var prevAreaId = _currentAreaId;
|
||||
_currentAreaId = node.Id;
|
||||
_visitedAreas.Add(node.Id);
|
||||
|
||||
var areaChanged = prevAreaId != _currentAreaId;
|
||||
|
||||
if (areaChanged)
|
||||
{
|
||||
Log.Information("Progression: entered {Area} ({Id})", node.Name, node.Id);
|
||||
|
||||
// Auto-mark all earlier areas (same act, lower order) as visited
|
||||
foreach (var earlierId in _graph.GetEarlierAreas(_currentAreaId))
|
||||
_visitedAreas.Add(earlierId);
|
||||
|
||||
// Force target recalculation
|
||||
_targetAreaId = null;
|
||||
}
|
||||
|
||||
// Detect area hash change → reset navigation/phase state
|
||||
if (state.AreaHash != _lastAreaHash)
|
||||
{
|
||||
_lastAreaHash = state.AreaHash;
|
||||
_targetTransitionEntityId = 0;
|
||||
_lootTargetEntityId = 0;
|
||||
_chestTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
_blacklistedTransitions.Clear();
|
||||
_interactedChests.Clear();
|
||||
}
|
||||
|
||||
// Resolve target BEFORE deciding whether to explore — we need to know
|
||||
// if the current area IS the target (affects exploration decision).
|
||||
if (_targetAreaId is null)
|
||||
{
|
||||
_targetAreaId = FindQuestTarget(state);
|
||||
_questDriven = _targetAreaId is not null;
|
||||
_targetAreaId ??= _graph.FindNextTarget(_currentAreaId, _visitedAreas);
|
||||
|
||||
if (_targetAreaId is null)
|
||||
{
|
||||
Log.Information("Progression: all reachable areas visited, no quest targets");
|
||||
IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var path = _graph.FindAreaPath(_currentAreaId, _targetAreaId);
|
||||
if (path is null || path.Count < 2)
|
||||
{
|
||||
// Current area IS the target — no hop needed, just explore it
|
||||
if (string.Equals(_currentAreaId, _targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Information("Progression: arrived at target {Target}, exploring",
|
||||
_graph.GetById(_targetAreaId)?.Name);
|
||||
_targetTransitionName = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Progression: no path from {From} to {To}", _currentAreaId, _targetAreaId);
|
||||
_targetAreaId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstHop = path[1];
|
||||
var hopNode = _graph.GetById(firstHop);
|
||||
_targetTransitionName = hopNode?.Name;
|
||||
Log.Information("Progression: targeting {Target} via {Hop} ({Source})",
|
||||
_graph.GetById(_targetAreaId)?.Name, _targetTransitionName,
|
||||
_questDriven ? "quest" : "order");
|
||||
}
|
||||
}
|
||||
|
||||
// Now decide exploration — target is resolved so we know if we're at the destination
|
||||
if (areaChanged)
|
||||
{
|
||||
var isAtTarget = string.Equals(_currentAreaId, _targetAreaId, StringComparison.OrdinalIgnoreCase);
|
||||
// Towns: skip exploration. Pass-through areas (quest-driven, not final target): skip.
|
||||
// Final target or non-quest areas: explore.
|
||||
_currentAreaExplored = node.IsTown || (_questDriven && !isAtTarget);
|
||||
|
||||
if (_currentAreaExplored)
|
||||
Log.Debug("Progression: skipping exploration (town={Town}, questPassThrough={Pass})",
|
||||
node.IsTown, _questDriven && !isAtTarget);
|
||||
}
|
||||
|
||||
// If we're in town and a quest targets this town, talk to NPC
|
||||
if (node.IsTown && HasQuestInThisArea(state))
|
||||
{
|
||||
UpdateTalkingToNpc(state, actions);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (_phase)
|
||||
{
|
||||
case Phase.Exploring:
|
||||
UpdateExploring(state, actions);
|
||||
break;
|
||||
case Phase.Looting:
|
||||
UpdateLooting(state, actions);
|
||||
break;
|
||||
case Phase.NavigatingToChest:
|
||||
UpdateNavigatingToChest(state, actions);
|
||||
break;
|
||||
case Phase.InteractingChest:
|
||||
UpdateInteractingChest(state, actions);
|
||||
break;
|
||||
case Phase.NavigatingToTransition:
|
||||
UpdateNavigatingToTransition(state, actions);
|
||||
break;
|
||||
case Phase.Interacting:
|
||||
UpdateInteracting(state, actions);
|
||||
break;
|
||||
case Phase.TalkingToNpc:
|
||||
UpdateTalkingToNpc(state, actions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateExploring(GameState state, ActionQueue actions)
|
||||
{
|
||||
// ── Check 1: Yield for elite combat ──
|
||||
const float EliteEngagementRange = 800f;
|
||||
foreach (var m in state.HostileMonsters)
|
||||
{
|
||||
if (m.Rarity >= MonsterRarity.Rare && m.DistanceToPlayer < EliteEngagementRange)
|
||||
{
|
||||
if (_nav.Mode != NavMode.Idle)
|
||||
{
|
||||
Log.Information("Progression: yielding for {Rarity} (dist={Dist:F0})", m.Rarity, m.DistanceToPlayer);
|
||||
_nav.Stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Quest chest interaction ──
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Path is null || !e.IsTargetable) continue;
|
||||
if (!e.Path.Contains("QuestChestBase", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (_interactedChests.Contains(e.Id)) continue;
|
||||
|
||||
_chestTargetEntityId = e.Id;
|
||||
_chestInteractStartMs = state.TimestampMs;
|
||||
Log.Information("Progression: found quest chest {Id} (dist={Dist:F0})", e.Id, e.DistanceToPlayer);
|
||||
|
||||
if (e.DistanceToPlayer < 150f)
|
||||
{
|
||||
_phase = Phase.InteractingChest;
|
||||
_nav.Stop();
|
||||
}
|
||||
else
|
||||
{
|
||||
_phase = Phase.NavigatingToChest;
|
||||
_nav.NavigateToEntity(e.Id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Check 3: Loot pickup when safe ──
|
||||
const float LootPickupRange = 600f;
|
||||
if (state.Danger <= DangerLevel.Low && state.NearbyLoot.Count > 0)
|
||||
{
|
||||
EntitySnapshot? nearestLoot = null;
|
||||
foreach (var item in state.NearbyLoot)
|
||||
{
|
||||
if (item.DistanceToPlayer > LootPickupRange) continue;
|
||||
if (nearestLoot is null || item.DistanceToPlayer < nearestLoot.DistanceToPlayer)
|
||||
nearestLoot = item;
|
||||
}
|
||||
if (nearestLoot is not null)
|
||||
{
|
||||
_lootTargetEntityId = nearestLoot.Id;
|
||||
_lootStartMs = state.TimestampMs;
|
||||
_phase = Phase.Looting;
|
||||
_nav.NavigateToEntity(nearestLoot.Id);
|
||||
Log.Debug("Progression: looting item {Id} (dist={Dist:F0})", nearestLoot.Id, nearestLoot.DistanceToPlayer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the current area hasn't been fully explored yet, keep exploring before
|
||||
// looking for the exit transition. This prevents the bot from entering a
|
||||
// dead-end area (like Mud Burrow) and immediately leaving without clearing it.
|
||||
if (!_currentAreaExplored)
|
||||
{
|
||||
if (_nav.IsExplorationComplete)
|
||||
{
|
||||
_currentAreaExplored = true;
|
||||
Log.Information("Progression: area exploration complete, now looking for transition to {Target}",
|
||||
_targetTransitionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_nav.Mode != NavMode.Exploring)
|
||||
_nav.Explore();
|
||||
return; // Don't scan for transitions yet
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle entity scanning to once per second
|
||||
var now = state.TimestampMs;
|
||||
if (now - _lastTransitionCheckMs < 1000) return;
|
||||
_lastTransitionCheckMs = now;
|
||||
|
||||
// Find the nearest targetable matching transition that isn't blacklisted
|
||||
EntitySnapshot? best = null;
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Category != EntityCategory.AreaTransition) continue;
|
||||
if (!e.IsTargetable) continue;
|
||||
if (!string.Equals(e.TransitionName, _targetTransitionName, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (_blacklistedTransitions.Contains(e.Id)) continue;
|
||||
|
||||
if (best is null || e.DistanceToPlayer < best.DistanceToPlayer)
|
||||
best = e;
|
||||
}
|
||||
|
||||
if (best is not null)
|
||||
{
|
||||
_targetTransitionEntityId = best.Id;
|
||||
Log.Information("Progression: found transition to {Target} (entity {Id}, dist={Dist:F0})",
|
||||
_targetTransitionName, best.Id, best.DistanceToPlayer);
|
||||
|
||||
if (best.DistanceToPlayer < 150f)
|
||||
{
|
||||
_phase = Phase.Interacting;
|
||||
_interactStartMs = state.TimestampMs;
|
||||
_nav.Stop();
|
||||
}
|
||||
else
|
||||
{
|
||||
_phase = Phase.NavigatingToTransition;
|
||||
_nav.NavigateToEntity(best.Id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No transition found yet — explore
|
||||
if (_nav.Mode != NavMode.Exploring)
|
||||
_nav.Explore();
|
||||
}
|
||||
|
||||
private void UpdateLooting(GameState state, ActionQueue actions)
|
||||
{
|
||||
// Danger spike — abandon loot, resume exploring
|
||||
if (state.Danger >= DangerLevel.Medium)
|
||||
{
|
||||
Log.Debug("Progression: danger spike, abandoning loot");
|
||||
_lootTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
return;
|
||||
}
|
||||
|
||||
// Elite appeared — yield to combat (will hit elite check in UpdateExploring next tick)
|
||||
const float EliteEngagementRange = 800f;
|
||||
foreach (var m in state.HostileMonsters)
|
||||
{
|
||||
if (m.Rarity >= MonsterRarity.Rare && m.DistanceToPlayer < EliteEngagementRange)
|
||||
{
|
||||
Log.Debug("Progression: elite appeared while looting, yielding");
|
||||
_lootTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
_nav.Stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout — entity unreachable
|
||||
if (state.TimestampMs - _lootStartMs > 5000)
|
||||
{
|
||||
Log.Debug("Progression: loot pickup timeout on {Id}", _lootTargetEntityId);
|
||||
_lootTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target loot entity
|
||||
EntitySnapshot? target = null;
|
||||
foreach (var item in state.NearbyLoot)
|
||||
{
|
||||
if (item.Id == _lootTargetEntityId) { target = item; break; }
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
// Target gone (picked up or despawned) — chain to next nearby loot
|
||||
const float LootPickupRange = 600f;
|
||||
EntitySnapshot? next = null;
|
||||
foreach (var item in state.NearbyLoot)
|
||||
{
|
||||
if (item.DistanceToPlayer > LootPickupRange) continue;
|
||||
if (next is null || item.DistanceToPlayer < next.DistanceToPlayer)
|
||||
next = item;
|
||||
}
|
||||
|
||||
if (next is not null)
|
||||
{
|
||||
_lootTargetEntityId = next.Id;
|
||||
_lootStartMs = state.TimestampMs;
|
||||
_nav.NavigateToEntity(next.Id);
|
||||
Log.Debug("Progression: chaining to next loot {Id} (dist={Dist:F0})", next.Id, next.DistanceToPlayer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_lootTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
Log.Debug("Progression: no more loot, resuming exploration");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Close enough — click to pick up
|
||||
if (target.DistanceToPlayer < 100f)
|
||||
{
|
||||
if (state.CameraMatrix.HasValue)
|
||||
{
|
||||
var screenPos = WorldToScreen.Project(target.Position, state.Player.Z, state.CameraMatrix.Value);
|
||||
if (screenPos.HasValue)
|
||||
actions.Submit(new ClickAction(SystemPriority.Navigation - 1, screenPos.Value, ClickType.Left));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise NavigateToEntity handles approach (already set when entering phase)
|
||||
}
|
||||
|
||||
private void UpdateNavigatingToChest(GameState state, ActionQueue actions)
|
||||
{
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Id != _chestTargetEntityId) continue;
|
||||
|
||||
if (e.DistanceToPlayer < 150f)
|
||||
{
|
||||
_phase = Phase.InteractingChest;
|
||||
_chestInteractStartMs = state.TimestampMs;
|
||||
_nav.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity still exists, keep navigating
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity disappeared — give up on this chest
|
||||
Log.Debug("Progression: quest chest entity lost, resuming exploration");
|
||||
_interactedChests.Add(_chestTargetEntityId);
|
||||
_chestTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
}
|
||||
|
||||
private void UpdateInteractingChest(GameState state, ActionQueue actions)
|
||||
{
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Id != _chestTargetEntityId) continue;
|
||||
|
||||
if (!e.IsTargetable)
|
||||
{
|
||||
// Chest opened — done
|
||||
Log.Information("Progression: quest chest {Id} opened", e.Id);
|
||||
_interactedChests.Add(_chestTargetEntityId);
|
||||
_chestTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.CameraMatrix.HasValue)
|
||||
{
|
||||
var screenPos = WorldToScreen.Project(e.Position, state.Player.Z, state.CameraMatrix.Value);
|
||||
if (screenPos.HasValue)
|
||||
{
|
||||
actions.Submit(new ClickAction(SystemPriority.Navigation - 1, screenPos.Value, ClickType.Left));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Can't project — walk closer
|
||||
if (e.DistanceToPlayer > 100f)
|
||||
{
|
||||
_phase = Phase.NavigatingToChest;
|
||||
_nav.NavigateToEntity(e.Id);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity gone or timeout
|
||||
if (state.TimestampMs - _chestInteractStartMs > 5000)
|
||||
{
|
||||
Log.Warning("Progression: quest chest interaction timeout on {Id}", _chestTargetEntityId);
|
||||
_interactedChests.Add(_chestTargetEntityId);
|
||||
_chestTargetEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateNavigatingToTransition(GameState state, ActionQueue actions)
|
||||
{
|
||||
// Check if the entity is still visible and close enough
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Id != _targetTransitionEntityId) continue;
|
||||
|
||||
if (e.DistanceToPlayer < 150f)
|
||||
{
|
||||
_phase = Phase.Interacting;
|
||||
_interactStartMs = state.TimestampMs;
|
||||
_nav.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity still exists, keep navigating
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity disappeared from view — go back to exploring
|
||||
Log.Debug("Progression: transition entity lost, returning to explore");
|
||||
_targetTransitionEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
_nav.Explore();
|
||||
}
|
||||
|
||||
private void UpdateInteracting(GameState state, ActionQueue actions)
|
||||
{
|
||||
// Project entity to screen and click
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Id != _targetTransitionEntityId) continue;
|
||||
|
||||
if (state.CameraMatrix.HasValue)
|
||||
{
|
||||
var screenPos = WorldToScreen.Project(e.Position, state.Player.Z, state.CameraMatrix.Value);
|
||||
if (screenPos.HasValue)
|
||||
{
|
||||
actions.Submit(new ClickAction(SystemPriority.Navigation - 1, screenPos.Value, ClickType.Left));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Can't project — walk closer
|
||||
if (e.DistanceToPlayer > 100f)
|
||||
{
|
||||
_phase = Phase.NavigatingToTransition;
|
||||
_nav.NavigateToEntity(e.Id);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Entity gone or interaction failed — blacklist this transition and try another
|
||||
if (state.TimestampMs - _interactStartMs > 5000)
|
||||
{
|
||||
Log.Warning("Progression: interaction timeout on entity {Id}, blacklisting and trying next",
|
||||
_targetTransitionEntityId);
|
||||
_blacklistedTransitions.Add(_targetTransitionEntityId);
|
||||
_targetTransitionEntityId = 0;
|
||||
_phase = Phase.Exploring;
|
||||
_nav.Explore();
|
||||
}
|
||||
}
|
||||
|
||||
private string? FindQuestTarget(GameState state)
|
||||
{
|
||||
var candidates = state.Quests
|
||||
.Where(q => q.TargetAreas is { Count: > 0 })
|
||||
.SelectMany(q => q.TargetAreas!.Select(a => new { Quest = q, Area = a }))
|
||||
.Where(x => x.Area.Id is not null
|
||||
&& !string.Equals(x.Area.Id, _currentAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0) return null;
|
||||
|
||||
// Prefer tracked quests, then lowest act, then quests with shortest path
|
||||
var best = candidates
|
||||
.OrderByDescending(x => x.Quest.IsTracked)
|
||||
.ThenBy(x => x.Area.Act)
|
||||
.ThenBy(x => x.Quest.PathToTarget?.Count ?? 999)
|
||||
.First();
|
||||
|
||||
return best.Area.Id;
|
||||
}
|
||||
|
||||
private bool HasQuestInThisArea(GameState state)
|
||||
{
|
||||
return state.Quests.Any(q => q.TargetAreas?.Any(a =>
|
||||
string.Equals(a.Id, _currentAreaId, StringComparison.OrdinalIgnoreCase)) == true);
|
||||
}
|
||||
|
||||
private void UpdateTalkingToNpc(GameState state, ActionQueue actions)
|
||||
{
|
||||
// Find nearest targetable NPC
|
||||
EntitySnapshot? npc = null;
|
||||
foreach (var e in state.Entities)
|
||||
{
|
||||
if (e.Category != EntityCategory.Npc || !e.IsTargetable) continue;
|
||||
if (npc is null || e.DistanceToPlayer < npc.DistanceToPlayer)
|
||||
npc = e;
|
||||
}
|
||||
|
||||
if (npc is null) return;
|
||||
|
||||
if (npc.DistanceToPlayer < 150f)
|
||||
{
|
||||
if (state.CameraMatrix.HasValue)
|
||||
{
|
||||
var screenPos = WorldToScreen.Project(npc.Position, state.Player.Z, state.CameraMatrix.Value);
|
||||
if (screenPos.HasValue)
|
||||
actions.Submit(new ClickAction(Priority, screenPos.Value, ClickType.Left));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_nav.NavigateToEntity(npc.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/Nexus.Systems/BotTick.cs
Normal file
46
src/Nexus.Systems/BotTick.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using Nexus.Core;
|
||||
using Nexus.Data;
|
||||
using Nexus.Pathfinding;
|
||||
|
||||
namespace Nexus.Systems;
|
||||
|
||||
public static class BotTick
|
||||
{
|
||||
public static List<BotAction> Run(
|
||||
GameState state,
|
||||
List<ISystem> systems,
|
||||
ActionQueue actionQueue,
|
||||
MovementBlender movementBlender,
|
||||
NavigationController nav,
|
||||
BotConfig config)
|
||||
{
|
||||
GameStateEnricher.Enrich(state);
|
||||
|
||||
actionQueue.Clear();
|
||||
movementBlender.Clear();
|
||||
nav.Update(state);
|
||||
|
||||
foreach (var sys in systems)
|
||||
if (sys.IsEnabled)
|
||||
sys.Update(state, actionQueue, movementBlender);
|
||||
|
||||
// Wall repulsion — push away from nearby walls to prevent getting stuck
|
||||
if (state.Terrain is { } terrain && state.Player.HasPosition)
|
||||
{
|
||||
var wallPush = TerrainQuery.ComputeWallRepulsion(terrain, state.Player.Position, config.WorldToGrid);
|
||||
if (wallPush.LengthSquared() > 0.0001f)
|
||||
movementBlender.Submit(new MovementIntent(2, wallPush * 0.6f, 0.3f, "WallPush"));
|
||||
}
|
||||
|
||||
if (nav.DesiredDirection.HasValue)
|
||||
movementBlender.Submit(new MovementIntent(3, nav.DesiredDirection.Value, 0f, "Navigation"));
|
||||
|
||||
movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid);
|
||||
var resolved = actionQueue.Resolve();
|
||||
|
||||
if (movementBlender.IsUrgentFlee)
|
||||
resolved.RemoveAll(a => a is CastAction);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ public class CombatSystem : ISystem
|
|||
_heldSlots.Clear();
|
||||
}
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
if (state.CameraMatrix is not { } camera)
|
||||
return;
|
||||
|
|
@ -92,16 +92,19 @@ public class CombatSystem : ISystem
|
|||
_lastAreaHash = state.AreaHash;
|
||||
}
|
||||
|
||||
// Global cooldown: don't cast if we recently cast any skill
|
||||
if (now - _lastCastGlobal < _globalCooldownMs)
|
||||
// Always submit orbit/herd intent when enemies are nearby — provides continuous
|
||||
// circular movement that blends with other systems. Override is stronger during GCD
|
||||
// (no cast competing) and weaker while casting (cast targeting takes priority).
|
||||
var inGcd = now - _lastCastGlobal < _globalCooldownMs;
|
||||
if (_kiteEnabled && state.NearestEnemies.Count > 0)
|
||||
{
|
||||
// Orbit-herd during cooldown window (after cast animation delay)
|
||||
if (_kiteEnabled && now - _lastCastGlobal >= _kiteDelayMs
|
||||
&& state.NearestEnemies.Count > 0)
|
||||
{
|
||||
TryHerd(state, actions);
|
||||
}
|
||||
var herdOverride = inGcd ? 0.4f : 0.2f;
|
||||
TryHerd(state, movement, herdOverride);
|
||||
}
|
||||
|
||||
// Global cooldown: don't cast if we recently cast any skill
|
||||
if (inGcd)
|
||||
{
|
||||
// Still need to handle MaintainPressed releases
|
||||
UpdateHeldKeys(state, camera, playerZ, actions);
|
||||
return;
|
||||
|
|
@ -205,7 +208,7 @@ public class CombatSystem : ISystem
|
|||
/// Orbit-herding: move perpendicular to enemy centroid so scattered mobs converge
|
||||
/// into a tight cluster for AOE. Maintains ideal distance via radial bias.
|
||||
/// </summary>
|
||||
private void TryHerd(GameState state, ActionQueue actions)
|
||||
private void TryHerd(GameState state, MovementBlender movement, float overrideFactor)
|
||||
{
|
||||
var playerPos = state.Player.Position;
|
||||
|
||||
|
|
@ -241,26 +244,8 @@ public class CombatSystem : ISystem
|
|||
|
||||
var dir = Vector2.Normalize(perp + centroidDir * radialBias);
|
||||
|
||||
// Validate against terrain — flip orbit direction on wall hit
|
||||
if (state.Terrain is { } terrain)
|
||||
{
|
||||
var validated = TerrainQuery.FindWalkableDirection(terrain, playerPos, dir, _worldToGrid);
|
||||
|
||||
// If terrain forced a significantly different direction, flip orbit
|
||||
if (Vector2.Dot(validated, dir) < 0.5f)
|
||||
{
|
||||
_orbitSign *= -1;
|
||||
perp = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
|
||||
dir = Vector2.Normalize(perp + centroidDir * radialBias);
|
||||
dir = TerrainQuery.FindWalkableDirection(terrain, playerPos, dir, _worldToGrid);
|
||||
}
|
||||
else
|
||||
{
|
||||
dir = validated;
|
||||
}
|
||||
}
|
||||
|
||||
actions.Submit(new MoveAction(SystemPriority.Combat, dir));
|
||||
// Layer 4: orbit/herd with variable override (stronger during GCD, weaker while casting)
|
||||
movement.Submit(new MovementIntent(4, dir, overrideFactor, "Herd"));
|
||||
}
|
||||
|
||||
private void UpdateHeldKeys(GameState state, Matrix4x4 camera, float playerZ, ActionQueue actions)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ public class LootSystem : ISystem
|
|||
public string Name => "Loot";
|
||||
public bool IsEnabled { get; set; } = false;
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
// STUB: loot detection and pickup logic
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ using Nexus.Core;
|
|||
namespace Nexus.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Force-based avoidance: applies inverse-square repulsion from hostile monsters
|
||||
/// within safe distance. Emits a MoveAction with the escape direction.
|
||||
/// Proximity-aware positioning: when enemies are within safe distance, applies a blend of
|
||||
/// radial push (away from centroid) and tangential orbit (perpendicular to centroid).
|
||||
/// The tangential component makes the bot circle around enemies instead of running backward.
|
||||
/// Closer enemies produce stronger push-out, but always with orbit mixed in.
|
||||
/// </summary>
|
||||
public class MovementSystem : ISystem
|
||||
{
|
||||
|
|
@ -14,41 +16,74 @@ public class MovementSystem : ISystem
|
|||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public float SafeDistance { get; set; } = 400f;
|
||||
public float RepulsionWeight { get; set; } = 1.5f;
|
||||
public float RepulsionWeight { get; set; } = 0.5f;
|
||||
|
||||
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
|
||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
/// <summary>Minimum distance before radial push kicks in hard.</summary>
|
||||
public float MinComfortDistance { get; set; } = 150f;
|
||||
|
||||
private int _orbitSign = 1;
|
||||
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
if (!state.Player.HasPosition) return;
|
||||
if (state.HostileMonsters.Count == 0) return;
|
||||
|
||||
var playerPos = state.Player.Position;
|
||||
var repulsion = Vector2.Zero;
|
||||
|
||||
// Compute weighted centroid and closest distance of nearby hostiles
|
||||
var centroid = Vector2.Zero;
|
||||
var count = 0;
|
||||
var closestDist = float.MaxValue;
|
||||
|
||||
foreach (var monster in state.HostileMonsters)
|
||||
{
|
||||
if (!monster.IsAlive) continue;
|
||||
if (monster.DistanceToPlayer > SafeDistance) continue;
|
||||
|
||||
var delta = playerPos - monster.Position;
|
||||
var distSq = delta.LengthSquared();
|
||||
if (distSq < 1f) distSq = 1f;
|
||||
|
||||
// Inverse-square repulsion: stronger when closer
|
||||
var force = delta / distSq * RepulsionWeight;
|
||||
repulsion += force;
|
||||
centroid += monster.Position;
|
||||
count++;
|
||||
if (monster.DistanceToPlayer < closestDist)
|
||||
closestDist = monster.DistanceToPlayer;
|
||||
}
|
||||
|
||||
if (repulsion.LengthSquared() < 0.0001f) return;
|
||||
if (count == 0) return;
|
||||
centroid /= count;
|
||||
|
||||
var direction = Vector2.Normalize(repulsion);
|
||||
var toCentroid = centroid - playerPos;
|
||||
var dist = toCentroid.Length();
|
||||
if (dist < 1f) return;
|
||||
|
||||
// Validate repulsion direction against terrain — avoid walking into walls
|
||||
if (state.Terrain is { } terrain)
|
||||
direction = TerrainQuery.FindWalkableDirection(terrain, state.Player.Position, direction, WorldToGrid);
|
||||
var centroidDir = toCentroid / dist;
|
||||
|
||||
actions.Enqueue(new MoveAction(Priority, direction));
|
||||
// Tangential component — perpendicular to centroid direction (orbit)
|
||||
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
|
||||
|
||||
// Radial component — push away from centroid, strength based on proximity
|
||||
// Close < MinComfort: strong push out
|
||||
// MinComfort..SafeDistance*0.5: gentle push out
|
||||
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away
|
||||
float radialStrength;
|
||||
if (closestDist < MinComfortDistance)
|
||||
radialStrength = -0.6f; // too close — push outward
|
||||
else if (closestDist < SafeDistance * 0.5f)
|
||||
radialStrength = -0.3f; // somewhat close — moderate push outward
|
||||
else if (closestDist > SafeDistance * 0.7f)
|
||||
radialStrength = 0.4f; // at edge — pull inward to maintain engagement
|
||||
else
|
||||
radialStrength = 0f; // sweet spot — pure orbit
|
||||
|
||||
// Blend: mostly tangent (circling) with radial bias
|
||||
var result = tangent + centroidDir * radialStrength;
|
||||
|
||||
if (result.LengthSquared() < 0.0001f) return;
|
||||
|
||||
// Override: attenuate navigation (layer 3) when actively orbiting enemies.
|
||||
// Without this, navigation at full weight pulls the bot past enemies.
|
||||
float orbitOverride = closestDist < SafeDistance * 0.7f ? 0.8f : 0.5f;
|
||||
|
||||
movement.Submit(new MovementIntent(2, Vector2.Normalize(result) * RepulsionWeight, orbitOverride, "Orbit"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ public class NavigationSystem : ISystem
|
|||
/// </summary>
|
||||
public Vector2? ExternalDirection { get; set; }
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
if (ExternalDirection.HasValue)
|
||||
actions.Submit(new MoveAction(Priority, ExternalDirection.Value));
|
||||
movement.Submit(new MovementIntent(3, ExternalDirection.Value, 0f, "Navigation"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nexus.Core\Nexus.Core.csproj" />
|
||||
<ProjectReference Include="..\Nexus.Data\Nexus.Data.csproj" />
|
||||
<ProjectReference Include="..\Nexus.Pathfinding\Nexus.Pathfinding.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public class ResourceSystem : ISystem
|
|||
_lastManaFlaskMs = 0;
|
||||
}
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
var player = state.Player;
|
||||
if (player.LifeTotal == 0) return;
|
||||
|
|
|
|||
35
src/Nexus.Systems/SystemFactory.cs
Normal file
35
src/Nexus.Systems/SystemFactory.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using Nexus.Core;
|
||||
using Nexus.Data;
|
||||
using Nexus.Pathfinding;
|
||||
|
||||
namespace Nexus.Systems;
|
||||
|
||||
public static class SystemFactory
|
||||
{
|
||||
public static List<ISystem> CreateSystems(BotConfig config, NavigationController nav,
|
||||
bool includeAreaProgression = false)
|
||||
{
|
||||
var systems = new List<ISystem>();
|
||||
|
||||
if (includeAreaProgression)
|
||||
systems.Add(new AreaProgressionSystem(config, nav, AreaGraph.Load()));
|
||||
|
||||
systems.Add(new ThreatSystem { WorldToGrid = config.WorldToGrid });
|
||||
systems.Add(new MovementSystem
|
||||
{
|
||||
SafeDistance = config.SafeDistance,
|
||||
RepulsionWeight = config.RepulsionWeight,
|
||||
WorldToGrid = config.WorldToGrid,
|
||||
});
|
||||
systems.Add(new NavigationSystem
|
||||
{
|
||||
WorldToGrid = config.WorldToGrid,
|
||||
WaypointReachedDistance = config.WaypointReachedDistance,
|
||||
});
|
||||
systems.Add(new CombatSystem(config));
|
||||
systems.Add(new ResourceSystem(config));
|
||||
systems.Add(new LootSystem());
|
||||
|
||||
return systems;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,9 @@ using Serilog;
|
|||
namespace Nexus.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency threat response. Runs first (priority 50).
|
||||
/// On Critical danger: urgent flee (blocks casting via priority ≤ 10).
|
||||
/// On High danger: flee toward safety but allow casting.
|
||||
/// Medium and below: no action (MovementSystem handles soft avoidance).
|
||||
/// Emergency-only threat response. Runs first (priority 50).
|
||||
/// Only fires on Critical danger (low HP or overwhelming threat score).
|
||||
/// Normal combat (High/Medium) is handled by MovementSystem orbiting + CombatSystem herding.
|
||||
/// </summary>
|
||||
public class ThreatSystem : ISystem
|
||||
{
|
||||
|
|
@ -16,18 +15,15 @@ public class ThreatSystem : ISystem
|
|||
public string Name => "Threat";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>Priority ≤ 10 blocks casting in ActionQueue.Resolve — pure flee.</summary>
|
||||
private const int UrgentFleePriority = 5;
|
||||
|
||||
/// <summary>If closest enemy is within this range, escalate to urgent flee.</summary>
|
||||
public float PointBlankRange { get; set; } = 150f;
|
||||
/// <summary>If closest enemy is within this range AND danger is Critical, escalate to urgent flee.</summary>
|
||||
public float PointBlankRange { get; set; } = 120f;
|
||||
|
||||
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
|
||||
public float WorldToGrid { get; set; } = 23f / 250f;
|
||||
|
||||
private DangerLevel _prevDanger = DangerLevel.Safe;
|
||||
|
||||
public void Update(GameState state, ActionQueue actions)
|
||||
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||
{
|
||||
if (!state.Player.HasPosition) return;
|
||||
|
||||
|
|
@ -45,32 +41,28 @@ public class ThreatSystem : ISystem
|
|||
_prevDanger = danger;
|
||||
}
|
||||
|
||||
if (danger <= DangerLevel.Medium) return;
|
||||
// Only respond to Critical danger — High is normal combat, handled by orbit/herd
|
||||
if (danger != DangerLevel.Critical) return;
|
||||
if (threats.TotalHostiles == 0) return;
|
||||
|
||||
// Compute flee direction: away from threat centroid
|
||||
var fleeDir = state.Player.Position - threats.ThreatCentroid;
|
||||
if (fleeDir.LengthSquared() < 0.0001f)
|
||||
fleeDir = Vector2.UnitY; // fallback if at centroid
|
||||
fleeDir = Vector2.UnitY;
|
||||
|
||||
fleeDir = Vector2.Normalize(fleeDir);
|
||||
|
||||
// Validate flee direction against terrain — avoid walking into walls
|
||||
if (state.Terrain is { } terrain)
|
||||
fleeDir = TerrainQuery.FindWalkableDirection(terrain, state.Player.Position, fleeDir, WorldToGrid);
|
||||
|
||||
// Point-blank override: if closest enemy is very close, escalate to urgent
|
||||
var isPointBlank = threats.ClosestDistance < PointBlankRange;
|
||||
|
||||
if (danger == DangerLevel.Critical || isPointBlank)
|
||||
if (isPointBlank)
|
||||
{
|
||||
// Urgent flee — blocks casting (priority ≤ 10)
|
||||
actions.Submit(new MoveAction(UrgentFleePriority, fleeDir));
|
||||
// Layer 0: total override — pure flee, blocks casting
|
||||
movement.Submit(new MovementIntent(0, fleeDir, 1.0f, "Threat"));
|
||||
}
|
||||
else // High
|
||||
else
|
||||
{
|
||||
// Flee but allow casting alongside
|
||||
actions.Submit(new MoveAction(Priority, fleeDir));
|
||||
// Layer 1: strong flee but allow some nav/orbit bleed-through
|
||||
movement.Submit(new MovementIntent(1, fleeDir, 0.6f, "Threat"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue