The basic problem is that when moving diagonally, the edges are ignored because edges only exist at the sides of the square, and not at the corners.
The solution to this was to query both the start tile (slg.PreTileUID) and the target tile (slg.TileUID) and from there, determine the GridMove/Board direction index that the current path was taking.
Using the principal direction (let’s assume it was a diagonal value of 6 (as demonstrated in the above image), the two other directions flanking the main one was also queried.
In the above image, the movement from the start tile is at direction 6. Direction 2 and 3 were derived from this by a lookup that defined two flanking direction for any given direction.
Then the target tile was also queried, but this time, the principal direction was the reverse of the start tile (computing for the reverse tile was made simpler by using a lookup, although a simple modulus would have done it, too). In other words, 4 is the direction. Using the same lookup, 0 and 1 were looked up as the flanking reverse directions.
Then a check is made against existing edges: are there any edges on the 2 and 3 directions of the start tile? If so, movement cost is BLOCKING. If not, then check the target tile’s 0 and 1 direction edges if they exist. And if they do block the movement.
The method used in determining the principal direction was simply comparing the logical X and Y positions of the start and end tiles.
On the RND test, here are some thoughts on triggers.
Triggers are broadcast by a function. Triggers may have a targeted object/instance. In order to target any potential object, they’re put into a Family, which I’ll refer to as f_trigger_receiver (f_tr, for short).
There are 2 parts to triggers. The ‘main’ logic, and the ‘map’ logic. The main logic handles generic logic of triggers.
The Family for all trigger receivers. Requires f_name, and f_receivedtrigger variables. f_name is the name of the entity which a trigger will use to refer to this instance. f_receivedtrigger is the string identifying the trigger that has been sent out.
On player GridMove reach target
Fired every time the player moves into a tile. This queries if a trigger area was stepped on.
Also, the player must have a current_trigger variable which keeps track of the trigger area it is on at any given time. This prevents re-triggering when the trigger area covers adjacent tiles. Also, this allows to find out if the player has stepped out of a trigger.
A function which handles the send off to f_trigger_receiver. It accepts a trigger_name, and a trigger_target. The trigger_name is the identifier of the trigger. The trigger_target is a comma-delimited string that identifies the objects/instances that the trigger will be sent to. The f_trigger_receiver family is used in order to go across different object types.
Any other interactions deemed worthy of a trigger only has to call the BroadcastTrigger function, and feed it an object that can accept a trigger.
The RND test, for example, had broadcast an NPC interaction generically by feeding it trigger_name="npctalk", trigger_target="npc1". Then the trigger was broadcast only on npc1 and processed accordingly.
There are no ‘global’ triggers (ie triggers must always have a target). If a ‘global’-like trigger is needed, it might be better to use the player’s mover token as that, since it’s as global as you’re going to get.
Map logic refers to the map/room-specific stuff.
Typically, the triggers for a particular room are stored in a separate event sheet (which I call scripts).
I put the time triggers in the map because it’s more specific to the map/mission. I still call BroadcastTrigger, but the trigger_name is specific to the map, of course.
Time triggers include ‘per-tick’ or any kind of time-related triggers.
Some instances need to init themselves before going into play. For example, a waypoint traveller needs to init the first waypoint index. This is done using the post_tmx boolean check, which is basically a switch that tells that the TMX has been completely read, and all objects have been created (and thus referenceable).
Other triggers and functions
Any other kind of triggers, whether they’re from FSM or TOWT, can be put in the map logic script. In the RND test, I’ve put in unique FSM states (eg “reachpath”) to put it in a special state so that the rest of AI can contextualise itself.
I’ve been testing a lot of concepts (some old, some new) with a test project and this post is about what I’ve learned, and what else needs to be explored.
CSVToDictionary and AJAX
It’s easier to maintain a separate text file for populating lookup dicts. Use the AJAX object to read the text and then CSVToDictionary to populate the dict. Remove the double-quote marks when using a text file. This makes it easier to read.
Newlines in text files
When extracting text using AJAX, newlines might be necessary, but escape characters do not seem to be recognised. Therefore, I ended up using escape characters, but had to process it (using search-replace) during extraction.
Containers have been extremely useful especially in terms of debugging messages. For every object I need to debug, a debug Text object is created, and querying the instance of the object will always point to the same objects of the container. No additional picking is necessary. This is probably the most important aspect of my testing.
There are no real enumerations in C2, but simply assigning a constant number to a recognisable variable name is good enough. For example, in the case where the z-layer of a logical position needs to be identified by keyword, I use Z_TILE=0, Z_WALLS=1, etc.
Although a topic unto itself, the main takeaway from doing AI, is how triggers are setup in Tiled and how it’s set up in C2 to respond to triggers.
There are area triggers, which are set up in Tiled. These are positional, and in the test project, they included a ‘facing’ property, which meant that the trigger is fired only when the player is facing a certain direction. The trigger’s name is the string that will end up being called by C2. I opted to use the ‘name’ attribute in Tiled instead of the relegating it to a property because it’s clearer to see the object name in the Tiled viewport.
Some triggers are set up in C2, especially other kinds of interactions. For example, talking to an NPC will yield a trigger specific to the interaction.
The C2 trigger itself is tied to a particular entity, whether it is another NPC, or some other object. That object is responsible for keeping track of the global trigger calls, and what is relevant to itself. For example, if a certain trigger is called 3 times, the object must keep track that it has heard those 3 triggers, and act accordingly. As such, two variables are meant to store AI-specific data: scriptmem, scriptmem_float. The scriptmem variable is meant for strings, and the scriptmem_float is for float value. For example, scriptmem_float was used a generic timer (for waiting). On the other hand, scriptmem was used to store how many times the AI has heard a specific trigger by checking and appending keywords onto the string.
Another important thing about AI is the switch between scripted AI and ‘nominal’ AI. Nominal AI is one that is already pre-programmed in C2 where if there are no scripts directing the AI, it will follow a certain logic (which also depends on the type of AI it is). Two things needed to happen. First the AI needed to know which AI it was allowed to switch to, and this was put in a C2 variable called ai. For example, one agent was assigned ai=script,see. This allowed the agent to switch to ‘script’ mode, but also allow state changes to occur when the C2 ‘see player’ trigger was fired. There was a ‘hear player’ trigger that existed, but because this was not included in the variable, the agent did not respond to hearing, only seeing, and only triggers involving scripts. This ai variable assignment is first done in Tiled, and then propagated to the agent during TMX load.
In addition to the ai variable, the agent had to be put into an FSM state called “script” when it is in scripted AI mode, which allows the system to distinguish which part of the AI sequence it is in. The C2 events which constitute the AI for that agent must consider other FSM states, like “idle”, which is often the ending state after a move.
AI is a bigger topic and I will delve into it more when needed.
I find, more and more, that groups are quite useful not only in organising and commenting, but allows simpler conditional actions to be done in-game. The only example I have is the deactivation of user input if a particular state is on-going. This makes it trivial to block input rather than having check conditions of state all through the user input event.
SLG movement cost
SLG movement cost functions can actually be quite simple. At first I thought it needed to accommodate many aspects, but in the end, despite the relatively complex requirement of the test AI, pathfinding, at most, needed only to query the LOS status of a tile. Impassability was bypassed by excluding impassable tiles from the MBoard, making pathfinding simpler and, I think, faster.
It’s also probably best to name SLG movement cost functions with the following convention: <char> <purpose> path. Eg: “npc evade path”, or “npc attack path”, whereby in the “npc evade path” the NPC avoids LOS, and “npc attack path” does not avoid LOS at all.
Orthogonal and Isometric measurements
I’ve researched and learned a lot of about how to transfer orthogonal measurements to isometric values. The positional values were simple enough, but the real progress was in computing angles.
Here is a list of important considerations when dealing with isometric stuff:
Converting a C2 object angle (orthogonal space) to an isometric angle (OrthoAngle2IsoAngle). This is used to draw a line in isometric view if that line’s angle was the same as in orthogonal view.
Converting an angle depicted in isometric view to orthogonal space (IsoAngle2OrthoAngle). This is used to determine what an angle would like when viewed from top-down. So when you measure the angle between two points, it’s not truly the angle when viewed in orthogonal space because the isometric view is skewing things. IsoAngle2OrthoAngle allows the reverse computation so that, for example, LOS could be determined for a particular point.
Converting orthogonal XY positions to logical XY (OXY2LXY). There is no Board function that allows this mainly because this is a peculiarity of the way Tiled positions objects of the Board. The positioning of objects is written in orthgonal space, but when Rex’s SquareTx projection is set to isometric, then all measurements become isometric. Thus this function is needed for this lack.
Movement Board and Graphics Board versions of OXY2LXY. This is required because SquareTx for each Board is different, and thus the logical positions will yield a different location.
Computing isometric distance to orthogonal distance. This measures two points in isometric space and gives out the distance as though you were looking from above. This is useful in determining the distance between foreground and background objects. This uses a SquareTx as a point of reference for the width/height ratio, but can use the MBoard or GBoard, because they are assumed to have the same ratio.
Computing snap angle (Angle2SnapeAngle). Looks at the object’s angle, and finds the nearest angle to snap to (assuming 8 directions). This is required so that the proper animation is set.
Converting MBoard logical positions to GBoard logical positions and vice-versa (MLXY2GLXY, GLXY2MLXY). This is very important as it is able to relate the MBoard to the GBoard. Because the MBoard has smaller cell sizes, querying the logical positions of the MBoard using bigger GBoard logical positions will always yield the top-left cell of the MBoard.
Convert GridMove direction to C2 angle (GetGridMoveDirection). The GridMove values are quite different. This function converts it for use with other things, like animation, or other function related to facing, which use the C2 angle, or snap angle.
The last part of this post is about LOS. I’ve already wrote about some aspects of this. But the main path of the research lay in the following:
A Line-of-sight behaviour is applied to the player.
The player’s facing angle is taken as orthogonal.
An LOS field-of-view is defined (eg 90 degrees) for the player.
At a given angle (facing_direction), left-side and right-side fov lines are drawn based on the defined fov (90 degrees). Note that these lines are virtually drawn orthogonally.
The left and right lines are then converted to isometric angles.
Because the left and right lines have been transformed, the difference between these two angles have changed. This new difference is the new LOS field-of-view.
The center between these two lines is the LOS center line. The player’s LOS is rotated towards the center.
With the new LOS field-of-view, and a new center, this corresponds to an isometric LOS based off a 90-degree LOS when viewed orthogonally.
The facing_direction mentioned above bears special mention. When a player clicks on tile in-game, he is actually picking with a view that he is viewing it in isometric view. Therefore, the facing_directionis an isometric angle, which must be converted to an orthogonal angle. It is only then that the left and right fov lines can be properly oriented, because they, in their turn, will be converted back to isometric after the computation is done.
What needs to be explored
ZSorting seems to take a lot of cpu time (~50%), and I’m wondering whether there is a way I can optimise this. So far, the best solution I’ve come up with is to use On GridMove as a condition for sorting. But I think the most ideal way is to localise the sorting around the areas where movement is taking place.
This post is going to try to be the most definitive guide to setting up the Board system from scratch for the purposes of movement. This means it will hit the following requirements.
Maps using Tiled/TMX via TMX Importer V2
Board setup based on TMX reading
SLG Movement for pathfinding
AJAX, for reading TMX files
Browser, for logging
Function2M (or any function plugin)
Keyboard, escaping movement
InstanceGroup, for storing path information from SLG
SLG Movement, for tile pathfinding
TMX Importer V2
GridMove, for the movers on the grid.
AJAX calls the tmx to be loaded, which gets it as a string.Then the trigger AJAX:On completed is called.
SquareTx’s position offset is set to (16,16); ie the position offset is the physical coordinates of LogicXY (0,0). In an orthographic setup (which this event sheet screencap is based), the value of 16 refers to the offset so that the center of the tile would be moved inside the layout, and the top-left corner of the upper-left most tile will be aligned squarely at the layout’s (0,0) coordinates.
In an isometric position the map height plays a part.
SLG is configured to use the Board as its Board, and an InstanceGroup (ig).
The mover’s GridMove behaviour is configured to use a particular InstanceGroup for its data.
Make sure that any object that is to be instantiated using the TMX process is destroyed. This makes sure that during the instantiation the proper object is being referenced.
When AJAX completes reading of the tmx, it will trigger its On completed event.
Use the TMX Importer V2 (tmx) to import AJAX.LastData using the XML parser. This populates the tmx object.
Then set the SquareTX’s cell width and height to correspond with the tmx.
Set the Board’s width and height (logical entries) to correspond to the size of the map in the tmx.
Then initiate the tile retrieval.
TMX Data Retrieval
I’ve not yet documented the timings of Objects vs Tile retrieval, so I’m not making any dependence on timings.
Tiles are retrieved first before Objects.
When creating areas for movement, I prefer to create a Tiled layer for movable areas and leave tiles blank where it’s not possible to move on, rather than tagging tiles impassable, so I don’t need to check this during the cost function.
On each tile cell
Use Board:Create tile to instantiate the tiles and place them on the board.
Board: Add chess could also be used, but this is more confusing because it only places a logical ‘marker’, and does not instantiate it.
Configure the frame of the tile (ie id of the tileset).
Note that some it’s not always the case that you can configure the tiles after they’ve been created, for example, re-positioning tiles after instantiation didn’t seem to be reliable or possible. But it seems that instance variables are ok.
TMX tile properties are set as necessary
On each object
Same sort of thing as On each tile cell.
Create using Board:Create chess.
Note: pay attention to the Board z-index as well as the C2 layer.
Configure non-Board related stuff as needed.
Note OXY2LXY, which is the ‘Orthogonal to Logical coordinate function’. To repeat a past post, the TMX Object’s position is recorded in orthogonal coordinates, and the Board or SquareTX object have no convenience features to translate those values to isometric. The OXY2LXY function is this translation:
This completes the TMX retrieval.
Initiating the move
The first thing to consider is the first call to move.
In this case it is a LMB on a tile.
Note ig:Clean group. This removes all previous path entries (in this case it is “path” referring to the waypoint nodes)
The main command is: slg: Get moving path start from <mover> to tile/chess <tile> with moving points to slg.INFINITY and cost to <cost_function> then put result to group <instance_group_name>
<mover> refers to the chess that is already on the Board.
<tile> refers to the tiles on Z=0 on the Board, which is the basis for moving.
Presumably (haven’t checked), if <tile> is at a specific Z index on the Board, then SLG will consider that Z index and pathfind on that level only. But what if the <tile> is at Z=2, for example?
<cost_function> is the cost function of SLG which determines the resulting path. We can also call this a path function.
<instance_group_name> is the group name inside the InstanceGroup object which stores the UIDs of the pathfinding nodes.
Then on the condition that the GridMove is not moving the mover, we pop the first waypoint, which is the first waypointand this is SOL’d as the tile object.
With this SOL, direct GridMove to move to that tile.
This the initial movement phase.
Cost function (moving path function)
Before dealing with the continuation of the move, we must define the cost function of SLG on the mouse click.
The cost function, also called moving path function, is called by SLG when a moving path is required.
The basic definition of a cost function to make set the the return cost to 1.
Returning a cost of SLG.BLOCKING will make this tile impassable
Use slg.TileUID as the reference to the tile being queried for pathfinding.
If the map was generated with blank areas, then there’s no need to check against those, as they won’t be even be considered for pathfinding.
Continuing the move
Once the move has been initiated, then continue to move as long as there are nodes in the InstanceGroup for paths.
The continuation of the move is on the GridMove:On reach target trigger, which is triggered when GridMove moves on top of each tile as stored in the InstanceGroup.
Use the conditionInstanceGroup:Pop one instance <tiles> from group <instance_group_name> in order to determine if it has popped the last one. If it has then GridMove is bypassed.
Movable area function
The movable area function may or may not be used in Citizen 2401, but this is a good time to document this function.
SLG has 2 ‘cost’ functions. One is the movable path, and the other is the movable area.
Just like movable path, movable area’s search pattern is to move out from a logical coordinate.
What the events above are trying to do is to generate a list of tiles which the AI can move to that are not LOS’d by the player. The LOS of the tile is determined by another function which switches the los instance variable accordingly, so that only this variable is checked.
The movable area cost function itself (‘p evade area’) does not check for the LOS state, and the reason is described here. Simply put, because of the movable area search ‘creep’ may get blocked by LOS’d tiles, only the tiles are tested on a distance basis (ie movement cost as defined in the SLG:Get moving area)
Then, a filter function is applied on top of the results of the movable area function in order to get rid of those tiles that is LOS’d.
Stopping, changing paths
To stop, simply clear the InstanceGroup path group. This will give GridMove no waypoints to go when GridMove:On reach target is triggered.
When trying to LMB on a tile while still moving, simply clean the Instance path group.
This has the effect of generating a new path while making sure the GridMove still goes to the last assigned waypoint.
The Board plugin has several important dependencies that must be put in the project depending on what functionality is desired. Some of the following notes are not necessarily in Rex’s docs, which sometimes can be sparse in detail, though his examples explain things very clearly. So, in effect, the following is a distillation of the things learned in the docs and as it relates to the examples.
Before anything, the Board must be setup, which, at its most primitive, just the Board object, which defines the logical positions of the tiles.
The Board is responsible for creating the virtual logical grid (ie tile coordinates with logical positions). It specifies its dimensions.
This is a plugin that allows sprites/tiles to be placed on the Board as they currently are positioned in the editor. This is a convenience feature that is more useful if I was editing my level in C2. However, Tiled is my editor and will place tiles procedurally through other means.
This was an improvement that I personally suggested to Rex which is an upgrade from the original squareTx plugin. (Currently, ProjectionTx is not available at his site). The purpose of these ‘Tx’ plugins is to display the Board’s logical positions as a particular projection. Rex has kept these two aspects separate, as it is easier to visualise a ‘top-down’ Board as the basis for computation, and a ‘projection’ as a basis for defining how that Board is going to be visually represented.
When a call to Board’s Action:Create tile is made it uses ProjectionTx to place it in screenspace. It also seems that all other calls that involve visual representation, Board uses ProjectionTx to translate it.
The image to the right is ProjectionTx’s property panel. VectorU represent the direction of the U (or X or left-right) of the tile (if you imagine looking top-down) and VectorV represent the other axis. In other words, ProjectionTx is asking what is the screenspace direction of the X and Y axis of the tile. In an isometric projection, 32×16 pixels sprite, UX=+32, UY=-16, VX=-32, VY=-16. Explanation: X axis of the tile right and down: right for 32 pixels (+32), and down 16 pixels (-16); the Y axis of the tiles goes left and down, hence -32 (left) and -16 (down).
The Edge plugin works in conjunction with Board and movement. Edge objects exist between tiles, and the Edge object itself is queried if a particular edge exists between two tiles. This might contrast with the instinctive notion that we query the Board if certain Edges exist in its logical positions. After all, it is the Board’s turf. However, the way it works is that Edge objects keep track of themselves and where they exist between tiles.
When Edges are created, a Sprite is used to visually represent the Edge. It is thus rotated perpendicularly to the VectorU or VectorV of any given tile depending on the side of the tile the Edge is being created on. In isometric tiles, this produces a wrong result, since the orientation has a isometric ‘skewing’, and thus the angles must be set manually.
Movement is more involved than Board setup. It involves at least two plugins working in conjunction with each other, and involves a third if pathfinding is needed. For my needs, I need all three.
SLGMovement is the plugin that is responsible for pathfinding. Its responsibility is querying the board for the best possible path, and taking that information and feeding it into the InstGroup plugin (explained later). Thus, SLGMovement has two dependencies at all times: Board, and InstGroup.
Implicitly, it will use the first Board and InstGroup object in the scene.
However, a specific Board and InstGroup can be specified through its Action:Setup call.
The plugin computes for ‘cost’, which is the concept of how many ‘points’ it takes to travel to a tile. In the same vein, it can also prevent movement from one tile to another (for example when tile is impassable).
Edge objects can be optionally queried in cases where movement between two tiles cannot occur at a particular direction between each other (though it can occur if the path goes around the Edge).
Through the example SLGMovement works this way: the path to follow is computed beforehand. This means that SLGMovement calls the On cost function repeatedly as it makes its way to query which tiles it can use to the desired destination. When by setting the cost as SLGMovement travels to the destination using its pathfinding algorithm, it can determine the best way. If SLGMovement.BLOCKING is encoutered, this means that this particular ‘leg’ of the path is not possible, and SLGMovement will try to find another way around.
Get moving path and InstGroup
The heart of SLGMovement is its Get moving path function, which calls the pathfinder. It has a few parameters:
As the captions explain, the ‘moving cost function’ is the heart of the pathfinding querying. It’s here where a valid path is traced based on the moving points and the results of the ‘moving cost function’.
The weird thing about this setup, however, is in the InstGroup, which is an implicit dependency. You need InstGroup to make SLGMovement work because there is no other way to store the resulting path.
Once the path has been stored, the next part is the actual movement, which is handled by GridMove.
Once SLGMovement has stored the path in InstGroup, GridMove is used to access the path. The principle is to start the chain of calls to GridMove by ‘popping’ the first element that’s stored in InstGroup. The first ‘pop’ and move occurs in the ‘Mouse on click’ or ‘Touch’ trigger itself. When the first node is ‘popped’ it is SOL’d, and thus a call to ‘GridMove move to tile‘ will yield the SOL’d tile as the target.
Then on Condition:On GridMove reach target, another instance is ‘popped’ out of the InstGroup. As the caption above describes, the ‘popping’ of the the instance is a GridMove condition. The condition will return a False if there are no more elements to ‘pop’, and in the image above, Function “GetMoveableTile” is run after the path has been reached.
Note that the ‘popping’ occurs at the head of the InstGroup array. This denotes that the sorting of the path nodes where the first element is always the next waypoint down the line.
Connection to Board
Normally, I would expect a explicit connection from GridMove to the Board, and hence, access to ProjectionTx in order to find the screen position of the logical coordinates. But as I look at it, the GridMove is attached as a behaviour of any given ‘chess’ (ie a movable element on the Board), and a ‘chess’ instance can only be in one Board anyway. So it seems that GridMove is able to trace back the Sprite’s association to the Board it was originally created in.
So, these are the following procedures that comprise isometric tile-based movement.
Board plugin as the basis for providing logical coordinates for positioning, and populating its own tiles with content through procedure.
(Optional) LayoutToBoard plugin as a convenience feature to populate Board with ‘chess’ data based on how the objects themselves are placed in the C2 editor in relation to the established Board.
Edge plugin as another element that is can be used in conjunction with SLGMovement to determine pathfinding. Though Edge is not technically related to Board, determining Edges is best done during the setup (though it can be changed any time).
ProjectionTx plugin as a visual transformation of the Board into a desired projection. In this case I’m concentrating on an isometric (or possibly trimetric) projection.
SLGMovement plugin as the pathfinding engine. It is connected to two other plugins: Board and InstGroup. By default, it will use the first object of each kind, so there’s no need to setup SLGMovement unless you need to. SLGMovement computes the cost of movement from one tile to another in the Board, and in this ‘cost function’ you can influence the pathfinding (eg blocking tiles). It then puts the resulting path into a named group in InstGroup.
InstGroup plugin as the container and actionable condition that works in conjunction with GridMove to ‘daisy-chain’ the stored waypoints. InstGroup allows ‘popping’ of instances in the array (ie group) which give the effect of moving to the next waypoint which is then fed into GridMove
GridMove behaviour as the function that moves the Sprite. GridMove is implicitly connected to the Board that the Sprite had been generated in using Board’s Action:Create tile method.
Knowledgebase for creating a Construct 2 isometric game.