Progress On Many Fronts

Over the past couple of weeks we have been trying to make progress to a lot of the areas in our roadmap that had not yet been started.

Some of the more notable areas:

  • Triggered Skills (Abilities)
  • NPC Behaviors

Triggered Skills (Abilities)

So what is a triggered skill, as compared to a passive skill?

Consider something like your evocation skill for direct-damage spells, or your 1 hand slashing skill. These are both passive skills. They don't require you to actively trigger the skill, but instead, are simply used in the background to calculate your spell / combat effectiveness.

What about Kick, Bash, Taunt, Tracking, Feign Death, etc.? These all require the user to trigger the skill, hence we're calling them Abilities / Triggered Skills.

To get this setup, we took our existing Skill classes and included a few new variables to keep track of whether the skill must be triggered, and if so, how long will it be on cooldown.

Before:

namespace RPG.Common.Skills.Interfaces {
    public interface ISkillDefinition {
        byte Id { get; }
        string Name { get; }
    }
}

After:

namespace RPG.Common.Skills.Definitions {
    public abstract class SkillDefinition : ISkillDefinition {
        public abstract byte Id { get; }
        public abstract string Name { get; }
        public abstract bool Passive { get; }
        public virtual float CooldownTime { get; }
        public virtual bool MustBeDeactivated { get; }
        public virtual SkillLineType SkillLine => SkillLineType.None;
    }
}

Note that before adding abilities we didn't have much need to create an abstract class. With the introduction of triggered skills, it made sense for us to use an abstract class to define some common properties.

  • Id - Internal identifier
  • Name - Display name of the skill. This is primarily used by the client when rendering UI
  • Passive - Flag to simply indicate whether the skill must be triggered
  • CooldownTime - How long that the character must wait before executing an ability again
  • MustBeDeactivated - Added this for skills like Hide, Sneak, etc., which are active until the user disabled the ability.
  • SkillLine - This is used to prevent the user from triggering multiple abilities which are tied to the same SkillLine. For example, Kick, Bash, and Slam are all on the same skill line. You can only execute one of those 3 skills at a time.

Now how are these skills tied to a particular race or class? These are just the definitions, but how does the client know whether the user has an ability that they can use?

In the past we were simply assigning all classes/races with 1 point in all skills in the game. But this isn't right.. a warrior should not be able to meditate, and spellcasters may not be able to learn many of the combat skills (slashing, kick, etc.).

To get this working, we created a new database entity which to store all of the metadata around which classes/races can learn a skill, and how that skill must be learned.

using PetaPoco;
using RPG.Server.Core.Data.Attributes;
using RPG.Server.Core.Data.Entities;
using RPG.Server.Core.Skills.Types;

namespace RPG.Server.Core.Skills.Entities {

    [ExplicitColumns]
    [TableName("skill_class_race_mappings")]
    [PrimaryKey("Id")]
    public class SkillClassRaceMappingEntity : DatabaseEntity<int> {

        public SkillClassRaceMappingEntity() { }

        public SkillClassRaceMappingEntity(SkillDefinitionMapType skillDefinitionMapType, byte skillId, byte mapId, byte level, ushort skillCap,
            bool trainingRequired, ushort startingValue = 1) {
            SkillDefinitionMapType = skillDefinitionMapType;
            SkillId = skillId;
            MapId = mapId;
            Level = level;
            SkillCap = skillCap;
            TrainingRequired = trainingRequired;
            StartingValue = startingValue;
        }

        [Field(required: true)]
        public SkillDefinitionMapType SkillDefinitionMapType { get; set; }

        [Field(required: true)]
        public byte MapId { get; set; }

        [Field(required: true)]
        public byte SkillId { get; set; }

        [Field(required: true)]
        public byte Level { get; set; }

        [Field(required: true)]
        public ushort SkillCap { get; set; }

        [Field(required: true)]
        public bool TrainingRequired { get; set; }

        [Field]
        public ushort StartingValue { get; set; }
    }
}

Above is the entity we're using to store this data for both Class and Race mappings.

  • SkillDefinitionMapType - A simply enum that simply indicates that the map is for a Class vs a Race
  • MapId - The identifier for the map type. This would be the ID for a particular Class or a particular Race.
  • SkillId - The skill identifier that the mapping is for.
  • Level - The required level that the character of a particular class/race must be to learn the skill.
  • SkillCap - The maximum value a character can have for a particular skill.
  • TrainingRequired - If set to false, the player can learn the skill once they have reached the required level. If set to true, the player must speak to a Trainer to learn the skill.
  • StartingValue - The initial value a character will have when the skill is added. This will be important for things like languages, which might start at 100 for certain races.
        private void CreateShadowKnightEntities() {
            var classId = ClassType.ShadowKnight.Id;

            // General skills
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Defense.Id, classId, 1, 210, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Offense.Id, classId, 1, 200, false));

            // Spell skills
            CreateSpellEntities(classId, 9);
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Meditate.Id, classId, 12, 235, true));

            // Combat skills
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.OneHandSlashing.Id, classId, 1, 200, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.TwoHandSlashing.Id, classId, 1, 200, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.OneHandBlunt.Id, classId, 1, 200, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.TwoHandBlunt.Id, classId, 1, 200, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.HandToHand.Id, classId, 1, 100, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Piercing.Id, classId, 1, 200, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Taunt.Id, classId, 1, 180, false));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Bash.Id, classId, 1, 200, false));

            // Class skills
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.Hide.Id, classId, 25, 75, true));
            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Class, SkillType.HarmTouch.Id, classId, 1, 200, false));
        }

        private void CreateTrollEntities() {
            var raceId = RaceType.Troll.Id;

            Save(new SkillClassRaceMappingEntity(SkillDefinitionMapType.Race, SkillType.Slam.Id, raceId, 1, 200, false));
        }

In the above example, we can see the skills for both a ShadowKnight and a Troll being defined.

How are these actually used?

  • Players: Upon creating a new character, all skills for the chosen race/class which have a Level of 1 and a TrainingRequired of false will be assigned to the player.
  • NPCs: Upon spawning a new NPC, we will find all available skills for the class/race of the NPC and assign them with the starting value scaled up to the NPCs level. Note that each skill can be increased by 5 points per level, so a level 30 NPC will have skills up to 150.

With this setup it was pretty easy for us to start building UI around the skills that a player has assigned to them. When going to learn a new ability we simply check what skills a player has assigned to them that are not passive skills are not already loaded into the UI.

To demonstrate, even though a Shadow Knight can learn Hide, it is not available until level 25. Therefore, the player will not have the skill assigned and will not show up in their window to load abilities, as seen below. Additionally, all of the skills that are marked as passive are not loaded.

Fair warning, we still have work to do to make the window for selecting abilities (and many other windows) pretty. This won't happen until later on in the project. We also haven't yet implemented endurance, which is why the bar is at 0%.

Challenges

Tracking

Honestly the challenge here came with developing the UI. We already have other players/NPCs loaded into memory on the client, but making sure all of the filters worked while keeping the tracking window up to date as you and other characters move around was a bit tough.

We did spend a bit more time on the UI here, which does look quite a bit better than a lot of our other components so far.

Tracking window

Harm Touch

Adding the harm touch ability itself was rather easy. However, we didn't want to create an entirely new framework for defining the effects of abilities in a generic way. We spent months getting the spell casting system setup to work generically for a given spell, spell effects, etc.

Therefore, to make this work we created a harm touch spell, the damage of which scaling with the character's level, and have the harm touch ability simply trigger a completed spell cast. This means that we get all the features of our spell casting system automatically.. automatically checking the player's resists against the spell definition, determining the target for the spell, etc. We also don't need to define new events for the result of these abilities, because a spell cast will trigger/send the successful/failure/resisted etc related spell events to nearby characters.

Harm Touch triggering spell related events

Hide & Sneak

These abilities introduced a new concept.. abilities that need to be deactivated. We also didn't have a way to indicate that a player is invisible, but adding that feature along with see invisibility was pretty easy. Additionally, we already had a way to adjust a character's movement speed.

To make this work we needed a new "MustBeDeactivated" field in our SkillDefinition class. That allowed us to update the UI and our APIs on the server to display the button as disabled until it's pressed again, and to remove certain effects such as the movement speed when sneak is disabled.

As far as functionality goes, hide will make you invisible, but if you are not sneaking you the ability will be deactivated upon moving the character's position. Sneak will simply slow your movement speed, and allow you to stay hidden while moving.

In the below picture, I'm demonstrating the Invisibility and See Invisibility spells so that you can actually see the character on the screen 😉

Invisible while player can See Invisible

There are still a couple of small things to wrap up with the Invisibility feature, but it's working pretty well so far. We need to still setup the ability for a NPC to see through invisibility, as well as updating the conning description so that a player is indifferent when they are not seen by a NPC.

NPC Behaviors

In just a few days we have made a lot of progress towards getting a framework setup to easily define a particular NPC's behaviors. Essentially, we wanted a way we could simply "drop in" a custom behavior script into our authoritative client, which control's a lot of the NPC's movement and proximity aggro. The goal here was to not have to wire every single behavior to the zone in Unity, something that would be tedious as we're adding/removing new NPCs. Instead, we wanted an easy way to dynamically load the specific NPC behavior at runtime.

First, lets look at the actual behavior script. Note, these are only a few days old and are constantly being refactored as we introduce new features!

using Assets.Scripts.Character;
using Assets.Scripts.Core.Movement;
using RPG.Common.Character.Models;
using RPG.Common.Character.Types;
using RPG.Common.Chat.Messages.Types;
using RPG.Common.Chat.Models;
using UnityEngine;

public class NpcBehaviorController : MonoBehaviour {
    protected NpcCharacterController _npcController;

    public void SetNpcCharacterController(NpcCharacterController controller) {
        _npcController = controller;
    }

    public virtual void OnDestinationReached(Vector3 position) { }
    public virtual void OnSay(string message, RPGCharacter fromCharacter) {
        if (IsHailing(message)) {
            OnHail(fromCharacter);
        }
    }
    public virtual void OnProximityAggro(RPGCharacter character) { }
    public virtual void OnAggro(RPGCharacter character) { }

    protected void SayMessage(string message) {
        NpcChatRequestFacade.SendMessage(new RPGMessage() {
            FromCharacterId = _npcController.Npc.Id,
            FromCharacterName = _npcController.Npc.GetDisplayName(),
            FromCharacterType = CharacterType.Npc,
            Message = message,
            MessageType = MessageType.Say
        });
    }

    protected bool IsHailing(string message) {
        return MatchMessage(message, "hail");
    }

    protected virtual void OnHail(RPGCharacter fromCharacter) {
        if (_npcController.Npc.Faction != null) {
            var modifier = fromCharacter.FactionEntries[_npcController.Npc.Faction.FactionId];
            if (modifier.ModifierValue < -700) {
                return;
            }
        }
        _npcController.SetDestination(transform.position, MovementHelper.GetPositionVector(fromCharacter.Movement) - transform.position);
    }

    protected bool MatchMessage(string message, string phrase) {
        return message.ToLower().Contains(phrase.ToLower());
    }
}

So what do we have here? This is the base behavior class that all NPCs will use unless a behavior is found for that specific NPC. Notice that there are a lot of "On<Action>" hooks that child classes could override and implement themselves.

One default behavior we have setup so far is how NPCs will respond to a hail. We have decided thus far to have the NPC turn and face the player which is hailing them whenever. This only happens for NPCs that do not have a primary faction, or for characters that have a decent reputation with the NPC's primary faction.

Below is an example of a custom behavior overriding some of these hooks.

using RPG.Common.Character.Models;
using UnityEngine;

public class Medieval_Town_Hurey_Mynge : NpcBehaviorController {

    private Vector3 _gateLocation = new Vector3(254.6901f, 10.11417f, 224.8017f);
    public override void OnSay(string message, RPGCharacter fromCharacter) {
        if (MatchMessage(message, "Go to the gate")) {
            _npcController.SetDestination(_gateLocation, Vector3.back);
        } else {
            base.OnSay(message, fromCharacter);
        }
    }

    protected override void OnHail(RPGCharacter fromCharacter) {
        base.OnHail(fromCharacter);
        SayMessage("Hello there! Shall I stay here? Or [go to the gate]?");
    }

    public override void OnDestinationReached(Vector3 position) {
        if (position == _gateLocation) {
            SayMessage("Well. I'm here now..");
        }
    }

    public override void OnAggro(RPGCharacter character) {
        SayMessage("You little shit!");
        SayMessage($"Everyone! Help me kill {character.GetDisplayName()}");
    }
}

As we can see here, the NPC will ask the player whether he should walk to the gate upon being hailed. Upon receiving the specific phrase "go to the gate", the NPC will then set walk over to the gate. Additionally, upon being aggroed the NPC will send messages that everyone nearby can see.

We still have a lot to do here, but a lot of the other features will require changes. For example, we still need to create APIs/Windows for trading with a NPC. We also need to start defining global flags that can be used to indicate whether a player has completed a quest. With both of those changes complete we will be able to create quests that require the player to turn in specific items, coins, etc to complete.

Whats Next?

As mentioned in the article, we still have a bit of work to do for triggered abilities and quite a bit of work to finish up the NPC behaviors. Abilities will probably wrap up first, as we want to setup our NPC behaviors to handle longer and more complex functionalities that will be used to create raids and other scripted events.

After those two pieces are done we really want to finish up something we started earlier this year: content submission API and web app. The API is pretty much done, and generic enough that with a few lines of code we can create a new API for any database entity. However, the real goal here is to remove all of the NPC, Item, Spell, etc. entities from our game servers and to instead have a web app that can collect submissions from contributors.

For example, someone else working on the project whom isn't technical could then submit changes for an existing NPCs or even create a new NPC via a user interface. The API is already setup to handle delta changes to each entity, as well as a way to block a change until they're approved. This will also help us remove a lot of the APIs we've created to ship entity data back to the player clients, such as spells and items. In the end, we will be generating SQL scripts from the submission database that will be independent from the actual game servers.

If you are interested in contributing to this project and have experience building CMS-type web applications that simply wrap CRUD APIs, feel free to reach out to me directly at jvogt@funkhouse.games. At the rate we have been going, the API should be available by the end of the year.

What did you think about this post?

Click on a star to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Be the first to comment

Leave a comment

Your email address will not be published.