Ghost types (#37949)

* Empty commit

* yeah thingi

* added a GetHighestDamageTypes thingi to the DamageableSystem

* no idea why those files names are different only in github so just in case readding them

* yeah doing that

* first steps of moving the logic somewhere nicer

* still plenty to do

* gosh such a mess but getting progress done

* small fixie push

* big mess of bunch of stuff

* dealing with a conflict and fixing the random numbers

* testing if github will update now

* dealing with the other conflict

* github please update i beg you

* dealing with more conflicts

* hopefully this fixes it

* fixing conflicts again

* cleaning up stuffies

* sprite fixie

* general cleanup

* doing the small fixies first

* getting rid of the new event, gotta handle ashing next

* adding spaces to comments before i forget

* handling ashing

* think that did it?

* small fixies

* more small fixies

* last batch of quickie fixies before i gotta handle the bigger stuff

* last bunch of fixies i do understand

* small bit of progress yknow may as well yeah

* renaming and moving stuff to shared

* comment fixiees

* saving damage in a new component instead of in MindComponent

* protoid's and dict usage instead of the previously ickier methods

* small fixie before biggie fixie

* more fixies im slepy gosh

* thinkie that should fixie it

* smoothed the damage storage systeem so its less repetitive and icki and now itss cooler and i can go eepy

* lots of stuffies x3

* first step of getting git to detect my file name changes

* thinkie that should fixie it

* fixies

* just getting rid of the merge conflict, will check damageable later

* small thingies first

* more small stuffiees

* now all of the sprites have at leeast a 0

* dirtying the lastbody comp

* more fixies

* small thingi first

* another small fixie and a minor sprite fixie

* rng fixie

* moving the damage storage system to shared

* smoothing out code thats likely to be replaced soon but its good to do for now

* just showing progress bcus yis

* general progress stuffies mhm

* pushie

* small cleanup

* general progress :3

* in progress push for helpie

* proper pushie with progress and workies

* removed unnecessary usage of the storedamage component

* minor fixiees

* extra comments

* replaced a couple strings for ProtoId's

* gibbing related fixies :3
This commit is contained in:
Thinbug
2026-01-30 20:16:03 -03:00
committed by GitHub
parent c54ba1c61f
commit 9393d624d7
31 changed files with 527 additions and 26 deletions

View File

@@ -17,6 +17,7 @@ using Content.Shared.Eye;
using Content.Shared.FixedPoint;
using Content.Shared.Follower;
using Content.Shared.Ghost;
using Content.Shared.GhostTypes;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Mobs;
@@ -68,6 +69,7 @@ namespace Content.Server.Ghost
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly TagSystem _tag = default!;
[Dependency] private readonly NameModifierSystem _nameMod = default!;
[Dependency] private readonly GhostSpriteStateSystem _ghostState = default!;
private EntityQuery<GhostComponent> _ghostQuery;
private EntityQuery<PhysicsComponent> _physicsQuery;
@@ -481,6 +483,11 @@ namespace Content.Server.Ghost
var ghost = SpawnAtPosition(GameTicker.ObserverPrototypeName, spawnPosition.Value);
var ghostComponent = Comp<GhostComponent>(ghost);
if (TryComp<GhostSpriteStateComponent>(ghost, out var state)) // If more TryComps are added this should be turned into an event
{
_ghostState.SetGhostSprite((ghost, state), mind);
}
// Try setting the ghost entity name to either the character name or the player name.
// If all else fails, it'll default to the default entity prototype name, "observer".
// However, that should rarely happen.

View File

@@ -1,7 +1,9 @@
using System.Linq;
using Content.Shared.Chemistry;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Explosion.EntitySystems;
using Content.Shared.FixedPoint;
using Content.Shared.Mobs.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.GameStates;
@@ -68,7 +70,6 @@ public sealed partial class DamageableSystem : EntitySystem
// byref struct event.
RaiseLocalEvent(ent, new DamageChangedEvent(ent.Comp, damageDelta, interruptsDoAfters, origin));
}
private void DamageableGetState(Entity<DamageableComponent> ent, ref ComponentGetState args)
{
if (_netMan.IsServer)
@@ -94,4 +95,26 @@ public sealed partial class DamageableSystem : EntitySystem
ent.Comp.HealthBarThreshold
);
}
/// <summary>
/// Goes through an entity damage's and saves them inside a dictionary if the value is higher than 0
/// The dictionary is structured with a string for the name of the damage type, and a FixedPoint2 for the numeric damage value
/// </summary>
public Dictionary<ProtoId<DamageTypePrototype>, FixedPoint2> GetDamages(Dictionary<ProtoId<DamageGroupPrototype>, FixedPoint2> damagePerGroup, DamageSpecifier damage)
{
var damageTypes = new Dictionary<ProtoId<DamageTypePrototype>, FixedPoint2>();
foreach (var (damageGroupId, _) in damagePerGroup) //go through each group
{
var group = _prototypeManager.Index<DamageGroupPrototype>(damageGroupId); //get group
foreach (var type in group.DamageTypes) //go through each type inside that group
{
if (!damage.DamageDict.TryGetValue(type, out var damageValue) || damageValue == 0) //get value and make sure it isn't 0
continue;
damageTypes.Add(type, damageValue);
}
}
return damageTypes;
}
}

View File

@@ -1,6 +1,7 @@
using Content.Shared.Actions;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Ghost;
@@ -95,6 +96,16 @@ public sealed partial class GhostComponent : Component
public Color Color = Color.White;
}
/// <summary>
/// Ghost sprites dependent on damage by the player body
/// </summary>
/// <remarks>Used to change a ghost sprite to better visually represent their cause of death</remarks>
[Serializable, NetSerializable]
public enum GhostVisuals : byte
{
Damage
}
public sealed partial class ToggleFoVActionEvent : InstantActionEvent { }
public sealed partial class ToggleGhostsActionEvent : InstantActionEvent { }

View File

@@ -0,0 +1,28 @@
using Content.Shared.Damage.Prototypes;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.GhostTypes;
/// <summary>
/// Changes the entity sprite according to damage taken
/// Slash may be shown by cuts and slashes on the ghost, Heat as flames, Cold as frostbite and ice, Radiation as a green glow, etc.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class GhostSpriteStateComponent : Component
{
/// <summary>
/// Prefix the GhostSpriteStateSystem will add to the name of the damage type it chooses.
/// It should be identical to the prefix of the entity optional damage sprites.
/// (Example) Ghosts sprites currently use a "ghost_" prefix for their optional damage states.
/// </summary>
[DataField]
public string Prefix;
/// <summary>
/// Should link damage types names to an int, according to the amount of possible sprites for that specific type.
/// (The GhostSpriteStateSystem will randomly choose between them)
/// </summary>
[DataField]
public Dictionary<ProtoId<DamageTypePrototype>, int> DamageMap = new();
}

View File

@@ -0,0 +1,70 @@
using System.Linq;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Systems;
using Content.Shared.FixedPoint;
using Content.Shared.Ghost;
using Content.Shared.Mind;
using Content.Shared.Random.Helpers;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.Shared.GhostTypes;
public sealed class GhostSpriteStateSystem : EntitySystem
{
[Dependency] private readonly DamageableSystem _damageable = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
/// <summary>
/// It goes through an entity damage and assigns them a sprite according to the highest damage type/s
/// </summary>
public void SetGhostSprite(Entity<GhostSpriteStateComponent?> ent, EntityUid mind)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (!TryComp<AppearanceComponent>(ent, out var appearance) || !HasComp<MindComponent>(mind))
return;
var damageTypes = new Dictionary<ProtoId<DamageTypePrototype>, FixedPoint2>();
ProtoId<SpecialCauseOfDeathPrototype>? specialCase = null;
if (!TryComp<LastBodyDamageComponent>(mind, out var storedDamage))
return;
if (storedDamage.DamagePerGroup != null && storedDamage.Damage != null)
{
damageTypes = _damageable.GetDamages(storedDamage.DamagePerGroup, storedDamage.Damage);
}
specialCase = storedDamage.SpecialCauseOfDeath;
Dirty(mind, storedDamage);
var damageTypesSorted = damageTypes.OrderByDescending(x => x.Value).ToDictionary();
if (damageTypesSorted.Count == 0)
return;
var highestType = damageTypesSorted.First().Key; // We only need 1 of the values
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(ent).Id);
var rand = new System.Random(seed);
ProtoId<DamageTypePrototype>? spriteState = null;
if (specialCase != null) // Possible special cases like death by an explosion
{
var prototype = _proto.Index(specialCase);
spriteState = specialCase + rand.Next(prototype.NumOfStates);
}
else if (ent.Comp.DamageMap.TryGetValue(highestType, out var spriteAmount))
{
spriteState = highestType + rand.Next(spriteAmount);
}
if (spriteState != null)
_appearance.SetData(ent, GhostVisuals.Damage, ent.Comp.Prefix + spriteState, appearance);
}
}

View File

@@ -0,0 +1,48 @@
using Content.Shared.Damage;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.GhostTypes;
/// <summary>
/// Added to the Mind of an entity by the StoreDamageTakenOnMindSystem, allowing storage of the damage values their body had.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class LastBodyDamageComponent : Component
{
/// <summary>
/// Dictionary DamageGroupPrototype proto ids to how much damage was received from that damage type.
/// </summary>
[DataField, AutoNetworkedField]
public Dictionary<ProtoId<DamageGroupPrototype>, FixedPoint2>? DamagePerGroup;
/// <summary>
/// Collection of possible damage types, stored by the StoreDamageTakenOnMind.
/// </summary>
[DataField, AutoNetworkedField]
public DamageSpecifier? Damage;
/// <summary>
/// Special death cause that's saved after an event related to it is triggered
/// For example, a BeforeExplodeEvent will save "Explosion" as the special cause of death
/// </summary>
[DataField, AutoNetworkedField]
public ProtoId<SpecialCauseOfDeathPrototype>? SpecialCauseOfDeath = null;
}
/// <summary>
/// Prototype for special causes of death (such as "Explosion")
/// </summary>
[Prototype]
public sealed partial class SpecialCauseOfDeathPrototype : IPrototype
{
[ViewVariables, IdDataField]
public string ID { get; private set; } = string.Empty;
// Specifies the amount of possible sprites for a special cause of death
// These values are set up in the special_cause_of_death_types.yml file
[DataField]
public int NumOfStates;
}

View File

@@ -0,0 +1,9 @@
using Robust.Shared.GameStates;
namespace Content.Shared.GhostTypes;
/// <summary>
/// Stores the damage an entity took before their body is destroyed inside it's mind LastBodyDamageComponent
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class StoreDamageTakenOnMindComponent : Component;

View File

@@ -0,0 +1,101 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Destructible;
using Content.Shared.Explosion;
using Content.Shared.FixedPoint;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Mobs;
using Robust.Shared.Prototypes;
namespace Content.Shared.GhostTypes;
public sealed class StoreDamageTakenOnMindSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _proto = default!;
public override void Initialize()
{
SubscribeLocalEvent<StoreDamageTakenOnMindComponent, DestructionEventArgs>(SaveBodyOnGib);
SubscribeLocalEvent<StoreDamageTakenOnMindComponent, MobStateChangedEvent>(SaveBodyOnThreshold);
SubscribeLocalEvent<StoreDamageTakenOnMindComponent, BeforeExplodeEvent>(DeathByExplosion);
}
/// <summary>
/// Saves the damage of a player body inside their MindComponent after a DestructionEventArgs
/// </summary>
private void SaveBodyOnGib(Entity<StoreDamageTakenOnMindComponent> ent, ref DestructionEventArgs args)
{
SaveBody(ent.Owner);
}
/// <summary>
/// Saves the damage of a player body inside their MindComponent after a damage threshold event
/// </summary>
private void SaveBodyOnThreshold(Entity<StoreDamageTakenOnMindComponent> ent, ref MobStateChangedEvent args)
{
if (args.NewMobState != MobState.Dead)
ClearSpecialCause(ent);
SaveBody(ent.Owner);
}
private void DeathByExplosion(Entity<StoreDamageTakenOnMindComponent> ent, ref BeforeExplodeEvent args)
{
SaveSpecialCauseOfDeath(ent, "Explosion");
}
/// <summary>
/// Gets an entity Mind and stores it's current body damages inside of it's LastBodyDamageComponent
/// </summary>
private void SaveBody(EntityUid ent)
{
if (!TryComp<DamageableComponent>(ent, out var damageable)
|| !TryComp<MindContainerComponent>(ent, out var mindContainer)
|| !HasComp<MindComponent>(mindContainer.Mind))
return;
EnsureComp<LastBodyDamageComponent>(mindContainer.Mind.Value, out var storedDamage);
var protoDict = new Dictionary<ProtoId<DamageGroupPrototype>, FixedPoint2>();
foreach (var stringDict in damageable.DamagePerGroup) // Translates the strings into ProtoId's before saving the Dictionary
{
if (!_proto.TryIndex(stringDict.Key, out DamageGroupPrototype? proto))
continue;
protoDict.TryAdd(proto, stringDict.Value);
}
storedDamage.DamagePerGroup = protoDict;
storedDamage.Damage = damageable.Damage;
Dirty(mindContainer.Mind.Value, storedDamage);
}
/// <summary>
/// Saves an specific cause of death inside of an entity LastBodyDamageComponent
/// </summary>
private void SaveSpecialCauseOfDeath(EntityUid ent, ProtoId<SpecialCauseOfDeathPrototype> cause)
{
if (!TryComp<MindContainerComponent>(ent, out var mindContainer)
|| !HasComp<MindComponent>(mindContainer.Mind))
return;
EnsureComp<LastBodyDamageComponent>(mindContainer.Mind.Value, out var storedDamage);
storedDamage.SpecialCauseOfDeath = cause;
Dirty(mindContainer.Mind.Value, storedDamage);
}
/// <summary>
/// Clears the specific cause of death of an entity LastBodyDamageComponent
/// </summary>
private void ClearSpecialCause(EntityUid ent)
{
if (!TryComp<MindContainerComponent>(ent, out var mindContainer)
|| !HasComp<MindComponent>(mindContainer.Mind))
return;
EnsureComp<LastBodyDamageComponent>(mindContainer.Mind.Value, out var storedDamage);
storedDamage.SpecialCauseOfDeath = null;
Dirty(mindContainer.Mind.Value, storedDamage);
}
}

View File

@@ -0,0 +1,3 @@
- type: specialCauseOfDeath
id: Explosion
numOfStates: 3

View File

@@ -44,7 +44,32 @@
color: "#fff8"
layers:
- state: animated
map: [ "ghostVariant" ]
shader: unshaded
- type: Appearance
- type: GenericVisualizer
visuals:
enum.GhostVisuals.Damage:
ghostVariant:
ghost_Blunt0: { state: ghost_Blunt0 } # this is an icki way of doing this u.u -Thinbug
ghost_Blunt1: { state: ghost_Blunt1 }
ghost_Blunt2: { state: ghost_Blunt2 }
ghost_Slash0: { state: ghost_Slash0 }
ghost_Slash1: { state: ghost_Slash1 }
ghost_Slash2: { state: ghost_Slash2 }
ghost_Piercing0: { state: ghost_Piercing0 }
ghost_Piercing1: { state: ghost_Piercing1 }
ghost_Piercing2: { state: ghost_Piercing2 }
ghost_Heat0: { state: ghost_Heat0 }
ghost_Cold0: { state: ghost_Cold0 }
ghost_Shock0: { state: ghost_Shock0 }
ghost_Radiation0: { state: ghost_Radiation0 }
ghost_Cellular0: { state: ghost_Cellular0 }
ghost_Poison0: { state: ghost_Poison0 }
ghost_Asphyxiation0: { state: ghost_Asphyxiation0 }
ghost_Explosion0: { state: ghost_Explosion0 }
ghost_Explosion1: { state: ghost_Explosion1 }
ghost_Explosion2: { state: ghost_Explosion2 }
- type: ContentEye
maxZoom: 1.44,1.44
- type: Eye
@@ -54,6 +79,19 @@
- type: Examiner
skipChecks: true
- type: Ghost
- type: GhostSpriteState
damageMap:
Blunt: 3
Slash: 3
Piercing: 3
Heat: 1
Cold: 1
Shock: 1
Radiation: 1
Cellular: 1
Poison: 1
Asphyxiation: 1
prefix: "ghost_"
- type: GhostHearing
- type: ShowElectrocutionHUD
- type: IntrinsicRadioReceiver

View File

@@ -123,6 +123,7 @@
- ActionCritSuccumb
- ActionCritFakeDeath
- ActionCritLastWords
- type: StoreDamageTakenOnMind
- type: Deathgasp
- type: HealthExaminable
examinableTypes:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

View File

@@ -1,26 +1,188 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "https://github.com/tgstation/tgstation/blob/f80e7ba62d27c77cfeac709dd71033744d0015c4/icons/mob/mob.dmi, inhand sprites by TiniestShark (github)",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "animated",
"directions": 4
},
{
"name": "icon"
},
{
"name": "inhand-left",
"directions": 4
},
{
"name": "inhand-right",
"directions": 4
}
]
}
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "https://github.com/tgstation/tgstation/blob/f80e7ba62d27c77cfeac709dd71033744d0015c4/icons/mob/mob.dmi, inhand sprites by TiniestShark (github), damage sprites by Thinbug (github)",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "animated",
"directions": 4
},
{
"name": "icon"
},
{
"name": "inhand-left",
"directions": 4
},
{
"name": "inhand-right",
"directions": 4
},
{
"name": "ghost_Blunt0",
"directions": 4
},
{
"name": "ghost_Blunt1",
"directions": 4
},
{
"name": "ghost_Blunt2",
"directions": 4
},
{
"name": "ghost_Slash0",
"directions": 4
},
{
"name": "ghost_Slash1",
"directions": 4
},
{
"name": "ghost_Slash2",
"directions": 4
},
{
"name": "ghost_Piercing0",
"directions": 4
},
{
"name": "ghost_Piercing1",
"directions": 4
},
{
"name": "ghost_Piercing2",
"directions": 4
},
{
"name": "ghost_Heat0",
"directions": 4,
"delays": [
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
]
]
},
{
"name": "ghost_Cold0",
"directions": 4
},
{
"name": "ghost_Shock0",
"directions": 4,
"delays": [
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
]
]
},
{
"name": "ghost_Radiation0",
"directions": 4,
"delays": [
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
],
[
0.1,
0.1,
0.1,
0.1
]
]
},
{
"name": "ghost_Cellular0",
"directions": 4
},
{
"name": "ghost_Poison0",
"directions": 4
},
{
"name": "ghost_Asphyxiation0",
"directions": 4
},
{
"name": "ghost_Explosion0",
"directions": 4
},
{
"name": "ghost_Explosion1",
"directions": 4
},
{
"name": "ghost_Explosion2",
"directions": 4
}
]
}