Magic Refactor + Wizard Grimoire (#22568)

* Brings over changes from the original magic refactor PR

* Adds Master Spellbook, spellbook categories, WizCoin currency, and locale

* Wiz€oin™

* Adds currency whitelist to Spellbook preset, grants contained actions on action added.

* Adds grant contained action and remove provided action.

* adds a way for actions to be upgraded to the store

* Adds Fireball 3 and fixes action upgrade logic so that it checks if the action can level or if the action can upgrade separately

* Fixes upgrade logic in ActionUpgradeSystem to allow for level ups without an actual upgrade. Fixed action upgrade logic in store system as well

* Removes current action entity from the bought entities list and adds new or old action entity

* Removes Current Entity

* Removes old comments, fixes TransferAllActionsWithNewAttached

* Removes TODO

* Removes Product Action Upgrade Event

* reverts changes to immovablerodrule

* Removes stale event reference

* fixes mind action grant logic

* reverts shared gun system change to projectile anomaly system

* forgor to remove the using

* Reverts unintended changes to action container

* Adds refund button to the store

* Refreshes store back to origin.

* Refund with correct currency

* Init refund

* Check for terminating and update interface

* Disables refund button

* Removes preset allow refund

* dont refund if map changed

* adds refunds to stores

* Adds method to check for starting map

* comments, datafields, some requested changes

* turns event into ref event

* Adds datafields

* Switches to entity terminating event

* Changes store entity to be nullable and checks if store is terminating to remove reference.

* Tryadd instead of containskey

* Adds a refund disable method, disables refund on bought ent container changes if not an action

* Removes duplicate refundcomp

* Removes unintended merges

* Removed another unintended change from merge

* removes extra using statement

* readds using statement

* might as well just remove both usings since it won't leave the PR

* Fixes Action upgrades from stores

* Changes to non obsolete method uses

* Shares spawn code between instant and world

* Adds action entity to action event, adds beforecastspellevent, adds spell requirements to magic component

* puts prereq check in spell methods, sets up template code for before cast event

* checks for required wizard clothes

* Networks Magic Comp and Wizard Clothes Comp. Renames MagicSpawnData to MagicInstantSpawnData.

* Removes posdata from projectiles

* Speech > RequiresSpeech

* Fixes ActionOnInteract

* checks for muted

* popup for missing reqs

* Validate click loc for blink spell

* Checks if doors are in range and not obstructed before opening

* Check ents by map coords

* Adds speak event

* Comments spellbooks

* Removes comments

* Unobsoletes smite spell

* Invert if

* Requirements loc

* Fixes spell reqs

* Inverts an if

* Comment updates

* Starts doafter work

* Removes doafter references

* Balances fireball upgrades to be more reasonable

* Enables refund on master spellbooks

* Spells to do

* update spellbook doafter

* knock toggles bolts

* Touch Spell comments

* Comments for pending spells

* more comments

* adds spider polymorph to spellbook

* TODOs for spells

* reorganizes spellbook categories and adds wands

* fixes spacing and adds limited conditions

* commented owner only for future store PR

* reenables owner only for the grimoire

* fixes grimoire sprite

* Adds wizard rod polymorph

* summon ghosts event

* Moves rod form to offensive category

* Adds charge spell and loc for rod polymorph

* Oops forgor the actual chages

* Item Recall comment

* Fixes UI

* removes extra field for wizard rod

* Cleanup

* New Condition (INCOMPLETE)

* Fix linter

* Fix linter (for real)

* fixed some descriptions

* adds regions to magic

* Adds a non-refund wizard grimoire, fixes blink to deselect after teleporting, reduces force wall despawn time to 12 seconds

* removes limited upgrade condition

---------

Co-authored-by: AJCM <AJCM@tutanota.com>
This commit is contained in:
keronshb
2024-05-11 19:06:49 -04:00
committed by GitHub
parent 73c1420648
commit 999d044139
46 changed files with 1262 additions and 510 deletions

View File

@@ -0,0 +1,5 @@
using Content.Shared.Magic;
namespace Content.Client.Magic;
public sealed class MagicSystem : SharedMagicSystem;

View File

@@ -1,3 +1,4 @@
using System.Linq;
using Content.Shared.Actions;
using Content.Shared.Interaction;
using Robust.Shared.Random;
@@ -38,10 +39,18 @@ public sealed class ActionOnInteractSystem : EntitySystem
private void OnActivate(EntityUid uid, ActionOnInteractComponent component, ActivateInWorldEvent args)
{
if (args.Handled || component.ActionEntities == null)
if (args.Handled)
return;
var options = GetValidActions<InstantActionComponent>(component.ActionEntities);
if (component.ActionEntities is not {} actionEnts)
{
if (!TryComp<ActionsContainerComponent>(uid, out var actionsContainerComponent))
return;
actionEnts = actionsContainerComponent.Container.ContainedEntities.ToList();
}
var options = GetValidActions<InstantActionComponent>(actionEnts);
if (options.Count == 0)
return;
@@ -58,13 +67,21 @@ public sealed class ActionOnInteractSystem : EntitySystem
private void OnAfterInteract(EntityUid uid, ActionOnInteractComponent component, AfterInteractEvent args)
{
if (args.Handled || component.ActionEntities == null)
if (args.Handled)
return;
if (component.ActionEntities is not {} actionEnts)
{
if (!TryComp<ActionsContainerComponent>(uid, out var actionsContainerComponent))
return;
actionEnts = actionsContainerComponent.Container.ContainedEntities.ToList();
}
// First, try entity target actions
if (args.Target != null)
{
var entOptions = GetValidActions<EntityTargetActionComponent>(component.ActionEntities, args.CanReach);
var entOptions = GetValidActions<EntityTargetActionComponent>(actionEnts, args.CanReach);
for (var i = entOptions.Count - 1; i >= 0; i--)
{
var action = entOptions[i];

View File

@@ -78,6 +78,7 @@ namespace Content.Server.Ghost
SubscribeLocalEvent<GhostComponent, InsertIntoEntityStorageAttemptEvent>(OnEntityStorageInsertAttempt);
SubscribeLocalEvent<RoundEndTextAppendEvent>(_ => MakeVisible(true));
SubscribeLocalEvent<ToggleGhostVisibilityToAllEvent>(OnToggleGhostVisibilityToAll);
}
private void OnGhostHearingAction(EntityUid uid, GhostComponent component, ToggleGhostHearingActionEvent args)
@@ -363,6 +364,15 @@ namespace Content.Server.Ghost
args.Cancelled = true;
}
private void OnToggleGhostVisibilityToAll(ToggleGhostVisibilityToAllEvent ev)
{
if (ev.Handled)
return;
ev.Handled = true;
MakeVisible(true);
}
/// <summary>
/// When the round ends, make all players able to see ghosts.
/// </summary>

View File

@@ -1,407 +1,22 @@
using System.Numerics;
using Content.Server.Body.Components;
using Content.Server.Body.Systems;
using Content.Server.Chat.Systems;
using Content.Server.Doors.Systems;
using Content.Server.Magic.Components;
using Content.Server.Weapons.Ranged.Systems;
using Content.Shared.Actions;
using Content.Shared.Body.Components;
using Content.Shared.Coordinates.Helpers;
using Content.Shared.DoAfter;
using Content.Shared.Doors.Components;
using Content.Shared.Doors.Systems;
using Content.Shared.Interaction.Events;
using Content.Shared.Magic;
using Content.Shared.Magic.Events;
using Content.Shared.Maps;
using Content.Shared.Physics;
using Content.Shared.Storage;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Random;
using Robust.Shared.Serialization.Manager;
using Robust.Shared.Spawners;
namespace Content.Server.Magic;
/// <summary>
/// Handles learning and using spells (actions)
/// </summary>
public sealed class MagicSystem : EntitySystem
public sealed class MagicSystem : SharedMagicSystem
{
[Dependency] private readonly ISerializationManager _seriMan = default!;
[Dependency] private readonly IComponentFactory _compFact = default!;
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly BodySystem _bodySystem = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly SharedDoorSystem _doorSystem = default!;
[Dependency] private readonly SharedActionsSystem _actionsSystem = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly GunSystem _gunSystem = default!;
[Dependency] private readonly PhysicsSystem _physics = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly ChatSystem _chat = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<SpellbookComponent, MapInitEvent>(OnInit);
SubscribeLocalEvent<SpellbookComponent, UseInHandEvent>(OnUse);
SubscribeLocalEvent<SpellbookComponent, SpellbookDoAfterEvent>(OnDoAfter);
SubscribeLocalEvent<InstantSpawnSpellEvent>(OnInstantSpawn);
SubscribeLocalEvent<TeleportSpellEvent>(OnTeleportSpell);
SubscribeLocalEvent<KnockSpellEvent>(OnKnockSpell);
SubscribeLocalEvent<SmiteSpellEvent>(OnSmiteSpell);
SubscribeLocalEvent<WorldSpawnSpellEvent>(OnWorldSpawn);
SubscribeLocalEvent<ProjectileSpellEvent>(OnProjectileSpell);
SubscribeLocalEvent<ChangeComponentsSpellEvent>(OnChangeComponentsSpell);
SubscribeLocalEvent<SpeakSpellEvent>(OnSpellSpoken);
}
private void OnDoAfter(EntityUid uid, SpellbookComponent component, DoAfterEvent args)
private void OnSpellSpoken(ref SpeakSpellEvent args)
{
if (args.Handled || args.Cancelled)
return;
args.Handled = true;
if (!component.LearnPermanently)
{
_actionsSystem.GrantActions(args.Args.User, component.Spells, uid);
return;
}
foreach (var (id, charges) in component.SpellActions)
{
// TOOD store spells entity ids on some sort of innate magic user component or something like that.
EntityUid? actionId = null;
if (_actionsSystem.AddAction(args.Args.User, ref actionId, id))
_actionsSystem.SetCharges(actionId, charges < 0 ? null : charges);
}
component.SpellActions.Clear();
}
private void OnInit(EntityUid uid, SpellbookComponent component, MapInitEvent args)
{
if (component.LearnPermanently)
return;
foreach (var (id, charges) in component.SpellActions)
{
var spell = _actionContainer.AddAction(uid, id);
if (spell == null)
continue;
_actionsSystem.SetCharges(spell, charges < 0 ? null : charges);
component.Spells.Add(spell.Value);
}
}
private void OnUse(EntityUid uid, SpellbookComponent component, UseInHandEvent args)
{
if (args.Handled)
return;
AttemptLearn(uid, component, args);
args.Handled = true;
}
private void AttemptLearn(EntityUid uid, SpellbookComponent component, UseInHandEvent args)
{
var doAfterEventArgs = new DoAfterArgs(EntityManager, args.User, component.LearnTime, new SpellbookDoAfterEvent(), uid, target: uid)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true //What, are you going to read with your eyes only??
};
_doAfter.TryStartDoAfter(doAfterEventArgs);
}
#region Spells
/// <summary>
/// Handles the instant action (i.e. on the caster) attempting to spawn an entity.
/// </summary>
private void OnInstantSpawn(InstantSpawnSpellEvent args)
{
if (args.Handled)
return;
var transform = Transform(args.Performer);
foreach (var position in GetSpawnPositions(transform, args.Pos))
{
var ent = Spawn(args.Prototype, position.SnapToGrid(EntityManager, _mapManager));
if (args.PreventCollideWithCaster)
{
var comp = EnsureComp<PreventCollideComponent>(ent);
comp.Uid = args.Performer;
}
}
Speak(args);
args.Handled = true;
}
private void OnProjectileSpell(ProjectileSpellEvent ev)
{
if (ev.Handled)
return;
ev.Handled = true;
Speak(ev);
var xform = Transform(ev.Performer);
var userVelocity = _physics.GetMapLinearVelocity(ev.Performer);
foreach (var pos in GetSpawnPositions(xform, ev.Pos))
{
// If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
var mapPos = pos.ToMap(EntityManager, _transformSystem);
var spawnCoords = _mapManager.TryFindGridAt(mapPos, out var gridUid, out _)
? pos.WithEntityId(gridUid, EntityManager)
: new(_mapManager.GetMapEntityId(mapPos.MapId), mapPos.Position);
var ent = Spawn(ev.Prototype, spawnCoords);
var direction = ev.Target.ToMapPos(EntityManager, _transformSystem) -
spawnCoords.ToMapPos(EntityManager, _transformSystem);
_gunSystem.ShootProjectile(ent, direction, userVelocity, ev.Performer, ev.Performer);
}
}
private void OnChangeComponentsSpell(ChangeComponentsSpellEvent ev)
{
if (ev.Handled)
return;
ev.Handled = true;
Speak(ev);
foreach (var toRemove in ev.ToRemove)
{
if (_compFact.TryGetRegistration(toRemove, out var registration))
RemComp(ev.Target, registration.Type);
}
foreach (var (name, data) in ev.ToAdd)
{
if (HasComp(ev.Target, data.Component.GetType()))
continue;
var component = (Component) _compFact.GetComponent(name);
component.Owner = ev.Target;
var temp = (object) component;
_seriMan.CopyTo(data.Component, ref temp);
EntityManager.AddComponent(ev.Target, (Component) temp!);
}
}
private List<EntityCoordinates> GetSpawnPositions(TransformComponent casterXform, MagicSpawnData data)
{
switch (data)
{
case TargetCasterPos:
return new List<EntityCoordinates>(1) {casterXform.Coordinates};
case TargetInFront:
{
// This is shit but you get the idea.
var directionPos = casterXform.Coordinates.Offset(casterXform.LocalRotation.ToWorldVec().Normalized());
if (!TryComp<MapGridComponent>(casterXform.GridUid, out var mapGrid))
return new List<EntityCoordinates>();
if (!directionPos.TryGetTileRef(out var tileReference, EntityManager, _mapManager))
return new List<EntityCoordinates>();
var tileIndex = tileReference.Value.GridIndices;
var coords = mapGrid.GridTileToLocal(tileIndex);
EntityCoordinates coordsPlus;
EntityCoordinates coordsMinus;
var dir = casterXform.LocalRotation.GetCardinalDir();
switch (dir)
{
case Direction.North:
case Direction.South:
{
coordsPlus = mapGrid.GridTileToLocal(tileIndex + (1, 0));
coordsMinus = mapGrid.GridTileToLocal(tileIndex + (-1, 0));
return new List<EntityCoordinates>(3)
{
coords,
coordsPlus,
coordsMinus,
};
}
case Direction.East:
case Direction.West:
{
coordsPlus = mapGrid.GridTileToLocal(tileIndex + (0, 1));
coordsMinus = mapGrid.GridTileToLocal(tileIndex + (0, -1));
return new List<EntityCoordinates>(3)
{
coords,
coordsPlus,
coordsMinus,
};
}
}
return new List<EntityCoordinates>();
}
default:
throw new ArgumentOutOfRangeException();
}
}
/// <summary>
/// Teleports the user to the clicked location
/// </summary>
/// <param name="args"></param>
private void OnTeleportSpell(TeleportSpellEvent args)
{
if (args.Handled)
return;
var transform = Transform(args.Performer);
if (transform.MapID != args.Target.GetMapId(EntityManager)) return;
_transformSystem.SetCoordinates(args.Performer, args.Target);
transform.AttachToGridOrMap();
_audio.PlayPvs(args.BlinkSound, args.Performer, AudioParams.Default.WithVolume(args.BlinkVolume));
Speak(args);
args.Handled = true;
}
/// <summary>
/// Opens all doors within range
/// </summary>
/// <param name="args"></param>
private void OnKnockSpell(KnockSpellEvent args)
{
if (args.Handled)
return;
args.Handled = true;
Speak(args);
//Get the position of the player
var transform = Transform(args.Performer);
var coords = transform.Coordinates;
_audio.PlayPvs(args.KnockSound, args.Performer, AudioParams.Default.WithVolume(args.KnockVolume));
//Look for doors and don't open them if they're already open.
foreach (var entity in _lookup.GetEntitiesInRange(coords, args.Range))
{
if (TryComp<DoorBoltComponent>(entity, out var bolts))
_doorSystem.SetBoltsDown((entity, bolts), false);
if (TryComp<DoorComponent>(entity, out var doorComp) && doorComp.State is not DoorState.Open)
_doorSystem.StartOpening(entity);
}
}
private void OnSmiteSpell(SmiteSpellEvent ev)
{
if (ev.Handled)
return;
ev.Handled = true;
Speak(ev);
var direction = Transform(ev.Target).MapPosition.Position - Transform(ev.Performer).MapPosition.Position;
var impulseVector = direction * 10000;
_physics.ApplyLinearImpulse(ev.Target, impulseVector);
if (!TryComp<BodyComponent>(ev.Target, out var body))
return;
var ents = _bodySystem.GibBody(ev.Target, true, body);
if (!ev.DeleteNonBrainParts)
return;
foreach (var part in ents)
{
// just leaves a brain and clothes
if (HasComp<BodyComponent>(part) && !HasComp<BrainComponent>(part))
{
QueueDel(part);
}
}
}
/// <summary>
/// Spawns entity prototypes from a list within range of click.
/// </summary>
/// <remarks>
/// It will offset mobs after the first mob based on the OffsetVector2 property supplied.
/// </remarks>
/// <param name="args"> The Spawn Spell Event args.</param>
private void OnWorldSpawn(WorldSpawnSpellEvent args)
{
if (args.Handled)
return;
var targetMapCoords = args.Target;
SpawnSpellHelper(args.Contents, targetMapCoords, args.Lifetime, args.Offset);
Speak(args);
args.Handled = true;
}
/// <summary>
/// Loops through a supplied list of entity prototypes and spawns them
/// </summary>
/// <remarks>
/// If an offset of 0, 0 is supplied then the entities will all spawn on the same tile.
/// Any other offset will spawn entities starting from the source Map Coordinates and will increment the supplied
/// offset
/// </remarks>
/// <param name="entityEntries"> The list of Entities to spawn in</param>
/// <param name="entityCoords"> Map Coordinates where the entities will spawn</param>
/// <param name="lifetime"> Check to see if the entities should self delete</param>
/// <param name="offsetVector2"> A Vector2 offset that the entities will spawn in</param>
private void SpawnSpellHelper(List<EntitySpawnEntry> entityEntries, EntityCoordinates entityCoords, float? lifetime, Vector2 offsetVector2)
{
var getProtos = EntitySpawnCollection.GetSpawns(entityEntries, _random);
var offsetCoords = entityCoords;
foreach (var proto in getProtos)
{
// TODO: Share this code with instant because they're both doing similar things for positioning.
var entity = Spawn(proto, offsetCoords);
offsetCoords = offsetCoords.Offset(offsetVector2);
if (lifetime != null)
{
var comp = EnsureComp<TimedDespawnComponent>(entity);
comp.Lifetime = lifetime.Value;
}
}
}
#endregion
private void Speak(BaseActionEvent args)
{
if (args is not ISpeakSpell speak || string.IsNullOrWhiteSpace(speak.Speech))
return;
_chat.TrySendInGameICMessage(args.Performer, Loc.GetString(speak.Speech),
InGameICChatType.Speak, false);
_chat.TrySendInGameICMessage(args.Performer, Loc.GetString(args.Speech), InGameICChatType.Speak, false);
}
}

View File

@@ -1,4 +1,4 @@
using Content.Server.Store.Components;
using Content.Server.Store.Components;
using Robust.Shared.Containers;
namespace Content.Server.Store.Systems;

View File

@@ -215,11 +215,11 @@ public sealed partial class StoreSystem
{
HandleRefundComp(uid, component, actionId.Value);
if (listing.ProductUpgradeID != null)
if (listing.ProductUpgradeId != null)
{
foreach (var upgradeListing in component.Listings)
{
if (upgradeListing.ID == listing.ProductUpgradeID)
if (upgradeListing.ID == listing.ProductUpgradeId)
{
upgradeListing.ProductActionEntity = actionId.Value;
break;
@@ -229,7 +229,7 @@ public sealed partial class StoreSystem
}
}
if (listing is { ProductUpgradeID: not null, ProductActionEntity: not null })
if (listing is { ProductUpgradeId: not null, ProductActionEntity: not null })
{
if (listing.ProductActionEntity != null)
{

View File

@@ -157,7 +157,7 @@ public abstract partial class BaseActionEvent : HandledEntityEventArgs
public EntityUid Performer;
/// <summary>
/// The action that was performed.
/// The action the event belongs to.
/// </summary>
public EntityUid Action;
}

View File

@@ -569,13 +569,12 @@ public abstract class SharedActionsSystem : EntitySystem
handled = actionEvent.Handled;
}
_audio.PlayPredicted(action.Sound, performer,predicted ? performer : null);
handled |= action.Sound != null;
if (!handled)
return; // no interaction occurred.
// reduce charges, start cooldown, and mark as dirty (if required).
// play sound, reduce charges, start cooldown, and mark as dirty (if required).
_audio.PlayPredicted(action.Sound, performer,predicted ? performer : null);
var dirty = toggledBefore == action.Toggled;

View File

@@ -101,4 +101,6 @@ public sealed partial class ToggleLightingActionEvent : InstantActionEvent { }
public sealed partial class ToggleGhostHearingActionEvent : InstantActionEvent { }
public sealed partial class ToggleGhostVisibilityToAllEvent : InstantActionEvent { }
public sealed partial class BooActionEvent : InstantActionEvent { }

View File

@@ -0,0 +1,39 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Magic.Components;
// TODO: Rename to MagicActionComponent or MagicRequirementsComponent
[RegisterComponent, NetworkedComponent, Access(typeof(SharedMagicSystem))]
public sealed partial class MagicComponent : Component
{
// TODO: Split into different components?
// This could be the MagicRequirementsComp - which just is requirements for the spell
// Magic comp could be on the actual entities itself
// Could handle lifetime, ignore caster, etc?
// Magic caster comp would be on the caster, used for what I'm not sure
// TODO: Do After here or in actions
// TODO: Spell requirements
// A list of requirements to cast the spell
// Hands
// Any item in hand
// Spell takes up an inhand slot
// May be an action toggle or something
// TODO: List requirements in action desc
/// <summary>
/// Does this spell require Wizard Robes & Hat?
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
public bool RequiresClothes;
/// <summary>
/// Does this spell require the user to speak?
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
public bool RequiresSpeech;
// TODO: FreeHand - should check if toggleable action
// Check which hand is free to toggle action in
}

View File

@@ -1,12 +1,13 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;
namespace Content.Server.Magic.Components;
namespace Content.Shared.Magic.Components;
/// <summary>
/// Spellbooks for having an entity learn spells as long as they've read the book and it's in their hand.
/// Spellbooks can grant one or more spells to the user. If marked as <see cref="LearnPermanently"/> it will teach
/// the performer the spells and wipe the book.
/// Default behavior requires the book to be held in hand
/// </summary>
[RegisterComponent]
[RegisterComponent, Access(typeof(SpellbookSystem))]
public sealed partial class SpellbookComponent : Component
{
/// <summary>
@@ -18,18 +19,18 @@ public sealed partial class SpellbookComponent : Component
/// <summary>
/// The three fields below is just used for initialization.
/// </summary>
[DataField("spells", customTypeSerializer: typeof(PrototypeIdDictionarySerializer<int, EntityPrototype>))]
[DataField]
[ViewVariables(VVAccess.ReadWrite)]
public Dictionary<string, int> SpellActions = new();
public Dictionary<EntProtoId, int> SpellActions = new();
[DataField("learnTime")]
[DataField]
[ViewVariables(VVAccess.ReadWrite)]
public float LearnTime = .75f;
/// <summary>
/// If true, the spell action stays even after the book is removed
/// </summary>
[DataField("learnPermanently")]
[DataField]
[ViewVariables(VVAccess.ReadWrite)]
public bool LearnPermanently;
}

View File

@@ -0,0 +1,10 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Magic.Components;
/// <summary>
/// The <see cref="SharedMagicSystem"/> checks this if a spell requires wizard clothes
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(SharedMagicSystem))]
public sealed partial class WizardClothesComponent : Component;

View File

@@ -0,0 +1,12 @@
namespace Content.Shared.Magic.Events;
[ByRefEvent]
public struct BeforeCastSpellEvent(EntityUid performer)
{
/// <summary>
/// The Performer of the event, to check if they meet the requirements.
/// </summary>
public EntityUid Performer = performer;
public bool Cancelled;
}

View File

@@ -0,0 +1,18 @@
using Content.Shared.Actions;
namespace Content.Shared.Magic.Events;
/// <summary>
/// Adds provided Charge to the held wand
/// </summary>
public sealed partial class ChargeSpellEvent : InstantActionEvent, ISpeakSpell
{
[DataField(required: true)]
public int Charge;
[DataField]
public string WandTag = "WizardWand";
[DataField]
public string? Speech { get; private set; }
}

View File

@@ -1,6 +1,5 @@
using Content.Shared.Actions;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Magic.Events;
@@ -9,17 +8,18 @@ public sealed partial class InstantSpawnSpellEvent : InstantActionEvent, ISpeakS
/// <summary>
/// What entity should be spawned.
/// </summary>
[DataField("prototype", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string Prototype = default!;
[DataField(required: true)]
public EntProtoId Prototype;
[DataField("preventCollide")]
[DataField]
public bool PreventCollideWithCaster = true;
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
/// <summary>
/// Gets the targeted spawn positons; may lead to multiple entities being spawned.
/// </summary>
[DataField("posData")] public MagicSpawnData Pos = new TargetCasterPos();
[DataField]
public MagicInstantSpawnData PosData = new TargetCasterPos();
}

View File

@@ -1,5 +1,4 @@
using Content.Shared.Actions;
using Robust.Shared.Audio;
namespace Content.Shared.Magic.Events;
@@ -7,20 +6,12 @@ public sealed partial class KnockSpellEvent : InstantActionEvent, ISpeakSpell
{
/// <summary>
/// The range this spell opens doors in
/// 4f is the default
/// 10f is the default
/// Should be able to open all doors/lockers in visible sight
/// </summary>
[DataField("range")]
public float Range = 4f;
[DataField]
public float Range = 10f;
[DataField("knockSound")]
public SoundSpecifier KnockSound = new SoundPathSpecifier("/Audio/Magic/knock.ogg");
/// <summary>
/// Volume control for the spell.
/// </summary>
[DataField("knockVolume")]
public float KnockVolume = 5f;
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
}

View File

@@ -1,6 +1,5 @@
using Content.Shared.Actions;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Magic.Events;
@@ -9,14 +8,9 @@ public sealed partial class ProjectileSpellEvent : WorldTargetActionEvent, ISpea
/// <summary>
/// What entity should be spawned.
/// </summary>
[DataField("prototype", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string Prototype = default!;
[DataField(required: true)]
public EntProtoId Prototype;
/// <summary>
/// Gets the targeted spawn positions; may lead to multiple entities being spawned.
/// </summary>
[DataField("posData")] public MagicSpawnData Pos = new TargetCasterPos();
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
}

View File

@@ -4,12 +4,13 @@ namespace Content.Shared.Magic.Events;
public sealed partial class SmiteSpellEvent : EntityTargetActionEvent, ISpeakSpell
{
// TODO: Make part of gib method
/// <summary>
/// Should this smite delete all parts/mechanisms gibbed except for the brain?
/// </summary>
[DataField("deleteNonBrainParts")]
[DataField]
public bool DeleteNonBrainParts = true;
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
}

View File

@@ -0,0 +1,8 @@
namespace Content.Shared.Magic.Events;
[ByRefEvent]
public readonly struct SpeakSpellEvent(EntityUid performer, string speech)
{
public readonly EntityUid Performer = performer;
public readonly string Speech = speech;
}

View File

@@ -1,19 +1,19 @@
using Content.Shared.Actions;
using Robust.Shared.Audio;
namespace Content.Shared.Magic.Events;
// TODO: Can probably just be an entity or something
public sealed partial class TeleportSpellEvent : WorldTargetActionEvent, ISpeakSpell
{
[DataField("blinkSound")]
public SoundSpecifier BlinkSound = new SoundPathSpecifier("/Audio/Magic/blink.ogg");
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
// TODO: Move to magic component
// TODO: Maybe not since sound specifier is a thing
// Keep here to remind what the volume was set as
/// <summary>
/// Volume control for the spell.
/// </summary>
[DataField("blinkVolume")]
[DataField]
public float BlinkVolume = 5f;
}

View File

@@ -4,29 +4,31 @@ using Content.Shared.Storage;
namespace Content.Shared.Magic.Events;
// TODO: This class needs combining with InstantSpawnSpellEvent
public sealed partial class WorldSpawnSpellEvent : WorldTargetActionEvent, ISpeakSpell
{
// TODO:This class needs combining with InstantSpawnSpellEvent
/// <summary>
/// The list of prototypes this spell will spawn
/// </summary>
[DataField("prototypes")]
public List<EntitySpawnEntry> Contents = new();
[DataField]
public List<EntitySpawnEntry> Prototypes = new();
// TODO: This offset is liable for deprecation.
// TODO: Target tile via code instead?
/// <summary>
/// The offset the prototypes will spawn in on relative to the one prior.
/// Set to 0,0 to have them spawn on the same tile.
/// </summary>
[DataField("offset")]
[DataField]
public Vector2 Offset;
/// <summary>
/// Lifetime to set for the entities to self delete
/// </summary>
[DataField("lifetime")] public float? Lifetime;
[DataField]
public float? Lifetime;
[DataField("speech")]
[DataField]
public string? Speech { get; private set; }
}

View File

@@ -0,0 +1,25 @@
namespace Content.Shared.Magic;
// TODO: If still needed, move to magic component
[ImplicitDataDefinitionForInheritors]
public abstract partial class MagicInstantSpawnData;
/// <summary>
/// Spawns underneath caster.
/// </summary>
public sealed partial class TargetCasterPos : MagicInstantSpawnData;
/// <summary>
/// Spawns 3 tiles wide in front of the caster.
/// </summary>
public sealed partial class TargetInFront : MagicInstantSpawnData
{
[DataField]
public int Width = 3;
}
/// <summary>
/// Spawns 1 tile in front of caster
/// </summary>
public sealed partial class TargetInFrontSingle : MagicInstantSpawnData;

View File

@@ -1,20 +0,0 @@
namespace Content.Shared.Magic;
[ImplicitDataDefinitionForInheritors]
public abstract partial class MagicSpawnData
{
}
/// <summary>
/// Spawns 1 at the caster's feet.
/// </summary>
public sealed partial class TargetCasterPos : MagicSpawnData {}
/// <summary>
/// Targets the 3 tiles in front of the caster.
/// </summary>
public sealed partial class TargetInFront : MagicSpawnData
{
[DataField("width")] public int Width = 3;
}

View File

@@ -0,0 +1,519 @@
using System.Numerics;
using Content.Shared.Actions;
using Content.Shared.Body.Components;
using Content.Shared.Body.Systems;
using Content.Shared.Coordinates.Helpers;
using Content.Shared.Doors.Components;
using Content.Shared.Doors.Systems;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Interaction;
using Content.Shared.Inventory;
using Content.Shared.Lock;
using Content.Shared.Magic.Components;
using Content.Shared.Magic.Events;
using Content.Shared.Maps;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.Speech.Muting;
using Content.Shared.Storage;
using Content.Shared.Tag;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Systems;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Network;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Random;
using Robust.Shared.Serialization.Manager;
using Robust.Shared.Spawners;
namespace Content.Shared.Magic;
/// <summary>
/// Handles learning and using spells (actions)
/// </summary>
public abstract class SharedMagicSystem : EntitySystem
{
[Dependency] private readonly ISerializationManager _seriMan = default!;
[Dependency] private readonly IComponentFactory _compFact = default!;
[Dependency] private readonly IMapManager _mapManager = default!;
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly SharedGunSystem _gunSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly SharedBodySystem _body = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly SharedDoorSystem _door = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
[Dependency] private readonly LockSystem _lock = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly TagSystem _tag = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<MagicComponent, BeforeCastSpellEvent>(OnBeforeCastSpell);
SubscribeLocalEvent<InstantSpawnSpellEvent>(OnInstantSpawn);
SubscribeLocalEvent<TeleportSpellEvent>(OnTeleportSpell);
SubscribeLocalEvent<WorldSpawnSpellEvent>(OnWorldSpawn);
SubscribeLocalEvent<ProjectileSpellEvent>(OnProjectileSpell);
SubscribeLocalEvent<ChangeComponentsSpellEvent>(OnChangeComponentsSpell);
SubscribeLocalEvent<SmiteSpellEvent>(OnSmiteSpell);
SubscribeLocalEvent<KnockSpellEvent>(OnKnockSpell);
SubscribeLocalEvent<ChargeSpellEvent>(OnChargeSpell);
// Spell wishlist
// A wishlish of spells that I'd like to implement or planning on implementing in a future PR
// TODO: InstantDoAfterSpell and WorldDoafterSpell
// Both would be an action that take in an event, that passes an event to trigger once the doafter is done
// This would be three events:
// 1 - Event that triggers from the action that starts the doafter
// 2 - The doafter event itself, which passes the event with it
// 3 - The event to trigger once the do-after finishes
// TODO: Inanimate objects to life ECS
// AI sentience
// TODO: Flesh2Stone
// Entity Target spell
// Synergy with Inanimate object to life (detects player and allows player to move around)
// TODO: Lightning Spell
// Should just fire lightning, try to prevent arc back to caster
// TODO: Magic Missile (homing projectile ecs)
// Instant action, target any player (except self) on screen
// TODO: Random projectile ECS for magic-carp, wand of magic
// TODO: Recall Spell
// mark any item in hand to recall
// ItemRecallComponent
// Event adds the component if it doesn't exist and the performer isn't stored in the comp
// 2nd firing of the event checks to see if the recall comp has this uid, and if it does it calls it
// if no free hands, summon at feet
// if item deleted, clear stored item
// TODO: Jaunt (should be its own ECS)
// Instant action
// When clicked, disappear/reappear (goes to paused map)
// option to restrict to tiles
// option for requiring entry/exit (blood jaunt)
// speed option
// TODO: Summon Events
// List of wizard events to add into the event pool that frequently activate
// floor is lava
// change places
// ECS that when triggered, will periodically trigger a random GameRule
// Would need a controller/controller entity?
// TODO: Summon Guns
// Summon a random gun at peoples feet
// Get every alive player (not in cryo, not a simplemob)
// TODO: After Antag Rework - Rare chance of giving gun collector status to people
// TODO: Summon Magic
// Summon a random magic wand at peoples feet
// Get every alive player (not in cryo, not a simplemob)
// TODO: After Antag Rework - Rare chance of giving magic collector status to people
// TODO: Bottle of Blood
// Summons Slaughter Demon
// TODO: Slaughter Demon
// Also see Jaunt
// TODO: Field Spells
// Should be able to specify a grid of tiles (3x3 for example) that it effects
// Timed despawn - so it doesn't last forever
// Ignore caster - for spells that shouldn't effect the caster (ie if timestop should effect the caster)
// TODO: Touch toggle spell
// 1 - When toggled on, show in hand
// 2 - Block hand when toggled on
// - Require free hand
// 3 - use spell event when toggled & click
}
private void OnBeforeCastSpell(Entity<MagicComponent> ent, ref BeforeCastSpellEvent args)
{
var comp = ent.Comp;
var hasReqs = true;
if (comp.RequiresClothes)
{
var enumerator = _inventory.GetSlotEnumerator(args.Performer, SlotFlags.OUTERCLOTHING | SlotFlags.HEAD);
while (enumerator.MoveNext(out var containerSlot))
{
if (containerSlot.ContainedEntity is { } item)
hasReqs = HasComp<WizardClothesComponent>(item);
else
hasReqs = false;
if (!hasReqs)
break;
}
}
if (comp.RequiresSpeech && HasComp<MutedComponent>(args.Performer))
hasReqs = false;
if (hasReqs)
return;
args.Cancelled = true;
_popup.PopupClient(Loc.GetString("spell-requirements-failed"), args.Performer, args.Performer);
// TODO: Pre-cast do after, either here or in SharedActionsSystem
}
private bool PassesSpellPrerequisites(EntityUid spell, EntityUid performer)
{
var ev = new BeforeCastSpellEvent(performer);
RaiseLocalEvent(spell, ref ev);
return !ev.Cancelled;
}
#region Spells
#region Instant Spawn Spells
/// <summary>
/// Handles the instant action (i.e. on the caster) attempting to spawn an entity.
/// </summary>
private void OnInstantSpawn(InstantSpawnSpellEvent args)
{
if (args.Handled || !PassesSpellPrerequisites(args.Action, args.Performer))
return;
var transform = Transform(args.Performer);
foreach (var position in GetInstantSpawnPositions(transform, args.PosData))
{
SpawnSpellHelper(args.Prototype, position, args.Performer, preventCollide: args.PreventCollideWithCaster);
}
Speak(args);
args.Handled = true;
}
/// <summary>
/// Gets spawn positions listed on <see cref="InstantSpawnSpellEvent"/>
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"></exception>
private List<EntityCoordinates> GetInstantSpawnPositions(TransformComponent casterXform, MagicInstantSpawnData data)
{
switch (data)
{
case TargetCasterPos:
return new List<EntityCoordinates>(1) {casterXform.Coordinates};
case TargetInFrontSingle:
{
var directionPos = casterXform.Coordinates.Offset(casterXform.LocalRotation.ToWorldVec().Normalized());
if (!TryComp<MapGridComponent>(casterXform.GridUid, out var mapGrid))
return new List<EntityCoordinates>();
if (!directionPos.TryGetTileRef(out var tileReference, EntityManager, _mapManager))
return new List<EntityCoordinates>();
var tileIndex = tileReference.Value.GridIndices;
return new List<EntityCoordinates>(1) { _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex) };
}
case TargetInFront:
{
var directionPos = casterXform.Coordinates.Offset(casterXform.LocalRotation.ToWorldVec().Normalized());
if (!TryComp<MapGridComponent>(casterXform.GridUid, out var mapGrid))
return new List<EntityCoordinates>();
if (!directionPos.TryGetTileRef(out var tileReference, EntityManager, _mapManager))
return new List<EntityCoordinates>();
var tileIndex = tileReference.Value.GridIndices;
var coords = _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex);
EntityCoordinates coordsPlus;
EntityCoordinates coordsMinus;
var dir = casterXform.LocalRotation.GetCardinalDir();
switch (dir)
{
case Direction.North:
case Direction.South:
{
coordsPlus = _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex + (1, 0));
coordsMinus = _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex + (-1, 0));
return new List<EntityCoordinates>(3)
{
coords,
coordsPlus,
coordsMinus,
};
}
case Direction.East:
case Direction.West:
{
coordsPlus = _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex + (0, 1));
coordsMinus = _mapSystem.GridTileToLocal(casterXform.GridUid.Value, mapGrid, tileIndex + (0, -1));
return new List<EntityCoordinates>(3)
{
coords,
coordsPlus,
coordsMinus,
};
}
}
return new List<EntityCoordinates>();
}
default:
throw new ArgumentOutOfRangeException();
}
}
// End Instant Spawn Spells
#endregion
#region World Spawn Spells
/// <summary>
/// Spawns entities from a list within range of click.
/// </summary>
/// <remarks>
/// It will offset entities after the first entity based on the OffsetVector2.
/// </remarks>
/// <param name="args"> The Spawn Spell Event args.</param>
private void OnWorldSpawn(WorldSpawnSpellEvent args)
{
if (args.Handled || !PassesSpellPrerequisites(args.Action, args.Performer))
return;
var targetMapCoords = args.Target;
WorldSpawnSpellHelper(args.Prototypes, targetMapCoords, args.Performer, args.Lifetime, args.Offset);
Speak(args);
args.Handled = true;
}
/// <summary>
/// Loops through a supplied list of entity prototypes and spawns them
/// </summary>
/// <remarks>
/// If an offset of 0, 0 is supplied then the entities will all spawn on the same tile.
/// Any other offset will spawn entities starting from the source Map Coordinates and will increment the supplied
/// offset
/// </remarks>
/// <param name="entityEntries"> The list of Entities to spawn in</param>
/// <param name="entityCoords"> Map Coordinates where the entities will spawn</param>
/// <param name="lifetime"> Check to see if the entities should self delete</param>
/// <param name="offsetVector2"> A Vector2 offset that the entities will spawn in</param>
private void WorldSpawnSpellHelper(List<EntitySpawnEntry> entityEntries, EntityCoordinates entityCoords, EntityUid performer, float? lifetime, Vector2 offsetVector2)
{
var getProtos = EntitySpawnCollection.GetSpawns(entityEntries, _random);
var offsetCoords = entityCoords;
foreach (var proto in getProtos)
{
SpawnSpellHelper(proto, offsetCoords, performer, lifetime);
offsetCoords = offsetCoords.Offset(offsetVector2);
}
}
// End World Spawn Spells
#endregion
#region Projectile Spells
private void OnProjectileSpell(ProjectileSpellEvent ev)
{
if (ev.Handled || !PassesSpellPrerequisites(ev.Action, ev.Performer) || !_net.IsServer)
return;
ev.Handled = true;
Speak(ev);
var xform = Transform(ev.Performer);
var fromCoords = xform.Coordinates;
var toCoords = ev.Target;
var userVelocity = _physics.GetMapLinearVelocity(ev.Performer);
// If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
var fromMap = fromCoords.ToMap(EntityManager, _transform);
var spawnCoords = _mapManager.TryFindGridAt(fromMap, out var gridUid, out _)
? fromCoords.WithEntityId(gridUid, EntityManager)
: new(_mapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
var ent = Spawn(ev.Prototype, spawnCoords);
var direction = toCoords.ToMapPos(EntityManager, _transform) -
spawnCoords.ToMapPos(EntityManager, _transform);
_gunSystem.ShootProjectile(ent, direction, userVelocity, ev.Performer, ev.Performer);
}
// End Projectile Spells
#endregion
#region Change Component Spells
// staves.yml ActionRGB light
private void OnChangeComponentsSpell(ChangeComponentsSpellEvent ev)
{
if (ev.Handled || !PassesSpellPrerequisites(ev.Action, ev.Performer))
return;
ev.Handled = true;
Speak(ev);
foreach (var toRemove in ev.ToRemove)
{
if (_compFact.TryGetRegistration(toRemove, out var registration))
RemComp(ev.Target, registration.Type);
}
foreach (var (name, data) in ev.ToAdd)
{
if (HasComp(ev.Target, data.Component.GetType()))
continue;
var component = (Component) _compFact.GetComponent(name);
component.Owner = ev.Target;
var temp = (object) component;
_seriMan.CopyTo(data.Component, ref temp);
EntityManager.AddComponent(ev.Target, (Component) temp!);
}
}
// End Change Component Spells
#endregion
#region Teleport Spells
// TODO: Rename to teleport clicked spell?
/// <summary>
/// Teleports the user to the clicked location
/// </summary>
/// <param name="args"></param>
private void OnTeleportSpell(TeleportSpellEvent args)
{
if (args.Handled || !PassesSpellPrerequisites(args.Action, args.Performer))
return;
var transform = Transform(args.Performer);
if (transform.MapID != args.Target.GetMapId(EntityManager) || !_interaction.InRangeUnobstructed(args.Performer, args.Target, range: 1000F, collisionMask: CollisionGroup.Opaque, popup: true))
return;
_transform.SetCoordinates(args.Performer, args.Target);
_transform.AttachToGridOrMap(args.Performer, transform);
Speak(args);
args.Handled = true;
}
// End Teleport Spells
#endregion
#region Spell Helpers
private void SpawnSpellHelper(string? proto, EntityCoordinates position, EntityUid performer, float? lifetime = null, bool preventCollide = false)
{
if (!_net.IsServer)
return;
var ent = Spawn(proto, position.SnapToGrid(EntityManager, _mapManager));
if (lifetime != null)
{
var comp = EnsureComp<TimedDespawnComponent>(ent);
comp.Lifetime = lifetime.Value;
}
if (preventCollide)
{
var comp = EnsureComp<PreventCollideComponent>(ent);
comp.Uid = performer;
}
}
// End Spell Helpers
#endregion
#region Smite Spells
private void OnSmiteSpell(SmiteSpellEvent ev)
{
if (ev.Handled || !PassesSpellPrerequisites(ev.Action, ev.Performer))
return;
ev.Handled = true;
Speak(ev);
var direction = _transform.GetMapCoordinates(ev.Target, Transform(ev.Target)).Position - _transform.GetMapCoordinates(ev.Performer, Transform(ev.Performer)).Position;
var impulseVector = direction * 10000;
_physics.ApplyLinearImpulse(ev.Target, impulseVector);
if (!TryComp<BodyComponent>(ev.Target, out var body))
return;
_body.GibBody(ev.Target, true, body);
}
// End Smite Spells
#endregion
#region Knock Spells
/// <summary>
/// Opens all doors and locks within range
/// </summary>
/// <param name="args"></param>
private void OnKnockSpell(KnockSpellEvent args)
{
if (args.Handled || !PassesSpellPrerequisites(args.Action, args.Performer))
return;
args.Handled = true;
Speak(args);
var transform = Transform(args.Performer);
// Look for doors and lockers, and don't open/unlock them if they're already opened/unlocked.
foreach (var target in _lookup.GetEntitiesInRange(_transform.GetMapCoordinates(args.Performer, transform), args.Range, flags: LookupFlags.Dynamic | LookupFlags.Static))
{
if (!_interaction.InRangeUnobstructed(args.Performer, target, range: 0, collisionMask: CollisionGroup.Opaque))
continue;
if (TryComp<DoorBoltComponent>(target, out var doorBoltComp) && doorBoltComp.BoltsDown)
_door.SetBoltsDown((target, doorBoltComp), false, predicted: true);
if (TryComp<DoorComponent>(target, out var doorComp) && doorComp.State is not DoorState.Open)
_door.StartOpening(target);
if (TryComp<LockComponent>(target, out var lockComp) && lockComp.Locked)
_lock.Unlock(target, args.Performer, lockComp);
}
}
// End Knock Spells
#endregion
#region Charge Spells
// TODO: Future support to charge other items
private void OnChargeSpell(ChargeSpellEvent ev)
{
if (ev.Handled || !PassesSpellPrerequisites(ev.Action, ev.Performer) || !TryComp<HandsComponent>(ev.Performer, out var handsComp))
return;
EntityUid? wand = null;
foreach (var item in _hands.EnumerateHeld(ev.Performer, handsComp))
{
if (!_tag.HasTag(item, ev.WandTag))
continue;
wand = item;
}
ev.Handled = true;
Speak(ev);
if (wand == null || !TryComp<BasicEntityAmmoProviderComponent>(wand, out var basicAmmoComp) || basicAmmoComp.Count == null)
return;
_gunSystem.UpdateBasicEntityAmmoCount(wand.Value, basicAmmoComp.Count.Value + ev.Charge, basicAmmoComp);
}
// End Charge Spells
#endregion
// End Spells
#endregion
// When any spell is cast it will raise this as an event, so then it can be played in server or something. At least until chat gets moved to shared
// TODO: Temp until chat is in shared
private void Speak(BaseActionEvent args)
{
if (args is not ISpeakSpell speak || string.IsNullOrWhiteSpace(speak.Speech))
return;
var ev = new SpeakSpellEvent(args.Performer, speak.Speech);
RaiseLocalEvent(ref ev);
}
}

View File

@@ -0,0 +1,96 @@
using Content.Shared.Actions;
using Content.Shared.DoAfter;
using Content.Shared.Interaction.Events;
using Content.Shared.Magic.Components;
using Content.Shared.Mind;
using Robust.Shared.Network;
namespace Content.Shared.Magic;
public sealed class SpellbookSystem : EntitySystem
{
[Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedActionsSystem _actions = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly INetManager _netManager = default!;
public override void Initialize()
{
SubscribeLocalEvent<SpellbookComponent, MapInitEvent>(OnInit, before: [typeof(SharedMagicSystem)]);
SubscribeLocalEvent<SpellbookComponent, UseInHandEvent>(OnUse);
SubscribeLocalEvent<SpellbookComponent, SpellbookDoAfterEvent>(OnDoAfter);
}
private void OnInit(Entity<SpellbookComponent> ent, ref MapInitEvent args)
{
foreach (var (id, charges) in ent.Comp.SpellActions)
{
var spell = _actionContainer.AddAction(ent, id);
if (spell == null)
continue;
int? charge = charges;
if (_actions.GetCharges(spell) != null)
charge = _actions.GetCharges(spell);
_actions.SetCharges(spell, charge < 0 ? null : charge);
ent.Comp.Spells.Add(spell.Value);
}
}
private void OnUse(Entity<SpellbookComponent> ent, ref UseInHandEvent args)
{
if (args.Handled)
return;
AttemptLearn(ent, args);
args.Handled = true;
}
private void OnDoAfter<T>(Entity<SpellbookComponent> ent, ref T args) where T : DoAfterEvent // Sometimes i despise this language
{
if (args.Handled || args.Cancelled)
return;
args.Handled = true;
if (!ent.Comp.LearnPermanently)
{
_actions.GrantActions(args.Args.User, ent.Comp.Spells, ent);
return;
}
if (_mind.TryGetMind(args.Args.User, out var mindId, out _))
{
var mindActionContainerComp = EnsureComp<ActionsContainerComponent>(mindId);
if (_netManager.IsServer)
_actionContainer.TransferAllActionsWithNewAttached(ent, mindId, args.Args.User, newContainer: mindActionContainerComp);
}
else
{
foreach (var (id, charges) in ent.Comp.SpellActions)
{
EntityUid? actionId = null;
if (_actions.AddAction(args.Args.User, ref actionId, id))
_actions.SetCharges(actionId, charges < 0 ? null : charges);
}
}
ent.Comp.SpellActions.Clear();
}
private void AttemptLearn(Entity<SpellbookComponent> ent, UseInHandEvent args)
{
var doAfterEventArgs = new DoAfterArgs(EntityManager, args.User, ent.Comp.LearnTime, new SpellbookDoAfterEvent(), ent, target: ent)
{
BreakOnMove = true,
BreakOnDamage = true,
NeedHand = true //What, are you going to read with your eyes only??
};
_doAfter.TryStartDoAfter(doAfterEventArgs);
}
}

View File

@@ -79,7 +79,7 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
/// upgrade or to use standalone as an upgrade
/// </summary>
[DataField]
public ProtoId<ListingPrototype>? ProductUpgradeID;
public ProtoId<ListingPrototype>? ProductUpgradeId;
/// <summary>
/// Keeps track of the current action entity this is tied to, for action upgrades
@@ -161,7 +161,7 @@ public partial class ListingData : IEquatable<ListingData>, ICloneable
Priority = Priority,
ProductEntity = ProductEntity,
ProductAction = ProductAction,
ProductUpgradeID = ProductUpgradeID,
ProductUpgradeId = ProductUpgradeId,
ProductActionEntity = ProductActionEntity,
ProductEvent = ProductEvent,
PurchaseAmount = PurchaseAmount,

View File

@@ -0,0 +1 @@
spell-requirements-failed = Missing requirements to cast this spell!

View File

@@ -15,3 +15,11 @@ store-category-pointless = Pointless
# Revenant
store-category-abilities = Abilities
# Wizard
store-caregory-spellbook-offensive = Offensive Spells
store-caregory-spellbook-defensive = Defensive Spells
store-caregory-spellbook-utility = Utility Spells
store-caregory-spellbook-equipment = Wizard Equipment
store-caregory-spellbook-events = Event Spells

View File

@@ -9,3 +9,4 @@ store-currency-display-debugdollar = {$amount ->
}
store-currency-display-telecrystal = TC
store-currency-display-stolen-essence = Stolen Essence
store-currency-display-wizcoin = Wiz€oin™

View File

@@ -0,0 +1,35 @@
# Spells
spellbook-fireball-name = Fireball
spellbook-fireball-desc = Get most crew exploding with rage when they see this fireball heading toward them!
spellbook-blink-name = Blink
spellbook-blink-desc = Don't blink or you'll miss yourself teleporting away.
spellbook-force-wall-name = Force Wall
spellbook-force-wall-desc = Make three walls of pure force that you can pass through, but other's can't.
spellbook-polymoprh-spider-name = Spider Polymoprh
spellbook-polymorph-spider-desc = Transforms you into a spider, man!
spellbook-polymorph-rod-name = Rod Polymorph
spellbook-polymorph-rod-desc = Change into an Immovable Rod with limited movement.
spellbook-charge-name = Charge
spellbook-charge-desc = Adds a charge back to your wand!
# Equipment
spellbook-wand-polymorph-door-name = Wand of Entrance
spellbook-wand-polymorph-door-description = For when you need a get-away route.
spellbook-wand-polymorph-carp-name = Wand of Carp Polymorph
spellbook-wand-polymorph-carp-description = For when you need a carp filet quick and the clown is looking juicy.
# Events
spellbook-event-summon-ghosts-name = Summon Ghosts
spellbook-event-summon-ghosts-description = Who ya gonna call?
# Upgrades
spellbook-upgrade-fireball-name = Upgrade Fireball
spellbook-upgrade-fireball-description = Upgrades Fireball to a maximum of level 3!

View File

@@ -14,3 +14,33 @@
- type: InstantAction
event: !type:PolymorphActionEvent
itemIconStyle: NoItem
- type: entity
id: ActionPolymorphWizardSpider
name: Spider Polymorph
description: Polymorphs you into a Spider.
noSpawn: true
components:
- type: InstantAction
useDelay: 60
event: !type:PolymorphActionEvent
protoId: WizardSpider
itemIconStyle: NoItem
icon:
sprite: Mobs/Animals/spider.rsi
state: tarantula
- type: entity
id: ActionPolymorphWizardRod
name: Rod Form
description: CLANG!
noSpawn: true
components:
- type: InstantAction
useDelay: 60
event: !type:PolymorphActionEvent
protoId: WizardRod
itemIconStyle: NoItem
icon:
sprite: Objects/Fun/immovable_rod.rsi
state: icon

View File

@@ -0,0 +1,140 @@
# Offensive
- type: listing
id: SpellbookFireball
name: spellbook-fireball-name
description: spellbook-fireball-desc
productAction: ActionFireball
productUpgradeId: SpellbookFireballUpgrade
cost:
WizCoin: 2
categories:
- SpellbookOffensive
conditions:
- !type:ListingLimitedStockCondition
stock: 1
- type: listing
id: SpellbookRodForm
name: spellbook-polymorph-rod-name
description: spellbook-polymorph-rod-desc
productAction: ActionPolymorphWizardRod
cost:
WizCoin: 3
categories:
- SpellbookOffensive
conditions:
- !type:ListingLimitedStockCondition
stock: 1
# Defensive
- type: listing
id: SpellbookForceWall
name: spellbook-force-wall-name
description: spellbook-force-wall-desc
productAction: ActionForceWall
cost:
WizCoin: 3
categories:
- SpellbookDefensive
# Utility
- type: listing
id: SpellbookPolymorphSpider
name: spellbook-polymoprh-spider-name
description: spellbook-polymorph-spider-desc
productAction: ActionPolymorphWizardSpider
cost:
WizCoin: 2
categories:
- SpellbookUtility
conditions:
- !type:ListingLimitedStockCondition
stock: 1
- type: listing
id: SpellbookBlink
name: spellbook-blink-name
description: spellbook-blink-desc
productAction: ActionBlink
cost:
WizCoin: 1
categories:
- SpellbookUtility
conditions:
- !type:ListingLimitedStockCondition
stock: 1
- type: listing
id: SpellbookCharge
name: spellbook-charge-name
description: spellbook-charge-desc
productAction: ActionChargeSpell
cost:
WizCoin: 1
categories:
- SpellbookUtility
conditions:
- !type:ListingLimitedStockCondition
stock: 1
# Equipment
- type: listing
id: SpellbookWandDoor
name: spellbook-wand-polymorph-door-name
description: spellbook-wand-polymorph-door-description
productEntity: WeaponWandPolymorphDoor
cost:
WizCoin: 3
categories:
- SpellbookEquipment
conditions:
- !type:ListingLimitedStockCondition
stock: 1
- type: listing
id: SpellbookWandPolymorphCarp
name: spellbook-wand-polymorph-carp-name
description: spellbook-wand-polymorph-carp-description
productEntity: WeaponWandPolymorphCarp
cost:
WizCoin: 3
categories:
- SpellbookEquipment
conditions:
- !type:ListingLimitedStockCondition
stock: 1
# Event
- type: listing
id: SpellbookEventSummonGhosts
name: spellbook-event-summon-ghosts-name
description: spellbook-event-summon-ghosts-description
productAction: ActionSummonGhosts
cost:
WizCoin: 0
categories:
- SpellbookEvents
conditions:
- !type:ListingLimitedStockCondition
stock: 1
# Upgrades
- type: listing
id: SpellbookFireballUpgrade
productUpgradeId: SpellbookFireballUpgrade
name: spellbook-upgrade-fireball-name
description: spellbook-upgrade-fireball-description
icon:
sprite: Objects/Magic/magicactions.rsi
state: fireball
cost:
WizCoin: 2
categories:
- SpellbookOffensive
conditions:
- !type:BuyBeforeCondition
whitelist:
- SpellbookFireball
# manual for now
- !type:ListingLimitedStockCondition
stock: 2

View File

@@ -390,7 +390,7 @@
- Snout
- type: entity
parent: ClothingHeadBase
parent: ClothingHeadHatWizardBase
id: ClothingHeadHatRedwizard
name: red wizard hat
description: Strange-looking red hat-wear that most certainly belongs to a real magic user.
@@ -505,7 +505,7 @@
- type: entity
parent: ClothingHeadBase
parent: ClothingHeadHatWizardBase
id: ClothingHeadHatVioletwizard
name: violet wizard hat
description: "Strange-looking violet hat-wear that most certainly belongs to a real magic user."
@@ -534,6 +534,7 @@
name: witch hat
description: A witch hat.
components:
- type: WizardClothes #Yes this will count
- type: Sprite
sprite: Clothing/Head/Hats/witch.rsi
- type: Clothing
@@ -558,7 +559,14 @@
sprite: Clothing/Head/Hats/wizard_fake.rsi
- type: entity
abstract: true
parent: ClothingHeadBase
id: ClothingHeadHatWizardBase
components:
- type: WizardClothes
- type: entity
parent: ClothingHeadHatWizardBase
id: ClothingHeadHatWizard
name: wizard hat
description: Strange-looking blue hat-wear that most certainly belongs to a powerful magic user.

View File

@@ -166,9 +166,16 @@
- type: Clothing
sprite: Clothing/OuterClothing/Misc/santa.rsi
- type: entity
abstract: true
parent: ClothingOuterBase
id: ClothingOuterWizardBase
components:
- type: WizardClothes
# Is this wizard wearing a fanny pack???
- type: entity
parent: ClothingOuterBase
parent: ClothingOuterWizardBase
id: ClothingOuterWizardViolet
name: violet wizard robes
description: A bizarre gem-encrusted violet robe that radiates magical energies.
@@ -179,7 +186,7 @@
sprite: Clothing/OuterClothing/Misc/violetwizard.rsi
- type: entity
parent: ClothingOuterBase
parent: ClothingOuterWizardBase
id: ClothingOuterWizard
name: wizard robes
description: A bizarre gem-encrusted blue robe that radiates magical energies.
@@ -190,7 +197,7 @@
sprite: Clothing/OuterClothing/Misc/wizard.rsi
- type: entity
parent: ClothingOuterBase
parent: ClothingOuterWizardBase
id: ClothingOuterWizardRed
name: red wizard robes
description: Strange-looking, red, hat-wear that most certainly belongs to a real magic user.

View File

@@ -2345,6 +2345,18 @@
bloodMaxVolume: 150
bloodReagent: Laughter
- type: entity
name: wizard spider
parent: MobGiantSpider
id: MobGiantSpiderWizard
description: This spider looks a little magical
suffix: Wizard
components:
- type: Accentless
removes:
- type: ReplacementAccent
accent: xeno #let this wizard speak
- type: entity
name: possum
parent: SimpleMobBase

View File

@@ -19,13 +19,58 @@
tags:
- Spellbook
# For the Wizard Antag
# Do not add discounts or price inflation
- type: entity
id: WizardsGrimoire
name: wizards grimoire
suffix: Wizard
parent: BaseItem
components:
- type: Sprite
sprite: Objects/Misc/books.rsi
layers:
- state: paper_blood
- state: cover_strong
color: "#645a5a"
- state: decor_wingette_flat
color: "#4d0303"
- state: icon_pentagramm
color: "#f7e19f"
- type: UserInterface
interfaces:
enum.StoreUiKey.Key:
type: StoreBoundUserInterface
- type: ActivatableUI
key: enum.StoreUiKey.Key
- type: Store
refundAllowed: true
ownerOnly: true # get your own tome!
preset: StorePresetSpellbook
balance:
WizCoin: 10 # prices are balanced around this 10 point maximum and how strong the spells are
# Not meant for wizard antag but meant for spawning, so people can't abuse refund if they were given a tome
- type: entity
id: WizardsGrimoireNoRefund
name: wizards grimoire
suffix: Wizard, No Refund
parent: WizardsGrimoire
components:
- type: Store
refundAllowed: false
ownerOnly: true # get your own tome!
preset: StorePresetSpellbook
balance:
WizCoin: 10 # prices are balanced around this 10 point maximum and how strong the spells are
- type: entity
id: SpawnSpellbook
name: spawn spellbook
parent: BaseSpellbook
components:
- type: Spellbook
spells:
spellActions:
ActionSpawnMagicarpSpell: -1
- type: entity
@@ -48,7 +93,7 @@
- state: detail_rivets
color: gold
- type: Spellbook
spells:
spellActions:
ActionForceWall: -1
- type: entity
@@ -69,7 +114,7 @@
- state: detail_rivets
color: gold
- type: Spellbook
spells:
spellActions:
ActionBlink: -1
- type: entity
@@ -92,7 +137,7 @@
color: red
- state: overlay_blood
- type: Spellbook
spells:
spellActions:
ActionSmite: -1
- type: entity
@@ -114,7 +159,7 @@
- state: detail_bookmark
color: "#98c495"
- type: Spellbook
spells:
spellActions:
ActionKnock: -1
- type: entity
@@ -138,7 +183,7 @@
- state: icon_magic_fireball
shader: unshaded
- type: Spellbook
spells:
spellActions:
ActionFireball: -1
- type: entity
@@ -153,7 +198,7 @@
layers:
- state: spell_default
- type: Spellbook
spells:
spellActions:
ActionFlashRune: -1
ActionExplosionRune: -1
ActionIgniteRune: -1

View File

@@ -1210,7 +1210,7 @@
name: force wall
components:
- type: TimedDespawn
lifetime: 20
lifetime: 12
- type: Tag
tags:
- Wall

View File

@@ -0,0 +1,13 @@
- type: entity
id: ActionSummonGhosts
name: Summon Ghosts
description: Makes all current ghosts permanently invisible
noSpawn: true
components:
- type: InstantAction
useDelay: 120
itemIconStyle: BigAction
icon:
sprite: Mobs/Ghosts/ghost_human.rsi
state: icon
event: !type:ToggleGhostVisibilityToAllEvent

View File

@@ -7,6 +7,8 @@
- type: InstantAction
useDelay: 10
itemIconStyle: BigAction
sound: !type:SoundPathSpecifier
path: /Audio/Magic/knock.ogg
icon:
sprite: Objects/Magic/magicactions.rsi
state: knock

View File

@@ -4,10 +4,12 @@
description: Fires an explosive fireball towards the clicked location.
noSpawn: true
components:
- type: Magic
- type: WorldTargetAction
useDelay: 15
itemIconStyle: BigAction
checkCanAccess: false
raiseOnUser: true
range: 60
sound: !type:SoundPathSpecifier
path: /Audio/Magic/fireball.ogg
@@ -16,25 +18,48 @@
state: fireball
event: !type:ProjectileSpellEvent
prototype: ProjectileFireball
posData: !type:TargetCasterPos
speech: action-speech-spell-fireball
- type: ActionUpgrade
effectedLevels:
2: ActionFireballII
3: ActionFireballIII
- type: entity
id: ActionFireballII
parent: ActionFireball
name: Fireball II
description: Fire three explosive fireball towards the clicked location.
description: Fires a fireball, but faster!
noSpawn: true
components:
- type: WorldTargetAction
useDelay: 5
charges: 3
useDelay: 10
renewCharges: true
itemIconStyle: BigAction
checkCanAccess: false
raiseOnUser: true
range: 60
sound: !type:SoundPathSpecifier
path: /Audio/Magic/fireball.ogg
icon:
sprite: Objects/Magic/magicactions.rsi
state: fireball
event: !type:ProjectileSpellEvent
prototype: ProjectileFireball
speech: action-speech-spell-fireball
- type: entity
id: ActionFireballIII
parent: ActionFireball
name: Fireball III
description: The fastest fireball in the west!
noSpawn: true
components:
- type: WorldTargetAction
useDelay: 8
renewCharges: true
itemIconStyle: BigAction
checkCanAccess: false
raiseOnUser: true
range: 60
sound: !type:SoundPathSpecifier
path: /Audio/Magic/fireball.ogg
@@ -43,5 +68,4 @@
state: fireball
event: !type:ProjectileSpellEvent
prototype: ProjectileFireball
posData: !type:TargetCasterPos
speech: action-speech-spell-fireball

View File

@@ -8,9 +8,11 @@
useDelay: 10
range: 16 # default examine-range.
# ^ should probably add better validation that the clicked location is on the users screen somewhere,
sound: !type:SoundPathSpecifier
path: /Audio/Magic/blink.ogg
itemIconStyle: BigAction
checkCanAccess: false
repeat: true
repeat: false
icon:
sprite: Objects/Magic/magicactions.rsi
state: blink

View File

@@ -0,0 +1,15 @@
- type: entity
id: ActionChargeSpell
name: Charge
description: Adds a charge back to your wand
noSpawn: true
components:
- type: InstantAction
useDelay: 30
itemIconStyle: BigAction
icon:
sprite: Objects/Weapons/Guns/Basic/wands.rsi
state: nothing
event: !type:ChargeSpellEvent
charge: 1
speech: DI'RI CEL!

View File

@@ -175,3 +175,25 @@
revertOnDeath: true
revertOnCrit: true
duration: 20
# Polymorphs for Wizards polymorph self spell
- type: polymorph
id: WizardSpider
configuration:
entity: MobGiantSpiderWizard #Not angry so ghosts can't just take over the wizard
transferName: true
inventory: None
revertOnDeath: true
revertOnCrit: true
- type: polymorph
id: WizardRod
configuration:
entity: ImmovableRodWizard #CLANG
transferName: true
transferDamage: false
inventory: None
duration: 1
forced: true
revertOnCrit: false
revertOnDeath: false

View File

@@ -7,6 +7,32 @@
id: Debug2
name: store-category-debug2
#WIZARD
- type: storeCategory
id: SpellbookOffensive
name: store-caregory-spellbook-offensive
priority: 0
- type: storeCategory
id: SpellbookDefensive
name: store-caregory-spellbook-defensive
priority: 1
- type: storeCategory
id: SpellbookUtility
name: store-caregory-spellbook-utility
priority: 2
- type: storeCategory
id: SpellbookEquipment
name: store-caregory-spellbook-equipment
priority: 3
- type: storeCategory
id: SpellbookEvents
name: store-caregory-spellbook-events
priority: 4
#uplink categoires
- type: storeCategory
id: UplinkWeaponry

View File

@@ -10,6 +10,11 @@
displayName: store-currency-display-stolen-essence
canWithdraw: false
- type: currency
id: WizCoin
displayName: store-currency-display-wizcoin
canWithdraw: false
#debug
- type: currency
id: DebugDollar

View File

@@ -15,3 +15,15 @@
- UplinkPointless
currencyWhitelist:
- Telecrystal
- type: storePreset
id: StorePresetSpellbook
storeName: Spellbook
categories:
- SpellbookOffensive #Fireball, Rod Form
- SpellbookDefensive #Magic Missile, Wall of Force
- SpellbookUtility #Body Swap, Lich, Teleport, Knock, Polymorph
- SpellbookEquipment #Battlemage Robes, Staff of Locker
- SpellbookEvents #Summon Weapons, Summon Ghosts
currencyWhitelist:
- WizCoin