From 1ba9239e48104903d9b9973f2d120c589577b53e Mon Sep 17 00:00:00 2001 From: pierow1 <43918249+pierow1@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:01:35 -0400 Subject: [PATCH] add(mob) SoulsMasterSummoner Moved from branch develop to develop-EN Adds a summon cap and check (spawnAttempts) to HOPEFULLY prevent mobs from spawning in walls/other mobs, summonEffect, removed FootstepSound tag, and misc stuff 180 to 300 total health SoulsMasterSummonerSystem reworked to 1 second UpdateInterval cycles (30 tick) with other methods using TryComp<> --- .../SoulsMasterSummonerComponent.cs | 50 ++++ .../SoulsMaster/SoulsMasterSummonerSystem.cs | 230 ++++++++++++++++++ .../soulsmastersummoner.yml | 82 +++++++ .../SoulsMaster/souls_master.rsi/idle.png | Bin 0 -> 4999 bytes .../SoulsMaster/souls_master.rsi/meta.json | 18 ++ 5 files changed, 380 insertions(+) create mode 100644 Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerComponent.cs create mode 100644 Content.Server/Imperial/Medieval/SoulsMaster/SoulsMasterSummonerSystem.cs create mode 100644 Resources/Prototypes/Imperial/Medieval/Bosses/SoulsMasterSummoner/soulsmastersummoner.yml create mode 100644 Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/idle.png create mode 100644 Resources/Textures/Imperial/Medieval/Mobs/SoulsMaster/souls_master.rsi/meta.json 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 0000000000000000000000000000000000000000..91070bc8dc93ff94ca15f18325778ee1f6a82962 GIT binary patch literal 4999 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 EAM2{D{Qv*} literal 0 HcmV?d00001 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] + ] + } + ] +}