Changeling last resort (#43820)

* Changeling-LastResort-Stasis

* Update changeling_catalog.yml

* fix

* Merge fix

* fix

* Update ChangelingLastResortSystem.cs

* ConfirmableAction

* fix 2

* Merge

* Update ChangelingLastResortSystem.cs

* review

* sprite

* Update icon.png

* fix

* NightVision

* review

* Interaction

* review review

* misc cleaning

- Moved some components around
- Lowered price of biodegrade and voice mimic
- Updated store locale to make the warning more clear
- Unpredicted "Take Over Body"
- Made cloning copy over entity descriptions

* abstract

* fix

* stuff

* move to API

* fix MobLing

* review

* docs

---------

Co-authored-by: ScarKy0 <scarky0@onet.eu>
This commit is contained in:
Pok 2026-07-08 03:53:42 +03:00 • committed by GitHub
parent 964aebb031
commit 951b2a4e3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 410 additions and 5 deletions

View file

@ -0,0 +1,5 @@
using Content.Shared.Changeling.Systems;
namespace Content.Client.Changeling.Systems;
public sealed partial class ChangelingLastResortSystem : SharedChangelingLastResortSystem;

View file

@ -497,4 +497,26 @@ public sealed partial class AntagSelectionSystem
GameTicker.StartGameRule(ruleEnt);
return (ruleEnt, antag);
}
/// <summary>
/// Assigns components to an entity based on a <see cref="AntagSpecifierPrototype"/>
/// </summary>
/// <param name="entity">The entity to give the components.</param>
/// <param name="antag">The prototype to apply the components from.</param>
[PublicAPI]
public void AssignAntagComponents(EntityUid entity, ProtoId<AntagSpecifierPrototype> antag)
{
// The following is where we apply components, equipment, and other changes to our antagonist entity.
if (!ProtoMan.Resolve(antag, out var antagPrototype))
return;
AssignAntagComponents(entity, antagPrototype);
}
/// <inheritdoc cref="AssignAntagComponents(EntityUid,ProtoId{AntagSpecifierPrototype})"/>
[PublicAPI]
public void AssignAntagComponents(EntityUid entity, AntagSpecifierPrototype antag)
{
EntityManager.AddComponents(entity, antag.Components);
}
}

View file

@ -26,6 +26,7 @@ using Content.Shared.GameTicking.Components;
using Content.Shared.Random.Helpers;
using Content.Shared.Roles;
using Content.Shared.Whitelist;
using JetBrains.Annotations;
using Robust.Server.Audio;
using Robust.Server.GameObjects;
using Robust.Server.Player;
@ -780,7 +781,7 @@ public sealed partial class AntagSelectionSystem : GameRuleSystem<AntagSelection
$"Game rule {ToPrettyString(gameRule)}, failed to pre-assign {player.Name} to antag {prototype.ID}");
// The following is where we apply components, equipment, and other changes to our antagonist entity.
EntityManager.AddComponents(antag, prototype.Components);
AssignAntagComponents(antag, prototype);
// Equip the entity's RoleLoadout and LoadoutGroup
List<ProtoId<StartingGearPrototype>> gear = new();

View file

@ -0,0 +1,109 @@
using Content.Server.Antag;
using Content.Shared.Administration.Systems;
using Content.Shared.Antag;
using Content.Shared.Changeling.Components;
using Content.Shared.Changeling.Systems;
using Content.Shared.DoAfter;
using Content.Shared.Humanoid;
using Content.Shared.Mind;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Robust.Shared.Prototypes;
namespace Content.Server.Changeling.Systems;
public sealed partial class ChangelingLastResortSystem : SharedChangelingLastResortSystem
{
private static readonly ProtoId<AntagSpecifierPrototype> ChangelingAntag = "Changeling";
[Dependency] private RejuvenateSystem _rejuvenate = default!;
[Dependency] private SharedMindSystem _mind = default!;
[Dependency] private MobStateSystem _mobState = default!;
[Dependency] private SharedPopupSystem _popup = default!;
[Dependency] private SharedDoAfterSystem _doAfter = default!;
[Dependency] private AntagSelectionSystem _antag = default!;
[SubscribeLocalEvent]
private void OnTakeOverCorpseAction(Entity<ChangelingSlugComponent> ent,
ref ChangelingTakeOverCorpseActionEvent args)
{
// CanTakeOver checks for the existence of ChangelingIdentityComponent on the target.
// As such it cannot be predicted as that component is not networked to sessions other than the owner
// Unless we do fucky networking magic
if (args.Handled || !CanTakeOver(ent.Owner, args.Target))
return;
args.Handled = true;
Audio.PlayPvs(ent.Comp.Sound, ent.Owner);
_popup.PopupEntity(Loc.GetString("changeling-takeover-start-others", ("user", ent.Owner)),
ent.Owner,
PopupType.MediumCaution);
var doAfter = new DoAfterArgs(EntityManager,
ent.Owner,
ent.Comp.TakeOverDuration,
new ChangelingTakeOverCorpseDoAfterEvent(),
ent,
target: args.Target)
{
BreakOnDamage = true,
BreakOnMove = true,
DuplicateCondition = DuplicateConditions.None,
RequireCanInteract = false,
};
_doAfter.TryStartDoAfter(doAfter);
}
/// <summary>
/// Checks whether a changeling slug can take over the <paramref name="target"/> body.
/// </summary>
private bool CanTakeOver(EntityUid user, EntityUid target, bool showPopups = true)
{
if (!HasComp<HumanoidProfileComponent>(target))
return false;
if (HasComp<ChangelingIdentityComponent>(target))
{
if (showPopups)
_popup.PopupEntity(Loc.GetString("changeling-takeover-is-changeling"), user);
return false;
}
if (_mobState.IsDead(target))
return true;
if (showPopups)
_popup.PopupEntity(Loc.GetString("changeling-takeover-not-dead"), user);
return false;
}
[SubscribeLocalEvent]
private void OnTakeOverCorpseDoAfter(Entity<ChangelingSlugComponent> ent,
ref ChangelingTakeOverCorpseDoAfterEvent args)
{
args.Handled = true;
if (args.Cancelled || args.Target is not { } target || !CanTakeOver(args.User, target, showPopups: false))
return;
if (!_mind.TryGetMind(args.User, out var mindId, out var mind))
return;
TakeOverCorpse(args.User, target, mindId, mind);
_popup.PopupEntity(Loc.GetString("changeling-takeover-success-self"), target, target, PopupType.Large);
}
private void TakeOverCorpse(EntityUid user, EntityUid target, EntityUid mindId, MindComponent mind)
{
// TODO: delete this after adding the stasis.
_rejuvenate.PerformRejuvenate(target);
_mind.TransferTo(mindId, target, mind: mind);
_antag.AssignAntagComponents(target, ChangelingAntag);
QueueDel(user);
}
}

View file

@ -88,6 +88,7 @@ public sealed partial class CloningSystem : SharedCloningSystem
// Set the clone's name. The raised events will also adjust their PDA and ID card names.
_metaData.SetEntityName(clone.Value, originalName, raiseEvents: settings.RaiseEntityRenamedEvent);
_metaData.SetEntityDescription(clone.Value, Description(original));
_identity.QueueIdentityUpdate(clone.Value); // We have to manually refresh the identity in case we did not raise events.
_adminLogger.Add(LogType.Chat, LogImpact.Medium, $"The body of {original:player} was cloned as {clone.Value:player}");

View file

@ -0,0 +1,30 @@
using Content.Shared.Actions;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.Changeling.Components;
/// <summary>
/// Allows a changeling to gib their current body and escape as a head slug.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class ChangelingLastResortAbilityComponent : Component
{
/// <summary>
/// The prototype ID of the slug to spawn.
/// </summary>
[DataField]
public EntProtoId SlugPrototype = "MobHeadSlug";
/// <summary>
/// The sound to play when the changeling gibbs.
/// </summary>
[DataField]
public SoundSpecifier Sound = new SoundCollectionSpecifier("gib", AudioParams.Default.WithVariation(0.025f));
}
/// <summary>
/// Action event for Last Resort.
/// </summary>
public sealed partial class ChangelingLastResortActionEvent : InstantActionEvent;

View file

@ -0,0 +1,51 @@
using Content.Shared.Actions;
using Content.Shared.DoAfter;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Changeling.Components;
/// <summary>
/// Allows a changeling slug to take over a corpse and become a full changeling again.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class ChangelingSlugComponent : Component
{
/// <summary>
/// The action granted for taking over a corpse.
/// </summary>
[DataField]
public EntProtoId? Action = "ActionChangelingTakeOverCorpse";
/// <summary>
/// The action entity associated with taking over a corpse.
/// </summary>
[DataField, AutoNetworkedField]
public EntityUid? ActionEntity;
/// <summary>
/// How long it takes to take over a corpse.
/// </summary>
[DataField]
public TimeSpan TakeOverDuration = TimeSpan.FromSeconds(5);
/// <summary>
/// The sound to play when starting the takeover.
/// </summary>
[DataField]
public SoundSpecifier? Sound = new SoundPathSpecifier("/Audio/Effects/Fluids/splat.ogg");
}
/// <summary>
/// Action event for taking over a corpse.
/// </summary>
[ByRefEvent]
public sealed partial class ChangelingTakeOverCorpseActionEvent : EntityTargetActionEvent;
/// <summary>
/// DoAfter event for the takeover process.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class ChangelingTakeOverCorpseDoAfterEvent : SimpleDoAfterEvent;

View file

@ -195,6 +195,7 @@ public sealed partial class ChangelingTransformSystem : EntitySystem
_adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(ent.Owner):player} successfully transformed into \"{Name(targetIdentity)}\"");
_metaData.SetEntityName(ent, Name(targetIdentity), raiseEvents: false); // Don't raise events because we don't want to rename the ID card.
_metaData.SetEntityDescription(ent, Description(targetIdentity));
_identity.QueueIdentityUpdate(ent); // We have to manually refresh the identity because we did not raise events.
Dirty(ent);

View file

@ -0,0 +1,49 @@
using Content.Shared.Mind;
using Content.Shared.Actions;
using Content.Shared.Changeling.Components;
using Content.Shared.Gibbing;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Network;
namespace Content.Shared.Changeling.Systems;
public abstract partial class SharedChangelingLastResortSystem : EntitySystem
{
[Dependency] private INetManager _net = default!;
[Dependency] private GibbingSystem _gibbing = default!;
[Dependency] private SharedMindSystem _mind = default!;
[Dependency] private SharedActionsSystem _actions = default!;
[Dependency] protected SharedAudioSystem Audio = default!;
[SubscribeLocalEvent]
private void OnTakeOverMapInit(Entity<ChangelingSlugComponent> ent, ref MapInitEvent args)
{
_actions.AddAction(ent, ref ent.Comp.ActionEntity, ent.Comp.Action);
}
[SubscribeLocalEvent]
private void OnTakeOverShutdown(Entity<ChangelingSlugComponent> ent, ref ComponentShutdown args)
{
if (ent.Comp.ActionEntity != null)
_actions.RemoveAction(ent.Owner, ent.Comp.ActionEntity);
}
[SubscribeLocalEvent]
private void OnLastResortAction(Entity<ChangelingLastResortAbilityComponent> ent,
ref ChangelingLastResortActionEvent args)
{
if (args.Handled || !_mind.TryGetMind(args.Performer, out var mindId, out var mind))
return;
args.Handled = true;
Audio.PlayPredicted(ent.Comp.Sound, args.Performer, args.Performer);
if (!_net.IsServer)
return; // Transfer Mind is unpredictable.
var slug = PredictedSpawnAtPosition(ent.Comp.SlugPrototype, Transform(args.Performer).Coordinates);
_mind.TransferTo(mindId, slug, mind: mind);
_gibbing.Gib(args.Performer);
}
}

View file

@ -42,6 +42,12 @@ changeling-flesh-clothing-alert-desc = Whether clothing transformation is enable
changeling-biodegrade-used-popup = {CAPITALIZE(THE($user))} vomits acid over {POSS-ADJ($user)} {$restraint}!
changeling-biodegrade-used-popup-self = We vomit acid over our {$restraint}!
# last resort
changeling-takeover-not-dead = This body yet lives! We cannot infest it alive.
changeling-takeover-is-changeling = This body rejects our attempts to take over!
changeling-takeover-start-others = {CAPITALIZE(THE($user))} begins to crawl inside the corpse!
changeling-takeover-success-self = We have successfully infested the body and assumed its form.
# stings
changeling-sting-success = We silently sting {THE($target)}!

View file

@ -24,3 +24,8 @@ changeling-catalog-lethal-sting-desc = Causes very slow damage to the target. Us
changeling-catalog-hallucinogenic-sting-name = Hallucinogenic Sting
changeling-catalog-hallucinogenic-sting-desc = Causes the target to get high and have difficulty speaking. Causes confusion among the crew. Can be used on self.
changeling-catalog-last-resort-name = Last Resort
changeling-catalog-last-resort-desc = Gib your current body and escape as a weak head slug. If you infest a humanoid corpse, you can return to a full changeling body.
Warning! This resets your progress as a changeling, removing your store purchases and absorbed identities!

View file

@ -202,3 +202,39 @@
- !type:AddReagentToBloodstream
reagent: Cryptobiolin
quantity: 10 # About 20s of your speech being scrambled
- type: entity
parent: BaseAction
id: ActionChangelingLastResort
name: "[color=red]Last Resort[/color]"
description: We sacrifice our current body in a moment of need, violently expanding to break through obstacles and reforming as a headslug.
components:
- type: ConfirmableAction
popup: suicide-action-popup
- type: Action
raiseOnAction: true
checkCanInteract: false # You can transform if you are killed
checkConsciousness: false
itemIconStyle: BigAction
icon:
sprite: Interface/Actions/changeling2.rsi
state: last_resort
- type: InstantAction
event: !type:ChangelingLastResortActionEvent
- type: ChangelingLastResortAbility
- type: entity
parent: BaseAction
id: ActionChangelingTakeOverCorpse
name: Take Over Corpse
description: Infest a corpse to return to your true form.
components:
- type: Action
useDelay: 10
itemIconStyle: BigAction
icon:
sprite: Interface/Actions/changeling2.rsi
state: take_over_corpse
- type: EntityTargetAction
event: !type:ChangelingTakeOverCorpseActionEvent
- type: TargetAction

View file

@ -34,7 +34,7 @@
description: changeling-catalog-voice-mimic-desc
icon: { sprite: /Textures/Interface/Actions/changeling2.rsi, state: mimicry}
cost:
ChangelingDNA: 20
ChangelingDNA: 10
categories:
- ChangelingStoreUtility
conditions:
@ -49,7 +49,7 @@
icon: { sprite: /Textures/Interface/Actions/changeling2.rsi, state: biodegrade }
applyToMob: true
cost:
ChangelingDNA: 20
ChangelingDNA: 10
categories:
- ChangelingStoreUtility
conditions:
@ -130,3 +130,17 @@
- !type:ListingLimitedStockCondition
stock: 1
productAction: ActionChangelingStingHallucinogenic
- type: listing
id: ChangelingLastResort
name: changeling-catalog-last-resort-name
description: changeling-catalog-last-resort-desc
applyToMob: true
cost:
ChangelingDNA: 20
categories:
- ChangelingStoreUtility
conditions:
- !type:ListingLimitedStockCondition
stock: 1
productAction: ActionChangelingLastResort

View file

@ -14,6 +14,53 @@
balance:
ChangelingDNA: 60
- type: entity
parent: [ SimpleSpaceMobBase, MobNightVision ]
id: MobHeadSlug
name: headslug
description: A small, slug-like creature with a large, gaping maw. It's covered in a thick, slimy mucus.
components:
- type: Fixtures
fixtures:
fix1:
shape:
!type:PhysShapeCircle
radius: 0.2
density: 15
mask:
- SmallMobMask
layer:
- SmallMobLayer
- type: Sprite
drawdepth: SmallMobs
layers:
- map: ["enum.DamageStateVisualLayers.Base"]
state: slug
sprite: Mobs/Animals/headslug.rsi
- type: DamageStateVisuals
states:
Alive:
Base: slug
Dead:
Base: slug_dead
- type: MobThresholds
thresholds:
0: Alive
50: Dead
- type: MovementSpeedModifier # Slightly faster than regular mobs
baseWalkSpeed: 3
baseSprintSpeed: 5
- type: MeleeWeapon
altDisarm: false
damage:
types:
Blunt: 5
Structural: 15 # to destroy lockers in which bodies could be hidden
- type: ChangelingSlug
- type: Tag # Removes DoorBumpOpener
tags:
[]
- type: entity # dummy prototype for the store listing
id: ChangelingFleshClothingAbilityStoreDummy
categories: [ HideSpawnMenu ]

View file

@ -209,6 +209,7 @@
id: Changeling
parent: [BaseGameRule, BaseRoundstartAntagRule]
components:
- type: ChangelingRule
- type: GameRule
minPlayers: 25
- type: AntagSelection

View file

@ -13,7 +13,7 @@
text: changeling-role-greeting
color: Red
sound: /Audio/Ambience/Antag/changeling_start.ogg
components:
components: # When updating components, remember to copy the changes over to MobLing !!!!!
- type: ChangelingDevour
- type: ChangelingIdentity
- type: ChangelingTransform

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,7 +1,7 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "Made by ketufaispikinut from a sprite taken from tgstation at commit https://github.com/tgstation/tgstation/commit/c838ba21dae97db345e0113f99596decd1d66039 (scientist suit), a sprite taken from tgstation at commit https://github.com/tgstation/tgstation/commit/c838ba21dae97db345e0113f99596decd1d66039 (hydro suit) and the changeling ability border/background sprites created by TiniestShark. Biodegrade, Mimicry and Extract Dna On/Off, Blind, Mute, Neurodepressant, Cryogenic, Lethal and Hallucination sting by ketufaispikinut, based on TiniestShark's textures. Shop icon by ketufaispikinut, based on TiniestShark's textures.",
"copyright": "Made by ketufaispikinut from a sprite taken from tgstation at commit https://github.com/tgstation/tgstation/commit/c838ba21dae97db345e0113f99596decd1d66039 (scientist suit), a sprite taken from tgstation at commit https://github.com/tgstation/tgstation/commit/c838ba21dae97db345e0113f99596decd1d66039 (hydro suit) and the changeling ability border/background sprites created by TiniestShark. Biodegrade, Mimicry and Extract Dna On/Off, Blind, Mute, Neurodepressant, Cryogenic, Lethal and Hallucination sting by ketufaispikinut, based on TiniestShark's textures. Shop icon by ketufaispikinut, based on TiniestShark's textures. last_resort taken from https://github.com/tgstation/tgstation/blob/5c90546a27a1e5ea59b9f127ae274d7341acc70b/icons/mob/actions/actions_changeling.dmi and modified by Pok27 (GitHub). take_over_corpse by Pok27 (GitHub).",
"size": {
"x": 32,
"y": 32
@ -45,6 +45,12 @@
},
{
"name": "shop"
},
{
"name": "last_resort"
},
{
"name": "take_over_corpse"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

View file

@ -0,0 +1,21 @@
{
"version": 1,
"license": "CC-BY-4.0",
"copyright": "Made by ketufaispikinut",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "slug",
"directions": 4
},
{
"name": "slug_dead"
},
{
"name": "icon"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 B