simulation done

This commit is contained in:
Boki 2026-03-07 09:53:57 -05:00
parent 0e7de0a5f3
commit 05bbcb244f
55 changed files with 4367 additions and 756 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View file

@ -9,7 +9,7 @@ Size=219,425
Collapsed=0 Collapsed=0
[Window][Simulator] [Window][Simulator]
Pos=11,220 Pos=341,232
Size=1200,681 Size=1200,681
Collapsed=0 Collapsed=0

View 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);
}
}

View file

@ -33,12 +33,11 @@ public class ActionQueue
} }
/// <summary> /// <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 /// 1. FlaskActions always pass through
/// 2. Get highest-priority MoveAction + CastAction /// 2. Get highest-priority CastAction
/// 3. Urgent move (priority ≤ 10) → include move, BLOCK cast (flee) /// 3. All other actions pass through
/// 4. Normal → include both cast + move
/// 5. All other actions pass through
/// </summary> /// </summary>
public List<BotAction> Resolve() public List<BotAction> Resolve()
{ {
@ -51,21 +50,9 @@ public class ActionQueue
resolved.Add(action); resolved.Add(action);
} }
var bestMove = GetHighestPriority<MoveAction>();
var bestCast = GetHighestPriority<CastAction>(); var bestCast = GetHighestPriority<CastAction>();
if (bestCast is not null)
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)
{
resolved.Add(bestCast); resolved.Add(bestCast);
}
// Pass through everything else (Key, Click, Chat, Wait) except types already handled // Pass through everything else (Key, Click, Chat, Wait) except types already handled
foreach (var action in _actions) foreach (var action in _actions)

View file

@ -7,8 +7,8 @@ public class BotConfig
public int MemoryPollRateHz { get; set; } = 30; public int MemoryPollRateHz { get; set; } = 30;
// Movement // Movement
public float SafeDistance { get; set; } = 400f; public float SafeDistance { get; set; } = 500f;
public float RepulsionWeight { get; set; } = 1.5f; public float RepulsionWeight { get; set; } = 0.5f;
public float WaypointReachedDistance { get; set; } = 80f; public float WaypointReachedDistance { get; set; } = 80f;
// Navigation // Navigation

View file

@ -5,5 +5,5 @@ public interface ISystem
int Priority { get; } int Priority { get; }
string Name { get; } string Name { get; }
bool IsEnabled { get; set; } bool IsEnabled { get; set; }
void Update(GameState state, ActionQueue actions); void Update(GameState state, ActionQueue actions, MovementBlender movement);
} }

View file

@ -8,7 +8,7 @@ public static class Logging
public static void Setup() public static void Setup()
{ {
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information() .MinimumLevel.Debug()
.WriteTo.Console( .WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}") outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/poe2trade-.log", .WriteTo.File("logs/poe2trade-.log",

View 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;
}
}

View 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);
}
}

View 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;
}

View file

@ -46,7 +46,7 @@ public static class TerrainQuery
/// </summary> /// </summary>
public static Vector2 FindWalkableDirection( public static Vector2 FindWalkableDirection(
WalkabilitySnapshot terrain, Vector2 playerPos, Vector2 desiredDir, float worldToGrid, WalkabilitySnapshot terrain, Vector2 playerPos, Vector2 desiredDir, float worldToGrid,
float probeDistance = 200f) float probeDistance = 60f)
{ {
if (IsDirectionClear(terrain, playerPos, desiredDir, worldToGrid, probeDistance)) if (IsDirectionClear(terrain, playerPos, desiredDir, worldToGrid, probeDistance))
return desiredDir; return desiredDir;
@ -67,15 +67,59 @@ public static class TerrainQuery
private static bool IsDirectionClear( private static bool IsDirectionClear(
WalkabilitySnapshot terrain, Vector2 origin, Vector2 dir, float worldToGrid, float distance) 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 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 mx = (int)(midpoint.X * worldToGrid);
int my = (int)(midpoint.Y * worldToGrid); int my = (int)(midpoint.Y * worldToGrid);
int ex = (int)(endpoint.X * worldToGrid); int ex = (int)(endpoint.X * worldToGrid);
int ey = (int)(endpoint.Y * 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) private static Vector2 Rotate(Vector2 v, float degrees)

View file

@ -6,10 +6,23 @@ public record WalkabilitySnapshot
public int Height { get; init; } public int Height { get; init; }
public byte[] Data { 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 false;
return Data[y * Width + x] != 0; return Data[ly * Width + lx] != 0;
} }
} }

View file

@ -63,26 +63,50 @@ public static class GameStateEnricher
} }
/// <summary> /// <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. /// Close enemies count more, rares/uniques escalate significantly.
/// Hysteresis: de-escalation requires a larger margin than escalation to prevent oscillation.
/// </summary> /// </summary>
private static DangerLevel _previousDanger = DangerLevel.Safe;
private static float _smoothedThreatScore;
private static long _lastEscalationMs;
private static DangerLevel ComputeDangerLevel(GameState state) private static DangerLevel ComputeDangerLevel(GameState state)
{ {
if (state.Player.LifePercent < 30f) return DangerLevel.Critical; // Effective HP = life + ES combined
if (state.Player.LifePercent < 50f) return DangerLevel.High; 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; var threatScore = 0f;
foreach (var m in state.HostileMonsters) foreach (var m in state.HostileMonsters)
{ {
var d = m.DistanceToPlayer; var d = m.DistanceToPlayer;
if (d > 800f) continue; if (d > 800f) continue;
// Distance weight: closer = more dangerous // Smooth distance weight: linear falloff from 3.0 at d=0 to 0.5 at d=800
float distWeight; var distWeight = 3f - 2.5f * (d / 800f);
if (d < 200f) distWeight = 3f;
else if (d < 400f) distWeight = 2f;
else distWeight = 1f;
// Rarity multiplier // Rarity multiplier
var rarityMul = m.Rarity switch var rarityMul = m.Rarity switch
@ -96,10 +120,48 @@ public static class GameStateEnricher
threatScore += distWeight * rarityMul; threatScore += distWeight * rarityMul;
} }
if (threatScore >= 15f) return DangerLevel.Critical; // EMA smoothing — prevents single-frame score spikes from causing oscillation.
if (threatScore >= 8f) return DangerLevel.High; // Snap upward (escalation is instant), smooth downward (de-escalation is gradual).
if (threatScore >= 4f) return DangerLevel.Medium; const float deescalationAlpha = 0.08f;
if (threatScore > 0f) return DangerLevel.Low; if (threatScore >= _smoothedThreatScore)
return DangerLevel.Safe; _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;
} }
} }

View file

@ -73,10 +73,12 @@ public sealed partial class InterceptionInputController : IInputController, IDis
if (_humanizer is not null) if (_humanizer is not null)
{ {
if (_humanizer.ShouldThrottle()) return; if (_humanizer.ShouldThrottle()) return;
holdMs = _humanizer.GaussianDelay(holdMs);
_humanizer.RecordAction(); _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) public void MouseMoveTo(int x, int y)
@ -150,7 +152,10 @@ public sealed partial class InterceptionInputController : IInputController, IDis
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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) public void RightClick(int x, int y)
@ -164,7 +169,10 @@ public sealed partial class InterceptionInputController : IInputController, IDis
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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) public void MiddleClick(int x, int y)
@ -178,7 +186,24 @@ public sealed partial class InterceptionInputController : IInputController, IDis
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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() public void LeftDown()

View file

@ -1,56 +1,44 @@
namespace Nexus.Input; // ScanCodes has moved to Nexus.Core. This file re-exports for backward compatibility.
// ReSharper disable once CheckNamespace
/// <summary> namespace Nexus.Input
/// Hardware scan codes for keyboard input via Interception driver.
/// </summary>
public static class ScanCodes
{ {
// WASD movement /// <summary>Re-exports Nexus.Core.ScanCodes for backward compatibility.</summary>
public const ushort W = 0x11; public static class ScanCodes
public const ushort A = 0x1E; {
public const ushort S = 0x1F; public const ushort W = Core.ScanCodes.W;
public const ushort D = 0x20; public const ushort A = Core.ScanCodes.A;
public const ushort S = Core.ScanCodes.S;
// Number row public const ushort D = Core.ScanCodes.D;
public const ushort Key1 = 0x02; public const ushort Key1 = Core.ScanCodes.Key1;
public const ushort Key2 = 0x03; public const ushort Key2 = Core.ScanCodes.Key2;
public const ushort Key3 = 0x04; public const ushort Key3 = Core.ScanCodes.Key3;
public const ushort Key4 = 0x05; public const ushort Key4 = Core.ScanCodes.Key4;
public const ushort Key5 = 0x06; public const ushort Key5 = Core.ScanCodes.Key5;
public const ushort Key6 = 0x07; public const ushort Key6 = Core.ScanCodes.Key6;
public const ushort Key7 = 0x08; public const ushort Key7 = Core.ScanCodes.Key7;
public const ushort Key8 = 0x09; public const ushort Key8 = Core.ScanCodes.Key8;
public const ushort Key9 = 0x0A; public const ushort Key9 = Core.ScanCodes.Key9;
public const ushort Key0 = 0x0B; public const ushort Key0 = Core.ScanCodes.Key0;
public const ushort LShift = Core.ScanCodes.LShift;
// Modifiers public const ushort RShift = Core.ScanCodes.RShift;
public const ushort LShift = 0x2A; public const ushort LCtrl = Core.ScanCodes.LCtrl;
public const ushort RShift = 0x36; public const ushort LAlt = Core.ScanCodes.LAlt;
public const ushort LCtrl = 0x1D; public const ushort Escape = Core.ScanCodes.Escape;
public const ushort LAlt = 0x38; public const ushort Tab = Core.ScanCodes.Tab;
public const ushort Space = Core.ScanCodes.Space;
// Common keys public const ushort Enter = Core.ScanCodes.Enter;
public const ushort Escape = 0x01; public const ushort Backspace = Core.ScanCodes.Backspace;
public const ushort Tab = 0x0F; public const ushort F1 = Core.ScanCodes.F1;
public const ushort Space = 0x39; public const ushort F2 = Core.ScanCodes.F2;
public const ushort Enter = 0x1C; public const ushort F3 = Core.ScanCodes.F3;
public const ushort Backspace = 0x0E; public const ushort F4 = Core.ScanCodes.F4;
public const ushort F5 = Core.ScanCodes.F5;
// Function keys public const ushort Q = Core.ScanCodes.Q;
public const ushort F1 = 0x3B; public const ushort E = Core.ScanCodes.E;
public const ushort F2 = 0x3C; public const ushort R = Core.ScanCodes.R;
public const ushort F3 = 0x3D; public const ushort T = Core.ScanCodes.T;
public const ushort F4 = 0x3E; public const ushort I = Core.ScanCodes.I;
public const ushort F5 = 0x3F; public const ushort F = Core.ScanCodes.F;
public const ushort Slash = Core.ScanCodes.Slash;
// 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;
} }

View file

@ -46,13 +46,15 @@ public sealed partial class SendInputController : IInputController
if (_humanizer is not null) if (_humanizer is not null)
{ {
if (_humanizer.ShouldThrottle()) return; if (_humanizer.ShouldThrottle()) return;
holdMs = _humanizer.GaussianDelay(holdMs);
_humanizer.RecordAction(); _humanizer.RecordAction();
} }
var hold = HoldMs();
Log.Information("[Key] 0x{ScanCode:X2} DOWN (hold={HoldMs}ms)", scanCode, hold);
KeyDown(scanCode); KeyDown(scanCode);
Thread.Sleep(holdMs); Thread.Sleep(hold);
KeyUp(scanCode); KeyUp(scanCode);
Log.Information("[Key] 0x{ScanCode:X2} UP", scanCode);
} }
// ── Mouse movement ── // ── Mouse movement ──
@ -113,7 +115,7 @@ public sealed partial class SendInputController : IInputController
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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) public void RightClick(int x, int y)
@ -127,7 +129,7 @@ public sealed partial class SendInputController : IInputController
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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) public void MiddleClick(int x, int y)
@ -141,7 +143,7 @@ public sealed partial class SendInputController : IInputController
} }
SmoothMoveTo(x, y); SmoothMoveTo(x, y);
Thread.Sleep(_humanizer is not null ? _humanizer.GaussianDelay(10) : 10); 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() public void LeftDown()
@ -170,13 +172,38 @@ public sealed partial class SendInputController : IInputController
// ── Private helpers ── // ── 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) 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 down = MakeMouseInput(downFlag);
var up = MakeMouseInput(upFlag); var up = MakeMouseInput(upFlag);
SendInput(1, [down], INPUT_SIZE); SendInput(1, [down], INPUT_SIZE);
Thread.Sleep(holdMs); Thread.Sleep(holdMs);
SendInput(1, [up], INPUT_SIZE); SendInput(1, [up], INPUT_SIZE);
Log.Information("[Click] {Button} UP", name);
} }
private static double EaseInOutQuad(double t) => private static double EaseInOutQuad(double t) =>

View file

@ -28,16 +28,20 @@ public sealed class NavigationController
// Explored grid — tracks which terrain cells the player has visited // Explored grid — tracks which terrain cells the player has visited
private bool[]? _exploredGrid; private bool[]? _exploredGrid;
private int _exploredWidth, _exploredHeight; private int _exploredWidth, _exploredHeight;
private int _exploredOffsetX, _exploredOffsetY;
private const int ExploreMarkRadius = 150; // grid cells (~1630 world units) private const int ExploreMarkRadius = 150; // grid cells (~1630 world units)
// Stuck detection: rolling window of recent positions // Stuck detection: rolling window of recent positions
private readonly Queue<Vector2> _positionHistory = new(); private readonly Queue<Vector2> _positionHistory = new();
private const int StuckWindowSize = 10; private const int StuckWindowSize = 120; // ~2 seconds at 60Hz
private const float StuckThreshold = 5f; 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 // Path failure cooldown — don't retry immediately when pathfinding fails
private long _pathFailCooldownMs; 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 NavMode Mode { get; private set; } = NavMode.Idle;
public Vector2? DesiredDirection { get; private set; } public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path; public IReadOnlyList<Vector2>? CurrentPath => _path;
@ -45,6 +49,8 @@ public sealed class NavigationController
public bool[]? ExploredGrid => _exploredGrid; public bool[]? ExploredGrid => _exploredGrid;
public int ExploredWidth => _exploredWidth; public int ExploredWidth => _exploredWidth;
public int ExploredHeight => _exploredHeight; public int ExploredHeight => _exploredHeight;
public int ExploredOffsetX => _exploredOffsetX;
public int ExploredOffsetY => _exploredOffsetY;
/// <summary> /// <summary>
/// True when BFS exploration finds no more unexplored walkable cells in the current area. /// True when BFS exploration finds no more unexplored walkable cells in the current area.
@ -136,39 +142,14 @@ public sealed class NavigationController
IsExplorationComplete = false; IsExplorationComplete = false;
} }
// Allocate explored grid on first tick with terrain, after area change, // Allocate or resize explored grid to match terrain (preserving old data on expansion)
// or when terrain dimensions change (prevents bounds mismatch crash)
var terrain = state.Terrain; var terrain = state.Terrain;
if (terrain is not null && if (terrain is not null)
(_exploredGrid is null || terrain.Width != _exploredWidth || terrain.Height != _exploredHeight)) EnsureExploredGrid(terrain);
{
_exploredWidth = terrain.Width;
_exploredHeight = terrain.Height;
_exploredGrid = new bool[_exploredWidth * _exploredHeight];
}
// Mark cells near player as explored // Mark cells near player as explored
if (_exploredGrid is not null && terrain is not null) if (_exploredGrid is not null && terrain is not null)
{ MarkExplored(playerPos);
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;
}
}
}
// Resolve goal based on mode // Resolve goal based on mode
var goal = ResolveGoal(state); var goal = ResolveGoal(state);
@ -211,8 +192,11 @@ public sealed class NavigationController
if (_positionHistory.Count > StuckWindowSize) if (_positionHistory.Count > StuckWindowSize)
_positionHistory.Dequeue(); _positionHistory.Dequeue();
if (_stuckGraceTicks > 0)
_stuckGraceTicks--;
var isStuck = false; 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(); var oldest = _positionHistory.Peek();
if (Vector2.Distance(oldest, playerPos) < StuckThreshold) if (Vector2.Distance(oldest, playerPos) < StuckThreshold)
@ -221,10 +205,16 @@ public sealed class NavigationController
if (Mode == NavMode.Exploring) if (Mode == NavMode.Exploring)
{ {
Log.Information("NavigationController: stuck while exploring, picking new target"); 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; _goalPosition = null;
_path = null; _path = null;
_waypointIndex = 0; _waypointIndex = 0;
_positionHistory.Clear(); _positionHistory.Clear();
_stuckGraceTicks = 120; // 2 seconds grace for next target
return; return;
} }
Log.Debug("NavigationController: stuck detected, repathing"); Log.Debug("NavigationController: stuck detected, repathing");
@ -264,7 +254,7 @@ public sealed class NavigationController
_path = Mode == NavMode.Exploring _path = Mode == NavMode.Exploring
? PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid, ? PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid,
_exploredGrid, _exploredWidth, _exploredHeight) _exploredGrid, _exploredWidth, _exploredHeight, _exploredOffsetX, _exploredOffsetY)
: PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid); : PathFinder.FindPath(terrain, playerPos, goal.Value, _config.WorldToGrid);
_waypointIndex = 0; _waypointIndex = 0;
_pathTimestampMs = now; _pathTimestampMs = now;
@ -321,18 +311,54 @@ public sealed class NavigationController
// Diagnostic: log every ~60 ticks (once per second at 60Hz) // Diagnostic: log every ~60 ticks (once per second at 60Hz)
if (state.TickNumber % 60 == 0) if (state.TickNumber % 60 == 0)
{ {
var gx = (int)(playerPos.X * _config.WorldToGrid); var gx2 = (int)(playerPos.X * _config.WorldToGrid);
var gy = (int)(playerPos.Y * _config.WorldToGrid); var gy2 = (int)(playerPos.Y * _config.WorldToGrid);
var walkable = state.Terrain?.IsWalkable(gx, gy) ?? false; var walkable = state.Terrain?.IsWalkable(gx2, gy2) ?? false;
Log.Information( Log.Information(
"NAV DIAG: playerWorld=({Px:F0},{Py:F0}) playerGrid=({Gx},{Gy}) walkable={W} " + "NAV DIAG: playerWorld=({Px:F0},{Py:F0}) playerGrid=({Gx},{Gy}) walkable={W} " +
"waypointWorld=({Tx:F0},{Ty:F0}) dir=({Dx:F2},{Dy:F2})", "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, target.X, target.Y,
DesiredDirection.Value.X, DesiredDirection.Value.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) private Vector2? ResolveGoal(GameState state)
{ {
switch (Mode) switch (Mode)
@ -365,22 +391,26 @@ public sealed class NavigationController
if (state.Terrain is null || _exploredGrid is null) return null; if (state.Terrain is null || _exploredGrid is null) return null;
var terrain = state.Terrain; 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; if (terrain.Width != _exploredWidth || terrain.Height != _exploredHeight) return null;
var gridToWorld = 1f / _config.WorldToGrid; var gridToWorld = 1f / _config.WorldToGrid;
var playerPos = state.Player.Position; var playerPos = state.Player.Position;
var ox = terrain.OffsetX;
var oy = terrain.OffsetY;
var w = terrain.Width; var w = terrain.Width;
var h = terrain.Height; var h = terrain.Height;
var startGx = Math.Clamp((int)(playerPos.X * _config.WorldToGrid), 0, w - 1); // Player in local grid coords
var startGy = Math.Clamp((int)(playerPos.Y * _config.WorldToGrid), 0, h - 1); 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 // BFS outward from player to find nearest unexplored walkable cell
var visited = new bool[w * h]; var visited = new bool[w * h];
var queue = new Queue<(int x, int y)>(); var queue = new Queue<(int lx, int ly)>();
queue.Enqueue((startGx, startGy)); queue.Enqueue((startLx, startLy));
visited[startGy * w + startGx] = true; visited[startLy * w + startLx] = true;
var iterations = 0; var iterations = 0;
const int maxIterations = 100_000; const int maxIterations = 100_000;
@ -388,13 +418,15 @@ public sealed class NavigationController
while (queue.Count > 0 && iterations++ < maxIterations) while (queue.Count > 0 && iterations++ < maxIterations)
{ {
var (cx, cy) = queue.Dequeue(); var (cx, cy) = queue.Dequeue();
var ax = cx + ox;
var ay = cy + oy;
// Found an unexplored walkable cell // 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; _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; return worldPos;
} }
@ -406,17 +438,101 @@ public sealed class NavigationController
if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue; if (nx < 0 || nx >= w || ny < 0 || ny >= h) continue;
var idx = ny * w + nx; var idx = ny * w + nx;
if (visited[idx]) continue; if (visited[idx]) continue;
if (!terrain.IsWalkable(nx, ny)) continue; if (!terrain.IsWalkable(nx + ox, ny + oy)) continue;
visited[idx] = true; visited[idx] = true;
queue.Enqueue((nx, ny)); 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"); Log.Information("BFS frontier: no unexplored cells found — exploration complete");
IsExplorationComplete = true; IsExplorationComplete = true;
return null; 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[] _bfsDx = [-1, 0, 1, 0, -1, -1, 1, 1];
private static readonly int[] _bfsDy = [0, -1, 0, 1, -1, 1, -1, 1]; private static readonly int[] _bfsDy = [0, -1, 0, 1, -1, 1, -1, 1];
} }

View file

@ -12,24 +12,27 @@ public static class PathFinder
/// <summary> /// <summary>
/// A* pathfinding on WalkabilitySnapshot. Returns world-coord waypoints or null if no path. /// 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> /// </summary>
public static List<Vector2>? FindPath( public static List<Vector2>? FindPath(
WalkabilitySnapshot terrain, Vector2 start, Vector2 goal, float worldToGrid, 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 w = terrain.Width;
var h = terrain.Height; var h = terrain.Height;
var ox = terrain.OffsetX;
var oy = terrain.OffsetY;
var gridToWorld = 1f / worldToGrid; var gridToWorld = 1f / worldToGrid;
var startGx = Math.Clamp((int)(start.X * worldToGrid), 0, w - 1); var startGx = Math.Clamp((int)(start.X * worldToGrid), ox, ox + w - 1);
var startGy = Math.Clamp((int)(start.Y * worldToGrid), 0, h - 1); var startGy = Math.Clamp((int)(start.Y * worldToGrid), oy, oy + h - 1);
var goalGx = Math.Clamp((int)(goal.X * worldToGrid), 0, w - 1); var goalGx = Math.Clamp((int)(goal.X * worldToGrid), ox, ox + w - 1);
var goalGy = Math.Clamp((int)(goal.Y * worldToGrid), 0, h - 1); var goalGy = Math.Clamp((int)(goal.Y * worldToGrid), oy, oy + h - 1);
// Snap to nearest walkable if start/goal are in walls // Snap to nearest walkable if start/goal are in walls
(startGx, startGy) = SnapToWalkable(terrain, startGx, startGy, w, h); (startGx, startGy) = SnapToWalkable(terrain, startGx, startGy);
(goalGx, goalGy) = SnapToWalkable(terrain, goalGx, goalGy, w, h); (goalGx, goalGy) = SnapToWalkable(terrain, goalGx, goalGy);
var startNode = (startGx, startGy); var startNode = (startGx, startGy);
var goalNode = (goalGx, goalGy); var goalNode = (goalGx, goalGy);
@ -78,7 +81,7 @@ public static class PathFinder
var nx = current.x + Dx[i]; var nx = current.x + Dx[i];
var ny = current.y + Dy[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; if (!terrain.IsWalkable(nx, ny)) continue;
var neighbor = (nx, ny); var neighbor = (nx, ny);
@ -93,8 +96,15 @@ public static class PathFinder
} }
var stepCost = Cost[i]; var stepCost = Cost[i];
if (exploredGrid is not null && nx < exploredWidth && ny < exploredHeight && exploredGrid[ny * exploredWidth + nx]) if (exploredGrid is not null)
stepCost *= 1.5f; {
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; var tentativeG = currentG + stepCost;
if (tentativeG < gScore.GetValueOrDefault(neighbor, float.MaxValue)) 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); 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); 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; if (Math.Abs(dx) != r && Math.Abs(dy) != r) continue;
var nx = gx + dx; var nx = gx + dx;
var ny = gy + dy; 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); return (nx, ny);
} }
} }

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using Nexus.Core; using Nexus.Core;
using Nexus.Simulator.World; using Nexus.Simulator.World;
using Serilog;
namespace Nexus.Simulator.Bridge; namespace Nexus.Simulator.Bridge;
@ -22,8 +23,27 @@ public class SimInputController : IInputController
// Camera matrix for screen→world conversion // Camera matrix for screen→world conversion
private Matrix4x4? _cameraMatrix; 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; 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) public SimInputController(SimWorld world)
{ {
_world = world; _world = world;
@ -74,6 +94,11 @@ public class SimInputController : IInputController
// IInputController implementation — captures actions, no actual Win32 calls // 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) public void KeyDown(ushort scanCode)
{ {
lock (_lock) lock (_lock)
@ -85,6 +110,7 @@ public class SimInputController : IInputController
case 0x1F: _sHeld = true; break; // S case 0x1F: _sHeld = true; break; // S
case 0x20: _dHeld = true; break; // D case 0x20: _dHeld = true; break; // D
} }
_keyTimers[scanCode] = float.MaxValue;
} }
} }
@ -99,11 +125,16 @@ public class SimInputController : IInputController
case 0x1F: _sHeld = false; break; case 0x1F: _sHeld = false; break;
case 0x20: _dHeld = false; break; case 0x20: _dHeld = false; break;
} }
_keyTimers.Remove(scanCode);
} }
} }
public void KeyPress(ushort scanCode, int holdMs = 50) public void KeyPress(ushort scanCode, int holdMs = 50)
{ {
lock (_lock)
{
_keyTimers[scanCode] = FlashDuration;
}
// Queue as skill cast // Queue as skill cast
var target = ScreenToWorld(_mouseScreenPos); var target = ScreenToWorld(_mouseScreenPos);
_world.QueueSkill(scanCode, target); _world.QueueSkill(scanCode, target);
@ -114,7 +145,15 @@ public class SimInputController : IInputController
lock (_lock) { _mouseScreenPos = new Vector2(x, y); } 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) public void MouseMoveBy(int dx, int dy)
{ {
lock (_lock) { _mouseScreenPos += new Vector2(dx, dy); } lock (_lock) { _mouseScreenPos += new Vector2(dx, dy); }
@ -122,14 +161,17 @@ public class SimInputController : IInputController
public void LeftClick(int x, int y) public void LeftClick(int x, int y)
{ {
Log.Information("[Click] Left at ({X},{Y})", x, y);
lock (_lock) { _mouseTimers[0] = FlashDuration; }
MouseMoveTo(x, y); MouseMoveTo(x, y);
var target = ScreenToWorld(new Vector2(x, y)); var target = ScreenToWorld(new Vector2(x, y));
// LMB = default attack / melee
_world.QueueSkill(0, target); _world.QueueSkill(0, target);
} }
public void RightClick(int x, int y) public void RightClick(int x, int y)
{ {
Log.Information("[Click] Right at ({X},{Y})", x, y);
lock (_lock) { _mouseTimers[1] = FlashDuration; }
MouseMoveTo(x, y); MouseMoveTo(x, y);
var target = ScreenToWorld(new Vector2(x, y)); var target = ScreenToWorld(new Vector2(x, y));
_world.QueueSkill(1, target); _world.QueueSkill(1, target);
@ -137,13 +179,93 @@ public class SimInputController : IInputController
public void MiddleClick(int x, int y) public void MiddleClick(int x, int y)
{ {
Log.Information("[Click] Middle at ({X},{Y})", x, y);
lock (_lock) { _mouseTimers[2] = FlashDuration; }
MouseMoveTo(x, y); MouseMoveTo(x, y);
var target = ScreenToWorld(new Vector2(x, y)); var target = ScreenToWorld(new Vector2(x, y));
_world.QueueSkill(2, target); _world.QueueSkill(2, target);
} }
public void LeftDown() { } public void LeftDown() { lock (_lock) { _mouseTimers[0] = float.MaxValue; } }
public void LeftUp() { } public void LeftUp() { lock (_lock) { _mouseTimers[0] = 0; } }
public void RightDown() { } public void RightDown() { lock (_lock) { _mouseTimers[1] = float.MaxValue; } }
public void RightUp() { } 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,
};
} }

View file

@ -83,7 +83,8 @@ public sealed class SimPoller : IDisposable
_world.Player.Position.X, _world.Player.Position.Y, 0f); _world.Player.Position.X, _world.Player.Position.Y, 0f);
_cache.PlayerVitals = new PlayerVitalsData( _cache.PlayerVitals = new PlayerVitalsData(
_world.Player.Health, _world.Player.MaxHealth, _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.IsLoading = false;
_cache.IsEscapeOpen = false; _cache.IsEscapeOpen = false;
_cache.Entities = state.Entities; _cache.Entities = state.Entities;

View file

@ -71,6 +71,8 @@ public static class SimStateBuilder
LifeTotal = player.MaxHealth, LifeTotal = player.MaxHealth,
ManaCurrent = player.Mana, ManaCurrent = player.Mana,
ManaTotal = player.MaxMana, ManaTotal = player.MaxMana,
EsCurrent = player.Es,
EsTotal = player.MaxEs,
Skills = BuildSkillStates(), Skills = BuildSkillStates(),
}, },
}; };

View file

@ -9,25 +9,45 @@ public class SimConfig
// Player // Player
public float PlayerMoveSpeed { get; set; } = 400f; 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 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 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 // Enemies — melee
public int TargetEnemyCount { get; set; } = 25; public int TargetEnemyCount { get; set; } = 50;
public float EnemyAggroRange { get; set; } = 600f; 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 float EnemyMoveSpeedFactor { get; set; } = 0.75f;
public int EnemyBaseHealth { get; set; } = 200; public int EnemyBaseHealth { get; set; } = 200;
public int EnemyAttackDamage { get; set; } = 30; public int EnemyMeleeBaseDamage { get; set; } = 60;
public float EnemyAttackCooldown { get; set; } = 1.5f; 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 EnemyDespawnTime { get; set; } = 2f;
public float EnemyRespawnTime { get; set; } = 5f; public float EnemyRespawnTime { get; set; } = 5f;
public float EnemyWanderRadius { get; set; } = 200f; 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 // Player skills
public float MeleeRange { get; set; } = 150f; public float MeleeRange { get; set; } = 350f;
public float MeleeConeAngle { get; set; } = 120f; public float MeleeConeAngle { get; set; } = 120f;
public float AoeRadius { get; set; } = 250f; public float AoeRadius { get; set; } = 250f;
public float ProjectileSpeed { get; set; } = 1200f; public float ProjectileSpeed { get; set; } = 1200f;
@ -35,6 +55,10 @@ public class SimConfig
public float ProjectileHitRadius { get; set; } = 80f; public float ProjectileHitRadius { get; set; } = 80f;
public int SkillBaseDamage { get; set; } = 200; public int SkillBaseDamage { get; set; } = 200;
// Terrain expansion
public int ExpandThreshold { get; set; } = 50;
public int ExpandAmount { get; set; } = 250;
// Simulation // Simulation
public float SpeedMultiplier { get; set; } = 1f; public float SpeedMultiplier { get; set; } = 1f;
public bool IsPaused { get; set; } public bool IsPaused { get; set; }

View file

@ -14,8 +14,8 @@ using Veldrid.Sdl2;
using Veldrid.StartupUtilities; using Veldrid.StartupUtilities;
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information() .MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger(); .CreateLogger();
Log.Information("Nexus Simulator starting..."); Log.Information("Nexus Simulator starting...");
@ -36,24 +36,7 @@ var poller = new SimPoller(world, input, cache, simConfig);
var nav = new NavigationController(botConfig); var nav = new NavigationController(botConfig);
// ── Create systems (same as BotEngine, minus AreaProgression) ── // ── Create systems (same as BotEngine, minus AreaProgression) ──
var systems = new List<ISystem> var systems = SystemFactory.CreateSystems(botConfig, nav);
{
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(),
};
// Apply a default profile with configured skills // Apply a default profile with configured skills
var profile = new CharacterProfile var profile = new CharacterProfile
@ -61,15 +44,15 @@ var profile = new CharacterProfile
Name = "SimPlayer", Name = "SimPlayer",
Skills = Skills =
[ [
new() { SlotIndex = 0, Label = "LMB", InputType = SkillInputType.LeftClick, Priority = 0, RangeMax = 150f, RequiresTarget = true }, new() { SlotIndex = 0, Label = "LMB", InputType = SkillInputType.LeftClick, Priority = 0, RangeMax = 350f },
new() { SlotIndex = 1, Label = "RMB", InputType = SkillInputType.RightClick, Priority = 1, RangeMax = 150f, RequiresTarget = true }, 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 = 600f, CooldownMs = 500, MinMonstersInRange = 2 }, 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 = 300 }, new() { SlotIndex = 4, Label = "E", InputType = SkillInputType.KeyPress, ScanCode = 0x12, Priority = 4, RangeMax = 800f, CooldownMs = 1500 },
], ],
Combat = new CombatSettings Combat = new CombatSettings
{ {
GlobalCooldownMs = 400, GlobalCooldownMs = 400,
AttackRange = 600f, AttackRange = 350f,
}, },
}; };
@ -88,7 +71,10 @@ nav.Explore();
// ── Bot logic thread ── // ── Bot logic thread ──
var actionQueue = new ActionQueue(); var actionQueue = new ActionQueue();
var movementBlender = new MovementBlender();
var moveTracker = new MovementKeyTracker();
var botRunning = true; var botRunning = true;
var lastStatusLogMs = 0L;
var botThread = new Thread(() => var botThread = new Thread(() =>
{ {
@ -102,26 +88,36 @@ var botThread = new Thread(() =>
var state = cache.LatestState; var state = cache.LatestState;
if (state is not null && !state.IsLoading && !state.IsEscapeOpen) if (state is not null && !state.IsLoading && !state.IsEscapeOpen)
{ {
// Enrich var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
GameStateEnricher.Enrich(state); ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
// Clear and run systems // Periodic status log (every 2 seconds)
actionQueue.Clear(); var nowMs = Environment.TickCount64;
nav.Update(state); if (nowMs - lastStatusLogMs >= 2000)
foreach (var sys in systems)
{ {
if (sys.IsEnabled) lastStatusLogMs = nowMs;
sys.Update(state, actionQueue); 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) catch (Exception ex)
@ -163,7 +159,7 @@ var imguiRenderer = new VeldridImGuiRenderer(
gd, gd.MainSwapchain.Framebuffer.OutputDescription, gd, gd.MainSwapchain.Framebuffer.OutputDescription,
window.Width, window.Height); 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(); var cl = gd.ResourceFactory.CreateCommandList();
window.Resized += () => window.Resized += () =>
@ -187,7 +183,8 @@ while (window.Exists)
imguiRenderer.Update(deltaSeconds, snapshot); imguiRenderer.Update(deltaSeconds, snapshot);
// Render sim world // Update input flash timers & render sim world
input.UpdateTimers(deltaSeconds);
renderer.Render(cache.LatestState); renderer.Render(cache.LatestState);
cl.Begin(); cl.Begin();
@ -208,57 +205,3 @@ imguiRenderer.Dispose();
gd.Dispose(); gd.Dispose();
Log.Information("Nexus Simulator stopped."); 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;
}
}
}

View file

@ -64,12 +64,13 @@ public class DebugPanel
// Enemy stats // Enemy stats
if (ImGui.CollapsingHeader("Enemies", ImGuiTreeNodeFlags.DefaultOpen)) if (ImGui.CollapsingHeader("Enemies", ImGuiTreeNodeFlags.DefaultOpen))
{ {
var alive = _world.Enemies.Count(e => e.IsAlive); var enemies = _world.Enemies.ToArray(); // snapshot — list mutated by SimPoller thread
var dead = _world.Enemies.Count(e => !e.IsAlive); var alive = enemies.Count(e => e.IsAlive);
var chasing = _world.Enemies.Count(e => e.AiState == EnemyAiState.Chasing); var dead = enemies.Count(e => !e.IsAlive);
var attacking = _world.Enemies.Count(e => e.AiState == EnemyAiState.Attacking); 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.Text($"Chasing: {chasing} Attacking: {attacking}");
ImGui.Separator(); ImGui.Separator();

View file

@ -6,25 +6,24 @@ namespace Nexus.Simulator.Rendering;
public static class EffectRenderer public static class EffectRenderer
{ {
public static void DrawEffects(ImDrawListPtr drawList, List<SimSkillEffect> effects, public static void DrawEffects(ImDrawListPtr drawList, IReadOnlyList<SimSkillEffect> effects, ViewTransform vt)
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
{ {
foreach (var effect in effects) foreach (var effect in effects)
{ {
var alpha = (byte)(255 * (1f - effect.Progress)); var alpha = (byte)(255 * (1f - effect.Progress));
var originScreen = canvasOrigin + viewOffset + effect.Origin * worldToGrid * zoom; var originScreen = vt.WorldToScreen(effect.Origin);
var targetScreen = canvasOrigin + viewOffset + effect.TargetPosition * worldToGrid * zoom; var targetScreen = vt.WorldToScreen(effect.TargetPosition);
switch (effect.Type) switch (effect.Type)
{ {
case SkillEffectType.Melee: case SkillEffectType.Melee:
DrawMeleeCone(drawList, originScreen, targetScreen, DrawMeleeCone(drawList, originScreen, targetScreen,
effect.Radius * worldToGrid * zoom, effect.ConeAngle, alpha); effect.Radius * vt.WorldScale, effect.ConeAngle, alpha);
break; break;
case SkillEffectType.Aoe: case SkillEffectType.Aoe:
DrawAoeCircle(drawList, targetScreen, DrawAoeCircle(drawList, targetScreen,
effect.Radius * worldToGrid * zoom, alpha); effect.Radius * vt.WorldScale, alpha);
break; break;
case SkillEffectType.Projectile: case SkillEffectType.Projectile:
@ -34,15 +33,25 @@ public static class EffectRenderer
} }
} }
public static void DrawProjectiles(ImDrawListPtr drawList, List<SimProjectile> projectiles, public static void DrawProjectiles(ImDrawListPtr drawList, IReadOnlyList<SimProjectile> projectiles, ViewTransform vt)
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
{ {
foreach (var proj in projectiles) foreach (var proj in projectiles)
{ {
var pos = canvasOrigin + viewOffset + proj.Position * worldToGrid * zoom; var pos = vt.WorldToScreen(proj.Position);
var radius = proj.HitRadius * worldToGrid * zoom * 0.3f; var radius = proj.HitRadius * vt.WorldScale * 0.3f;
drawList.AddCircleFilled(pos, Math.Max(3f, radius), 0xFF00DDFF);
drawList.AddCircle(pos, Math.Max(4f, radius + 1), 0xFF00AAFF); 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);
}
} }
} }

View file

@ -7,33 +7,40 @@ namespace Nexus.Simulator.Rendering;
public static class EntityRenderer public static class EntityRenderer
{ {
public static void DrawPlayer(ImDrawListPtr drawList, SimPlayer player, public static void DrawPlayer(ImDrawListPtr drawList, SimPlayer player, ViewTransform vt)
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
{ {
var gridPos = player.Position * worldToGrid; var screenPos = vt.WorldToScreen(player.Position);
var screenPos = canvasOrigin + viewOffset + gridPos * zoom;
var radius = 8f; var radius = 8f;
drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green
drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00); drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00);
// Health bar above player var barY = radius + 8;
DrawHealthBar(drawList, screenPos - new Vector2(15, radius + 8), 30, 4,
// 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); player.Health, player.MaxHealth, 0xFF00DD00);
barY += 6;
// Mana bar // 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); player.Mana, player.MaxMana, 0xFFDD6600);
} }
public static void DrawEnemies(ImDrawListPtr drawList, List<SimEnemy> enemies, public static void DrawEnemies(ImDrawListPtr drawList, IReadOnlyList<SimEnemy> enemies,
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid, ViewTransform vt, Vector2 canvasMin, Vector2 canvasMax)
Vector2 canvasMin, Vector2 canvasMax)
{ {
foreach (var enemy in enemies) foreach (var enemy in enemies)
{ {
var gridPos = enemy.Position * worldToGrid; var screenPos = vt.WorldToScreen(enemy.Position);
var screenPos = canvasOrigin + viewOffset + gridPos * zoom;
// Cull off-screen // Cull off-screen
if (screenPos.X < canvasMin.X - 20 || screenPos.X > canvasMax.X + 20 || if (screenPos.X < canvasMin.X - 20 || screenPos.X > canvasMax.X + 20 ||
@ -54,12 +61,30 @@ public static class EntityRenderer
radius *= 0.7f; 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) if (enemy.AiState == EnemyAiState.Chasing)
drawList.AddCircle(screenPos, radius + 2, 0xFF0000FF); // Red ring when chasing drawList.AddCircle(screenPos, radius + 2, 0xFF0000FF); // Red ring when chasing
else if (enemy.AiState == EnemyAiState.Attacking) else if (enemy.AiState == EnemyAiState.Attacking)
drawList.AddCircle(screenPos, radius + 3, 0xFF0000FF, 0, 3f); 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 // Health bar
if (enemy.IsAlive && enemy.Health < enemy.MaxHealth) if (enemy.IsAlive && enemy.Health < enemy.MaxHealth)

View 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);
}
}

View file

@ -6,32 +6,23 @@ namespace Nexus.Simulator.Rendering;
public static class PathRenderer public static class PathRenderer
{ {
public static void Draw(ImDrawListPtr drawList, NavigationController nav, public static void Draw(ImDrawListPtr drawList, NavigationController nav, ViewTransform vt)
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, float worldToGrid)
{ {
var path = nav.CurrentPath; var path = nav.CurrentPath;
if (path is null || path.Count < 2) return; if (path is null || path.Count < 2) return;
for (var i = 0; i < path.Count - 1; i++) for (var i = 0; i < path.Count - 1; i++)
{ {
var a = canvasOrigin + viewOffset + path[i] * worldToGrid * zoom; var a = vt.WorldToScreen(path[i]);
var b = canvasOrigin + viewOffset + path[i + 1] * worldToGrid * zoom; var b = vt.WorldToScreen(path[i + 1]);
drawList.AddLine(a, b, 0xFFFFFF00, 2f); // Cyan drawList.AddLine(a, b, 0xFFFFFF00, 2f); // Cyan
} }
// Draw waypoint dots // Draw waypoint dots
foreach (var wp in path) foreach (var wp in path)
{ {
var pos = canvasOrigin + viewOffset + wp * worldToGrid * zoom; var pos = vt.WorldToScreen(wp);
drawList.AddCircleFilled(pos, 3f, 0xFFFFFF00); 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
}
} }

View file

@ -2,31 +2,35 @@ using System.Numerics;
using ImGuiNET; using ImGuiNET;
using Nexus.Core; using Nexus.Core;
using Nexus.Pathfinding; using Nexus.Pathfinding;
using Nexus.Simulator.Bridge;
using Nexus.Simulator.Config; using Nexus.Simulator.Config;
using Nexus.Simulator.World; using Nexus.Simulator.World;
namespace Nexus.Simulator.Rendering; namespace Nexus.Simulator.Rendering;
/// <summary> /// <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> /// </summary>
public class SimRenderer public class SimRenderer
{ {
private readonly SimConfig _config; private readonly SimConfig _config;
private readonly SimWorld _world; private readonly SimWorld _world;
private readonly NavigationController _nav; private readonly NavigationController _nav;
private readonly SimInputController _input;
private readonly DebugPanel _debugPanel; private readonly DebugPanel _debugPanel;
// Camera // Camera
private Vector2 _viewOffset; private Vector2 _viewOffset;
private float _zoom = 2f; // pixels per grid cell private float _zoom = 2f; // pixels per grid cell
private const float C = 0.70710678f;
public SimRenderer(SimConfig config, SimWorld world, NavigationController nav, public SimRenderer(SimConfig config, SimWorld world, NavigationController nav,
IReadOnlyList<ISystem> systems) IReadOnlyList<ISystem> systems, SimInputController input)
{ {
_config = config; _config = config;
_world = world; _world = world;
_nav = nav; _nav = nav;
_input = input;
_debugPanel = new DebugPanel(config, world, nav, systems); _debugPanel = new DebugPanel(config, world, nav, systems);
// Center view on player // Center view on player
@ -53,31 +57,39 @@ public class SimRenderer
CenterOnPlayer(canvasSize); CenterOnPlayer(canvasSize);
var drawList = ImGui.GetWindowDrawList(); var drawList = ImGui.GetWindowDrawList();
var vt = new ViewTransform(canvasOrigin, _viewOffset, _zoom, _config.WorldToGrid);
// Clip to canvas // Clip to canvas
drawList.PushClipRect(canvasOrigin, canvasOrigin + canvasSize); drawList.PushClipRect(canvasOrigin, canvasOrigin + canvasSize);
// 1. Terrain // 1. Terrain
TerrainRenderer.Draw(drawList, _world.Terrain, _viewOffset, _zoom, canvasOrigin, canvasSize, TerrainRenderer.Draw(drawList, _world.Terrain, vt, canvasSize,
_nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight); _nav.ExploredGrid, _nav.ExploredWidth, _nav.ExploredHeight,
_nav.ExploredOffsetX, _nav.ExploredOffsetY);
// 2. Path // 2. Path
PathRenderer.Draw(drawList, _nav, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid); PathRenderer.Draw(drawList, _nav, vt);
// 3. Effects // 3. Effects — snapshot shared lists to avoid concurrent modification from SimPoller thread
EffectRenderer.DrawEffects(drawList, _world.ActiveEffects, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid); var effects = _world.ActiveEffects.ToArray();
EffectRenderer.DrawProjectiles(drawList, _world.Projectiles, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid); var projectiles = _world.Projectiles.ToArray();
var enemies = _world.Enemies.ToArray();
EffectRenderer.DrawEffects(drawList, effects, vt);
EffectRenderer.DrawProjectiles(drawList, projectiles, vt);
// 4. Enemies // 4. Enemies
EntityRenderer.DrawEnemies(drawList, _world.Enemies, _viewOffset, _zoom, canvasOrigin, _config.WorldToGrid, EntityRenderer.DrawEnemies(drawList, enemies, vt, canvasOrigin, canvasOrigin + canvasSize);
canvasOrigin, canvasOrigin + canvasSize);
// 5. Player // 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(); drawList.PopClipRect();
// Minimap (bottom-right corner) // Minimap (bottom-right corner, top-down view)
var minimapSize = 150f; var minimapSize = 150f;
var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10); var minimapOrigin = canvasOrigin + canvasSize - new Vector2(minimapSize + 10, minimapSize + 10);
var playerGridPos = _world.Player.Position * _config.WorldToGrid; var playerGridPos = _world.Player.Position * _config.WorldToGrid;
@ -86,14 +98,22 @@ public class SimRenderer
// HUD text // HUD text
DrawHud(drawList, canvasOrigin, state); DrawHud(drawList, canvasOrigin, state);
// Input overlay (keyboard + mouse + mousepad)
InputOverlayRenderer.Draw(drawList, _input.GetInputSnapshot(), _input.MouseScreenPos,
canvasOrigin, canvasSize);
ImGui.End(); ImGui.End();
} }
private void CenterOnPlayer(Vector2? canvasSize = null) private void CenterOnPlayer(Vector2? canvasSize = null)
{ {
var cs = canvasSize ?? new Vector2(1200, 900); var cs = canvasSize ?? new Vector2(1200, 900);
var playerGrid = _world.Player.Position * _config.WorldToGrid; var gx = _world.Player.Position.X * _config.WorldToGrid;
_viewOffset = cs * 0.5f - playerGrid * _zoom; 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) private void HandleInput(Vector2 canvasOrigin, Vector2 canvasSize)
@ -102,7 +122,7 @@ public class SimRenderer
var io = ImGui.GetIO(); var io = ImGui.GetIO();
// Scroll to zoom // Scroll to zoom (works in rotated space — no change needed)
if (io.MouseWheel != 0) if (io.MouseWheel != 0)
{ {
var mousePos = io.MousePos - canvasOrigin; 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) private void DrawHud(ImDrawListPtr drawList, Vector2 canvasOrigin, GameState? state)
{ {
var textPos = canvasOrigin + new Vector2(10, 10); var textPos = canvasOrigin + new Vector2(10, 10);

View file

@ -7,23 +7,38 @@ namespace Nexus.Simulator.Rendering;
public static class TerrainRenderer public static class TerrainRenderer
{ {
/// <summary> /// <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. /// Only draws cells visible in the current viewport for performance.
/// </summary> /// </summary>
public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain, public static void Draw(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
Vector2 viewOffset, float zoom, Vector2 canvasOrigin, Vector2 canvasSize, ViewTransform vt, Vector2 canvasSize,
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 cellSize = zoom; var cellSize = vt.Zoom;
if (cellSize < 0.5f) return; // Too zoomed out to draw individual cells if (cellSize < 0.5f) return;
// Visible range in grid coords var ox = terrain.OffsetX;
var minGx = Math.Max(0, (int)(-viewOffset.X / cellSize)); var oy = terrain.OffsetY;
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));
// 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; var step = 1;
if (cellSize < 2f) step = 4; if (cellSize < 2f) step = 4;
else if (cellSize < 4f) step = 2; else if (cellSize < 4f) step = 2;
@ -31,9 +46,6 @@ public static class TerrainRenderer
for (var gy = minGy; gy <= maxGy; gy += step) for (var gy = minGy; gy <= maxGy; gy += step)
for (var gx = minGx; gx <= maxGx; gx += 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); var w = terrain.IsWalkable(gx, gy);
uint color; uint color;
@ -43,23 +55,26 @@ public static class TerrainRenderer
} }
else else
{ {
var elx = gx - exploredOffsetX;
var ely = gy - exploredOffsetY;
var explored = exploredGrid is not null var explored = exploredGrid is not null
&& gx < exploredWidth && gy < exploredHeight && elx >= 0 && elx < exploredWidth && ely >= 0 && ely < exploredHeight
&& exploredGrid[gy * exploredWidth + gx]; && exploredGrid[ely * exploredWidth + elx];
color = explored ? 0xFF3D3D5C : 0xFF2A2A3F; // Brighter if explored color = explored ? 0xFF3D3D5C : 0xFF2A2A3F;
} }
var size = cellSize * step; // Draw diamond (rotated grid cell)
drawList.AddRectFilled( var p0 = vt.GridToScreen(gx, gy); // top
new Vector2(screenX, screenY), var p1 = vt.GridToScreen(gx + step, gy); // right
new Vector2(screenX + size, screenY + size), var p2 = vt.GridToScreen(gx + step, gy + step); // bottom
color); var p3 = vt.GridToScreen(gx, gy + step); // left
drawList.AddQuadFilled(p0, p1, p2, p3, color);
} }
} }
/// <summary> /// <summary>
/// Draws a minimap in the corner. /// Draws a minimap in the corner (top-down, no rotation).
/// </summary> /// </summary>
public static void DrawMinimap(ImDrawListPtr drawList, WalkabilitySnapshot terrain, public static void DrawMinimap(ImDrawListPtr drawList, WalkabilitySnapshot terrain,
Vector2 playerGridPos, Vector2 minimapOrigin, float minimapSize) Vector2 playerGridPos, Vector2 minimapOrigin, float minimapSize)
@ -73,22 +88,23 @@ public static class TerrainRenderer
minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale), minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale),
0xFF0A0A15); 0xFF0A0A15);
// Draw walkable cells (sampled) // Draw walkable cells (sampled) — local coords
var step = Math.Max(1, terrain.Width / 200); var step = Math.Max(1, terrain.Width / 200);
for (var gy = 0; gy < terrain.Height; gy += step) for (var ly = 0; ly < terrain.Height; ly += step)
for (var gx = 0; gx < terrain.Width; gx += step) for (var lx = 0; lx < terrain.Width; lx += step)
{ {
if (!terrain.IsWalkable(gx, gy)) continue; if (!terrain.IsWalkable(lx + terrain.OffsetX, ly + terrain.OffsetY)) continue;
var px = minimapOrigin.X + gx * scale; var px = minimapOrigin.X + lx * scale;
var py = minimapOrigin.Y + gy * scale; var py = minimapOrigin.Y + ly * scale;
drawList.AddRectFilled( drawList.AddRectFilled(
new Vector2(px, py), new Vector2(px, py),
new Vector2(px + scale * step, py + scale * step), new Vector2(px + scale * step, py + scale * step),
0xFF2A2A3F); 0xFF2A2A3F);
} }
// Player dot // Player dot — convert absolute grid pos to local
var playerPx = minimapOrigin + playerGridPos * scale; var playerLocalPos = playerGridPos - new Vector2(terrain.OffsetX, terrain.OffsetY);
var playerPx = minimapOrigin + playerLocalPos * scale;
drawList.AddCircleFilled(playerPx, 3f, 0xFF00FF00); drawList.AddCircleFilled(playerPx, 3f, 0xFF00FF00);
// Border // Border
@ -96,4 +112,10 @@ public static class TerrainRenderer
minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale), minimapOrigin + new Vector2(terrain.Width * scale, terrain.Height * scale),
0xFF666666); 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));
} }

View file

@ -21,10 +21,11 @@ public sealed class VeldridImGuiRenderer : IDisposable
private Texture _fontTexture = null!; private Texture _fontTexture = null!;
private TextureView _fontTextureView = null!; private TextureView _fontTextureView = null!;
private ResourceSet _fontResourceSet = null!; private ResourceSet _fontResourceSet = null!;
private ResourceLayout _layout = null!; private ResourceLayout _projSamplerLayout = null!;
private ResourceLayout _textureLayout = null!; private ResourceLayout _textureLayout = null!;
private Pipeline _pipeline = null!; private Pipeline _pipeline = null!;
private Shader[] _shaders = null!; private Shader[] _shaders = null!;
private ResourceSet _projSamplerSet = null!;
private int _windowWidth; private int _windowWidth;
private int _windowHeight; private int _windowHeight;
@ -49,7 +50,6 @@ public sealed class VeldridImGuiRenderer : IDisposable
io.DisplayFramebufferScale = Vector2.One; io.DisplayFramebufferScale = Vector2.One;
CreateDeviceResources(gd, outputDescription); CreateDeviceResources(gd, outputDescription);
SetupKeyMappings();
} }
public void WindowResized(int width, int height) 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)); _indexBuffer = factory.CreateBuffer(new BufferDescription(2000, BufferUsage.IndexBuffer | BufferUsage.Dynamic));
_projMatrixBuffer = factory.CreateBuffer(new BufferDescription(64, BufferUsage.UniformBuffer | BufferUsage.Dynamic)); _projMatrixBuffer = factory.CreateBuffer(new BufferDescription(64, BufferUsage.UniformBuffer | BufferUsage.Dynamic));
// Create shaders using HLSL // Create shaders
_shaders = CreateShaders(gd); _shaders = CreateShaders(gd);
// Font texture
RecreateFontDeviceTexture(gd);
// Resource layouts // Resource layouts
_layout = factory.CreateResourceLayout(new ResourceLayoutDescription( _projSamplerLayout = factory.CreateResourceLayout(new ResourceLayoutDescription(
new ResourceLayoutElementDescription("ProjectionMatrixBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex), new ResourceLayoutElementDescription("ProjectionMatrixBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex),
new ResourceLayoutElementDescription("MainSampler", ResourceKind.Sampler, ShaderStages.Fragment))); new ResourceLayoutElementDescription("MainSampler", ResourceKind.Sampler, ShaderStages.Fragment)));
@ -126,12 +123,19 @@ public sealed class VeldridImGuiRenderer : IDisposable
FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise,
true, true), true, true),
PrimitiveTopology = PrimitiveTopology.TriangleList, PrimitiveTopology = PrimitiveTopology.TriangleList,
ResourceLayouts = [_layout, _textureLayout], ResourceLayouts = [_projSamplerLayout, _textureLayout],
ShaderSet = new ShaderSetDescription([vertexLayout], _shaders), ShaderSet = new ShaderSetDescription([vertexLayout], _shaders),
Outputs = outputDescription, Outputs = outputDescription,
}; };
_pipeline = factory.CreateGraphicsPipeline(ref pipelineDesc); _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) private void RecreateFontDeviceTexture(GraphicsDevice gd)
@ -141,6 +145,7 @@ public sealed class VeldridImGuiRenderer : IDisposable
_fontTexture?.Dispose(); _fontTexture?.Dispose();
_fontTextureView?.Dispose(); _fontTextureView?.Dispose();
_fontResourceSet?.Dispose();
_fontTexture = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D( _fontTexture = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D(
(uint)width, (uint)height, 1, 1, (uint)width, (uint)height, 1, 1,
@ -155,34 +160,18 @@ public sealed class VeldridImGuiRenderer : IDisposable
io.Fonts.SetTexID(_fontAtlasId); io.Fonts.SetTexID(_fontAtlasId);
io.Fonts.ClearTexData(); io.Fonts.ClearTexData();
// Create resource set for font _fontResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
if (_textureLayout is not null) _textureLayout, _fontTextureView));
{ _resourceSets[_fontAtlasId] = _fontResourceSet;
_fontResourceSet?.Dispose();
_fontResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
_textureLayout, _fontTextureView));
_resourceSets[_fontAtlasId] = _fontResourceSet;
}
} }
private Shader[] CreateShaders(GraphicsDevice gd) private Shader[] CreateShaders(GraphicsDevice gd)
{ {
// For D3D11, use HLSL compiled at runtime if (gd.BackendType != GraphicsBackend.Direct3D11)
// For other backends, we'd need SPIRV throw new NotSupportedException($"Backend {gd.BackendType} not supported. Use Direct3D11.");
var backend = gd.BackendType;
byte[] vertexShaderBytes; var vertexShaderBytes = CompileHlsl(VertexShaderHlsl, "main", "vs_5_0");
byte[] fragmentShaderBytes; var fragmentShaderBytes = CompileHlsl(FragmentShaderHlsl, "main", "ps_5_0");
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 vertexShader = gd.ResourceFactory.CreateShader(new ShaderDescription( var vertexShader = gd.ResourceFactory.CreateShader(new ShaderDescription(
ShaderStages.Vertex, vertexShaderBytes, "main")); ShaderStages.Vertex, vertexShaderBytes, "main"));
@ -200,6 +189,8 @@ public sealed class VeldridImGuiRenderer : IDisposable
var totalVtxSize = (uint)(drawData.TotalVtxCount * Unsafe.SizeOf<ImDrawVert>()); var totalVtxSize = (uint)(drawData.TotalVtxCount * Unsafe.SizeOf<ImDrawVert>());
var totalIdxSize = (uint)(drawData.TotalIdxCount * sizeof(ushort)); var totalIdxSize = (uint)(drawData.TotalIdxCount * sizeof(ushort));
if (totalVtxSize == 0 || totalIdxSize == 0) return;
if (totalVtxSize > _vertexBuffer.SizeInBytes) if (totalVtxSize > _vertexBuffer.SizeInBytes)
{ {
_vertexBuffer.Dispose(); _vertexBuffer.Dispose();
@ -219,16 +210,15 @@ public sealed class VeldridImGuiRenderer : IDisposable
for (var i = 0; i < drawData.CmdListsCount; i++) for (var i = 0; i < drawData.CmdListsCount; i++)
{ {
var cmdList = drawData.CmdLists[i]; var cmdList = drawData.CmdLists[i];
cl.UpdateBuffer(_vertexBuffer, vtxOffset, var vtxSize = (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>());
cmdList.VtxBuffer.Data, (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>())); var idxSize = (uint)(cmdList.IdxBuffer.Size * sizeof(ushort));
cl.UpdateBuffer(_indexBuffer, idxOffset, cl.UpdateBuffer(_vertexBuffer, vtxOffset, cmdList.VtxBuffer.Data, vtxSize);
cmdList.IdxBuffer.Data, (uint)(cmdList.IdxBuffer.Size * sizeof(ushort))); cl.UpdateBuffer(_indexBuffer, idxOffset, cmdList.IdxBuffer.Data, idxSize);
vtxOffset += (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>()); vtxOffset += vtxSize;
idxOffset += (uint)(cmdList.IdxBuffer.Size * sizeof(ushort)); idxOffset += idxSize;
} }
// Update projection matrix // Update projection matrix
var io = ImGui.GetIO();
var mvp = Matrix4x4.CreateOrthographicOffCenter( var mvp = Matrix4x4.CreateOrthographicOffCenter(
drawData.DisplayPos.X, drawData.DisplayPos.X,
drawData.DisplayPos.X + drawData.DisplaySize.X, drawData.DisplayPos.X + drawData.DisplaySize.X,
@ -240,14 +230,11 @@ public sealed class VeldridImGuiRenderer : IDisposable
cl.SetVertexBuffer(0, _vertexBuffer); cl.SetVertexBuffer(0, _vertexBuffer);
cl.SetIndexBuffer(_indexBuffer, IndexFormat.UInt16); cl.SetIndexBuffer(_indexBuffer, IndexFormat.UInt16);
cl.SetPipeline(_pipeline); cl.SetPipeline(_pipeline);
cl.SetGraphicsResourceSet(0, _projSamplerSet);
// Create main resource set
var mainResourceSet = gd.ResourceFactory.CreateResourceSet(new ResourceSetDescription(
_layout, _projMatrixBuffer, gd.PointSampler));
cl.SetGraphicsResourceSet(0, mainResourceSet);
// Draw // Draw
var clipOff = drawData.DisplayPos; var clipOff = drawData.DisplayPos;
var clipScale = drawData.FramebufferScale;
vtxOffset = 0; vtxOffset = 0;
idxOffset = 0; idxOffset = 0;
for (var n = 0; n < drawData.CmdListsCount; n++) for (var n = 0; n < drawData.CmdListsCount; n++)
@ -263,23 +250,23 @@ public sealed class VeldridImGuiRenderer : IDisposable
cl.SetGraphicsResourceSet(1, rs); cl.SetGraphicsResourceSet(1, rs);
} }
var clipRect = pcmd.ClipRect; var clipX = (uint)Math.Max(0, (pcmd.ClipRect.X - clipOff.X) * clipScale.X);
cl.SetScissorRect(0, var clipY = (uint)Math.Max(0, (pcmd.ClipRect.Y - clipOff.Y) * clipScale.Y);
(uint)(clipRect.X - clipOff.X), var clipW = (uint)Math.Max(0, (pcmd.ClipRect.Z - pcmd.ClipRect.X) * clipScale.X);
(uint)(clipRect.Y - clipOff.Y), var clipH = (uint)Math.Max(0, (pcmd.ClipRect.W - pcmd.ClipRect.Y) * clipScale.Y);
(uint)(clipRect.Z - clipRect.X),
(uint)(clipRect.W - clipRect.Y)); if (clipW == 0 || clipH == 0) continue;
cl.SetScissorRect(0, clipX, clipY, clipW, clipH);
cl.DrawIndexed(pcmd.ElemCount, 1, cl.DrawIndexed(pcmd.ElemCount, 1,
pcmd.IdxOffset + idxOffset, pcmd.IdxOffset + idxOffset,
(int)(pcmd.VtxOffset + vtxOffset), (int)(pcmd.VtxOffset + vtxOffset / (uint)Unsafe.SizeOf<ImDrawVert>()),
0); 0);
} }
vtxOffset += (uint)cmdList.VtxBuffer.Size; vtxOffset += (uint)(cmdList.VtxBuffer.Size * Unsafe.SizeOf<ImDrawVert>());
idxOffset += (uint)cmdList.IdxBuffer.Size; idxOffset += (uint)cmdList.IdxBuffer.Size;
} }
mainResourceSet.Dispose();
} }
private void UpdateInput(InputSnapshot snapshot) private void UpdateInput(InputSnapshot snapshot)
@ -327,11 +314,6 @@ public sealed class VeldridImGuiRenderer : IDisposable
_ => ImGuiKey.None, _ => ImGuiKey.None,
}; };
private void SetupKeyMappings()
{
// ImGui.NET 1.91+ uses the key event API directly, no need for explicit mappings
}
// ── HLSL Shader Sources ── // ── HLSL Shader Sources ──
private const string VertexShaderHlsl = @" private const string VertexShaderHlsl = @"
@ -387,7 +369,7 @@ float4 main(PS_INPUT input) : SV_Target
[DllImport("d3dcompiler_47.dll", CallingConvention = CallingConvention.StdCall)] [DllImport("d3dcompiler_47.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int D3DCompile( private static extern int D3DCompile(
[MarshalAs(UnmanagedType.LPStr)] string pSrcData, [MarshalAs(UnmanagedType.LPStr)] string pSrcData,
int srcDataSize, nint srcDataSize,
[MarshalAs(UnmanagedType.LPStr)] string? pSourceName, [MarshalAs(UnmanagedType.LPStr)] string? pSourceName,
IntPtr pDefines, IntPtr pDefines,
IntPtr pInclude, IntPtr pInclude,
@ -398,46 +380,40 @@ float4 main(PS_INPUT input) : SV_Target
out IntPtr ppCode, out IntPtr ppCode,
out IntPtr ppErrorMsgs); 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 // ID3DBlob vtable offsets
private static IntPtr BlobGetBufferPointer(IntPtr blob) private static IntPtr BlobGetBufferPointer(IntPtr blob)
{ {
var vtable = Marshal.ReadIntPtr(blob); var vtable = Marshal.ReadIntPtr(blob);
var getBufferPtr = Marshal.ReadIntPtr(vtable, 3 * IntPtr.Size); // IUnknown (3 methods) + GetBufferPointer var fn = Marshal.ReadIntPtr(vtable, 3 * IntPtr.Size);
var del = Marshal.GetDelegateForFunctionPointer<GetBufferPointerDelegate>(getBufferPtr); return Marshal.GetDelegateForFunctionPointer<BlobBufferPointerFn>(fn)(blob);
return del(blob);
} }
private static int BlobGetBufferSize(IntPtr blob) private static nint BlobGetBufferSize(IntPtr blob)
{ {
var vtable = Marshal.ReadIntPtr(blob); var vtable = Marshal.ReadIntPtr(blob);
var getBufferSize = Marshal.ReadIntPtr(vtable, 4 * IntPtr.Size); // IUnknown (3 methods) + GetBufferPointer + GetBufferSize var fn = Marshal.ReadIntPtr(vtable, 4 * IntPtr.Size);
var del = Marshal.GetDelegateForFunctionPointer<GetBufferSizeDelegate>(getBufferSize); return Marshal.GetDelegateForFunctionPointer<BlobBufferSizeFn>(fn)(blob);
return del(blob);
} }
private static void BlobRelease(IntPtr blob) private static void BlobRelease(IntPtr blob)
{ {
var vtable = Marshal.ReadIntPtr(blob); var vtable = Marshal.ReadIntPtr(blob);
var release = Marshal.ReadIntPtr(vtable, 2 * IntPtr.Size); // IUnknown::Release var fn = Marshal.ReadIntPtr(vtable, 2 * IntPtr.Size);
var del = Marshal.GetDelegateForFunctionPointer<ReleaseDelegate>(release); Marshal.GetDelegateForFunctionPointer<BlobReleaseFn>(fn)(blob);
del(blob);
} }
[UnmanagedFunctionPointer(CallingConvention.StdCall)] [UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate IntPtr GetBufferPointerDelegate(IntPtr self); private delegate IntPtr BlobBufferPointerFn(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] [UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate int GetBufferSizeDelegate(IntPtr self); private delegate nint BlobBufferSizeFn(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] [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) 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); entryPoint, target, 0, 0, out var codeBlob, out var errorBlob);
if (hr != 0 || codeBlob == IntPtr.Zero) if (hr != 0 || codeBlob == IntPtr.Zero)
@ -456,7 +432,7 @@ float4 main(PS_INPUT input) : SV_Target
BlobRelease(errorBlob); BlobRelease(errorBlob);
var bufferPtr = BlobGetBufferPointer(codeBlob); var bufferPtr = BlobGetBufferPointer(codeBlob);
var bufferSize = BlobGetBufferSize(codeBlob); var bufferSize = (int)BlobGetBufferSize(codeBlob);
var result = new byte[bufferSize]; var result = new byte[bufferSize];
Marshal.Copy(bufferPtr, result, 0, bufferSize); Marshal.Copy(bufferPtr, result, 0, bufferSize);
BlobRelease(codeBlob); BlobRelease(codeBlob);
@ -471,11 +447,13 @@ float4 main(PS_INPUT input) : SV_Target
_fontTexture?.Dispose(); _fontTexture?.Dispose();
_fontTextureView?.Dispose(); _fontTextureView?.Dispose();
_fontResourceSet?.Dispose(); _fontResourceSet?.Dispose();
_projSamplerSet?.Dispose();
_pipeline?.Dispose(); _pipeline?.Dispose();
_layout?.Dispose(); _projSamplerLayout?.Dispose();
_textureLayout?.Dispose(); _textureLayout?.Dispose();
foreach (var shader in _shaders) if (_shaders is not null)
shader?.Dispose(); foreach (var shader in _shaders)
shader?.Dispose();
foreach (var rs in _resourceSets.Values) foreach (var rs in _resourceSets.Values)
rs?.Dispose(); rs?.Dispose();
} }

View 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);
}
}

View file

@ -8,9 +8,16 @@ public enum EnemyAiState
Idle, Idle,
Chasing, Chasing,
Attacking, Attacking,
Retreating, // Ranged enemy backing away to maintain distance
Dead, Dead,
} }
public enum EnemyType
{
Melee,
Ranged,
}
public class SimEnemy public class SimEnemy
{ {
private static uint _nextId = 1000; private static uint _nextId = 1000;
@ -20,14 +27,22 @@ public class SimEnemy
public int Health { get; set; } public int Health { get; set; }
public int MaxHealth { get; set; } public int MaxHealth { get; set; }
public MonsterRarity Rarity { get; set; } public MonsterRarity Rarity { get; set; }
public EnemyType Type { get; set; }
public EnemyAiState AiState { get; set; } = EnemyAiState.Idle; public EnemyAiState AiState { get; set; } = EnemyAiState.Idle;
public float MoveSpeed { get; set; } public float MoveSpeed { get; set; }
// Damage (scaled by rarity)
public int AttackDamage { get; set; }
// Timers // Timers
public float AttackCooldownRemaining { get; set; } public float AttackCooldownRemaining { get; set; }
public float DespawnTimer { get; set; } public float DespawnTimer { get; set; }
public float RespawnTimer { 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 // Wander
public Vector2 WanderTarget { get; set; } public Vector2 WanderTarget { get; set; }
public float WanderTimer { get; set; } public float WanderTimer { get; set; }
@ -35,13 +50,16 @@ public class SimEnemy
public bool IsAlive => Health > 0; public bool IsAlive => Health > 0;
public bool IsAttacking => AiState == EnemyAiState.Attacking; 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); Id = Interlocked.Increment(ref _nextId);
Position = position; Position = position;
SpawnPosition = position; SpawnPosition = position;
Rarity = rarity; Rarity = rarity;
Type = type;
MoveSpeed = moveSpeed; MoveSpeed = moveSpeed;
var hpMultiplier = rarity switch var hpMultiplier = rarity switch
@ -53,6 +71,15 @@ public class SimEnemy
}; };
MaxHealth = (int)(baseHealth * hpMultiplier); MaxHealth = (int)(baseHealth * hpMultiplier);
Health = MaxHealth; 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) public void TakeDamage(int damage)

View file

@ -9,31 +9,44 @@ public class SimPlayer
public int MaxHealth { get; set; } public int MaxHealth { get; set; }
public int Mana { get; set; } public int Mana { get; set; }
public int MaxMana { get; set; } public int MaxMana { get; set; }
public int Es { get; set; }
public int MaxEs { get; set; }
public float MoveSpeed { get; set; } public float MoveSpeed { get; set; }
public float HealthRegen { get; set; } public float HealthRegen { get; set; }
public float ManaRegen { get; set; } public float ManaRegen { get; set; }
public float EsRegen { get; set; }
public float EsRechargeDelay { get; set; }
// Accumulate fractional regen // Accumulate fractional regen
private float _healthRegenAccum; private float _healthRegenAccum;
private float _manaRegenAccum; 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; MaxHealth = maxHealth;
MaxMana = maxMana; MaxMana = maxMana;
MaxEs = maxEs;
Health = maxHealth; Health = maxHealth;
Mana = maxMana; Mana = maxMana;
Es = maxEs;
MoveSpeed = moveSpeed; MoveSpeed = moveSpeed;
HealthRegen = healthRegen; HealthRegen = healthRegen;
ManaRegen = manaRegen; ManaRegen = manaRegen;
EsRegen = esRegen;
EsRechargeDelay = esRechargeDelay;
_timeSinceLastDamage = esRechargeDelay; // Start with ES recharging
} }
public void Update(float dt) public void Update(float dt)
{ {
// Regenerate _timeSinceLastDamage += dt;
_healthRegenAccum += HealthRegen * dt;
_manaRegenAccum += ManaRegen * dt;
// Health regen (always active)
_healthRegenAccum += HealthRegen * dt;
if (_healthRegenAccum >= 1f) if (_healthRegenAccum >= 1f)
{ {
var amount = (int)_healthRegenAccum; var amount = (int)_healthRegenAccum;
@ -41,18 +54,58 @@ public class SimPlayer
_healthRegenAccum -= amount; _healthRegenAccum -= amount;
} }
// Mana regen
_manaRegenAccum += ManaRegen * dt;
if (_manaRegenAccum >= 1f) if (_manaRegenAccum >= 1f)
{ {
var amount = (int)_manaRegenAccum; var amount = (int)_manaRegenAccum;
Mana = Math.Min(MaxMana, Mana + amount); Mana = Math.Min(MaxMana, Mana + amount);
_manaRegenAccum -= 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) 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); Health = Math.Max(0, Health - damage);
} }
public bool IsAlive => Health > 0; 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;
}
}
} }

View file

@ -12,8 +12,10 @@ public class SimProjectile
public int Damage { get; set; } public int Damage { get; set; }
public float DistanceTraveled { get; set; } public float DistanceTraveled { get; set; }
public bool IsExpired { 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; Position = origin;
Direction = Vector2.Normalize(direction); Direction = Vector2.Normalize(direction);
@ -21,6 +23,7 @@ public class SimProjectile
MaxRange = maxRange; MaxRange = maxRange;
HitRadius = hitRadius; HitRadius = hitRadius;
Damage = damage; Damage = damage;
IsEnemyProjectile = isEnemyProjectile;
} }
public void Update(float dt) public void Update(float dt)

View file

@ -1,6 +1,7 @@
using System.Numerics; using System.Numerics;
using Nexus.Core; using Nexus.Core;
using Nexus.Simulator.Config; using Nexus.Simulator.Config;
using Serilog;
namespace Nexus.Simulator.World; namespace Nexus.Simulator.World;
@ -33,8 +34,9 @@ public class SimWorld
var gridToWorld = 1f / config.WorldToGrid; var gridToWorld = 1f / config.WorldToGrid;
var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain); var (sx, sy) = TerrainGenerator.FindSpawnPosition(Terrain);
Player = new SimPlayer( Player = new SimPlayer(
config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxHealth, config.PlayerMaxMana, config.PlayerMaxEs,
config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen) config.PlayerMoveSpeed, config.PlayerHealthRegen, config.PlayerManaRegen,
config.PlayerEsRegen, config.PlayerEsRechargeDelay)
{ {
Position = new Vector2(sx * gridToWorld, sy * gridToWorld), Position = new Vector2(sx * gridToWorld, sy * gridToWorld),
}; };
@ -51,6 +53,7 @@ public class SimWorld
Player.Position = new Vector2(sx * gridToWorld, sy * gridToWorld); Player.Position = new Vector2(sx * gridToWorld, sy * gridToWorld);
Player.Health = Player.MaxHealth; Player.Health = Player.MaxHealth;
Player.Mana = Player.MaxMana; Player.Mana = Player.MaxMana;
Player.Es = Player.MaxEs;
Enemies.Clear(); Enemies.Clear();
Projectiles.Clear(); Projectiles.Clear();
ActiveEffects.Clear(); ActiveEffects.Clear();
@ -70,13 +73,16 @@ public class SimWorld
dt *= _config.SpeedMultiplier; dt *= _config.SpeedMultiplier;
TickNumber++; TickNumber++;
// 0. Expand terrain if player near edge
CheckAndExpandTerrain();
// 1. Move player // 1. Move player
MovePlayer(dt); MovePlayer(dt);
// 2. Process queued skills // 2. Process queued skills
ProcessSkills(); ProcessSkills();
// 3. Update projectiles // 3. Update projectiles (player + enemy)
UpdateProjectiles(dt); UpdateProjectiles(dt);
// 4. Update skill effects // 4. Update skill effects
@ -92,45 +98,62 @@ public class SimWorld
Player.Update(dt); 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) private void MovePlayer(float dt)
{ {
if (MoveDirection.LengthSquared() < 0.001f) return; if (MoveDirection.LengthSquared() < 0.001f) return;
var dir = Vector2.Normalize(MoveDirection); 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 gx = (int)(newPos.X * _config.WorldToGrid);
var gy = (int)(newPos.Y * _config.WorldToGrid); var gy = (int)(newPos.Y * _config.WorldToGrid);
if (Terrain.IsWalkable(gx, gy)) if (!Terrain.IsWalkable(gx, gy)) return false;
Player.Position = newPos; Player.Position = newPos;
else return true;
{
// 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;
}
} }
private void ProcessSkills() private void ProcessSkills()
{ {
while (_skillQueue.TryDequeue(out var skill)) 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 type = GetSkillType(skill.scanCode);
var targetPos = skill.targetWorldPos; var targetPos = skill.targetWorldPos;
@ -196,6 +219,8 @@ public class SimWorld
} }
enemy.TakeDamage(_config.SkillBaseDamage); 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); ActiveEffects.Add(effect);
// Apply AOE damage
foreach (var enemy in Enemies) foreach (var enemy in Enemies)
{ {
if (!enemy.IsAlive) continue; if (!enemy.IsAlive) continue;
if (Vector2.Distance(enemy.Position, targetPos) <= _config.AoeRadius) if (Vector2.Distance(enemy.Position, targetPos) <= _config.AoeRadius)
{
enemy.TakeDamage(_config.SkillBaseDamage); 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; if (dir.LengthSquared() < 1f) dir = Vector2.UnitX;
var projectile = new SimProjectile( var projectile = new SimProjectile(
Player.Position, Player.Position, dir,
dir, _config.ProjectileSpeed, _config.ProjectileRange,
_config.ProjectileSpeed, _config.ProjectileHitRadius, _config.SkillBaseDamage);
_config.ProjectileRange,
_config.ProjectileHitRadius,
_config.SkillBaseDamage);
Projectiles.Add(projectile); Projectiles.Add(projectile);
@ -261,17 +286,35 @@ public class SimWorld
proj.IsExpired = true; proj.IsExpired = true;
} }
// Check enemy hits
if (!proj.IsExpired) if (!proj.IsExpired)
{ {
foreach (var enemy in Enemies) if (proj.IsEnemyProjectile)
{ {
if (!enemy.IsAlive) continue; // Enemy projectile hits player
if (Vector2.Distance(enemy.Position, proj.Position) <= proj.HitRadius) 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; 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; enemy.DespawnTimer -= dt;
if (enemy.DespawnTimer <= 0) if (enemy.DespawnTimer <= 0)
{ {
// Queue respawn
_respawnQueue.Add((_config.EnemyRespawnTime, enemy.Rarity)); _respawnQueue.Add((_config.EnemyRespawnTime, enemy.Rarity));
Enemies.RemoveAt(i); Enemies.RemoveAt(i);
} }
@ -315,53 +357,148 @@ public class SimWorld
var dist = Vector2.Distance(enemy.Position, Player.Position); 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.AiState = EnemyAiState.Idle;
enemy.WanderTimer -= dt; UpdateWander(enemy, dt);
if (enemy.WanderTimer <= 0) continue;
{ }
// 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;
}
if (Vector2.Distance(enemy.Position, enemy.WanderTarget) > 10f) if (enemy.IsRanged)
{ UpdateRangedEnemy(enemy, dist, dt);
var dir = Vector2.Normalize(enemy.WanderTarget - enemy.Position); else
var newPos = enemy.Position + dir * enemy.MoveSpeed * 0.3f * dt; UpdateMeleeEnemy(enemy, dist, 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 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) private void UpdateRespawns(float dt)
@ -371,54 +508,131 @@ public class SimWorld
var (timer, rarity) = _respawnQueue[i]; var (timer, rarity) = _respawnQueue[i];
timer -= dt; timer -= dt;
if (timer <= 0) if (timer <= 0)
{
SpawnEnemy(rarity);
_respawnQueue.RemoveAt(i); _respawnQueue.RemoveAt(i);
}
else else
{
_respawnQueue[i] = (timer, rarity); _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 // Maintain population
var aliveCount = Enemies.Count(e => e.IsAlive) + _respawnQueue.Count; var aliveCount = Enemies.Count(e => e.IsAlive);
while (aliveCount < _config.TargetEnemyCount) while (aliveCount < _config.TargetEnemyCount)
{ {
SpawnEnemy(RollRarity()); var spawned = SpawnGroup(RollRarity());
aliveCount++; aliveCount += spawned;
if (spawned == 0) break;
} }
} }
public void SpawnEnemyAt(Vector2 worldPos, MonsterRarity rarity) public void SpawnEnemyAt(Vector2 worldPos, MonsterRarity rarity)
{ {
var enemy = new SimEnemy(worldPos, rarity, _config.EnemyBaseHealth, var type = _rng.NextSingle() < _config.RangedEnemyChance ? EnemyType.Ranged : EnemyType.Melee;
_config.PlayerMoveSpeed * _config.EnemyMoveSpeedFactor) 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, WanderTarget = worldPos,
WanderTimer = _rng.NextSingle() * 3f, WanderTimer = _rng.NextSingle() * 3f,
AttackRange = type == EnemyType.Ranged ? _config.EnemyRangedAttackRange : _config.EnemyMeleeAttackRange,
PreferredRange = type == EnemyType.Ranged ? _config.EnemyRangedPreferredRange : 0f,
}; };
Enemies.Add(enemy); Enemies.Add(enemy);
} }
private void SpawnEnemies(int count) private void SpawnEnemies(int count)
{ {
for (var i = 0; i < count; i++) while (count > 0)
SpawnEnemy(RollRarity()); {
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 center = FindSpawnNearPlayer();
var pos = TerrainGenerator.FindRandomWalkable(Terrain, _rng); if (center is null) return 0;
if (pos is null) return;
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 for (var i = 0; i < groupSize; i++)
if (Vector2.Distance(worldPos, Player.Position) < 300f) return; {
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() private MonsterRarity RollRarity()

View file

@ -1,103 +1,189 @@
using System.Numerics;
using Nexus.Core; using Nexus.Core;
namespace Nexus.Simulator.World; namespace Nexus.Simulator.World;
public static class TerrainGenerator 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; var p = new int[256];
public int CenterY => Y + Height / 2; 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) 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];
var data = new byte[width * height]; // 0 = wall by default FillNoiseRegion(data, width, height, 0, 0, 0, 0, width, height);
var rooms = new List<Room>(); // Clear spawn area at center
var attempts = 0; FillCircle(data, width, height, width / 2, height / 2, 20, 1);
var targetRooms = 15 + rng.Next(10);
while (rooms.Count < targetRooms && attempts < 500) return new WalkabilitySnapshot { Width = width, Height = height, Data = data };
{ }
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);
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) // Fill entire new grid with noise (absolute coordinates ensure seamless expansion)
var overlaps = false; FillNoiseRegion(data, newW, newH, newOffsetX, newOffsetY, 0, 0, newW, newH);
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;
}
}
if (!overlaps) // Overwrite the old region with preserved data
rooms.Add(candidate); for (var y = 0; y < old.Height; y++)
} Array.Copy(old.Data, y * old.Width, data, (y + top) * newW + left, old.Width);
// 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);
}
return new WalkabilitySnapshot return new WalkabilitySnapshot
{ {
Width = width, Width = newW,
Height = height, Height = newH,
Data = data, Data = data,
OffsetX = newOffsetX,
OffsetY = newOffsetY,
}; };
} }
/// <summary> /// <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> /// </summary>
public static (int x, int y) FindSpawnPosition(WalkabilitySnapshot terrain) public static (int x, int y) FindSpawnPosition(WalkabilitySnapshot terrain)
{ {
var cx = terrain.Width / 2; var cx = terrain.OffsetX + terrain.Width / 2;
var cy = terrain.Height / 2; var cy = terrain.OffsetY + terrain.Height / 2;
// Spiral outward
for (var r = 0; r < Math.Max(terrain.Width, terrain.Height); r++) for (var r = 0; r < Math.Max(terrain.Width, terrain.Height); r++)
{ {
for (var dx = -r; dx <= r; dx++) for (var dx = -r; dx <= r; dx++)
@ -115,61 +201,19 @@ public static class TerrainGenerator
} }
/// <summary> /// <summary>
/// Finds a random walkable position. /// Finds a random walkable position. Returns absolute grid coords.
/// </summary> /// </summary>
public static (int x, int y)? FindRandomWalkable(WalkabilitySnapshot terrain, Random rng, int maxAttempts = 200) public static (int x, int y)? FindRandomWalkable(WalkabilitySnapshot terrain, Random rng, int maxAttempts = 200)
{ {
for (var i = 0; i < maxAttempts; i++) for (var i = 0; i < maxAttempts; i++)
{ {
var x = rng.Next(terrain.Width); var lx = rng.Next(terrain.Width);
var y = rng.Next(terrain.Height); var ly = rng.Next(terrain.Height);
if (terrain.IsWalkable(x, y)) var ax = lx + terrain.OffsetX;
return (x, y); var ay = ly + terrain.OffsetY;
if (terrain.IsWalkable(ax, ay))
return (ax, ay);
} }
return null; 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;
}
}
} }

View 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);
}
}
}

View 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;
}
}

View file

@ -76,7 +76,7 @@ public class CombatSystem : ISystem
_heldSlots.Clear(); _heldSlots.Clear();
} }
public void Update(GameState state, ActionQueue actions) public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{ {
if (state.CameraMatrix is not { } camera) if (state.CameraMatrix is not { } camera)
return; return;
@ -92,16 +92,19 @@ public class CombatSystem : ISystem
_lastAreaHash = state.AreaHash; _lastAreaHash = state.AreaHash;
} }
// Global cooldown: don't cast if we recently cast any skill // Always submit orbit/herd intent when enemies are nearby — provides continuous
if (now - _lastCastGlobal < _globalCooldownMs) // 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) var herdOverride = inGcd ? 0.4f : 0.2f;
if (_kiteEnabled && now - _lastCastGlobal >= _kiteDelayMs TryHerd(state, movement, herdOverride);
&& state.NearestEnemies.Count > 0) }
{
TryHerd(state, actions);
}
// Global cooldown: don't cast if we recently cast any skill
if (inGcd)
{
// Still need to handle MaintainPressed releases // Still need to handle MaintainPressed releases
UpdateHeldKeys(state, camera, playerZ, actions); UpdateHeldKeys(state, camera, playerZ, actions);
return; return;
@ -205,7 +208,7 @@ public class CombatSystem : ISystem
/// Orbit-herding: move perpendicular to enemy centroid so scattered mobs converge /// Orbit-herding: move perpendicular to enemy centroid so scattered mobs converge
/// into a tight cluster for AOE. Maintains ideal distance via radial bias. /// into a tight cluster for AOE. Maintains ideal distance via radial bias.
/// </summary> /// </summary>
private void TryHerd(GameState state, ActionQueue actions) private void TryHerd(GameState state, MovementBlender movement, float overrideFactor)
{ {
var playerPos = state.Player.Position; var playerPos = state.Player.Position;
@ -241,26 +244,8 @@ public class CombatSystem : ISystem
var dir = Vector2.Normalize(perp + centroidDir * radialBias); var dir = Vector2.Normalize(perp + centroidDir * radialBias);
// Validate against terrain — flip orbit direction on wall hit // Layer 4: orbit/herd with variable override (stronger during GCD, weaker while casting)
if (state.Terrain is { } terrain) movement.Submit(new MovementIntent(4, dir, overrideFactor, "Herd"));
{
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));
} }
private void UpdateHeldKeys(GameState state, Matrix4x4 camera, float playerZ, ActionQueue actions) private void UpdateHeldKeys(GameState state, Matrix4x4 camera, float playerZ, ActionQueue actions)

View file

@ -8,7 +8,7 @@ public class LootSystem : ISystem
public string Name => "Loot"; public string Name => "Loot";
public bool IsEnabled { get; set; } = false; 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 // STUB: loot detection and pickup logic
} }

View file

@ -4,8 +4,10 @@ using Nexus.Core;
namespace Nexus.Systems; namespace Nexus.Systems;
/// <summary> /// <summary>
/// Force-based avoidance: applies inverse-square repulsion from hostile monsters /// Proximity-aware positioning: when enemies are within safe distance, applies a blend of
/// within safe distance. Emits a MoveAction with the escape direction. /// 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> /// </summary>
public class MovementSystem : ISystem public class MovementSystem : ISystem
{ {
@ -14,41 +16,74 @@ public class MovementSystem : ISystem
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
public float SafeDistance { get; set; } = 400f; 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> /// <summary>World-to-grid conversion factor for terrain queries.</summary>
public float WorldToGrid { get; set; } = 23f / 250f; 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.Player.HasPosition) return;
if (state.HostileMonsters.Count == 0) return; if (state.HostileMonsters.Count == 0) return;
var playerPos = state.Player.Position; 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) foreach (var monster in state.HostileMonsters)
{ {
if (!monster.IsAlive) continue; if (!monster.IsAlive) continue;
if (monster.DistanceToPlayer > SafeDistance) continue; if (monster.DistanceToPlayer > SafeDistance) continue;
var delta = playerPos - monster.Position; centroid += monster.Position;
var distSq = delta.LengthSquared(); count++;
if (distSq < 1f) distSq = 1f; if (monster.DistanceToPlayer < closestDist)
closestDist = monster.DistanceToPlayer;
// Inverse-square repulsion: stronger when closer
var force = delta / distSq * RepulsionWeight;
repulsion += force;
} }
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 var centroidDir = toCentroid / dist;
if (state.Terrain is { } terrain)
direction = TerrainQuery.FindWalkableDirection(terrain, state.Player.Position, direction, WorldToGrid);
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"));
} }
} }

View file

@ -21,9 +21,9 @@ public class NavigationSystem : ISystem
/// </summary> /// </summary>
public Vector2? ExternalDirection { get; set; } public Vector2? ExternalDirection { get; set; }
public void Update(GameState state, ActionQueue actions) public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{ {
if (ExternalDirection.HasValue) if (ExternalDirection.HasValue)
actions.Submit(new MoveAction(Priority, ExternalDirection.Value)); movement.Submit(new MovementIntent(3, ExternalDirection.Value, 0f, "Navigation"));
} }
} }

View file

@ -9,5 +9,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Nexus.Core\Nexus.Core.csproj" /> <ProjectReference Include="..\Nexus.Core\Nexus.Core.csproj" />
<ProjectReference Include="..\Nexus.Data\Nexus.Data.csproj" />
<ProjectReference Include="..\Nexus.Pathfinding\Nexus.Pathfinding.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -29,7 +29,7 @@ public class ResourceSystem : ISystem
_lastManaFlaskMs = 0; _lastManaFlaskMs = 0;
} }
public void Update(GameState state, ActionQueue actions) public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{ {
var player = state.Player; var player = state.Player;
if (player.LifeTotal == 0) return; if (player.LifeTotal == 0) return;

View 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;
}
}

View file

@ -5,10 +5,9 @@ using Serilog;
namespace Nexus.Systems; namespace Nexus.Systems;
/// <summary> /// <summary>
/// Emergency threat response. Runs first (priority 50). /// Emergency-only threat response. Runs first (priority 50).
/// On Critical danger: urgent flee (blocks casting via priority ≤ 10). /// Only fires on Critical danger (low HP or overwhelming threat score).
/// On High danger: flee toward safety but allow casting. /// Normal combat (High/Medium) is handled by MovementSystem orbiting + CombatSystem herding.
/// Medium and below: no action (MovementSystem handles soft avoidance).
/// </summary> /// </summary>
public class ThreatSystem : ISystem public class ThreatSystem : ISystem
{ {
@ -16,18 +15,15 @@ public class ThreatSystem : ISystem
public string Name => "Threat"; public string Name => "Threat";
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
/// <summary>Priority ≤ 10 blocks casting in ActionQueue.Resolve — pure flee.</summary> /// <summary>If closest enemy is within this range AND danger is Critical, escalate to urgent flee.</summary>
private const int UrgentFleePriority = 5; public float PointBlankRange { get; set; } = 120f;
/// <summary>If closest enemy is within this range, escalate to urgent flee.</summary>
public float PointBlankRange { get; set; } = 150f;
/// <summary>World-to-grid conversion factor for terrain queries.</summary> /// <summary>World-to-grid conversion factor for terrain queries.</summary>
public float WorldToGrid { get; set; } = 23f / 250f; public float WorldToGrid { get; set; } = 23f / 250f;
private DangerLevel _prevDanger = DangerLevel.Safe; 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; if (!state.Player.HasPosition) return;
@ -45,32 +41,28 @@ public class ThreatSystem : ISystem
_prevDanger = danger; _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; if (threats.TotalHostiles == 0) return;
// Compute flee direction: away from threat centroid // Compute flee direction: away from threat centroid
var fleeDir = state.Player.Position - threats.ThreatCentroid; var fleeDir = state.Player.Position - threats.ThreatCentroid;
if (fleeDir.LengthSquared() < 0.0001f) if (fleeDir.LengthSquared() < 0.0001f)
fleeDir = Vector2.UnitY; // fallback if at centroid fleeDir = Vector2.UnitY;
fleeDir = Vector2.Normalize(fleeDir); 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; var isPointBlank = threats.ClosestDistance < PointBlankRange;
if (danger == DangerLevel.Critical || isPointBlank) if (isPointBlank)
{ {
// Urgent flee — blocks casting (priority ≤ 10) // Layer 0: total override — pure flee, blocks casting
actions.Submit(new MoveAction(UrgentFleePriority, fleeDir)); movement.Submit(new MovementIntent(0, fleeDir, 1.0f, "Threat"));
} }
else // High else
{ {
// Flee but allow casting alongside // Layer 1: strong flee but allow some nav/orbit bleed-through
actions.Submit(new MoveAction(Priority, fleeDir)); movement.Submit(new MovementIntent(1, fleeDir, 0.6f, "Threat"));
} }
} }
} }