Carp Queen (#3352)

This commit is contained in:
KAVALDi 2025-12-26 00:17:55 +07:00 committed by GitHub
parent 71761da9b2
commit 6343a5cd7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2025 additions and 0 deletions

View file

@ -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<CarpEggComponent>
{
[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);
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class CarpServantVisualizerSystem : VisualizerSystem<CarpServantMemoryComponent>
{
[Dependency] private readonly SharedPointLightSystem _lights = default!;
[Dependency] private readonly SpriteSystem _sprite = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CarpServantMemoryComponent, ComponentStartup>(OnStartup);
SubscribeLocalEvent<CarpServantMemoryComponent, ComponentAdd>(OnComponentAdd);
}
private void OnComponentAdd(EntityUid uid, CarpServantMemoryComponent component, ComponentAdd args)
{
// Remove RgbLightController on client side to prevent rainbow effect
RemComp<RgbLightControllerComponent>(uid);
}
public override void FrameUpdate(float frameTime)
{
base.FrameUpdate(frameTime);
// Continuously override RgbLightController color with fixed liquid color
var query = EntityQueryEnumerator<CarpServantMemoryComponent, SpriteComponent>();
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<PointLightComponent>(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<PointLightComponent>(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<SpriteComponent>(uid, out var sprite))
{
_sprite.LayerSetColor((uid, sprite), 0, component.LiquidColor);
}
if (TryComp<PointLightComponent>(uid, out var light))
{
_lights.SetColor(uid, component.LiquidColor, light);
}
}
}

View file

@ -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<CarpEggComponent, DestructionEventArgs>(OnEggDestroyed);
SubscribeLocalEvent<CarpEggComponent, ComponentShutdown>(OnEggShutdown);
SubscribeLocalEvent<CarpEggComponent, MapInitEvent>(OnEggMapInit);
SubscribeLocalEvent<CarpEggComponent, AnchorStateChangedEvent>(OnAnchorChanged);
SubscribeLocalEvent<CarpEggComponent, EntGotRemovedFromContainerMessage>(OnRemovedFromContainer);
SubscribeLocalEvent<SolutionChangedEvent>(OnSolutionChanged);
SubscribeLocalEvent<CarpQueenServantComponent, ComponentStartup>(OnServantStartup);
SubscribeLocalEvent<PuddleComponent, MapInitEvent>(OnPuddleMapInit);
SubscribeLocalEvent<TileChangedEvent>(OnTileChanged);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<CarpEggComponent, TransformComponent>();
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<MapGridComponent>(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<HTNComponent>(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<PuddleComponent>(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<MapGridComponent>(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<CarpEggComponent>(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<MapGridComponent>(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<CarpEggComponent>(ent, out var egg))
TryHatchCheck(ent, egg);
}
}
private void OnTileChanged(ref TileChangedEvent ev)
{
if (!TryComp<MapGridComponent>(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<CarpEggComponent>(ent, out var egg))
TryHatchCheck(ent, egg);
}
}
}
private void TryHatchCheck(EntityUid uid, CarpEggComponent egg)
{
if (!TryComp<TransformComponent>(uid, out var xform))
return;
// Only hatch if not inside containers
if (xform.GridUid == null)
return;
if (_containers.IsEntityInContainer(uid))
return;
if (!TryComp<MapGridComponent>(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<MapGridComponent>(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<ReagentPrototype>("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<string, FixedPoint2> rememberedReagents = new();
if (TryComp<MapGridComponent>(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<ReagentPrototype>("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<CarpServantMemoryComponent>(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<EntityUid>();
_lookup.GetEntitiesInRange(xform.Coordinates, egg.FriendSearchRange, nearbyEntities);
var exception = EnsureComp<FactionExceptionComponent>(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<HumanoidAppearanceComponent>(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<CarpQueenServantComponent>(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<CarpQueenServantComponent>(mob);
// Use normal carp HTN compound instead of RatServantCompound
if (TryComp<HTNComponent>(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<ReagentPrototype>();
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);
}
}
}

View file

@ -0,0 +1,31 @@
using Content.Shared._Sunrise.CarpQueen;
using Content.Shared.CombatMode;
using Content.Shared.Movement.Components;
namespace Content.Server._Sunrise.CarpQueen;
/// <summary>
/// System that increases Carp Queen's pushing strength when in combat mode.
/// </summary>
public sealed class CarpQueenStrengthSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CarpQueenComponent, CombatModeChangedEvent>(OnCombatModeChanged);
}
private void OnCombatModeChanged(EntityUid uid, CarpQueenComponent component, ref CombatModeChangedEvent args)
{
if (!TryComp<MobCollisionComponent>(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);
}
}

View file

@ -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<CarpQueenComponent, CarpQueenSummonActionEvent>(OnSummon);
SubscribeLocalEvent<CarpQueenComponent, AfterPointedAtEvent>(OnPointedAt);
SubscribeLocalEvent<CarpQueenServantComponent, ComponentShutdown>(OnServantShutdown);
}
protected override void OnStartup(EntityUid uid, CarpQueenComponent component, ComponentStartup args)
{
base.OnStartup(uid, component, args);
if (TryComp<HungerComponent>(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<EntityUid>();
var aliveServants = 0;
foreach (var s in component.Servants)
{
if (!Exists(s))
{
toRemoveServants.Add(s);
continue;
}
if (TryComp<MobStateComponent>(s, out var mobState) && mobState.CurrentState == MobState.Dead)
continue;
aliveServants++;
}
foreach (var rem in toRemoveServants)
component.Servants.Remove(rem);
var toRemoveEggs = new List<EntityUid>();
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<HungerComponent>(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<CarpEggComponent>(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<MobStateComponent>(target, out var mobState))
valid = mobState.CurrentState != MobState.Dead;
else if (HasComp<NpcFactionMemberComponent>(target))
valid = true;
else if (HasComp<ActorComponent>(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<CarpServantMemoryComponent>(servant, out var memory))
{
var exception = EnsureComp<FactionExceptionComponent>(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<CarpQueenComponent, HungerComponent>();
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<CarpQueenServantComponent>(uid, out var servant) || servant.Queen == null || !Exists(servant.Queen.Value))
return;
if (!TryComp<HTNComponent>(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<LocalizedDatasetPrototype>(datasetId, out var datasetPrototype))
return;
var msg = Random.Pick(datasetPrototype);
_chat.TrySendInGameICMessage(uid, msg, InGameICChatType.Speak, true);
}
}

View file

@ -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;
/// <summary>
/// System that handles carp servant bite mechanics:
/// Injects 1u of each remembered reagent from the liquid the carp hatched from.
/// </summary>
public sealed class CarpServantBiteSystem : EntitySystem
{
[Dependency] private readonly BloodstreamSystem _bloodstream = default!;
[Dependency] private readonly IPrototypeManager _protos = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CarpServantMemoryComponent, MeleeHitEvent>(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<BloodstreamComponent>(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<ReagentPrototype>(reagentId))
{
solution.AddReagent(reagentId, memory.BiteReagentAmount);
}
}
if (solution.Volume > FixedPoint2.Zero)
{
_bloodstream.TryAddToChemicals((target, bloodstream), solution);
}
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<CarpServantMemoryComponent, AttackedEvent>(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<CarpQueenServantComponent>(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<NPCMeleeCombatComponent>(uid, out meleeCombat) && meleeCombat.Target != EntityUid.Invalid)
{
currentTarget = meleeCombat.Target;
}
// Fallback to blackboard - check common target keys
else if (TryComp<HTNComponent>(uid, out htn))
{
// Try CurrentOrderedTarget first (set by queen/orders)
if (htn.Blackboard.TryGetValue<EntityUid>(NPCBlackboard.CurrentOrderedTarget, out var orderedTarget, EntityManager) && orderedTarget != EntityUid.Invalid)
currentTarget = orderedTarget;
// Then try generic "Target" key
else if (htn.Blackboard.TryGetValue<EntityUid>("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<FactionExceptionComponent>(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);
}
}
}
}

View file

@ -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;
/// <summary>
/// System that makes tamed carps retaliate against entities that damage their remembered friends.
/// </summary>
public sealed class CarpServantRetaliationSystem : EntitySystem
{
[Dependency] private readonly NpcFactionSystem _npcFaction = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<DamageableComponent, DamageChangedEvent>(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<MobStateComponent>(attacker))
return;
// Find all carps that remember the damaged entity as a friend
var query = EntityQueryEnumerator<CarpServantMemoryComponent>();
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<CarpQueenServantComponent>(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<FactionExceptionComponent>(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));
}
}
}

View file

@ -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
{
}
/// <summary>
/// Raised when combat mode changes for an entity.
/// </summary>
[ByRefEvent]
public readonly record struct CombatModeChangedEvent(bool IsInCombatMode);

View file

@ -120,6 +120,23 @@ public sealed partial class NpcFactionSystem
tracker.Entities.Remove(ent);
}
// Sunrise-Start
/// <summary>
/// Makes an entity no longer be ignored, if it was.
/// Allows the NPC to attack the entity again.
/// </summary>
public void UnignoreEntity(Entity<FactionExceptionComponent?> 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
/// <summary>
/// Makes a list of entities no longer be considered hostile, if it was.
/// Doesn't apply to regular faction hostilities.

View file

@ -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;
/// <summary>
/// Required puddle volume (u) to hatch.
/// </summary>
[DataField("requiredVolume")] public float RequiredVolume = 15f;
/// <summary>
/// Seconds between hatch checks.
/// </summary>
[DataField("checkInterval")] public float CheckInterval = 3f;
[DataField("accum")] public float Accum;
/// <summary>
/// Seconds the egg must remain on valid liquid before hatching.
/// </summary>
[DataField("hatchDelay")] public float HatchDelay = 5f;
/// <summary>
/// Whether current tile conditions are sufficient for hatching.
/// </summary>
[DataField("eligible")] public bool Eligible;
/// <summary>
/// Accumulated time spent waiting without valid liquid. If exceeds MaxWaitWithoutLiquid, egg breaks.
/// </summary>
[DataField("waitElapsed")] public float WaitElapsed;
/// <summary>
/// Max seconds to wait for liquid to appear before breaking the egg.
/// </summary>
[DataField("maxWaitWithoutLiquid")] public float MaxWaitWithoutLiquid = 30f;
/// <summary>
/// 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.
/// </summary>
[DataField("queenCheckRange")] public float QueenCheckRange = 3f;
/// <summary>
/// Range (in tiles) to search for nearby players to imprint on when queen is not nearby.
/// </summary>
[DataField("friendSearchRange")] public float FriendSearchRange = 3f;
}

View file

@ -0,0 +1,11 @@
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.CarpQueen;
[Serializable, NetSerializable]
public enum CarpEggVisuals
{
OverlayColor
}

View file

@ -0,0 +1,11 @@
namespace Content.Shared._Sunrise.CarpQueen;
/// <summary>
/// Marker system that grants access permissions to mutate Carp Queen components from server systems.
/// Server systems that need write access should inherit from this.
/// </summary>
public abstract class CarpQueenAccessSystem : EntitySystem
{
}

View file

@ -0,0 +1,31 @@
using Content.Shared.Actions;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.CarpQueen;
public sealed partial class CarpQueenSummonActionEvent : InstantActionEvent
{
}
/// <summary>
/// Event for carp queen order actions (Stay, Follow, Kill, Loose).
/// </summary>
public sealed partial class CarpQueenOrderActionEvent : InstantActionEvent
{
/// <summary>
/// The type of order being given
/// </summary>
[DataField("type")]
public CarpQueenOrderType Type;
}
[Serializable, NetSerializable]
public enum CarpQueenOrderType : byte
{
Stay,
Follow,
Kill,
Loose
}

View file

@ -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<EntityPrototype>))]
public string ActionSummon = "ActionCarpQueenSummon";
[DataField("actionSummonEntity")]
public EntityUid? ActionSummonEntity;
[DataField("actionOrderStay", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ActionOrderStay = "ActionCarpQueenOrderStay";
[DataField("actionOrderStayEntity")]
public EntityUid? ActionOrderStayEntity;
[DataField("actionOrderFollow", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ActionOrderFollow = "ActionCarpQueenOrderFollow";
[DataField("actionOrderFollowEntity")]
public EntityUid? ActionOrderFollowEntity;
[DataField("actionOrderKill", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ActionOrderKill = "ActionCarpQueenOrderKill";
[DataField("actionOrderKillEntity")]
public EntityUid? ActionOrderKillEntity;
[DataField("actionOrderLoose", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ActionOrderLoose = "ActionCarpQueenOrderLoose";
[DataField("actionOrderLooseEntity")]
public EntityUid? ActionOrderLooseEntity;
/// <summary>
/// Current order applied to servants.
/// </summary>
[DataField("currentOrders"), AutoNetworkedField]
public CarpQueenOrderType CurrentOrder = CarpQueenOrderType.Loose;
/// <summary>
/// List of spawned servants controlled by the queen.
/// </summary>
[DataField("servants")]
public HashSet<EntityUid> Servants = new();
/// <summary>
/// Active eggs spawned by the queen (not yet hatched).
/// </summary>
[DataField("eggs")]
public HashSet<EntityUid> Eggs = new();
/// <summary>
/// Pool of carp servant prototype IDs to randomly pick from when summoning.
/// </summary>
[DataField("armyMobSpawnOptions")]
public List<string> ArmyMobSpawnOptions = new()
{
"MobCarpServantDungeon",
"MobCarpServantMagic",
"MobCarpServantHolo",
"MobCarpServantRainbow",
"MobCarpServantDragon"
};
/// <summary>
/// Dataset mapping for order callouts (spoken lines on order change).
/// </summary>
[DataField("orderCallouts")]
public Dictionary<CarpQueenOrderType, string> OrderCallouts = new()
{
{ CarpQueenOrderType.Stay, "CarpQueenCommandStay" },
{ CarpQueenOrderType.Follow, "CarpQueenCommandFollow" },
{ CarpQueenOrderType.Kill, "CarpQueenCommandKill" },
{ CarpQueenOrderType.Loose, "CarpQueenCommandLoose" }
};
/// <summary>
/// Hunger consumed per summon use.
/// </summary>
[DataField("hungerPerSummon")]
public float HungerPerSummon = 25f;
/// <summary>
/// Tracks last observed hunger to grant small healing when eating.
/// Server-side only; not networked.
/// </summary>
public float LastObservedHunger;
/// <summary>
/// Maximum total servants + eggs the queen can have at once.
/// </summary>
[DataField("maxArmySize")]
public int MaxArmySize = 5;
/// <summary>
/// HP healed per 1 unit of hunger gained (when eating).
/// </summary>
[DataField("healPerHunger")]
public float HealPerHunger = 0.2f;
/// <summary>
/// Maximum HP healed per tick from eating.
/// </summary>
[DataField("maxHealPerTick")]
public float MaxHealPerTick = 5f;
/// <summary>
/// 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).
/// </summary>
[DataField("spawnChances")]
public Dictionary<string, int> SpawnChances = new()
{
{ "MobCarpServantRainbow", 80 },
{ "MobCarpServantHolo", 10 },
{ "MobCarpServantDungeon", 10 }
};
}

View file

@ -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;
}

View file

@ -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;
/// <summary>
/// Component that stores memory of the liquid the carp hatched from,
/// including its color and reagents for injection on bite.
/// </summary>
[RegisterComponent, NetworkedComponent]
[AutoGenerateComponentState]
public sealed partial class CarpServantMemoryComponent : Component
{
/// <summary>
/// Color of the liquid the carp hatched from.
/// Used for visual appearance.
/// </summary>
[DataField("liquidColor"), AutoNetworkedField]
public Color LiquidColor = Color.White;
/// <summary>
/// Dictionary of reagent IDs and their amounts that were in the liquid.
/// Used for injection on bite.
/// </summary>
[DataField("rememberedReagents"), AutoNetworkedField]
public Dictionary<string, FixedPoint2> RememberedReagents = new();
/// <summary>
/// Amount of each remembered reagent to inject per bite (in units).
/// </summary>
[DataField("biteReagentAmount")]
public FixedPoint2 BiteReagentAmount = FixedPoint2.New(1);
/// <summary>
/// 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.
/// </summary>
[DataField("rememberedFriends"), AutoNetworkedField]
public HashSet<EntityUid> RememberedFriends = new();
/// <summary>
/// List of entities that the carp is temporarily forbidden to attack.
/// These are cleared when the attacker damages the carp's owner.
/// </summary>
[DataField("forbiddenTargets")]
public HashSet<EntityUid> ForbiddenTargets = new();
}

View file

@ -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<CarpQueenComponent, ComponentStartup>(OnStartup);
SubscribeLocalEvent<CarpQueenComponent, ComponentShutdown>(OnShutdown);
SubscribeLocalEvent<CarpQueenComponent, CarpQueenOrderActionEvent>(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<ActionsComponent?>(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)
{
}
/// <summary>
/// Converts CarpQueenOrderType to RatKingOrderType for HTN compatibility.
/// HTN compounds use RatKingOrderType, so we need to map our order types to them.
/// </summary>
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
};
}
}

View file

@ -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!

View file

@ -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 }

View file

@ -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.

View file

@ -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.

View file

@ -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...

View file

@ -0,0 +1,12 @@
ent-ActionCarpQueenSummon = Отложить икру
.desc = Откладывает икру, которая при попадании в жидкость вылупляется в случайного карпа.
ent-ActionCarpQueenOrderStay = Отдых
.desc = Приказывает карпам-слугам игнорировать угрозы.
ent-ActionCarpQueenOrderFollow = Следуй
.desc = Приказывает карпам-слугам следовать вплотную за вами.
ent-ActionCarpQueenOrderKill = Убейте его
.desc = Приказывает карпам-слугам атаковать того, на кого вы укажете.
ent-ActionCarpQueenOrderLoose = Вольно
.desc = Приказывает карпам-слугам следовать по велению матушки-природы.
carp-queen-summon-popup = Вы отложили икру!

View file

@ -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 }

View file

@ -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 } слуг и икринок вместе.

View file

@ -1,2 +1,5 @@
ent-EggSpiderFertilized = яйцо паука
.desc = Это драгоценный камень? Или яйцо? Выглядит дорого.
ent-MobCarpEgg = икра карпа
.desc = Пульсирующая масса, ожидающая вылупления.

View file

@ -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 = Гр-рр...

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 ]

View file

@ -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" ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

View file

@ -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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B