Menu

Subscribe

Using hscript to program entity behaviors in luxe

Menu

Abstract / TLDR;

I have made a proof-of-concept which uses hscript together with luxe and the new automatic reloading functionality to provide a very quick way of tweaking entity behavior. With some workarounds which I have provided, I think this is a viable way doing prototyping. A big bonus with my solution is that scripts will behave as a normal Haxe-files. This makes the default code completion work out-of-the-box for most IDE's / text editors - which I think is absolutely necessary when scripting.

Prerequisites to run this code

The hscript library (haxelib git hscript https://github.com/HaxeFoundation/hscript.git) - note that we need the latest development version, 2.0.4 will not work

The Delta tweening library, currently only on git (haxelib git delta https://github.com/furusystems/Delta.git)

Latest version of hxcpp (haxelib install hxcpp)

I have tested using Haxe 3.2.0, hxcpp 3.2.102, latest git versions of luxe, snow, hscript and Delta as of 20.06.15

Since we are using the reloading functionality - only Windows, Mac and Linux are supported. Web target should work, but will not reload.

hscript topics

I guess the most interesting part for most people are how I utilized hscript and some of the technical issues I encountered during the implementation, so here goes!

Code completion

This was the first thing I set out to solve. Not having all functions in my head / fingers, it was very important that I could do "normal" completion when coding the scripts. This is also vital to avoid errors since hscript doesn't compile and doesn't have type safety. So we need to count on the Haxe completion server to help us out!

Some of the things I encountered and had to solve:

Use hscript allowTypes = true so that we can type all local variables in the script and easier guess the types for the completion server

Parse import statements to be able to dynamically map variables available and to help code completion. Note that hscript requires to have an explicit mapping to know about static classes / singletons like Luxe, and for instancing new types. This is done for the hscript intrepter as follows: scr_interp.variables.set('Luxe', Luxe);.

Allow class statement to satisfy completion - or else it will choke completely.

For specific, non-static variables, have to use self-referencing: var entity : Sprite = entity;. Note that we have to use the assignment.

Remember to add extra classpaths to let the completion server register the files. In the flow file, put an extra directive for app in the flow file like this: codepaths: ['src', 'assets/scripts'] or wherever you have your script. They also have to have the .hx file extension. Even though many code completion plugins understands .hscript, the completion server does not.

I wondered how to call script functions. @PDeveloper helped by pointing out that functions are variables and can be casted! Example: `` For now, expected functions are 'init' and 'ondestroy' and optionally 'update'.

Limits of hscript

It is important to understand that hscript is a parser built on top of Haxe and does not have the full feature set of Haxe. This is stated on the hscript README, but in addition beware of the following:

In general, you will lose your type safety. hscript uses reflection and doesn't really care about types. This is to some degree alleviated by subjecting the script to completion rules when coding. I don't think I would have pursued this path without proper completion as described earlier.

hscript doesn't handle generics, so it will give an error on any variable declarations like new Array<String>, but it is possible to instance it like this: new Array().

Script format

First, I branched hscript to let the parser ignore import and class statements (tokens), and to fix a long-standing issues with assigning to properties. Later I decided to drop this in favor of a slightly more "hacky" solution that supports the official branch of the hscript library. Also, I had to override the interpreter similar what is described by @underscorediscovery in the hscript issue log #10.
In general, the code completion topic and the extra "features" that I allow as an "extension" to hscript leads to that the file format needs to adhere to a couple of rules. (Also note that I haven't done extensive testing to find faults of the formats, please let me know if you find any border cases that breaks it).

Minimal script

In principle, we can use any minimal script as normal hscript allows as follows:

var nothing = 0;

Class-based script

This script is not connected to the luxe component. If you go this route, you can define one and only one class without extensions in your file. Optionally, you can include import statements that will be auto-assigned like luxe.Sprite in the following example:

Note that when using classes, all the normal hscript restrictions apply and this means that you cannot use things like:

typedefs

using statements

package statement

Standard luxe component script

This script can be utilized directly by the luxe ScriptComponent class as a template:

import luxe.Sprite;
class EmptyScript
{
// always defined, this is the entity variable of the component, already cast to Sprite
var entity : Sprite = entity;
function init()
{
// called when the component is initialized (Component.init)
}
function ondestroy()
{
// called before reloading and when destroying the component (Component.ondestroy)
}
function update()
{
// called each update, can be omitted and will not be attempted to call unless defined in the script
// note that you have to use Luxe.time to calculate your own delta time
}
}

Debugging

Unfortunately, debugging becomes a lot harder with scripts in general, and also when using a very callback-heavy architecture. Here I have a hard time knowing which part of the script has actually failed. This is one possible area for future improvement.

Called from snow.Snow::on_event snow/Snow.hx line 311
Called from snow.Snow::on_snow_update snow/Snow.hx line 263
Called from snow.App::on_internal_update snow/App.hx line 151
Called from snow.Snow::do_internal_update snow/Snow.hx line 233
Called from luxe.Core::update luxe/Core.hx line 415
Called from luxe.Emitter::emit luxe/Emitter.hx line 47
Called from luxe.States::update luxe/States.hx line 407
Called from TestView::update TestView.hx line 121
Called from tween.Delta::step tween/Delta.hx line 334
Called from tween._Delta.TweenSequence::step tween/Delta.hx line 2
Called from tween._Delta.TweenAction::step tween/Delta.hx line 221
Called from *::_Function_3_1 hscript/Interp.hx line 399
Called from hscript.Interp::exprReturn hscript/Interp.hx line 211

The only thing I could figure out the help was the fact that I most likely do a lot of very small changes, so when something stops working, backtrace a few steps and try to locate the culprit. In many cases there are null references as a result of missing import statements in script. Also, runaway event handlers contributed to some of my bugs.

Notes about class availability for scripts

These are some of the things I also encountered when resolving classes for use in the scripts.

One of the early show-stoppers was the fact that Haxe can't load classes which aren't declared at compile time. Imports that are not used anywhere are by default ignored by the Haxe Dead Code Elimination (DCE). This must therefore be turned on in the flow file under the build section like this: flags: ['-dce no'].

Be aware that import statements with asterisk does not work! (For example import luxe.collision.*).

To easier collect all classes needed, I created a separate class to include all avaible classes to the script. This idea was borrowed from Acadnme.

Another important thing to note is that Luxe uses "aliases" (typdefs) to create a consistent luxe namespace, for example typedef Vector = phoenix.Vector;. Since we and hscript use Type.resolveClass to map classes, this means that we have to use the actual class when importing, both in the scripts and in the ScriptClassLibrary class. So, instead of importing luxe.Vector, we have to use phoenix.Vector.

Overall architecture

Let's return to the core of the program and the general architecture. All core script helper classes are framework-independent and resides under the scripting source folder.

Scripting

The scripts should primarly control state and values of entities and components - note that I do not create or destroy any components or entities inside scripts themselves. This is of course not an absolute rule or technical limitation, but I think it makes the architecture clearer by setting some overall rules - it also helps deciding where to put some additional logic or feature.

I created a script handler for ease the dealing with script loading and function calling - including catching potential errors without crashing. I created separate classes / files declaring imports to make classes available to the scripts. For convenience, I also created a luxe Component to handle scripts to be attached to entities.

I quickly discovered that I needed a simple helper class to help me control main sequences at a more overall level than only using tweening onComplete functions - I prefer to limit the use of anonymous functions and an excessive use of extra functions just to sequence things. So I created a ScriptSequencer class that specifies functions calls, how many times they will be called and a loop point. It can be used from scripts like this:

So here we do one approach from off-screen, two swipes, a beam-prepare action and a approach with the BeamOfDeath.Then we start with the swipes again. Since we hook on to damage and death-events from other components, we handle this when the events actually occur.

Pitfalls

Some of the pitfalls that I encountered and spent some time on debugging:

Runaway event handlers - always clean up your events. In the script I have an array of references which I add to and always empty and unlisten in ondestroy.

Be careful when calling the init function again from the script itself. You might end up with runaway event handlers. Make a separate reset function or similar instead.

Do small changes in the script before saving and testing. After all - this is what this solution excels at! Also, it is easier to pinpoint errors since debugging is currently very hard.

Components

I utilize the ECS architecture by creating a set of components that can be (re)used, and more importantly - also be fetched and manipulated directly in the script. All "hard-coded" components like weapons and health are (mostly) stand-alone, and do not know much about other components. There are some exceptions, but I try to have other components as explicit dependencies and not implicit ones.

As an example: in BossWeapons, I have a function run_hull_collision(hull:EntityHull, ?_remove_on_hit : Bool = false) : Bool. This takes an argument of another component (EntityHull). -But they do not do internal calls to fetch components directly from entity via the get method.

Further, the "core" component classes emit events that can be picked up by other components or scripts. The scripts are generally allowed to access components directly if needed. They also subscribe to events from the generic components.Example to access other components from the script:

hull = entity.get('EntityHull');
hull.auto_immune_timer = 1;

At first I didn't add a update function since I also wanted to challenge myself by making a pure event-based architecture. So for the Boss I have been relying on creating a sequence helper which is very basic, and handle all other logic using only events and tweening. This works fairly well in my opinion, but depends on how complex your behavior is in the end.

Events

For handling bullet / weapon logic, I soon discovered that I also spent time customizing some aspects (especially aestethics) of the weapon sprites themselves, so I had to provide separate events for this as well. As an example, the following event fires when shooting a new bullet: entity.events.fire('BossWeapons.bullet.fire', b);. This can be linked up in the script with a custom functions to do a simple animation, for example:

A side-note here is that I save all the event-id's for the registered in an array. This makes the cleanup job much easier and more reliable. The only downside of an event-based and callback-heavy architecture is that it is a lot harder to debug as mentioned previously.

Later, I decided to add the player as a script as well, just for fun! I quickly realized that I would need the update loop to handle some of the logic, so I also support this. The function is optional and no calls are attempted at all if it doesn't exist.

Summary

To summarize, this is how I intended to use the scripting architecture:

The general scripting classes make no assumptions of what you're trying to do and should be independent.

The component makes the assumptions that you are tying it to an entity, so it adds entity and standard functions

I place game-specific assignments into a separate state - these are assumptions about the game, for example that there is a player variable that the boss can use for whatever it wants...

To make additional classes available for the script to use, you can add custom files for imports, like ScriptClassLibraryLuxe.

Reload functionality in snõw

@underscorediscovery described this in his alpha-2 wrap-up post. Be aware that this only works with desktop targets which have an actual filesystem. Just remember to add --sync when running flow. Hook on to the reload event, and the rest is automatic. Beware that you will get the full path with the event data. Another important thing to notice is that currently, any single file will generate an event for the watched directory, so there is currently no easy way to distinguish which script was actually reloaded. The result is that all scripts will be reloaded as soon as you save one of them. I'm not sure if this behavior is correct...

A note on actuator frameworks

I started with the goal to only use the Delta framework from furusystems which will also be the future standard tweening library in luxe. I very much liked the sequencing concept of Delta. But I also discovered that it lacks some of the looping concepts that the original Actuate implementation has (which is currently in luxe.tween). This includes the ability to make a loop using repeat(x) and can be very useful to minimize the cross-function calls and to avoid creating a lot of functions for each step in my sequence. For example, I could not easily reproduce something like this with Delta, witout using callbacks:

I have also not been experimenting with Actuate motion paths, which I think would be something worth checking for boss movements. So my ideal tween lib would probably be a mix between Delta and Actuate :)

Conclusion

I set out to create a simple example since I have never coded this kind of behavior like this before. So I had to make a more complete example to also test if this was something that I could work with. Sorry for the bloat, but I hope you also agree on some of the overall, architecural patterns :) I found myself enjoying the opportunity to do a very fast reload, and I will atually consider using this in the future. Currently there are very few limits that cannot be overcome in my opinion. I deemed this as a useful exercise that hopefully others can learn from!

Future

Some of the things that I might look into (or suggestions for others!) include:

Actually learn to use the luxe log facilities properly instead of throwing tracecalls around

Find a better way of pinpointing errors and debugging info from script