Import art/VFX asset packs + game-feel systems; normalize texture extensions to lowercase for LFS

Add BefourStudios SciFi environment packs, Gabriel Aguiar VFX, and the
ShaderCrew Toon Shader embedded packages, plus combat/enemy/wave/death
gameplay systems and supporting vault docs/screenshots.

Rename 11 vendor textures from uppercase .PNG/.HDR to lowercase so the
case-sensitive Git LFS filters (*.png/*.hdr) match on case-sensitive
filesystems (Linux CI, case-sensitive macOS), not just locally where
core.ignorecase=true masks the gap. Each .meta moved with its asset so
GUID references are preserved. All ~1000 binaries tracked via LFS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 22:50:43 -07:00
parent dd0064c377
commit e362aaeb43
4830 changed files with 1293057 additions and 38 deletions
@@ -68,7 +68,7 @@ namespace ProjectM.Simulation
if (isServer)
{
foreach (var dummyTransform in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<TrainingDummyTag>())
SystemAPI.Query<RefRO<LocalTransform>>().WithAny<TrainingDummyTag, EnemyTag>())
{
candidatePositions.Add(dummyTransform.ValueRO.Position);
}
@@ -81,7 +81,7 @@ namespace ProjectM.Simulation
SystemAPI.Query<RefRO<PlayerInput>, RefRO<PlayerFacing>, RefRO<LocalTransform>,
RefRO<EffectiveAbilityStats>, RefRO<AbilityRef>, RefRW<AbilityCooldown>,
RefRO<GhostOwner>>()
.WithAll<Simulate>()
.WithAll<Simulate>().WithDisabled<Dead>()
.WithEntityAccess())
{
// The InputEvent on the component carries the per-tick delta: set => fired this tick.
@@ -162,7 +162,7 @@ namespace ProjectM.Simulation
// Earliest raw tick the player may fire again. Clamp cooldown to >= 1 tick.
uint cooldownTicks = (uint)math.max(1, eff.ValueRO.CooldownTicks);
cd.ValueRW.NextFireTick = serverTick.TickIndexForValidTick + cooldownTicks;
cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
}
ecb.Playback(state.EntityManager);
@@ -0,0 +1,54 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic Husk AI math — no RNG, no wall-clock — so server simulation stays reproducible and
/// the helpers are EditMode-unit-testable without an ECS world (mirrors <see cref="PlayerSpawnMath"/> /
/// <c>StatMath</c>).
/// </summary>
public static class EnemyAIMath
{
/// <summary>
/// Planar (XZ) seek velocity from <paramref name="from"/> toward <paramref name="to"/> at
/// <paramref name="speed"/>. Y is forced to 0 (top-down plane). Returns zero once within
/// <paramref name="stopDistance"/> (so the Husk halts at strike range instead of jittering through the
/// target) or when the two points coincide.
/// </summary>
public static float3 SeekVelocity(float3 from, float3 to, float speed, float stopDistance)
{
float3 d = to - from;
d.y = 0f;
float distSq = math.lengthsq(d);
if (distSq <= stopDistance * stopDistance || distSq < 1e-8f)
return float3.zero;
return math.normalize(d) * speed;
}
/// <summary>
/// True when <paramref name="to"/> is within <paramref name="range"/> of <paramref name="from"/> on the
/// XZ plane.
/// </summary>
public static bool InAttackRange(float3 from, float3 to, float range)
{
float3 d = to - from;
d.y = 0f;
return math.lengthsq(d) <= range * range;
}
/// <summary>
/// Deterministic planar ring position around <paramref name="center"/> for spawn
/// <paramref name="index"/>: evenly spaced over <paramref name="slots"/> angles at
/// <paramref name="radius"/>. Stable per index so a replayed spawn lands identically.
/// </summary>
public static float3 RingPosition(float3 center, int index, int slots, float radius)
{
if (slots < 1)
slots = 1;
int slot = ((index % slots) + slots) % slots;
float angle = (2f * math.PI * slot) / slots;
math.sincos(angle, out float s, out float c);
return center + new float3(c * radius, 0f, s * radius);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7db18ae1e3adab1448a9769b002fb9cd
@@ -0,0 +1,43 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Marks a Husk enemy: a server-simulated, OWNERLESS INTERPOLATED ghost that seeks the nearest player
/// and strikes on contact. Damageable like the training dummy (Health/HitRadius/DamageEvent), but unlike
/// the dummy it IS a replicated ghost, so every client sees it. Movement is server-authoritative — written
/// to LocalTransform in the plain <c>SimulationSystemGroup</c> (interpolated ghosts are NOT predicted) and
/// replicated via the stock LocalTransform default variant (no hand-written <c>[GhostField]</c>). Spawned by
/// the <see cref="WaveDirector"/> (round-robin over Husk variants).
/// </summary>
public struct EnemyTag : IComponentData { }
/// <summary>
/// Baked Husk tunables — identical on both worlds, not replicated (only server systems read them). Different
/// Husk variants (Grunt / Swarmer / Brute) are just different baked values of this component.
/// </summary>
public struct EnemyStats : IComponentData
{
/// <summary>Planar seek speed toward the target, world units/second.</summary>
public float MoveSpeed;
/// <summary>Centre-to-centre distance at which the Husk can strike a player.</summary>
public float AttackRange;
/// <summary>Damage dealt per strike.</summary>
public float AttackDamage;
/// <summary>Simulation ticks between strikes.</summary>
public int AttackCooldownTicks;
}
/// <summary>
/// Server-only per-Husk attack gate. Raw tick value of the earliest tick it may strike again; <c>0</c> =
/// ready. Compared by wrapping into a <see cref="Unity.NetCode.NetworkTick"/> (raw subtraction is unsafe
/// across tick wraparound), mirroring <see cref="AbilityCooldown"/>. Not replicated.
/// </summary>
public struct EnemyAttackCooldown : IComponentData
{
public uint NextAttackTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d06ea439174f0cd45b95e9a6e02c14bf
@@ -0,0 +1,17 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Shared tick helpers for the project-wide convention that a stored raw "next tick" value of <c>0</c> means
/// "ready / nothing pending". A computed absolute tick (<c>ServerTick + delay</c>) can legitimately equal 0 at
/// <see cref="uint"/> wraparound, which would be misread as "ready"; <see cref="NonZero"/> coerces it to 1 (a
/// 1-tick error only at the single wrap instant). Mirrors the guard already inlined in
/// <see cref="RespawnMath.RespawnTick"/>, applied consistently at every cooldown/spawn "next tick" write
/// (<c>AbilityCooldown.NextFireTick</c>, <c>EnemyAttackCooldown.NextAttackTick</c>,
/// <c>EnemySpawner.NextSpawnTick</c>).
/// </summary>
public static class TickUtil
{
/// <summary>Coerce a computed raw tick of 0 to 1 so it never collides with the "0 = ready" sentinel.</summary>
public static uint NonZero(uint tick) => tick == 0u ? 1u : tick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3cea93829504a584fabb291b2d559f2f
@@ -0,0 +1,47 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked config for the Husk wave/threat director (singleton). The director escalates: wave N spawns
/// <c>BaseCount + (N-1)*CountPerWave</c> Husks (round-robin over the <see cref="WaveEnemyPrefab"/> pool) at a
/// deterministic ring around the <see cref="BaseAnchor"/>, one every <c>SpawnIntervalTicks</c>; once a wave is
/// cleared the field stays calm for <c>LullTicks</c> before the next, bigger wave. Replaces the flat sustain.
/// </summary>
public struct WaveDirector : IComponentData
{
public float RingRadius;
public int RingSlots;
public int BaseCount;
public int CountPerWave;
public int SpawnIntervalTicks;
public int LullTicks;
}
/// <summary>Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...).</summary>
public struct WaveEnemyPrefab : IBufferElementData
{
public Entity Prefab;
}
/// <summary>Phase constants for <see cref="WaveState.Phase"/>.</summary>
public static class WavePhase
{
public const byte Lull = 0;
public const byte Spawning = 1;
}
/// <summary>
/// Runtime state of the wave director (server-only singleton; not replicated). Tracks the current wave, the
/// phase (lull vs spawning), the next action tick, how many Husks remain to spawn this wave, and a monotonic
/// spawn counter (drives the ring slot + the round-robin prefab pick).
/// </summary>
public struct WaveState : IComponentData
{
public int WaveNumber;
public byte Phase;
public uint NextActionTick;
public int RemainingToSpawn;
public int SpawnCounter;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1999bc9f9b3f2ac4da59d1e8f51849be
@@ -0,0 +1,42 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Enableable, LOCAL (not replicated) "is dead" gate for a player. Derived every predicted tick from the
/// replicated <see cref="Health"/> by <see cref="PlayerDeathStateSystem"/> (Dead == Health.Current &lt;= 0) —
/// exactly the derive-don't-replicate idiom of <see cref="EffectiveCharacterStats"/>/<c>StatRecomputeSystem</c>,
/// so it is identical on server + owner-predicted client and rollback-correct with no replicated enabled bit.
/// Baked DISABLED (the player spawns alive). Movement/aim/fire systems query <c>.WithDisabled&lt;Dead&gt;()</c>
/// so a dead player is frozen and can't act.
/// </summary>
public struct Dead : IComponentData, IEnableableComponent { }
/// <summary>
/// Server-only respawn timer for a player. NOT replicated — recovery is server-authoritative and the refilled
/// <see cref="Health"/> (GhostField) + repositioned LocalTransform replicate instead. <see cref="RespawnTick"/>
/// == 0 means "no respawn pending / alive"; <see cref="DelayTicks"/> is the baked down-time before recovery.
/// </summary>
public struct RespawnState : IComponentData
{
/// <summary>Raw server tick at which to respawn; 0 = none pending.</summary>
public uint RespawnTick;
/// <summary>Ticks the player stays down before recovering (~60 ticks/sec).</summary>
public int DelayTicks;
/// <summary>Ticks of post-respawn damage immunity granted on recovery (~60 ticks/sec).</summary>
public int InvulnTicks;
}
/// <summary>
/// Replicated post-respawn damage-immunity window. <c>UntilTick</c> = the raw server tick until which the
/// player ignores damage; 0 = none. Set server-side by <c>PlayerRespawnSystem</c> on recovery, enforced by
/// <c>HealthApplyDamageSystem</c>, and a <c>[GhostField]</c> so the client HUD can show a SHIELDED cue.
/// </summary>
public struct RespawnInvuln : IComponentData
{
[GhostField] public uint UntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3bd1e4b63cf826446b85d11124bc291b
@@ -22,7 +22,7 @@ namespace ProjectM.Simulation
{
foreach (var (facing, transform, input) in
SystemAPI.Query<RefRW<PlayerFacing>, RefRW<LocalTransform>, RefRO<PlayerInput>>()
.WithAll<Simulate>())
.WithAll<Simulate>().WithDisabled<Dead>())
{
float2 aim = input.ValueRO.Aim;
if (math.lengthsq(aim) < 1e-6f)
@@ -24,7 +24,7 @@ namespace ProjectM.Simulation
{
foreach (var (control, input, stats) in
SystemAPI.Query<RefRW<CharacterControl>, RefRO<PlayerInput>, RefRO<EffectiveCharacterStats>>()
.WithAll<Simulate>())
.WithAll<Simulate>().WithDisabled<Dead>())
{
control.ValueRW.MoveVelocity =
CharacterControlMath.DesiredMovement(input.ValueRO.Move, stats.ValueRO.MoveSpeed);
@@ -0,0 +1,42 @@
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Derives the LOCAL enableable <see cref="Dead"/> gate from the replicated <see cref="Health"/> every predicted
/// tick (Dead == Health.Current &lt;= 0). Runs in BOTH worlds inside
/// <see cref="PredictedSimulationSystemGroup"/>, BEFORE movement/aim/fire, so a dead player is excluded from
/// those systems (which query <c>.WithDisabled&lt;Dead&gt;()</c>) on the server AND the owner-predicting client.
/// Because it is a pure function of the already-replicated, reconciled Health (the same derive-don't-replicate
/// pattern as <see cref="StatRecomputeSystem"/>), the gate is identical across server, owner-client, and rollback
/// — no replicated enabled bit required. Also zeroes <see cref="CharacterControl.MoveVelocity"/> while dead so
/// the kinematic character holds still (the movement system is skipped and would otherwise coast on stale
/// velocity). The authoritative recovery (Health refill + reposition) is owned server-side by
/// <c>PlayerRespawnSystem</c>. Visits dead players too via <c>.WithPresent&lt;Dead&gt;()</c> (required to write
/// the enabled bit on an entity whose Dead is currently disabled).
/// </summary>
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateBefore(typeof(PlayerControlSystem))]
[UpdateBefore(typeof(PlayerAimSystem))]
[BurstCompile]
public partial struct PlayerDeathStateSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (health, control, deadEnabled) in
SystemAPI.Query<RefRO<Health>, RefRW<CharacterControl>, EnabledRefRW<Dead>>()
.WithAll<PlayerTag, Simulate>()
.WithPresent<Dead>())
{
bool isDead = health.ValueRO.Current <= 0f;
deadEnabled.ValueRW = isDead;
if (isDead)
control.ValueRW.MoveVelocity = float3.zero;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65930c35d657ce84fbce6f1130efb441
@@ -0,0 +1,36 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic respawn-timer math (no RNG, no wall-clock) — unit-testable in EditMode without a netcode
/// world (mirrors <see cref="PlayerSpawnMath"/> / <see cref="EnemyAIMath"/>). Ticks are the server's monotonic
/// simulation ticks; a stored value of 0 means "no respawn pending / alive". Comparisons use a wrap-safe signed-delta (modular) compare — matching
/// <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/> semantics — so they hold across the uint tick wraparound.
/// </summary>
public static class RespawnMath
{
/// <summary>
/// The tick at which a death at <paramref name="deathTick"/> should respawn, given
/// <paramref name="delayTicks"/> (clamped to &gt;= 1). Never returns 0 (0 is the "no respawn pending"
/// sentinel), so a death exactly at tick 0 still schedules a recovery.
/// </summary>
public static uint RespawnTick(uint deathTick, int delayTicks)
{
uint delay = (uint)math.max(1, delayTicks);
uint t = deathTick + delay;
return t == 0u ? 1u : t;
}
/// <summary>
/// True when <paramref name="now"/> has reached/passed a scheduled <paramref name="respawnTick"/> (and one
/// is actually scheduled, i.e. non-zero).
/// </summary>
public static bool IsDue(uint now, uint respawnTick)
{
// Wrap-safe modular compare (signed delta), NOT a raw `now >= respawnTick` (which is unsafe at the
// uint tick wraparound). Matches NetworkTick.IsNewerThan; keeps this helper pure-uint + unit-testable.
return respawnTick != 0u && (int)(now - respawnTick) >= 0;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 128d07bd94a16ab40b8a71bdf4f3e4cf