Foreword
I didn't know where to put this. It's kind of a tutorial, kind of a show-off, kind of a compilation of snippets. I settled on tutorials as I'm providing full source code that works with my server (which was originally based off a public C# source posted on rune-server). This source can very easily be adapted to any server under any revision (and even other games, I'm using a slightly edited version of this for a Unity game I'm developing).
- This thread assumes that you have basic to intermediate programming knowledge already.
- If you want to reproduce this for your copy paste sources in Java, this shouldn't be too hard as C# is a similar enough language.
- Code is commented for the purpose of this thread.
- You could theoretically take the 2 scripting C# projects and load them in to Java. I haven't tried this, but it should be possible.
Introduction
Hi guys, I've been working on a private server with a lad I've been teaching for a while now. Progress has been slow as my free time is limited, but one of the obstacles I've been working through is how to make a modular, expandable scripting engine for our rsps. The aim for this engine is to be able to easily script NPCs, object interactions, quests, shops and other content down the line. For the purpose of making this relatively brief, we'll be looking specifically at NPC scripts.
Because scripts can grow to be complicated and the files quite large, it's important that we stick to the basic principle of separation of responsibility. This means that when we're done we'll have one class responsible for one NPC (technically multiple NPCs in some cases, as NPCs with different IDs can share the same dialogue). It's worth noting that the current project does not adhere to this at all, the original base wasn't written by me. The action button packet handlers decide which dialogue method to activate and where along the dialogue to begin. Here's a snippet of this in action:
Code:
if (player.getTemporaryAttribute("barrowTunnel") != null)
{
Barrows.verifyEnterTunnel(player);
return;
}
else if (dialogueStatus == 1005)
{
Slayer.doDialogue(player, 1006);
break; //These breaks actually do nothing...
}
else if (dialogueStatus == 1009)
{
Slayer.doDialogue(player, 1010);
break;
}
else if (dialogueStatus == 1002)
{
Slayer.doDialogue(player, 1013);
break;
}
To further adhere to this principle I'll be creating a separate C# project to house any scripts. This will compile to a Dynamic Link Library (.dll) file, which will be loaded in to memory at runtime by the main project. More on how this works later.
Before beginning any actual programming, lets summarise our requirements for the engine. In no particular order, the engine MUST:
- House scripts separately to the main project.
- Be capable of interacting with the player object and other objects in the main server project.
- Have some mechanism to map scripts to unique IDs, in the case of this tutorial these are NPC IDs.
- Be revision agnostic in order to be completely future-proof (that is to say what works in 1 revision will work in every revision). The best way to achieve this is to separate out the scripting engine entirely and provide what is essentially a layer of middleware to bridge the gap between scripting engine and main server project. This way only the seam will need to be changed across revisions.
- Have an API which is easily understandable. I have up to date industry experience to help with this.
It would be nice if the engine could:
- Be easily accessible to those with little to no programming knowledge, so that it can be easily accesible to as many people as possible.
- Reload scripts at run-time without needing to restart the server.
I've made a simple project outside of the main server project by right clicking the solution, selecting "Add -> New Project" and picking "Class Library (.NET Framework)". We're targeting .NET 4.6.1 for now, in the future we may upgrade to 4.7.2 to make use of some of the newest features. This .dll file will be used as an API between the main server and a separate scripting project, which I've created in a new solution.
By using the built .dll file as a reference, we can build our script files which are then compiled down to another dll file. This is then loaded in to the main server project and executed at run time. More on this later.
Attributes
In C# we call these attributes. I believe in Java these are called annotations and they function almost identically.
We'll be writing some custom attributes to go with our script classes. We'll start by creating a static class Attributes which will house all of our script related attributes. A couple of simple examples are an author and an Npc attribute.
Code:
using System;
using System.Collections.Generic;
namespace RS2Server.Scripts
{
public static class Attributes
{
// This attribute is only allowed to be put on a class, anything else is invalid.
[AttributeUsage(AttributeTargets.Class)]
public class AuthorAttribute : Attribute // We have to inherit from the Attribute class so that our compiler can tell it's an attribute.
{
// Store the author name as a property on the attribute when constructed.
// Author name should be set in stone once constructed, hence no { set; }
// These { get; } and { set; } accessors are just a better way of writing the
// get and set boilerplate that are commonly seen in Java.
public string AuthorName { get; }
public AuthorAttribute(string authorName)
{
AuthorName = authorName;
}
}
[AttributeUsage(AttributeTargets.Class)]
public class NpcAttribute : Attribute
{
// IReadOnlyCollection is useful for when we don't want anything to be able
// to change the collection after the instance has been constructed.
// We use a collection here as multiple NPC IDs may share the same dialogue
// e.g bankers in Runescape have many IDs but have the same dialogue. I don't want
// to have to rewrite the same script just to change the npc id.
public IReadOnlyCollection<int> Ids { get; }
// Rather than just passing in an integer array, or better
// yet an IEnumerable of integers, an attribute won't accept
// the syntax new int[] { x, y, z } because it's not a compile time constant.
// So instead we pass in "params", which will allow us to pass in IDs as (x, y, z)
// If that doesn't make sense, google it.
public NpcAttribute(params int[] ids)
{
Ids = ids;
}
}
}
}
Testing the attributes out
The output from our RS2Server.Scripts project is to be consumed by a separate project, which has been created under the separate namespace RS2Scripts.
We'll create an empty class and add these attributes to it just for the purpose of testing.
Code:
using RS2Server.Scripts;
using static RS2Server.Scripts.Attributes;
namespace RS2Scripts
{
[Author("Testicles")]
[Npc(2305)]
public sealed class Vanessa
{
}
}
Note that the naming convention for the class is down to personal choice. I'm naming this class after the NPC it represents, this may change as the engine grows. The important thing is to be consistent with naming conventions to keep things neat and tidy.
Abstract classes
Most of the coding that will happen in this c# project will be done in the form of abstract classes. We'll be creating what is essentially a set of building blocks which we can add to as needed in order to add features to our scripting engine. This helps to adhere to the separation of responsibility concept that we talked about earlier. We'll be using the Object Oriented Programming (OOP) pattern of inheritance throughout our abstract classes, which stops us from having to duplicate code between script types.
We're going to start with an abstract Script class, which contains a single void method Execute. We do this because the need to execute will be common across every single script we write.
Code:
namespace RS2Server.Scripts
{
public abstract class Script
{
public abstract void Execute();
}
}
We need to provide our scripts with some way to access the player. The abstract class will be passed along to the scripts, which won't have any understanding of what the player object looks like or how it functions. All it knows is that it can call methods and properties to access information. This is where the revision agnostic concept comes in to play. These scripts will never have to change between revisions (or sources, or even games) as long as they have a layer that provides the functionality for these methods. Our abstract script layer is going to be called an AbstractScriptCharacter.
Code:
namespace RS2Server.Scripts
{
public abstract class AbstractScriptCharacter
{
// Property which we can call to get the name of the player
public abstract string Name { get; }
//
public abstract void AddItem(int itemId, int quantity);
public abstract void RemoveItem(int itemId, int quantity);
}
}
This script character can be now be used by any script which needs access to the player. If we write an abstract class for Npcs, we can utilise an instance of this AbstractCharacterScript.
Code:
using System;
namespace RS2Server.Scripts
{
public abstract class NpcScript : Script
{
public AbstractScriptCharacter Character { get; set; }
// Although you can't see it, this script has access to the execute method
// because it inherits from the abstract class Script
}
}
Providing methods to the scripts
First things first, lets provide some implementation for the methods that we've given our AbstractScriptCharacter. In our main server project we'll make a class called ScriptCharacter, which implements AbstractScriptCharacter.
Code:
using System;
using RS2Server.Player;
using RS2Server.Scripts;
namespace RS2Server.Data
{
internal sealed class ScriptCharacter : AbstractScriptCharacter
{
// This will mostly be used for debugging purposes
public string ScriptName { get; }
// https://msdn.microsoft.com/en-us/library/gg712738(v=vs.110).aspx
private WeakReference<Player> PlayerReference { get; }
public ScriptCharacter(Player player, string scriptName)
{
PlayerReference = new WeakReference<Player>(player);
ScriptName = scriptName;
}
// This is private so that our methods that provide actual functionality can access it,
// but it's inaccessible to the script itself so it can't modify the player class at will
private Player Player => PlayerReference.TryGetTarget(out var ret) ? ret : null;
// Not sure how this relates to Override in Java? Probably works the same way.
public override string Name => Player.LoginDetails.Username;
public override void AddItem(int itemId, int quantity) => Player.Inventory.AddItem(itemId, quantity);
public override void RemoveItem(int itemId, int quantity) => Player.Inventory.DeleteItem(itemId, quantity);
}
}
Loading the scripts in to the main server
Now that we have a project which is compiled down to a dll file, we need to leverage the reflection namespace and load the scripts in to memory so that they can be activated when needed.
Code:
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using RS2Server.Scripts;
using static RS2Server.Scripts.Attributes;
namespace RS2Server.Data
{
internal static class ScriptLoader
{
// Use a dictionary as the time to look up by ID is always the same regardless of collection size
// and we're guaranteed to have unique IDs unless we make a mistake. This speed is referred to
// as O(1). This is a good read on how dictionaries work in C#:
// https://softwareengineering.stackexchange.com/questions/264766/efficiency-of-c-dictionaries
public static Dictionary<int, Type> NpcScripts;
// We could make this more generic in the future, calling it LoadScripts instead
// and sorting each type of script in to different dictionaries, but for now since we
// only have 1 type of script, it's only loading npc scripts and so this name makes more sense
public static int LoadNpcScripts()
{
// Initialize new dictionary so we have an empty collection
NpcScripts = new Dictionary<int, Type>();
// The scripting project is set to output the build result to the same folder as this project
const string scriptFile = @".\RS2Scripts.dll";
// This could maybe be improved by throwing an exception if we can't find the script file
// No problems are caused as long as this method has been called though, so return 0 for now
if (!File.Exists(scriptFile)) return 0;
// Read the contents of the file to memory
var bytes = File.ReadAllBytes(scriptFile);
// Use the reflection namespace to load the read bytes and get all public types
var scriptAssembly = Assembly.Load(bytes);
var types = scriptAssembly.GetExportedTypes();
foreach (var t in types)
{
// Does the class we're looking at inherit from NpcScript?
if (t.IsSubclassOf(typeof(NpcScript)))
{
// Get the NPC IDs from the custom attribute.
var ids = t.GetCustomAttribute<NpcAttribute>(true).Ids;
// To pack our scripts in to our dictionary, we create 1 record for each ID, pointing that ID to the same type.
foreach(var id in ids)
{
NpcScripts.Add(id, t);
}
}
}
// Return the amount of scripts loaded, mostly so we can print this to console
return NpcScripts.Count;
}
}
}
Now to call this method when the server starts, simple addition to the entry point of the application.
Code:
public static void Main(string[] args)
{
var scriptCount = DataProvider.LoadNpcScripts();
Console.WriteLine($"Loaded {scriptCount} scripts from the script engine.");
// Load the other shit etc etc
}
Script activator
Now that we've loaded all of our types in to memory, we need something to create instances of them when we need them. This is where our script activator comes in to play.
Code:
namespace RS2Server.Data
{
internal static class ScriptActivator
{
public static Script CreateScriptInstance(Type npcType, string scriptName, Player player)
{
// Create an instance of given class with default constructor, see
// https://msdn.microsoft.com/en-us/library/wccyzw83(v=vs.110).aspx
// If resulting object can't be cast to script, it doesn't inherit from our base class
// and therefore should not be returned
if (!(Activator.CreateInstance(npcType) is Script instance))
{
Console.WriteLine($"Type {npcType} could not be loaded as a script");
return null;
}
// If our script can be cast to an NpcScript, set up reference to an instance of ScriptCharacter
if (instance is NpcScript nInstance)
{
nInstance.Character = new ScriptCharacter(player, scriptName);
}
return instance;
}
}
}
Writing the engine in the main project
Now that we've loaded them all and can instantiate our scripts, it's time that we wrote an engine to tie it all together. In our main server project we'll write an NpcEngine class to make use of our script activator. This will also be a good place to provide the implementation for some of the methods that our NpcScripts can call.
Code:
using RS2Server.player;
using System;
using RS2Server.Definitions;
using RS2Server.Scripts;
namespace RS2Server.Data
{
internal sealed class NpcEngine : IDisposable
{
private Player PlayerReference;
private readonly int NpcId;
public NpcScript ScriptInstance { get; set; }
private NpcEngine(Player player, int npcId)
{
PlayerReference = player;
NpcId = npcId;
// For a given ID, we attempt to get the value out of the NPC scripts dictionary
// if it exists, create an instance of the class and store a reference to it so we can use it later
if (DataProvider.NpcScripts.TryGetValue(NpcId, out var npcT) && npcT != null)
{
ScriptInstance = ScriptActivator.CreateScriptInstance(npcT, npcT.ToString(), player) as NpcScript;
// This is only null if we've somehow tried to create a script that isn't an npc script.
if (ScriptInstance == null)
{
Console.WriteLine($"Failed to load NPC script {npcT} for NPC {npcId}");
return;
}
}
// If we couldn't find a script with the given ID, display a generic missing script chat box
else
{
PlayerReference.Packets.sendNPCHead(npcId, 241, 2);
PlayerReference.Packets.modifyText(NpcDataManager.forId(npcId).Name, 241, 3);
PlayerReference.Packets.modifyText("I'm missing a script, and that makes me sad.", 241, 4);
PlayerReference.Packets.animateInterface(9768, 241, 2);
PlayerReference.Packets.sendChatboxInterface2(241);
}
}
private void ExecuteScriptForNpc()
{
if (ScriptInstance == null)
return;
// We utilise a try catch block so that we can log errors in the form of
// a chat box which users can see.
try
{
ScriptInstance.Execute();
}
catch (Exception e)
{
Console.WriteLine($"Script for {NpcId} broke.);
Console.WriteLine(e);
PlayerReference.Packets.sendNPCHead(NpcId, 241, 2);
PlayerReference.Packets.modifyText(NpcDataManager.forId(NpcId).Name, 241, 3);
PlayerReference.Packets.modifyText($"Something broke! My Id is {NpcId}. Please fix me!", 241, 4);
PlayerReference.Packets.animateInterface(9768, 241, 2);
PlayerReference.Packets.sendChatboxInterface2(241);
}
}
// Instantiate copy of NpcEngine and pass reference to player
// then execute the script
public static void OpenNpc(int npcId, Player player)
{
var npcEngine = new NpcEngine(player, npcId);
player.NpcEngine = npcEngine;
if (player.NpcEngine.ScriptInstance == null)
return;
player.NpcEngine.ExecuteScriptForNpc();
}
// Delete player's reference to NpcEngine instance when disposed either by garbage collector or manually
public void Dispose()
{
if (PlayerReference.NpcEngine == this)
PlayerReference.NpcEngine = null;
PlayerReference = null;
ScriptInstance = null;
}
}
}
Writing functionality and testing
Now that we have a skeleton for creating and loading scripts, it's time to add some methods which the script can call to actually send packets across. For now, we'll focus on a simple example which sends 1 line of text with the continue button available.
First things first, we need to make some modifications to our NpcScript class which our scripts can call.
Code:
namespace RS2Server.Scripts
{
public abstract class NpcScript : Script
{
public AbstractScriptCharacter Character { get; set; }
// These actions are methods which can be invoked with the given parameters.
// The types in the angle brackets (sometimes referred to as chevrons) define the parameters of the function
// This requires a string, then an integer, then an integer in that order
public Action<string, int, int> ModifyText;
public Action<int, int, int> AnimateInterface;
public Action<int, int> SendNpcChatHead;
public Action<int, int> SendPlayerHead;
// These 2 actions below require a single integer
public Action<int> SendChatBoxInterface;
public Action<int> SendChatBoxInterface2;
// An action that would require no parameters would just be "public Action ActionName;"
}
}
Now that we have some methods which can be called, lets change Vanessa's script to make use of these methods.
Code:
using RS2Server.Scripts;
using static RS2Server.Scripts.Attributes;
namespace RS2Scripts
{
[Author("Testicles")]
[Npc(2305)]
public sealed class Vanessa : NpcScript
{
public override void Execute()
{
SendPlayerHead(64, 2);
ModifyText(Character.Name, 64, 3);
ModifyText("Oi cunt", 64, 4);
AnimateInterface(9764, 64, 2);
SendChatBoxInterface2(64);
}
}
}
Right now, these methods don't actually do anything, because our instance of our Npc script hasn't been told what these methods are for.
Going back to our NpcEngine class, we can hook these methods up to our script on creation.
Code:
private void SendPlayerHead(int interfaceId, int childId)
{
PlayerReference.Packets.sendPlayerHead(interfaceId, childId);
}
private void ModifyText(string text, int interfaceId, int childId)
{
PlayerReference.Packets.modifyText(text, interfaceId, childId);
}
private void SendChatBoxInterface(int childId)
{
PlayerReference.Packets.sendChatboxInterface(childId);
}
private void SendNpcChatHead(int interfaceId, int childId)
{
PlayerReference.Packets.sendNPCHead(NpcId, interfaceId, childId);
}
private void AnimateInterface(int animationId, int interfaceId, int childId)
{
PlayerReference.Packets.animateInterface(animationId, interfaceId, childId);
}
private void SendChatBoxInterface2(int childId)
{
PlayerReference.Packets.sendChatboxInterface2(childId);
}
These are all very simple and just call the equivalent packet sending methods.
Now to hook these up when we create an instance of the NpcEngine.
Code:
private NpcEngine(Player player, int npcId)
{
PlayerReference = player;
NpcId = npcId;
if (DataProvider.NpcScripts.TryGetValue(NpcId, out var npcT) && npcT != null)
{
ScriptInstance = ScriptActivator.CreateScriptInstance(npcT, npcT.ToString(), player) as NpcScript;
if (ScriptInstance == null)
{
Console.WriteLine($"Failed to load NPC script {npcT} for NPC {npcId}");
return;
}
ScriptInstance.ModifyText = ModifyText;
ScriptInstance.SendChatBoxInterface = SendChatBoxInterface;
ScriptInstance.SendNpcChatHead = SendNpcChatHead;
ScriptInstance.SendPlayerHead = SendPlayerHead;
ScriptInstance.AnimateInterface = AnimateInterface;
ScriptInstance.SendChatBoxInterface2 = SendChatBoxInterface2;
}
else
{
// Default "that makes me sad" behaviour that we saw earlier
}
}
The only thing left to do before we can test out our NPC scripts is to make some changes to the packet handler.
In our NpcInteract packet handler we have a method called "HandleSecondClickNPC".
This iterates over a lot of different IDs in a big switch, which will eventually be replaced. For now, to make sure we don't break anything we'll be adding a default case to this switch statement so that our NpcEngine is called if there isn't already existing behaviour.
Code:
switch (npc.Id)
{
//Bob, Aubury, Ali Morisanne, slayer master etc above here
default:
player.Packets.softCloseInterfaces();
player.setEntityFocus(npc.ClientIndex);
// Prepare the code that should be executed when player is 1 square in any direction from the NPC
var interactWithNpc = new AreaEvent(player, npc.Location.X - 1, npc.Location.Y - 1, npc.Location.X + 1, npc.Location.Y + 1);
interactWithNpc.setAction(() =>
{
npc.setFaceLocation(player.Location);
player.setFaceLocation(npc.Location);
player.setEntityFocus(65535);
NpcEngine.OpenNpc(npc.Id, player);
});
Server.RegisterCoordinateEvent(interactWithNpc);
break;
}
Now if we hop in to the game, we can see that if I speak to an NPC which has no script:
If I go to speak to Vanessa and force an error to appear:
If I let the script execute properly:
Finite state machines
A finite state machine is the concept of a given entity being capable of existing in exactly one of many predefined states at any one given time. This concept isn't unique to programming, but can be observed in engineering, mathematics and many other fields. A car's gear stick, for example can only be in 1 of the many gears, reverse or neutral at any given time. Traffic lights can be in 1 state, green yellow or red at any time. The code base that I've started with made an attempt to adopt the finite state machine pattern, but didn't quite get it right as the single variable which tracks NPC state is shared amongst all scripts.
Code:
if (player.getTemporaryAttribute("barrowTunnel") != null)
{
Barrows.verifyEnterTunnel(player);
return;
}
else if (dialogueStatus == 1005)
{
Slayer.doDialogue(player, 1006);
break;
}
else if (dialogueStatus == 1009)
{
Slayer.doDialogue(player, 1010);
break;
}
else if (dialogueStatus == 1002)
{
Slayer.doDialogue(player, 1013);
break; // These breaks also do nothing
}
This results in a big long switch statement which is triggered by the second integer passed in to doDialogue:
Code:
switch (dialogueStatus)
{
case 1000:
p.Packets.sendNPCHead((int)SLAYER_MASTERS[index][0], 241, 2);
p.Packets.modifyText((string)SLAYER_MASTERS[index][2], 241, 3);
p.Packets.modifyText("Hello, what can i help you with?", 241, 4);
p.Packets.animateInterface(9827, 241, 2);
p.Packets.sendChatboxInterface2(241);
newStatus = 1001;
break;
case 1001:
p.Packets.modifyText("I need another Slayer assignment.", 238, 1);
p.Packets.modifyText("Could i see your supplies?", 238, 2);
p.Packets.modifyText("I'd like to discuss Slayer points.", 238, 3);
p.Packets.modifyText("I'd like a Slayer Skillcape.", 238, 4);
p.Packets.modifyText("Er...nothing...", 238, 5);
p.Packets.sendChatboxInterface2(238);
newStatus = 1002;
break;
case 1002: // New assignment
p.Packets.sendPlayerHead(64, 2);
p.Packets.modifyText(p.LoginDetails.getUsername(), 64, 3);
p.Packets.modifyText("I need a new assignment.", 64, 4);
p.Packets.animateInterface(9827, 64, 2);
p.Packets.sendChatboxInterface2(64);
newStatus = 1003;
break;
// etc etc
}
Our scripts will look very similar to this towards the end, but will be responsible for tracking their own state. Currently the dialogueStatus variable is pulled from a list of "temporary attributes". I'd like this system to die off eventually, as it's a bit flaky and feels like extremely lazy programming. The system will live for now, but this single temporary attribute will die very soon. Here's the snippet that retrieves the dialogue status:
Code:
var dialogueStatus = player.getTemporaryAttribute("dialogue") == null ? -1 : (int)player.getTemporaryAttribute("dialogue"); // Dialogue status
Simply adding the following code to our NpcScript class assures we start off at state 0. The script can then decide what to do from there.
We're also going to add a LastInterface variable. This will check to see that the interface that contained the clicked action button was the last interface we showed the player.
Code:
public int LastInterface = -1;
public int State = 0;
In order to move from 1 state to the next, our NpcEngine will contain a method which will be called by our action button packet handler.
For now it will only handle the "continue" button, since one liners are all the functionality our scripts have right now.
Code:
public void HandleSelection(int interfaceChildId)
{
// Continue
if (interfaceChildId == 5)
{
ScriptInstance.State++;
}
// TODO: Investigate each dialogue interface to see which
// children have which IDs and code interface specific behaviour
// Execute the script again
ScriptInstance.Execute();
}
Note that while the above behaviour works for now, later on when there are multiple interfaces being worked with this behaviour won't suffice.
For now I'm happy with leaving a TODO here and coming back to handle interface specific behaviour later on.
In the action button packet handler, the "continue" button triggers the method handleActionButton3.
I don't really know the difference between the action button methods, possibly has something to do with how many layers deep the child is in the interface?
Code:
private void handleActionButton3(Player player, Packet packet)
{
int id = packet.readUShort();
int interfaceId = packet.readUShort();
int junk = packet.readLEShort();
Console.WriteLine($"ACTIONBUTTON-3 {id}, interface {interfaceId}");
// For information on the null conditional (?) operator, see https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-conditional-operators
// Probably better to just call HandleSelection and let it handle the interface id checks in there, but this will do for now
if (player.NpcEngine?.ScriptInstance?.LastInterface == interfaceId)
{
player.NpcEngine.HandleSelection(id);
return;
}
// etc etc
}
Now that we have the concept of state and our scripts can navigate between them, we should give the scripts a way of ending the chat dialogue.
To do this, we add a method to NpcEngine and NpcScript called EndChat, the exact same way we added the other methods before. This method is quite simple:
Code:
private void EndChat()
{
p.Packets.softCloseInterfaces();
}
We can now make Vanessa have multiple dialogue states:
Code:
using RS2Server.Scripts;
using static RS2Server.Scripts.Attributes;
namespace RS2Scripts
{
[Author("Testicles")]
[Npc(2305)]
public sealed class Vanessa : NpcScript
{
public override void Execute()
{
switch (State)
{
case 0:
SendPlayerHead(64, 2);
ModifyText(Character.Name, 64, 3);
ModifyText("Oi cunt", 64, 4);
AnimateInterface(9764, 64, 2);
SendChatBoxInterface2(64);
LastInterface = 64;
break;
case 1:
SendNpcChatHead(64, 2);
ModifyText("Vanessa", 64, 3);
ModifyText("U wot m8?", 64, 4);
AnimateInterface(9764, 64, 2);
SendChatBoxInterface2(64);
LastInterface = 64;
break;
default:
EndChat();
break;
}
}
}
}
Using EndChat in our default case ends the chat if we ever end up in a state that we have no dialogue for. This should be obvious enough during testing that there's a problem with the script.
It also allows us to set the state to something like -1 if we want to end the chat easily.
Here's what this looks like in game:
The final thing left to do for part 1 is to be able to load scripts on the fly.
If I make a change to an NPC script, I don't want to have to restart the server, I want to reload the NPC scripts from scratch so that I can see if the bug is fixed immediately.
To do this, we'll make a simple command script that requires level 2 rights to use.
Code:
using RS2Server.Data;
using RS2Server.player;
namespace RS2Server.PacketHandlers.Commands
{
internal sealed class ReloadScripts : ICommand
{
public void execute(Player player, string[] arguments)
{
var count = DataProvider.LoadNpcScripts();
player.Packets.sendMessage($"Loaded {count} scripts!");
}
public int minimumRightsNeeded()
{
return 2;
}
}
}
Part 2 will be coming soon(ish) and will focus on
- Simplifying the scripts to reduce redundant code
- Removing hard coded integers, such as face animation IDs
- Extending the functionality of the engine to include multiple option dialogue interfaces etc