diff --git a/Content.Client/_Sunrise/CarpQueen/CarpEggVisualizerSystem.cs b/Content.Client/_Sunrise/CarpQueen/CarpEggVisualizerSystem.cs new file mode 100644 index 0000000000..fa1914ba24 --- /dev/null +++ b/Content.Client/_Sunrise/CarpQueen/CarpEggVisualizerSystem.cs @@ -0,0 +1,28 @@ +using Content.Shared._Sunrise.CarpQueen; +using Robust.Client.GameObjects; +using Robust.Shared.Maths; + +namespace Content.Client._Sunrise.CarpQueen; + +public sealed class CarpEggVisualizerSystem : VisualizerSystem +{ + [Dependency] private readonly SpriteSystem _sprite = default!; + + protected override void OnAppearanceChange(EntityUid uid, CarpEggComponent component, ref AppearanceChangeEvent args) + { + if (args.Sprite == null) + return; + + if (!args.AppearanceData.TryGetValue(CarpEggVisuals.OverlayColor, out var obj)) + return; + + var color = (Color) obj; + var sprite = args.Sprite; + if (_sprite.LayerMapTryGet((uid, sprite), "overlay", out var layer, false)) + { + _sprite.LayerSetColor((uid, sprite), layer, color); + } + } +} + + diff --git a/Content.Client/_Sunrise/CarpQueen/CarpServantVisualizerSystem.cs b/Content.Client/_Sunrise/CarpQueen/CarpServantVisualizerSystem.cs new file mode 100644 index 0000000000..ba54534616 --- /dev/null +++ b/Content.Client/_Sunrise/CarpQueen/CarpServantVisualizerSystem.cs @@ -0,0 +1,85 @@ +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Light.Components; +using Robust.Client.GameObjects; +using Robust.Shared.Maths; + +namespace Content.Client._Sunrise.CarpQueen; + +/// +/// Client-side system that applies the liquid color to carp servants +/// based on the color of the liquid they hatched from. +/// Overrides RgbLightController behavior to use fixed color. +/// +public sealed class CarpServantVisualizerSystem : VisualizerSystem +{ + [Dependency] private readonly SharedPointLightSystem _lights = default!; + [Dependency] private readonly SpriteSystem _sprite = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnStartup); + SubscribeLocalEvent(OnComponentAdd); + } + + private void OnComponentAdd(EntityUid uid, CarpServantMemoryComponent component, ComponentAdd args) + { + // Remove RgbLightController on client side to prevent rainbow effect + RemComp(uid); + } + + public override void FrameUpdate(float frameTime) + { + base.FrameUpdate(frameTime); + + // Continuously override RgbLightController color with fixed liquid color + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var memory, out var sprite)) + { + var color = memory.LiquidColor; + + // Override sprite layer 0 color (base layer) + _sprite.LayerSetColor((uid, sprite), 0, color); + + // Override light color if present + if (TryComp(uid, out var light)) + { + _lights.SetColor(uid, color, light); + } + } + } + + protected override void OnAppearanceChange(EntityUid uid, CarpServantMemoryComponent component, ref AppearanceChangeEvent args) + { + if (args.Sprite == null) + return; + + // Apply color to sprite layers + var color = component.LiquidColor; + var sprite = args.Sprite; + + // Apply color to base layer (layer 0) + _sprite.LayerSetColor((uid, sprite), 0, color); + + // Also update light color if present + if (TryComp(uid, out var light)) + { + _lights.SetColor(uid, color, light); + } + } + + private void OnStartup(EntityUid uid, CarpServantMemoryComponent component, ComponentStartup args) + { + // Apply color immediately on startup + if (TryComp(uid, out var sprite)) + { + _sprite.LayerSetColor((uid, sprite), 0, component.LiquidColor); + } + + if (TryComp(uid, out var light)) + { + _lights.SetColor(uid, component.LiquidColor, light); + } + } +} + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpEggSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpEggSystem.cs new file mode 100644 index 0000000000..7245d142a5 --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpEggSystem.cs @@ -0,0 +1,545 @@ +using System.Numerics; +using Content.Server.Fluids.EntitySystems; +using Content.Server.NPC; +using Content.Server.NPC.HTN; +using Content.Server.NPC.Systems; +using Content.Server.Popups; +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Chemistry.Components; +using Content.Shared.Chemistry.Reagent; +using Content.Shared.Chemistry.EntitySystems; +using Content.Shared.Destructible; +using Content.Shared.FixedPoint; +using Content.Shared.Fluids.Components; +using Content.Shared.RatKing; +using Content.Shared.Humanoid; +using Content.Shared.NPC.Components; +using Content.Shared.NPC.Systems; +using Robust.Shared.Player; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Content.Shared.Maps; +using Robust.Shared.Containers; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Maths; + +namespace Content.Server._Sunrise.CarpQueen; + +public sealed class CarpEggSystem : CarpQueenAccessSystem +{ + [Dependency] private readonly PuddleSystem _puddles = default!; + [Dependency] private readonly IPrototypeManager _protos = default!; + [Dependency] private readonly IRobustRandom _rand = default!; + [Dependency] private readonly NPCSystem _npc = default!; + [Dependency] private readonly SharedMapSystem _map = default!; + [Dependency] private readonly SharedTransformSystem _xformSys = default!; + [Dependency] private readonly SharedContainerSystem _containers = default!; + [Dependency] private readonly HTNSystem _htn = default!; + [Dependency] private readonly CarpQueenSystem _carpQueenSystem = default!; + [Dependency] private readonly EntityLookupSystem _lookup = default!; + [Dependency] private readonly SharedPointLightSystem _lights = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly SharedDestructibleSystem _destructible = default!; + [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly NpcFactionSystem _npcFaction = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnEggDestroyed); + SubscribeLocalEvent(OnEggShutdown); + SubscribeLocalEvent(OnEggMapInit); + SubscribeLocalEvent(OnAnchorChanged); + SubscribeLocalEvent(OnRemovedFromContainer); + SubscribeLocalEvent(OnSolutionChanged); + SubscribeLocalEvent(OnServantStartup); + SubscribeLocalEvent(OnPuddleMapInit); + SubscribeLocalEvent(OnTileChanged); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var egg, out var xform)) + { + // If not currently eligible, periodically re-check conditions to become eligible + if (!egg.Eligible) + { + egg.Accum += frameTime; + egg.WaitElapsed += frameTime; + if (egg.Accum >= egg.CheckInterval) + { + egg.Accum = 0f; + TryHatchCheck(uid, egg); + } + + // If waited too long without liquid, destroy the egg (same as if it was broken) + if (egg.WaitElapsed >= egg.MaxWaitWithoutLiquid) + { + _destructible.DestroyEntity(uid); + continue; + } + continue; + } + + // Eligible: count down to hatch + egg.Accum += frameTime; + if (egg.Accum >= egg.HatchDelay) + { + // Validate still on liquid before hatching + if (TryComp(xform.GridUid, out var grid)) + { + var tile = _map.GetTileRef(xform.GridUid.Value, grid, xform.Coordinates); + if (HasSufficientLiquid(tile, egg.RequiredVolume)) + { + Hatch(uid, egg, xform); + continue; + } + } + + // Conditions no longer valid + egg.Eligible = false; + egg.Accum = 0f; + // Do not reset WaitElapsed here so total wait continues accumulating until liquid appears + ResetVisual(uid); + } + } + } + + private void OnServantStartup(EntityUid uid, CarpQueenServantComponent servant, ComponentStartup args) + { + // Queen present: follow and use her current orders + if (servant.Queen != null && TryComp(servant.Queen.Value, out CarpQueenComponent? queen)) + { + _npc.SetBlackboard(uid, NPCBlackboard.FollowTarget, new EntityCoordinates(servant.Queen.Value, Vector2.Zero)); + // Convert CarpQueenOrderType to RatKingOrderType for HTN compatibility + var ratKingOrder = SharedCarpQueenSystem.ConvertToRatKingOrder(queen.CurrentOrder); + _npc.SetBlackboard(uid, NPCBlackboard.CurrentOrders, ratKingOrder); + _npc.SetBlackboard(uid, "FollowCloseRange", 1.0f); + _npc.SetBlackboard(uid, "FollowRange", 1.5f); + } + else + { + // No queen: default to Loose so directly spawned servants are active + // Convert to RatKingOrderType for HTN compatibility + _npc.SetBlackboard(uid, NPCBlackboard.CurrentOrders, RatKingOrderType.Loose); + } + + // If HTN is already present, force a replan now + if (TryComp(uid, out var htn)) + { + if (htn.Plan != null) + _htn.ShutdownPlan(htn); + _htn.Replan(htn); + } + } + + private void OnEggMapInit(EntityUid uid, CarpEggComponent egg, MapInitEvent args) + { + // Defer until queen is assigned to avoid spawning unlinked servants + if (egg.Queen == null) + return; + + TryHatchCheck(uid, egg); + } + + private void OnAnchorChanged(EntityUid uid, CarpEggComponent egg, ref AnchorStateChangedEvent args) + { + if (args.Anchored) + TryHatchCheck(uid, egg); + } + + private void OnRemovedFromContainer(EntityUid uid, CarpEggComponent egg, EntGotRemovedFromContainerMessage args) + { + TryHatchCheck(uid, egg); + } + + private void OnSolutionChanged(ref SolutionChangedEvent args) + { + // If a puddle changed, re-check eggs on that tile + // Skip if entity is being deleted or doesn't have required components + if (!TryComp(args.Solution.Owner, out var _)) + return; + + // Additional safety check - ensure entity is valid + if (TerminatingOrDeleted(args.Solution.Owner)) + return; + + var xform = Transform(args.Solution.Owner); + if (xform.GridUid == null) + return; + if (!TryComp(xform.GridUid.Value, out var grid)) + return; + var tile = _map.GetTileRef(xform.GridUid.Value, grid, xform.Coordinates); + foreach (var ent in _lookup.GetEntitiesInTile(tile)) + { + if (TryComp(ent, out var egg)) + TryHatchCheck(ent, egg); + } + } + + private void OnPuddleMapInit(EntityUid uid, PuddleComponent puddle, MapInitEvent args) + { + // New puddle spawned: check eggs on this tile + var xform = Transform(uid); + if (xform.GridUid == null) + return; + if (!TryComp(xform.GridUid.Value, out var grid)) + return; + var tile = _map.GetTileRef(xform.GridUid.Value, grid, xform.Coordinates); + foreach (var ent in _lookup.GetEntitiesInTile(tile)) + { + if (TryComp(ent, out var egg)) + TryHatchCheck(ent, egg); + } + } + + private void OnTileChanged(ref TileChangedEvent ev) + { + if (!TryComp(ev.Entity, out var grid)) + return; + + foreach (var change in ev.Changes) + { + var tile = _map.GetTileRef(ev.Entity, grid, change.GridIndices); + foreach (var ent in _lookup.GetEntitiesInTile(tile)) + { + if (TryComp(ent, out var egg)) + TryHatchCheck(ent, egg); + } + } + } + + + + private void TryHatchCheck(EntityUid uid, CarpEggComponent egg) + { + if (!TryComp(uid, out var xform)) + return; + + // Only hatch if not inside containers + if (xform.GridUid == null) + return; + if (_containers.IsEntityInContainer(uid)) + return; + + if (!TryComp(xform.GridUid.Value, out var grid)) + return; + var tile = _map.GetTileRef(xform.GridUid.Value, grid, xform.Coordinates); + + if (HasSufficientLiquid(tile, egg.RequiredVolume)) + { + if (!egg.Eligible) + { + egg.Eligible = true; + egg.Accum = 0f; + egg.WaitElapsed = 0f; + // Show popup locally to queen if present, otherwise to all nearby + if (egg.Queen != null && Exists(egg.Queen.Value)) + _popup.PopupEntity(Loc.GetString("carp-egg-activates"), uid, egg.Queen.Value); + else + _popup.PopupEntity(Loc.GetString("carp-egg-activates"), uid); + } + UpdateVisualForTile(uid, tile); + } + else + { + if (egg.Eligible) + { + egg.Eligible = false; + egg.Accum = 0f; + ResetVisual(uid); + } + } + } + + private bool HasSufficientLiquid(TileRef tile, float required) + { + // Puddle volume check + if (_puddles.TryGetPuddle(tile, out var puddle)) + { + var vol = _puddles.CurrentVolume(puddle); + if (vol >= FixedPoint2.New(required)) + return true; + } + + // Floor water entity check counts as sufficient + var gridId = tile.GridUid; + if (gridId != null) + { + // Check anchored entities first + if (gridId is { } gid && TryComp(gid, out var grid)) + { + var enumerator = _map.GetAnchoredEntitiesEnumerator(gid, grid, tile.GridIndices); + while (enumerator.MoveNext(out EntityUid? ent)) + { + if (!ent.HasValue) + continue; + + // Check by prototype ID + var meta = MetaData(ent.Value); + if (meta.EntityPrototype?.ID == "FloorWaterEntity") + return true; + } + } + + // Also check all entities in tile (fallback) + var entities = _lookup.GetEntitiesInTile(tile); + foreach (var ent in entities) + { + var meta = MetaData(ent); + if (meta.EntityPrototype?.ID == "FloorWaterEntity") + return true; + } + } + + return false; + } + + private void UpdateVisualForTile(EntityUid uid, TileRef tile) + { + Color color; + // Prefer puddle solution color if present + if (_puddles.TryGetPuddle(tile, out var puddle) && TryComp(puddle, out PuddleComponent? puddleComp) && puddleComp.Solution != null) + { + var sol = puddleComp.Solution.Value.Comp.Solution; + color = sol.GetColor(_protos); + } + else + { + // FloorWaterEntity fallback -> use Water reagent color + color = _protos.Index("Water").SubstanceColor; + } + + // Tint light only on server; sprite tint is clientside visualizer concern + _lights.SetColor(uid, color); + _appearance.SetData(uid, CarpEggVisuals.OverlayColor, color); + } + + private void ResetVisual(EntityUid uid) + { + // Reset to white + _lights.SetColor(uid, Color.White); + _appearance.SetData(uid, CarpEggVisuals.OverlayColor, Color.White); + } + + private void Hatch(EntityUid uid, CarpEggComponent egg, TransformComponent xform) + { + // Get tile information for liquid color and reagents + Color liquidColor = Color.White; + Dictionary rememberedReagents = new(); + + if (TryComp(xform.GridUid, out var grid)) + { + var tile = _map.GetTileRef(xform.GridUid.Value, grid, xform.Coordinates); + + // Get liquid color and reagents from puddle or FloorWaterEntity + if (_puddles.TryGetPuddle(tile, out var puddle) && TryComp(puddle, out PuddleComponent? puddleComp) && puddleComp.Solution != null) + { + var sol = puddleComp.Solution.Value.Comp.Solution; + liquidColor = sol.GetColor(_protos); + + // Remember all reagents in the solution + foreach (var (reagentId, quantity) in sol.Contents) + { + rememberedReagents[reagentId.ToString()] = quantity; + } + } + else + { + // FloorWaterEntity fallback -> use Water reagent color + liquidColor = _protos.Index("Water").SubstanceColor; + rememberedReagents["Water"] = FixedPoint2.New(30); // Assume water + } + } + + // Determine spawn prototype: mostly rainbow carp, rarely holo/dungeon + string protoId = "MobCarpServantRainbow"; // Default to rainbow + + if (egg.Queen != null && TryComp(egg.Queen.Value, out CarpQueenComponent? queen)) + { + // Use spawn chances from queen component + var roll = _rand.Next(100); + var cumulative = 0; + var selected = false; + + foreach (var (proto, chance) in queen.SpawnChances) + { + cumulative += chance; + if (roll < cumulative) + { + protoId = proto; + selected = true; + break; + } + } + + // If no spawn chance matched (sum < 100), default to rainbow + if (!selected) + protoId = "MobCarpServantRainbow"; + } + + var mob = Spawn(protoId, xform.Coordinates); + + // Store liquid memory + var memory = EnsureComp(mob); + memory.LiquidColor = liquidColor; + memory.RememberedReagents = rememberedReagents; + + // Check if queen is nearby + bool queenNearby = false; + EntityUid? closestFriend = null; + float closestDistance = float.MaxValue; + + if (egg.Queen != null && Exists(egg.Queen.Value)) + { + var queenXform = Transform(egg.Queen.Value); + var queenCoords = queenXform.Coordinates.ToMap(EntityManager, _xformSys); + var mobCoords = xform.Coordinates.ToMap(EntityManager, _xformSys); + var distance = (queenCoords.Position - mobCoords.Position).Length(); + + // Consider queen "nearby" if within configured range + if (distance <= egg.QueenCheckRange) + queenNearby = true; + } + + // Always remember nearby players (within configured range) + var nearbyEntities = new HashSet(); + _lookup.GetEntitiesInRange(xform.Coordinates, egg.FriendSearchRange, nearbyEntities); + + var exception = EnsureComp(mob); + + foreach (var entity in nearbyEntities) + { + // Check if it's a humanoid (same as MobTomatoKiller uses whitelist with HumanoidAppearanceComponent) + // This will match both players and AI with humanoid appearance + if (HasComp(entity)) + { + memory.RememberedFriends.Add(entity); + + // Add to faction exceptions so they won't be attacked (unless queen orders) + // Use NpcFactionSystem to properly add to ignored list + if (!_npcFaction.IsIgnored((mob, exception), entity)) + { + _npcFaction.IgnoreEntity((mob, exception), (entity, null)); + } + + // Track closest friend for following + var entityXform = Transform(entity); + var entityCoords = entityXform.Coordinates.ToMap(EntityManager, _xformSys); + var mobCoords = xform.Coordinates.ToMap(EntityManager, _xformSys); + var friendDistance = (entityCoords.Position - mobCoords.Position).Length(); + + if (friendDistance < closestDistance) + { + closestDistance = friendDistance; + closestFriend = entity; + } + } + } + + // If queen is nearby, make carp a servant; otherwise, let it work as normal carp + if (queenNearby && egg.Queen != null && Exists(egg.Queen.Value)) + { + // Make carp a servant of the queen + if (TryComp(egg.Queen, out CarpQueenComponent? qc)) + { + var queenUid = egg.Queen.Value; + memory.RememberedFriends.Add(queenUid); + if (!_npcFaction.IsIgnored((mob, exception), queenUid)) + { + _npcFaction.IgnoreEntity((mob, exception), (queenUid, null)); + } + + var comp = EnsureComp(mob); + comp.Queen = egg.Queen; + Dirty(mob, comp); + qc.Servants.Add(mob); + // Remove egg from tracking + qc.Eggs.Remove(uid); + + // Follow queen and execute her commands + _npc.SetBlackboard(mob, NPCBlackboard.FollowTarget, new EntityCoordinates(egg.Queen.Value, Vector2.Zero)); + _carpQueenSystem.UpdateServantNpc(mob, qc.CurrentOrder); + } + } + else + { + // Queen is not nearby - carp works as normal carp (like MobTomatoKiller) + // Remove servant components if they exist + RemComp(mob); + + // Use normal carp HTN compound instead of RatServantCompound + if (TryComp(mob, out var htn)) + { + // Change HTN root task to normal carp behavior + htn.RootTask = new HTNCompoundTask { Task = "DragonCarpCompound" }; + _htn.Replan(htn); + } + + // Set follow target to closest friend if available + if (closestFriend != null) + { + _npc.SetBlackboard(mob, NPCBlackboard.FollowTarget, new EntityCoordinates(closestFriend.Value, Vector2.Zero)); + } + + // Still remove egg from tracking if queen exists + if (TryComp(egg.Queen, out CarpQueenComponent? qc)) + { + qc.Eggs.Remove(uid); + } + } + + // Apply color to carp (will be handled by visualizer system) + Dirty(mob, memory); + QueueDel(uid); + } + + // Public entry-point for other systems (e.g. queen) to re-check hatching conditions + public void RequestHatchCheck(EntityUid uid) + { + if (!TryComp(uid, out CarpEggComponent? egg)) + return; + + TryHatchCheck(uid, egg); + } + + private void OnEggDestroyed(EntityUid uid, CarpEggComponent egg, DestructionEventArgs args) + { + // Spill 2u of a random reagent on destruction + var reagents = _protos.EnumeratePrototypes(); + string chosen = null!; + var count = 0; + foreach (var r in reagents) + { + count++; + if (_rand.Prob(1f / count)) + chosen = r.ID; + } + + if (chosen != null) + { + var sol = new Solution(chosen, FixedPoint2.New(2)); + _puddles.TrySpillAt(uid, sol, out _, sound: false); + } + + // Remove egg from queen tracking + if (egg.Queen != null && TryComp(egg.Queen.Value, out CarpQueenComponent? queen)) + { + queen.Eggs.Remove(uid); + } + } + + private void OnEggShutdown(EntityUid uid, CarpEggComponent egg, ComponentShutdown args) + { + if (egg.Queen != null && TryComp(egg.Queen.Value, out CarpQueenComponent? queen)) + { + queen.Eggs.Remove(uid); + } + } +} + + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpQueenStrengthSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpQueenStrengthSystem.cs new file mode 100644 index 0000000000..49df5ef8bf --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpQueenStrengthSystem.cs @@ -0,0 +1,31 @@ +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.CombatMode; +using Content.Shared.Movement.Components; + +namespace Content.Server._Sunrise.CarpQueen; + +/// +/// System that increases Carp Queen's pushing strength when in combat mode. +/// +public sealed class CarpQueenStrengthSystem : EntitySystem +{ + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnCombatModeChanged); + } + + private void OnCombatModeChanged(EntityUid uid, CarpQueenComponent component, ref CombatModeChangedEvent args) + { + if (!TryComp(uid, out var mobCollision)) + return; + + // Base strength for normal mode, boosted strength for combat mode + const float baseStrength = 50f; + const float combatStrength = 200f; // 4x stronger in combat mode + + mobCollision.Strength = args.IsInCombatMode ? combatStrength : baseStrength; + Dirty(uid, mobCollision); + } +} + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpQueenSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpQueenSystem.cs new file mode 100644 index 0000000000..f5a9a129fb --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpQueenSystem.cs @@ -0,0 +1,237 @@ +using System.Numerics; +using Content.Server.NPC; +using Content.Server.NPC.HTN; +using Content.Server.NPC.Systems; +using Content.Server.Popups; +using Content.Server.Chat.Systems; +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Pointing; +using Content.Shared.Random.Helpers; +using Content.Shared.Dataset; +using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; +using Content.Shared.RatKing; +using Robust.Shared.Map; +using Robust.Shared.Player; +using Content.Shared.NPC.Components; +using Content.Shared.NPC.Systems; +using Content.Shared.Nutrition.Components; +using Content.Shared.Nutrition.EntitySystems; +using Content.Shared.Damage; + +namespace Content.Server._Sunrise.CarpQueen; + +public sealed class CarpQueenSystem : SharedCarpQueenSystem +{ + [Dependency] private readonly NPCSystem _npc = default!; + [Dependency] private readonly HTNSystem _htn = default!; + [Dependency] private readonly CarpEggSystem _carpEggs = default!; + [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly HungerSystem _hunger = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + [Dependency] private readonly NpcFactionSystem _npcFaction = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnSummon); + SubscribeLocalEvent(OnPointedAt); + SubscribeLocalEvent(OnServantShutdown); + } + + protected override void OnStartup(EntityUid uid, CarpQueenComponent component, ComponentStartup args) + { + base.OnStartup(uid, component, args); + + if (TryComp(uid, out var hunger)) + component.LastObservedHunger = _hunger.GetHunger(hunger); + } + + private void OnSummon(EntityUid uid, CarpQueenComponent component, CarpQueenSummonActionEvent args) + { + if (args.Handled) + return; + + if (component.ArmyMobSpawnOptions.Count == 0) + return; + + // Limit total eggs + servants to MaxArmySize. Prune invalid references first. + var toRemoveServants = new List(); + var aliveServants = 0; + foreach (var s in component.Servants) + { + if (!Exists(s)) + { + toRemoveServants.Add(s); + continue; + } + if (TryComp(s, out var mobState) && mobState.CurrentState == MobState.Dead) + continue; + aliveServants++; + } + foreach (var rem in toRemoveServants) + component.Servants.Remove(rem); + + var toRemoveEggs = new List(); + foreach (var e in component.Eggs) + { + if (!Exists(e)) + toRemoveEggs.Add(e); + } + foreach (var rem in toRemoveEggs) + component.Eggs.Remove(rem); + + var eggsCount = component.Eggs.Count; + if (aliveServants + eggsCount >= component.MaxArmySize) + { + _popup.PopupEntity(Loc.GetString("carp-queen-max-army", ("amount", component.MaxArmySize)), uid, uid); + return; + } + + // Hunger cost like Rat King + if (!TryComp(uid, out var hungerComp)) + return; + + if (_hunger.GetHunger(hungerComp) < component.HungerPerSummon) + { + _popup.PopupEntity(Loc.GetString("rat-king-too-hungry"), uid, uid); + return; + } + + args.Handled = true; + _hunger.ModifyHunger(uid, -component.HungerPerSummon, hungerComp); + // Spawn egg instead of immediate servant + var egg = Spawn("MobCarpEgg", Transform(uid).Coordinates); + var eggComp = EnsureComp(egg); + eggComp.Queen = uid; + Dirty(egg, eggComp); + component.Eggs.Add(egg); + + // Trigger hatch check now that queen is assigned (covers tiles already containing liquid/FloorWater) + _carpEggs.RequestHatchCheck(egg); + _popup.PopupEntity(Loc.GetString("carp-queen-summon-popup"), uid, uid); + } + + private void OnServantShutdown(EntityUid uid, CarpQueenServantComponent servant, ComponentShutdown args) + { + if (servant.Queen == null || !TryComp(servant.Queen.Value, out CarpQueenComponent? queen)) + return; + + queen.Servants.Remove(uid); + } + + private void OnPointedAt(EntityUid uid, CarpQueenComponent component, ref AfterPointedAtEvent args) + { + if (component.CurrentOrder != CarpQueenOrderType.Kill) + return; + + var target = args.Pointed; + if (!Exists(target)) + return; + + // Accept any living mob (players or AI). Ignore objects. + var valid = false; + if (TryComp(target, out var mobState)) + valid = mobState.CurrentState != MobState.Dead; + else if (HasComp(target)) + valid = true; + else if (HasComp(target)) + valid = true; + + if (!valid) + return; + + foreach (var servant in component.Servants) + { + // Skip if servant is being deleted or doesn't exist + if (TerminatingOrDeleted(servant)) + continue; + + if (TryComp(servant, out var memory)) + { + var exception = EnsureComp(servant); + + if (_npcFaction.IsIgnored((servant, exception), target)) + _npcFaction.UnignoreEntity((servant, exception), target); + + if (memory.RememberedFriends.Remove(target)) + Dirty(servant, memory); + + if (memory.ForbiddenTargets.Remove(target)) + Dirty(servant, memory); + } + + _npc.SetBlackboard(servant, NPCBlackboard.CurrentOrderedTarget, target); + } + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + // Small self-heal when hunger increases (i.e., when eating). + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var queen, out var hunger)) + { + var current = _hunger.GetHunger(hunger); + if (current > queen.LastObservedHunger) + { + var delta = current - queen.LastObservedHunger; + var heal = MathF.Min(delta * queen.HealPerHunger, queen.MaxHealPerTick); + if (heal > 0f) + { + var spec = new DamageSpecifier(); + spec.DamageDict["Blunt"] = -heal / 2f; + spec.DamageDict["Slash"] = -heal / 2f; + spec.DamageDict["Heat"] = 0f; // leave as 0; can be adjusted later + _damageable.TryChangeDamage(uid, spec, true, false); + } + } + + queen.LastObservedHunger = current; + } + } + + public override void UpdateServantNpc(EntityUid uid, CarpQueenOrderType orderType) + { + base.UpdateServantNpc(uid, orderType); + + // Only update if this is actually a servant (has CarpQueenServantComponent) + if (!TryComp(uid, out var servant) || servant.Queen == null || !Exists(servant.Queen.Value)) + return; + + if (!TryComp(uid, out var htn)) + return; + + if (htn.Plan != null) + _htn.ShutdownPlan(htn); + + // Servant always follows queen and executes her commands + _npc.SetBlackboard(uid, NPCBlackboard.FollowTarget, new EntityCoordinates(servant.Queen.Value, Vector2.Zero)); + + // Configure order and follow distances as requested (close follow ~1 tile) + // Convert CarpQueenOrderType to RatKingOrderType for HTN compatibility + var ratKingOrder = SharedCarpQueenSystem.ConvertToRatKingOrder(orderType); + _npc.SetBlackboard(uid, NPCBlackboard.CurrentOrders, ratKingOrder); + _npc.SetBlackboard(uid, "FollowCloseRange", 1.0f); + _npc.SetBlackboard(uid, "FollowRange", 1.5f); + _htn.Replan(htn); + } + + + public override void DoCommandCallout(EntityUid uid, CarpQueenComponent component) + { + base.DoCommandCallout(uid, component); + + if (!component.OrderCallouts.TryGetValue(component.CurrentOrder, out var datasetId) || + !PrototypeManager.TryIndex(datasetId, out var datasetPrototype)) + return; + + var msg = Random.Pick(datasetPrototype); + _chat.TrySendInGameICMessage(uid, msg, InGameICChatType.Speak, true); + } +} + + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpServantBiteSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpServantBiteSystem.cs new file mode 100644 index 0000000000..40a27d8b2d --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpServantBiteSystem.cs @@ -0,0 +1,57 @@ +using Content.Server.Body.Systems; +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Body.Components; +using Content.Shared.Chemistry.Components; +using Content.Shared.Chemistry.EntitySystems; +using Content.Shared.Chemistry.Reagent; +using Content.Shared.Damage; +using Content.Shared.FixedPoint; +using Content.Shared.Weapons.Melee.Events; +using Robust.Shared.Prototypes; + +namespace Content.Server._Sunrise.CarpQueen; + +/// +/// System that handles carp servant bite mechanics: +/// Injects 1u of each remembered reagent from the liquid the carp hatched from. +/// +public sealed class CarpServantBiteSystem : EntitySystem +{ + [Dependency] private readonly BloodstreamSystem _bloodstream = default!; + [Dependency] private readonly IPrototypeManager _protos = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnMeleeHit); + } + + private void OnMeleeHit(EntityUid uid, CarpServantMemoryComponent memory, MeleeHitEvent args) + { + if (memory.RememberedReagents.Count == 0) + return; + + // Inject 1u of each remembered reagent into each hit target + foreach (var target in args.HitEntities) + { + if (!TryComp(target, out var bloodstream)) + continue; + + // Create solution with configured amount of each remembered reagent + var solution = new Solution(); + foreach (var (reagentId, _) in memory.RememberedReagents) + { + if (_protos.HasIndex(reagentId)) + { + solution.AddReagent(reagentId, memory.BiteReagentAmount); + } + } + + if (solution.Volume > FixedPoint2.Zero) + { + _bloodstream.TryAddToChemicals((target, bloodstream), solution); + } + } + } +} + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpServantDisciplineSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpServantDisciplineSystem.cs new file mode 100644 index 0000000000..fa4a6e6869 --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpServantDisciplineSystem.cs @@ -0,0 +1,103 @@ +using Content.Server.NPC; +using Content.Server.NPC.Components; +using Content.Server.NPC.HTN; +using Content.Server.NPC.Systems; +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Damage; +using Content.Shared.Damage.Components; +using Content.Shared.Inventory; +using Content.Shared.NPC.Components; +using Content.Shared.NPC.Systems; +using Content.Shared.Weapons.Melee.Events; + +namespace Content.Server._Sunrise.CarpQueen; + +/// +/// System that handles discipline for tamed carps (hand-raised carps). +/// If the owner hits their tamed carp with bare hands or gloves, the carp stops attacking its current target. +/// The carp will resume attacking that target if the attacker damages the owner. +/// +public sealed class CarpServantDisciplineSystem : EntitySystem +{ + [Dependency] private readonly NpcFactionSystem _npcFaction = default!; + [Dependency] private readonly NPCSystem _npc = default!; + [Dependency] private readonly InventorySystem _inventory = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnCarpAttacked); + // Note: We don't subscribe to DamageChangedEvent here to avoid duplicate with CarpServantRetaliationSystem + // The discipline logic (removing from forbidden targets) is handled separately + } + + private void OnCarpAttacked(EntityUid uid, CarpServantMemoryComponent memory, AttackedEvent args) + { + // Only handle if this is a tamed carp (not a queen's servant) + if (HasComp(uid)) + return; + + // Check if attacker is one of the remembered friends (owner) + if (!memory.RememberedFriends.Contains(args.User)) + return; + + // Check if attack was made with bare hands or something in gloves slot + // Used == User means no weapon (bare hands) + // Or Used is an item equipped in the gloves slot of the user + var isBareHands = args.Used == args.User; + var isGlovesSlotItem = _inventory.TryGetSlotEntity(args.User, "gloves", out var glovesSlotItem) && glovesSlotItem == args.Used; + + if (!isBareHands && !isGlovesSlotItem) + return; + + // Get current attack target from NPCMeleeCombatComponent or blackboard + EntityUid? currentTarget = null; + NPCMeleeCombatComponent? meleeCombat = null; + HTNComponent? htn = null; + + // Try to get from NPCMeleeCombatComponent first (most direct) + if (TryComp(uid, out meleeCombat) && meleeCombat.Target != EntityUid.Invalid) + { + currentTarget = meleeCombat.Target; + } + // Fallback to blackboard - check common target keys + else if (TryComp(uid, out htn)) + { + // Try CurrentOrderedTarget first (set by queen/orders) + if (htn.Blackboard.TryGetValue(NPCBlackboard.CurrentOrderedTarget, out var orderedTarget, EntityManager) && orderedTarget != EntityUid.Invalid) + currentTarget = orderedTarget; + // Then try generic "Target" key + else if (htn.Blackboard.TryGetValue("Target", out var target, EntityManager) && target != EntityUid.Invalid) + currentTarget = target; + } + + // If there's a current target, add it to forbidden targets + if (currentTarget != null && currentTarget != args.User) + { + memory.ForbiddenTargets.Add(currentTarget.Value); + Dirty(uid, memory); + + // Add to faction exceptions to prevent attack + var exception = EnsureComp(uid); + if (!_npcFaction.IsIgnored((uid, exception), currentTarget.Value)) + { + _npcFaction.IgnoreEntity((uid, exception), (currentTarget.Value, null)); + } + + // Clear the attack target from blackboard/combat component + if (meleeCombat != null) + { + meleeCombat.Target = EntityUid.Invalid; + } + else if (htn != null) + { + // Clear target keys from blackboard + if (htn.Blackboard.ContainsKey("Target")) + _npc.SetBlackboard(uid, "Target", EntityUid.Invalid); + if (htn.Blackboard.ContainsKey(NPCBlackboard.CurrentOrderedTarget)) + _npc.SetBlackboard(uid, NPCBlackboard.CurrentOrderedTarget, EntityUid.Invalid); + } + } + } +} + diff --git a/Content.Server/_Sunrise/CarpQueen/CarpServantRetaliationSystem.cs b/Content.Server/_Sunrise/CarpQueen/CarpServantRetaliationSystem.cs new file mode 100644 index 0000000000..a56925e1b5 --- /dev/null +++ b/Content.Server/_Sunrise/CarpQueen/CarpServantRetaliationSystem.cs @@ -0,0 +1,84 @@ +using Content.Shared._Sunrise.CarpQueen; +using Content.Shared.Damage; +using Content.Shared.Damage.Components; +using Content.Shared.Mobs.Components; +using Content.Shared.NPC.Components; +using Content.Shared.NPC.Systems; + +namespace Content.Server._Sunrise.CarpQueen; + +/// +/// System that makes tamed carps retaliate against entities that damage their remembered friends. +/// +public sealed class CarpServantRetaliationSystem : EntitySystem +{ + [Dependency] private readonly NpcFactionSystem _npcFaction = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnDamageChanged); + } + + private void OnDamageChanged(EntityUid uid, DamageableComponent component, DamageChangedEvent args) + { + // Only react to damage increases + if (!args.DamageIncreased) + return; + + // Get the damaged entity (uid is the entity that received damage) + var damagedEntity = uid; + + // Get the attacker + if (args.Origin is not { } attacker) + return; + + // Don't retaliate against inanimate objects + if (!HasComp(attacker)) + return; + + // Find all carps that remember the damaged entity as a friend + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var carpUid, out var memory)) + { + // Skip if carp is being deleted + if (TerminatingOrDeleted(carpUid)) + continue; + + // Check if the damaged entity is one of this carp's remembered friends + if (!memory.RememberedFriends.Contains(damagedEntity)) + continue; + + var attackerIsFriend = memory.RememberedFriends.Contains(attacker); + var isServant = TryComp(carpUid, out var servant) && servant.Queen != null; + + if (isServant) + { + // Servants only retaliate when the queen is harmed + if (servant!.Queen != damagedEntity) + continue; + } + else + { + // Free-roaming carp do not retaliate against other remembered friends + if (attackerIsFriend) + continue; + } + + // Aggro on the attacker + var exception = EnsureComp(carpUid); + + // Also handle discipline logic: if attacker was in forbidden targets, remove it + // This allows the carp to attack again after the attacker damaged the owner + if (memory.ForbiddenTargets.Remove(attacker)) + { + Dirty(carpUid, memory); + } + + // Unignore and aggro the attacker (allows attack after discipline was cleared) + _npcFaction.UnignoreEntity((carpUid, exception), attacker); + _npcFaction.AggroEntity((carpUid, exception), (attacker, null)); + } + } +} + diff --git a/Content.Shared/CombatMode/SharedCombatModeSystem.cs b/Content.Shared/CombatMode/SharedCombatModeSystem.cs index d5452524ce..709c420038 100644 --- a/Content.Shared/CombatMode/SharedCombatModeSystem.cs +++ b/Content.Shared/CombatMode/SharedCombatModeSystem.cs @@ -87,6 +87,11 @@ public abstract class SharedCombatModeSystem : EntitySystem component.IsInCombatMode = value; Dirty(entity, component); + // Sunrise-Start + var ev = new CombatModeChangedEvent(value); + RaiseLocalEvent(entity, ref ev); + // Sunrise-End + if (component.CombatToggleActionEntity != null) _actionsSystem.SetToggled(component.CombatToggleActionEntity, component.IsInCombatMode); @@ -129,3 +134,9 @@ public sealed partial class ToggleCombatActionEvent : InstantActionEvent { } + +/// +/// Raised when combat mode changes for an entity. +/// +[ByRefEvent] +public readonly record struct CombatModeChangedEvent(bool IsInCombatMode); diff --git a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs index e69f0c2f7a..23c970ec5e 100644 --- a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs +++ b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs @@ -120,6 +120,23 @@ public sealed partial class NpcFactionSystem tracker.Entities.Remove(ent); } + // Sunrise-Start + /// + /// Makes an entity no longer be ignored, if it was. + /// Allows the NPC to attack the entity again. + /// + public void UnignoreEntity(Entity ent, EntityUid target) + { + if (!Resolve(ent, ref ent.Comp, false)) + return; + + if (!ent.Comp.Ignored.Remove(target) || !_trackerQuery.TryGetComponent(target, out var tracker)) + return; + + tracker.Entities.Remove(ent); + } + // Sunrise-End + /// /// Makes a list of entities no longer be considered hostile, if it was. /// Doesn't apply to regular faction hostilities. diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpEggComponent.cs b/Content.Shared/_Sunrise/CarpQueen/CarpEggComponent.cs new file mode 100644 index 0000000000..a8203b3d59 --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpEggComponent.cs @@ -0,0 +1,54 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Sunrise.CarpQueen; + +[RegisterComponent, NetworkedComponent] +public sealed partial class CarpEggComponent : Component +{ + [DataField("queen")] public EntityUid? Queen; + + /// + /// Required puddle volume (u) to hatch. + /// + [DataField("requiredVolume")] public float RequiredVolume = 15f; + + /// + /// Seconds between hatch checks. + /// + [DataField("checkInterval")] public float CheckInterval = 3f; + + [DataField("accum")] public float Accum; + + /// + /// Seconds the egg must remain on valid liquid before hatching. + /// + [DataField("hatchDelay")] public float HatchDelay = 5f; + + /// + /// Whether current tile conditions are sufficient for hatching. + /// + [DataField("eligible")] public bool Eligible; + + /// + /// Accumulated time spent waiting without valid liquid. If exceeds MaxWaitWithoutLiquid, egg breaks. + /// + [DataField("waitElapsed")] public float WaitElapsed; + + /// + /// Max seconds to wait for liquid to appear before breaking the egg. + /// + [DataField("maxWaitWithoutLiquid")] public float MaxWaitWithoutLiquid = 30f; + + /// + /// Range (in tiles) to check if queen is nearby when hatching. + /// If queen is within this range, carp becomes servant; otherwise, it imprints on nearby players. + /// + [DataField("queenCheckRange")] public float QueenCheckRange = 3f; + + /// + /// Range (in tiles) to search for nearby players to imprint on when queen is not nearby. + /// + [DataField("friendSearchRange")] public float FriendSearchRange = 3f; +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpEggVisuals.cs b/Content.Shared/_Sunrise/CarpQueen/CarpEggVisuals.cs new file mode 100644 index 0000000000..f7142999ba --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpEggVisuals.cs @@ -0,0 +1,11 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared._Sunrise.CarpQueen; + +[Serializable, NetSerializable] +public enum CarpEggVisuals +{ + OverlayColor +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpQueenAccessSystem.cs b/Content.Shared/_Sunrise/CarpQueen/CarpQueenAccessSystem.cs new file mode 100644 index 0000000000..73fc95be25 --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpQueenAccessSystem.cs @@ -0,0 +1,11 @@ +namespace Content.Shared._Sunrise.CarpQueen; + +/// +/// Marker system that grants access permissions to mutate Carp Queen components from server systems. +/// Server systems that need write access should inherit from this. +/// +public abstract class CarpQueenAccessSystem : EntitySystem +{ +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpQueenActionEvents.cs b/Content.Shared/_Sunrise/CarpQueen/CarpQueenActionEvents.cs new file mode 100644 index 0000000000..86acc434c9 --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpQueenActionEvents.cs @@ -0,0 +1,31 @@ +using Content.Shared.Actions; +using Robust.Shared.Serialization; + +namespace Content.Shared._Sunrise.CarpQueen; + +public sealed partial class CarpQueenSummonActionEvent : InstantActionEvent +{ +} + +/// +/// Event for carp queen order actions (Stay, Follow, Kill, Loose). +/// +public sealed partial class CarpQueenOrderActionEvent : InstantActionEvent +{ + /// + /// The type of order being given + /// + [DataField("type")] + public CarpQueenOrderType Type; +} + +[Serializable, NetSerializable] +public enum CarpQueenOrderType : byte +{ + Stay, + Follow, + Kill, + Loose +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpQueenComponent.cs b/Content.Shared/_Sunrise/CarpQueen/CarpQueenComponent.cs new file mode 100644 index 0000000000..f25f690e97 --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpQueenComponent.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using Content.Shared.Actions; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Shared._Sunrise.CarpQueen; + +[RegisterComponent, NetworkedComponent, Access(typeof(SharedCarpQueenSystem), typeof(CarpQueenAccessSystem))] +[AutoGenerateComponentState] +public sealed partial class CarpQueenComponent : Component +{ + [DataField("actionSummon", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ActionSummon = "ActionCarpQueenSummon"; + + [DataField("actionSummonEntity")] + public EntityUid? ActionSummonEntity; + + [DataField("actionOrderStay", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ActionOrderStay = "ActionCarpQueenOrderStay"; + + [DataField("actionOrderStayEntity")] + public EntityUid? ActionOrderStayEntity; + + [DataField("actionOrderFollow", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ActionOrderFollow = "ActionCarpQueenOrderFollow"; + + [DataField("actionOrderFollowEntity")] + public EntityUid? ActionOrderFollowEntity; + + [DataField("actionOrderKill", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ActionOrderKill = "ActionCarpQueenOrderKill"; + + [DataField("actionOrderKillEntity")] + public EntityUid? ActionOrderKillEntity; + + [DataField("actionOrderLoose", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string ActionOrderLoose = "ActionCarpQueenOrderLoose"; + + [DataField("actionOrderLooseEntity")] + public EntityUid? ActionOrderLooseEntity; + + /// + /// Current order applied to servants. + /// + [DataField("currentOrders"), AutoNetworkedField] + public CarpQueenOrderType CurrentOrder = CarpQueenOrderType.Loose; + + /// + /// List of spawned servants controlled by the queen. + /// + [DataField("servants")] + public HashSet Servants = new(); + + /// + /// Active eggs spawned by the queen (not yet hatched). + /// + [DataField("eggs")] + public HashSet Eggs = new(); + + /// + /// Pool of carp servant prototype IDs to randomly pick from when summoning. + /// + [DataField("armyMobSpawnOptions")] + public List ArmyMobSpawnOptions = new() + { + "MobCarpServantDungeon", + "MobCarpServantMagic", + "MobCarpServantHolo", + "MobCarpServantRainbow", + "MobCarpServantDragon" + }; + + /// + /// Dataset mapping for order callouts (spoken lines on order change). + /// + [DataField("orderCallouts")] + public Dictionary OrderCallouts = new() + { + { CarpQueenOrderType.Stay, "CarpQueenCommandStay" }, + { CarpQueenOrderType.Follow, "CarpQueenCommandFollow" }, + { CarpQueenOrderType.Kill, "CarpQueenCommandKill" }, + { CarpQueenOrderType.Loose, "CarpQueenCommandLoose" } + }; + + /// + /// Hunger consumed per summon use. + /// + [DataField("hungerPerSummon")] + public float HungerPerSummon = 25f; + + /// + /// Tracks last observed hunger to grant small healing when eating. + /// Server-side only; not networked. + /// + public float LastObservedHunger; + + /// + /// Maximum total servants + eggs the queen can have at once. + /// + [DataField("maxArmySize")] + public int MaxArmySize = 5; + + /// + /// HP healed per 1 unit of hunger gained (when eating). + /// + [DataField("healPerHunger")] + public float HealPerHunger = 0.2f; + + /// + /// Maximum HP healed per tick from eating. + /// + [DataField("maxHealPerTick")] + public float MaxHealPerTick = 5f; + + /// + /// Spawn chances for different carp types when hatching from egg. + /// Key: prototype ID, Value: chance (0-100). + /// If sum is less than 100, remaining chance goes to default (MobCarpServantRainbow). + /// + [DataField("spawnChances")] + public Dictionary SpawnChances = new() + { + { "MobCarpServantRainbow", 80 }, + { "MobCarpServantHolo", 10 }, + { "MobCarpServantDungeon", 10 } + }; +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpQueenServantComponent.cs b/Content.Shared/_Sunrise/CarpQueen/CarpQueenServantComponent.cs new file mode 100644 index 0000000000..15942f1c3b --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpQueenServantComponent.cs @@ -0,0 +1,13 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Sunrise.CarpQueen; + +[RegisterComponent, NetworkedComponent, Access(typeof(SharedCarpQueenSystem), typeof(CarpQueenAccessSystem))] +[AutoGenerateComponentState] +public sealed partial class CarpQueenServantComponent : Component +{ + [DataField("queen"), AutoNetworkedField] + public EntityUid? Queen; +} + + diff --git a/Content.Shared/_Sunrise/CarpQueen/CarpServantMemoryComponent.cs b/Content.Shared/_Sunrise/CarpQueen/CarpServantMemoryComponent.cs new file mode 100644 index 0000000000..e3ff74008c --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/CarpServantMemoryComponent.cs @@ -0,0 +1,52 @@ +using Content.Shared.Chemistry.Reagent; +using Content.Shared.FixedPoint; +using Robust.Shared.GameStates; +using Robust.Shared.Maths; +using Robust.Shared.Serialization; + +namespace Content.Shared._Sunrise.CarpQueen; + +/// +/// Component that stores memory of the liquid the carp hatched from, +/// including its color and reagents for injection on bite. +/// +[RegisterComponent, NetworkedComponent] +[AutoGenerateComponentState] +public sealed partial class CarpServantMemoryComponent : Component +{ + /// + /// Color of the liquid the carp hatched from. + /// Used for visual appearance. + /// + [DataField("liquidColor"), AutoNetworkedField] + public Color LiquidColor = Color.White; + + /// + /// Dictionary of reagent IDs and their amounts that were in the liquid. + /// Used for injection on bite. + /// + [DataField("rememberedReagents"), AutoNetworkedField] + public Dictionary RememberedReagents = new(); + + /// + /// Amount of each remembered reagent to inject per bite (in units). + /// + [DataField("biteReagentAmount")] + public FixedPoint2 BiteReagentAmount = FixedPoint2.New(1); + + /// + /// List of players that were nearby when the carp hatched. + /// These players are considered "friends" and won't be attacked + /// unless the queen orders it. + /// + [DataField("rememberedFriends"), AutoNetworkedField] + public HashSet RememberedFriends = new(); + + /// + /// List of entities that the carp is temporarily forbidden to attack. + /// These are cleared when the attacker damages the carp's owner. + /// + [DataField("forbiddenTargets")] + public HashSet ForbiddenTargets = new(); +} + diff --git a/Content.Shared/_Sunrise/CarpQueen/SharedCarpQueenSystem.cs b/Content.Shared/_Sunrise/CarpQueen/SharedCarpQueenSystem.cs new file mode 100644 index 0000000000..b863b0dc8a --- /dev/null +++ b/Content.Shared/_Sunrise/CarpQueen/SharedCarpQueenSystem.cs @@ -0,0 +1,114 @@ +using Content.Shared.Actions; +using Content.Shared.Actions.Components; +using Content.Shared.RatKing; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Shared._Sunrise.CarpQueen; + +public abstract class SharedCarpQueenSystem : EntitySystem +{ + [Dependency] protected readonly IPrototypeManager PrototypeManager = default!; + [Dependency] protected readonly IRobustRandom Random = default!; + [Dependency] private readonly SharedActionsSystem _actions = default!; + + public override void Initialize() + { + SubscribeLocalEvent(OnStartup); + SubscribeLocalEvent(OnShutdown); + SubscribeLocalEvent(OnOrderAction); + } + + protected virtual void OnStartup(EntityUid uid, CarpQueenComponent component, ComponentStartup args) + { + if (!TryComp(uid, out ActionsComponent? comp)) + return; + + _actions.AddAction(uid, ref component.ActionSummonEntity, component.ActionSummon, component: comp); + _actions.AddAction(uid, ref component.ActionOrderStayEntity, component.ActionOrderStay, component: comp); + _actions.AddAction(uid, ref component.ActionOrderFollowEntity, component.ActionOrderFollow, component: comp); + _actions.AddAction(uid, ref component.ActionOrderKillEntity, component.ActionOrderKill, component: comp); + _actions.AddAction(uid, ref component.ActionOrderLooseEntity, component.ActionOrderLoose, component: comp); + + UpdateActions(uid, component); + } + + private void OnShutdown(EntityUid uid, CarpQueenComponent component, ComponentShutdown args) + { + foreach (var servant in component.Servants) + { + if (TryComp(servant, out CarpQueenServantComponent? servantComp)) + servantComp.Queen = null; + } + + if (!TryComp(uid, out ActionsComponent? comp)) + return; + + var actions = new Entity(uid, comp); + _actions.RemoveAction(actions, component.ActionSummonEntity); + _actions.RemoveAction(actions, component.ActionOrderStayEntity); + _actions.RemoveAction(actions, component.ActionOrderFollowEntity); + _actions.RemoveAction(actions, component.ActionOrderKillEntity); + _actions.RemoveAction(actions, component.ActionOrderLooseEntity); + } + + private void OnOrderAction(EntityUid uid, CarpQueenComponent component, CarpQueenOrderActionEvent args) + { + if (component.CurrentOrder == args.Type) + return; + + args.Handled = true; + component.CurrentOrder = args.Type; + Dirty(uid, component); + + UpdateActions(uid, component); + UpdateAllServants(uid, component); + DoCommandCallout(uid, component); + } + + private void UpdateActions(EntityUid uid, CarpQueenComponent component) + { + _actions.SetToggled(component.ActionOrderStayEntity, component.CurrentOrder == CarpQueenOrderType.Stay); + _actions.SetToggled(component.ActionOrderFollowEntity, component.CurrentOrder == CarpQueenOrderType.Follow); + _actions.SetToggled(component.ActionOrderKillEntity, component.CurrentOrder == CarpQueenOrderType.Kill); + _actions.SetToggled(component.ActionOrderLooseEntity, component.CurrentOrder == CarpQueenOrderType.Loose); + _actions.StartUseDelay(component.ActionOrderStayEntity); + _actions.StartUseDelay(component.ActionOrderFollowEntity); + _actions.StartUseDelay(component.ActionOrderKillEntity); + _actions.StartUseDelay(component.ActionOrderLooseEntity); + } + + public void UpdateAllServants(EntityUid uid, CarpQueenComponent component) + { + foreach (var servant in component.Servants) + { + UpdateServantNpc(servant, component.CurrentOrder); + } + } + + public virtual void UpdateServantNpc(EntityUid uid, CarpQueenOrderType orderType) + { + } + + public virtual void DoCommandCallout(EntityUid uid, CarpQueenComponent component) + { + } + + /// + /// Converts CarpQueenOrderType to RatKingOrderType for HTN compatibility. + /// HTN compounds use RatKingOrderType, so we need to map our order types to them. + /// + public static RatKingOrderType ConvertToRatKingOrder(CarpQueenOrderType orderType) + { + return orderType switch + { + CarpQueenOrderType.Stay => RatKingOrderType.Stay, + CarpQueenOrderType.Follow => RatKingOrderType.Follow, + CarpQueenOrderType.Kill => RatKingOrderType.CheeseEm, + CarpQueenOrderType.Loose => RatKingOrderType.Loose, + _ => RatKingOrderType.Loose + }; + } +} + + diff --git a/Resources/Locale/en-US/_prototypes/_sunrise/actions/carp_queen.ftl b/Resources/Locale/en-US/_prototypes/_sunrise/actions/carp_queen.ftl new file mode 100644 index 0000000000..8a3ffbdf65 --- /dev/null +++ b/Resources/Locale/en-US/_prototypes/_sunrise/actions/carp_queen.ftl @@ -0,0 +1,14 @@ +ent-ActionCarpQueenSummon = Lay roe + .desc = Lays eggs that hatch into a random carp when exposed to liquid. +ent-ActionCarpQueenOrderStay = Rest + .desc = Commands the servant carps to ignore threats. +ent-ActionCarpQueenOrderFollow = Follow + .desc = Commands the servant carps to follow you closely. +ent-ActionCarpQueenOrderKill = Kill it + .desc = Commands the servant carps to attack the target you indicate. +ent-ActionCarpQueenOrderLoose = Free + .desc = Commands the servant carps to act according to the will of Mother Nature. + +carp-queen-summon-popup = You have laid roe! + + diff --git a/Resources/Locale/en-US/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl b/Resources/Locale/en-US/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl index a460902719..9f33875000 100644 --- a/Resources/Locale/en-US/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl +++ b/Resources/Locale/en-US/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl @@ -3,3 +3,6 @@ ent-SpawnPointGhostFoliant = ghost role spawn point .desc = { ent-MarkerBase.desc } ent-SpawnPointGhostTerminator = terminator spawn point .desc = { ent-MarkerBase.desc } +ent-SpawnPointGhostCarpQueen = { ent-SpawnPointGhostFoliant } + .suffix = carp queen + .desc = { ent-MarkerBase.desc } diff --git a/Resources/Locale/en-US/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl b/Resources/Locale/en-US/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl new file mode 100644 index 0000000000..6309e63ac2 --- /dev/null +++ b/Resources/Locale/en-US/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl @@ -0,0 +1,9 @@ +ent-MobCarpQueen = carp queen + .desc = The sovereign of the shoal. + .suffix = { ent-MobHellspawnGhostRole.suffix } + +ghost-role-information-carp-queen-name = Carp Queen +ghost-role-information-carp-queen-description = Command your school of carps, hatching them from roes in the water, and make them do everything your majesty commands. + +carp-egg-activates = Roe is starting to throb! +carp-queen-max-army = Maximum { $amount } servants and roes combined. diff --git a/Resources/Locale/en-US/_prototypes/_sunrise/entities/objects/misc/eggs.ftl b/Resources/Locale/en-US/_prototypes/_sunrise/entities/objects/misc/eggs.ftl index 5ca0849d82..11d376f2a3 100644 --- a/Resources/Locale/en-US/_prototypes/_sunrise/entities/objects/misc/eggs.ftl +++ b/Resources/Locale/en-US/_prototypes/_sunrise/entities/objects/misc/eggs.ftl @@ -1,2 +1,5 @@ ent-EggSpiderFertilized = egg spider .desc = Is it a gemstone? Is it an egg? It looks expensive. + +ent-MobCarpEgg = carp egg + .desc = A pulsing mass, waiting to hatch. diff --git a/Resources/Locale/en-US/datasets/carp_queen_commands.ftl b/Resources/Locale/en-US/datasets/carp_queen_commands.ftl new file mode 100644 index 0000000000..85d0721613 --- /dev/null +++ b/Resources/Locale/en-US/datasets/carp_queen_commands.ftl @@ -0,0 +1,13 @@ +carp-queen-command-stay-1 = Grr... +carp-queen-command-stay-2 = Grrrr... +carp-queen-command-stay-3 = Grk... grr... +carp-queen-command-follow-1 = Grr! Grr! +carp-queen-command-follow-2 = Grr-rr... grr. +carp-queen-command-kill-1 = GRR! +carp-queen-command-kill-2 = Grr-RAH! +carp-queen-command-kill-3 = Grrr... RAH! +carp-queen-command-kill-4 = GRRRRR! +carp-queen-command-loose-1 = Grrr... +carp-queen-command-loose-2 = Gr-rr... + + diff --git a/Resources/Locale/ru-RU/_prototypes/_sunrise/actions/carp_queen.ftl b/Resources/Locale/ru-RU/_prototypes/_sunrise/actions/carp_queen.ftl new file mode 100644 index 0000000000..eead078328 --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_sunrise/actions/carp_queen.ftl @@ -0,0 +1,12 @@ +ent-ActionCarpQueenSummon = Отложить икру + .desc = Откладывает икру, которая при попадании в жидкость вылупляется в случайного карпа. +ent-ActionCarpQueenOrderStay = Отдых + .desc = Приказывает карпам-слугам игнорировать угрозы. +ent-ActionCarpQueenOrderFollow = Следуй + .desc = Приказывает карпам-слугам следовать вплотную за вами. +ent-ActionCarpQueenOrderKill = Убейте его + .desc = Приказывает карпам-слугам атаковать того, на кого вы укажете. +ent-ActionCarpQueenOrderLoose = Вольно + .desc = Приказывает карпам-слугам следовать по велению матушки-природы. + +carp-queen-summon-popup = Вы отложили икру! diff --git a/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl index dda825dedc..eb0f572566 100644 --- a/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl +++ b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/markers/spawners/ghost_roles.ftl @@ -3,3 +3,6 @@ ent-SpawnPointGhostFoliant = точка спавна призрачной рол .desc = { ent-MarkerBase.desc } ent-SpawnPointGhostTerminator = точка спавна терминатора .desc = { ent-MarkerBase.desc } +ent-SpawnPointGhostCarpQueen = { ent-SpawnPointGhostFoliant } + .suffix = королева карпов + .desc = { ent-MarkerBase.desc } diff --git a/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl new file mode 100644 index 0000000000..3bdd4784b5 --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/mobs/npcs/carp_queen.ftl @@ -0,0 +1,9 @@ +ent-MobCarpQueen = королева карпов + .desc = Повелительница стаи карпов. + .suffix = { ent-MobHellspawnGhostRole.suffix } + +ghost-role-information-carp-queen-name = Королева карпов +ghost-role-information-carp-queen-description = Управляйте своей стаей карпов, вырастив их из икринок в воде, и заставляйте выполнять всё, что велит ваше величество. + +carp-egg-activates = Икра начинает пульсировать! +carp-queen-max-army = Максимум { $amount } слуг и икринок вместе. diff --git a/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/objects/misc/eggs.ftl b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/objects/misc/eggs.ftl index d6613cc1de..12131534a7 100644 --- a/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/objects/misc/eggs.ftl +++ b/Resources/Locale/ru-RU/_prototypes/_sunrise/entities/objects/misc/eggs.ftl @@ -1,2 +1,5 @@ ent-EggSpiderFertilized = яйцо паука .desc = Это драгоценный камень? Или яйцо? Выглядит дорого. + +ent-MobCarpEgg = икра карпа + .desc = Пульсирующая масса, ожидающая вылупления. diff --git a/Resources/Locale/ru-RU/datasets/carp_queen_commands.ftl b/Resources/Locale/ru-RU/datasets/carp_queen_commands.ftl new file mode 100644 index 0000000000..cd02f7eb08 --- /dev/null +++ b/Resources/Locale/ru-RU/datasets/carp_queen_commands.ftl @@ -0,0 +1,13 @@ +carp-queen-command-stay-1 = Ррр... +carp-queen-command-stay-2 = Гррр... +carp-queen-command-stay-3 = Ррк... грр... +carp-queen-command-follow-1 = ГРР! ГРР! +carp-queen-command-follow-2 = Грр-рр... грр. +carp-queen-command-kill-1 = ГРР! +carp-queen-command-kill-2 = Грр-РА! +carp-queen-command-kill-3 = Гррр... РА! +carp-queen-command-kill-4 = ГРРРРР! +carp-queen-command-loose-1 = Гррр... +carp-queen-command-loose-2 = Гр-рр... + + diff --git a/Resources/Prototypes/_Sunrise/Actions/carp_queen.yml b/Resources/Prototypes/_Sunrise/Actions/carp_queen.yml new file mode 100644 index 0000000000..da4042b359 --- /dev/null +++ b/Resources/Prototypes/_Sunrise/Actions/carp_queen.yml @@ -0,0 +1,91 @@ +- type: entity + parent: BaseAction + id: ActionCarpQueenSummon + name: carp-queen-action-summon + description: carp-queen-action-summon-desc + components: + - type: Action + useDelay: 3 + icon: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: carpQueenEgg + - type: InstantAction + event: !type:CarpQueenSummonActionEvent + +- type: entity + parent: BaseAction + id: ActionCarpQueenOrderStay + name: carp-queen-action-stay + description: carp-queen-action-stay-desc + components: + - type: Action + useDelay: 1 + icon: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: stayOff + iconOn: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: stay + priority: 5 + - type: InstantAction + event: !type:CarpQueenOrderActionEvent + type: Stay + +- type: entity + parent: BaseAction + id: ActionCarpQueenOrderFollow + name: carp-queen-action-follow + description: carp-queen-action-follow-desc + components: + - type: Action + useDelay: 1 + icon: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: followOff + iconOn: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: follow + priority: 6 + - type: InstantAction + event: !type:CarpQueenOrderActionEvent + type: Follow + +- type: entity + parent: BaseAction + id: ActionCarpQueenOrderKill + name: carp-queen-action-kill + description: carp-queen-action-kill-desc + components: + - type: Action + useDelay: 1 + icon: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: attackOff + iconOn: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: attack + priority: 7 + - type: InstantAction + event: !type:CarpQueenOrderActionEvent + type: Kill + +- type: entity + parent: BaseAction + id: ActionCarpQueenOrderLoose + name: carp-queen-action-loose + description: carp-queen-action-loose-desc + components: + - type: Action + useDelay: 1 + icon: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: looseOff + iconOn: + sprite: _Sunrise/Interface/Actions/actions_carp_queen.rsi + state: loose + priority: 8 + - type: InstantAction + event: !type:CarpQueenOrderActionEvent + type: Loose + + diff --git a/Resources/Prototypes/_Sunrise/Datasets/carp_queen_commands.yml b/Resources/Prototypes/_Sunrise/Datasets/carp_queen_commands.yml new file mode 100644 index 0000000000..9abd363973 --- /dev/null +++ b/Resources/Prototypes/_Sunrise/Datasets/carp_queen_commands.yml @@ -0,0 +1,25 @@ +- type: localizedDataset + id: CarpQueenCommandStay + values: + prefix: carp-queen-command-stay- + count: 3 + +- type: localizedDataset + id: CarpQueenCommandFollow + values: + prefix: carp-queen-command-follow- + count: 2 + +- type: localizedDataset + id: CarpQueenCommandKill + values: + prefix: carp-queen-command-kill- + count: 4 + +- type: localizedDataset + id: CarpQueenCommandLoose + values: + prefix: carp-queen-command-loose- + count: 2 + + diff --git a/Resources/Prototypes/_Sunrise/Entities/Markers/Spawners/ghost_roles.yml b/Resources/Prototypes/_Sunrise/Entities/Markers/Spawners/ghost_roles.yml index 270c1f0acf..7d2b9cf0ce 100644 --- a/Resources/Prototypes/_Sunrise/Entities/Markers/Spawners/ghost_roles.yml +++ b/Resources/Prototypes/_Sunrise/Entities/Markers/Spawners/ghost_roles.yml @@ -45,3 +45,25 @@ - state: green - sprite: Mobs/Species/Terminator/parts.rsi state: full + +- type: entity + id: SpawnPointGhostCarpQueen + parent: SpawnPointGhostFoliant + suffix: carp queen + components: + - type: GhostRole + name: ghost-role-information-carp-queen-name + description: ghost-role-information-carp-queen-description + rules: ghost-role-information-freeagent-rules + mindRoles: + - MindRoleGhostRoleSoloAntagonist + raffle: + settings: default + - type: GhostRoleMobSpawner + prototype: MobCarpQueen + - type: Sprite + sprite: Markers/jobs.rsi + layers: + - state: green + - sprite: Mobs/Aliens/Carps/sharkminnow.rsi + state: icon \ No newline at end of file diff --git a/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_queen.yml b/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_queen.yml new file mode 100644 index 0000000000..d4bf440233 --- /dev/null +++ b/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_queen.yml @@ -0,0 +1,44 @@ +- type: entity + id: MobCarpQueen + name: carp queen + parent: MobShark + description: The queen of carps, commanding a vicious shoal. + suffix: Ghost Role + components: + - type: Actions + - type: GhostRole + makeSentient: true + allowMovement: true + allowSpeech: true + name: ghost-role-information-carp-queen-name + description: ghost-role-information-carp-queen-description + rules: ghost-role-information-freeagent-rules + mindRoles: + - MindRoleGhostRoleSoloAntagonist + raffle: + settings: default + - type: GhostTakeoverAvailable + - type: Tag + tags: + - DoorBumpOpener + - type: CarpQueen + hungerPerSummon: 25 + maxArmySize: 5 + healPerHunger: 0.2 + maxHealPerTick: 5 + spawnChances: + MobCarpServantRainbow: 80 + MobCarpServantHolo: 10 + MobCarpServantDungeon: 10 + - type: ReplacementAccent + remove: true + - type: Accentless + removes: + - type: ReplacementAccent + accent: genericAggressive + - type: TypingIndicator + proto: slime + - type: Hunger + - type: Thirst + + diff --git a/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_servants.yml b/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_servants.yml new file mode 100644 index 0000000000..faef41c35a --- /dev/null +++ b/Resources/Prototypes/_Sunrise/Entities/Mobs/NPCs/carp_servants.yml @@ -0,0 +1,48 @@ +- type: entity + id: BaseMobCarpServant + abstract: true + components: + - type: CarpQueenServant + - type: CarpServantMemory + biteReagentAmount: 1 + - type: MeleeWeapon + damage: + types: + Blunt: 2 + Slash: 3 + - type: HTN + rootTask: + task: RatServantCompound + blackboard: + IdleRange: !type:Single + 3.5 + FollowCloseRange: !type:Single + 2.0 + FollowRange: !type:Single + 3.0 + +- type: entity + id: MobCarpServantDungeon + parent: [ BaseMobCarpServant, MobCarpDungeon ] + +- type: entity + id: MobCarpServantMagic + parent: [ BaseMobCarpServant, MobCarpMagic ] + +- type: entity + id: MobCarpServantHolo + parent: [ BaseMobCarpServant, MobCarpHolo ] + +- type: entity + id: MobCarpServantRainbow + parent: [ BaseMobCarpServant, MobCarpRainbow ] + components: + # Remove RgbLightController to allow fixed color from liquid + - type: RgbLightController + remove: true + +- type: entity + id: MobCarpServantDragon + parent: [ BaseMobCarpServant, MobCarpDragon ] + + diff --git a/Resources/Prototypes/_Sunrise/Entities/Objects/Misc/carp_egg.yml b/Resources/Prototypes/_Sunrise/Entities/Objects/Misc/carp_egg.yml new file mode 100644 index 0000000000..f1ef8ccf24 --- /dev/null +++ b/Resources/Prototypes/_Sunrise/Entities/Objects/Misc/carp_egg.yml @@ -0,0 +1,44 @@ +- type: entity + parent: FoodEggBase + id: MobCarpEgg + name: carp egg + description: A pulsing mass, waiting to hatch. + components: + - type: Appearance + - type: Sprite + layers: + - sprite: _Sunrise/Objects/Misc/egg_carp_queen.rsi + state: icon + map: [ base ] + - sprite: _Sunrise/Objects/Misc/egg_carp_queen.rsi + state: overlay-icon + map: [ overlay ] + - type: Item + sprite: _Sunrise/Objects/Misc/egg_carp_queen.rsi + size: Tiny + - type: PointLight + radius: 1.5 + energy: 3 + color: "#4faffb" + - type: CarpEgg + requiredVolume: 15 + checkInterval: 3 + hatchDelay: 5 + maxWaitWithoutLiquid: 30 + queenCheckRange: 3 + friendSearchRange: 3 + - type: Destructible + thresholds: + - trigger: !type:DamageTrigger + damage: 1 + behaviors: + - !type:PlaySoundBehavior + sound: + collection: desecration + - !type:SpillBehavior + solution: food + - !type:DoActsBehavior + acts: [ "Destruction" ] + + + diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attack.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attack.png new file mode 100644 index 0000000000..0356b6995a Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attack.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attackOff.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attackOff.png new file mode 100644 index 0000000000..153897219e Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/attackOff.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/carpQueenEgg.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/carpQueenEgg.png new file mode 100644 index 0000000000..61ea8907a5 Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/carpQueenEgg.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/follow.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/follow.png new file mode 100644 index 0000000000..b3a3b0194a Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/follow.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/followOff.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/followOff.png new file mode 100644 index 0000000000..1adeb7a43e Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/followOff.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/loose.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/loose.png new file mode 100644 index 0000000000..3b39253539 Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/loose.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/looseOff.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/looseOff.png new file mode 100644 index 0000000000..be9b95b004 Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/looseOff.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/meta.json b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/meta.json new file mode 100644 index 0000000000..499e187add --- /dev/null +++ b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/meta.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by KAVALDi, modified from actions_rat_king.rsi and carp.rsi", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "carpQueenEgg" + }, + { + "name": "attack" + }, + { + "name": "attackOff" + }, + { + "name": "follow" + }, + { + "name": "followOff" + }, + { + "name": "loose" + }, + { + "name": "looseOff" + }, + { + "name": "stay" + }, + { + "name": "stayOff" + } + ] +} diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stay.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stay.png new file mode 100644 index 0000000000..19e70781a2 Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stay.png differ diff --git a/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stayOff.png b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stayOff.png new file mode 100644 index 0000000000..97fb31ec64 Binary files /dev/null and b/Resources/Textures/_Sunrise/Interface/Actions/actions_carp_queen.rsi/stayOff.png differ diff --git a/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/icon.png b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/icon.png new file mode 100644 index 0000000000..cb2a91828d Binary files /dev/null and b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/icon.png differ diff --git a/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/meta.json b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/meta.json new file mode 100644 index 0000000000..a931e90d4c --- /dev/null +++ b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/meta.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "by KAVALDi", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "overlay-icon" + } + ] +} diff --git a/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/overlay-icon.png b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/overlay-icon.png new file mode 100644 index 0000000000..03a546e43a Binary files /dev/null and b/Resources/Textures/_Sunrise/Objects/Misc/egg_carp_queen.rsi/overlay-icon.png differ