This commit is contained in:
KOTOB 2026-05-10 11:43:47 +00:00 committed by GitHub
commit cb6fb50c38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 497 additions and 0 deletions

View File

@ -0,0 +1,39 @@
using Content.Shared._DV.Body.Components;
using Content.Shared.Clothing.Components;
using Content.Shared.Clothing.EntitySystems;
using Robust.Client.GameObjects;
namespace Content.Client._DV.Body;
/// <summary>
/// Colors feathers.
/// </summary>
public sealed class FeatherVisualizer : VisualizerSystem<FeatherComponent>
{
[Dependency] private readonly ClothingSystem _clothing = default!;
protected override void OnAppearanceChange(EntityUid uid, FeatherComponent component, ref AppearanceChangeEvent args)
{
if (AppearanceSystem.TryGetData<Color>(uid, FeatherVisuals.FeatherColor, out var featherColor, args.Component))
{
SpriteSystem.LayerSetColor(uid, FeatherVisualLayers.Feather, featherColor);
if (TryComp<ClothingComponent>(uid, out var clothing))
{
foreach (var slotPair in clothing.ClothingVisuals)
{
_clothing.SetLayerColor(clothing, slotPair.Key, "feather", featherColor);
}
}
}
if (AppearanceSystem.TryGetData<Color>(uid, FeatherVisuals.BloodColor, out var bloodColor, args.Component))
SpriteSystem.LayerSetColor(uid, FeatherVisualLayers.Blood, bloodColor);
}
}
public enum FeatherVisualLayers : byte
{
Feather,
Blood,
}

View File

@ -3,6 +3,7 @@ using System.Linq;
using Content.Shared.Dataset;
using Content.Shared.FixedPoint;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Shared.Random.Helpers
{
@ -210,5 +211,34 @@ namespace Content.Shared.Random.Helpers
}
return hash;
}
// TODO: REPLACE ALL OF THIS WITH PREDICTED RANDOM WHEN ENGINE PR IS MERGED
/// <summary>
/// Creates an instance of System.Random that will be the same for both the server and client.
/// This allows for the client and server to roll the same results when determining things randomly, preventing mispredictions.
/// We generate a unique seed by getting 2-3 unique but predictable integers into a Hashcode.
/// </summary>
/// <param name="timing">An instance if IGameTiming.
/// We use the integer value of the current tick to ensure a different seed every tick.</param>
/// <param name="netEnt">The relevant net entity to our seed.
/// This allows different entities to have different seeds and therefore different results on the same game-tick.</param>
/// <param name="netEnt2">An optional relevant net entity to our seed.
/// Typically used if we have an entity checking random potentially multiple times per tick, to ensure we get a unique seed each time.
/// This entity should not be the same entity as <see cref="netEnt"/>.</param>
public static System.Random PredictedRandom(IGameTiming timing, NetEntity netEnt, NetEntity? netEnt2 = null)
{
var seed = HashCodeCombine((int)timing.CurTick.Value, netEnt.Id, netEnt2?.Id ?? 0);
return new System.Random(seed);
}
/// <summary>
/// Checks a probability against a <see cref="PredictedRandom"/> instance.
/// Returns true if the amount rolled is below the probability.
/// </summary>
public static bool PredictedProb(IGameTiming timing, float probability, NetEntity netEnt1, NetEntity? netEnt2 = null)
{
var rand = PredictedRandom(timing, netEnt1, netEnt2);
return rand.Prob(probability);
}
}
}

View File

@ -0,0 +1,19 @@
using Robust.Shared.GameStates;
using Robust.Shared.Serialization;
namespace Content.Shared._DV.Body.Components;
/// <summary>
/// Marks this entity as a feather, that probably has a custom color
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class FeatherComponent : Component
{
}
[Serializable, NetSerializable]
public enum FeatherVisuals : byte
{
FeatherColor,
BloodColor,
}

View File

@ -0,0 +1,76 @@
using Content.Shared.Chat.Prototypes;
using Content.Shared.Damage;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared._DV.Body.Components;
/// <summary>
/// This is used for Avali feather preening functionality.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class PreenableComponent : Component
{
[DataField]
public EntProtoId FeatherPrototype;
[DataField]
public HashSet<ProtoId<DamageGroupPrototype>>? ValidDamageGroups = new()
{
"Brute",
};
[DataField]
public LocId SelfPreeningMessage = "preening-popup-self";
[DataField]
public LocId GettingPreenedMessage = "preening-popup-self-recipient";
[DataField]
public LocId PreeningOtherMessage = "preening-popup-other";
[DataField]
public LocId FeatherBloodiedNameString = "feather-bloody-name-modifier";
[DataField]
public LocId FeatherBloodiedDescString = "feather-bloody-desc";
[DataField]
public LocId PreeningVerbString = "preening-action-verb";
[DataField]
public LocId DroppedFeatherString = "preening-feather-dropped-injured";
[DataField]
public ProtoId<EmotePrototype> ScreamEmote = "Scream";
/// <summary>
/// The minimum amount of damage that must be taken from one attack to have a chance to shed a feather.
/// </summary>
[DataField]
public FixedPoint2 ShedDamageThreshold = 9;
/// <summary>
/// The chance for a feather to be shed on hit, per point of damage taken.
/// </summary>
[DataField]
public float ShedScalingChance = 0.0125f;
[DataField]
public DamageModifierSet? VulnerabilityModifier;
[DataField, AutoNetworkedField]
public int MaximumFeathers = 3;
[DataField, AutoNetworkedField]
public int CurrentFeathers;
[DataField]
public TimeSpan ReplenishDelay = TimeSpan.FromSeconds(150);
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField]
public TimeSpan? ReplenishTime;
}

View File

@ -0,0 +1,7 @@
using Content.Shared.DoAfter;
using Robust.Shared.Serialization;
namespace Content.Shared._DV.Body.Events;
[Serializable, NetSerializable]
public sealed partial class PreeningEvent : SimpleDoAfterEvent;

View File

@ -0,0 +1,229 @@
using Content.Shared._DV.Body.Components;
using Content.Shared._DV.Body.Events;
using Content.Shared.Body.Components;
using Content.Shared.Chat;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.DoAfter;
using Content.Shared.FixedPoint;
using Content.Shared.Forensics.Systems;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Humanoid;
using Content.Shared.IdentityManagement;
using Content.Shared.Popups;
using Content.Shared.Random.Helpers;
using Content.Shared.StatusEffect;
using Content.Shared.Verbs;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Shared._DV.Body.Systems;
public sealed class PreenableSystem : EntitySystem
{
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly SharedForensicsSystem _forensics = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
[Dependency] private readonly StatusEffectsSystem _statusEffects = default!;
[Dependency] private readonly SharedChatSystem _chat = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PreenableComponent, GetVerbsEvent<Verb>>(AddVerb);
SubscribeLocalEvent<PreenableComponent, PreeningEvent>(OnPreened);
SubscribeLocalEvent<PreenableComponent, DamageChangedEvent>(OnDamaged);
SubscribeLocalEvent<PreenableComponent, DamageModifyEvent>(OnDamageModify);
SubscribeLocalEvent<PreenableComponent, ComponentInit>(OnCompInit);
}
private void OnCompInit(Entity<PreenableComponent> ent, ref ComponentInit args)
{
ent.Comp.CurrentFeathers = ent.Comp.MaximumFeathers;
Dirty(ent);
}
private void AddVerb(Entity<PreenableComponent> ent, ref GetVerbsEvent<Verb> args)
{
if (!args.CanInteract)
return;
// can't preen with no feathers
if (ent.Comp.CurrentFeathers <= 0)
return;
var user = args.User;
Verb verb = new()
{
Act = () => AttemptDoAfter(ent, user),
Text = Loc.GetString(ent.Comp.PreeningVerbString),
};
args.Verbs.Add(verb);
}
private void AttemptDoAfter(Entity<PreenableComponent> ent, EntityUid userUid)
{
var doArgs = new DoAfterArgs(EntityManager, userUid, 5f, new PreeningEvent(), ent, ent)
{
BreakOnMove = true,
BreakOnDamage = true,
};
if (userUid == ent.Owner)
{
_popup.PopupClient(Loc.GetString(ent.Comp.SelfPreeningMessage), ent, ent);
}
else
{
_popup.PopupEntity(Loc.GetString(ent.Comp.GettingPreenedMessage, ("preener", Identity.Entity(userUid, EntityManager))), ent, ent, PopupType.Medium);
_popup.PopupClient(Loc.GetString(ent.Comp.PreeningOtherMessage, ("preenee", Identity.Entity(ent, EntityManager))), userUid, userUid);
}
_doAfter.TryStartDoAfter(doArgs);
}
private void OnPreened(Entity<PreenableComponent> ent, ref PreeningEvent args)
{
if (args.Cancelled || args.Handled)
return;
if (ent.Comp.CurrentFeathers <= 0)
return;
var feather = SpawnFeather(ent, false);
_hands.TryPickupAnyHand(args.User, feather);
}
private void OnDamaged(Entity<PreenableComponent> ent, ref DamageChangedEvent args)
{
if (args.DamageDelta == null || ent.Comp.ValidDamageGroups == null)
return;
if (ent.Comp.CurrentFeathers <= 0)
return;
var totalApplicableDamage = FixedPoint2.Zero;
foreach (var (group, value) in args.DamageDelta.GetDamagePerGroup(_prototype))
{
if (!ent.Comp.ValidDamageGroups.Contains(group))
continue;
totalApplicableDamage += value;
}
if (totalApplicableDamage <= ent.Comp.ShedDamageThreshold)
return;
// predicted randomness is a truly evil thing
var rand = SharedRandomExtensions.PredictedRandom(_timing, GetNetEntity(ent));
var randomDouble = rand.NextDouble();
var triggerChance = totalApplicableDamage * ent.Comp.ShedScalingChance;
if (randomDouble >= triggerChance)
return;
var feather = SpawnFeather(ent, true);
// apply a random impulse so it's flying off the body. similar code to GibbingSystem
var scatterVector = rand.NextAngle().ToVec() * (rand.NextFloat(10, 40));
_physics.ApplyLinearImpulse(feather, scatterVector);
_physics.ApplyAngularImpulse(feather, rand.NextFloat(-30, 30));
// update name/desc for increased validness
var meta = MetaData(feather);
_metaData.SetEntityName(feather, Loc.GetString(ent.Comp.FeatherBloodiedNameString, ("item", Name(feather))), meta);
_metaData.SetEntityDescription(feather, Loc.GetString(ent.Comp.FeatherBloodiedDescString), meta);
Dirty(feather, meta);
// yeeeowch!
_popup.PopupClient(Loc.GetString(ent.Comp.DroppedFeatherString), ent, ent, PopupType.MediumCaution);
_chat.TryEmoteWithoutChat(ent, ent.Comp.ScreamEmote);
// old StatusEffects is obsolete, however Adrenaline has not been moved over to the new system yet
_statusEffects.TryAddStatusEffect(ent, "Adrenaline", TimeSpan.FromSeconds(3), true);
}
private void OnDamageModify(Entity<PreenableComponent> ent, ref DamageModifyEvent args)
{
if (ent.Comp.VulnerabilityModifier == null)
return;
// zero vulnerability at max feathers, full vulnerability at 0 feathers
var vulnerabilityModifier = 1f - (ent.Comp.CurrentFeathers / (float)ent.Comp.MaximumFeathers);
var damageSpecifier = new DamageModifierSet
{
Coefficients = new Dictionary<string, float>(ent.Comp.VulnerabilityModifier.Coefficients),
};
foreach (var key in damageSpecifier.Coefficients.Keys)
{
damageSpecifier.Coefficients[key] = 1f + ((damageSpecifier.Coefficients[key] - 1f) * vulnerabilityModifier);
}
args.Damage = DamageSpecifier.ApplyModifierSet(args.Damage, damageSpecifier);
}
private EntityUid SpawnFeather(Entity<PreenableComponent> ent, bool bloody)
{
var feather = PredictedSpawnAtPosition(ent.Comp.FeatherPrototype.Id, Transform(ent).Coordinates);
if (TryComp<HumanoidAppearanceComponent>(ent, out var appearance))
{
_appearance.SetData(feather, FeatherVisuals.FeatherColor, appearance.SkinColor);
}
// best be careful, no cleaning this
_forensics.TransferDna(feather, ent, false);
ent.Comp.CurrentFeathers -= 1;
ent.Comp.ReplenishTime = _timing.CurTime + ent.Comp.ReplenishDelay;
Dirty(ent);
if (!bloody || !TryComp<BloodstreamComponent>(ent, out var bloodstream) || bloodstream.BloodSolution == null)
return feather;
var solution = bloodstream.BloodSolution.Value.Comp.Solution;
_appearance.SetData(feather, FeatherVisuals.BloodColor, solution.GetColor(_prototype));
return feather;
}
public override void Update(float deltaTime)
{
base.Update(deltaTime);
var preenableQuery = EntityQueryEnumerator<PreenableComponent>();
while (preenableQuery.MoveNext(out var uid, out var preenable))
{
if (preenable.ReplenishTime == null || !(preenable.ReplenishTime <= _timing.CurTime))
continue;
if (preenable.CurrentFeathers >= preenable.MaximumFeathers)
continue;
preenable.CurrentFeathers += 1;
if (preenable.CurrentFeathers >= preenable.MaximumFeathers)
{
preenable.ReplenishTime = null;
continue;
}
preenable.ReplenishTime = _timing.CurTime + preenable.ReplenishDelay;
}
}
}

View File

@ -0,0 +1,10 @@
preening-action-verb = Preen
preening-popup-self = You begin to preen your feathers...
preening-popup-other = You begin to preen { CAPITALIZE(THE($preenee)) }'s feathers...
preening-popup-self-recipient = { CAPITALIZE(THE($preener)) } begins to preen your feathers!
preening-feather-dropped-injured = You feel a feather get torn off!
feather-bloody-name-modifier = bloody {$item}
feather-bloody-desc = DEFINITELY not ethically sourced.

View File

@ -0,0 +1,54 @@
- type: entity
parent: BaseItem
id: AvaliFeather
name: feather
description: Probably not ethically sourced.
components:
- type: Appearance
- type: Sprite
sprite: _DV/Objects/Misc/feather.rsi
layers:
- state: feather
map: [ "enum.FeatherVisualLayers.Feather" ]
- state: featherblood
color: "#FFFFFF00"
map: [ "enum.FeatherVisualLayers.Blood"]
- type: Flammable
fireSpread: true
alwaysCombustible: true
damage:
types:
Heat: 1
- type: FireVisuals
sprite: Effects/fire.rsi
normalState: fire
- type: Damageable # a lot of this is sensible boilerplate from the Paper prototype
damageModifierSet: Wood
- type: Destructible
thresholds:
- trigger:
!type:DamageTrigger
damage: 15
behaviors:
- !type:DoActsBehavior
acts: [ "Destruction" ]
- type: Item
size: Tiny
- type: Clothing
sprite: _DV/Objects/Misc/feather.rsi
clothingVisuals:
ears:
- state: equipped-EARS
map: [ "feather" ]
head:
- state: equipped-HELMET
map: [ "feather" ]
slots:
- ears
- head
- type: Tag
tags:
- Write # Like a quill!
- Pen
- Trash
- type: Feather

View File

@ -115,6 +115,8 @@
279: 0.95
265: 0.9
240: 0.85
- type: Preenable
featherPrototype: AvaliFeather
# End DeltaV additions
- type: entity

View File

@ -29,4 +29,10 @@
- As a lightweight species, they tend to be a bit frail, taking [color=#ffa500]30% more blunt damage[/color], as well as [color=#ffa500]10% more slash and pierce[/color].
- For the same reason, they lack muscle, [color=#ffa500]dealing 35% less blunt damage[/color] and [color=#ffa500]15% less slash and stamina damage[/color].
## Feathers
<Box>
<GuideEntityEmbed Entity="AvaliFeather" Caption=""/>
</Box>
Being injured has a chance to cause an Avali to drop a feather, granting them a [color=#1e90ff]short burst of adrenaline[/color]. They can lose up to three feathers at once, which will very slowly regrow over time. Feathers can also be [color=#1e90ff]manually preened[/color] via the context menu.
</Document>

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

View File

@ -0,0 +1,25 @@
{
"version": 1,
"license": "CC-BY-4.0",
"copyright": "Item sprite modified from https://opengameart.org/content/animated-feather-pickup. featherblood and equip sprites by kotobdev (github).",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "feather"
},
{
"name": "featherblood"
},
{
"name": "equipped-HELMET",
"directions": 4
},
{
"name": "equipped-EARS",
"directions": 4
}
]
}