Research done on implementing scripting system
Last year i outlined how a scripting system could be implemented:
https://twhl.info/thread/view/20055?page=2#post-346080I wanted to do some preliminary work on this to see if any of the scripting code used by the conditional evaluation system needed changing, but it grew quite a bit bigger.
I've spent some time researching everything i outlined to determine how feasible it would be, and i built a prototype to test some things.
Note: This is a prototype, which means it's not even a pre-alpha design. It's just an experiment, it has not been added to the project and won't be for quite a while.
List of topics:
- Plugins and map scripts: what differences are there between them, what are they, how do they work
- Are shared script entities allowed
- How are multiple scripts dealt with
- How does the scheduler work
- How does trigger_script work
- How are hooks handled
- Are custom entities supported, and how do they work
- Analysis
Plugins and map scripts: what differences are there between them, what are they, how do they work
What differences are there between them
None. Well, not none, but they behave functionally the same. Plugins are loaded from a configuration file that looks like this:
[
{
"Name": "TestPlugin",
"Scripts": [
"maps/test.as",
"maps/another.as",
"plugins/plugin.as"
]
}
]
Plugins are loaded on server startup and stay loaded. There is a command to reload them but it's for debugging purposes, i haven't implemented proper lifetime management yet.
Map scripts are loaded from the map configuration file:
{
"Includes": [
"cfg/HalfLifeConfig.json"
],
"Sections": [
{
"Name": "Scripts",
"Scripts": [
"maps/test.as",
"maps/duplicate.as"
]
}
]
}
Map scripts are discarded on map change.
When printed in the console the type is printed, but this only affects names and not behavior.
Beyond that they are identical in every way. Plugins can do everything a map script can and vice versa. Map scripts are just plugins with special treatment. They even use the same configuration file parsing code, so i will be referring to both as plugins.
What are they
Plugins are a set of Angelscript modules. Each module represents a script file listed in the configuration file. A script may include other files, including files that are themselves listed in the file but this will result in the same code being included in two separate modules.
Non-shared script entities may use the same names in multiple modules.
How do they work
When a plugin is loaded all of its scripts are loaded into modules. Each module is then initialized by calling
ScriptInit
.
When a module is about to be discarded
ScriptShutdown
is called. This can happen if an error occurred during initialization for any module in a plugin, if the plugin is being reloaded or if the game is shutting down.
These are the only functions called directly by the game and are also the only ones that cannot be marked shared.
All other script code is executed in response to game events which can come from various sources.
Are shared script entities allowed
Yes. This mechanism is used to share the custom entity base classes (see below for more information).
How are multiple scripts dealt with
The scripts listed in the config file are all loaded into their own module so if a script just needs to be loaded but is not interacted with directly by other script code this makes it easy to use. This solves the problem of scripts clashing with each-other.
For example a script could define a custom entity and register it in its
ScriptInit
function.
ScriptInit
cannot be shared so multiple custom entities can be added to a plugin just by including the scripts in the config file.
Note that you should still avoid doing that when possible since it makes using the script code directly much more difficult.
How does the scheduler work
I've redesigned the scheduler to be safer and easier to work with:
void ScriptInit()
{
State state;
state.Data = "Hello World!";
Scheduler.Schedule(ScheduledCallback(@state.Callback), 1.5, 3);
//Will trigger an Angelscript exception (stops script execution and logs to the console)
//Scheduler.Schedule(null, 1.5, 3);
}
class State
{
string Data;
int Count = 1;
void Callback()
{
log::info("Data is: " + Data + ", " + Count);
++Count;
}
}
The scheduler no longer uses function names and no longer captures parameters. It takes a single funcdef
void ScheduledCallback()
that functions must match to be used. If a function isn't compatible your script won't compile.
Stateful callbacks are possible using delegates as shown above.
Scheduler::Schedule
takes 3 parameters: the callback, interval between executions and total number of executions (default 1 for single execution, can be
Scheduler::REPEAT_INFINITE_TIMES
for unlimited executions).
It returns a handle type that can be used to clear the callback.
The scheduler is now so simple that you could almost implement it yourself in a script. The only thing that's missing is a way to call a function every frame (which the scheduler provides).
How does trigger_script work
It searches for the first occurrence of the specified function with the right signature:
void functionName(CBaseEntity@ activator, CBaseEntity@ caller, USE_TYPE useType, float value)
The first module to provide it wins. And other instances are logged as warnings.
This supports namespaces so it should be easy enough to avoid conflicts.
No think function support has been added since you can do that with the scheduler.
How are hooks handled
Hooks are now events and are handled using a uniform syntax:
void ScriptInit()
{
Events::Subscribe(@Callback);
Events::Subscribe(@MapInit);
}
void MapInit(MapInitEventArgs@ args)
{
}
void Callback(SayTextEventArgs@ foo)
{
log::info("Callback executed");
log::info(foo.AllText);
log::info(foo.Command);
}
All events have a reference type of name
<EventName>EventArgs
and a funcdef
void <EventName>Handler(<EventName>EventArgs@ args)
.
Subscribing is done by calling
Events::Subscribe
with your callback, unsubscribing works the same way with
Events::Unsubscribe
. If you're using an object method you'll need to cast it using the funcdef.
Events are completely type-safe and have compile-time error checking so you don't have to worry about getting the function signature wrong or having a typo in the name.
There is no
PLUGIN_HANDLED
functionality. Event handlers are always invoked in the order that they are subscribed, regardless of whether the handler is in a plugin or map script. The order that plugins are listed in the config file has no effect on behavior.
Events may provide a way to suppress behavior in C++ code, that's provided on a case-by-case basis and does not affect event handler execution.
Defining events in C++:
/**
* @brief Base class for script event argument types.
*/
class EventArgs : public as::RefCountedClass
{
public:
EventArgs() = default;
EventArgs(const EventArgs&) = delete;
EventArgs& operator=(const EventArgs&) = delete;
};
class MapInitEventArgs : public EventArgs
{
public:
MapInitEventArgs() = default;
};
class SayTextEventArgs : public EventArgs
{
public:
SayTextEventArgs(std::string&& allText, std::string&& command)
: AllText(std::move(allText)), Command(std::move(command))
{
}
const std::string AllText;
const std::string Command;
bool Suppress = false;
};
Registering events in C++:
static void RegisterMapInitEventArgs(asIScriptEngine& engine, EventSystem& eventSystem)
{
const char* const name = "MapInitEventArgs";
eventSystem.RegisterEvent<MapInitEventArgs>(name);
}
static void RegisterSayTextEventArgs(asIScriptEngine& engine, EventSystem& eventSystem)
{
const char* const name = "SayTextEventArgs";
eventSystem.RegisterEvent<SayTextEventArgs>(name);
engine.RegisterObjectProperty(name, "const string AllText", asOFFSET(SayTextEventArgs, AllText));
engine.RegisterObjectProperty(name, "const string Command", asOFFSET(SayTextEventArgs, Command));
engine.RegisterObjectProperty(name, "bool Suppress", asOFFSET(SayTextEventArgs, Suppress));
}
void RegisterEventTypes(asIScriptEngine& engine, EventSystem& eventSystem)
{
const std::string previousNamespace = engine.GetDefaultNamespace();
engine.SetDefaultNamespace("");
RegisterMapInitEventArgs(engine, eventSystem);
RegisterSayTextEventArgs(engine, eventSystem);
engine.SetDefaultNamespace(previousNamespace.c_str());
}
Publishing an event is also simple:
// Inform plugins, see if any of them want to handle it.
{
const auto event = scripting::g_Scripting.GetEventSystem()->Publish<scripting::SayTextEventArgs>(p, pcmd);
if (event->Suppress)
{
return;
}
}
It's all very simple.