diff --git a/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerComponent.cs b/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerComponent.cs new file mode 100644 index 00000000000..074aeda359f --- /dev/null +++ b/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerComponent.cs @@ -0,0 +1,50 @@ +/// +/// Spellward International add; +/// Periodically summons goons around its summoner while the summoner is alive +/// +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Server.Imperial.Medieval.SoulsMaster; + +[RegisterComponent] +public sealed partial class SoulsMasterSummonerComponent : Component +{ + [DataField] + public EntProtoId SummonPrototype = "MedievalMobSkeletWeakSpell"; + + // Number of entities spawned each time the summon cooldown completes + [DataField] + public int SummonCount = 1; + + // Cool effect proc'd when summon is spawned + [DataField] + public EntProtoId SummonEffect = "IceBarrierSpellCastEffectBeginner"; + + // Maximum number of this summoner's living summons that may exist at once + [DataField] + public int MaxSummons = 3; + + [DataField] + public float SummonRadius = 2f; + + // Number of random positions attempted for each summon before that spawn is skipped + // Summons must be on a non-space floor tile that is not blocked by walls or mobs + [DataField] + public int SpawnAttempts = 5; + + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] + public TimeSpan NextSummon; + + [DataField] + public TimeSpan InitialDelay = TimeSpan.FromSeconds(5); + + [DataField] + public TimeSpan Cooldown = TimeSpan.FromSeconds(20); + + // Summons currently owned by this component. Dead or deleted entities stop the summoning + public readonly HashSet ActiveSummons = new(); + + // Whether the summoner had an active combat target during the previous update + public bool WasAggroed; +} diff --git a/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerSystem.cs b/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerSystem.cs new file mode 100644 index 00000000000..cb0ed64b2cc --- /dev/null +++ b/Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerSystem.cs @@ -0,0 +1,230 @@ +/// +/// Spellward International add; +/// Periodically spawns configured summon entities around living mobs that have a +/// SoulsMasterSummonerComponent. The first summon is delayed by the +/// component's initial delay, and following summons use its configured cooldown +/// Also, Summoning stops when aggro is lost and resumes with the initial delay when +/// a new target is acquired. Each summoner tracks its own living summons and respects its +/// configured maximum in the YAML +/// +using Content.Server.NPC.HTN; +using Content.Shared.Coordinates.Helpers; +using Content.Shared.Maps; +using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; +using Content.Shared.Physics; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Server.Imperial.Medieval.SoulsMaster; + +public sealed class SoulsMasterSummonerSystem : EntitySystem +{ + // Run the main summoner query once every second (30 ticks) + private const float UpdateInterval = 1f; + + // Stores time until the next summoner update + private float _updateAccumulator; + + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly SharedMapSystem _map = default!; + [Dependency] private readonly TurfSystem _turf = default!; + + public override void Initialize() + { + base.Initialize(); + + // Starts the summon timer when the summoner enters the map + SubscribeLocalEvent(OnMapInit); + } + + private void OnMapInit( + EntityUid uid, + SoulsMasterSummonerComponent component, + MapInitEvent args) + { + // This timer is reset again when the summoner first becomes aggroed + component.NextSummon = _timing.CurTime + component.InitialDelay; + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + // Update() is called every tick, but the main logic only runs + // after UpdateInterval seconds have elapsed + _updateAccumulator += frameTime; + + if (_updateAccumulator < UpdateInterval) + return; + + // Preserve any remaining time without running several + // catch-up updates after a server stall + _updateAccumulator %= UpdateInterval; + + // Query only the custom component + // Other required components are retrieved below using TryComp<> + var query = EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out var summoner)) + { + // Remove dead or deleted summons before calculating available slots + PruneSummons(summoner); + + // Dead summoners cannot summon + if (!TryComp(uid, out var mobState) || + mobState.CurrentState != MobState.Alive) + { + summoner.WasAggroed = false; + continue; + } + + // The summoner requires a transform for spawning and an HTN + // component for checking its current combat target + if (!TryComp(uid, out var transform) || + !TryComp(uid, out var htn)) + { + summoner.WasAggroed = false; + continue; + } + + // The HTN Target blackboard value represents the mob's active target + var aggroed = + htn.Blackboard.TryGetValue( + "Target", + out var target, + EntityManager) && + EntityManager.EntityExists(target); + + // Stop summon processing while no active target exists + if (!aggroed) + { + summoner.WasAggroed = false; + continue; + } + + // Start a fresh initial delay whenever the summoner acquires + // a target after previously being unaggroed + if (!summoner.WasAggroed) + { + summoner.WasAggroed = true; + summoner.NextSummon = + _timing.CurTime + summoner.InitialDelay; + + continue; + } + + // Wait until the initial delay or normal cooldown has elapsed + if (summoner.NextSummon > _timing.CurTime) + continue; + + // Begin the next cooldown before attempting to summon + summoner.NextSummon = + _timing.CurTime + summoner.Cooldown; + + // Never exceed the configured maximum number of active summons + var availableSlots = Math.Max( + 0, + summoner.MaxSummons - summoner.ActiveSummons.Count); + + // SummonCount controls the number spawned per cycle, while + // availableSlots prevents the configured cap from being exceeded + var amountToSpawn = Math.Min( + Math.Max(0, summoner.SummonCount), + availableSlots); + + for (var i = 0; i < amountToSpawn; i++) + { + // Search for a valid floor tile that is not blocked by + // walls, impassable objects, or another mob + if (!TryFindSpawnCoordinates( + transform.Coordinates, + summoner, + out var spawnCoordinates)) + { + continue; + } + + // Play the configured visual effect at the spawned position + Spawn(summoner.SummonEffect, spawnCoordinates); + + // Spawn and track the summon so it counts toward maxSummons + var summon = Spawn( + summoner.SummonPrototype, + spawnCoordinates); + + summoner.ActiveSummons.Add(summon); + } + } + } + + private bool TryFindSpawnCoordinates( + EntityCoordinates origin, + SoulsMasterSummonerComponent summoner, + out EntityCoordinates spawnCoordinates) + { + spawnCoordinates = default; + + // Summons must be placed on an actual map grid + if (origin.GetGridUid(EntityManager) is not { } gridUid || + !TryComp(gridUid, out var grid)) + { + return false; + } + + // Try several random positions before giving up on this summon + for (var attempt = 0; + attempt < summoner.SpawnAttempts; + attempt++) + { + var offset = + _random.NextVector2() * summoner.SummonRadius; + + // Snap the random position to the center of tile + var candidate = + origin.Offset(offset).SnapToGrid(grid); + + // Reject missing tiles, space, walls, impassable objects, + // and tiles currently occupied by mobs + if (!_map.TryGetTileRef( + gridUid, + grid, + candidate, + out var tileRef) || + tileRef.Tile.IsEmpty || + _turf.IsSpace(tileRef) || + _turf.IsTileBlocked( + tileRef, + CollisionGroup.Impassable | + CollisionGroup.MobMask)) + { + continue; + } + + spawnCoordinates = candidate; + return true; + } + + // No valid position found within the spawnAttempt count + return false; + } + + private void PruneSummons( + SoulsMasterSummonerComponent summoner) + { + // Deleted or dead summons no longer occupy a summon slot + summoner.ActiveSummons.RemoveWhere(summon => + { + if (!EntityManager.EntityExists(summon)) + return true; + + return TryComp( + summon, + out var mobState) && + mobState.CurrentState != MobState.Alive; + }); + } +} diff --git a/Resources/Prototypes/Imperial/Medieval/Bosses/SoulsMasterSummoner/soulsmastersummoner.yml b/Resources/Prototypes/Imperial/Medieval/Bosses/SoulsMasterSummoner/soulsmastersummoner.yml new file mode 100644 index 00000000000..ceae67a6e90 --- /dev/null +++ b/Resources/Prototypes/Imperial/Medieval/Bosses/SoulsMasterSummoner/soulsmastersummoner.yml @@ -0,0 +1,82 @@ +- type: entity + id: MedievalMobSoulsMaster + name: Souls Master Summoner + description: SWALLOW YER SOUL! SWALLOW YER SOUL... + parent: [ SimpleMobBaseNoFood, MobCombat ] + components: + - type: Sprite + drawdepth: Mobs + sprite: Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi + layers: + - state: idle + - type: Physics + - type: Fixtures + fixtures: + fix1: + shape: !type:PhysShapeCircle + radius: 0.45 + density: 50 + mask: + - MobMask + layer: + - MobLayer + - type: Appearance + - type: NpcFactionMember + factions: + - Syndicate + - type: InputMover + - type: MobMover + - type: HTN + rootTask: + task: SimpleRangedHostileCompound + - type: MovementSpeedModifier + baseWalkSpeed: 2.8 + baseSprintSpeed: 3.4 + - type: MobThresholds + thresholds: + 0: Alive + 300: Dead + - type: Damageable + damageModifierSet: MedievalRespawn + - type: Bloodstream + bloodMaxVolume: 0 + - type: Puller + - type: IgnoreSpiderWeb + - type: Tag + tags: + - CannotSuicide + - DoorBumpOpener + - MedievalMob + - type: RechargeBasicEntityAmmo + rechargeCooldown: 2.5 + - type: BasicEntityAmmoProvider + proto: MedievalProjectileCursedArrow + capacity: 1 + count: 1 + - type: Gun + projectileSpeed: 12 + fireRate: 1 + useKey: false + selectedMode: SemiAuto + availableModes: + - SemiAuto + soundGunshot: /Audio/Magic/ethereal_exit.ogg + - type: SoulsMasterSummoner + summonPrototype: MedievalMobSkeletWeakSpell + summonEffect: IceBarrierSpellCastEffectBeginner + summonCount: 1 + maxSummons: 3 + summonRadius: 2 + spawnAttempts: 5 + initialDelay: 5 + cooldown: 20 + - type: Destructible + thresholds: + - trigger: !type:DamageTrigger + damage: 300 + behaviors: + - !type:PlaySoundBehavior + sound: + collection: gib + - !type:DoActsBehavior + acts: [ "Destruction" ] diff --git a/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/idle.png b/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/idle.png new file mode 100644 index 00000000000..91070bc8dc9 Binary files /dev/null and b/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/idle.png differ diff --git a/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/meta.json b/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/meta.json new file mode 100644 index 00000000000..4a52c2fe1ed --- /dev/null +++ b/Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/meta.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "license": "this content is under ICLA licence, read more on https://wiki.imperialspace.net/icla", + "copyright": "by dantanat#7095 (i think -pierow)", + "size": { + "x": 48, + "y": 48 + }, + "states": [ + { + "name": "idle", + "directions": 1, + "delays": [ + [0.14, 0.14, 0.14, 0.14, 0.14, 0.14, 0.14] + ] + } + ] +}