--- /dev/null
+using Content.Shared.Magic;
+
+namespace Content.Client.Magic;
+
+public sealed class MagicSystem : SharedMagicSystem;
+using System.Linq;
using Content.Shared.Actions;
using Content.Shared.Interaction;
using Robust.Shared.Random;
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;
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];
SubscribeLocalEvent<GhostComponent, InsertIntoEntityStorageAttemptEvent>(OnEntityStorageInsertAttempt);
SubscribeLocalEvent<RoundEndTextAppendEvent>(_ => MakeVisible(true));
+ SubscribeLocalEvent<ToggleGhostVisibilityToAllEvent>(OnToggleGhostVisibilityToAll);
}
private void OnGhostHearingAction(EntityUid uid, GhostComponent component, ToggleGhostHearingActionEvent args)
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>
-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);
- }
-
- private void OnDoAfter(EntityUid uid, SpellbookComponent component, DoAfterEvent 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;
+ SubscribeLocalEvent<SpeakSpellEvent>(OnSpellSpoken);
}
- /// <summary>
- /// Opens all doors within range
- /// </summary>
- /// <param name="args"></param>
- private void OnKnockSpell(KnockSpellEvent args)
+ private void OnSpellSpoken(ref SpeakSpellEvent 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);
}
}
-using Content.Server.Store.Components;
+using Content.Server.Store.Components;
using Robust.Shared.Containers;
namespace Content.Server.Store.Systems;
{
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;
}
}
- if (listing is { ProductUpgradeID: not null, ProductActionEntity: not null })
+ if (listing is { ProductUpgradeId: not null, ProductActionEntity: not null })
{
if (listing.ProductActionEntity != null)
{
public EntityUid Performer;
/// <summary>
- /// The action that was performed.
+ /// The action the event belongs to.
/// </summary>
public EntityUid Action;
}
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;
public sealed partial class ToggleGhostHearingActionEvent : InstantActionEvent { }
+public sealed partial class ToggleGhostVisibilityToAllEvent : InstantActionEvent { }
+
public sealed partial class BooActionEvent : InstantActionEvent { }
--- /dev/null
+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
+}
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>
/// <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;
}
--- /dev/null
+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;
--- /dev/null
+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;
+}
--- /dev/null
+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; }
+}
using Content.Shared.Actions;
using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Magic.Events;
/// <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();
}
using Content.Shared.Actions;
-using Robust.Shared.Audio;
namespace Content.Shared.Magic.Events;
{
/// <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; }
}
using Content.Shared.Actions;
using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Shared.Magic.Events;
/// <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; }
}
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?
+ /// 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; }
}
--- /dev/null
+namespace Content.Shared.Magic.Events;
+
+[ByRefEvent]
+public readonly struct SpeakSpellEvent(EntityUid performer, string speech)
+{
+ public readonly EntityUid Performer = performer;
+ public readonly string Speech = speech;
+}
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;
}
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; }
}
--- /dev/null
+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;
+++ /dev/null
-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;
-}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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);
+ }
+}
public EntProtoId? ProductAction;
/// <summary>
- /// The listing ID of the related upgrade listing. Can be used to link a <see cref="ProductAction"/> to an
- /// upgrade or to use standalone as an upgrade
+ /// The listing ID of the related upgrade listing. Can be used to link a <see cref="ProductAction"/> to an
+ /// 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
+ /// Keeps track of the current action entity this is tied to, for action upgrades
/// </summary>
[DataField]
[NonSerialized]
Priority = Priority,
ProductEntity = ProductEntity,
ProductAction = ProductAction,
- ProductUpgradeID = ProductUpgradeID,
+ ProductUpgradeId = ProductUpgradeId,
ProductActionEntity = ProductActionEntity,
ProductEvent = ProductEvent,
PurchaseAmount = PurchaseAmount,
--- /dev/null
+spell-requirements-failed = Missing requirements to cast this spell!
# 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
+
}
store-currency-display-telecrystal = TC
store-currency-display-stolen-essence = Stolen Essence
+store-currency-display-wizcoin = Wiz€oin™
--- /dev/null
+# 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!
- 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
--- /dev/null
+# 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
- 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.
- 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."
name: witch hat
description: A witch hat.
components:
+ - type: WizardClothes #Yes this will count
- type: Sprite
sprite: Clothing/Head/Hats/witch.rsi
- type: Clothing
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.
- type: Clothing
sprite: Clothing/OuterClothing/Misc/santa.rsi
-# Is this wizard wearing a fanny pack???
- type: entity
+ abstract: true
parent: ClothingOuterBase
+ id: ClothingOuterWizardBase
+ components:
+ - type: WizardClothes
+
+# Is this wizard wearing a fanny pack???
+- type: entity
+ parent: ClothingOuterWizardBase
id: ClothingOuterWizardViolet
name: violet wizard robes
description: A bizarre gem-encrusted violet robe that radiates magical energies.
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.
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.
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
layers:
- state: paper_blood
- state: cover_strong
- color: "#645a5a"
+ color: "#645a5a"
- state: decor_wingette_flat
color: "#4d0303"
- state: icon_pentagramm
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
- state: detail_rivets
color: gold
- type: Spellbook
- spells:
+ spellActions:
ActionForceWall: -1
- type: entity
- state: detail_rivets
color: gold
- type: Spellbook
- spells:
+ spellActions:
ActionBlink: -1
- type: entity
color: red
- state: overlay_blood
- type: Spellbook
- spells:
+ spellActions:
ActionSmite: -1
- type: entity
- state: detail_bookmark
color: "#98c495"
- type: Spellbook
- spells:
+ spellActions:
ActionKnock: -1
- type: entity
- state: icon_magic_fireball
shader: unshaded
- type: Spellbook
- spells:
+ spellActions:
ActionFireball: -1
- type: entity
layers:
- state: spell_default
- type: Spellbook
- spells:
+ spellActions:
ActionFlashRune: -1
ActionExplosionRune: -1
ActionIgniteRune: -1
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
name: force wall
components:
- type: TimedDespawn
- lifetime: 20
+ lifetime: 12
- type: Tag
tags:
- Wall
- type: RCDDeconstructable
cost: 6
delay: 8
- fx: EffectRCDDeconstruct8
+ fx: EffectRCDDeconstruct8
- type: Destructible
thresholds:
- trigger:
--- /dev/null
+- 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
- type: InstantAction
useDelay: 10
itemIconStyle: BigAction
+ sound: !type:SoundPathSpecifier
+ path: /Audio/Magic/knock.ogg
icon:
sprite: Objects/Magic/magicactions.rsi
state: knock
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
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
state: fireball
event: !type:ProjectileSpellEvent
prototype: ProjectileFireball
- posData: !type:TargetCasterPos
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
+ icon:
+ sprite: Objects/Magic/magicactions.rsi
+ state: fireball
+ event: !type:ProjectileSpellEvent
+ prototype: ProjectileFireball
+ speech: action-speech-spell-fireball
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
--- /dev/null
+- 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!
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
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
- type: currency
id: Telecrystal
displayName: store-currency-display-telecrystal
- cash:
+ cash:
1: Telecrystal1
canWithdraw: true
displayName: store-currency-display-stolen-essence
canWithdraw: false
+- type: currency
+ id: WizCoin
+ displayName: store-currency-display-wizcoin
+ canWithdraw: false
+
#debug
- type: currency
id: DebugDollar
- displayName: store-currency-display-debugdollar
\ No newline at end of file
+ displayName: store-currency-display-debugdollar
- 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