Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using Theriapolis.Core.Rules.Character;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Base actor record. Position is in world-pixel space and is meaningful in
|
||||
/// both the world-map view and the tactical view (see Section 5 of the
|
||||
/// implementation plan — one canonical coordinate system).
|
||||
///
|
||||
/// Phase 5 adds the optional <see cref="Character"/> attachment carrying all
|
||||
/// gameplay state (stats, inventory, HP, conditions). The renderer layer
|
||||
/// stays ignorant of the gameplay layer — Camera2D doesn't need to know HP.
|
||||
/// </summary>
|
||||
public class Actor
|
||||
{
|
||||
/// <summary>Stable id assigned by <see cref="ActorManager"/>.</summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>World-pixel position. Reused at both zoom scales.</summary>
|
||||
public Vec2 Position { get; set; }
|
||||
|
||||
/// <summary>Facing angle in radians (0 = east, π/2 = south).</summary>
|
||||
public float FacingAngleRad { get; set; }
|
||||
|
||||
/// <summary>Continuous-time travel speed on the world map, in world pixels per real second.</summary>
|
||||
public float SpeedWorldPxPerSec { get; set; } = C.PLAYER_TRAVEL_PX_PER_SEC;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5: gameplay state. Null when this actor isn't combat-capable
|
||||
/// (cosmetic NPCs, future-phase additions). PlayerActor and NpcActor
|
||||
/// always carry one.
|
||||
/// </summary>
|
||||
public Character? Character { get; set; }
|
||||
|
||||
/// <summary>Whose side this actor is on. Drives encounter triggering and AI targeting.</summary>
|
||||
public Allegiance Allegiance { get; set; } = Allegiance.Neutral;
|
||||
|
||||
/// <summary>
|
||||
/// True if this actor still has positive HP (or is downed but not dead).
|
||||
/// Subclasses (e.g. <see cref="NpcActor"/>) override to use direct HP
|
||||
/// fields instead of a <see cref="Character"/> reference.
|
||||
/// </summary>
|
||||
public virtual bool IsAlive => Character is null ? true : Character.IsAlive;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Owns live actors. Phase 4 only ever holds the single player actor; this
|
||||
/// class exists so Phase 5/6 can add NPCs without further architectural change.
|
||||
/// </summary>
|
||||
public sealed class ActorManager
|
||||
{
|
||||
private readonly List<Actor> _actors = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public IReadOnlyList<Actor> All => _actors;
|
||||
|
||||
public PlayerActor? Player { get; private set; }
|
||||
|
||||
/// <summary>Creates the player actor. Idempotent on repeat calls — returns the existing player.</summary>
|
||||
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos)
|
||||
{
|
||||
if (Player is not null) return Player;
|
||||
var p = new PlayerActor
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = worldPixelPos,
|
||||
Allegiance = Rules.Character.Allegiance.Player,
|
||||
};
|
||||
_actors.Add(p);
|
||||
Player = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 overload: spawn the player and attach a freshly-built character
|
||||
/// in one step. The character carries the player's name into the actor.
|
||||
/// </summary>
|
||||
public PlayerActor SpawnPlayer(Util.Vec2 worldPixelPos, Rules.Character.Character character)
|
||||
{
|
||||
var p = SpawnPlayer(worldPixelPos);
|
||||
p.Character = character;
|
||||
// The character record's identity (name) wins over the default actor name.
|
||||
// Other fields (HP, abilities, inventory) live exclusively on the Character.
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore a player actor from a save. Does not allocate a new id — the
|
||||
/// saved id is restored as-is so cross-save references stay stable.
|
||||
/// </summary>
|
||||
public PlayerActor RestorePlayer(PlayerActorState state)
|
||||
{
|
||||
var p = new PlayerActor { Id = state.Id };
|
||||
p.RestoreState(state);
|
||||
_actors.Add(p);
|
||||
Player = p;
|
||||
if (state.Id >= _nextId) _nextId = state.Id + 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5: spawn an NPC at the given world-pixel position. The caller
|
||||
/// chooses the template (including any DangerZone-driven per-zone selection).
|
||||
/// Returns the spawned actor with a freshly-allocated id.
|
||||
/// </summary>
|
||||
public NpcActor SpawnNpc(
|
||||
Data.NpcTemplateDef template,
|
||||
Util.Vec2 worldPixelPos,
|
||||
Tactical.ChunkCoord? sourceChunk = null,
|
||||
int? sourceSpawnIndex = null)
|
||||
{
|
||||
var npc = new NpcActor(template)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = worldPixelPos,
|
||||
SourceChunk = sourceChunk,
|
||||
SourceSpawnIndex = sourceSpawnIndex,
|
||||
};
|
||||
_actors.Add(npc);
|
||||
return npc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — adopt a pre-constructed NpcActor (e.g. a resident built
|
||||
/// by <see cref="Rules.Combat.ResidentInstantiator"/> with the resident
|
||||
/// template constructor). Assigns an id and registers in the actor list.
|
||||
/// </summary>
|
||||
public NpcActor SpawnNpc(NpcActor pre)
|
||||
{
|
||||
if (pre.Id <= 0)
|
||||
{
|
||||
// Use reflection-free init via a fresh actor copy. NpcActor.Id is
|
||||
// init-only, but the only place we hit this path is the
|
||||
// resident-instantiator which constructs `pre` with Id = -1
|
||||
// expecting us to assign. Build the real one here.
|
||||
NpcActor adopted = pre.Resident is not null
|
||||
? new NpcActor(pre.Resident)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = pre.Position,
|
||||
SourceChunk = pre.SourceChunk,
|
||||
SourceSpawnIndex = pre.SourceSpawnIndex,
|
||||
RoleTag = pre.RoleTag,
|
||||
HomeSettlementId = pre.HomeSettlementId,
|
||||
}
|
||||
: new NpcActor(pre.Template!)
|
||||
{
|
||||
Id = _nextId++,
|
||||
Position = pre.Position,
|
||||
SourceChunk = pre.SourceChunk,
|
||||
SourceSpawnIndex = pre.SourceSpawnIndex,
|
||||
RoleTag = pre.RoleTag,
|
||||
HomeSettlementId = pre.HomeSettlementId,
|
||||
};
|
||||
_actors.Add(adopted);
|
||||
return adopted;
|
||||
}
|
||||
_actors.Add(pre);
|
||||
if (pre.Id >= _nextId) _nextId = pre.Id + 1;
|
||||
return pre;
|
||||
}
|
||||
|
||||
/// <summary>Remove an actor by id. Returns true if it was found.</summary>
|
||||
public bool RemoveActor(int id)
|
||||
{
|
||||
for (int i = 0; i < _actors.Count; i++)
|
||||
{
|
||||
if (_actors[i].Id == id) { _actors.RemoveAt(i); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns every NPC currently spawned.</summary>
|
||||
public IEnumerable<NpcActor> Npcs
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var a in _actors) if (a is NpcActor npc) yield return npc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Find a live NPC originating from a specific chunk + spawn index.</summary>
|
||||
public NpcActor? FindNpcBySource(Tactical.ChunkCoord chunk, int spawnIndex)
|
||||
{
|
||||
foreach (var a in _actors)
|
||||
if (a is NpcActor npc &&
|
||||
npc.SourceChunk.HasValue && npc.SourceChunk.Value.Equals(chunk) &&
|
||||
npc.SourceSpawnIndex == spawnIndex)
|
||||
return npc;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only view of the world the AI behavior consults during a turn.
|
||||
/// Wraps the live <see cref="Encounter"/> + a sight predicate so behaviors
|
||||
/// stay testable in isolation (M5 unit tests pass an always-clear predicate;
|
||||
/// the live game wires in the tactical-tile sampler).
|
||||
///
|
||||
/// Behaviors call into the resolver to actually mutate the encounter; this
|
||||
/// context is purely for reading the situation.
|
||||
/// </summary>
|
||||
public sealed class AiContext
|
||||
{
|
||||
public Encounter Encounter { get; }
|
||||
|
||||
/// <summary>Returns true if the tile at (tx, ty) blocks line-of-sight.</summary>
|
||||
public System.Func<int, int, bool> IsLosBlocked { get; }
|
||||
|
||||
public AiContext(Encounter encounter, System.Func<int, int, bool>? isLosBlocked = null)
|
||||
{
|
||||
Encounter = encounter;
|
||||
IsLosBlocked = isLosBlocked ?? LineOfSight.AlwaysClear;
|
||||
}
|
||||
|
||||
/// <summary>Find the closest hostile combatant to the given actor, or null if none.</summary>
|
||||
public Combatant? FindClosestHostile(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDist = int.MaxValue;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
if (!IsHostileTo(self.Allegiance, c.Allegiance)) continue;
|
||||
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
|
||||
if (d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — find the closest *ally* to the given actor (excludes
|
||||
/// self). Allies share the player-side allegiance: Player or Allied.
|
||||
/// Returns null when none qualify (which is the typical M1 case — the
|
||||
/// player is alone — and disables ally-targeted features cleanly).
|
||||
/// </summary>
|
||||
public Combatant? FindClosestAlly(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDist = int.MaxValue;
|
||||
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!selfPlayerSide) return null;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.Id == self.Id) continue;
|
||||
if (c.IsDown) continue;
|
||||
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!cPlayerSide) continue;
|
||||
int d = ReachAndCover.EdgeToEdgeChebyshev(self, c);
|
||||
if (d < bestDist) { best = c; bestDist = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M1 — find the lowest-HP friendly target (self or any
|
||||
/// ally), preferring the most damaged. Returns null only when every
|
||||
/// friendly is at full HP. Used as the auto-target for healing
|
||||
/// features when the player presses the heal hotkey without picking
|
||||
/// explicitly.
|
||||
/// </summary>
|
||||
public Combatant? FindMostDamagedFriendly(Combatant self)
|
||||
{
|
||||
Combatant? best = null;
|
||||
int bestDeficit = -1;
|
||||
bool selfPlayerSide = self.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| self.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!selfPlayerSide) return null;
|
||||
foreach (var c in Encounter.Participants)
|
||||
{
|
||||
if (c.IsDown) continue;
|
||||
bool cPlayerSide = c.Allegiance == Rules.Character.Allegiance.Player
|
||||
|| c.Allegiance == Rules.Character.Allegiance.Allied;
|
||||
if (!cPlayerSide) continue;
|
||||
int deficit = c.MaxHp - c.CurrentHp;
|
||||
if (deficit > bestDeficit) { best = c; bestDeficit = deficit; }
|
||||
}
|
||||
return bestDeficit > 0 ? best : null;
|
||||
}
|
||||
|
||||
/// <summary>True if the two allegiances should target each other in combat.</summary>
|
||||
public static bool IsHostileTo(
|
||||
Rules.Character.Allegiance a,
|
||||
Rules.Character.Allegiance b)
|
||||
{
|
||||
bool aIsPlayerSide = a == Rules.Character.Allegiance.Player || a == Rules.Character.Allegiance.Allied;
|
||||
bool bIsPlayerSide = b == Rules.Character.Allegiance.Player || b == Rules.Character.Allegiance.Allied;
|
||||
if (aIsPlayerSide && b == Rules.Character.Allegiance.Hostile) return true;
|
||||
if (bIsPlayerSide && a == Rules.Character.Allegiance.Hostile) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// "Move toward target, attack when in reach" — the baseline aggressive
|
||||
/// melee behavior. Used by Brigand* and Patrol templates that aren't
|
||||
/// scripted to flee.
|
||||
/// </summary>
|
||||
public sealed class BrigandBehavior : INpcBehavior
|
||||
{
|
||||
public void TakeTurn(Combatant self, AiContext ctx)
|
||||
{
|
||||
if (self.IsDown) return;
|
||||
var target = ctx.FindClosestHostile(self);
|
||||
if (target is null) return;
|
||||
var attack = self.AttackOptions[0];
|
||||
|
||||
// Move budget: 5 ft. = 1 tactical tile per d20 standard.
|
||||
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
while (!ReachAndCover.IsInReach(self, target, attack) && tiles > 0)
|
||||
{
|
||||
var next = ReachAndCover.StepToward(self.Position, target.Position);
|
||||
if (next.X == self.Position.X && next.Y == self.Position.Y) break;
|
||||
self.Position = next;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
|
||||
tiles--;
|
||||
}
|
||||
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tiles;
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
|
||||
|
||||
if (!ReachAndCover.IsInReach(self, target, attack)) return;
|
||||
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
|
||||
ctx.Encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// One NPC behavior — what does this NPC do on its turn? Behaviors are
|
||||
/// dispatched by id (the template's <c>behavior</c> field) via
|
||||
/// <see cref="BehaviorRegistry"/>. Each behavior must produce its turn's
|
||||
/// actions within bounded time — no recursion, no while-true loops; if no
|
||||
/// valid action is available, end the turn.
|
||||
/// </summary>
|
||||
public interface INpcBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Take one turn for <paramref name="self"/>. Behaviors call into
|
||||
/// <see cref="Resolver"/> to mutate the encounter, then return — the
|
||||
/// caller (typically <see cref="Encounter"/>'s turn pump) calls
|
||||
/// <see cref="Encounter.EndTurn"/> after this returns.
|
||||
/// </summary>
|
||||
void TakeTurn(Combatant self, AiContext ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps behavior ids (the strings in <c>npc_templates.json</c>'s
|
||||
/// <c>behavior</c> field) to their <see cref="INpcBehavior"/>
|
||||
/// implementation. Phase 5 M5 ships three: <c>brigand</c>,
|
||||
/// <c>wild_animal</c>, <c>poi_guard</c>. Unknown ids fall back to
|
||||
/// <c>brigand</c> with a debug log.
|
||||
/// </summary>
|
||||
public static class BehaviorRegistry
|
||||
{
|
||||
private static readonly System.Collections.Generic.Dictionary<string, INpcBehavior> _impls
|
||||
= new(System.StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["brigand"] = new BrigandBehavior(),
|
||||
["wild_animal"] = new WildAnimalBehavior(),
|
||||
["poi_guard"] = new PoiGuardBehavior(),
|
||||
["patrol"] = new BrigandBehavior(), // patrol shares brigand combat behavior
|
||||
};
|
||||
|
||||
public static INpcBehavior For(string id)
|
||||
{
|
||||
return _impls.TryGetValue(id, out var b) ? b : _impls["brigand"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Defends a point of interest. Phase 5 M5 ships this as Brigand-equivalent
|
||||
/// combat behavior — patrolling around a home position is M6 territory once
|
||||
/// out-of-combat tactical movement is implemented (currently NPCs only act
|
||||
/// during encounters).
|
||||
/// </summary>
|
||||
public sealed class PoiGuardBehavior : INpcBehavior
|
||||
{
|
||||
private readonly BrigandBehavior _melee = new();
|
||||
|
||||
public void TakeTurn(Rules.Combat.Combatant self, AiContext ctx)
|
||||
{
|
||||
// Same combat logic as Brigand. The "patrol around home" behavior
|
||||
// outside of combat hooks in at M6 — for now PoiGuards stand still
|
||||
// until engaged.
|
||||
_melee.TakeTurn(self, ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Theriapolis.Core.Rules.Combat;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Like <see cref="BrigandBehavior"/>, but flees when reduced below
|
||||
/// <c>FLEE_HP_FRACTION</c> of max HP — wild animals don't have honor.
|
||||
/// </summary>
|
||||
public sealed class WildAnimalBehavior : INpcBehavior
|
||||
{
|
||||
public const float FLEE_HP_FRACTION = 0.25f;
|
||||
|
||||
public void TakeTurn(Combatant self, AiContext ctx)
|
||||
{
|
||||
if (self.IsDown) return;
|
||||
var target = ctx.FindClosestHostile(self);
|
||||
if (target is null) return;
|
||||
|
||||
// Flee at low HP — move directly away, don't attack.
|
||||
if (self.CurrentHp <= self.MaxHp * FLEE_HP_FRACTION)
|
||||
{
|
||||
int tiles = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
for (int i = 0; i < tiles; i++)
|
||||
{
|
||||
var away = StepAwayFrom(self.Position, target.Position);
|
||||
if ((int)away.X == (int)self.Position.X && (int)away.Y == (int)self.Position.Y) break;
|
||||
self.Position = away;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} flees to ({(int)away.X},{(int)away.Y}).");
|
||||
}
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(tiles * 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, identical to Brigand: close + attack.
|
||||
var attack = self.AttackOptions[0];
|
||||
int tilesAvail = ctx.Encounter.CurrentTurn.RemainingMovementFt / 5;
|
||||
while (!ReachAndCover.IsInReach(self, target, attack) && tilesAvail > 0)
|
||||
{
|
||||
var next = ReachAndCover.StepToward(self.Position, target.Position);
|
||||
if ((int)next.X == (int)self.Position.X && (int)next.Y == (int)self.Position.Y) break;
|
||||
self.Position = next;
|
||||
ctx.Encounter.AppendLog(CombatLogEntry.Kind.Move,
|
||||
$"{self.Name} moves to ({(int)next.X},{(int)next.Y}).");
|
||||
tilesAvail--;
|
||||
}
|
||||
int consumedTiles = (ctx.Encounter.CurrentTurn.RemainingMovementFt / 5) - tilesAvail;
|
||||
ctx.Encounter.CurrentTurn.ConsumeMovement(consumedTiles * 5);
|
||||
|
||||
if (!ReachAndCover.IsInReach(self, target, attack)) return;
|
||||
Resolver.AttemptAttack(ctx.Encounter, self, target, attack);
|
||||
ctx.Encounter.CurrentTurn.ConsumeAction();
|
||||
}
|
||||
|
||||
private static Vec2 StepAwayFrom(Vec2 from, Vec2 menace)
|
||||
{
|
||||
// Reverse of StepToward — increment by sign of (from - menace) so we
|
||||
// move away from the menace.
|
||||
int dx = System.Math.Sign(from.X - menace.X);
|
||||
int dy = System.Math.Sign(from.Y - menace.Y);
|
||||
if (dx == 0 && dy == 0) dx = 1; // pick a direction if standing on top
|
||||
return new Vec2((int)from.X + dx, (int)from.Y + dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5 M5 NPC actor. Distinct from <see cref="PlayerActor"/>: stat-block
|
||||
/// driven (no <see cref="Rules.Character.Character"/>), HP/AC/attacks read
|
||||
/// directly from the source <see cref="NpcTemplateDef"/>. The
|
||||
/// <see cref="Combatant"/> wrapper is built from this on encounter start.
|
||||
///
|
||||
/// AI behavior is dispatched by id (the template's <c>behavior</c> field)
|
||||
/// — the ActorManager doesn't tick behaviors itself; combat does, and the
|
||||
/// ChunkStreamer-driven spawn/despawn lifecycle keeps the live actor list
|
||||
/// in sync with the player's tactical window.
|
||||
/// </summary>
|
||||
public sealed class NpcActor : Actor
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 5 hostile/wild template. Mutually exclusive with <see cref="Resident"/>.
|
||||
/// One of the two must be non-null.
|
||||
/// </summary>
|
||||
public NpcTemplateDef? Template { get; }
|
||||
|
||||
/// <summary>Phase 6 M1 friendly/neutral resident template.</summary>
|
||||
public ResidentTemplateDef? Resident { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — display name (e.g. "Mara Threadwell"). For Phase-5 NPCs
|
||||
/// falls back to <see cref="NpcTemplateDef.Name"/>; for residents uses
|
||||
/// <see cref="ResidentTemplateDef.Name"/>.
|
||||
/// </summary>
|
||||
public string DisplayName => Resident?.Name ?? Template?.Name ?? "NPC";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 — role tag (anchor-qualified for named NPCs:
|
||||
/// "millhaven.innkeeper", or generic: "shopkeeper"). Empty for hostile
|
||||
/// NPCs that don't carry a settlement role.
|
||||
/// </summary>
|
||||
public string RoleTag { get; init; } = "";
|
||||
|
||||
/// <summary>Phase 6 M1 — dialogue tree id (matches dialogues/*.json). Empty → InteractionScreen falls back to placeholder.</summary>
|
||||
public string DialogueId { get; init; } = "";
|
||||
|
||||
/// <summary>Phase 6 M1 — bias profile id used in disposition formula.</summary>
|
||||
public string BiasProfileId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M1 / M5 — faction affiliation id. Set automatically from
|
||||
/// <see cref="Data.NpcTemplateDef.Faction"/> or
|
||||
/// <see cref="Data.ResidentTemplateDef.Faction"/> at construction.
|
||||
/// Can also be set directly via init for testing / scripting.
|
||||
/// </summary>
|
||||
public string FactionId { get; init; } = "";
|
||||
|
||||
/// <summary>Current HP. Mutated by combat; written back from <see cref="Rules.Combat.Combatant"/> when an encounter ends.</summary>
|
||||
public int CurrentHp { get; set; }
|
||||
|
||||
/// <summary>HP at full. From whichever template populated this actor.</summary>
|
||||
public int MaxHp { get; }
|
||||
|
||||
/// <summary>The chunk this NPC was spawned from + its index in <c>chunk.Spawns</c>. Null when spawned outside the chunk system.</summary>
|
||||
public Tactical.ChunkCoord? SourceChunk { get; init; }
|
||||
public int? SourceSpawnIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6 M5 — settlement this NPC belongs to. Set by
|
||||
/// <see cref="Rules.Combat.ResidentInstantiator"/> at spawn time when
|
||||
/// the NPC was placed inside a building footprint. Drives
|
||||
/// <see cref="Rules.Reputation.RepPropagation"/> when computing the
|
||||
/// faction standing this NPC perceives.
|
||||
/// </summary>
|
||||
public int? HomeSettlementId { get; init; }
|
||||
|
||||
/// <summary>Per-NPC AI scratchpad. Behaviors read/write here between turns.</summary>
|
||||
public AiState Ai { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — runtime scent-tag flags. Set by combat events
|
||||
/// (<see cref="HasRecentlyKilled"/> on melee kill, etc.) and
|
||||
/// surfaced through <see cref="ComputeScentTags"/>.
|
||||
///
|
||||
/// Faction-derived tags are NOT stored here — they're computed from
|
||||
/// <see cref="FactionId"/> on demand. Only flags that result from
|
||||
/// runtime activity (kills, fleeing, low HP) live as state.
|
||||
/// </summary>
|
||||
public bool HasRecentlyKilled { get; set; }
|
||||
public bool CarriesContrabandFlag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M7 — sticky aggro flag set by
|
||||
/// <see cref="Rules.Reputation.BetrayalCascade"/> on a betrayed
|
||||
/// guard / patrol NPC. Once set, the NPC attacks on sight regardless
|
||||
/// of faction-standing recovery — they remember.
|
||||
///
|
||||
/// Flag survives chunk despawn/respawn for *named* NPCs (their
|
||||
/// PersonalDisposition.Memory.betrayed_me tag drives re-application
|
||||
/// at re-instantiation). Generic respawning NPCs lose it on chunk
|
||||
/// re-stream — they're literally a fresh template-rolled NPC, the
|
||||
/// betrayal followed the role tag, not the entity.
|
||||
/// </summary>
|
||||
public bool PermanentAggroAfterBetrayal { get; set; }
|
||||
|
||||
/// <summary>Behavior id used by combat AI dispatch. Residents return "resident" (M1 stub — they don't move).</summary>
|
||||
public string BehaviorId => Template?.Behavior ?? "resident";
|
||||
|
||||
public override bool IsAlive => CurrentHp > 0;
|
||||
|
||||
public NpcActor(NpcTemplateDef template)
|
||||
{
|
||||
Template = template ?? throw new System.ArgumentNullException(nameof(template));
|
||||
Resident = null;
|
||||
CurrentHp = template.Hp;
|
||||
MaxHp = template.Hp;
|
||||
Allegiance = Rules.Character.AllegianceExtensions.FromJson(template.DefaultAllegiance);
|
||||
// Phase 6 M5 — pick up the template's faction id so propagation can
|
||||
// re-classify (e.g. militia_patrol → covenant_enforcers).
|
||||
FactionId = template.Faction;
|
||||
}
|
||||
|
||||
/// <summary>Phase 6 M1 — resident-template constructor.</summary>
|
||||
public NpcActor(ResidentTemplateDef resident)
|
||||
{
|
||||
Template = null;
|
||||
Resident = resident ?? throw new System.ArgumentNullException(nameof(resident));
|
||||
CurrentHp = resident.Hp;
|
||||
MaxHp = resident.Hp;
|
||||
RoleTag = resident.RoleTag;
|
||||
DialogueId = resident.Dialogue;
|
||||
BiasProfileId = resident.BiasProfile;
|
||||
FactionId = resident.Faction;
|
||||
Allegiance = Rules.Character.AllegianceExtensions.FromJson(resident.DefaultAllegiance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — compute the ordered scent-tag list for a Scent-Broker
|
||||
/// reading this NPC. Higher-priority tags sort first; ties broken by
|
||||
/// enum order. Returns at most <paramref name="maxCount"/> entries.
|
||||
///
|
||||
/// Standard tiers:
|
||||
/// - Scent Literacy (level 1): maxCount = 1 — the headline read.
|
||||
/// - Scent Mastery (master_nose, level 11): maxCount = 3 — fuller picture.
|
||||
///
|
||||
/// Tag derivation:
|
||||
/// - <see cref="FactionId"/> resolves to a single faction-affiliation
|
||||
/// tag (priority 1–8 by enum order).
|
||||
/// - <see cref="HasRecentlyKilled"/> → <see cref="ScentTag.RecentlyKilled"/>.
|
||||
/// - HP < 25% (or low-HP fleeing) → <see cref="ScentTag.Frightened"/>.
|
||||
/// - HP < 50% → <see cref="ScentTag.Wounded"/>.
|
||||
/// - <see cref="CarriesContrabandFlag"/> → <see cref="ScentTag.CarriesContraband"/>.
|
||||
/// </summary>
|
||||
public List<ScentTag> ComputeScentTags(int maxCount = 1)
|
||||
{
|
||||
if (maxCount <= 0) return new List<ScentTag>();
|
||||
var list = new List<ScentTag>();
|
||||
|
||||
// Faction-derived (priority 1–8). At most one — an NPC carries one
|
||||
// faction's chemistry strongly.
|
||||
var factionTag = ScentTagExtensions.FromFactionId(FactionId);
|
||||
if (factionTag != ScentTag.None) list.Add(factionTag);
|
||||
|
||||
// Runtime-derived. Order matters — most informative first.
|
||||
if (HasRecentlyKilled) list.Add(ScentTag.RecentlyKilled);
|
||||
|
||||
// Distress markers — cheaper of the two when triggered.
|
||||
float hpFraction = MaxHp > 0 ? (float)CurrentHp / MaxHp : 1f;
|
||||
if (hpFraction < 0.25f && CurrentHp > 0)
|
||||
list.Add(ScentTag.Frightened);
|
||||
else if (hpFraction < 0.50f && CurrentHp > 0)
|
||||
list.Add(ScentTag.Wounded);
|
||||
|
||||
if (CarriesContrabandFlag) list.Add(ScentTag.CarriesContraband);
|
||||
|
||||
// Truncate to the cap.
|
||||
if (list.Count > maxCount) list.RemoveRange(maxCount, list.Count - maxCount);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mutable per-NPC AI state. Behaviors read/write between turns.</summary>
|
||||
public sealed class AiState
|
||||
{
|
||||
/// <summary>Last position where this NPC saw a hostile target. Used to chase after losing line-of-sight.</summary>
|
||||
public Vec2? LastSeenTargetPos { get; set; }
|
||||
|
||||
/// <summary>Turns since LastSeenTargetPos was updated. After N turns, the NPC gives up and returns to home.</summary>
|
||||
public int TurnsSinceLastSeen { get; set; }
|
||||
|
||||
/// <summary>"Home" position the NPC drifts back to (PoiGuard patrol anchor). Null = no home, just stand still.</summary>
|
||||
public Vec2? HomePos { get; set; }
|
||||
|
||||
/// <summary>Currently engaged (in combat). Cleared on encounter end.</summary>
|
||||
public bool InCombat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Theriapolis.Core.Util;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Player actor. Phase 4 keeps the field set deliberately small — just enough
|
||||
/// to position, save, and reload a single human-controlled character. Stats,
|
||||
/// inventory, and class data are added in Phase 5.
|
||||
/// </summary>
|
||||
public sealed class PlayerActor : Actor
|
||||
{
|
||||
public string Name { get; set; } = "Wanderer";
|
||||
|
||||
/// <summary>Highest settlement tier the player has actually visited (1 = capital).</summary>
|
||||
public int HighestTierReached { get; set; } = 5;
|
||||
|
||||
/// <summary>Set of discovered settlement / PoI ids. Drives slot-picker labels and Phase 7+ map UI.</summary>
|
||||
public HashSet<int> DiscoveredPoiIds { get; } = new();
|
||||
|
||||
/// <summary>Snapshot used by SaveCodec; mutated by RestoreState.</summary>
|
||||
public PlayerActorState CaptureState() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
PositionX = Position.X,
|
||||
PositionY = Position.Y,
|
||||
FacingAngleRad = FacingAngleRad,
|
||||
SpeedWorldPxPerSec = SpeedWorldPxPerSec,
|
||||
HighestTierReached = HighestTierReached,
|
||||
DiscoveredPoiIds = DiscoveredPoiIds.ToArray(),
|
||||
};
|
||||
|
||||
public void RestoreState(PlayerActorState s)
|
||||
{
|
||||
Name = s.Name;
|
||||
Position = new Vec2(s.PositionX, s.PositionY);
|
||||
FacingAngleRad = s.FacingAngleRad;
|
||||
SpeedWorldPxPerSec = s.SpeedWorldPxPerSec;
|
||||
HighestTierReached = s.HighestTierReached;
|
||||
DiscoveredPoiIds.Clear();
|
||||
foreach (int id in s.DiscoveredPoiIds) DiscoveredPoiIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain serializable snapshot of a <see cref="PlayerActor"/>.
|
||||
/// Kept as a struct of primitive fields so the persistence layer doesn't need
|
||||
/// MessagePack attributes on the live object — keeps Core dependency-free for
|
||||
/// modules that don't yet care about saves.
|
||||
/// </summary>
|
||||
public sealed class PlayerActorState
|
||||
{
|
||||
public int Id;
|
||||
public string Name = "";
|
||||
public float PositionX;
|
||||
public float PositionY;
|
||||
public float FacingAngleRad;
|
||||
public float SpeedWorldPxPerSec;
|
||||
public int HighestTierReached;
|
||||
public int[] DiscoveredPoiIds = Array.Empty<int>();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 M6 — bounded vocabulary of "scent reads" surfaced by
|
||||
/// Scent-Broker abilities (Scent Literacy at L1, Scent Mastery /
|
||||
/// <c>master_nose</c> at L11). Each tag is a short, in-fiction
|
||||
/// observation a Scent-Broker can pick out of an NPC's scent profile —
|
||||
/// recent activity, faction affiliation, distress markers.
|
||||
///
|
||||
/// Per the Phase 6.5 plan §3.2: bounded enum on purpose. Phase 8's full
|
||||
/// scent-propagation simulation can extend the vocabulary; Phase 6.5
|
||||
/// keeps the set small and the population path simple (auto-derived from
|
||||
/// faction id + a small set of runtime triggers — see
|
||||
/// <see cref="NpcActor.ComputeScentTags"/>).
|
||||
///
|
||||
/// Display priority is the enum order: lower-numbered tags win when
|
||||
/// truncating to a single read for L1 Scent Literacy. Faction-affiliation
|
||||
/// tags lead because they're the most narratively meaningful (Lacroix
|
||||
/// reads as "Maw-affiliated" before "recently killed", because the latter
|
||||
/// is generic brigand flavour).
|
||||
/// </summary>
|
||||
public enum ScentTag : byte
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// ── Faction-affiliation reads (priority 1–8) ─────────────────────────
|
||||
/// <summary>The NPC carries the scent of Maw chemistry / contact.</summary>
|
||||
MawAffiliated = 1,
|
||||
/// <summary>Inheritor pheromone-marker / ration scent.</summary>
|
||||
InheritorAffiliated = 2,
|
||||
/// <summary>Thorn Council chemical signatures.</summary>
|
||||
ThornCouncilAffiliated = 3,
|
||||
/// <summary>Covenant Enforcer protocol scent (uniform launderings, sigil oils).</summary>
|
||||
CovenantEnforcerAffiliated = 4,
|
||||
/// <summary>Hybrid Underground safe-house scent traces.</summary>
|
||||
HybridUndergroundAffiliated = 5,
|
||||
/// <summary>Unsheathed (hybrid activist) chemical markers.</summary>
|
||||
UnsheathedAffiliated = 6,
|
||||
/// <summary>Merchant-guild handshake oils — well-traveled, much-greeted.</summary>
|
||||
MerchantAffiliated = 7,
|
||||
|
||||
// ── Runtime-derived reads (priority 16+) ─────────────────────────────
|
||||
/// <summary>Has killed something recently (within ~1 hour). Set on melee kill.</summary>
|
||||
RecentlyKilled = 16,
|
||||
/// <summary>Carries combat-distress markers — fleeing or near-death.</summary>
|
||||
Frightened = 17,
|
||||
/// <summary>Carries contraband (pheromone vials, deep-cover masks, faction sigils).</summary>
|
||||
CarriesContraband = 18,
|
||||
/// <summary>Wounded — current HP < 50%.</summary>
|
||||
Wounded = 19,
|
||||
}
|
||||
|
||||
public static class ScentTagExtensions
|
||||
{
|
||||
/// <summary>Human-readable label for the InteractionScreen overlay.</summary>
|
||||
public static string DisplayName(this ScentTag tag) => tag switch
|
||||
{
|
||||
ScentTag.MawAffiliated => "Maw-affiliated",
|
||||
ScentTag.InheritorAffiliated => "Inheritor-affiliated",
|
||||
ScentTag.ThornCouncilAffiliated => "Thorn Council-affiliated",
|
||||
ScentTag.CovenantEnforcerAffiliated => "Covenant Enforcer-affiliated",
|
||||
ScentTag.HybridUndergroundAffiliated => "Hybrid Underground-affiliated",
|
||||
ScentTag.UnsheathedAffiliated => "Unsheathed-affiliated",
|
||||
ScentTag.MerchantAffiliated => "Merchant-affiliated",
|
||||
ScentTag.RecentlyKilled => "Recently killed",
|
||||
ScentTag.Frightened => "Frightened",
|
||||
ScentTag.CarriesContraband => "Carries contraband",
|
||||
ScentTag.Wounded => "Wounded",
|
||||
_ => "—",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a faction id to the corresponding affiliation tag. Returns
|
||||
/// <see cref="ScentTag.None"/> for empty / unknown faction ids.
|
||||
/// </summary>
|
||||
public static ScentTag FromFactionId(string factionId) => factionId?.ToLowerInvariant() switch
|
||||
{
|
||||
"maw" => ScentTag.MawAffiliated,
|
||||
"inheritors" => ScentTag.InheritorAffiliated,
|
||||
"thorn_council" => ScentTag.ThornCouncilAffiliated,
|
||||
"covenant_enforcers" => ScentTag.CovenantEnforcerAffiliated,
|
||||
"hybrid_underground" => ScentTag.HybridUndergroundAffiliated,
|
||||
"unsheathed" => ScentTag.UnsheathedAffiliated,
|
||||
"merchant_guilds" => ScentTag.MerchantAffiliated,
|
||||
_ => ScentTag.None,
|
||||
};
|
||||
|
||||
/// <summary>True if this tag carries narrative weight — e.g. faction reveals.</summary>
|
||||
public static bool IsNarrative(this ScentTag tag) =>
|
||||
tag != ScentTag.None && (byte)tag < 16;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Theriapolis.Core.Util;
|
||||
using Theriapolis.Core.World;
|
||||
|
||||
namespace Theriapolis.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Plans a continuous-time path between two world tiles. The result is a
|
||||
/// list of world-pixel waypoints (tile centers); the controller animates
|
||||
/// the player along it at <see cref="C.PLAYER_TRAVEL_PX_PER_SEC"/>.
|
||||
///
|
||||
/// Cost function:
|
||||
/// • Ocean tiles (Biome == Ocean) are impassable.
|
||||
/// • Mountain tiles cost 4× a grassland tile (slow but not blocked).
|
||||
/// • Tiles carrying a road get a strong discount (matches the ROAD_SPEED_MULT idea).
|
||||
/// • Diagonal moves are √2 like everywhere else in the codebase.
|
||||
///
|
||||
/// The clock-advance amount is computed afterwards by walking the path
|
||||
/// and summing per-segment travel time — see <see cref="EstimateSecondsForLeg"/>.
|
||||
/// </summary>
|
||||
public sealed class WorldTravelPlanner
|
||||
{
|
||||
private readonly AStarPathfinder _astar = new();
|
||||
private readonly WorldState _world;
|
||||
|
||||
public WorldTravelPlanner(WorldState world) { _world = world; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns null if no path exists. Returned waypoints are tile coordinates,
|
||||
/// not world-pixel coordinates — convert with <see cref="TileCenterToWorldPixel"/>.
|
||||
/// </summary>
|
||||
public List<(int X, int Y)>? PlanTilePath(int sx, int sy, int gx, int gy)
|
||||
{
|
||||
if (!IsWalkable(sx, sy)) return null;
|
||||
if (!IsWalkable(gx, gy)) return null;
|
||||
return _astar.FindPath(sx, sy, gx, gy, CostFn);
|
||||
}
|
||||
|
||||
private float CostFn(int fx, int fy, int tx, int ty, byte _)
|
||||
{
|
||||
if (!IsWalkable(tx, ty)) return float.PositiveInfinity;
|
||||
|
||||
ref var t = ref _world.TileAt(tx, ty);
|
||||
float baseCost = TerrainCost(t);
|
||||
// Strong incentive to follow roads — matches the EXISTING_ROAD_COST
|
||||
// worldgen philosophy. Pure additive (not multiplicative) so it
|
||||
// never goes negative and A* admissibility holds.
|
||||
if ((t.Features & FeatureFlags.HasRoad) != 0) baseCost *= 0.25f;
|
||||
return baseCost;
|
||||
}
|
||||
|
||||
private bool IsWalkable(int x, int y)
|
||||
{
|
||||
if ((uint)x >= C.WORLD_WIDTH_TILES) return false;
|
||||
if ((uint)y >= C.WORLD_HEIGHT_TILES) return false;
|
||||
ref var t = ref _world.TileAt(x, y);
|
||||
return t.Biome != BiomeId.Ocean;
|
||||
}
|
||||
|
||||
private static float TerrainCost(in WorldTile t) => t.Biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine => 4.0f,
|
||||
BiomeId.MountainForested => 3.0f,
|
||||
BiomeId.Wetland => 2.5f,
|
||||
BiomeId.Foothills => 1.8f,
|
||||
BiomeId.Boreal => 1.6f,
|
||||
BiomeId.SubtropicalForest => 1.6f,
|
||||
BiomeId.TemperateDeciduous => 1.5f,
|
||||
BiomeId.Tundra => 1.5f,
|
||||
BiomeId.DesertCold => 1.4f,
|
||||
BiomeId.Mangrove => 1.4f,
|
||||
BiomeId.MarshEdge => 1.4f,
|
||||
BiomeId.Scrubland => 1.2f,
|
||||
BiomeId.ForestEdge => 1.3f,
|
||||
BiomeId.Cliff => 3.0f,
|
||||
BiomeId.TemperateGrassland => 1.0f,
|
||||
BiomeId.RiverValley => 1.0f,
|
||||
BiomeId.Coastal => 1.0f,
|
||||
BiomeId.Beach => 1.0f,
|
||||
BiomeId.TidalFlat => 1.5f,
|
||||
_ => 1.2f,
|
||||
};
|
||||
|
||||
public static Vec2 TileCenterToWorldPixel(int x, int y)
|
||||
=> new(x * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f,
|
||||
y * C.WORLD_TILE_PIXELS + C.WORLD_TILE_PIXELS * 0.5f);
|
||||
|
||||
/// <summary>
|
||||
/// In-game seconds to traverse a single world-pixel between adjacent tiles.
|
||||
/// Combines BASE_SEC_PER_WORLD_PIXEL with biome modifier and a road bonus.
|
||||
/// </summary>
|
||||
public float SecondsPerPixel(in WorldTile t)
|
||||
{
|
||||
float biomeMod = t.Biome switch
|
||||
{
|
||||
BiomeId.MountainAlpine => 3.0f,
|
||||
BiomeId.MountainForested => 2.5f,
|
||||
BiomeId.Wetland => 2.0f,
|
||||
BiomeId.Foothills => 1.6f,
|
||||
BiomeId.Boreal => 1.5f,
|
||||
BiomeId.SubtropicalForest => 1.5f,
|
||||
BiomeId.TemperateDeciduous => 1.4f,
|
||||
BiomeId.Tundra => 1.5f,
|
||||
_ => 1.0f,
|
||||
};
|
||||
float roadMod = ((t.Features & FeatureFlags.HasRoad) != 0) ? C.ROAD_SPEED_MULT : 1f;
|
||||
return C.BASE_SEC_PER_WORLD_PIXEL * biomeMod * roadMod;
|
||||
}
|
||||
|
||||
/// <summary>In-game seconds to walk between two adjacent tile centers.</summary>
|
||||
public float EstimateSecondsForLeg(int fx, int fy, int tx, int ty)
|
||||
{
|
||||
ref var to = ref _world.TileAt(tx, ty);
|
||||
float pixDist = Vec2.Dist(TileCenterToWorldPixel(fx, fy), TileCenterToWorldPixel(tx, ty));
|
||||
return pixDist * SecondsPerPixel(to);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user