Compare commits
20 Commits
3b08138580
...
68eeeeb7a6
| Author | SHA1 | Date |
|---|---|---|
|
|
68eeeeb7a6 | |
|
|
b0bf10f286 | |
|
|
535c8d948e | |
|
|
94a9cc9519 | |
|
|
9ef387f010 | |
|
|
4871f68fb8 | |
|
|
6e8ac0adc0 | |
|
|
cbb7edd472 | |
|
|
4cdfe9c957 | |
|
|
52e1e8fdc9 | |
|
|
4b94f411bf | |
|
|
1e8e4e1b38 | |
|
|
5d7eb14911 | |
|
|
d9cd943c52 | |
|
|
e460c0e70f | |
|
|
96d732d6ab | |
|
|
b98e8d66af | |
|
|
737136e2ef | |
|
|
91c6f4aa2c | |
|
|
2de735498c |
|
|
@ -38,11 +38,11 @@ Key ADRs to keep in mind when changing the library:
|
|||
| 002 | 32-bit Entity (24 ID + 8 version) | Fits in register, enough headroom |
|
||||
| 003 | Explicit query iteration | Visible cost model, no code-gen needed |
|
||||
| 004 | Registration order for systems | Simplest model that works at this scale |
|
||||
| 005 | Manual `MarkModified` for value changes | No per-component overhead |
|
||||
| 006 | Deferred change posting | Prevents mid-system reentrancy |
|
||||
| 005 | Auto-tracking in batching scopes | Eliminates manual `MarkModified` in systems |
|
||||
| 006 | Deferred mutation batching | Prevents mid-system reentrancy, safe iteration |
|
||||
| 007 | R3 for reactivity | Zero-allocation, UI lifecycle-friendly |
|
||||
| 008 | Commands as serializable structs in a queue | Not ECS state |
|
||||
| 009 | Singleton as reserved entity (ID 1) | Reuses component storage |
|
||||
| 009 | Singleton per component type | Each singleton gets its own entity |
|
||||
| 010 | Single-threaded by default | Simpler, no locks needed |
|
||||
| 011 | .NET 8 target | LTS through Nov 2026 |
|
||||
| 012 | Public types required for MessagePack | Build-time validation via analyzer |
|
||||
|
|
@ -69,19 +69,32 @@ Dense/sparse array pair. `dense[]` is packed values, `denseEntities[]` is
|
|||
parallel entity IDs, `sparse[]` maps entity ID → dense index (-1 = absent).
|
||||
Uses swap-remove for O(1) deletion.
|
||||
|
||||
### `EntityIterator.Select1/2/3<T...>` — ref struct iterators
|
||||
### `WorldQueryExtensions.Select1..Select6<T...>` — ref struct iterators
|
||||
|
||||
Zero-allocation iterators. Drive from the smallest sparse set to minimize
|
||||
probes. Support `foreach` via `GetEnumerator()` returning `this`. Must call
|
||||
`world.BeginIteration()` / `EndIteration()` for pending mutation flushing.
|
||||
Always skip singleton entity (ID 1).
|
||||
Zero-allocation `ref struct` enumerators returned by `world.Select<T1..T6>()`.
|
||||
Drive from the smallest sparse set to minimize probes. Support `foreach` via
|
||||
`GetEnumerator()` returning `this`. Expose `Entity`, `Ref1`..`RefN` (mutable,
|
||||
tracked for auto-dirty-marking), and `Val1`..`ValN` (read-only, untracked).
|
||||
Constructor/dispose manage `BeginBatching()`/`EndBatching()` for pending
|
||||
mutation flushing. Singleton entities are excluded via `IsSingletonEntity()`.
|
||||
|
||||
Also provides `FindEntity<T>()` (first match).
|
||||
|
||||
### `Query<T1..T6>` — generic query descriptors
|
||||
|
||||
Structs that encode `With` types as generic parameters. `Without<W>()` fluent
|
||||
method adds exclusion filters without changing the arity. Replaces the old
|
||||
`QueryBuilder` / `QueryDescriptor` / `ForEachAction` pattern.
|
||||
|
||||
### `SystemGroup` — system orchestration
|
||||
|
||||
Manages an ordered list of `ISystem`. `RunAll()`:
|
||||
1. Drain commands (pre-tick).
|
||||
2. For each system: run it → drain commands → post changes.
|
||||
3. Drain commands + post changes (post-tick).
|
||||
2. `BeginBatching()` — outer batching scope for the tick.
|
||||
3. For each system: run via `Run` extension (nested batch) → drain commands →
|
||||
flush pending mutations → post changes.
|
||||
4. `EndBatching()` — auto-marks + flushes remaining.
|
||||
5. Drain commands + post changes (post-tick).
|
||||
|
||||
### `WorldSerializer` — save/load
|
||||
|
||||
|
|
@ -179,12 +192,13 @@ when there are no fields to compare.
|
|||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Forgetting `MarkModified` after mutating a `ref` component.** Changes won't
|
||||
propagate to R3 subscribers. Debug builds have a warning for this.
|
||||
- **Using `ValN` when you meant to mutate.** `ValN` returns a `ref readonly` —
|
||||
writes through it still work but won't be tracked for auto-dirty-marking.
|
||||
Use `RefN` for any component you intend to mutate.
|
||||
- **Modifying components during iteration.** Pending mutations are flushed at
|
||||
the end of iteration (via `BeginIteration`/`EndIteration`). Adding/removing
|
||||
components mid-iteration is safe — they're deferred.
|
||||
the end of the batching scope. Adding/removing components mid-iteration is
|
||||
safe — they're deferred.
|
||||
- **Type not public or missing `[MessagePackObject]`.** Serialization will fail.
|
||||
The `MessagePackAnalyzer` catches most issues at compile time.
|
||||
- **Entity ID 1 is the singleton.** Don't destroy it. Iterators skip it
|
||||
automatically.
|
||||
- **Singletons each have their own entity allocated automatically by
|
||||
`SetSingleton<T>`.** Iterators skip singleton entities automatically.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public void PlaceBet_AdvancesToDealing()
|
|||
|
||||
Common test helpers:
|
||||
- `SetupGame(seed?)` — create world, register systems, set initial singletons.
|
||||
- `FindEntity<T>(world)` — find first non-singleton entity with component T.
|
||||
- `FindEntity<T>(world)` — find first entity with component T (singletons are automatically excluded by query results).
|
||||
- `CountCardsInHand(world, handEntity)` or `GetHandCards(world, handEntity)`.
|
||||
|
||||
Always keep helpers in the test class (or a shared base) rather than in the
|
||||
|
|
|
|||
|
|
@ -97,12 +97,13 @@ target) are cleaned up automatically.
|
|||
|
||||
## Defining Systems
|
||||
|
||||
Systems implement `ISystem` (or `ITickedSystem` if they need delta time):
|
||||
Systems implement `ISystem` (or `ITickedSystem` if they need delta time).
|
||||
Implement `RunImpl` with the system logic:
|
||||
|
||||
```csharp
|
||||
public class DealSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Dealing)
|
||||
|
|
@ -117,30 +118,54 @@ The `ISystem` interface has no `Query` property. Systems read singletons,
|
|||
build queries, and iterate on their own — this keeps the interface minimal
|
||||
and gives systems full flexibility.
|
||||
|
||||
### Iteration Styles
|
||||
### Running Systems
|
||||
|
||||
Two options:
|
||||
Use the `Run` extension method to execute a system with automatic batching:
|
||||
|
||||
**ForEach callbacks** (1–6 components):
|
||||
```csharp
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) =>
|
||||
{
|
||||
pos.X += vel.X * dt;
|
||||
world.MarkModified<Position>(e);
|
||||
});
|
||||
var system = new MySystem();
|
||||
system.Run(world); // wraps RunImpl in BeginBatching/EndBatching
|
||||
```
|
||||
|
||||
**Ref struct iterators** via `EntityIterator.Select<T>()` (1–3 components):
|
||||
`SystemGroup` handles this automatically — you just register systems and call
|
||||
`RunLogical()` or `RunTimed()`.
|
||||
|
||||
### Iteration
|
||||
|
||||
Use `world.Select<T1..T6>()` with `foreach` for zero-allocation iteration with
|
||||
`ref` access to components. Use `RefN` for components you intend to mutate
|
||||
(tracked for auto-dirty-marking) and `ValN` for read-only access (untracked):
|
||||
|
||||
```csharp
|
||||
using var iter = world.Select<PlayerHand>();
|
||||
while (iter.MoveNext())
|
||||
foreach (var it in world.Select<Position, Velocity>())
|
||||
{
|
||||
// iter.CurrentEntity, iter.Current1 (ref)
|
||||
it.Ref1.X += it.Val2.X * dt; // Position tracked, Velocity not
|
||||
it.Ref1.Y += it.Val2.Y * dt;
|
||||
}
|
||||
```
|
||||
|
||||
The singleton entity (ID 1) is automatically skipped by all iterators.
|
||||
For queries with exclusion filters, create a `Query<T>` with `.Without<W>()`:
|
||||
|
||||
```csharp
|
||||
var query = new Query<Cell>().Without<Mark>();
|
||||
foreach (var it in world.Select(query))
|
||||
{
|
||||
if (it.Val1.Row == row && it.Val1.Col == col) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
To find a single entity, use `FindEntity<T>()`:
|
||||
|
||||
```csharp
|
||||
var handEntity = world.FindEntity<PlayerHand>();
|
||||
```
|
||||
|
||||
The `Select` ref struct enumerators expose:
|
||||
- `it.Entity` — the current entity handle.
|
||||
- `it.Ref1`..`it.RefN` — `ref` references to components (tracked for auto-dirty).
|
||||
- `it.Val1`..`it.ValN` — `ref readonly` references to components (untracked).
|
||||
|
||||
Singleton entities are automatically excluded from query results.
|
||||
|
||||
### System Registration
|
||||
|
||||
|
|
@ -155,8 +180,8 @@ group.Add(new PlayerBustCheckSystem());
|
|||
group.Add(new DealerSystem());
|
||||
```
|
||||
|
||||
`SystemGroup` automatically drains commands and posts changes after each system
|
||||
and after the full tick.
|
||||
`SystemGroup` automatically drains commands, flushes pending mutations, and
|
||||
posts changes after each system and after the full tick.
|
||||
|
||||
## Defining Commands
|
||||
|
||||
|
|
@ -176,40 +201,46 @@ public record struct PlaceBetCommand : ICommand
|
|||
state.CurrentBet = Amount;
|
||||
state.Chips -= Amount;
|
||||
state.Phase = GamePhase.Dealing;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enqueue commands via `world.Commands.Enqueue(...)`. They execute deferred
|
||||
when the queue is drained (automatically by `SystemGroup`).
|
||||
when the queue is drained (automatically by `SystemGroup`). Commands run
|
||||
inside a batching scope, so `GetSingleton` and `GetComponent` calls are
|
||||
auto-tracked for modification.
|
||||
|
||||
## Singletons
|
||||
|
||||
Global state lives on the singleton entity (ID 1). Use `SetSingleton<T>`,
|
||||
`GetSingleton<T>` (ref), and `ReadSingleton<T>` (copy):
|
||||
Each singleton component type gets its own dedicated entity, allocated
|
||||
automatically by `SetSingleton<T>`. Use `SetSingleton<T>`, `GetSingleton<T>`
|
||||
(ref), and `ReadSingleton<T>` (copy):
|
||||
|
||||
```csharp
|
||||
// Setup:
|
||||
world.SetSingleton(new GameState { Phase = GamePhase.Betting, Chips = 100 });
|
||||
|
||||
// Read-only inspection:
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
|
||||
// Mutation:
|
||||
// Mutation (auto-tracked during batching scopes):
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
mutable.Phase = GamePhase.RoundOver;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
```
|
||||
|
||||
`GetSingleton` returns a `ref` — always call `MarkModified` after mutating
|
||||
so reactivity subscribers see the change. `ReadSingleton` returns a copy and
|
||||
never auto-marks.
|
||||
`GetSingleton` returns a `ref`. During system execution, command drains, and
|
||||
`foreach` iterations, mutations via `GetSingleton` are auto-marked for change
|
||||
tracking. Outside a batching scope, call `MarkModified` after mutating.
|
||||
`ReadSingleton` returns a copy and never auto-marks.
|
||||
|
||||
## Change Tracking
|
||||
|
||||
- Structural changes (entity create/destroy, component add/remove) are auto-marked.
|
||||
- Value mutations (modifying a `ref T` component) must be manually marked via
|
||||
`world.MarkModified<T>(entity)`.
|
||||
- Structural changes (entity create/destroy, component add/remove) are always
|
||||
auto-marked.
|
||||
- Value mutations via `GetComponent<T>`, `GetSingleton<T>`, and `RefN` iterator
|
||||
properties are auto-tracked during batching scopes (system runs, command
|
||||
drains, `foreach` iterations).
|
||||
- Outside batching scopes, call `world.MarkModified<T>(entity)` explicitly.
|
||||
- Changes are posted after each system runs (automatic via `SystemGroup`).
|
||||
|
||||
## Serialization
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.Blackjack\Blackjack.csproj" />
|
||||
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void NewGame_StartsInBettingPhase()
|
||||
{
|
||||
var (world, _) = SetupGame();
|
||||
var (world, _) = TestHelpers.SetupGame();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.Betting);
|
||||
|
|
@ -21,13 +21,13 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlaceBet_AdvancesToDealing()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.PlayerTurn); // Dealing → PlayerTurn happens automatically
|
||||
state.Phase.Should().Be(GamePhase.PlayerTurn);
|
||||
state.CurrentBet.Should().Be(10);
|
||||
state.Chips.Should().Be(90);
|
||||
}
|
||||
|
|
@ -35,20 +35,20 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlaceBet_RejectsInsufficientChips()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 200 });
|
||||
group.RunLogical();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.Betting); // Should not advance
|
||||
state.Phase.Should().Be(GamePhase.Betting);
|
||||
state.Chips.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlaceBet_RejectsZeroOrNegative()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 0 });
|
||||
group.RunLogical();
|
||||
|
|
@ -62,60 +62,53 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void Dealing_CreatesDeckAndHands()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
// Should have a deck entity.
|
||||
var deckEntity = FindEntity<Deck>(world);
|
||||
deckEntity.Should().NotBe(Entity.Null);
|
||||
|
||||
// Should have player and dealer hand entities.
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
playerHand.Should().NotBe(Entity.Null);
|
||||
|
||||
var dealerHand = FindEntity<DealerHand>(world);
|
||||
dealerHand.Should().NotBe(Entity.Null);
|
||||
TestHelpers.FindEntity<Deck>(world).Should().NotBe(Entity.Null);
|
||||
TestHelpers.FindEntity<PlayerHand>(world).Should().NotBe(Entity.Null);
|
||||
TestHelpers.FindEntity<DealerHand>(world).Should().NotBe(Entity.Null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dealing_DealsTwoCardsToEach()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
var dealerHand = FindEntity<DealerHand>(world);
|
||||
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
|
||||
var dealerHand = TestHelpers.FindEntity<DealerHand>(world);
|
||||
|
||||
CountCardsInHand(world, playerHand).Should().Be(2);
|
||||
CountCardsInHand(world, dealerHand).Should().Be(2);
|
||||
TestHelpers.CountCardsInHand(world, playerHand).Should().Be(2);
|
||||
TestHelpers.CountCardsInHand(world, dealerHand).Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hit_DrawsOneCard()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
var before = CountCardsInHand(world, playerHand);
|
||||
var playerHand = TestHelpers.FindEntity<PlayerHand>(world);
|
||||
var before = TestHelpers.CountCardsInHand(world, playerHand);
|
||||
|
||||
world.Commands.Enqueue(new HitCommand());
|
||||
group.RunLogical();
|
||||
|
||||
var after = CountCardsInHand(world, playerHand);
|
||||
var after = TestHelpers.CountCardsInHand(world, playerHand);
|
||||
after.Should().Be(before + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stand_AdvancesToDealerTurn()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
|
@ -130,15 +123,13 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void NewRound_ResetsPhase()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// Play a round.
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
world.Commands.Enqueue(new StandCommand());
|
||||
group.RunLogical();
|
||||
|
||||
// Start new round.
|
||||
world.Commands.Enqueue(new NewRoundCommand());
|
||||
group.RunLogical();
|
||||
|
||||
|
|
@ -150,15 +141,15 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void DeterministicSeed_ProducesSameDeal()
|
||||
{
|
||||
var (world1, group1) = SetupGame(seed: 42);
|
||||
var (world1, group1) = TestHelpers.SetupGame(seed: 42);
|
||||
world1.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group1.RunLogical();
|
||||
var cards1 = GetAllCards(world1);
|
||||
var cards1 = TestHelpers.GetAllCards(world1);
|
||||
|
||||
var (world2, group2) = SetupGame(seed: 42);
|
||||
var (world2, group2) = TestHelpers.SetupGame(seed: 42);
|
||||
world2.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group2.RunLogical();
|
||||
var cards2 = GetAllCards(world2);
|
||||
var cards2 = TestHelpers.GetAllCards(world2);
|
||||
|
||||
cards1.Should().Equal(cards2);
|
||||
}
|
||||
|
|
@ -166,20 +157,17 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlayerBust_LosesBet()
|
||||
{
|
||||
// Use a seed that produces a bust-prone hand, then hit repeatedly.
|
||||
var (world, group) = SetupGame(seed: 12345);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 12345);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
// Hit until bust or stand.
|
||||
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
||||
{
|
||||
world.Commands.Enqueue(new HitCommand());
|
||||
group.RunLogical();
|
||||
}
|
||||
|
||||
// Game should have resolved.
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.RoundOver);
|
||||
}
|
||||
|
|
@ -187,7 +175,7 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void Serialization_RoundTrips()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
|
@ -204,63 +192,4 @@ public class GameFlowTests
|
|||
state.CurrentBet.Should().Be(10);
|
||||
state.RoundNumber.Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame(uint seed = 42)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new DeckSetupSystem());
|
||||
group.Add(new DealSystem());
|
||||
group.Add(new PlayerBustCheckSystem());
|
||||
group.Add(new DealerSystem());
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
Phase = GamePhase.Betting,
|
||||
Result = RoundResult.None,
|
||||
Chips = 100,
|
||||
CurrentBet = 0,
|
||||
RoundNumber = 1,
|
||||
Seed = seed
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
private static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
private static int CountCardsInHand(World world, Entity handEntity)
|
||||
{
|
||||
// Holds relationship: Source = card entity, Target = hand entity.
|
||||
// GetSources returns all card entities that have a Holds pointing to handEntity.
|
||||
return world.GetSources<Holds>(handEntity).Count;
|
||||
}
|
||||
|
||||
private static List<(Suit, Rank)> GetAllCards(World world)
|
||||
{
|
||||
var cards = new List<(Suit, Rank)>();
|
||||
var query = world.Query().With<Card>().Build();
|
||||
world.ForEach(query, (Entity e, ref Card card) =>
|
||||
{
|
||||
cards.Add((card.Suit, card.Rank));
|
||||
});
|
||||
cards.Sort((a, b) =>
|
||||
{
|
||||
int cmp = a.Item1.CompareTo(b.Item1);
|
||||
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using FluentAssertions;
|
||||
using Game.Blackjack;
|
||||
using OECS;
|
||||
using R3;
|
||||
using OECS.PlayTest;
|
||||
using Xunit;
|
||||
|
||||
namespace Game.Blackjack.Tests;
|
||||
|
|
@ -14,167 +14,73 @@ public class PlayTests
|
|||
[Fact]
|
||||
public void Play_BasicStrategy()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
|
||||
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||
{
|
||||
var card = world.ReadComponent<Card>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||
});
|
||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||
{
|
||||
var state = world.ReadComponent<GameState>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||
});
|
||||
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
var agent = new BasicStrategyAgent();
|
||||
log.Header = $"Blackjack: Basic Strategy (seed=42, agent={agent})";
|
||||
|
||||
RunUntilDone(world, group, agent, log);
|
||||
var log = RunUntilDone(world, group, agent,
|
||||
$"Blackjack: Basic Strategy (seed=42, agent={agent})");
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("blackjack_basic_strategy.playlog", log);
|
||||
var path = log.SaveTo("blackjack_basic_strategy.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_RandomAgent()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = SetupGame(seed: 123);
|
||||
|
||||
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||
{
|
||||
var card = world.ReadComponent<Card>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||
});
|
||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||
{
|
||||
var state = world.ReadComponent<GameState>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||
});
|
||||
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 123);
|
||||
var agent = new RandomBlackjackAgent();
|
||||
log.Header = $"Blackjack: Random Agent (seed=123, agent={agent})";
|
||||
|
||||
RunUntilDone(world, group, agent, log);
|
||||
var log = RunUntilDone(world, group, agent,
|
||||
$"Blackjack: Random Agent (seed=123, agent={agent})");
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("blackjack_random_agent.playlog", log);
|
||||
var path = log.SaveTo("blackjack_random_agent.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_WeightedPool()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = SetupGame(seed: 77);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 77);
|
||||
|
||||
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||
{
|
||||
var card = world.ReadComponent<Card>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Card = {card} on {change.Entity}");
|
||||
});
|
||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||
{
|
||||
var state = world.ReadComponent<GameState>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} GameState = {state}");
|
||||
});
|
||||
|
||||
var pool = new WeightedAgentPool();
|
||||
var pool = new WeightedAgentPool<BlackjackDecision>();
|
||||
pool.Add(new BasicStrategyAgent(), 7);
|
||||
pool.Add(new RandomBlackjackAgent(), 3);
|
||||
|
||||
var agent = pool.Pick();
|
||||
log.Header = $"Blackjack: Weighted Pool (seed=77, agent={agent})";
|
||||
var log = RunUntilDone(world, group, agent,
|
||||
$"Blackjack: Weighted Pool (seed=77, agent={agent})");
|
||||
|
||||
RunUntilDone(world, group, agent, log);
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("blackjack_weighted_pool.playlog", log);
|
||||
var path = log.SaveTo("blackjack_weighted_pool.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
// ── Play Log ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class PlayLog
|
||||
{
|
||||
public string Header { get; set; } = "";
|
||||
public readonly List<string> RoundSummaries = new();
|
||||
public readonly List<string> Decisions = new();
|
||||
public readonly List<string> Reactivity = new();
|
||||
public string FinalSnapshot { get; set; } = "";
|
||||
|
||||
public string Build()
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(Header);
|
||||
sb.AppendLine(new string('=', Header.Length));
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Round Summaries ---");
|
||||
for (int i = 0; i < RoundSummaries.Count; i++)
|
||||
sb.AppendLine($" Round {i + 1}: {RoundSummaries[i]}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Decisions ---");
|
||||
for (int i = 0; i < Decisions.Count; i++)
|
||||
sb.AppendLine($" {i + 1}. {Decisions[i]}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Reactivity ---");
|
||||
foreach (var entry in Reactivity)
|
||||
sb.AppendLine($" {entry}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Final State ---");
|
||||
sb.AppendLine(FinalSnapshot);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Round Runner ──────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame(uint seed)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new DeckSetupSystem());
|
||||
group.Add(new DealSystem());
|
||||
group.Add(new PlayerBustCheckSystem());
|
||||
group.Add(new DealerSystem());
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
Phase = GamePhase.Betting,
|
||||
Result = RoundResult.None,
|
||||
Chips = StartingChips,
|
||||
CurrentBet = 0,
|
||||
RoundNumber = 1,
|
||||
Seed = seed
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs rounds until the player runs out of chips (can't afford minimum bet)
|
||||
/// or doubles their starting chips.
|
||||
/// </summary>
|
||||
private static void RunUntilDone(World world, SystemGroup group, IBlackjackAgent agent, PlayLog log)
|
||||
private static PlayLog RunUntilDone(World world, SystemGroup group,
|
||||
IAgent<BlackjackDecision> agent, string header)
|
||||
{
|
||||
var log = new PlayLog { Header = header };
|
||||
|
||||
using var capture = new ObservableCapture(world);
|
||||
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
|
||||
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet}");
|
||||
|
||||
int totalRounds = 0;
|
||||
int wins = 0;
|
||||
int losses = 0;
|
||||
int pushes = 0;
|
||||
var roundSummaries = new List<string>();
|
||||
var decisions = new List<string>();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
int chips = state.Chips;
|
||||
|
||||
// Stop conditions.
|
||||
if (chips < BetAmount)
|
||||
{
|
||||
log.Header += $" — BUSTED after {totalRounds} rounds";
|
||||
|
|
@ -185,25 +91,23 @@ public class PlayTests
|
|||
log.Header += $" — DOUBLED after {totalRounds} rounds";
|
||||
break;
|
||||
}
|
||||
|
||||
// Safety: max 200 rounds to prevent infinite loops.
|
||||
if (totalRounds >= 200)
|
||||
{
|
||||
log.Header += $" — MAX ROUNDS ({totalRounds})";
|
||||
break;
|
||||
}
|
||||
|
||||
// Place bet and run dealing.
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = BetAmount });
|
||||
group.RunLogical();
|
||||
|
||||
// Player turn: agent decides hit/stand.
|
||||
int roundHits = 0;
|
||||
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn)
|
||||
int playerTurnSafety = 0;
|
||||
while (world.ReadSingleton<GameState>().Phase == GamePhase.PlayerTurn && playerTurnSafety < 52)
|
||||
{
|
||||
playerTurnSafety++;
|
||||
var total = HandUtil.CalculateHand(world, new PlayerHand());
|
||||
var decision = agent.Decide(world);
|
||||
log.Decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
|
||||
decisions.Add($"R{totalRounds + 1} Hand={total}, Decision={decision}");
|
||||
|
||||
if (decision == BlackjackDecision.Hit)
|
||||
{
|
||||
|
|
@ -218,12 +122,10 @@ public class PlayTests
|
|||
group.RunLogical();
|
||||
}
|
||||
|
||||
// Record round result.
|
||||
state = world.ReadSingleton<GameState>();
|
||||
int newChips = state.Chips;
|
||||
int delta = newChips - chips;
|
||||
string summary = $"{state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}";
|
||||
log.RoundSummaries.Add(summary);
|
||||
roundSummaries.Add($"Round {totalRounds + 1}: {state.Result} | Bet={BetAmount} | Chips: {chips}→{newChips} ({delta:+0;-#}) | Hits: {roundHits}");
|
||||
|
||||
switch (state.Result)
|
||||
{
|
||||
|
|
@ -242,75 +144,22 @@ public class PlayTests
|
|||
|
||||
totalRounds++;
|
||||
|
||||
// Start next round.
|
||||
world.Commands.Enqueue(new NewRoundCommand());
|
||||
group.RunLogical();
|
||||
}
|
||||
|
||||
log.Header += $" | W:{wins} L:{losses} P:{pushes}";
|
||||
}
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────
|
||||
log.AddSection("Round Summaries", roundSummaries);
|
||||
log.AddSection("Decisions", decisions);
|
||||
log.AddSection("Reactivity", capture.GetLogLines());
|
||||
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
|
||||
|
||||
private static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
sb.AppendLine($"Phase: {state.Phase}");
|
||||
sb.AppendLine($"Chips: {state.Chips}, Bet: {state.CurrentBet}");
|
||||
sb.AppendLine($"Round: {state.RoundNumber}");
|
||||
sb.AppendLine($"Result: {state.Result}");
|
||||
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
if (playerHand != Entity.Null)
|
||||
{
|
||||
sb.AppendLine($"Player ({HandUtil.CalculateHand(world, new PlayerHand())}):");
|
||||
foreach (var card in GetHandCards(world, playerHand))
|
||||
sb.AppendLine($" {card}");
|
||||
}
|
||||
|
||||
var dealerHand = FindEntity<DealerHand>(world);
|
||||
if (dealerHand != Entity.Null)
|
||||
{
|
||||
sb.AppendLine($"Dealer ({HandUtil.CalculateHand(world, new DealerHand())}):");
|
||||
foreach (var card in GetHandCards(world, dealerHand))
|
||||
sb.AppendLine($" {card}");
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
private static List<string> GetHandCards(World world, Entity hand)
|
||||
{
|
||||
var cards = new List<string>();
|
||||
foreach (var cardEntity in world.GetSources<Holds>(hand))
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
cards.Add($"{card.Rank} of {card.Suit}");
|
||||
}
|
||||
return cards;
|
||||
return log;
|
||||
}
|
||||
|
||||
// ── File I/O ──────────────────────────────────────────────────────
|
||||
|
||||
private static string SavePlayLog(string filename, PlayLog log)
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, filename);
|
||||
File.WriteAllText(path, log.Build());
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void ReadAndVerify(string path)
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
|
|
@ -326,12 +175,7 @@ public class PlayTests
|
|||
|
||||
private enum BlackjackDecision { Hit, Stand }
|
||||
|
||||
private interface IBlackjackAgent
|
||||
{
|
||||
BlackjackDecision Decide(World world);
|
||||
}
|
||||
|
||||
private sealed class BasicStrategyAgent : IBlackjackAgent
|
||||
private sealed class BasicStrategyAgent : IAgent<BlackjackDecision>
|
||||
{
|
||||
public BlackjackDecision Decide(World world)
|
||||
{
|
||||
|
|
@ -341,7 +185,7 @@ public class PlayTests
|
|||
public override string ToString() => "BasicStrategy";
|
||||
}
|
||||
|
||||
private sealed class RandomBlackjackAgent : IBlackjackAgent
|
||||
private sealed class RandomBlackjackAgent : IAgent<BlackjackDecision>
|
||||
{
|
||||
private static readonly Random _rng = new();
|
||||
public BlackjackDecision Decide(World world)
|
||||
|
|
@ -352,30 +196,4 @@ public class PlayTests
|
|||
}
|
||||
public override string ToString() => "Random";
|
||||
}
|
||||
|
||||
private sealed class WeightedAgentPool
|
||||
{
|
||||
private readonly List<(IBlackjackAgent Agent, int Weight)> _agents = new();
|
||||
private int _totalWeight;
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
public void Add(IBlackjackAgent agent, int weight)
|
||||
{
|
||||
_agents.Add((agent, weight));
|
||||
_totalWeight += weight;
|
||||
}
|
||||
|
||||
public IBlackjackAgent Pick()
|
||||
{
|
||||
int roll = _rng.Next(_totalWeight);
|
||||
int cumulative = 0;
|
||||
foreach (var (agent, weight) in _agents)
|
||||
{
|
||||
cumulative += weight;
|
||||
if (roll < cumulative)
|
||||
return agent;
|
||||
}
|
||||
return _agents[^1].Agent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using FluentAssertions;
|
||||
using Game.Blackjack;
|
||||
using OECS;
|
||||
using R3;
|
||||
using OECS.PlayTest;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
|
@ -24,9 +24,9 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_InitialState()
|
||||
{
|
||||
var (world, _) = SetupGame();
|
||||
var (world, _) = TestHelpers.SetupGame();
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("Phase: Betting");
|
||||
|
|
@ -37,12 +37,12 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_AfterDeal()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("Phase: PlayerTurn");
|
||||
|
|
@ -56,7 +56,7 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_AfterHit()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
|
@ -64,17 +64,16 @@ public class SnapshotTests
|
|||
world.Commands.Enqueue(new HitCommand());
|
||||
group.RunLogical();
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
// With seed 42, one hit may bust. Just verify the game resolved.
|
||||
snapshot.Should().NotContain("Phase: Dealing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_AfterStand()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
|
@ -82,7 +81,7 @@ public class SnapshotTests
|
|||
world.Commands.Enqueue(new StandCommand());
|
||||
group.RunLogical();
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("Phase: RoundOver");
|
||||
|
|
@ -92,23 +91,11 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Log_ReactivityDuringRound()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var log = new List<string>();
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.ObserveEntityChanges().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[entity] {change}");
|
||||
});
|
||||
|
||||
world.ObserveComponentChanges<Card>().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[card] {change}");
|
||||
});
|
||||
|
||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[gamestate] {change}");
|
||||
});
|
||||
using var capture = new ObservableCapture(world);
|
||||
capture.FormatWith<Card>(c => $"{c.Rank} of {c.Suit}");
|
||||
capture.FormatWith<GameState>(s => $"{s.Phase} Chips={s.Chips} Bet={s.CurrentBet} Round={s.RoundNumber}");
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
|
@ -116,24 +103,23 @@ public class SnapshotTests
|
|||
world.Commands.Enqueue(new StandCommand());
|
||||
group.RunLogical();
|
||||
|
||||
var logText = string.Join("\n", log);
|
||||
var logText = string.Join("\n", capture.GetLogLines());
|
||||
_output.WriteLine(logText);
|
||||
|
||||
// Should have entity creations (deck, hands, cards) and component changes.
|
||||
log.Should().Contain(l => l.Contains("EntityAdded"));
|
||||
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
|
||||
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||
capture.GetLogLines().Should().Contain(l => l.Contains("EntityAdded"));
|
||||
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Card"));
|
||||
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_RoundTrip_PreservesCoreState()
|
||||
{
|
||||
var (world, group) = SetupGame(seed: 42);
|
||||
var (world, group) = TestHelpers.SetupGame(seed: 42);
|
||||
|
||||
world.Commands.Enqueue(new PlaceBetCommand { Amount = 10 });
|
||||
group.RunLogical();
|
||||
|
||||
var before = SnapshotWorld(world);
|
||||
var before = TestHelpers.SnapshotWorld(world);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
|
@ -142,118 +128,19 @@ public class SnapshotTests
|
|||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
var after = SnapshotWorld(world2);
|
||||
var after = TestHelpers.SnapshotWorld(world2);
|
||||
|
||||
_output.WriteLine("=== Before ===");
|
||||
_output.WriteLine(before);
|
||||
_output.WriteLine("=== After ===");
|
||||
_output.WriteLine(after);
|
||||
|
||||
// Core game state must round-trip.
|
||||
var state = world2.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.PlayerTurn);
|
||||
state.Chips.Should().Be(90);
|
||||
state.CurrentBet.Should().Be(10);
|
||||
state.RoundNumber.Should().Be(1);
|
||||
|
||||
// Card entities must be preserved.
|
||||
after.Should().Contain("Card entities: 52");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame(uint seed = 42)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new DeckSetupSystem());
|
||||
group.Add(new DealSystem());
|
||||
group.Add(new PlayerBustCheckSystem());
|
||||
group.Add(new DealerSystem());
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
Phase = GamePhase.Betting,
|
||||
Result = RoundResult.None,
|
||||
Chips = 100,
|
||||
CurrentBet = 0,
|
||||
RoundNumber = 1,
|
||||
Seed = seed
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces a human-readable textual snapshot of the world state.
|
||||
/// </summary>
|
||||
private static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// GameState singleton.
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
sb.AppendLine($"Phase: {state.Phase}");
|
||||
sb.AppendLine($"Chips: {state.Chips}");
|
||||
sb.AppendLine($"Bet: {state.CurrentBet}");
|
||||
sb.AppendLine($"Round: {state.RoundNumber}");
|
||||
sb.AppendLine($"Result: {state.Result}");
|
||||
sb.AppendLine($"Seed: {state.Seed}");
|
||||
|
||||
// Deck entity.
|
||||
var deckEntity = FindEntity<Deck>(world);
|
||||
if (deckEntity != Entity.Null)
|
||||
{
|
||||
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
|
||||
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
|
||||
}
|
||||
|
||||
// Player hand.
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
if (playerHand != Entity.Null)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(playerHand).ToList();
|
||||
sb.AppendLine($"Player hand: {cards.Count} cards");
|
||||
foreach (var cardEntity in cards)
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||
}
|
||||
}
|
||||
|
||||
// Dealer hand.
|
||||
var dealerHand = FindEntity<DealerHand>(world);
|
||||
if (dealerHand != Entity.Null)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(dealerHand).ToList();
|
||||
sb.AppendLine($"Dealer hand: {cards.Count} cards");
|
||||
foreach (var cardEntity in cards)
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||
}
|
||||
}
|
||||
|
||||
// All entities.
|
||||
int entityCount = 0;
|
||||
using (var iter = world.Select<Card>())
|
||||
{
|
||||
while (iter.MoveNext()) entityCount++;
|
||||
}
|
||||
sb.AppendLine($"Card entities: {entityCount}");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
using Game.Blackjack;
|
||||
using OECS;
|
||||
using System.Text;
|
||||
|
||||
namespace Game.Blackjack.Tests;
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
private const int StartingChips = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fresh Blackjack world with systems registered and GameState singleton initialized.
|
||||
/// </summary>
|
||||
public static (World World, SystemGroup Group) SetupGame(uint seed = 42)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new DeckSetupSystem());
|
||||
group.Add(new DealSystem());
|
||||
group.Add(new PlayerBustCheckSystem());
|
||||
group.Add(new DealerSystem());
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
Phase = GamePhase.Betting,
|
||||
Result = RoundResult.None,
|
||||
Chips = StartingChips,
|
||||
CurrentBet = 0,
|
||||
RoundNumber = 1,
|
||||
Seed = seed
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable textual snapshot of the world state.
|
||||
/// </summary>
|
||||
public static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
sb.AppendLine($"Phase: {state.Phase}");
|
||||
sb.AppendLine($"Chips: {state.Chips}");
|
||||
sb.AppendLine($"Bet: {state.CurrentBet}");
|
||||
sb.AppendLine($"Round: {state.RoundNumber}");
|
||||
sb.AppendLine($"Result: {state.Result}");
|
||||
sb.AppendLine($"Seed: {state.Seed}");
|
||||
|
||||
var deckEntity = FindEntity<Deck>(world);
|
||||
if (deckEntity != Entity.Null)
|
||||
{
|
||||
var cardsInDeck = world.GetSources<InDeck>(deckEntity).ToList();
|
||||
sb.AppendLine($"Cards in deck: {cardsInDeck.Count}");
|
||||
}
|
||||
|
||||
var playerHand = FindEntity<PlayerHand>(world);
|
||||
if (playerHand != Entity.Null)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(playerHand).ToList();
|
||||
sb.AppendLine($"Player hand: {cards.Count} cards");
|
||||
foreach (var cardEntity in cards)
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||
}
|
||||
}
|
||||
|
||||
var dealerHand = FindEntity<DealerHand>(world);
|
||||
if (dealerHand != Entity.Null)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(dealerHand).ToList();
|
||||
sb.AppendLine($"Dealer hand: {cards.Count} cards");
|
||||
foreach (var cardEntity in cards)
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
sb.AppendLine($" {card.Rank} of {card.Suit}");
|
||||
}
|
||||
}
|
||||
|
||||
int entityCount = 0;
|
||||
using (var iter = world.Select<Card>())
|
||||
{
|
||||
while (iter.MoveNext()) entityCount++;
|
||||
}
|
||||
sb.AppendLine($"Card entities: {entityCount}");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
return world.FindEntity<T>();
|
||||
}
|
||||
|
||||
public static int CountCardsInHand(World world, Entity handEntity)
|
||||
{
|
||||
return world.GetSources<Holds>(handEntity).Count;
|
||||
}
|
||||
|
||||
public static List<string> GetHandCards(World world, Entity hand)
|
||||
{
|
||||
var cards = new List<string>();
|
||||
foreach (var cardEntity in world.GetSources<Holds>(hand))
|
||||
{
|
||||
var card = world.ReadComponent<Card>(cardEntity);
|
||||
cards.Add($"{card.Rank} of {card.Suit}");
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
public static List<(Suit, Rank)> GetAllCards(World world)
|
||||
{
|
||||
var cards = new List<(Suit, Rank)>();
|
||||
using (var iter = world.Select<Card>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
var card = iter.Val1;
|
||||
cards.Add((card.Suit, card.Rank));
|
||||
}
|
||||
}
|
||||
cards.Sort((a, b) =>
|
||||
{
|
||||
int cmp = a.Item1.CompareTo(b.Item1);
|
||||
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ public struct HitCommand : ICommand
|
|||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
return;
|
||||
|
|
@ -25,9 +25,8 @@ public struct HitCommand : ICommand
|
|||
internal static void DrawCard<THand>(World world)
|
||||
where THand : struct
|
||||
{
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
var deckEntity = FindEntity<Deck>(world, singletonEntity);
|
||||
var handEntity = FindEntity<THand>(world, singletonEntity);
|
||||
var deckEntity = world.FindEntity<Deck>();
|
||||
var handEntity = world.FindEntity<THand>();
|
||||
|
||||
if (deckEntity == Entity.Null || handEntity == Entity.Null)
|
||||
return;
|
||||
|
|
@ -41,18 +40,15 @@ public struct HitCommand : ICommand
|
|||
|
||||
// Remove from deck, add to hand.
|
||||
world.RemoveComponent<InDeck>(cardEntity);
|
||||
world.AddComponent(cardEntity, new Holds { Source = cardEntity, Target = handEntity });
|
||||
world.AddComponent(cardEntity, new Holds { Target = handEntity });
|
||||
|
||||
// Flush so subsequent DrawCard calls see the updated state.
|
||||
world.FlushPendingMutations();
|
||||
}
|
||||
|
||||
internal static Entity FindEntity<T>(World world, Entity singletonEntity)
|
||||
internal static Entity FindEntity<T>(World world)
|
||||
where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
return world.FindEntity<T>();
|
||||
}
|
||||
}
|
||||
|
|
@ -17,38 +17,34 @@ public struct NewRoundCommand : ICommand
|
|||
return;
|
||||
|
||||
// Clear hands from previous round.
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
var handEntities = new List<Entity>();
|
||||
using (var iter = world.Select<PlayerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
handEntities.Add(iter.Entity);
|
||||
}
|
||||
}
|
||||
using (var iter = world.Select<DealerHand>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != singletonEntity)
|
||||
handEntities.Add(iter.CurrentEntity);
|
||||
handEntities.Add(iter.Entity);
|
||||
}
|
||||
}
|
||||
foreach (var hand in handEntities)
|
||||
{
|
||||
var cards = world.GetSources<Holds>(hand);
|
||||
var deckEntity = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
||||
var deckEntity = world.FindEntity<Deck>();
|
||||
foreach (var card in cards)
|
||||
{
|
||||
world.RemoveComponent<Holds>(card);
|
||||
world.AddComponent(card, new InDeck { Source = card, Target = deckEntity });
|
||||
world.AddComponent(card, new InDeck { Target = deckEntity });
|
||||
}
|
||||
}
|
||||
|
||||
state.RoundNumber++;
|
||||
state.Phase = GamePhase.Betting;
|
||||
state.Result = RoundResult.None;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,5 @@ public struct PlaceBetCommand : ICommand
|
|||
state.CurrentBet = Amount;
|
||||
state.Chips -= Amount;
|
||||
state.Phase = GamePhase.Dealing;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,5 @@ public struct StandCommand : ICommand
|
|||
return;
|
||||
|
||||
state.Phase = GamePhase.DealerTurn;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,5 @@ namespace Game.Blackjack;
|
|||
[MessagePackObject]
|
||||
public record struct Holds : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Source { get; set; }
|
||||
[Key(1)] public Entity Target { get; set; }
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -10,6 +10,5 @@ namespace Game.Blackjack;
|
|||
[MessagePackObject]
|
||||
public record struct InDeck : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Source { get; set; }
|
||||
[Key(1)] public Entity Target { get; set; }
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ namespace Game.Blackjack;
|
|||
/// </summary>
|
||||
public class DealSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Dealing)
|
||||
|
|
@ -23,7 +23,6 @@ public class DealSystem : ISystem
|
|||
HitCommand.DrawCard<DealerHand>(world);
|
||||
|
||||
mutableState.Phase = GamePhase.PlayerTurn;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace Game.Blackjack;
|
|||
/// </summary>
|
||||
public class DealerSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.DealerTurn)
|
||||
|
|
@ -18,10 +18,12 @@ public class DealerSystem : ISystem
|
|||
|
||||
// Dealer must hit on 16 and below, stand on 17+.
|
||||
int dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||
while (dealerTotal < 17)
|
||||
int safety = 0;
|
||||
while (dealerTotal < 17 && safety < 52)
|
||||
{
|
||||
HitCommand.DrawCard<DealerHand>(world);
|
||||
dealerTotal = HandUtil.CalculateHand(world, DealerHandTag.Instance);
|
||||
safety++;
|
||||
}
|
||||
|
||||
int playerTotal = HandUtil.CalculateHand(world, PlayerHandTag.Instance);
|
||||
|
|
@ -49,6 +51,5 @@ public class DealerSystem : ISystem
|
|||
mutableState.Chips += mutableState.CurrentBet;
|
||||
}
|
||||
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,18 +9,17 @@ namespace Game.Blackjack;
|
|||
/// </summary>
|
||||
public class DeckSetupSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Dealing)
|
||||
return;
|
||||
|
||||
// Only create deck entities if they don't exist yet.
|
||||
var singletonEntity = World.SingletonEntity;
|
||||
bool hasDeck = false;
|
||||
using (var iter = world.Select<Deck>())
|
||||
{
|
||||
hasDeck = iter.MoveNext() && iter.CurrentEntity != singletonEntity;
|
||||
hasDeck = iter.MoveNext();
|
||||
}
|
||||
|
||||
if (!hasDeck)
|
||||
|
|
@ -36,7 +35,7 @@ public class DeckSetupSystem : ISystem
|
|||
{
|
||||
var cardEntity = world.CreateEntity();
|
||||
world.AddComponent(cardEntity, new Card { Suit = suit, Rank = rank });
|
||||
world.AddComponent(cardEntity, new InDeck { Source = cardEntity, Target = deckEntity });
|
||||
world.AddComponent(cardEntity, new InDeck { Target = deckEntity });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +49,7 @@ public class DeckSetupSystem : ISystem
|
|||
|
||||
// Shuffle the deck using mulberry32 with the current seed.
|
||||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
var deckEntity2 = HitCommand.FindEntity<Deck>(world, singletonEntity);
|
||||
var deckEntity2 = world.FindEntity<Deck>();
|
||||
var cards = world.GetSources<InDeck>(deckEntity2).ToArray();
|
||||
Shuffle(world, deckEntity2, cards, ref mutableState.Seed);
|
||||
}
|
||||
|
|
@ -61,27 +60,10 @@ public class DeckSetupSystem : ISystem
|
|||
for (int i = cardEntities.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Mulberry32.NextInt(ref seed, 0, i);
|
||||
|
||||
// Swap the InDeck relationship targets (cards are always in the deck,
|
||||
// so there's nothing to swap except the cards themselves — but we
|
||||
// shuffle the card order conceptually by removing and re-adding
|
||||
// InDeck components in shuffled order). Actually, since InDeck
|
||||
// is just a tag, we just re-shuffle the order in the source collection.
|
||||
// The simplest approach: no need to swap component data; we just
|
||||
// need to ensure the cards are iterated in shuffled order.
|
||||
// We'll swap the card entities in the array.
|
||||
(cardEntities[i], cardEntities[j]) = (cardEntities[j], cardEntities[i]);
|
||||
}
|
||||
|
||||
// Now remove all InDeck and re-add in shuffled order so GetSources
|
||||
// returns them in shuffled order.
|
||||
for (int i = cardEntities.Length - 1; i >= 0; i--)
|
||||
{
|
||||
world.RemoveComponent<InDeck>(cardEntities[i]);
|
||||
}
|
||||
for (int i = 0; i < cardEntities.Length; i++)
|
||||
{
|
||||
world.AddComponent(cardEntities[i], new InDeck { Source = cardEntities[i], Target = deckEntity });
|
||||
}
|
||||
// Reorder the source set in-place — no component add/remove needed.
|
||||
world.ReorderSources<InDeck>(deckEntity, cardEntities);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,18 +22,7 @@ public static class HandUtil
|
|||
where T : struct
|
||||
{
|
||||
// Find the hand entity.
|
||||
Entity handEntity = Entity.Null;
|
||||
using (var iter = world.Select<T>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
{
|
||||
handEntity = iter.CurrentEntity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var handEntity = world.FindEntity<T>();
|
||||
|
||||
if (handEntity == Entity.Null)
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace Game.Blackjack;
|
|||
/// </summary>
|
||||
public class PlayerBustCheckSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayerTurn)
|
||||
|
|
@ -21,6 +21,5 @@ public class PlayerBustCheckSystem : ISystem
|
|||
ref var mutableState = ref world.GetSingleton<GameState>();
|
||||
mutableState.Phase = GamePhase.RoundOver;
|
||||
mutableState.Result = RoundResult.PlayerBust;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
using FluentAssertions;
|
||||
using Game.CardWars;
|
||||
using OECS;
|
||||
using Xunit;
|
||||
|
||||
namespace Game.CardWars.Tests;
|
||||
|
||||
public class CardEffectTests
|
||||
{
|
||||
private static (World World, SystemGroup Group) Setup()
|
||||
{
|
||||
CardEffectRegistry.ClearForTests();
|
||||
return GameFactory.Create(playerCount: 2, seed: 42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarriorEffect_AddsHornWhenAnotherWarriorExists()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
// Play two warriors.
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var warrior1 = FindCardWithEffect(world, hand, CardEffect.Warrior);
|
||||
var warrior2 = FindSecondCardWithEffect(world, hand, CardEffect.Warrior);
|
||||
|
||||
if (warrior1 == Entity.Null || warrior2 == Entity.Null) return; // Not enough warriors in deck.
|
||||
|
||||
var def = world.ReadComponent<CardDef>(warrior1);
|
||||
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = warrior1,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// First warrior: no other warrior, no horn.
|
||||
GameUtil.GetFieldCards(world, player).Should().Contain(warrior1);
|
||||
world.HasComponent<Horn>(warrior1).Should().BeFalse(because: "no other warrior on field");
|
||||
|
||||
// Play second warrior.
|
||||
var def2 = world.ReadComponent<CardDef>(warrior2);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = warrior2,
|
||||
Rank = def2.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Second warrior has another warrior, should get horn.
|
||||
// But Wait — the effect checks "other" warriors on field. The first warrior
|
||||
// is on field, so the second warrior should get a horn.
|
||||
world.HasComponent<Horn>(warrior2).Should().BeTrue(because: "another warrior exists on field");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MercenaryEffect_SetsPendingChoice()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
// Play first mercenary (no effect).
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var merc = FindCardWithEffect(world, hand, CardEffect.Mercenary);
|
||||
if (merc == Entity.Null) return;
|
||||
|
||||
var def = world.ReadComponent<CardDef>(merc);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = merc,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Play second mercenary. We need another one from a drawn card.
|
||||
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
|
||||
GameUtil.DrawCard(world, publicDeck, player);
|
||||
var newHand = GameUtil.GetHandCards(world, player);
|
||||
var merc2 = FindCardWithEffect(world, newHand, CardEffect.Mercenary);
|
||||
if (merc2 == Entity.Null) return;
|
||||
|
||||
var def2 = world.ReadComponent<CardDef>(merc2);
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = merc2,
|
||||
Rank = def2.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Should have a pending Mercenary choice.
|
||||
world.HasSingleton<PendingMercenary>().Should().BeTrue();
|
||||
var pending = world.ReadSingleton<PendingMercenary>();
|
||||
pending.CardEntity.Should().Be(merc2);
|
||||
|
||||
// Resolve the choice (pick a target).
|
||||
world.Commands.Enqueue(new ResolveChoiceCommand { Target = merc });
|
||||
group.RunLogical();
|
||||
|
||||
// The pending should be cleared.
|
||||
world.HasSingleton<PendingMercenary>().Should().BeFalse();
|
||||
}
|
||||
|
||||
private static Entity FindCardWithEffect(World world, List<Entity> cards, CardEffect effect)
|
||||
{
|
||||
foreach (var c in cards)
|
||||
{
|
||||
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
|
||||
return c;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
private static Entity FindSecondCardWithEffect(World world, List<Entity> cards, CardEffect effect)
|
||||
{
|
||||
Entity first = Entity.Null;
|
||||
foreach (var c in cards)
|
||||
{
|
||||
if (world.HasComponent<CardDef>(c) && world.ReadComponent<CardDef>(c).Effect == effect)
|
||||
{
|
||||
if (first == Entity.Null) first = c;
|
||||
else return c;
|
||||
}
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Game.CardWars.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.CardWars\CardWars.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
using FluentAssertions;
|
||||
using Game.CardWars;
|
||||
using OECS;
|
||||
using Xunit;
|
||||
|
||||
namespace Game.CardWars.Tests;
|
||||
|
||||
public class GameSetupTests
|
||||
{
|
||||
private static (World World, SystemGroup Group) Setup()
|
||||
{
|
||||
// Reset registry state between tests.
|
||||
CardEffectRegistry.ClearForTests();
|
||||
return GameFactory.Create(playerCount: 2, seed: 42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateGame_CreatesPlayersAndDecks()
|
||||
{
|
||||
var (world, _) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
state.Phase.Should().Be(GamePhase.PlayPhase);
|
||||
state.PlayerCount.Should().Be(2);
|
||||
state.RoundNumber.Should().Be(1);
|
||||
|
||||
GameUtil.FindEntity<PublicDeck>(world).Should().NotBe(Entity.Null);
|
||||
GameUtil.FindAllEntities<Player>(world).Should().HaveCount(2);
|
||||
GameUtil.FindAllEntities<Leader>(world).Should().HaveCount(2);
|
||||
GameUtil.FindAllEntities<Banner>(world).Should().HaveCount(2);
|
||||
GameUtil.FindAllEntities<Castle>(world).Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartingHand_HasThreeCards()
|
||||
{
|
||||
var (world, _) = Setup();
|
||||
|
||||
foreach (var player in GameUtil.FindAllEntities<Player>(world))
|
||||
{
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
hand.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayCard_MovesCardFromHandToField()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var cardInHand = hand[0];
|
||||
var def = world.ReadComponent<CardDef>(cardInHand);
|
||||
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = cardInHand,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
GameUtil.GetFieldCards(world, player).Should().Contain(cardInHand);
|
||||
var cardData = world.ReadComponent<Card>(cardInHand);
|
||||
cardData.Rank.Should().Be(def.Ranks[0]);
|
||||
cardData.FaceDown.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayCardFaceDown_CardIsHidden()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var cardInHand = hand[0];
|
||||
var def = world.ReadComponent<CardDef>(cardInHand);
|
||||
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = cardInHand,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = true,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipCard_RevealsFaceDownCard()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
var cardInHand = hand[0];
|
||||
var def = world.ReadComponent<CardDef>(cardInHand);
|
||||
|
||||
// Play face-down.
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = cardInHand,
|
||||
Rank = def.Ranks[0],
|
||||
FaceDown = true,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
// Advance to flip phase: both players skip.
|
||||
world.Commands.Enqueue(new SkipPlayCommand());
|
||||
group.RunLogical();
|
||||
world.Commands.Enqueue(new SkipPlayCommand());
|
||||
group.RunLogical();
|
||||
|
||||
world.ReadSingleton<GameState>().Phase.Should().Be(GamePhase.FlipPhase);
|
||||
|
||||
// Flip it.
|
||||
world.Commands.Enqueue(new FlipCardCommand { CardEntity = cardInHand });
|
||||
group.RunLogical();
|
||||
|
||||
world.ReadComponent<Card>(cardInHand).FaceDown.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkipPlay_AdvancesTurn()
|
||||
{
|
||||
var (world, group) = Setup();
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
int originalPlayer = state.CurrentPlayerIndex;
|
||||
|
||||
world.Commands.Enqueue(new SkipPlayCommand());
|
||||
group.RunLogical();
|
||||
|
||||
state = world.ReadSingleton<GameState>();
|
||||
state.CurrentPlayerIndex.Should().Be((originalPlayer + 1) % 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Game.CardWars;
|
||||
using OECS;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Game.CardWars.Tests;
|
||||
|
||||
public class PlayLogTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public PlayLogTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneRound_ThreePlayers_PlayLog()
|
||||
{
|
||||
CardEffectRegistry.ClearForTests();
|
||||
var (world, group) = GameFactory.Create(playerCount: 3, seed: 12345);
|
||||
var log = new StringBuilder();
|
||||
|
||||
var players = GameUtil.FindAllEntities<Player>(world);
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
|
||||
LogSection(log, "=== CardWars Play Log — Round 1 ===");
|
||||
log.AppendLine($"Seed: {state.Seed} | Players: {state.PlayerCount} | Starting: P{state.StartingPlayerIndex}");
|
||||
log.AppendLine();
|
||||
|
||||
// ── Initial hands ──
|
||||
LogSection(log, "── Starting Hands ──");
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
log.AppendLine($" P{i}: {DescribeHand(world, GameUtil.GetHandCards(world, players[i]))}");
|
||||
log.AppendLine();
|
||||
|
||||
// ── Play Phase ──
|
||||
LogSection(log, "── Play Phase ──");
|
||||
RunPlayPhase(world, group, players, log);
|
||||
|
||||
// ── Flip Phase ──
|
||||
LogSection(log, "── Flip Phase ──");
|
||||
RunFlipPhase(world, group, players, log);
|
||||
|
||||
// ── Scoring (capture powers BEFORE cleanup) ──
|
||||
LogSection(log, "── Scoring ──");
|
||||
var powers = new int[players.Count];
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
powers[i] = GameUtil.CalculatePower(world, players[i]);
|
||||
|
||||
// Advance to scoring phase and let systems run.
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
mutable.Phase = GamePhase.Scoring;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
group.RunLogical(); // ScoringSystem → CleanupSystem
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
log.AppendLine($" P{i} power: {powers[i]}");
|
||||
|
||||
int winnerIdx = Array.IndexOf(powers, powers.Max());
|
||||
log.AppendLine($" Winner: P{winnerIdx} (power={powers[winnerIdx]})");
|
||||
|
||||
// Find the castle that was just awarded (it has HeldBy but no Castle component).
|
||||
var wonCastleEntity = world.GetSources<HeldBy>(players[winnerIdx])
|
||||
.FirstOrDefault(c => !world.HasComponent<Castle>(c) && !world.HasComponent<CardDef>(c));
|
||||
log.AppendLine($" Castle awarded to P{winnerIdx}");
|
||||
log.AppendLine();
|
||||
|
||||
// ── Cleanup already ran ──
|
||||
LogSection(log, "── After Cleanup ──");
|
||||
state = world.ReadSingleton<GameState>();
|
||||
log.AppendLine($" Phase: {state.Phase} | Round: {state.RoundNumber}");
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
var hand = GameUtil.GetHandCards(world, players[i]);
|
||||
log.AppendLine($" P{i} hand: {DescribeHand(world, hand)}");
|
||||
}
|
||||
log.AppendLine();
|
||||
|
||||
// ── Castle counts ──
|
||||
LogSection(log, "── Castle Tally ──");
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// A won castle: has HeldBy to this player, no CardDef, no Card, no Leader, no Banner.
|
||||
var castleEntities = world.GetSources<HeldBy>(players[i])
|
||||
.Where(c => !world.HasComponent<CardDef>(c)
|
||||
&& !world.HasComponent<Card>(c)
|
||||
&& !world.HasComponent<Leader>(c)
|
||||
&& !world.HasComponent<Banner>(c))
|
||||
.ToList();
|
||||
log.AppendLine($" P{i}: {castleEntities.Count} castle(s)");
|
||||
}
|
||||
log.AppendLine();
|
||||
|
||||
_output.WriteLine(log.ToString());
|
||||
|
||||
state.Phase.Should().BeOneOf(GamePhase.PlayPhase, GamePhase.GameOver);
|
||||
}
|
||||
|
||||
// ── Play phase: greedy agent, plays best card each turn ──
|
||||
|
||||
private static void RunPlayPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
int startIdx = state.StartingPlayerIndex;
|
||||
int current = startIdx;
|
||||
int safety = 0;
|
||||
|
||||
while (safety++ < 100)
|
||||
{
|
||||
state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayPhase) break;
|
||||
|
||||
var player = players[state.CurrentPlayerIndex];
|
||||
var hand = GameUtil.GetHandCards(world, player);
|
||||
|
||||
if (hand.Count > 0)
|
||||
{
|
||||
var best = PickBestCard(world, hand);
|
||||
var def = world.ReadComponent<CardDef>(best);
|
||||
int rank = def.Ranks.Max();
|
||||
|
||||
world.Commands.Enqueue(new PlayCardCommand
|
||||
{
|
||||
CardEntity = best,
|
||||
Rank = rank,
|
||||
FaceDown = false,
|
||||
TargetPlayer = null
|
||||
});
|
||||
group.RunLogical();
|
||||
|
||||
log.AppendLine($" P{state.CurrentPlayerIndex} plays [{def.Name}] rank={rank}");
|
||||
|
||||
// Resolve any pending choices automatically.
|
||||
ResolvePendingChoices(world, group, players, log);
|
||||
}
|
||||
else
|
||||
{
|
||||
world.Commands.Enqueue(new SkipPlayCommand());
|
||||
group.RunLogical();
|
||||
log.AppendLine($" P{state.CurrentPlayerIndex} skips (hand empty)");
|
||||
}
|
||||
}
|
||||
|
||||
state = world.ReadSingleton<GameState>();
|
||||
log.AppendLine($" Phase → {state.Phase}");
|
||||
log.AppendLine();
|
||||
}
|
||||
|
||||
// ── Flip phase ──
|
||||
|
||||
private static void RunFlipPhase(World world, SystemGroup group, List<Entity> players, StringBuilder log)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.FlipPhase)
|
||||
{
|
||||
ref var m = ref world.GetSingleton<GameState>();
|
||||
m.Phase = GamePhase.FlipPhase;
|
||||
m.CurrentPlayerIndex = m.StartingPlayerIndex;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
state = world.ReadSingleton<GameState>();
|
||||
var player = players[state.CurrentPlayerIndex];
|
||||
var field = GameUtil.GetFieldCards(world, player);
|
||||
bool anyFaceDown = field.Any(c =>
|
||||
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
|
||||
|
||||
if (anyFaceDown)
|
||||
{
|
||||
var fd = field.First(c =>
|
||||
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
|
||||
world.Commands.Enqueue(new FlipCardCommand { CardEntity = fd });
|
||||
group.RunLogical();
|
||||
var def = world.ReadComponent<CardDef>(fd);
|
||||
log.AppendLine($" P{state.CurrentPlayerIndex} flips [{def.Name}]");
|
||||
ResolvePendingChoices(world, group, players, log);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.AppendLine($" P{state.CurrentPlayerIndex} skips flip (no face-down)");
|
||||
}
|
||||
|
||||
ref var m2 = ref world.GetSingleton<GameState>();
|
||||
m2.CurrentPlayerIndex = (m2.CurrentPlayerIndex + 1) % m2.PlayerCount;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
log.AppendLine();
|
||||
}
|
||||
|
||||
// ── Pending choice auto-resolver ──
|
||||
|
||||
private static void ResolvePendingChoices(World world, SystemGroup group, List<Entity> players, StringBuilder log)
|
||||
{
|
||||
while (PendingChoice.Any(world))
|
||||
{
|
||||
Entity target = PickDefaultTarget(world, players);
|
||||
world.Commands.Enqueue(new ResolveChoiceCommand { Target = target });
|
||||
group.RunLogical();
|
||||
log.AppendLine($" ↳ auto-resolve → {DescribeEntity(world, target)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Entity PickDefaultTarget(World world, List<Entity> players)
|
||||
{
|
||||
if (world.HasSingleton<PendingMercenary>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingMercenary>();
|
||||
return GameUtil.GetFieldCards(world, p.Player)
|
||||
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
||||
}
|
||||
if (world.HasSingleton<PendingDancer>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingDancer>();
|
||||
return GameUtil.GetFieldCards(world, p.Player)
|
||||
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
||||
}
|
||||
if (world.HasSingleton<PendingPaladin>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingPaladin>();
|
||||
var powered = GameUtil.GetFieldCards(world, p.Player)
|
||||
.FirstOrDefault(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0);
|
||||
return powered != Entity.Null ? powered : CardEffectHelpers.GetBanner(world, p.Player);
|
||||
}
|
||||
if (world.HasSingleton<PendingNun>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingNun>();
|
||||
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault();
|
||||
}
|
||||
if (world.HasSingleton<PendingScout>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingScout>();
|
||||
return players.FirstOrDefault(pl => pl != p.Player);
|
||||
}
|
||||
if (world.HasSingleton<PendingPegasus>())
|
||||
{
|
||||
var p = world.ReadSingleton<PendingPegasus>();
|
||||
return GameUtil.GetFieldCards(world, p.Player).FirstOrDefault(c => c != p.CardEntity);
|
||||
}
|
||||
if (world.HasSingleton<PendingCurseMaster>())
|
||||
{
|
||||
return GameUtil.FindAllEntities<Horn>(world).FirstOrDefault();
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private static Entity PickBestCard(World world, List<Entity> hand)
|
||||
{
|
||||
Entity best = Entity.Null;
|
||||
int bestRank = -999;
|
||||
foreach (var c in hand)
|
||||
{
|
||||
int maxRank = world.ReadComponent<CardDef>(c).Ranks.Max();
|
||||
if (maxRank > bestRank) { bestRank = maxRank; best = c; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string DescribeHand(World world, List<Entity> hand)
|
||||
{
|
||||
var cards = hand.Where(c => world.HasComponent<CardDef>(c)).ToList();
|
||||
if (cards.Count == 0) return "(empty)";
|
||||
return string.Join(" ", cards.Select(c =>
|
||||
{
|
||||
var def = world.ReadComponent<CardDef>(c);
|
||||
return $"[{def.Name} {string.Join("/", def.Ranks)}]";
|
||||
}));
|
||||
}
|
||||
|
||||
private static string DescribeEntity(World world, Entity e)
|
||||
{
|
||||
if (e == Entity.Null) return "(none)";
|
||||
if (world.HasComponent<CardDef>(e)) return $"[{world.ReadComponent<CardDef>(e).Name}]";
|
||||
if (world.HasComponent<Player>(e)) return $"P{world.ReadComponent<Player>(e).Index}";
|
||||
if (world.HasComponent<Horn>(e)) return "[Horn token]";
|
||||
if (world.HasComponent<Skull>(e)) return "[Skull token]";
|
||||
if (world.HasComponent<Banner>(e)) return "[Banner]";
|
||||
return e.ToString();
|
||||
}
|
||||
|
||||
private static void LogSection(StringBuilder log, string title) => log.AppendLine(title);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Loads card definitions from a CSV and creates card entities in the world.
|
||||
/// </summary>
|
||||
public static class CardDataLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates all card definition entities in the world from CSV data.
|
||||
/// Returns a list of (entity, CardDef) pairs.
|
||||
/// </summary>
|
||||
public static List<(Entity Entity, CardDef Def)> LoadDefinitions(World world, string csv)
|
||||
{
|
||||
var results = new List<(Entity, CardDef)>();
|
||||
var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (lines.Length < 2)
|
||||
return results;
|
||||
|
||||
// Skip header line.
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
|
||||
var def = ParseLine(line);
|
||||
if (def.Name == null) continue;
|
||||
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, def);
|
||||
results.Add((entity, def));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static CardDef ParseLine(string line)
|
||||
{
|
||||
// Simple CSV parsing: name,ranks,effect
|
||||
// Ranks are semicolon-separated.
|
||||
var parts = SplitCsv(line);
|
||||
if (parts.Length < 3)
|
||||
return default;
|
||||
|
||||
var name = parts[0].Trim();
|
||||
var rankStrs = parts[1].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var ranks = new int[rankStrs.Length];
|
||||
for (int i = 0; i < rankStrs.Length; i++)
|
||||
ranks[i] = int.Parse(rankStrs[i]);
|
||||
|
||||
var effect = ParseEffect(parts[2].Trim());
|
||||
|
||||
return new CardDef
|
||||
{
|
||||
Name = name,
|
||||
Ranks = ranks,
|
||||
Effect = effect,
|
||||
Kind = CardKind.Public
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] SplitCsv(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ',' && !inQuotes)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
result.Add(current.ToString());
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static CardEffect ParseEffect(string effect)
|
||||
{
|
||||
return effect switch
|
||||
{
|
||||
string s when s.Contains("战士") => CardEffect.Warrior,
|
||||
string s when s.Contains("弓手") => CardEffect.Archer,
|
||||
string s when s.Contains("佣兵") => CardEffect.Mercenary,
|
||||
string s when s.Contains("商人") => CardEffect.Merchant,
|
||||
string s when s.Contains("舞姬") => CardEffect.Dancer,
|
||||
string s when s.Contains("圣骑士") => CardEffect.Paladin,
|
||||
string s when s.Contains("飞马") => CardEffect.Pegasus,
|
||||
string s when s.Contains("诅咒师") => CardEffect.CurseMaster,
|
||||
string s when s.Contains("魔导士") => CardEffect.Mage,
|
||||
string s when s.Contains("公主") => CardEffect.Princess,
|
||||
string s when s.Contains("决斗家") => CardEffect.Duelist,
|
||||
string s when s.Contains("修女") => CardEffect.Nun,
|
||||
string s when s.Contains("斥候") => CardEffect.Scout,
|
||||
string s when s.Contains("女巫") => CardEffect.Witch,
|
||||
string s when s.Contains("盗贼") => CardEffect.Thief,
|
||||
_ => CardEffect.None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// A card effect that can be registered with the effect dispatcher.
|
||||
/// </summary>
|
||||
public interface ICardEffect
|
||||
{
|
||||
CardEffect Effect { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Called when the card is played face-up or flipped face-up.
|
||||
/// Return true if fully resolved. Return false if a pending component
|
||||
/// was set and the effect needs a player command to continue.
|
||||
/// </summary>
|
||||
bool Resolve(World world, Entity card, Entity player, Entity owner);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the player provides a choice to continue a pending effect.
|
||||
/// The effect handler is responsible for reading the correct pending
|
||||
/// component type from the singleton and removing it when done.
|
||||
/// </summary>
|
||||
bool ResolveChoice(World world, Entity target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry of card effects, ordered by registration.
|
||||
/// </summary>
|
||||
public static class CardEffectRegistry
|
||||
{
|
||||
private static readonly List<ICardEffect> _effects = new();
|
||||
private static bool _frozen;
|
||||
|
||||
public static void Add<TEffect>() where TEffect : ICardEffect, new()
|
||||
{
|
||||
if (_frozen)
|
||||
throw new InvalidOperationException("Cannot add effects after the registry is frozen.");
|
||||
_effects.Add(new TEffect());
|
||||
}
|
||||
|
||||
public static void Freeze() => _frozen = true;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a card effect. Returns true if fully resolved.
|
||||
/// </summary>
|
||||
public static bool Dispatch(World world, CardEffect effect, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
foreach (var e in _effects)
|
||||
{
|
||||
if (e.Effect == effect)
|
||||
return e.Resolve(world, card, player, owner);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the handler for the currently pending effect and resolve it.
|
||||
/// </summary>
|
||||
public static bool DispatchChoice(World world, Entity target)
|
||||
{
|
||||
// Find which pending component is active and dispatch to the matching handler.
|
||||
if (world.HasSingleton<PendingMercenary>())
|
||||
return DispatchByEffect(world, CardEffect.Mercenary, target);
|
||||
if (world.HasSingleton<PendingDancer>())
|
||||
return DispatchByEffect(world, CardEffect.Dancer, target);
|
||||
if (world.HasSingleton<PendingPaladin>())
|
||||
return DispatchByEffect(world, CardEffect.Paladin, target);
|
||||
if (world.HasSingleton<PendingNun>())
|
||||
return DispatchByEffect(world, CardEffect.Nun, target);
|
||||
if (world.HasSingleton<PendingScout>())
|
||||
return DispatchByEffect(world, CardEffect.Scout, target);
|
||||
if (world.HasSingleton<PendingPegasus>())
|
||||
return DispatchByEffect(world, CardEffect.Pegasus, target);
|
||||
if (world.HasSingleton<PendingCurseMaster>())
|
||||
return DispatchByEffect(world, CardEffect.CurseMaster, target);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool DispatchByEffect(World world, CardEffect effect, Entity target)
|
||||
{
|
||||
foreach (var e in _effects)
|
||||
{
|
||||
if (e.Effect == effect)
|
||||
return e.ResolveChoice(world, target);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ClearForTests()
|
||||
{
|
||||
_effects.Clear();
|
||||
_frozen = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Simple effects that resolve immediately.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public class WarriorEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Warrior;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (HasOtherOnField(world, owner, card, CardEffect.Warrior))
|
||||
CardEffectHelpers.AddHorn(world, card);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class ArcherEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Archer;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (HasOtherOnField(world, owner, card, CardEffect.Archer))
|
||||
{
|
||||
var banner = CardEffectHelpers.GetBanner(world, owner);
|
||||
if (banner != Entity.Null) CardEffectHelpers.AddHorn(world, banner);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class MercenaryEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Mercenary;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
if (!HasOtherOnField(world, owner, card, CardEffect.Mercenary))
|
||||
return true;
|
||||
|
||||
world.SetSingleton(new PendingMercenary { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingMercenary>();
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, target);
|
||||
world.RemoveSingleton<PendingMercenary>();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasOtherOnField(World world, Entity player, Entity self, CardEffect effect)
|
||||
{
|
||||
return GameUtil.GetFieldCards(world, player)
|
||||
.Any(c => c != self && world.HasComponent<CardDef>(c)
|
||||
&& world.ReadComponent<CardDef>(c).Effect == effect);
|
||||
}
|
||||
}
|
||||
|
||||
public class MerchantEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Merchant;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
|
||||
world.Commands.Enqueue(new ExtraActionCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class DancerEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Dancer;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var powered = GameUtil.GetFieldCards(world, owner)
|
||||
.Where(c => world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).Rank > 0)
|
||||
.ToList();
|
||||
|
||||
if (powered.Count == 0) return true;
|
||||
if (powered.Count <= 2)
|
||||
{
|
||||
foreach (var c in powered)
|
||||
CardEffectHelpers.AddHorn(world, c);
|
||||
return true;
|
||||
}
|
||||
|
||||
world.SetSingleton(new PendingDancer { CardEntity = card, Player = player, Step = 0 });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingDancer>();
|
||||
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddHorn(world, target);
|
||||
|
||||
if (pending.Step == 0)
|
||||
{
|
||||
world.SetSingleton(new PendingDancer { CardEntity = pending.CardEntity, Player = pending.Player, Step = 1 });
|
||||
return false;
|
||||
}
|
||||
|
||||
world.RemoveSingleton<PendingDancer>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class PaladinEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Paladin;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingPaladin { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
if (target != Entity.Null)
|
||||
CardEffectHelpers.AddHorn(world, target);
|
||||
world.RemoveSingleton<PendingPaladin>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class PrincessEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Princess;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new ReplaceCastleCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class DuelistEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Duelist;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.Commands.Enqueue(new EndPlayPhaseCommand());
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class NunEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Nun;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var fieldCards = GameUtil.GetFieldCards(world, owner);
|
||||
if (fieldCards.Count == 0)
|
||||
{
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = player, Kind = null });
|
||||
return true;
|
||||
}
|
||||
|
||||
world.SetSingleton(new PendingNun { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingNun>();
|
||||
if (target != Entity.Null)
|
||||
{
|
||||
var discardDeck = CardEffectHelpers.GetDiscardDeck(world, pending.Player);
|
||||
world.RemoveComponent<OnField>(target);
|
||||
if (world.HasComponent<Card>(target))
|
||||
world.RemoveComponent<Card>(target);
|
||||
world.AddComponent(target, new InDeck { Target = discardDeck });
|
||||
}
|
||||
world.Commands.Enqueue(new DrawCardCommand { Player = pending.Player, Kind = null });
|
||||
world.RemoveSingleton<PendingNun>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ScoutEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Scout;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingScout { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingScout>();
|
||||
if (target != Entity.Null && world.HasComponent<Player>(target))
|
||||
{
|
||||
foreach (var c in GameUtil.GetFieldCards(world, target))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(c, out var cd) && cd.FaceDown)
|
||||
{
|
||||
ref var cardRef = ref world.GetComponent<Card>(c);
|
||||
cardRef.FaceDown = false;
|
||||
world.MarkModified<Card>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.RemoveSingleton<PendingScout>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class WitchEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Witch;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
var banner = CardEffectHelpers.GetBanner(world, owner);
|
||||
if (banner != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, banner);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
public class ThiefEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Thief;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Face-down flip effects
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public class PegasusEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Pegasus;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingPegasus { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingPegasus>();
|
||||
if (target != Entity.Null && target != pending.CardEntity)
|
||||
{
|
||||
world.RemoveComponent<OnField>(target);
|
||||
if (world.HasComponent<Card>(target))
|
||||
world.RemoveComponent<Card>(target);
|
||||
world.AddComponent(target, new HeldBy { Target = pending.Player });
|
||||
}
|
||||
world.RemoveSingleton<PendingPegasus>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class CurseMasterEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.CurseMaster;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
world.SetSingleton(new PendingCurseMaster { CardEntity = card, Player = player });
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target)
|
||||
{
|
||||
var pending = world.ReadSingleton<PendingCurseMaster>();
|
||||
if (target != Entity.Null && world.HasComponent<Horn>(target))
|
||||
{
|
||||
var placedTargets = world.GetSources<PlacedOn>(target).ToList();
|
||||
world.DestroyEntity(target);
|
||||
foreach (var pt in placedTargets)
|
||||
{
|
||||
var skull = world.CreateEntity();
|
||||
world.AddComponent(skull, new Skull());
|
||||
world.AddComponent(skull, new PlacedOn { Target = pt });
|
||||
}
|
||||
}
|
||||
world.RemoveSingleton<PendingCurseMaster>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class MageEffect : ICardEffect
|
||||
{
|
||||
public CardEffect Effect => CardEffect.Mage;
|
||||
|
||||
public bool Resolve(World world, Entity card, Entity player, Entity owner)
|
||||
{
|
||||
Entity best = Entity.Null;
|
||||
int bestRank = int.MinValue;
|
||||
foreach (var p in GameUtil.FindAllEntities<Player>(world))
|
||||
{
|
||||
foreach (var c in GameUtil.GetFieldCards(world, p))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(c, out var cd) && !cd.FaceDown && cd.Rank > bestRank)
|
||||
{
|
||||
bestRank = cd.Rank;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best != Entity.Null)
|
||||
CardEffectHelpers.AddSkull(world, best);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResolveChoice(World world, Entity target) => true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Shared helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public static class CardEffectHelpers
|
||||
{
|
||||
public static void AddHorn(World world, Entity target)
|
||||
{
|
||||
var horn = world.CreateEntity();
|
||||
world.AddComponent(horn, new Horn());
|
||||
world.AddComponent(horn, new PlacedOn { Target = target });
|
||||
}
|
||||
|
||||
public static void AddSkull(World world, Entity target)
|
||||
{
|
||||
var skull = world.CreateEntity();
|
||||
world.AddComponent(skull, new Skull());
|
||||
world.AddComponent(skull, new PlacedOn { Target = target });
|
||||
}
|
||||
|
||||
public static Entity GetBanner(World world, Entity player)
|
||||
{
|
||||
using var iter = world.Select<Banner>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity == World.SingletonEntity) continue;
|
||||
var onField = world.GetSources<OnField>(player);
|
||||
if (onField.Contains(iter.CurrentEntity))
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
public static Entity GetDiscardDeck(World world, Entity player)
|
||||
{
|
||||
using var iter = world.Select<FactionDeck>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>Game.CardWars</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS.SourceGen\OECS.SourceGen.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
[MessagePackObject]
|
||||
public struct SkipPlayCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayPhase) return;
|
||||
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
mutable.PlayPhaseEnded = true;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct FlipCardCommand : ICommand
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.FlipPhase) return;
|
||||
if (!world.IsAlive(CardEntity)) return;
|
||||
if (!world.HasComponent<Card>(CardEntity)) return;
|
||||
|
||||
ref var card = ref world.GetComponent<Card>(CardEntity);
|
||||
if (!card.FaceDown) return;
|
||||
|
||||
card.FaceDown = false;
|
||||
world.MarkModified<Card>(CardEntity);
|
||||
|
||||
var def = world.ReadComponent<CardDef>(CardEntity);
|
||||
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
|
||||
// Resolve flip effects via registry.
|
||||
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, player);
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct SkipFlipCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
// Skip is only valid if no face-down cards remain.
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.FlipPhase) return;
|
||||
|
||||
Entity player = PlayCardCommand.FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
var fieldCards = GameUtil.GetFieldCards(world, player);
|
||||
bool hasFaceDown = fieldCards.Any(c =>
|
||||
world.HasComponent<Card>(c) && world.ReadComponent<Card>(c).FaceDown);
|
||||
if (hasFaceDown) return;
|
||||
|
||||
// Advance is handled by FlipPhaseSystem.
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct DrawCardCommand : ICommand
|
||||
{
|
||||
[Key(0)] public Entity Player;
|
||||
[Key(1)] public CardKind? Kind;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
if (!world.IsAlive(Player)) return;
|
||||
|
||||
var kind = Kind ?? CardKind.Public;
|
||||
var deck = GetDeck(world, kind);
|
||||
if (deck == Entity.Null) return;
|
||||
|
||||
var cards = world.GetSources<InDeck>(deck);
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
// Recycle: for now, faction discards recycle to the same deck.
|
||||
GameUtil.RecycleDiscard(world, deck, deck);
|
||||
cards = world.GetSources<InDeck>(deck);
|
||||
if (cards.Count == 0) return;
|
||||
}
|
||||
|
||||
GameUtil.DrawCard(world, deck, Player);
|
||||
}
|
||||
|
||||
private static Entity GetDeck(World world, CardKind kind)
|
||||
{
|
||||
if (kind == CardKind.Public)
|
||||
{
|
||||
using var iter = world.Select<PublicDeck>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using var iter = world.Select<FactionDeck>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct ExtraActionCommand : ICommand
|
||||
{
|
||||
public void Execute(World world) { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct EndPlayPhaseCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayPhase) return;
|
||||
world.SetSingleton(new PendingDuelist { PlayerIndex = state.CurrentPlayerIndex });
|
||||
}
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public struct ReplaceCastleCommand : ICommand
|
||||
{
|
||||
public void Execute(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var castles = GameUtil.FindAllEntities<Castle>(world);
|
||||
foreach (var c in castles)
|
||||
world.DestroyEntity(c);
|
||||
|
||||
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
|
||||
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
|
||||
var seed = state.Seed;
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
int idx = Mulberry32.NextInt(ref seed, 0, colors.Length - 1);
|
||||
var castle = world.CreateEntity();
|
||||
world.AddComponent(castle, new Castle { Color = colors[idx] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player makes a choice to resolve a pending card effect.
|
||||
/// The target entity depends on the effect:
|
||||
/// - Mercenary, Dancer, Paladin, Nun: a field card entity
|
||||
/// - Scout: a player entity
|
||||
/// - Pegasus: a field card to return (or null)
|
||||
/// - CurseMaster: a horn token entity
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct ResolveChoiceCommand : ICommand
|
||||
{
|
||||
[Key(0)] public Entity Target;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
if (!PendingChoice.Any(world)) return;
|
||||
CardEffectRegistry.DispatchChoice(world, Target);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a fresh game with the given player count and seed.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct NewGameCommand : ICommand
|
||||
{
|
||||
[Key(0)] public int PlayerCount;
|
||||
[Key(1)] public uint Seed;
|
||||
[Key(2)] public string CardDataCsv;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
Phase = GamePhase.Setup,
|
||||
PlayerCount = PlayerCount,
|
||||
CurrentPlayerIndex = 0,
|
||||
StartingPlayerIndex = 0,
|
||||
RoundNumber = 0,
|
||||
Seed = Seed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Play a card from hand to the field, choosing a rank.
|
||||
/// Resolves on-play effects via the CardEffectRegistry.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PlayCardCommand : ICommand
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public int Rank;
|
||||
[Key(2)] public bool FaceDown;
|
||||
[Key(3)] public Entity? TargetPlayer;
|
||||
|
||||
public void Execute(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase is not GamePhase.PlayPhase) return;
|
||||
if (!world.IsAlive(CardEntity)) return;
|
||||
|
||||
Entity player = FindPlayerByIndex(world, state.CurrentPlayerIndex);
|
||||
if (player == Entity.Null) return;
|
||||
|
||||
// Verify the card is in the player's hand.
|
||||
if (!world.HasComponent<HeldBy>(CardEntity)) return;
|
||||
var heldBy = world.ReadComponent<HeldBy>(CardEntity);
|
||||
if (heldBy.Target != player) return;
|
||||
|
||||
var def = world.ReadComponent<CardDef>(CardEntity);
|
||||
|
||||
// Determine target: Witch/Thief are played to another player's field.
|
||||
Entity fieldOwner = def.Effect is CardEffect.Witch or CardEffect.Thief
|
||||
? (TargetPlayer ?? player)
|
||||
: player;
|
||||
|
||||
// Move from hand to field.
|
||||
world.RemoveComponent<HeldBy>(CardEntity);
|
||||
world.AddComponent(CardEntity, new OnField { Target = fieldOwner });
|
||||
world.AddComponent(CardEntity, new Card { Rank = Rank, FaceDown = FaceDown });
|
||||
|
||||
// Resolve on-play effects via registry.
|
||||
if (!FaceDown)
|
||||
CardEffectRegistry.Dispatch(world, def.Effect, CardEntity, player, fieldOwner);
|
||||
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
|
||||
public static Entity FindPlayerByIndex(World world, int index)
|
||||
{
|
||||
using var iter = world.Select<Player>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity == World.SingletonEntity) continue;
|
||||
if (iter.Current1.Index == index)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a player's banner entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Banner { }
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Instance data for a card that has been played to the field.
|
||||
/// Cards in hand/deck only have CardDef; this is added when played.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct Card
|
||||
{
|
||||
/// <summary>The rank chosen for this play (one of CardDef.Ranks).</summary>
|
||||
[Key(0)] public int Rank;
|
||||
|
||||
/// <summary>Whether the card is face-down on the field.</summary>
|
||||
[Key(1)] public bool FaceDown;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Static card definition — name, possible ranks, effect, and kind.
|
||||
/// Cards in the deck/hand only have CardDef. When played to the field,
|
||||
/// they also get a Card component with the chosen rank.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct CardDef
|
||||
{
|
||||
[Key(0)] public string Name;
|
||||
[Key(1)] public int[] Ranks;
|
||||
[Key(2)] public CardEffect Effect;
|
||||
[Key(3)] public CardKind Kind;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// A castle that can be won each round.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct Castle
|
||||
{
|
||||
[Key(0)] public CastleColor Color;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a faction deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct FactionDeck { }
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a card entity to the player who holds it in hand.
|
||||
/// Added to the card: Source=card, Target=player.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct HeldBy : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a horn token. Can be placed on cards, banners, or leaders.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Horn { }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a card entity to a deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct InDeck : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component marking a card entity as the leader card.
|
||||
/// The leader is always on the field and contributes its rank to power.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Leader { }
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a card entity to the player entity whose field it's on.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct OnField : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Per-effect pending choice components.
|
||||
// Each is placed on the singleton entity when a card effect
|
||||
// requires player input. Their presence blocks game advancement.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Mercenary: choose a combatant to give a skull.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingMercenary
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Dancer: choose up to 2 combatants for horn. Step tracks progress.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingDancer
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
[Key(2)] public int Step; // 0 = first choice, 1 = second choice
|
||||
}
|
||||
|
||||
/// <summary>Paladin: choose a combatant or banner for horn.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingPaladin
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Nun: choose a field card to discard.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingNun
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Scout: choose an opponent player to scout.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingScout
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>Pegasus: choose a field card to return to hand (or null to pass).</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingPegasus
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
/// <summary>CurseMaster: choose a horn token to turn into skull.</summary>
|
||||
[MessagePackObject]
|
||||
public record struct PendingCurseMaster
|
||||
{
|
||||
[Key(0)] public Entity CardEntity;
|
||||
[Key(1)] public Entity Player;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helper to detect any pending component on the singleton.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
public static class PendingChoice
|
||||
{
|
||||
/// <summary>Returns true if any pending choice component exists on the singleton.</summary>
|
||||
public static bool Any(World world)
|
||||
{
|
||||
return world.HasSingleton<PendingMercenary>()
|
||||
|| world.HasSingleton<PendingDancer>()
|
||||
|| world.HasSingleton<PendingPaladin>()
|
||||
|| world.HasSingleton<PendingNun>()
|
||||
|| world.HasSingleton<PendingScout>()
|
||||
|| world.HasSingleton<PendingPegasus>()
|
||||
|| world.HasSingleton<PendingCurseMaster>();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Placed on the singleton when a 决斗家 (Duelist) is played.
|
||||
/// The play phase ends when the turn comes back to this player.
|
||||
/// Removed automatically when the phase transitions.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PendingDuelist
|
||||
{
|
||||
[Key(0)] public int PlayerIndex;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using MessagePack;
|
||||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Relationship from a horn/skull token entity to the target entity it's placed on.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct PlacedOn : IRelationship
|
||||
{
|
||||
[Key(0)] public Entity Target { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag + index for a player entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct Player
|
||||
{
|
||||
[Key(0)] public int Index;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for the public deck entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct PublicDeck { }
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Tag component for a skull token. Can be placed on cards, banners, or leaders.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public struct Skull { }
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
namespace Game.CardWars;
|
||||
|
||||
public enum CardKind : byte
|
||||
{
|
||||
Public = 0,
|
||||
Faction = 1
|
||||
}
|
||||
|
||||
public enum CardEffect : byte
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// On-play effects
|
||||
Warrior,
|
||||
Archer,
|
||||
Mercenary,
|
||||
Merchant,
|
||||
Dancer,
|
||||
Paladin,
|
||||
Princess,
|
||||
Duelist,
|
||||
Nun,
|
||||
Scout,
|
||||
Witch,
|
||||
Thief,
|
||||
|
||||
// On-flip effects (played face-down)
|
||||
Pegasus,
|
||||
CurseMaster,
|
||||
Mage
|
||||
}
|
||||
|
||||
public enum CastleColor : byte
|
||||
{
|
||||
Black = 0,
|
||||
White = 1,
|
||||
Blue = 2,
|
||||
Brown = 3,
|
||||
Yellow = 4,
|
||||
Indigo = 5,
|
||||
Gold = 6
|
||||
}
|
||||
|
||||
public enum GamePhase : byte
|
||||
{
|
||||
Setup = 0,
|
||||
PlayPhase = 1,
|
||||
FlipPhase = 2,
|
||||
Scoring = 3,
|
||||
Cleanup = 4,
|
||||
GameOver = 5
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating a fully configured game world and system group.
|
||||
/// </summary>
|
||||
public static class GameFactory
|
||||
{
|
||||
public static string DefaultCardData =>
|
||||
"name,ranks,effect\n" +
|
||||
"战士,3;4;4;5,战士\n" +
|
||||
"弓手,3;4;4;5,弓手\n" +
|
||||
"佣兵,4;5;5;6,佣兵\n" +
|
||||
"商人,0;1,商人\n" +
|
||||
"舞姬,0;1,舞姬\n" +
|
||||
"圣骑士,2;3,圣骑士\n" +
|
||||
"飞马,2;3,飞马\n" +
|
||||
"诅咒师,1;2,诅咒师\n" +
|
||||
"魔导士,0;1,魔导士\n" +
|
||||
"公主,4,公主\n" +
|
||||
"决斗家,4,决斗家\n" +
|
||||
"修女,3,修女\n" +
|
||||
"斥候,2,斥候\n" +
|
||||
"女巫,-1,女巫\n" +
|
||||
"盗贼,-1,盗贼\n";
|
||||
|
||||
public static (World World, SystemGroup Group) Create(int playerCount, uint seed = 42)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
world.Commands.Enqueue(new NewGameCommand
|
||||
{
|
||||
PlayerCount = playerCount,
|
||||
Seed = seed,
|
||||
CardDataCsv = DefaultCardData
|
||||
});
|
||||
|
||||
group.Add(new GameSetupSystem(playerCount, DefaultCardData));
|
||||
group.Add(new PlayPhaseSystem());
|
||||
group.Add(new FlipPhaseSystem());
|
||||
group.Add(new ScoringSystem());
|
||||
group.Add(new CleanupSystem());
|
||||
|
||||
// Run setup.
|
||||
group.RunLogical();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Shared utility methods for querying and manipulating the game world.
|
||||
/// </summary>
|
||||
public static class GameUtil
|
||||
{
|
||||
public static Entity FindEntity<T>(World world) where T : struct
|
||||
{
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
return iter.CurrentEntity;
|
||||
}
|
||||
return Entity.Null;
|
||||
}
|
||||
|
||||
public static List<Entity> FindAllEntities<T>(World world) where T : struct
|
||||
{
|
||||
var list = new List<Entity>();
|
||||
using var iter = world.Select<T>();
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.CurrentEntity != World.SingletonEntity)
|
||||
list.Add(iter.CurrentEntity);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Draw the top card from a deck and add it to the player's hand.</summary>
|
||||
public static bool DrawCard(World world, Entity deck, Entity player)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(deck);
|
||||
if (cards.Count == 0) return false;
|
||||
|
||||
var cardEntity = cards.First();
|
||||
world.RemoveComponent<InDeck>(cardEntity);
|
||||
world.AddComponent(cardEntity, new HeldBy { Target = player });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Shuffle cards in a deck using Fisher-Yates with the given seed.</summary>
|
||||
public static void ShuffleDeck(World world, Entity deck, ref uint seed)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(deck).ToArray();
|
||||
for (int i = cards.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = Mulberry32.NextInt(ref seed, 0, i);
|
||||
(cards[i], cards[j]) = (cards[j], cards[i]);
|
||||
}
|
||||
for (int i = cards.Length - 1; i >= 0; i--)
|
||||
world.RemoveComponent<InDeck>(cards[i]);
|
||||
for (int i = 0; i < cards.Length; i++)
|
||||
world.AddComponent(cards[i], new InDeck { Target = deck });
|
||||
}
|
||||
|
||||
/// <summary>Get all field cards for a player.</summary>
|
||||
public static List<Entity> GetFieldCards(World world, Entity player)
|
||||
{
|
||||
return world.GetSources<OnField>(player).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Get all cards in a player's hand.</summary>
|
||||
public static List<Entity> GetHandCards(World world, Entity player)
|
||||
{
|
||||
return world.GetSources<HeldBy>(player).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Calculate a player's total battle power.</summary>
|
||||
public static int CalculatePower(World world, Entity player)
|
||||
{
|
||||
int total = 0;
|
||||
|
||||
// Leader power.
|
||||
using var leaderIter = world.Select<Leader>();
|
||||
while (leaderIter.MoveNext())
|
||||
{
|
||||
if (leaderIter.CurrentEntity == World.SingletonEntity) continue;
|
||||
var onFieldSources = world.GetSources<OnField>(player);
|
||||
if (onFieldSources.Contains(leaderIter.CurrentEntity))
|
||||
{
|
||||
if (world.TryGetComponent<Card>(leaderIter.CurrentEntity, out var leaderCard))
|
||||
total += leaderCard.Rank;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the player's banner.
|
||||
var banner = CardEffectHelpers.GetBanner(world, player);
|
||||
bool bannerHasHorn = banner != Entity.Null && world.HasComponent<Horn>(banner);
|
||||
bool bannerHasSkull = banner != Entity.Null && world.HasComponent<Skull>(banner);
|
||||
|
||||
// Field cards.
|
||||
var fieldCards = GetFieldCards(world, player);
|
||||
int poweredCount = 0;
|
||||
foreach (var card in fieldCards)
|
||||
{
|
||||
if (!world.TryGetComponent<Card>(card, out var cardData)) continue;
|
||||
if (cardData.FaceDown) continue;
|
||||
if (cardData.Rank <= 0) continue;
|
||||
|
||||
poweredCount++;
|
||||
int rank = cardData.Rank;
|
||||
if (world.HasComponent<Horn>(card))
|
||||
rank *= 2;
|
||||
if (world.HasComponent<Skull>(card))
|
||||
rank = 0;
|
||||
|
||||
total += rank;
|
||||
}
|
||||
|
||||
if (bannerHasHorn) total += poweredCount;
|
||||
if (bannerHasSkull) total -= poweredCount;
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Move all field cards (except leaders and banners) to the discard pile.</summary>
|
||||
public static void DiscardField(World world, Entity player, Entity discardDeck)
|
||||
{
|
||||
var fieldCards = GetFieldCards(world, player);
|
||||
|
||||
var toDiscard = new List<Entity>();
|
||||
foreach (var card in fieldCards)
|
||||
{
|
||||
// Keep leaders and banners on the field.
|
||||
if (world.HasComponent<Leader>(card)) continue;
|
||||
if (world.HasComponent<Banner>(card)) continue;
|
||||
toDiscard.Add(card);
|
||||
}
|
||||
|
||||
foreach (var card in toDiscard)
|
||||
{
|
||||
world.RemoveComponent<OnField>(card);
|
||||
world.RemoveComponent<Card>(card);
|
||||
world.AddComponent(card, new InDeck { Target = discardDeck });
|
||||
}
|
||||
|
||||
// Destroy horns and skulls placed on discarded cards.
|
||||
var tokenTargets = new HashSet<Entity>();
|
||||
foreach (var card in toDiscard)
|
||||
{
|
||||
foreach (var token in world.GetSources<PlacedOn>(card))
|
||||
tokenTargets.Add(token);
|
||||
}
|
||||
foreach (var token in tokenTargets)
|
||||
world.DestroyEntity(token);
|
||||
}
|
||||
|
||||
/// <summary>Recycle discard pile into draw pile.</summary>
|
||||
public static void RecycleDiscard(World world, Entity drawDeck, Entity discardDeck)
|
||||
{
|
||||
var cards = world.GetSources<InDeck>(discardDeck).ToList();
|
||||
foreach (var card in cards)
|
||||
{
|
||||
world.RemoveComponent<InDeck>(card);
|
||||
world.AddComponent(card, new InDeck { Target = drawDeck });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Mulberry32 PRNG — a fast, high-quality 32-bit random number generator.
|
||||
/// </summary>
|
||||
public static class Mulberry32
|
||||
{
|
||||
public static float NextFloat(ref uint state)
|
||||
{
|
||||
state += 0x6D2B79F5u;
|
||||
uint z = state;
|
||||
z = (z ^ (z >> 15)) * (z | 1u);
|
||||
z ^= z + (z ^ (z >> 7)) * (z | 61u);
|
||||
return (z ^ (z >> 14)) / (float)uint.MaxValue;
|
||||
}
|
||||
|
||||
public static int NextInt(ref uint state, int min, int max)
|
||||
{
|
||||
return (int)(NextFloat(ref state) * (max - min + 1)) + min;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Global game state on the singleton entity.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct GameState
|
||||
{
|
||||
[Key(0)] public GamePhase Phase;
|
||||
[Key(1)] public int PlayerCount;
|
||||
[Key(2)] public int CurrentPlayerIndex;
|
||||
[Key(3)] public int StartingPlayerIndex;
|
||||
[Key(4)] public int RoundNumber;
|
||||
[Key(5)] public uint Seed;
|
||||
[Key(6)] public bool PlayPhaseEnded;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using MessagePack;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player score tracking.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public record struct PlayerScores
|
||||
{
|
||||
[Key(0)] public int[] Scores;
|
||||
[Key(1)] public CastleColor[] WonCastles;
|
||||
[Key(2)] public int Count;
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Discards field cards, clears tokens, draws new hands, checks game over.
|
||||
/// </summary>
|
||||
public class CleanupSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Cleanup) return;
|
||||
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
|
||||
var players = GameUtil.FindAllEntities<Player>(world);
|
||||
var publicDeck = GameUtil.FindEntity<PublicDeck>(world);
|
||||
|
||||
foreach (var player in players)
|
||||
{
|
||||
GameUtil.DiscardField(world, player, publicDeck);
|
||||
|
||||
// Clear tokens on banners.
|
||||
var banner = CardEffectHelpers.GetBanner(world, player);
|
||||
if (banner != Entity.Null)
|
||||
{
|
||||
var tokens = world.GetSources<PlacedOn>(banner).ToList();
|
||||
foreach (var token in tokens)
|
||||
world.DestroyEntity(token);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw 2 public cards per player.
|
||||
foreach (var player in players)
|
||||
{
|
||||
for (int i = 0; i < 2; i++)
|
||||
GameUtil.DrawCard(world, publicDeck, player);
|
||||
}
|
||||
|
||||
// Check game over.
|
||||
int maxScore = 0;
|
||||
foreach (var player in players)
|
||||
{
|
||||
var castles = world.GetSources<HeldBy>(player)
|
||||
.Where(c => world.HasComponent<Castle>(c))
|
||||
.ToList();
|
||||
maxScore = Math.Max(maxScore, castles.Count);
|
||||
}
|
||||
|
||||
if (maxScore >= 4)
|
||||
{
|
||||
mutable.Phase = GamePhase.GameOver;
|
||||
}
|
||||
else
|
||||
{
|
||||
mutable.Phase = GamePhase.PlayPhase;
|
||||
mutable.RoundNumber++;
|
||||
mutable.CurrentPlayerIndex = (mutable.StartingPlayerIndex + 1) % mutable.PlayerCount;
|
||||
mutable.StartingPlayerIndex = mutable.CurrentPlayerIndex;
|
||||
mutable.PlayPhaseEnded = false;
|
||||
}
|
||||
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the flip phase: players take turns flipping cards.
|
||||
/// Blocks if a PendingChoice exists.
|
||||
/// </summary>
|
||||
public class FlipPhaseSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.FlipPhase) return;
|
||||
|
||||
// Block if a pending choice exists.
|
||||
if (PendingChoice.Any(world))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// One-time setup: creates players, leaders, banners, decks, castles, and deals starting hands.
|
||||
/// </summary>
|
||||
public class GameSetupSystem : ISystem
|
||||
{
|
||||
private readonly int _playerCount;
|
||||
private readonly string _cardDataCsv;
|
||||
private bool _hasRun;
|
||||
|
||||
public GameSetupSystem(int playerCount, string cardDataCsv)
|
||||
{
|
||||
_playerCount = playerCount;
|
||||
_cardDataCsv = cardDataCsv;
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
{
|
||||
if (_hasRun) return;
|
||||
_hasRun = true;
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
mutable.PlayerCount = _playerCount;
|
||||
mutable.Phase = GamePhase.PlayPhase;
|
||||
mutable.CurrentPlayerIndex = 0;
|
||||
mutable.StartingPlayerIndex = 0;
|
||||
mutable.RoundNumber = 1;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
|
||||
// Register card effects.
|
||||
CardEffectRegistry.Add<WarriorEffect>();
|
||||
CardEffectRegistry.Add<ArcherEffect>();
|
||||
CardEffectRegistry.Add<MercenaryEffect>();
|
||||
CardEffectRegistry.Add<MerchantEffect>();
|
||||
CardEffectRegistry.Add<DancerEffect>();
|
||||
CardEffectRegistry.Add<PaladinEffect>();
|
||||
CardEffectRegistry.Add<PrincessEffect>();
|
||||
CardEffectRegistry.Add<DuelistEffect>();
|
||||
CardEffectRegistry.Add<NunEffect>();
|
||||
CardEffectRegistry.Add<ScoutEffect>();
|
||||
CardEffectRegistry.Add<WitchEffect>();
|
||||
CardEffectRegistry.Add<ThiefEffect>();
|
||||
CardEffectRegistry.Add<PegasusEffect>();
|
||||
CardEffectRegistry.Add<CurseMasterEffect>();
|
||||
CardEffectRegistry.Add<MageEffect>();
|
||||
CardEffectRegistry.Freeze();
|
||||
|
||||
// Create public deck.
|
||||
var publicDeck = world.CreateEntity();
|
||||
world.AddComponent(publicDeck, new PublicDeck());
|
||||
|
||||
// Load card definitions and create card instances for the public deck.
|
||||
var defs = CardDataLoader.LoadDefinitions(world, _cardDataCsv);
|
||||
foreach (var (defEntity, _) in defs)
|
||||
{
|
||||
world.AddComponent(defEntity, new InDeck { Target = publicDeck });
|
||||
}
|
||||
|
||||
// Shuffle public deck.
|
||||
var seed = state.Seed;
|
||||
GameUtil.ShuffleDeck(world, publicDeck, ref seed);
|
||||
|
||||
// Create the first castle.
|
||||
var castle = world.CreateEntity();
|
||||
world.AddComponent(castle, new Castle { Color = CastleColor.Black });
|
||||
|
||||
// Create players.
|
||||
for (int i = 0; i < _playerCount; i++)
|
||||
{
|
||||
var player = world.CreateEntity();
|
||||
world.AddComponent(player, new Player { Index = i });
|
||||
|
||||
// Create leader card (always on field, rank 0 placeholder).
|
||||
var leader = world.CreateEntity();
|
||||
world.AddComponent(leader, new Leader());
|
||||
world.AddComponent(leader, new CardDef { Name = $"领袖{i}", Ranks = new[] { 0 }, Effect = CardEffect.None, Kind = CardKind.Faction });
|
||||
world.AddComponent(leader, new Card { Rank = 0, FaceDown = false });
|
||||
world.AddComponent(leader, new OnField { Target = player });
|
||||
|
||||
// Create banner.
|
||||
var banner = world.CreateEntity();
|
||||
world.AddComponent(banner, new Banner());
|
||||
world.AddComponent(banner, new OnField { Target = player });
|
||||
|
||||
// Create faction deck.
|
||||
var factionDeck = world.CreateEntity();
|
||||
world.AddComponent(factionDeck, new FactionDeck());
|
||||
}
|
||||
|
||||
// Deal starting hands: 3 public cards per player.
|
||||
foreach (var player in GameUtil.FindAllEntities<Player>(world))
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
GameUtil.DrawCard(world, publicDeck, player);
|
||||
}
|
||||
|
||||
mutable.Seed = seed;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the play phase: players take turns. If a PendingChoice exists,
|
||||
/// blocks advancement until the player resolves it.
|
||||
/// </summary>
|
||||
public class PlayPhaseSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.PlayPhase) return;
|
||||
|
||||
// Block if a pending choice exists.
|
||||
if (PendingChoice.Any(world))
|
||||
return;
|
||||
|
||||
// If the current player has skipped, move to the next player.
|
||||
if (state.PlayPhaseEnded)
|
||||
{
|
||||
state.PlayPhaseEnded = false;
|
||||
AdvanceTurn(world, ref state);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AdvanceTurn(World world, ref GameState state)
|
||||
{
|
||||
int next = (state.CurrentPlayerIndex + 1) % state.PlayerCount;
|
||||
|
||||
// Duelist: if the next player is the one who played duelist, end the phase.
|
||||
if (world.HasSingleton<PendingDuelist>())
|
||||
{
|
||||
var duelist = world.ReadSingleton<PendingDuelist>();
|
||||
if (next == duelist.PlayerIndex)
|
||||
{
|
||||
world.RemoveSingleton<PendingDuelist>();
|
||||
state.Phase = GamePhase.FlipPhase;
|
||||
state.CurrentPlayerIndex = state.StartingPlayerIndex;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state.CurrentPlayerIndex = next;
|
||||
|
||||
// If we've come back to the starting player, everyone has skipped.
|
||||
if (state.CurrentPlayerIndex == state.StartingPlayerIndex)
|
||||
{
|
||||
state.Phase = GamePhase.FlipPhase;
|
||||
world.RemoveSingleton<PendingDuelist>(); // Clean up in case duelist was never triggered.
|
||||
}
|
||||
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using OECS;
|
||||
|
||||
namespace Game.CardWars;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates each player's battle power, awards the castle to the winner,
|
||||
/// and checks for game end.
|
||||
/// </summary>
|
||||
public class ScoringSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
if (state.Phase != GamePhase.Scoring) return;
|
||||
|
||||
ref var mutable = ref world.GetSingleton<GameState>();
|
||||
|
||||
var players = GameUtil.FindAllEntities<Player>(world);
|
||||
if (players.Count == 0) return;
|
||||
|
||||
int bestPower = int.MinValue;
|
||||
Entity winner = Entity.Null;
|
||||
int winnerIndex = -1;
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
int idx = (state.StartingPlayerIndex + i) % players.Count;
|
||||
var player = players[idx];
|
||||
int power = GameUtil.CalculatePower(world, player);
|
||||
|
||||
if (power > bestPower)
|
||||
{
|
||||
bestPower = power;
|
||||
winner = player;
|
||||
winnerIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (winner != Entity.Null)
|
||||
{
|
||||
var castles = GameUtil.FindAllEntities<Castle>(world);
|
||||
if (castles.Count > 0)
|
||||
{
|
||||
var castle = castles[0];
|
||||
world.AddComponent(castle, new HeldBy { Target = winner });
|
||||
world.RemoveComponent<Castle>(castle);
|
||||
}
|
||||
|
||||
var colors = new[] { CastleColor.Black, CastleColor.White, CastleColor.Blue,
|
||||
CastleColor.Brown, CastleColor.Yellow, CastleColor.Indigo, CastleColor.Gold };
|
||||
int colorIdx = Mulberry32.NextInt(ref mutable.Seed, 0, colors.Length - 1);
|
||||
var newCastle = world.CreateEntity();
|
||||
world.AddComponent(newCastle, new Castle { Color = colors[colorIdx] });
|
||||
}
|
||||
|
||||
mutable.Phase = GamePhase.Cleanup;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# CardWars — Design Document
|
||||
|
||||
<!-- Fill in the rules, mechanics, and design notes here. -->
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Game.TicTacToe\TicTacToe.csproj" />
|
||||
<ProjectReference Include="..\OECS.PlayTest\OECS.PlayTest.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void NewGame_HasEmptyBoard()
|
||||
{
|
||||
var (world, _) = SetupGame();
|
||||
var (world, _) = TestHelpers.SetupGame();
|
||||
|
||||
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||
var emptyCount = 0;
|
||||
world.ForEach(query, (Entity e, ref Cell cell) => emptyCount++);
|
||||
foreach (var _ in world.Select(new Query<Cell>().Without<Mark>()))
|
||||
emptyCount++;
|
||||
|
||||
emptyCount.Should().Be(9);
|
||||
}
|
||||
|
|
@ -22,20 +22,19 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlaceMark_ClaimsCell()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
|
||||
group.RunLogical();
|
||||
|
||||
var query = world.Query().With<Cell>().With<Mark>().Build();
|
||||
var markedCount = 0;
|
||||
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||
foreach (var it in world.Select<Cell, Mark>())
|
||||
{
|
||||
markedCount++;
|
||||
cell.Row.Should().Be(0);
|
||||
cell.Col.Should().Be(0);
|
||||
mark.Player.Should().Be(Player.X);
|
||||
});
|
||||
it.Val1.Row.Should().Be(0);
|
||||
it.Val1.Col.Should().Be(0);
|
||||
it.Val2.Player.Should().Be(Player.X);
|
||||
}
|
||||
|
||||
markedCount.Should().Be(1);
|
||||
}
|
||||
|
|
@ -43,7 +42,7 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlaceMark_TogglesPlayer()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
|
||||
group.RunLogical();
|
||||
|
|
@ -57,20 +56,16 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void PlaceMark_CannotOverwriteClaimedCell()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // X
|
||||
group.RunLogical();
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 }); // O tries same cell
|
||||
group.RunLogical();
|
||||
|
||||
// Only one mark should exist at (0,0), and it should still be X.
|
||||
var query = world.Query().With<Cell>().With<Mark>().Build();
|
||||
var marks = new List<(int Row, int Col, Player Player)>();
|
||||
world.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||
{
|
||||
marks.Add((cell.Row, cell.Col, mark.Player));
|
||||
});
|
||||
foreach (var it in world.Select<Cell, Mark>())
|
||||
marks.Add((it.Val1.Row, it.Val1.Col, it.Val2.Player));
|
||||
|
||||
marks.Should().ContainSingle()
|
||||
.Which.Should().Be((0, 0, Player.X));
|
||||
|
|
@ -79,10 +74,9 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void XWins_Row()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
|
||||
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||
}
|
||||
|
|
@ -90,10 +84,9 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void OWins_Column()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (2,2), O: (1,2) → O wins col 0
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (2, 2), (1, 2));
|
||||
|
||||
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.OWon);
|
||||
}
|
||||
|
|
@ -101,10 +94,9 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void XWins_Diagonal()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X: (0,0), O: (1,0), X: (1,1), O: (1,2), X: (2,2) → X wins diagonal
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (1, 1), (1, 2), (2, 2));
|
||||
|
||||
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||
}
|
||||
|
|
@ -112,12 +104,9 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void Draw_AllCellsFilled()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// Fill all 9 cells without a winner.
|
||||
// X: (0,0) (0,2) (1,0) (2,1) (2,2)
|
||||
// O: (0,1) (1,1) (1,2) (2,0)
|
||||
PlayMoves(world, group,
|
||||
TestHelpers.PlayMoves(world, group,
|
||||
(0, 0), (0, 1), (0, 2),
|
||||
(1, 1), (1, 0), (1, 2),
|
||||
(2, 1), (2, 0), (2, 2));
|
||||
|
|
@ -128,14 +117,12 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void MovesAfterGameOver_AreIgnored()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X wins.
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
|
||||
world.ReadSingleton<GameState>().Status.Should().Be(GameStatus.XWon);
|
||||
|
||||
// Try to place another mark — should be ignored.
|
||||
var moveCountBefore = world.ReadSingleton<GameState>().MoveCount;
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 2, Col = 2 });
|
||||
group.RunLogical();
|
||||
|
|
@ -146,68 +133,25 @@ public class GameFlowTests
|
|||
[Fact]
|
||||
public void SaveLoad_RoundTrips()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// Play a few moves.
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 0, Col = 0 });
|
||||
group.RunLogical();
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = 1, Col = 0 });
|
||||
group.RunLogical();
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0));
|
||||
|
||||
// Save.
|
||||
using var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
stream.Position = 0;
|
||||
|
||||
// Load into a new world.
|
||||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
// Verify state.
|
||||
var state = world2.ReadSingleton<GameState>();
|
||||
state.CurrentPlayer.Should().Be(Player.X);
|
||||
state.MoveCount.Should().Be(2);
|
||||
|
||||
var query = world2.Query().With<Cell>().With<Mark>().Build();
|
||||
var marks = new List<(int Row, int Col, Player Player)>();
|
||||
world2.ForEach(query, (Entity e, ref Cell cell, ref Mark mark) =>
|
||||
{
|
||||
marks.Add((cell.Row, cell.Col, mark.Player));
|
||||
});
|
||||
foreach (var it in world2.Select<Cell, Mark>())
|
||||
marks.Add((it.Val1.Row, it.Val1.Col, it.Val2.Player));
|
||||
|
||||
marks.Should().BeEquivalentTo([(0, 0, Player.X), (1, 0, Player.O)]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new WinCheckSystem());
|
||||
|
||||
// Create the 9 cells.
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++)
|
||||
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
CurrentPlayer = Player.X,
|
||||
Status = GameStatus.Playing,
|
||||
MoveCount = 0
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
|
||||
{
|
||||
foreach (var (row, col) in moves)
|
||||
{
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||
group.RunLogical();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using FluentAssertions;
|
||||
using Game.TicTacToe;
|
||||
using OECS;
|
||||
using R3;
|
||||
using OECS.PlayTest;
|
||||
using Xunit;
|
||||
|
||||
namespace Game.TicTacToe.Tests;
|
||||
|
|
@ -11,133 +11,53 @@ public class PlayTests
|
|||
[Fact]
|
||||
public void Play_GreedyVsRandom()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||
var agentO = new RandomTicTacToeAgent();
|
||||
|
||||
var (world, group) = SetupGame();
|
||||
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||
var agentO = new RandomAgent();
|
||||
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Random O");
|
||||
|
||||
// Subscribe reactivity.
|
||||
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||
{
|
||||
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||
});
|
||||
|
||||
log.Header = "TicTacToe: Greedy X vs Random O";
|
||||
|
||||
RunGame(world, group, agentX, agentO, log);
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("tic_tac_toe_greedy_vs_random.playlog", log);
|
||||
var path = log.SaveTo("tic_tac_toe_greedy_vs_random.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_RandomVsRandom()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
var agentX = new RandomTicTacToeAgent();
|
||||
var agentO = new RandomTicTacToeAgent();
|
||||
|
||||
var (world, group) = SetupGame();
|
||||
var agentX = new RandomAgent();
|
||||
var agentO = new RandomAgent();
|
||||
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Random X vs Random O");
|
||||
|
||||
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||
{
|
||||
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||
});
|
||||
|
||||
log.Header = "TicTacToe: Random X vs Random O";
|
||||
|
||||
RunGame(world, group, agentX, agentO, log);
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("tic_tac_toe_random_vs_random.playlog", log);
|
||||
var path = log.SaveTo("tic_tac_toe_random_vs_random.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Play_GreedyVsGreedy()
|
||||
{
|
||||
var log = new PlayLog();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
var agentX = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||
var agentO = new GreedyTicTacToeAgent(GreedyTicTacToeAgent.Strategy.WinOrBlock);
|
||||
|
||||
var (world, group) = SetupGame();
|
||||
var agentX = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||
var agentO = new GreedyAgent(GreedyAgent.Strategy.WinOrBlock);
|
||||
var log = RunGame(world, group, agentX, agentO, "TicTacToe: Greedy X vs Greedy O");
|
||||
|
||||
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||
{
|
||||
var mark = world.ReadComponent<Mark>(change.Entity);
|
||||
log.Reactivity.Add($"{change.Kind} Mark = {mark} on {change.Entity}");
|
||||
});
|
||||
|
||||
log.Header = "TicTacToe: Greedy X vs Greedy O";
|
||||
|
||||
RunGame(world, group, agentX, agentO, log);
|
||||
|
||||
log.FinalSnapshot = SnapshotWorld(world);
|
||||
|
||||
var path = SavePlayLog("tic_tac_toe_greedy_vs_greedy.playlog", log);
|
||||
var path = log.SaveTo("tic_tac_toe_greedy_vs_greedy.playlog");
|
||||
ReadAndVerify(path);
|
||||
}
|
||||
|
||||
// ── Play Log ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class PlayLog
|
||||
{
|
||||
public string Header { get; set; } = "";
|
||||
public readonly List<string> Moves = new();
|
||||
public readonly List<string> Reactivity = new();
|
||||
public string FinalSnapshot { get; set; } = "";
|
||||
|
||||
public string Build()
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(Header);
|
||||
sb.AppendLine(new string('=', Header.Length));
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Moves ---");
|
||||
for (int i = 0; i < Moves.Count; i++)
|
||||
sb.AppendLine($" {i + 1}. {Moves[i]}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Reactivity ---");
|
||||
foreach (var entry in Reactivity)
|
||||
sb.AppendLine($" {entry}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Final Board ---");
|
||||
sb.AppendLine(FinalSnapshot);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game Runner ───────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame()
|
||||
private static PlayLog RunGame(World world, SystemGroup group,
|
||||
IAgent<PlaceMarkCommand> agentX, IAgent<PlaceMarkCommand> agentO, string header)
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new WinCheckSystem());
|
||||
var log = new PlayLog { Header = header };
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++)
|
||||
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||
using var capture = new ObservableCapture(world);
|
||||
capture.FormatWith<Mark>(m => m.Player.ToString());
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
CurrentPlayer = Player.X,
|
||||
Status = GameStatus.Playing,
|
||||
MoveCount = 0
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
private static void RunGame(World world, SystemGroup group, IAgent agentX, IAgent agentO, PlayLog log)
|
||||
{
|
||||
var moves = new List<string>();
|
||||
while (world.ReadSingleton<GameState>().Status == GameStatus.Playing)
|
||||
{
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
|
|
@ -147,138 +67,76 @@ public class PlayTests
|
|||
world.Commands.Enqueue(cmd);
|
||||
group.RunLogical();
|
||||
|
||||
log.Moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
|
||||
moves.Add($"{state.CurrentPlayer} → ({cmd.Row},{cmd.Col})");
|
||||
}
|
||||
|
||||
var final = world.ReadSingleton<GameState>();
|
||||
log.Header += $" — Result: {final.Status}";
|
||||
}
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────
|
||||
log.AddSection("Moves", moves.Select((m, i) => $"{i + 1}. {m}"));
|
||||
log.AddSection("Reactivity", capture.GetLogLines());
|
||||
log.FinalSnapshot = TestHelpers.SnapshotWorld(world);
|
||||
|
||||
private static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
var grid = new char?[3, 3];
|
||||
using (var iter = world.Select<Cell, Mark>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
grid[iter.Current1.Row, iter.Current1.Col] =
|
||||
iter.Current2.Player == Player.X ? 'X' : 'O';
|
||||
}
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
{
|
||||
sb.Append(" ");
|
||||
for (int c = 0; c < 3; c++)
|
||||
sb.Append(grid[r, c]?.ToString() ?? ".");
|
||||
sb.AppendLine();
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
return log;
|
||||
}
|
||||
|
||||
// ── File I/O ──────────────────────────────────────────────────────
|
||||
|
||||
private static string SavePlayLog(string filename, PlayLog log)
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, filename);
|
||||
File.WriteAllText(path, log.Build());
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void ReadAndVerify(string path)
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
|
||||
// Verify the play log is well-formed.
|
||||
content.Should().Contain("--- Moves ---");
|
||||
content.Should().Contain("--- Reactivity ---");
|
||||
content.Should().Contain("--- Final Board ---");
|
||||
|
||||
// The game must have finished.
|
||||
content.Should().Contain("--- Final State ---");
|
||||
content.Should().Contain("Result:");
|
||||
content.Should().NotContain("Result: Playing");
|
||||
}
|
||||
|
||||
// ── Agents ────────────────────────────────────────────────────────
|
||||
|
||||
private interface IAgent
|
||||
private sealed class RandomTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
|
||||
{
|
||||
PlaceMarkCommand Decide(World world);
|
||||
}
|
||||
|
||||
private sealed class RandomAgent : IAgent
|
||||
{
|
||||
private static readonly Random _rng = new();
|
||||
public PlaceMarkCommand Decide(World world)
|
||||
protected override List<PlaceMarkCommand> GetLegalActions(World world)
|
||||
{
|
||||
var empty = GetEmptyCells(world);
|
||||
var (row, col) = empty[_rng.Next(empty.Count)];
|
||||
return new PlaceMarkCommand { Row = row, Col = col };
|
||||
return TestHelpers.GetEmptyCells(world)
|
||||
.Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GreedyAgent : IAgent
|
||||
private sealed class GreedyTicTacToeAgent : GreedyAgent<PlaceMarkCommand>
|
||||
{
|
||||
public enum Strategy { WinOrBlock }
|
||||
private readonly Strategy _strategy;
|
||||
private static readonly Random _rng = new();
|
||||
public GreedyAgent(Strategy strategy) => _strategy = strategy;
|
||||
|
||||
public PlaceMarkCommand Decide(World world)
|
||||
public GreedyTicTacToeAgent(Strategy strategy) => _strategy = strategy;
|
||||
|
||||
protected override List<PlaceMarkCommand> GetLegalActions(World world)
|
||||
{
|
||||
return TestHelpers.GetEmptyCells(world)
|
||||
.Select(c => new PlaceMarkCommand { Row = c.Row, Col = c.Col })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
protected override float ScoreAction(World world, PlaceMarkCommand action)
|
||||
{
|
||||
if (_strategy != Strategy.WinOrBlock)
|
||||
return 0f;
|
||||
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var me = state.CurrentPlayer;
|
||||
var empty = GetEmptyCells(world);
|
||||
|
||||
foreach (var (r, c) in empty)
|
||||
if (WouldWin(world, r, c, me))
|
||||
return new PlaceMarkCommand { Row = r, Col = c };
|
||||
|
||||
var opponent = me == Player.X ? Player.O : Player.X;
|
||||
foreach (var (r, c) in empty)
|
||||
if (WouldWin(world, r, c, opponent))
|
||||
return new PlaceMarkCommand { Row = r, Col = c };
|
||||
|
||||
if (empty.Any(c => c.Row == 1 && c.Col == 1))
|
||||
return new PlaceMarkCommand { Row = 1, Col = 1 };
|
||||
if (TestHelpers.WouldWin(world, action.Row, action.Col, me))
|
||||
return 100f;
|
||||
if (TestHelpers.WouldWin(world, action.Row, action.Col, opponent))
|
||||
return 50f;
|
||||
if (action.Row == 1 && action.Col == 1)
|
||||
return 10f;
|
||||
|
||||
var (rr, cc) = empty[_rng.Next(empty.Count)];
|
||||
return new PlaceMarkCommand { Row = rr, Col = cc };
|
||||
return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<(int Row, int Col)> GetEmptyCells(World world)
|
||||
{
|
||||
var empty = new List<(int, int)>();
|
||||
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||
using var iter = world.Select<Cell>(query);
|
||||
while (iter.MoveNext())
|
||||
empty.Add((iter.Current1.Row, iter.Current1.Col));
|
||||
return empty;
|
||||
}
|
||||
|
||||
private static bool WouldWin(World world, int row, int col, Player player)
|
||||
{
|
||||
var grid = new Player[3, 3];
|
||||
using (var iter = world.Select<Cell, Mark>())
|
||||
while (iter.MoveNext())
|
||||
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
||||
grid[row, col] = player;
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
|
||||
return true;
|
||||
for (int c = 0; c < 3; c++)
|
||||
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
|
||||
return true;
|
||||
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
|
||||
return true;
|
||||
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using FluentAssertions;
|
||||
using Game.TicTacToe;
|
||||
using OECS;
|
||||
using R3;
|
||||
using OECS.PlayTest;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
|
@ -36,10 +36,9 @@ public class SnapshotTests
|
|||
MoveCount = 0
|
||||
});
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
// Verify baseline properties.
|
||||
snapshot.Should().Contain("GameState: X's turn, 0 moves");
|
||||
snapshot.Should().Contain("Cells: 9 total, 0 marked");
|
||||
}
|
||||
|
|
@ -47,12 +46,11 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_AfterThreeMoves()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X: (0,0), O: (1,1), X: (2,2)
|
||||
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("GameState: O's turn, 3 moves");
|
||||
|
|
@ -65,12 +63,11 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_XWins()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// X: (0,0), O: (1,0), X: (0,1), O: (1,1), X: (0,2) → X wins row 0
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("GameState: XWon");
|
||||
|
|
@ -80,14 +77,14 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Snapshot_Draw()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
PlayMoves(world, group,
|
||||
TestHelpers.PlayMoves(world, group,
|
||||
(0, 0), (0, 1), (0, 2),
|
||||
(1, 1), (1, 0), (1, 2),
|
||||
(2, 1), (2, 0), (2, 2));
|
||||
|
||||
var snapshot = SnapshotWorld(world);
|
||||
var snapshot = TestHelpers.SnapshotWorld(world);
|
||||
_output.WriteLine(snapshot);
|
||||
|
||||
snapshot.Should().Contain("GameState: Draw");
|
||||
|
|
@ -97,46 +94,29 @@ public class SnapshotTests
|
|||
[Fact]
|
||||
public void Log_ReactivityDuringGame()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var log = new List<string>();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// Subscribe to all entity-level changes.
|
||||
world.ObserveEntityChanges().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[entity] {change}");
|
||||
});
|
||||
using var capture = new ObservableCapture(world);
|
||||
capture.FormatWith<Mark>(m => m.Player.ToString());
|
||||
capture.FormatWith<GameState>(s => $"{s.Status} ({s.CurrentPlayer}, {s.MoveCount} moves)");
|
||||
|
||||
// Subscribe to component-specific changes.
|
||||
world.ObserveComponentChanges<Mark>().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[mark] {change}");
|
||||
});
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
|
||||
world.ObserveComponentChanges<GameState>().Subscribe(change =>
|
||||
{
|
||||
log.Add($"[gamestate] {change}");
|
||||
});
|
||||
|
||||
// Play a full game: X wins on row 0.
|
||||
PlayMoves(world, group, (0, 0), (1, 0), (0, 1), (1, 1), (0, 2));
|
||||
|
||||
var logText = string.Join("\n", log);
|
||||
var logText = string.Join("\n", capture.GetLogLines());
|
||||
_output.WriteLine(logText);
|
||||
|
||||
// Verify key events appear in the log.
|
||||
log.Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
|
||||
log.Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentAdded") && l.Contains("Mark"));
|
||||
capture.GetLogLines().Should().Contain(l => l.Contains("ComponentModified") && l.Contains("GameState"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_RoundTrip_PreservesSnapshot()
|
||||
{
|
||||
var (world, group) = SetupGame();
|
||||
var (world, group) = TestHelpers.SetupGame();
|
||||
|
||||
// Play a few moves.
|
||||
PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||
TestHelpers.PlayMoves(world, group, (0, 0), (1, 1), (2, 2));
|
||||
|
||||
var before = SnapshotWorld(world);
|
||||
var before = TestHelpers.SnapshotWorld(world);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
WorldSerializer.Save(world, stream);
|
||||
|
|
@ -145,7 +125,7 @@ public class SnapshotTests
|
|||
var world2 = new World();
|
||||
WorldSerializer.Load(world2, stream);
|
||||
|
||||
var after = SnapshotWorld(world2);
|
||||
var after = TestHelpers.SnapshotWorld(world2);
|
||||
|
||||
_output.WriteLine("=== Before ===");
|
||||
_output.WriteLine(before);
|
||||
|
|
@ -154,91 +134,4 @@ public class SnapshotTests
|
|||
|
||||
after.Should().Be(before);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static (World, SystemGroup) SetupGame()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new WinCheckSystem());
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++)
|
||||
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
CurrentPlayer = Player.X,
|
||||
Status = GameStatus.Playing,
|
||||
MoveCount = 0
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
private static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
|
||||
{
|
||||
foreach (var (row, col) in moves)
|
||||
{
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||
group.RunLogical();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces a human-readable textual snapshot of the world state.
|
||||
/// </summary>
|
||||
private static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
// Singleton: GameState.
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var statusText = state.Status switch
|
||||
{
|
||||
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
|
||||
GameStatus.XWon => "XWon",
|
||||
GameStatus.OWon => "OWon",
|
||||
GameStatus.Draw => "Draw",
|
||||
_ => "Unknown"
|
||||
};
|
||||
sb.AppendLine($"GameState: {statusText}");
|
||||
|
||||
// Board: build a 3x3 grid.
|
||||
var grid = new char?[3, 3];
|
||||
using (var iter = world.Select<Cell, Mark>())
|
||||
{
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
grid[iter.Current1.Row, iter.Current1.Col] =
|
||||
iter.Current2.Player == Player.X ? 'X' : 'O';
|
||||
}
|
||||
}
|
||||
|
||||
int totalCells = 0;
|
||||
int markedCells = 0;
|
||||
for (int r = 0; r < 3; r++)
|
||||
{
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
totalCells++;
|
||||
var mark = grid[r, c];
|
||||
if (mark.HasValue)
|
||||
{
|
||||
markedCells++;
|
||||
sb.AppendLine($" ({r},{c}): {mark.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($" ({r},{c}): .");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
using Game.TicTacToe;
|
||||
using OECS;
|
||||
using System.Text;
|
||||
|
||||
namespace Game.TicTacToe.Tests;
|
||||
|
||||
internal static class TestHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fresh TicTacToe world with 9 empty cells, GameState singleton, and WinCheckSystem.
|
||||
/// </summary>
|
||||
public static (World World, SystemGroup Group) SetupGame()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
group.Add(new WinCheckSystem());
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 3; c++)
|
||||
world.AddComponent(world.CreateEntity(), new Cell { Row = r, Col = c });
|
||||
|
||||
world.SetSingleton(new GameState
|
||||
{
|
||||
CurrentPlayer = Player.X,
|
||||
Status = GameStatus.Playing,
|
||||
MoveCount = 0
|
||||
});
|
||||
world.PostChanges();
|
||||
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues and runs a sequence of moves.
|
||||
/// </summary>
|
||||
public static void PlayMoves(World world, SystemGroup group, params (int Row, int Col)[] moves)
|
||||
{
|
||||
foreach (var (row, col) in moves)
|
||||
{
|
||||
world.Commands.Enqueue(new PlaceMarkCommand { Row = row, Col = col });
|
||||
group.RunLogical();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable textual snapshot of the world state.
|
||||
/// </summary>
|
||||
public static string SnapshotWorld(World world)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var state = world.ReadSingleton<GameState>();
|
||||
var statusText = state.Status switch
|
||||
{
|
||||
GameStatus.Playing => $"{(state.CurrentPlayer == Player.X ? 'X' : 'O')}'s turn, {state.MoveCount} moves",
|
||||
GameStatus.XWon => "XWon",
|
||||
GameStatus.OWon => "OWon",
|
||||
GameStatus.Draw => "Draw",
|
||||
_ => "Unknown"
|
||||
};
|
||||
sb.AppendLine($"GameState: {statusText}");
|
||||
|
||||
var grid = new char?[3, 3];
|
||||
foreach (var it in world.Select<Cell, Mark>())
|
||||
grid[it.Val1.Row, it.Val1.Col] =
|
||||
it.Val2.Player == Player.X ? 'X' : 'O';
|
||||
|
||||
int totalCells = 0;
|
||||
int markedCells = 0;
|
||||
for (int r = 0; r < 3; r++)
|
||||
{
|
||||
for (int c = 0; c < 3; c++)
|
||||
{
|
||||
totalCells++;
|
||||
var mark = grid[r, c];
|
||||
if (mark.HasValue)
|
||||
{
|
||||
markedCells++;
|
||||
sb.AppendLine($" ({r},{c}): {mark.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($" ({r},{c}): .");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine($"Cells: {totalCells} total, {markedCells} marked");
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (Row, Col) of all empty cells.
|
||||
/// </summary>
|
||||
public static List<(int Row, int Col)> GetEmptyCells(World world)
|
||||
{
|
||||
var empty = new List<(int, int)>();
|
||||
foreach (var it in world.Select(new Query<Cell>().Without<Mark>()))
|
||||
empty.Add((it.Val1.Row, it.Val1.Col));
|
||||
return empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether placing a mark at (row, col) for the given player would win the game.
|
||||
/// </summary>
|
||||
public static bool WouldWin(World world, int row, int col, Player player)
|
||||
{
|
||||
var grid = new Player[3, 3];
|
||||
foreach (var it in world.Select<Cell, Mark>())
|
||||
grid[it.Val1.Row, it.Val1.Col] = it.Val2.Player;
|
||||
grid[row, col] = player;
|
||||
|
||||
for (int r = 0; r < 3; r++)
|
||||
if (grid[r, 0] == player && grid[r, 1] == player && grid[r, 2] == player)
|
||||
return true;
|
||||
for (int c = 0; c < 3; c++)
|
||||
if (grid[0, c] == player && grid[1, c] == player && grid[2, c] == player)
|
||||
return true;
|
||||
if (grid[0, 0] == player && grid[1, 1] == player && grid[2, 2] == player)
|
||||
return true;
|
||||
if (grid[0, 2] == player && grid[1, 1] == player && grid[2, 0] == player)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,36 +15,25 @@ public struct PlaceMarkCommand : ICommand
|
|||
|
||||
public void Execute(World world)
|
||||
{
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
if (state.Status != GameStatus.Playing)
|
||||
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||
return;
|
||||
|
||||
// Find the cell entity at (Row, Col) that has no Mark.
|
||||
// Use the iterator API with early-exit via break.
|
||||
var query = world.Query().With<Cell>().Without<Mark>().Build();
|
||||
Entity? target = null;
|
||||
|
||||
using var iter = world.Select<Cell>(query);
|
||||
while (iter.MoveNext())
|
||||
{
|
||||
if (iter.Current1.Row == Row && iter.Current1.Col == Col)
|
||||
{
|
||||
target = iter.CurrentEntity;
|
||||
break;
|
||||
}
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
foreach(var iter in world.Select(new Query<Cell>().Without<Mark>())){
|
||||
if (iter.Val1.Row != Row || iter.Val1.Col != Col) continue;
|
||||
target = iter.Entity;
|
||||
break;
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
return; // Cell already occupied or invalid position.
|
||||
return;
|
||||
|
||||
// Place the mark. ComponentAdded is sufficient — MarkModified
|
||||
// is only needed when mutating an existing component via GetComponent<T>.
|
||||
world.AddComponent(target.Value, new Mark { Player = state.CurrentPlayer });
|
||||
|
||||
// Advance turn.
|
||||
state.MoveCount++;
|
||||
state.CurrentPlayer = state.CurrentPlayer == Player.X ? Player.O : Player.X;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,22 +8,18 @@ namespace Game.TicTacToe;
|
|||
/// </summary>
|
||||
public class WinCheckSystem : ISystem
|
||||
{
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
// Check game status before doing any work. Use ReadSingleton
|
||||
// to avoid auto-marking GameState as modified when we bail early.
|
||||
if (world.ReadSingleton<GameState>().Status != GameStatus.Playing)
|
||||
return;
|
||||
|
||||
ref var state = ref world.GetSingleton<GameState>();
|
||||
|
||||
// Build a 3×3 grid of marks using the iterator API.
|
||||
// Build a 3×3 grid of marks.
|
||||
var grid = new Player[3, 3];
|
||||
|
||||
using var iter = world.Select<Cell, Mark>();
|
||||
while (iter.MoveNext())
|
||||
foreach (var iter in world.Select<Cell, Mark>())
|
||||
{
|
||||
grid[iter.Current1.Row, iter.Current1.Col] = iter.Current2.Player;
|
||||
grid[iter.Val1.Row, iter.Val1.Col] = iter.Val2.Player;
|
||||
}
|
||||
|
||||
// Check rows.
|
||||
|
|
@ -31,7 +27,7 @@ public class WinCheckSystem : ISystem
|
|||
{
|
||||
if (TryGetWinner(grid[r, 0], grid[r, 1], grid[r, 2], out var winner))
|
||||
{
|
||||
SetWinner(world, ref state, winner);
|
||||
SetWinner(ref state, winner);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +37,7 @@ public class WinCheckSystem : ISystem
|
|||
{
|
||||
if (TryGetWinner(grid[0, c], grid[1, c], grid[2, c], out var winner))
|
||||
{
|
||||
SetWinner(world, ref state, winner);
|
||||
SetWinner(ref state, winner);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -49,21 +45,18 @@ public class WinCheckSystem : ISystem
|
|||
// Check diagonals.
|
||||
if (TryGetWinner(grid[0, 0], grid[1, 1], grid[2, 2], out var diag1))
|
||||
{
|
||||
SetWinner(world, ref state, diag1);
|
||||
SetWinner(ref state, diag1);
|
||||
return;
|
||||
}
|
||||
if (TryGetWinner(grid[0, 2], grid[1, 1], grid[2, 0], out var diag2))
|
||||
{
|
||||
SetWinner(world, ref state, diag2);
|
||||
SetWinner(ref state, diag2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check draw.
|
||||
if (state.MoveCount >= 9)
|
||||
{
|
||||
state.Status = GameStatus.Draw;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetWinner(Player a, Player b, Player c, out Player winner)
|
||||
|
|
@ -77,9 +70,8 @@ public class WinCheckSystem : ISystem
|
|||
return false;
|
||||
}
|
||||
|
||||
private static void SetWinner(World world, ref GameState state, Player winner)
|
||||
private static void SetWinner(ref GameState state, Player winner)
|
||||
{
|
||||
state.Status = winner == Player.X ? GameStatus.XWon : GameStatus.OWon;
|
||||
world.MarkModified<GameState>(World.SingletonEntity);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
using OECS;
|
||||
|
||||
namespace OECS.PlayTest;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract agent that picks the action with the highest score.
|
||||
/// When <see cref="ScoreAction"/> returns 0 for all actions (the default),
|
||||
/// the agent behaves as a uniform random agent over legal actions.
|
||||
/// Ties are broken randomly.
|
||||
/// </summary>
|
||||
public abstract class GreedyAgent<TDecision> : IAgent<TDecision>
|
||||
{
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of currently legal actions.
|
||||
/// </summary>
|
||||
protected abstract List<TDecision> GetLegalActions(World world);
|
||||
|
||||
/// <summary>
|
||||
/// Scores an action. Defaults to 0 (uniform random).
|
||||
/// </summary>
|
||||
protected virtual float ScoreAction(World world, TDecision action) => 0f;
|
||||
|
||||
public TDecision Decide(World world)
|
||||
{
|
||||
var actions = GetLegalActions(world);
|
||||
if (actions.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"{GetType().Name}: no legal actions available");
|
||||
|
||||
if (actions.Count == 1)
|
||||
return actions[0];
|
||||
|
||||
float bestScore = float.MinValue;
|
||||
var bestActions = new List<TDecision>();
|
||||
foreach (var action in actions)
|
||||
{
|
||||
float score = ScoreAction(world, action);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestActions.Clear();
|
||||
bestActions.Add(action);
|
||||
}
|
||||
else if (score == bestScore)
|
||||
{
|
||||
bestActions.Add(action);
|
||||
}
|
||||
}
|
||||
|
||||
return bestActions[_rng.Next(bestActions.Count)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
using OECS;
|
||||
|
||||
namespace OECS.PlayTest;
|
||||
|
||||
/// <summary>
|
||||
/// An AI player that inspects the world and returns a decision.
|
||||
/// </summary>
|
||||
public interface IAgent<TDecision>
|
||||
{
|
||||
TDecision Decide(World world);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
namespace OECS.PlayTest;
|
||||
|
||||
/// <summary>
|
||||
/// A pool of agents selected by weight. Higher weight = more likely to be picked.
|
||||
/// </summary>
|
||||
public class WeightedAgentPool<TDecision>
|
||||
{
|
||||
private readonly List<(IAgent<TDecision> Agent, int Weight)> _agents = new();
|
||||
private int _totalWeight;
|
||||
private static readonly Random _rng = new();
|
||||
|
||||
public void Add(IAgent<TDecision> agent, int weight)
|
||||
{
|
||||
_agents.Add((agent, weight));
|
||||
_totalWeight += weight;
|
||||
}
|
||||
|
||||
public IAgent<TDecision> Pick()
|
||||
{
|
||||
if (_agents.Count == 0)
|
||||
throw new InvalidOperationException("WeightedAgentPool is empty");
|
||||
|
||||
int roll = _rng.Next(_totalWeight);
|
||||
int cumulative = 0;
|
||||
foreach (var (agent, weight) in _agents)
|
||||
{
|
||||
cumulative += weight;
|
||||
if (roll < cumulative)
|
||||
return agent;
|
||||
}
|
||||
return _agents[^1].Agent;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>OECS.PlayTest</RootNamespace>
|
||||
<AssemblyName>OECS.PlayTest</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OECS\OECS.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
using OECS;
|
||||
using R3;
|
||||
|
||||
namespace OECS.PlayTest;
|
||||
|
||||
/// <summary>
|
||||
/// Captures all entity changes from the World's observable API and produces
|
||||
/// a compact text log on demand. Call <see cref="FormatWith{T}"/> to enrich
|
||||
/// component entries with human-readable values.
|
||||
/// </summary>
|
||||
public sealed class ObservableCapture : IDisposable
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly IDisposable _subscription;
|
||||
private readonly List<CapturedEvent> _events = new();
|
||||
private readonly Dictionary<Type, Func<World, Entity, string>> _formatters = new();
|
||||
|
||||
public ObservableCapture(World world)
|
||||
{
|
||||
_world = world;
|
||||
_subscription = world.ObserveEntityChanges().Subscribe(OnChange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a formatter for component type <typeparamref name="T"/>.
|
||||
/// When a change for this type is captured, the formatted value is
|
||||
/// included in the log output.
|
||||
/// </summary>
|
||||
public void FormatWith<T>(Func<T, string> formatter) where T : struct
|
||||
{
|
||||
_formatters[typeof(T)] = (w, e) =>
|
||||
{
|
||||
var value = w.ReadComponent<T>(e);
|
||||
return formatter(value);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the captured log as a single string with one line per change.
|
||||
/// </summary>
|
||||
public string GetLog() => string.Join(Environment.NewLine, GetLogLines());
|
||||
|
||||
/// <summary>
|
||||
/// Returns the captured log as a list of lines, one per change.
|
||||
/// </summary>
|
||||
public List<string> GetLogLines()
|
||||
{
|
||||
var lines = new List<string>(_events.Count);
|
||||
foreach (var evt in _events)
|
||||
{
|
||||
var change = evt.Change;
|
||||
var kind = change.Kind.ToString();
|
||||
if (change.ComponentType != null)
|
||||
{
|
||||
var typeName = change.ComponentType.Name;
|
||||
if (evt.FormattedValue != null)
|
||||
lines.Add($"{kind} {typeName} = {evt.FormattedValue} on {change.Entity}");
|
||||
else
|
||||
lines.Add($"{kind} {typeName} on {change.Entity}");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"{kind} on {change.Entity}");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Dispose() => _subscription.Dispose();
|
||||
|
||||
private void OnChange(EntityChange change)
|
||||
{
|
||||
string? formattedValue = null;
|
||||
if (change.ComponentType != null
|
||||
&& change.Kind != ChangeKind.ComponentRemoved
|
||||
&& change.Kind != ChangeKind.EntityRemoved
|
||||
&& change.Kind != ChangeKind.RelationshipReordered
|
||||
&& _formatters.TryGetValue(change.ComponentType, out var formatter))
|
||||
{
|
||||
formattedValue = formatter(_world, change.Entity);
|
||||
}
|
||||
_events.Add(new CapturedEvent(change, formattedValue));
|
||||
}
|
||||
|
||||
private readonly struct CapturedEvent
|
||||
{
|
||||
public readonly EntityChange Change;
|
||||
public readonly string? FormattedValue;
|
||||
public CapturedEvent(EntityChange change, string? formattedValue)
|
||||
{
|
||||
Change = change;
|
||||
FormattedValue = formattedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text;
|
||||
|
||||
namespace OECS.PlayTest;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a sectioned play log with header, sections, and final snapshot,
|
||||
/// then saves it to a .playlog file.
|
||||
/// </summary>
|
||||
public class PlayLog
|
||||
{
|
||||
public string Header { get; set; } = "";
|
||||
public string FinalSnapshot { get; set; } = "";
|
||||
|
||||
private readonly List<(string Title, List<string> Lines)> _sections = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a section with the given title and lines.
|
||||
/// </summary>
|
||||
public void AddSection(string title, IEnumerable<string> lines)
|
||||
{
|
||||
_sections.Add((title, lines.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the complete play log text.
|
||||
/// </summary>
|
||||
public string Build()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(Header);
|
||||
sb.AppendLine(new string('=', Header.Length));
|
||||
sb.AppendLine();
|
||||
foreach (var (title, lines) in _sections)
|
||||
{
|
||||
sb.AppendLine($"--- {title} ---");
|
||||
foreach (var line in lines)
|
||||
sb.AppendLine($" {line}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
sb.AppendLine("--- Final State ---");
|
||||
sb.AppendLine(FinalSnapshot);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the play log to AppContext.BaseDirectory/playlogs/<paramref name="filename"/>
|
||||
/// and returns the full path.
|
||||
/// </summary>
|
||||
public string SaveTo(string filename)
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "playlogs");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, filename);
|
||||
File.WriteAllText(path, Build());
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,15 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
"GetSources",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _singletonMethods = new()
|
||||
{
|
||||
"SetSingleton",
|
||||
"GetSingleton",
|
||||
"ReadSingleton",
|
||||
"HasSingleton",
|
||||
"RemoveSingleton",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _forEachNames = new()
|
||||
{
|
||||
"ForEach",
|
||||
|
|
@ -57,7 +66,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
predicate: IsCandidateInvocation,
|
||||
transform: ExtractComponentType)
|
||||
.Where(t => t.FullyQualifiedName != null)
|
||||
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship));
|
||||
.Select((t, _) => (t.FullyQualifiedName!, t.AssemblyQualifiedName!, t.IsRelationship, t.IsSingleton));
|
||||
|
||||
// Also collect ForEach type arguments from the World class.
|
||||
var forEachCalls = context.SyntaxProvider
|
||||
|
|
@ -104,7 +113,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
return false;
|
||||
}
|
||||
|
||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship) ExtractComponentType(
|
||||
private static (string? FullyQualifiedName, string? AssemblyQualifiedName, bool IsRelationship, bool IsSingleton) ExtractComponentType(
|
||||
GeneratorSyntaxContext ctx, CancellationToken _)
|
||||
{
|
||||
if (ctx.Node is not InvocationExpressionSyntax invocation)
|
||||
|
|
@ -140,7 +149,9 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
bool isRelationship = typeSymbol.AllInterfaces.Any(i =>
|
||||
i.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::OECS.IRelationship");
|
||||
|
||||
return (fqn, aqn, isRelationship);
|
||||
bool isSingleton = _singletonMethods.Contains(genericName.Identifier.Text);
|
||||
|
||||
return (fqn, aqn, isRelationship, isSingleton);
|
||||
}
|
||||
|
||||
private static ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> ExtractForEachTypes(
|
||||
|
|
@ -177,21 +188,29 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
}
|
||||
|
||||
private static void GenerateRegistry(SourceProductionContext context,
|
||||
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Left,
|
||||
(ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship, bool IsSingleton)> Left,
|
||||
ImmutableArray<(string FullyQualifiedName, string AssemblyQualifiedName, bool IsRelationship)> Right) data)
|
||||
{
|
||||
var (invocations, forEachTypes) = data;
|
||||
|
||||
// Collect unique types by fully-qualified name, deduplicating.
|
||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel)>();
|
||||
var typeMap = new Dictionary<string, (string Fqn, string Aqn, bool IsRel, bool IsSingleton)>();
|
||||
foreach (var t in invocations)
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||
{
|
||||
if (typeMap.TryGetValue(t.FullyQualifiedName, out var existing))
|
||||
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, existing.IsRel || t.IsRelationship, existing.IsSingleton || t.IsSingleton);
|
||||
else
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, t.IsSingleton);
|
||||
}
|
||||
foreach (var t in forEachTypes)
|
||||
{
|
||||
if (!typeMap.ContainsKey(t.FullyQualifiedName))
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship);
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, t.IsRelationship, false);
|
||||
else if (t.IsRelationship)
|
||||
typeMap[t.FullyQualifiedName] = (t.FullyQualifiedName, t.AssemblyQualifiedName, true);
|
||||
{
|
||||
var existing = typeMap[t.FullyQualifiedName];
|
||||
typeMap[t.FullyQualifiedName] = (existing.Fqn, existing.Aqn, true, existing.IsSingleton);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeMap.Count == 0)
|
||||
|
|
@ -213,7 +232,7 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
sb.AppendLine(" {");
|
||||
|
||||
bool first = true;
|
||||
foreach (var (fqn, aqn, isRel) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||
foreach (var (fqn, aqn, isRel, isSingleton) in typeMap.Values.OrderBy(t => t.Fqn))
|
||||
{
|
||||
if (!first)
|
||||
sb.AppendLine(",");
|
||||
|
|
@ -225,7 +244,8 @@ public class ComponentDiscoveryGenerator : IIncrementalGenerator
|
|||
sb.AppendLine($" serialize: obj => MessagePackSerializer.Serialize(({fqn})obj),");
|
||||
sb.AppendLine($" deserializeAndAdd: (world, entity, data) =>");
|
||||
sb.AppendLine($" world.AddComponent(entity, MessagePackSerializer.Deserialize<{fqn}>(data)),");
|
||||
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data)");
|
||||
sb.AppendLine($" deserialize: data => MessagePackSerializer.Deserialize<{fqn}>(data),");
|
||||
sb.AppendLine($" isSingleton: {(isSingleton ? "true" : "false")}");
|
||||
|
||||
sb.Append(" )");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,13 +114,11 @@ public class CommandTests
|
|||
|
||||
world.ExecuteCommands();
|
||||
|
||||
var query = world.Query().With<TestPosition>().With<TestHealth>().Build();
|
||||
var positions = new List<(float X, float Y, int Health)>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref TestPosition pos, ref TestHealth hp) =>
|
||||
foreach (var it in world.Select<TestPosition, TestHealth>())
|
||||
{
|
||||
positions.Add((pos.X, pos.Y, hp.Value));
|
||||
});
|
||||
positions.Add((it.Val1.X, it.Val1.Y, it.Val2.Value));
|
||||
}
|
||||
|
||||
positions.Should().BeEquivalentTo([
|
||||
(1, 2, 100),
|
||||
|
|
@ -151,13 +149,11 @@ public class CommandTests
|
|||
world.Commands.Enqueue(new ChainedCommand { X = 0, Y = 0 });
|
||||
world.ExecuteCommands();
|
||||
|
||||
var query = world.Query().With<TestPosition>().Build();
|
||||
var positions = new List<(float X, float Y)>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref TestPosition pos) =>
|
||||
foreach (var it in world.Select<TestPosition>())
|
||||
{
|
||||
positions.Add((pos.X, pos.Y));
|
||||
});
|
||||
positions.Add((it.Val1.X, it.Val1.Y));
|
||||
}
|
||||
|
||||
positions.Should().BeEquivalentTo([(10, 20)]);
|
||||
}
|
||||
|
|
@ -173,9 +169,9 @@ public class CommandTests
|
|||
|
||||
world.ExecuteCommands();
|
||||
|
||||
var query = world.Query().With<TestPosition>().Build();
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
|
||||
foreach (var it in world.Select<TestPosition>())
|
||||
count++;
|
||||
count.Should().Be(2);
|
||||
|
||||
world.Commands.Errors.Should().HaveCount(1);
|
||||
|
|
@ -222,9 +218,9 @@ public class CommandTests
|
|||
|
||||
world.ExecuteCommands();
|
||||
|
||||
var query = world.Query().With<TestPosition>().Build();
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref TestPosition pos) => count++);
|
||||
foreach (var it in world.Select<TestPosition>())
|
||||
count++;
|
||||
count.Should().Be(0);
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +232,7 @@ public class CommandTests
|
|||
|
||||
group.Add(new CommandEnqueueSystem(world, new SpawnCommand { X = 1, Y = 2, Health = 100 }));
|
||||
|
||||
var observer = new EntityCountObserver(world);
|
||||
var observer = new EntityCountObserver();
|
||||
group.Add(observer);
|
||||
|
||||
group.RunLogical();
|
||||
|
|
@ -255,7 +251,7 @@ public class CommandTests
|
|||
_command = command;
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
_world.Commands.Enqueue(_command);
|
||||
}
|
||||
|
|
@ -263,20 +259,12 @@ public class CommandTests
|
|||
|
||||
private class EntityCountObserver : ISystem
|
||||
{
|
||||
private readonly QueryDescriptor _query;
|
||||
public int SeenCount { get; private set; }
|
||||
|
||||
public EntityCountObserver(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
_query = world.Query().With<TestPosition>().Build();
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
{
|
||||
world.ForEach(_query, (Entity e, ref TestPosition pos) =>
|
||||
{
|
||||
foreach (var it in world.Select<TestPosition>())
|
||||
SeenCount++;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,7 +111,6 @@ public class EdgeCaseTests
|
|||
sources.Add(source);
|
||||
world.AddComponent(source, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = source,
|
||||
Target = target
|
||||
});
|
||||
}
|
||||
|
|
@ -154,45 +153,6 @@ public class EdgeCaseTests
|
|||
c.Kind == ChangeKind.EntityRemoved && c.Entity == entity);
|
||||
}
|
||||
|
||||
// ── QueryDescriptor equality ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_EqualQueries_AreEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Without<Frozen>().Build();
|
||||
|
||||
q1.Should().Be(q2);
|
||||
q1.GetHashCode().Should().Be(q2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentWith_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Build();
|
||||
var q2 = new QueryBuilder().With<Velocity>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentWithout_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Without<Frozen>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().Without<Burning>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryDescriptor_DifferentCount_AreNotEqual()
|
||||
{
|
||||
var q1 = new QueryBuilder().With<Position>().Build();
|
||||
var q2 = new QueryBuilder().With<Position>().With<Velocity>().Build();
|
||||
|
||||
q1.Should().NotBe(q2);
|
||||
}
|
||||
|
||||
// ── Query with 4, 5, 6 components ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
@ -205,18 +165,14 @@ public class EdgeCaseTests
|
|||
world.AddComponent(entity, new Health { Value = 100 });
|
||||
world.AddComponent(entity, new Frozen());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen>())
|
||||
{
|
||||
count++;
|
||||
pos.X.Should().Be(1);
|
||||
vel.Y.Should().Be(4);
|
||||
hp.Value.Should().Be(100);
|
||||
});
|
||||
it.Val1.X.Should().Be(1);
|
||||
it.Val2.Y.Should().Be(4);
|
||||
it.Val3.Value.Should().Be(100);
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
|
|
@ -231,15 +187,11 @@ public class EdgeCaseTests
|
|||
world.AddComponent(entity, new Frozen());
|
||||
world.AddComponent(entity, new Burning());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>().With<Frozen>().With<Burning>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp, ref Frozen f, ref Burning b) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning>())
|
||||
{
|
||||
count++;
|
||||
});
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
|
|
@ -255,17 +207,11 @@ public class EdgeCaseTests
|
|||
world.AddComponent(entity, new Burning());
|
||||
world.AddComponent(entity, new Poisoned());
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>().With<Velocity>().With<Health>()
|
||||
.With<Frozen>().With<Burning>().With<Poisoned>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp,
|
||||
ref Frozen f, ref Burning b, ref Poisoned p) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health, Frozen, Burning, Poisoned>())
|
||||
{
|
||||
count++;
|
||||
});
|
||||
}
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
|
|
@ -283,13 +229,13 @@ public class EdgeCaseTests
|
|||
world.AddComponent(b, new Frozen());
|
||||
|
||||
// Without<Frozen> filters out entity b which has Frozen.
|
||||
var query = world.Query().Without<Frozen>().Build();
|
||||
var query = new Query<Position>().Without<Frozen>();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select(query))
|
||||
{
|
||||
results.Add(e);
|
||||
});
|
||||
results.Add(it.Entity);
|
||||
}
|
||||
|
||||
results.Should().BeEquivalentTo([a]);
|
||||
}
|
||||
|
|
@ -417,16 +363,15 @@ public class EdgeCaseTests
|
|||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var seen = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select<Position>())
|
||||
{
|
||||
seen.Add(e);
|
||||
seen.Add(it.Entity);
|
||||
// Add a component to the other entity during iteration.
|
||||
// This should not affect the current iteration.
|
||||
world.AddComponent(e, new Velocity { X = 1, Y = 1 });
|
||||
});
|
||||
world.AddComponent(it.Entity, new Velocity { X = 1, Y = 1 });
|
||||
}
|
||||
|
||||
seen.Should().BeEquivalentTo([a, b]);
|
||||
}
|
||||
|
|
@ -440,94 +385,16 @@ public class EdgeCaseTests
|
|||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
|
||||
// Destroying an entity during iteration: the sparse set uses
|
||||
// swap-remove, so the destroyed entity's slot is replaced by
|
||||
// the last element. This is safe as long as we don't re-iterate
|
||||
// the destroyed entity (which we won't since it's swap-removed).
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
foreach (var it in world.Select<Position>())
|
||||
{
|
||||
world.DestroyEntity(e);
|
||||
});
|
||||
};
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
// ── QueryDescriptor.With enforced during ForEach ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsDivergeFromQueryWith()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
|
||||
// Build a query requiring Position + Velocity, but iterate only Position.
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Position*Velocity*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsAreSubsetOfQueryWith()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
world.AddComponent(entity, new Health { Value = 100 });
|
||||
|
||||
// Build a query requiring 3 components, but iterate only 2.
|
||||
var query = world.Query().With<Position>().With<Velocity>().With<Health>().Build();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Health*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEach_Throws_WhenTypeParamsAreSupersetOfQueryWith()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
|
||||
// Build a query requiring only Position, but iterate Position + Velocity.
|
||||
var query = world.Query().With<Position>().Build();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
};
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*ForEach*type*Velocity*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForEach_DoesNotThrow_WhenTypeParamsMatchQueryWith()
|
||||
{
|
||||
var world = new World();
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
|
||||
var act = () =>
|
||||
{
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel) => { });
|
||||
world.DestroyEntity(it.Entity);
|
||||
}
|
||||
};
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,9 @@ public class QueryTests
|
|||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
// c has no Position
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
||||
{
|
||||
results.Add(entity);
|
||||
});
|
||||
foreach (var it in world.Select<Position>())
|
||||
results.Add(it.Entity);
|
||||
|
||||
results.Should().BeEquivalentTo([a, b]);
|
||||
}
|
||||
|
|
@ -50,13 +46,9 @@ public class QueryTests
|
|||
world.AddComponent(c, new Velocity { X = 0, Y = 1 });
|
||||
// c has no Position
|
||||
|
||||
var query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
||||
{
|
||||
results.Add(entity);
|
||||
});
|
||||
foreach (var it in world.Select<Position, Velocity>())
|
||||
results.Add(it.Entity);
|
||||
|
||||
results.Should().BeEquivalentTo([a]);
|
||||
}
|
||||
|
|
@ -72,13 +64,10 @@ public class QueryTests
|
|||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
world.AddComponent(b, new Frozen());
|
||||
|
||||
var query = world.Query().With<Position>().Without<Frozen>().Build();
|
||||
var query = new Query<Position>().Without<Frozen>();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
||||
{
|
||||
results.Add(entity);
|
||||
});
|
||||
foreach (var it in world.Select(query))
|
||||
results.Add(it.Entity);
|
||||
|
||||
results.Should().BeEquivalentTo([a]);
|
||||
}
|
||||
|
|
@ -87,14 +76,9 @@ public class QueryTests
|
|||
public void Query_EmptyResult_WhenNoEntitiesMatch()
|
||||
{
|
||||
var world = new World();
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var count = 0;
|
||||
|
||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
||||
{
|
||||
foreach (var it in world.Select<Position>())
|
||||
count++;
|
||||
});
|
||||
|
||||
count.Should().Be(0);
|
||||
}
|
||||
|
||||
|
|
@ -105,12 +89,8 @@ public class QueryTests
|
|||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Position pos) =>
|
||||
{
|
||||
pos.X = 42;
|
||||
});
|
||||
foreach (var it in world.Select<Position>())
|
||||
it.Ref1.X = 42;
|
||||
|
||||
ref var pos = ref world.GetComponent<Position>(entity);
|
||||
pos.X.Should().Be(42);
|
||||
|
|
@ -128,13 +108,9 @@ public class QueryTests
|
|||
|
||||
world.DestroyEntity(a);
|
||||
|
||||
var query = world.Query().With<Position>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity entity, ref Position pos) =>
|
||||
{
|
||||
results.Add(entity);
|
||||
});
|
||||
foreach (var it in world.Select<Position>())
|
||||
results.Add(it.Entity);
|
||||
|
||||
results.Should().BeEquivalentTo([b]);
|
||||
}
|
||||
|
|
@ -149,23 +125,31 @@ public class QueryTests
|
|||
world.AddComponent(entity, new Velocity { X = 3, Y = 4 });
|
||||
world.AddComponent(entity, new Health { Value = 100 });
|
||||
|
||||
var query = world.Query()
|
||||
.With<Position>()
|
||||
.With<Velocity>()
|
||||
.With<Health>()
|
||||
.Build();
|
||||
|
||||
var count = 0;
|
||||
world.ForEach(query, (Entity e, ref Position pos, ref Velocity vel, ref Health hp) =>
|
||||
foreach (var it in world.Select<Position, Velocity, Health>())
|
||||
{
|
||||
count++;
|
||||
pos.X.Should().Be(1);
|
||||
vel.Y.Should().Be(4);
|
||||
hp.Value.Should().Be(100);
|
||||
});
|
||||
it.Val1.X.Should().Be(1);
|
||||
it.Val2.Y.Should().Be(4);
|
||||
it.Val3.Value.Should().Be(100);
|
||||
}
|
||||
|
||||
count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindEntity_ReturnsFirstMatch()
|
||||
{
|
||||
var world = new World();
|
||||
var a = world.CreateEntity();
|
||||
var b = world.CreateEntity();
|
||||
|
||||
world.AddComponent(a, new Position { X = 1, Y = 2 });
|
||||
world.AddComponent(b, new Position { X = 3, Y = 4 });
|
||||
|
||||
var found = world.FindEntity<Position>();
|
||||
found.Should().NotBe(Entity.Null);
|
||||
}
|
||||
}
|
||||
|
||||
public class SystemTests
|
||||
|
|
@ -175,23 +159,16 @@ public class SystemTests
|
|||
|
||||
private class MovementSystem : ITickedSystem
|
||||
{
|
||||
private readonly QueryDescriptor _query;
|
||||
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
|
||||
|
||||
public MovementSystem(World world)
|
||||
{
|
||||
_query = world.Query().With<Position>().With<Velocity>().Build();
|
||||
}
|
||||
|
||||
public void Run(World world) => Run(world, Tick.Logical());
|
||||
|
||||
public void Run(World world, Tick tick)
|
||||
public void RunImpl(World world, Tick tick)
|
||||
{
|
||||
float dt = tick.DeltaTime;
|
||||
world.ForEach(_query, (Entity entity, ref Position pos, ref Velocity vel) =>
|
||||
foreach (var it in world.Select<Position, Velocity>())
|
||||
{
|
||||
pos.X += vel.X * dt;
|
||||
pos.Y += vel.Y * dt;
|
||||
});
|
||||
it.Ref1.X += it.Val2.X * dt;
|
||||
it.Ref1.Y += it.Val2.Y * dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,8 +177,7 @@ public class SystemTests
|
|||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
var system = new MovementSystem(world);
|
||||
group.Add(system);
|
||||
group.Add(new MovementSystem());
|
||||
|
||||
var entity = world.CreateEntity();
|
||||
world.AddComponent(entity, new Position { X = 0, Y = 0 });
|
||||
|
|
@ -220,7 +196,7 @@ public class SystemTests
|
|||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
|
||||
var system = new TestLogicalSystem(world);
|
||||
var system = new TestLogicalSystem();
|
||||
group.Add(system);
|
||||
|
||||
group.RunLogical();
|
||||
|
|
@ -235,67 +211,12 @@ public class SystemTests
|
|||
public bool WasCalled { get; private set; }
|
||||
public Tick ReceivedTick { get; private set; }
|
||||
|
||||
public TestLogicalSystem(World world)
|
||||
{
|
||||
}
|
||||
public void RunImpl(World world) { }
|
||||
|
||||
public void Run(World world) => Run(world, Tick.Logical());
|
||||
|
||||
public void Run(World world, Tick tick)
|
||||
public void RunImpl(World world, Tick tick)
|
||||
{
|
||||
WasCalled = true;
|
||||
ReceivedTick = tick;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Systems_RunInRegistrationOrder()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
var order = new List<int>();
|
||||
|
||||
group.Add(new OrderTrackingSystem(world, 1, order));
|
||||
group.Add(new OrderTrackingSystem(world, 2, order));
|
||||
group.Add(new OrderTrackingSystem(world, 3, order));
|
||||
|
||||
group.RunLogical();
|
||||
|
||||
order.Should().BeInAscendingOrder();
|
||||
order.Should().BeEquivalentTo([1, 2, 3]);
|
||||
}
|
||||
|
||||
private class OrderTrackingSystem : ISystem
|
||||
{
|
||||
private readonly int _id;
|
||||
private readonly List<int> _order;
|
||||
|
||||
public OrderTrackingSystem(World world, int id, List<int> order)
|
||||
{
|
||||
_id = id;
|
||||
_order = order;
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
{
|
||||
_order.Add(_id);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_CanBeRemoved()
|
||||
{
|
||||
var world = new World();
|
||||
var group = new SystemGroup(world);
|
||||
var system = new TestLogicalSystem(world);
|
||||
|
||||
group.Add(system);
|
||||
group.Count.Should().Be(1);
|
||||
|
||||
group.Remove(system);
|
||||
group.Count.Should().Be(0);
|
||||
|
||||
group.RunLogical();
|
||||
system.WasCalled.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ public class ReactivityTests
|
|||
public void ObserveQuery_OnlyReceivesMatchingChanges()
|
||||
{
|
||||
var world = new World();
|
||||
var query = world.Query().With<Position>().Without<Frozen>().Build();
|
||||
var query = new Query<Position>().Without<Frozen>();
|
||||
var collector = new ChangeCollector();
|
||||
|
||||
using var sub = world.ObserveQuery(query).Subscribe(collector.ToObserver());
|
||||
|
|
@ -273,7 +273,7 @@ public class ReactivityTests
|
|||
_world = world;
|
||||
}
|
||||
|
||||
public void Run(World world)
|
||||
public void RunImpl(World world)
|
||||
{
|
||||
_world.CreateEntity();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +40,6 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
|
|
@ -61,14 +59,12 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent1
|
||||
});
|
||||
|
||||
// Replace with a new target.
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent2
|
||||
});
|
||||
|
||||
|
|
@ -85,7 +81,6 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
|
|
@ -107,7 +102,6 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child,
|
||||
Target = parent
|
||||
});
|
||||
|
||||
|
|
@ -131,15 +125,15 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child1, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child1, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
world.AddComponent(child2, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child2, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
world.AddComponent(child3, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child3, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var sources = world.GetSources<Relationship<ChildOf, ParentOf>>(parent);
|
||||
|
|
@ -156,11 +150,11 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(entity, new Relationship<OwnedBy, Thing>
|
||||
{
|
||||
Source = entity, Target = owner
|
||||
Target = owner
|
||||
});
|
||||
world.AddComponent(entity, new Relationship<MemberOf, Thing>
|
||||
{
|
||||
Source = entity, Target = group
|
||||
Target = group
|
||||
});
|
||||
|
||||
world.GetSources<Relationship<OwnedBy, Thing>>(owner).Should().BeEquivalentTo([entity]);
|
||||
|
|
@ -189,17 +183,16 @@ public class RelationshipTests
|
|||
|
||||
world.AddComponent(child, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = child, Target = parent
|
||||
Target = parent
|
||||
});
|
||||
|
||||
var query = world.Query().With<Relationship<ChildOf, ParentOf>>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref Relationship<ChildOf, ParentOf> rel) =>
|
||||
foreach (var it in world.Select<Relationship<ChildOf, ParentOf>>())
|
||||
{
|
||||
results.Add(e);
|
||||
rel.Target.Should().Be(parent);
|
||||
});
|
||||
results.Add(it.Entity);
|
||||
it.Val1.Target.Should().Be(parent);
|
||||
}
|
||||
|
||||
results.Should().BeEquivalentTo([child]);
|
||||
}
|
||||
|
|
@ -215,13 +208,13 @@ public class RelationshipTests
|
|||
// a → b (a is child of b)
|
||||
world.AddComponent(a, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = a, Target = b
|
||||
Target = b
|
||||
});
|
||||
|
||||
// c → a (c is child of a)
|
||||
world.AddComponent(c, new Relationship<ChildOf, ParentOf>
|
||||
{
|
||||
Source = c, Target = a
|
||||
Target = a
|
||||
});
|
||||
|
||||
// Destroy a. This should:
|
||||
|
|
|
|||
|
|
@ -75,13 +75,12 @@ public class SingletonTests
|
|||
var regular = world.CreateEntity();
|
||||
world.AddComponent(regular, new GameConfig { Gravity = 1.6f, MaxPlayers = 2 });
|
||||
|
||||
var query = world.Query().With<GameConfig>().Build();
|
||||
var results = new List<Entity>();
|
||||
|
||||
world.ForEach(query, (Entity e, ref GameConfig cfg) =>
|
||||
foreach (var it in world.Select<GameConfig>())
|
||||
{
|
||||
results.Add(e);
|
||||
});
|
||||
results.Add(it.Entity);
|
||||
}
|
||||
|
||||
// Only the regular entity should appear, not the singleton.
|
||||
results.Should().BeEquivalentTo([regular]);
|
||||
|
|
@ -94,7 +93,9 @@ public class SingletonTests
|
|||
|
||||
world.SetSingleton(new GameConfig());
|
||||
|
||||
world.IsAlive(World.SingletonEntity).Should().BeTrue();
|
||||
// Singletons are now on their own dedicated entities.
|
||||
// Verify the singleton exists (its entity is alive).
|
||||
world.HasSingleton<GameConfig>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -153,9 +154,9 @@ public class SingletonTests
|
|||
_world = world;
|
||||
}
|
||||
|
||||
public void Run(World world) => Run(world, Tick.Logical());
|
||||
public void RunImpl(World world) => RunImpl(world, Tick.Logical());
|
||||
|
||||
public void Run(World world, Tick tick)
|
||||
public void RunImpl(World world, Tick tick)
|
||||
{
|
||||
ref var time = ref _world.GetSingleton<TimeState>();
|
||||
time.Elapsed += tick.DeltaTime;
|
||||
|
|
|
|||
100
OECS.sln
100
OECS.sln
|
|
@ -1,4 +1,5 @@
|
|||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
|
|
@ -8,14 +9,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.SourceGen", "OECS.Sour
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.Tests", "OECS.Tests\OECS.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blackjack", "Game.Blackjack\Blackjack.csproj", "{85631EDE-F984-4DA6-8EE9-0715AF1204E6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TicTacToe", "Game.TicTacToe\TicTacToe.csproj", "{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.Blackjack.Tests", "Game.Blackjack.Tests\Game.Blackjack.Tests.csproj", "{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.TicTacToe.Tests", "Game.TicTacToe.Tests\Game.TicTacToe.Tests.csproj", "{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CardWars", "Game.CardWars\CardWars.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Game.CardWars.Tests", "Game.CardWars.Tests\Game.CardWars.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678902}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OECS.PlayTest", "OECS.PlayTest\OECS.PlayTest.csproj", "{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -86,33 +93,68 @@ Global
|
|||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D4E5F6A7-B8C9-0123-DEF4-567890ABCDEF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567891}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678902}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ABF4060A-B8A2-49FC-9CEA-A54BCB3E315E}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E5F6A7B8-C9D0-1234-EF56-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F6A7B8C9-D0E1-2345-F678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ internal class ChangeBuffer
|
|||
|
||||
private readonly Subject<EntityChange> _entitySubject = new();
|
||||
private readonly Dictionary<Type, TrackedSubject> _componentSubjects = new();
|
||||
private readonly Dictionary<QueryDescriptor, TrackedSubject> _querySubjects = new();
|
||||
private readonly Dictionary<QueryKey, TrackedSubject> _querySubjects = new();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="Subject{T}"/> with a subscriber count so that
|
||||
|
|
@ -27,6 +27,42 @@ internal class ChangeBuffer
|
|||
public int SubscriberCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a query subscription for cleanup tracking.
|
||||
/// </summary>
|
||||
private readonly struct QueryKey : IEquatable<QueryKey>
|
||||
{
|
||||
public readonly Type[] WithTypes;
|
||||
public readonly Type[] WithoutTypes;
|
||||
|
||||
public QueryKey(Type[] withTypes, Type[] withoutTypes)
|
||||
{
|
||||
WithTypes = withTypes;
|
||||
WithoutTypes = withoutTypes;
|
||||
}
|
||||
|
||||
public bool Equals(QueryKey other)
|
||||
{
|
||||
if (WithTypes.Length != other.WithTypes.Length) return false;
|
||||
if (WithoutTypes.Length != other.WithoutTypes.Length) return false;
|
||||
for (int i = 0; i < WithTypes.Length; i++)
|
||||
if (WithTypes[i] != other.WithTypes[i]) return false;
|
||||
for (int i = 0; i < WithoutTypes.Length; i++)
|
||||
if (WithoutTypes[i] != other.WithoutTypes[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is QueryKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var t in WithTypes) hash.Add(t);
|
||||
foreach (var t in WithoutTypes) hash.Add(t);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The change set currently accumulating. Cleared after each <see cref="Post"/>.
|
||||
/// </summary>
|
||||
|
|
@ -101,14 +137,33 @@ internal class ChangeBuffer
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits changes matching the given query.
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query's With types and excluding its Without types.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||
public Observable<EntityChange> ObserveQuery<T1>(Query<T1> query)
|
||||
where T1 : struct
|
||||
{
|
||||
if (!_querySubjects.TryGetValue(query, out var tracked))
|
||||
var key = MakeKey(query);
|
||||
return SubscribeQuery(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
var key = MakeKey(query);
|
||||
return SubscribeQuery(key);
|
||||
}
|
||||
|
||||
private Observable<EntityChange> SubscribeQuery(QueryKey key)
|
||||
{
|
||||
if (!_querySubjects.TryGetValue(key, out var tracked))
|
||||
{
|
||||
tracked = new TrackedSubject();
|
||||
_querySubjects[query] = tracked;
|
||||
_querySubjects[key] = tracked;
|
||||
}
|
||||
|
||||
tracked.SubscriberCount++;
|
||||
|
|
@ -118,11 +173,28 @@ internal class ChangeBuffer
|
|||
if (tracked.SubscriberCount <= 0)
|
||||
{
|
||||
tracked.Subject.Dispose();
|
||||
_querySubjects.Remove(query);
|
||||
_querySubjects.Remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static QueryKey MakeKey<T1>(Query<T1> query) where T1 : struct
|
||||
{
|
||||
var without = query.WithoutTypes;
|
||||
return new QueryKey(
|
||||
[typeof(T1)],
|
||||
without != null ? [.. without] : []);
|
||||
}
|
||||
|
||||
private static QueryKey MakeKey<T1, T2>(Query<T1, T2> query)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
var without = query.WithoutTypes;
|
||||
return new QueryKey(
|
||||
[typeof(T1), typeof(T2)],
|
||||
without != null ? [.. without] : []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an observable so that <paramref name="onLastDispose"/> is called
|
||||
/// when the last subscriber disposes.
|
||||
|
|
@ -159,21 +231,26 @@ internal class ChangeBuffer
|
|||
_querySubjects.Clear();
|
||||
}
|
||||
|
||||
private static bool ChangeMatchesQuery(EntityChange change, QueryDescriptor query)
|
||||
private static bool ChangeMatchesQuery(EntityChange change, QueryKey query)
|
||||
{
|
||||
// Entity-level changes: match if the entity was added/removed and
|
||||
// the query has any "with" types (we can't know component state for
|
||||
// removed entities, so we only match added entities that would satisfy
|
||||
// the query — but we don't have component data here).
|
||||
// For simplicity, we only match component-level changes against queries.
|
||||
// Entity-level changes don't have a component type.
|
||||
if (change.ComponentType == null)
|
||||
return false;
|
||||
|
||||
// A component change matches a query if the component type is in the
|
||||
// query's With set and not in the Without set.
|
||||
if (query.Without.Contains(change.ComponentType))
|
||||
return false;
|
||||
// Must be in With types.
|
||||
bool inWith = false;
|
||||
foreach (var t in query.WithTypes)
|
||||
{
|
||||
if (t == change.ComponentType) { inWith = true; break; }
|
||||
}
|
||||
if (!inWith) return false;
|
||||
|
||||
return query.With.Contains(change.ComponentType);
|
||||
// Must not be in Without types.
|
||||
foreach (var t in query.WithoutTypes)
|
||||
{
|
||||
if (t == change.ComponentType) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,5 +29,12 @@ public enum ChangeKind
|
|||
/// A component's value was modified. Must be explicitly marked
|
||||
/// via <see cref="World.MarkModified{T}"/>.
|
||||
/// </summary>
|
||||
ComponentModified
|
||||
ComponentModified,
|
||||
|
||||
/// <summary>
|
||||
/// The source set for a relationship type on a target entity was
|
||||
/// reordered. The <see cref="EntityChange.Entity"/> is the target
|
||||
/// and <see cref="EntityChange.ComponentType"/> is the relationship type.
|
||||
/// </summary>
|
||||
RelationshipReordered
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ internal class ChangeSet
|
|||
TryAdd(new EntityChange(entity, ChangeKind.ComponentModified, componentType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the source set for a relationship type on a target entity
|
||||
/// was reordered.
|
||||
/// </summary>
|
||||
public void MarkRelationshipReordered(Entity target, Type relationshipType)
|
||||
{
|
||||
TryAdd(new EntityChange(target, ChangeKind.RelationshipReordered, relationshipType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all accumulated changes.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -41,11 +41,18 @@ public class CommandQueue
|
|||
/// <summary>
|
||||
/// Executes all queued commands in FIFO order against the given world.
|
||||
///
|
||||
/// Commands run inside a batching scope — structural mutations are
|
||||
/// deferred and <see cref="World.GetComponent{T}"/> calls are
|
||||
/// auto-tracked for modification until the drain completes.
|
||||
/// Pending mutations are flushed after each command, so chained
|
||||
/// commands see each other's changes within the same drain cycle.
|
||||
///
|
||||
/// The queue is fully drained — commands enqueued by other commands during
|
||||
/// this call are also executed before the method returns.
|
||||
/// </summary>
|
||||
public void ExecuteAll(World world)
|
||||
{
|
||||
world.BeginBatching();
|
||||
int index = 0;
|
||||
while (index < _commands.Count)
|
||||
{
|
||||
|
|
@ -60,9 +67,13 @@ public class CommandQueue
|
|||
{
|
||||
_errors.Add(ex);
|
||||
}
|
||||
|
||||
// Flush after each command so chained commands see mutations.
|
||||
world.FlushPendingMutations();
|
||||
}
|
||||
|
||||
_commands.Clear();
|
||||
world.EndBatching();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -34,17 +34,25 @@ public sealed class ComponentDescriptor
|
|||
/// </summary>
|
||||
public Func<byte[], object> Deserialize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this component type is used as a singleton (via <c>SetSingleton</c>).
|
||||
/// Set by the source generator at compile time.
|
||||
/// </summary>
|
||||
public bool IsSingleton { get; }
|
||||
|
||||
public ComponentDescriptor(
|
||||
string typeName,
|
||||
Type type,
|
||||
Func<object, byte[]> serialize,
|
||||
Action<World, Entity, byte[]> deserializeAndAdd,
|
||||
Func<byte[], object> deserialize)
|
||||
Func<byte[], object> deserialize,
|
||||
bool isSingleton = false)
|
||||
{
|
||||
TypeName = typeName;
|
||||
Type = type;
|
||||
Serialize = serialize;
|
||||
DeserializeAndAdd = deserializeAndAdd;
|
||||
Deserialize = deserialize;
|
||||
IsSingleton = isSingleton;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ namespace OECS;
|
|||
internal class EntityAllocator
|
||||
{
|
||||
private const int DefaultCapacity = 1024;
|
||||
private const uint FirstValidId = 2; // 0 = Null, 1 = Singleton
|
||||
private const uint FirstValidId = 1; // 0 = Null
|
||||
|
||||
private byte[] _versions;
|
||||
private readonly Stack<uint> _freeIds;
|
||||
|
|
@ -63,16 +63,6 @@ internal class EntityAllocator
|
|||
_freeIds.Push(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the singleton entity so that <see cref="IsAlive"/> returns true for it.
|
||||
/// Called once by <see cref="World"/> when the first singleton accessor is used.
|
||||
/// </summary>
|
||||
public void RegisterSingleton(Entity singleton)
|
||||
{
|
||||
EnsureCapacity(singleton.Id);
|
||||
_versions[singleton.Id] = (byte)singleton.Version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reserves a specific entity ID and version for deserialization.
|
||||
/// Advances <see cref="_nextId"/> past the reserved ID so future
|
||||
|
|
@ -94,7 +84,6 @@ internal class EntityAllocator
|
|||
{
|
||||
uint id = entity.Id;
|
||||
if (entity.IsNull) return false;
|
||||
if (id == 1) return true; // Singleton is always alive.
|
||||
if (id >= _versions.Length) return false;
|
||||
return _versions[id] == entity.Version && _versions[id] != 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,327 +0,0 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides zero-allocation ref struct iterators for querying entities.
|
||||
/// Use with <c>foreach</c> or manual <c>while (iter.MoveNext())</c>.
|
||||
/// Supports <c>break</c>, <c>continue</c>, and early returns.
|
||||
/// </summary>
|
||||
public static class EntityIterator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities matching the query, with ref access
|
||||
/// to one component type.
|
||||
/// </summary>
|
||||
public static Select1<T1> Select<T1>(
|
||||
this World world, QueryDescriptor query)
|
||||
where T1 : struct
|
||||
{
|
||||
return new Select1<T1>(world, query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities that have a component of type
|
||||
/// <typeparamref name="T1"/>. No <c>Without</c> filter is applied.
|
||||
/// </summary>
|
||||
public static Select1<T1> Select<T1>(
|
||||
this World world)
|
||||
where T1 : struct
|
||||
{
|
||||
return new Select1<T1>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities matching the query, with ref access
|
||||
/// to two component types.
|
||||
/// </summary>
|
||||
public static Select2<T1, T2> Select<T1, T2>(
|
||||
this World world, QueryDescriptor query)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
return new Select2<T1, T2>(world, query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities that have both components of type
|
||||
/// <typeparamref name="T1"/> and <typeparamref name="T2"/>.
|
||||
/// No <c>Without</c> filter is applied.
|
||||
/// </summary>
|
||||
public static Select2<T1, T2> Select<T1, T2>(
|
||||
this World world)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
return new Select2<T1, T2>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities matching the query, with ref access
|
||||
/// to three component types.
|
||||
/// </summary>
|
||||
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||
this World world, QueryDescriptor query)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
return new Select3<T1, T2, T3>(world, query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an iterator over entities that have all three components of type
|
||||
/// <typeparamref name="T1"/>, <typeparamref name="T2"/>, and
|
||||
/// <typeparamref name="T3"/>. No <c>Without</c> filter is applied.
|
||||
/// </summary>
|
||||
public static Select3<T1, T2, T3> Select<T1, T2, T3>(
|
||||
this World world)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
return new Select3<T1, T2, T3>(world, new QueryDescriptor(new HashSet<Type>(), new HashSet<Type>()));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ref struct iterator for queries with one component type.
|
||||
/// </summary>
|
||||
public ref struct Select1<T1> where T1 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly IReadOnlySet<Type> _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
|
||||
internal Select1(World world, QueryDescriptor query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.Without;
|
||||
_set = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_count = _set?.Count ?? 0;
|
||||
_index = -1;
|
||||
|
||||
_world.BeginIteration();
|
||||
}
|
||||
|
||||
public Entity CurrentEntity { get; private set; }
|
||||
public ref T1 Current1 => ref _set!.Get(CurrentEntity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
CurrentEntity = _set.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue; // Skip singleton.
|
||||
if (!PassesWithout()) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select1<T1> GetEnumerator() => this;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_world.EndIteration();
|
||||
}
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(CurrentEntity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ref struct iterator for queries with two component types.
|
||||
/// Drives iteration from the smaller sparse set to minimize probes.
|
||||
/// </summary>
|
||||
public ref struct Select2<T1, T2>
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly IReadOnlySet<Type> _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly bool _swapped; // true when driving from _set2
|
||||
|
||||
internal Select2(World world, QueryDescriptor query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.Without;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
|
||||
int count1 = _set1?.Count ?? 0;
|
||||
int count2 = _set2?.Count ?? 0;
|
||||
_swapped = count2 < count1;
|
||||
_count = _swapped ? count2 : count1;
|
||||
_index = -1;
|
||||
|
||||
_world.BeginIteration();
|
||||
}
|
||||
|
||||
public Entity CurrentEntity { get; private set; }
|
||||
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
|
||||
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
if (_swapped)
|
||||
{
|
||||
CurrentEntity = _set2!.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue;
|
||||
if (!_set1.Contains(CurrentEntity)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentEntity = _set1.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue;
|
||||
if (!_set2.Contains(CurrentEntity)) continue;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select2<T1, T2> GetEnumerator() => this;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_world.EndIteration();
|
||||
}
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(CurrentEntity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ref struct iterator for queries with three component types.
|
||||
/// Drives iteration from the smallest sparse set to minimize probes.
|
||||
/// </summary>
|
||||
public ref struct Select3<T1, T2, T3>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly SparseSet<T3>? _set3;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly IReadOnlySet<Type> _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly int _driver; // 0 = _set1, 1 = _set2, 2 = _set3
|
||||
|
||||
internal Select3(World world, QueryDescriptor query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.Without;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
|
||||
int count1 = _set1?.Count ?? 0;
|
||||
int count2 = _set2?.Count ?? 0;
|
||||
int count3 = _set3?.Count ?? 0;
|
||||
|
||||
if (count2 <= count1 && count2 <= count3)
|
||||
{
|
||||
_driver = 1;
|
||||
_count = count2;
|
||||
}
|
||||
else if (count3 <= count1 && count3 <= count2)
|
||||
{
|
||||
_driver = 2;
|
||||
_count = count3;
|
||||
}
|
||||
else
|
||||
{
|
||||
_driver = 0;
|
||||
_count = count1;
|
||||
}
|
||||
|
||||
_index = -1;
|
||||
|
||||
_world.BeginIteration();
|
||||
}
|
||||
|
||||
public Entity CurrentEntity { get; private set; }
|
||||
public ref T1 Current1 => ref _set1!.Get(CurrentEntity);
|
||||
public ref T2 Current2 => ref _set2!.Get(CurrentEntity);
|
||||
public ref T3 Current3 => ref _set3!.Get(CurrentEntity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null || _set3 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
switch (_driver)
|
||||
{
|
||||
case 0:
|
||||
CurrentEntity = _set1.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue;
|
||||
if (!_set2.Contains(CurrentEntity)) continue;
|
||||
if (!_set3.Contains(CurrentEntity)) continue;
|
||||
break;
|
||||
case 1:
|
||||
CurrentEntity = _set2!.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue;
|
||||
if (!_set1.Contains(CurrentEntity)) continue;
|
||||
if (!_set3.Contains(CurrentEntity)) continue;
|
||||
break;
|
||||
case 2:
|
||||
CurrentEntity = _set3!.DenseEntities[_index];
|
||||
if (CurrentEntity.Id == 1) continue;
|
||||
if (!_set1.Contains(CurrentEntity)) continue;
|
||||
if (!_set2.Contains(CurrentEntity)) continue;
|
||||
break;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select3<T1, T2, T3> GetEnumerator() => this;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_world.EndIteration();
|
||||
}
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(CurrentEntity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
namespace OECS;
|
||||
|
||||
// Custom delegate types for query iteration with ref parameters.
|
||||
// The built-in Action<...> delegates do not support ref parameters.
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with one component type.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1>(Entity entity, ref T1 c1) where T1 : struct;
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with two component types.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1, T2>(Entity entity, ref T1 c1, ref T2 c2)
|
||||
where T1 : struct where T2 : struct;
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with three component types.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1, T2, T3>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3)
|
||||
where T1 : struct where T2 : struct where T3 : struct;
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with four component types.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1, T2, T3, T4>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct;
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with five component types.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1, T2, T3, T4, T5>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct;
|
||||
|
||||
/// <summary>
|
||||
/// Callback for iterating entities with six component types.
|
||||
/// </summary>
|
||||
public delegate void ForEachAction<T1, T2, T3, T4, T5, T6>(Entity entity, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4, ref T5 c5, ref T6 c6)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct;
|
||||
|
|
@ -2,19 +2,14 @@ namespace OECS;
|
|||
|
||||
/// <summary>
|
||||
/// Marker interface for components that represent a directed relationship
|
||||
/// between two entities. The component is stored on the <see cref="Source"/>
|
||||
/// entity and points to the <see cref="Target"/> entity.
|
||||
/// between two entities. The component is stored on the source entity
|
||||
/// (the entity it's added to) and points to the <see cref="Target"/> entity.
|
||||
///
|
||||
/// The <see cref="World"/> automatically maintains a reverse index so that
|
||||
/// all sources pointing to a given target can be looked up efficiently.
|
||||
/// </summary>
|
||||
public interface IRelationship
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity that owns this relationship component.
|
||||
/// </summary>
|
||||
Entity Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The entity that this relationship points to.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,44 @@ namespace OECS;
|
|||
|
||||
/// <summary>
|
||||
/// A system that runs on every tick.
|
||||
/// Systems receive the world in their <see cref="Run"/> method.
|
||||
/// Implement <see cref="RunImpl"/> with the system logic.
|
||||
/// Call <see cref="ISystem.Run"/> to execute with batching.
|
||||
/// </summary>
|
||||
public interface ISystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the system logic for this tick.
|
||||
/// Implement this with the system's logic for this tick.
|
||||
/// The caller (e.g., <see cref="ISystem.Run"/>) wraps this
|
||||
/// in a batching scope so mutations and component accesses
|
||||
/// are automatically tracked.
|
||||
/// </summary>
|
||||
void Run(World world);
|
||||
void RunImpl(World world);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for running systems with proper batching.
|
||||
/// </summary>
|
||||
public static class SystemExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a system with a batching scope. Structural mutations are
|
||||
/// buffered, and <see cref="World.GetComponent{T}"/> calls are
|
||||
/// auto-tracked for modification until the system returns.
|
||||
/// </summary>
|
||||
public static void Run(this ISystem system, World world)
|
||||
{
|
||||
world.BeginBatching();
|
||||
system.RunImpl(world);
|
||||
world.EndBatching();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a ticked system with a batching scope.
|
||||
/// </summary>
|
||||
public static void Run(this ITickedSystem system, World world, Tick tick)
|
||||
{
|
||||
world.BeginBatching();
|
||||
system.RunImpl(world, tick);
|
||||
world.EndBatching();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ namespace OECS;
|
|||
public interface ITickedSystem : ISystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes the system logic with tick information.
|
||||
/// Implement this with the system's ticked logic.
|
||||
/// </summary>
|
||||
void Run(World world, Tick tick);
|
||||
void RunImpl(World world, Tick tick);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using System.Collections;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// An insertion-ordered set of entities that supports O(1) Add, Remove, and Contains
|
||||
/// while iterating in insertion order. Drop-in for cases where both
|
||||
/// stable ordering and fast membership checks are needed.
|
||||
///
|
||||
/// Not thread-safe.
|
||||
/// </summary>
|
||||
internal sealed class OrderedEntitySet : IReadOnlyCollection<Entity>
|
||||
{
|
||||
// Entities are stored in a packed list; a secondary dictionary maps
|
||||
// each entity to its index for O(1) removal via swap-with-last.
|
||||
private readonly List<Entity> _order = new();
|
||||
private readonly Dictionary<Entity, int> _index = new();
|
||||
|
||||
public int Count => _order.Count;
|
||||
|
||||
public bool Add(Entity entity)
|
||||
{
|
||||
if (_index.ContainsKey(entity))
|
||||
return false;
|
||||
|
||||
_index[entity] = _order.Count;
|
||||
_order.Add(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Remove(Entity entity)
|
||||
{
|
||||
if (!_index.Remove(entity, out var idx))
|
||||
return false;
|
||||
|
||||
int lastIdx = _order.Count - 1;
|
||||
if (idx < lastIdx)
|
||||
{
|
||||
var last = _order[lastIdx];
|
||||
_order[idx] = last;
|
||||
_index[last] = idx;
|
||||
}
|
||||
_order.RemoveAt(lastIdx);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the set to match the given entity order.
|
||||
/// The provided list must contain exactly the same entities as the set.
|
||||
/// </summary>
|
||||
public void Reorder(IReadOnlyList<Entity> ordered)
|
||||
{
|
||||
_order.Clear();
|
||||
_index.Clear();
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
_order.Add(ordered[i]);
|
||||
_index[ordered[i]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator<Entity> GetEnumerator() => _order.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with one required component type.
|
||||
/// Add optional <c>Without</c> filters via the fluent API.
|
||||
/// </summary>
|
||||
public struct Query<T1> where T1 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Excludes entities that have a component of type <typeparamref name="W"/>.
|
||||
/// </summary>
|
||||
public Query<T1> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with two required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2> where T1 : struct where T2 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with three required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with four required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with five required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4, T5>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4, T5> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world with six required component types.
|
||||
/// </summary>
|
||||
public struct Query<T1, T2, T3, T4, T5, T6>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
internal HashSet<Type>? WithoutTypes;
|
||||
|
||||
public Query<T1, T2, T3, T4, T5, T6> Without<W>() where W : struct
|
||||
{
|
||||
WithoutTypes ??= new HashSet<Type>();
|
||||
WithoutTypes.Add(typeof(W));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing <see cref="QueryDescriptor"/> instances.
|
||||
/// Returned by <see cref="World.Query"/>.
|
||||
/// </summary>
|
||||
public class QueryBuilder
|
||||
{
|
||||
private readonly HashSet<Type> _with = new();
|
||||
private readonly HashSet<Type> _without = new();
|
||||
|
||||
/// <summary>
|
||||
/// Requires entities to have a component of type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
public QueryBuilder With<T>() where T : struct
|
||||
{
|
||||
_with.Add(typeof(T));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excludes entities that have a component of type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
public QueryBuilder Without<T>() where T : struct
|
||||
{
|
||||
_without.Add(typeof(T));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the query descriptor.
|
||||
/// </summary>
|
||||
public QueryDescriptor Build()
|
||||
{
|
||||
return new QueryDescriptor(
|
||||
new HashSet<Type>(_with),
|
||||
new HashSet<Type>(_without));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a query over the ECS world.
|
||||
/// Composed of a set of required component types ("with") and
|
||||
/// a set of excluded component types ("without").
|
||||
/// </summary>
|
||||
public class QueryDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Component types that must be present on matching entities.
|
||||
/// </summary>
|
||||
public IReadOnlySet<Type> With { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Component types that must NOT be present on matching entities.
|
||||
/// </summary>
|
||||
public IReadOnlySet<Type> Without { get; }
|
||||
|
||||
private readonly int _hashCode;
|
||||
|
||||
internal QueryDescriptor(HashSet<Type> with, HashSet<Type> without)
|
||||
{
|
||||
With = with;
|
||||
Without = without;
|
||||
_hashCode = ComputeHashCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this query has no "with" components (matches nothing).
|
||||
/// </summary>
|
||||
internal bool IsEmpty => With.Count == 0;
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not QueryDescriptor other) return false;
|
||||
if (With.Count != other.With.Count || Without.Count != other.Without.Count)
|
||||
return false;
|
||||
return With.SetEquals(other.With) && Without.SetEquals(other.Without);
|
||||
}
|
||||
|
||||
public override int GetHashCode() => _hashCode;
|
||||
|
||||
private int ComputeHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var t in With.OrderBy(t => t.GUID.ToString()))
|
||||
hash.Add(t);
|
||||
foreach (var t in Without.OrderBy(t => t.GUID.ToString()))
|
||||
hash.Add(t);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,507 +0,0 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides query execution logic for <see cref="World"/>.
|
||||
/// Iterates one sparse set as the driver and probes the remaining sets
|
||||
/// for membership. The "without" filter is checked after all "with" probes.
|
||||
///
|
||||
/// The singleton entity (ID 1) is automatically excluded from all queries.
|
||||
/// </summary>
|
||||
internal static class QueryExecutor
|
||||
{
|
||||
private const uint SingletonId = 1;
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet<Type> withoutTypes)
|
||||
{
|
||||
foreach (var type in withoutTypes)
|
||||
{
|
||||
var set = store.GetSet(type);
|
||||
if (set != null && set.Contains(entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── ForEach overloads ──────────────────────────────────────────────
|
||||
|
||||
public static void ForEach<T1>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1> action)
|
||||
where T1 : struct
|
||||
{
|
||||
var set = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set == null) return;
|
||||
|
||||
var dense = set.Dense;
|
||||
var denseEntities = set.DenseEntities;
|
||||
var count = set.Count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without))
|
||||
continue;
|
||||
action(entity, ref dense[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ForEach<T1, T2>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2> action)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set1 == null) return;
|
||||
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
if (set2 == null) return;
|
||||
|
||||
if (set1.Count <= set2.Count)
|
||||
{
|
||||
IterateTwo(set1, set2, store, query.Without, action);
|
||||
}
|
||||
else
|
||||
{
|
||||
IterateTwoSwapped(set2, set1, store, query.Without, action);
|
||||
}
|
||||
}
|
||||
|
||||
private static void IterateTwo<TDriver, TOther>(
|
||||
SparseSet<TDriver> driveSet,
|
||||
SparseSet<TOther> otherSet,
|
||||
ComponentStore store,
|
||||
IReadOnlySet<Type> withoutTypes,
|
||||
ForEachAction<TDriver, TOther> action)
|
||||
where TDriver : struct where TOther : struct
|
||||
{
|
||||
var dense = driveSet.Dense;
|
||||
var denseEntities = driveSet.DenseEntities;
|
||||
var count = driveSet.Count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!otherSet.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, withoutTypes))
|
||||
continue;
|
||||
action(entity, ref dense[i], ref otherSet.Get(entity));
|
||||
}
|
||||
}
|
||||
|
||||
private static void IterateTwoSwapped<TOther, TDriver>(
|
||||
SparseSet<TDriver> driveSet,
|
||||
SparseSet<TOther> otherSet,
|
||||
ComponentStore store,
|
||||
IReadOnlySet<Type> withoutTypes,
|
||||
ForEachAction<TOther, TDriver> action)
|
||||
where TDriver : struct where TOther : struct
|
||||
{
|
||||
var dense = driveSet.Dense;
|
||||
var denseEntities = driveSet.DenseEntities;
|
||||
var count = driveSet.Count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!otherSet.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, withoutTypes))
|
||||
continue;
|
||||
action(entity, ref otherSet.Get(entity), ref dense[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ForEach<T1, T2, T3>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set1 == null) return;
|
||||
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
if (set2 == null) return;
|
||||
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
if (set3 == null) return;
|
||||
|
||||
// Pick the smallest set as the driver.
|
||||
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count;
|
||||
|
||||
if (c2 <= c1 && c2 <= c3)
|
||||
{
|
||||
var dense = set2.Dense;
|
||||
var denseEntities = set2.DenseEntities;
|
||||
var count = set2.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (c3 <= c1 && c3 <= c2)
|
||||
{
|
||||
var dense = set3.Dense;
|
||||
var denseEntities = set3.DenseEntities;
|
||||
var count = set3.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var dense = set1.Dense;
|
||||
var denseEntities = set1.DenseEntities;
|
||||
var count = set1.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ForEach<T1, T2, T3, T4>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set1 == null) return;
|
||||
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
if (set2 == null) return;
|
||||
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
if (set3 == null) return;
|
||||
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
if (set4 == null) return;
|
||||
|
||||
// Pick the smallest set as the driver.
|
||||
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count;
|
||||
int min = Math.Min(Math.Min(c1, c2), Math.Min(c3, c4));
|
||||
|
||||
if (min == c2)
|
||||
{
|
||||
var dense = set2.Dense;
|
||||
var denseEntities = set2.DenseEntities;
|
||||
var count = set2.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c3)
|
||||
{
|
||||
var dense = set3.Dense;
|
||||
var denseEntities = set3.DenseEntities;
|
||||
var count = set3.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c4)
|
||||
{
|
||||
var dense = set4.Dense;
|
||||
var denseEntities = set4.DenseEntities;
|
||||
var count = set4.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var dense = set1.Dense;
|
||||
var denseEntities = set1.DenseEntities;
|
||||
var count = set1.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ForEach<T1, T2, T3, T4, T5>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4, T5> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set1 == null) return;
|
||||
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
if (set2 == null) return;
|
||||
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
if (set3 == null) return;
|
||||
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
if (set4 == null) return;
|
||||
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
|
||||
if (set5 == null) return;
|
||||
|
||||
// Pick the smallest set as the driver.
|
||||
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count;
|
||||
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), c5);
|
||||
|
||||
if (min == c2)
|
||||
{
|
||||
var dense = set2.Dense;
|
||||
var denseEntities = set2.DenseEntities;
|
||||
var count = set2.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c3)
|
||||
{
|
||||
var dense = set3.Dense;
|
||||
var denseEntities = set3.DenseEntities;
|
||||
var count = set3.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c4)
|
||||
{
|
||||
var dense = set4.Dense;
|
||||
var denseEntities = set4.DenseEntities;
|
||||
var count = set4.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c5)
|
||||
{
|
||||
var dense = set5.Dense;
|
||||
var denseEntities = set5.DenseEntities;
|
||||
var count = set5.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var dense = set1.Dense;
|
||||
var denseEntities = set1.DenseEntities;
|
||||
var count = set1.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ForEach<T1, T2, T3, T4, T5, T6>(
|
||||
ComponentStore store,
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4, T5, T6> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
var set1 = store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
if (set1 == null) return;
|
||||
var set2 = store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
if (set2 == null) return;
|
||||
var set3 = store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
if (set3 == null) return;
|
||||
var set4 = store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
if (set4 == null) return;
|
||||
var set5 = store.GetSet(typeof(T5)) as SparseSet<T5>;
|
||||
if (set5 == null) return;
|
||||
var set6 = store.GetSet(typeof(T6)) as SparseSet<T6>;
|
||||
if (set6 == null) return;
|
||||
|
||||
// Pick the smallest set as the driver.
|
||||
int c1 = set1.Count, c2 = set2.Count, c3 = set3.Count, c4 = set4.Count, c5 = set5.Count, c6 = set6.Count;
|
||||
int min = Math.Min(Math.Min(Math.Min(c1, c2), Math.Min(c3, c4)), Math.Min(c5, c6));
|
||||
|
||||
if (min == c2)
|
||||
{
|
||||
var dense = set2.Dense;
|
||||
var denseEntities = set2.DenseEntities;
|
||||
var count = set2.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!set6.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref dense[i], ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c3)
|
||||
{
|
||||
var dense = set3.Dense;
|
||||
var denseEntities = set3.DenseEntities;
|
||||
var count = set3.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!set6.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref dense[i], ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c4)
|
||||
{
|
||||
var dense = set4.Dense;
|
||||
var denseEntities = set4.DenseEntities;
|
||||
var count = set4.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!set6.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref dense[i], ref set5.Get(entity), ref set6.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c5)
|
||||
{
|
||||
var dense = set5.Dense;
|
||||
var denseEntities = set5.DenseEntities;
|
||||
var count = set5.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set6.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref dense[i], ref set6.Get(entity));
|
||||
}
|
||||
}
|
||||
else if (min == c6)
|
||||
{
|
||||
var dense = set6.Dense;
|
||||
var denseEntities = set6.DenseEntities;
|
||||
var count = set6.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set1.Contains(entity)) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref set1.Get(entity), ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref dense[i]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var dense = set1.Dense;
|
||||
var denseEntities = set1.DenseEntities;
|
||||
var count = set1.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var entity = denseEntities[i];
|
||||
if (entity.Id == SingletonId) continue;
|
||||
if (!set2.Contains(entity)) continue;
|
||||
if (!set3.Contains(entity)) continue;
|
||||
if (!set4.Contains(entity)) continue;
|
||||
if (!set5.Contains(entity)) continue;
|
||||
if (!set6.Contains(entity)) continue;
|
||||
if (!PassesWithoutFilter(store, entity, query.Without)) continue;
|
||||
action(entity, ref dense[i], ref set2.Get(entity), ref set3.Get(entity), ref set4.Get(entity), ref set5.Get(entity), ref set6.Get(entity));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ namespace OECS;
|
|||
/// // Define a "ChildOf" relationship between a child and a parent entity.
|
||||
/// world.AddComponent(child, new Relationship<ChildOf, Parent>
|
||||
/// {
|
||||
/// Source = child,
|
||||
/// Target = parent
|
||||
/// });
|
||||
///
|
||||
|
|
@ -27,15 +26,9 @@ namespace OECS;
|
|||
public struct Relationship<TSelf, TTarget> : IRelationship
|
||||
where TSelf : struct where TTarget : struct
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity that owns this relationship component.
|
||||
/// </summary>
|
||||
[Key(0)]
|
||||
public Entity Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The entity that this relationship points to.
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
[Key(0)]
|
||||
public Entity Target { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ namespace OECS;
|
|||
///
|
||||
/// Updated automatically by <see cref="World"/> when relationship
|
||||
/// components are added, removed, or when entities are destroyed.
|
||||
///
|
||||
/// Source sets are insertion-ordered — the order in which relationships
|
||||
/// are added is preserved during iteration.
|
||||
/// </summary>
|
||||
internal class RelationshipIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Per relationship type: target entity → set of source entities.
|
||||
/// </summary>
|
||||
private readonly Dictionary<Type, Dictionary<Entity, HashSet<Entity>>> _index = new();
|
||||
private readonly Dictionary<Type, Dictionary<Entity, OrderedEntitySet>> _index = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registers that a source entity now has a relationship pointing to a target.
|
||||
|
|
@ -23,13 +26,13 @@ internal class RelationshipIndex
|
|||
{
|
||||
if (!_index.TryGetValue(relationshipType, out var targetMap))
|
||||
{
|
||||
targetMap = new Dictionary<Entity, HashSet<Entity>>();
|
||||
targetMap = new Dictionary<Entity, OrderedEntitySet>();
|
||||
_index[relationshipType] = targetMap;
|
||||
}
|
||||
|
||||
if (!targetMap.TryGetValue(target, out var sources))
|
||||
{
|
||||
sources = new HashSet<Entity>();
|
||||
sources = new OrderedEntitySet();
|
||||
targetMap[target] = sources;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ internal class RelationshipIndex
|
|||
|
||||
/// <summary>
|
||||
/// Returns all source entities that have a relationship of the given type
|
||||
/// pointing to the specified target entity.
|
||||
/// pointing to the specified target entity, in insertion order.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
|
||||
where T : struct, IRelationship
|
||||
|
|
@ -116,6 +119,23 @@ internal class RelationshipIndex
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the source set for the given relationship type and target
|
||||
/// to match the order of entities in <paramref name="ordered"/>.
|
||||
/// </summary>
|
||||
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
|
||||
where T : struct, IRelationship
|
||||
{
|
||||
var type = typeof(T);
|
||||
if (!_index.TryGetValue(type, out var targetMap))
|
||||
return;
|
||||
|
||||
if (!targetMap.TryGetValue(target, out var sources))
|
||||
return;
|
||||
|
||||
sources.Reorder(ordered);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the index has any entries for the given relationship type.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ public class SystemGroup
|
|||
// sees their effects.
|
||||
_world.ExecuteCommands();
|
||||
|
||||
_world.BeginBatching();
|
||||
foreach (var system in _systems)
|
||||
{
|
||||
if (system is ITickedSystem ticked)
|
||||
|
|
@ -77,10 +78,15 @@ public class SystemGroup
|
|||
// see the effects of commands enqueued by prior systems.
|
||||
_world.ExecuteCommands();
|
||||
|
||||
// Flush pending mutations so subsequent systems see
|
||||
// entity/component changes from commands and previous systems.
|
||||
_world.FlushPendingMutations();
|
||||
|
||||
// Post changes after each system so subscribers see
|
||||
// incremental updates.
|
||||
_world.PostChanges();
|
||||
}
|
||||
_world.EndBatching();
|
||||
|
||||
// Drain any remaining commands (e.g., those enqueued outside systems).
|
||||
_world.ExecuteCommands();
|
||||
|
|
|
|||
359
OECS/World.cs
359
OECS/World.cs
|
|
@ -11,23 +11,19 @@ namespace OECS;
|
|||
/// </summary>
|
||||
public class World : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The reserved entity ID for the singleton entity.
|
||||
/// </summary>
|
||||
public static readonly Entity SingletonEntity = new(1, 1);
|
||||
|
||||
private readonly EntityAllocator _allocator;
|
||||
private readonly ComponentStore _components;
|
||||
private readonly CommandQueue _commands;
|
||||
private readonly RelationshipIndex _relationships;
|
||||
private readonly ChangeBuffer _changes;
|
||||
private bool _singletonCreated;
|
||||
private readonly Dictionary<Type, Entity> _singletonEntities = new();
|
||||
private bool _disposed;
|
||||
|
||||
// Deferred structural mutation support: when iterating entities,
|
||||
// AddComponent, RemoveComponent, and DestroyEntity are buffered and
|
||||
// applied after the iteration completes.
|
||||
private int _iterationDepth;
|
||||
// Deferred structural mutation support: when inside a batching scope
|
||||
// (e.g., a foreach iteration or a system run), AddComponent,
|
||||
// RemoveComponent, and DestroyEntity are buffered and applied when
|
||||
// the outermost scope ends.
|
||||
private int _batchingDepth;
|
||||
private readonly List<PendingMutation> _pendingMutations = new();
|
||||
|
||||
private enum PendingMutationKind { AddComponent, RemoveComponent, DestroyEntity }
|
||||
|
|
@ -40,10 +36,10 @@ public class World : IDisposable
|
|||
public Type ComponentType;
|
||||
}
|
||||
|
||||
// Auto-dirty-marking: during iteration, component accesses via
|
||||
// GetComponent<T> are tracked. When the iteration scope ends,
|
||||
// all accessed components are automatically marked as modified.
|
||||
// Outside of iteration, MarkModified<T> must be called explicitly.
|
||||
// Auto-dirty-marking: during a batching scope, component accesses via
|
||||
// GetComponent<T> are tracked. When the scope ends, all accessed
|
||||
// components are automatically marked as modified.
|
||||
// Outside of a batching scope, MarkModified<T> must be called explicitly.
|
||||
private readonly HashSet<(Entity Entity, Type ComponentType)> _accessedComponents = new();
|
||||
private readonly HashSet<(Entity Entity, Type ComponentType)> _markedModified = new();
|
||||
|
||||
|
|
@ -91,7 +87,7 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public void DestroyEntity(Entity entity)
|
||||
{
|
||||
if (_iterationDepth > 0)
|
||||
if (_batchingDepth > 0)
|
||||
{
|
||||
_pendingMutations.Add(new PendingMutation
|
||||
{
|
||||
|
|
@ -109,10 +105,6 @@ public class World : IDisposable
|
|||
if (!_allocator.IsAlive(entity))
|
||||
return;
|
||||
|
||||
// Singleton entity is never destroyed.
|
||||
if (entity.Id == 1)
|
||||
return;
|
||||
|
||||
// Remove all relationships where this entity is the target.
|
||||
// Materialize to avoid collection-modified-during-enumeration when
|
||||
// OnRemoved mutates the same HashSet we're iterating.
|
||||
|
|
@ -166,7 +158,7 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public void AddComponent<T>(Entity entity, T component) where T : struct
|
||||
{
|
||||
if (_iterationDepth > 0)
|
||||
if (_batchingDepth > 0)
|
||||
{
|
||||
_pendingMutations.Add(new PendingMutation
|
||||
{
|
||||
|
|
@ -203,19 +195,8 @@ public class World : IDisposable
|
|||
ThrowIfNotAlive(entity);
|
||||
|
||||
// If replacing an existing relationship, remove the old index entry first.
|
||||
if (component is IRelationship newRel)
|
||||
if (component is IRelationship)
|
||||
{
|
||||
// Guard against mismatched Source: the relationship must be stored
|
||||
// on the entity it claims as its Source, otherwise the reverse
|
||||
// index becomes corrupted.
|
||||
if (newRel.Source != entity)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Relationship of type {typeof(T).Name} has Source={newRel.Source} " +
|
||||
$"but is being added to entity {entity}. " +
|
||||
$"The Source must match the entity the component is added to.");
|
||||
}
|
||||
|
||||
if (_components.TryGet<T>(entity, out var old))
|
||||
{
|
||||
var oldRel = (IRelationship)(object)old;
|
||||
|
|
@ -246,7 +227,7 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public void RemoveComponent<T>(Entity entity) where T : struct
|
||||
{
|
||||
if (_iterationDepth > 0)
|
||||
if (_batchingDepth > 0)
|
||||
{
|
||||
_pendingMutations.Add(new PendingMutation
|
||||
{
|
||||
|
|
@ -289,11 +270,22 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public ref T GetComponent<T>(Entity entity) where T : struct
|
||||
{
|
||||
if (_iterationDepth > 0)
|
||||
if (_batchingDepth > 0)
|
||||
_accessedComponents.Add((entity, typeof(T)));
|
||||
return ref _components.Get<T>(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a component access for auto-dirty-marking without returning
|
||||
/// a ref. Used by <see cref="SelectN"/> iterators when they expose
|
||||
/// component refs directly from the sparse set.
|
||||
/// </summary>
|
||||
internal void TrackAccess(Entity entity, Type componentType)
|
||||
{
|
||||
if (_batchingDepth > 0)
|
||||
_accessedComponents.Add((entity, componentType));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of the component of type <typeparamref name="T"/>
|
||||
/// for the given entity. Never auto-marks as modified — use this when
|
||||
|
|
@ -327,9 +319,9 @@ public class World : IDisposable
|
|||
|
||||
/// <summary>
|
||||
/// Marks a component of type <typeparamref name="T"/> on the given entity
|
||||
/// as modified. Only needed outside of iteration scopes — during
|
||||
/// <see cref="ForEach"/> or a <c>Select</c> loop, components accessed
|
||||
/// via <see cref="GetComponent{T}"/> are auto-marked.
|
||||
/// as modified. Only needed outside of iteration scopes — during a
|
||||
/// <c>Select</c> loop, components accessed via <see cref="GetComponent{T}"/>
|
||||
/// are auto-marked.
|
||||
/// </summary>
|
||||
public void MarkModified<T>(Entity entity) where T : struct
|
||||
{
|
||||
|
|
@ -366,11 +358,21 @@ public class World : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits changes matching the given query.
|
||||
/// Only component-level changes whose component type is in the query's
|
||||
/// "with" set and not in the "without" set are emitted.
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query's With types and excluding its Without types.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery(QueryDescriptor query)
|
||||
public Observable<EntityChange> ObserveQuery<T1>(Query<T1> query = default)
|
||||
where T1 : struct
|
||||
{
|
||||
return _changes.ObserveQuery(query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an observable that emits component-level changes matching
|
||||
/// the given query.
|
||||
/// </summary>
|
||||
public Observable<EntityChange> ObserveQuery<T1, T2>(Query<T1, T2> query = default)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
return _changes.ObserveQuery(query);
|
||||
}
|
||||
|
|
@ -379,13 +381,16 @@ public class World : IDisposable
|
|||
|
||||
/// <summary>
|
||||
/// Sets (adds or replaces) a singleton component of type <typeparamref name="T"/>.
|
||||
/// Singletons are stored on a reserved entity that is never destroyed
|
||||
/// and excluded from normal queries.
|
||||
/// Each singleton component type gets its own dedicated entity.
|
||||
/// </summary>
|
||||
public void SetSingleton<T>(T component) where T : struct
|
||||
{
|
||||
EnsureSingleton();
|
||||
AddComponent(SingletonEntity, component);
|
||||
if (!_singletonEntities.TryGetValue(typeof(T), out var entity))
|
||||
{
|
||||
entity = _allocator.Allocate();
|
||||
_singletonEntities[typeof(T)] = entity;
|
||||
}
|
||||
AddComponent(entity, component);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -394,8 +399,7 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public ref T GetSingleton<T>() where T : struct
|
||||
{
|
||||
EnsureSingleton();
|
||||
return ref GetComponent<T>(SingletonEntity);
|
||||
return ref GetComponent<T>(GetSingletonEntity<T>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -404,8 +408,7 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public T ReadSingleton<T>() where T : struct
|
||||
{
|
||||
EnsureSingleton();
|
||||
return ReadComponent<T>(SingletonEntity);
|
||||
return ReadComponent<T>(GetSingletonEntity<T>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -413,7 +416,8 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public bool HasSingleton<T>() where T : struct
|
||||
{
|
||||
return HasComponent<T>(SingletonEntity);
|
||||
return _singletonEntities.TryGetValue(typeof(T), out var entity)
|
||||
&& HasComponent<T>(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -422,20 +426,22 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
public void RemoveSingleton<T>() where T : struct
|
||||
{
|
||||
RemoveComponent<T>(SingletonEntity);
|
||||
if (_singletonEntities.TryGetValue(typeof(T), out var entity))
|
||||
RemoveComponent<T>(entity);
|
||||
}
|
||||
|
||||
internal void EnsureSingleton()
|
||||
/// <summary>
|
||||
/// Gets the entity backing the singleton of type <typeparamref name="T"/>,
|
||||
/// creating it if it doesn't exist yet.
|
||||
/// </summary>
|
||||
private Entity GetSingletonEntity<T>() where T : struct
|
||||
{
|
||||
if (_singletonCreated)
|
||||
return;
|
||||
|
||||
_singletonCreated = true;
|
||||
|
||||
// Manually register the singleton entity in the allocator so
|
||||
// IsAlive returns true for it. We bypass Allocate() because
|
||||
// the singleton has a fixed ID and version.
|
||||
_allocator.RegisterSingleton(SingletonEntity);
|
||||
if (!_singletonEntities.TryGetValue(typeof(T), out var entity))
|
||||
{
|
||||
entity = _allocator.Allocate();
|
||||
_singletonEntities[typeof(T)] = entity;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────────
|
||||
|
|
@ -443,6 +449,7 @@ public class World : IDisposable
|
|||
/// <summary>
|
||||
/// Returns all source entities that have a relationship of type
|
||||
/// <typeparamref name="T"/> pointing to the given target entity.
|
||||
/// Sources are iterated in insertion order.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Entity> GetSources<T>(Entity target)
|
||||
where T : struct, IRelationship
|
||||
|
|
@ -450,6 +457,21 @@ public class World : IDisposable
|
|||
return _relationships.GetSources<T>(target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the source set for the given relationship type and target
|
||||
/// to match the order of entities in <paramref name="ordered"/>.
|
||||
/// All entities currently in the set must appear exactly once in the
|
||||
/// provided collection; no entities are added or removed.
|
||||
/// Fires a <see cref="ChangeKind.RelationshipReordered"/> change event
|
||||
/// on the target entity.
|
||||
/// </summary>
|
||||
public void ReorderSources<T>(Entity target, IReadOnlyList<Entity> ordered)
|
||||
where T : struct, IRelationship
|
||||
{
|
||||
_relationships.ReorderSources<T>(target, ordered);
|
||||
_changes.Pending.MarkRelationshipReordered(target, typeof(T));
|
||||
}
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -468,159 +490,28 @@ public class World : IDisposable
|
|||
_commands.ExecuteAll(this);
|
||||
}
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────
|
||||
// ── Iteration Lifecycle ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new <see cref="QueryBuilder"/> for constructing queries.
|
||||
/// Begins a batching scope. Structural mutations (AddComponent,
|
||||
/// RemoveComponent, DestroyEntity) are buffered until the outermost
|
||||
/// <see cref="EndBatching"/> call. Component accesses via
|
||||
/// <see cref="GetComponent{T}"/> are auto-tracked for modification.
|
||||
/// </summary>
|
||||
public QueryBuilder Query() => new();
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// one component type. Structural mutations (AddComponent, RemoveComponent,
|
||||
/// DestroyEntity) made during iteration are deferred and applied after
|
||||
/// the iteration completes.
|
||||
/// </summary>
|
||||
public void ForEach<T1>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1> action)
|
||||
where T1 : struct
|
||||
internal void BeginBatching()
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
_batchingDepth++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// two component types. Structural mutations are deferred.
|
||||
/// </summary>
|
||||
public void ForEach<T1, T2>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2> action)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1), typeof(T2));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// three component types. Structural mutations are deferred.
|
||||
/// </summary>
|
||||
public void ForEach<T1, T2, T3>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// four component types. Structural mutations are deferred.
|
||||
/// </summary>
|
||||
public void ForEach<T1, T2, T3, T4>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// five component types. Structural mutations are deferred.
|
||||
/// </summary>
|
||||
public void ForEach<T1, T2, T3, T4, T5>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4, T5> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates all entities matching the query, providing ref access to
|
||||
/// six component types. Structural mutations are deferred.
|
||||
/// </summary>
|
||||
public void ForEach<T1, T2, T3, T4, T5, T6>(
|
||||
QueryDescriptor query,
|
||||
ForEachAction<T1, T2, T3, T4, T5, T6> action)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
ValidateQueryTypes(query, typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5), typeof(T6));
|
||||
BeginIteration();
|
||||
try
|
||||
{
|
||||
QueryExecutor.ForEach(_components, query, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
EndIteration();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins an iteration scope. Structural mutations are buffered
|
||||
/// until <see cref="EndIteration"/> is called.
|
||||
/// </summary>
|
||||
internal void BeginIteration()
|
||||
{
|
||||
_iterationDepth++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends an iteration scope. When the outermost scope ends, all
|
||||
/// Ends a batching scope. When the outermost scope ends, all
|
||||
/// buffered structural mutations are applied and auto-tracked
|
||||
/// component modifications are posted.
|
||||
/// </summary>
|
||||
internal void EndIteration()
|
||||
internal void EndBatching()
|
||||
{
|
||||
_iterationDepth--;
|
||||
if (_iterationDepth == 0)
|
||||
_batchingDepth--;
|
||||
if (_batchingDepth == 0)
|
||||
{
|
||||
// Auto-mark all components accessed via GetComponent<T>
|
||||
// during the iteration as modified.
|
||||
|
|
@ -638,7 +529,7 @@ public class World : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void FlushPendingMutations()
|
||||
public void FlushPendingMutations()
|
||||
{
|
||||
if (_pendingMutations.Count == 0)
|
||||
return;
|
||||
|
|
@ -681,6 +572,33 @@ public class World : IDisposable
|
|||
/// </summary>
|
||||
internal ComponentStore Components => _components;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity that backs the singleton of type <typeparamref name="T"/>,
|
||||
/// or <see cref="Entity.Null"/> if the singleton has not been set.
|
||||
/// </summary>
|
||||
internal Entity GetSingletonEntityOrNull<T>() where T : struct
|
||||
{
|
||||
_singletonEntities.TryGetValue(typeof(T), out var entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an existing entity as the backing entity for a singleton component.
|
||||
/// Used during deserialization to restore singleton state.
|
||||
/// </summary>
|
||||
internal void RegisterSingletonEntity(Type componentType, Entity entity)
|
||||
{
|
||||
_singletonEntities[componentType] = entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given entity is a singleton entity (backs any singleton component).
|
||||
/// </summary>
|
||||
internal bool IsSingletonEntity(Entity entity)
|
||||
{
|
||||
return _singletonEntities.ContainsValue(entity);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private void ThrowIfNotAlive(Entity entity)
|
||||
|
|
@ -689,39 +607,6 @@ public class World : IDisposable
|
|||
throw new InvalidOperationException($"Entity {entity} is not alive.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the ForEach type parameters match the query's
|
||||
/// With set. When the With set is empty (only Without filters are
|
||||
/// specified), any ForEach type parameters are accepted.
|
||||
/// </summary>
|
||||
private static void ValidateQueryTypes(QueryDescriptor query, params Type[] forEachTypes)
|
||||
{
|
||||
// When With is empty, the query is using only Without filters.
|
||||
// The ForEach type parameters are the sole source of truth.
|
||||
if (query.With.Count == 0)
|
||||
return;
|
||||
|
||||
if (query.With.Count != forEachTypes.Length)
|
||||
ThrowMismatch(query, forEachTypes);
|
||||
|
||||
foreach (var t in forEachTypes)
|
||||
{
|
||||
if (!query.With.Contains(t))
|
||||
ThrowMismatch(query, forEachTypes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowMismatch(QueryDescriptor query, Type[] forEachTypes)
|
||||
{
|
||||
var queryTypes = string.Join(", ", query.With.Select(t => t.Name));
|
||||
var forEachTypeNames = string.Join(", ", forEachTypes.Select(t => t.Name));
|
||||
throw new InvalidOperationException(
|
||||
$"ForEach type parameters [{forEachTypeNames}] do not match " +
|
||||
$"the query's With types [{queryTypes}]. " +
|
||||
$"Ensure the ForEach type parameters exactly match the types " +
|
||||
$"passed to QueryBuilder.With<T>().");
|
||||
}
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,719 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace OECS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides zero-allocation ref struct iterators and entity-finding
|
||||
/// extension methods for querying a <see cref="World"/>.
|
||||
/// </summary>
|
||||
public static class WorldQueryExtensions
|
||||
{
|
||||
// ── Select (ref struct enumerators) ──────────────────────────────
|
||||
|
||||
public static Select1<T1> Select<T1>(this World world, Query<T1> query = default)
|
||||
where T1 : struct
|
||||
{
|
||||
return new Select1<T1>(world, query);
|
||||
}
|
||||
|
||||
public static Select2<T1, T2> Select<T1, T2>(this World world, Query<T1, T2> query = default)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
return new Select2<T1, T2>(world, query);
|
||||
}
|
||||
|
||||
public static Select3<T1, T2, T3> Select<T1, T2, T3>(this World world, Query<T1, T2, T3> query = default)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
return new Select3<T1, T2, T3>(world, query);
|
||||
}
|
||||
|
||||
public static Select4<T1, T2, T3, T4> Select<T1, T2, T3, T4>(this World world, Query<T1, T2, T3, T4> query = default)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
return new Select4<T1, T2, T3, T4>(world, query);
|
||||
}
|
||||
|
||||
public static Select5<T1, T2, T3, T4, T5> Select<T1, T2, T3, T4, T5>(this World world, Query<T1, T2, T3, T4, T5> query = default)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
return new Select5<T1, T2, T3, T4, T5>(world, query);
|
||||
}
|
||||
|
||||
public static Select6<T1, T2, T3, T4, T5, T6> Select<T1, T2, T3, T4, T5, T6>(this World world, Query<T1, T2, T3, T4, T5, T6> query = default)
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
return new Select6<T1, T2, T3, T4, T5, T6>(world, query);
|
||||
}
|
||||
|
||||
// ── FindEntity ──────────────────────────────────────────────────
|
||||
|
||||
public static Entity FindEntity<T1>(this World world, Query<T1> query = default)
|
||||
where T1 : struct
|
||||
{
|
||||
using var iter = new Select1<T1>(world, query);
|
||||
return iter.MoveNext() ? iter.Entity : Entity.Null;
|
||||
}
|
||||
|
||||
public static Entity FindEntity<T1, T2>(this World world, Query<T1, T2> query = default)
|
||||
where T1 : struct where T2 : struct
|
||||
{
|
||||
using var iter = new Select2<T1, T2>(world, query);
|
||||
return iter.MoveNext() ? iter.Entity : Entity.Null;
|
||||
}
|
||||
|
||||
public static Entity FindEntity<T1, T2, T3>(this World world, Query<T1, T2, T3> query = default)
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
using var iter = new Select3<T1, T2, T3>(world, query);
|
||||
return iter.MoveNext() ? iter.Entity : Entity.Null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ref struct enumerators ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Ref struct iterator for queries with one component type.</summary>
|
||||
public ref struct Select1<T1> where T1 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
|
||||
internal Select1(World world, Query<T1> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_count = _set?.Count ?? 0;
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
/// <summary>The current entity handle.</summary>
|
||||
public Entity Entity { get; private set; }
|
||||
|
||||
/// <summary>The current enumerator (required for foreach duck-typing).</summary>
|
||||
public Select1<T1> Current => this;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable reference to the current entity's component of type <typeparamref name="T1"/>.
|
||||
/// Access is tracked for auto-dirty-marking during a batching scope.
|
||||
/// </summary>
|
||||
public ref T1 Ref1
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get
|
||||
{
|
||||
_world.TrackAccess(Entity, typeof(T1));
|
||||
return ref _set!.Get(Entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only reference to the current entity's component of type <typeparamref name="T1"/>.
|
||||
/// Access is NOT tracked for auto-dirty-marking.
|
||||
/// </summary>
|
||||
public ref readonly T1 Val1 => ref _set!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
Entity = _set.DenseEntities[_index];
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select1<T1> GetEnumerator() => this;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_world.EndBatching();
|
||||
}
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ref struct iterator for queries with two component types. Drives from the smaller set.</summary>
|
||||
public ref struct Select2<T1, T2> where T1 : struct where T2 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly bool _swapped;
|
||||
|
||||
internal Select2(World world, Query<T1, T2> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
|
||||
int c1 = _set1?.Count ?? 0;
|
||||
int c2 = _set2?.Count ?? 0;
|
||||
_swapped = c2 < c1;
|
||||
_count = _swapped ? c2 : c1;
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
public Entity Entity { get; private set; }
|
||||
public Select2<T1, T2> Current => this;
|
||||
|
||||
public ref T1 Ref1
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get
|
||||
{
|
||||
_world.TrackAccess(Entity, typeof(T1));
|
||||
return ref _set1!.Get(Entity);
|
||||
}
|
||||
}
|
||||
|
||||
public ref T2 Ref2
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
get
|
||||
{
|
||||
_world.TrackAccess(Entity, typeof(T2));
|
||||
return ref _set2!.Get(Entity);
|
||||
}
|
||||
}
|
||||
|
||||
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
|
||||
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
if (_swapped)
|
||||
{
|
||||
Entity = _set2!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
Entity = _set1.DenseEntities[_index];
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select2<T1, T2> GetEnumerator() => this;
|
||||
|
||||
public void Dispose() => _world.EndBatching();
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ref struct iterator for queries with three component types. Drives from the smallest set.</summary>
|
||||
public ref struct Select3<T1, T2, T3>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly SparseSet<T3>? _set3;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly int _driver; // 0 = set1, 1 = set2, 2 = set3
|
||||
|
||||
internal Select3(World world, Query<T1, T2, T3> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
|
||||
int c1 = _set1?.Count ?? 0;
|
||||
int c2 = _set2?.Count ?? 0;
|
||||
int c3 = _set3?.Count ?? 0;
|
||||
|
||||
if (c2 <= c1 && c2 <= c3) { _driver = 1; _count = c2; }
|
||||
else if (c3 <= c1 && c3 <= c2) { _driver = 2; _count = c3; }
|
||||
else { _driver = 0; _count = c1; }
|
||||
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
public Entity Entity { get; private set; }
|
||||
public Select3<T1, T2, T3> Current => this;
|
||||
|
||||
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
|
||||
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
|
||||
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
|
||||
|
||||
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
|
||||
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
|
||||
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null || _set3 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
switch (_driver)
|
||||
{
|
||||
case 0:
|
||||
Entity = _set1.DenseEntities[_index];
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
break;
|
||||
case 1:
|
||||
Entity = _set2!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
break;
|
||||
case 2:
|
||||
Entity = _set3!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
break;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select3<T1, T2, T3> GetEnumerator() => this;
|
||||
|
||||
public void Dispose() => _world.EndBatching();
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ref struct iterator for queries with four component types.</summary>
|
||||
public ref struct Select4<T1, T2, T3, T4>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly SparseSet<T3>? _set3;
|
||||
private readonly SparseSet<T4>? _set4;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly int _driver;
|
||||
|
||||
internal Select4(World world, Query<T1, T2, T3, T4> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
|
||||
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
|
||||
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
|
||||
int min = c1;
|
||||
_driver = 0;
|
||||
if (c2 < min) { min = c2; _driver = 1; }
|
||||
if (c3 < min) { min = c3; _driver = 2; }
|
||||
if (c4 < min) { min = c4; _driver = 3; }
|
||||
_count = min;
|
||||
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
public Entity Entity { get; private set; }
|
||||
public Select4<T1, T2, T3, T4> Current => this;
|
||||
|
||||
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
|
||||
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
|
||||
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
|
||||
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
|
||||
|
||||
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
|
||||
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
|
||||
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
|
||||
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
switch (_driver)
|
||||
{
|
||||
case 0:
|
||||
Entity = _set1.DenseEntities[_index];
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
break;
|
||||
case 1:
|
||||
Entity = _set2!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
break;
|
||||
case 2:
|
||||
Entity = _set3!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
break;
|
||||
case 3:
|
||||
Entity = _set4!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
break;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select4<T1, T2, T3, T4> GetEnumerator() => this;
|
||||
|
||||
public void Dispose() => _world.EndBatching();
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ref struct iterator for queries with five component types.</summary>
|
||||
public ref struct Select5<T1, T2, T3, T4, T5>
|
||||
where T1 : struct where T2 : struct where T3 : struct where T4 : struct where T5 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly SparseSet<T3>? _set3;
|
||||
private readonly SparseSet<T4>? _set4;
|
||||
private readonly SparseSet<T5>? _set5;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly int _driver;
|
||||
|
||||
internal Select5(World world, Query<T1, T2, T3, T4, T5> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
_set5 = _store.GetSet(typeof(T5)) as SparseSet<T5>;
|
||||
|
||||
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
|
||||
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
|
||||
int c5 = _set5?.Count ?? 0;
|
||||
int min = c1;
|
||||
_driver = 0;
|
||||
if (c2 < min) { min = c2; _driver = 1; }
|
||||
if (c3 < min) { min = c3; _driver = 2; }
|
||||
if (c4 < min) { min = c4; _driver = 3; }
|
||||
if (c5 < min) { min = c5; _driver = 4; }
|
||||
_count = min;
|
||||
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
public Entity Entity { get; private set; }
|
||||
public Select5<T1, T2, T3, T4, T5> Current => this;
|
||||
|
||||
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
|
||||
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
|
||||
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
|
||||
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
|
||||
public ref T5 Ref5 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T5)); return ref _set5!.Get(Entity); } }
|
||||
|
||||
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
|
||||
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
|
||||
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
|
||||
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
|
||||
public ref readonly T5 Val5 => ref _set5!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null || _set5 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
switch (_driver)
|
||||
{
|
||||
case 0:
|
||||
Entity = _set1.DenseEntities[_index];
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
break;
|
||||
case 1:
|
||||
Entity = _set2!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
break;
|
||||
case 2:
|
||||
Entity = _set3!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
break;
|
||||
case 3:
|
||||
Entity = _set4!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
break;
|
||||
case 4:
|
||||
Entity = _set5!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
break;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select5<T1, T2, T3, T4, T5> GetEnumerator() => this;
|
||||
|
||||
public void Dispose() => _world.EndBatching();
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ref struct iterator for queries with six component types.</summary>
|
||||
public ref struct Select6<T1, T2, T3, T4, T5, T6>
|
||||
where T1 : struct where T2 : struct where T3 : struct
|
||||
where T4 : struct where T5 : struct where T6 : struct
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly SparseSet<T1>? _set1;
|
||||
private readonly SparseSet<T2>? _set2;
|
||||
private readonly SparseSet<T3>? _set3;
|
||||
private readonly SparseSet<T4>? _set4;
|
||||
private readonly SparseSet<T5>? _set5;
|
||||
private readonly SparseSet<T6>? _set6;
|
||||
private readonly ComponentStore _store;
|
||||
private readonly HashSet<Type>? _without;
|
||||
private int _index;
|
||||
private readonly int _count;
|
||||
private readonly int _driver;
|
||||
|
||||
internal Select6(World world, Query<T1, T2, T3, T4, T5, T6> query)
|
||||
{
|
||||
_world = world;
|
||||
_store = world.Components;
|
||||
_without = query.WithoutTypes;
|
||||
_set1 = _store.GetSet(typeof(T1)) as SparseSet<T1>;
|
||||
_set2 = _store.GetSet(typeof(T2)) as SparseSet<T2>;
|
||||
_set3 = _store.GetSet(typeof(T3)) as SparseSet<T3>;
|
||||
_set4 = _store.GetSet(typeof(T4)) as SparseSet<T4>;
|
||||
_set5 = _store.GetSet(typeof(T5)) as SparseSet<T5>;
|
||||
_set6 = _store.GetSet(typeof(T6)) as SparseSet<T6>;
|
||||
|
||||
int c1 = _set1?.Count ?? 0, c2 = _set2?.Count ?? 0;
|
||||
int c3 = _set3?.Count ?? 0, c4 = _set4?.Count ?? 0;
|
||||
int c5 = _set5?.Count ?? 0, c6 = _set6?.Count ?? 0;
|
||||
int min = c1;
|
||||
_driver = 0;
|
||||
if (c2 < min) { min = c2; _driver = 1; }
|
||||
if (c3 < min) { min = c3; _driver = 2; }
|
||||
if (c4 < min) { min = c4; _driver = 3; }
|
||||
if (c5 < min) { min = c5; _driver = 4; }
|
||||
if (c6 < min) { min = c6; _driver = 5; }
|
||||
_count = min;
|
||||
|
||||
_index = -1;
|
||||
_world.BeginBatching();
|
||||
}
|
||||
|
||||
public Entity Entity { get; private set; }
|
||||
public Select6<T1, T2, T3, T4, T5, T6> Current => this;
|
||||
|
||||
public ref T1 Ref1 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T1)); return ref _set1!.Get(Entity); } }
|
||||
public ref T2 Ref2 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T2)); return ref _set2!.Get(Entity); } }
|
||||
public ref T3 Ref3 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T3)); return ref _set3!.Get(Entity); } }
|
||||
public ref T4 Ref4 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T4)); return ref _set4!.Get(Entity); } }
|
||||
public ref T5 Ref5 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T5)); return ref _set5!.Get(Entity); } }
|
||||
public ref T6 Ref6 { [MethodImpl(MethodImplOptions.AggressiveInlining)] get { _world.TrackAccess(Entity, typeof(T6)); return ref _set6!.Get(Entity); } }
|
||||
|
||||
public ref readonly T1 Val1 => ref _set1!.Get(Entity);
|
||||
public ref readonly T2 Val2 => ref _set2!.Get(Entity);
|
||||
public ref readonly T3 Val3 => ref _set3!.Get(Entity);
|
||||
public ref readonly T4 Val4 => ref _set4!.Get(Entity);
|
||||
public ref readonly T5 Val5 => ref _set5!.Get(Entity);
|
||||
public ref readonly T6 Val6 => ref _set6!.Get(Entity);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public bool MoveNext()
|
||||
{
|
||||
if (_set1 == null || _set2 == null || _set3 == null || _set4 == null || _set5 == null || _set6 == null) return false;
|
||||
while (++_index < _count)
|
||||
{
|
||||
switch (_driver)
|
||||
{
|
||||
case 0:
|
||||
Entity = _set1.DenseEntities[_index];
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
if (!_set6.Contains(Entity)) continue;
|
||||
break;
|
||||
case 1:
|
||||
Entity = _set2!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
if (!_set6.Contains(Entity)) continue;
|
||||
break;
|
||||
case 2:
|
||||
Entity = _set3!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
if (!_set6.Contains(Entity)) continue;
|
||||
break;
|
||||
case 3:
|
||||
Entity = _set4!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
if (!_set6.Contains(Entity)) continue;
|
||||
break;
|
||||
case 4:
|
||||
Entity = _set5!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set6.Contains(Entity)) continue;
|
||||
break;
|
||||
case 5:
|
||||
Entity = _set6!.DenseEntities[_index];
|
||||
if (!_set1.Contains(Entity)) continue;
|
||||
if (!_set2.Contains(Entity)) continue;
|
||||
if (!_set3.Contains(Entity)) continue;
|
||||
if (!_set4.Contains(Entity)) continue;
|
||||
if (!_set5.Contains(Entity)) continue;
|
||||
break;
|
||||
}
|
||||
if (!PassesWithout()) continue;
|
||||
if (_world.IsSingletonEntity(Entity)) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public Select6<T1, T2, T3, T4, T5, T6> GetEnumerator() => this;
|
||||
|
||||
public void Dispose() => _world.EndBatching();
|
||||
|
||||
private bool PassesWithout()
|
||||
{
|
||||
if (_without == null) return true;
|
||||
foreach (var type in _without)
|
||||
{
|
||||
var set = _store.GetSet(type);
|
||||
if (set != null && set.Contains(Entity))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue