Jump to content

Recommended Posts

How do I play Bearing?

 

Filter your server browser for 'Bearing'.

 

SZeWDiA.png


Currently, there is a single server with a limited number of slots.
The server is online most of the time, unless there are issues with the game or server.

 

 

5tlD9s4.png

 

Bearing - An Orienteering RPG

 

One billion people missing. A global communications blackout.


Join a scientific and engineering expedition team on Altis as they try to discover what happened in Europe, and why radio signals are being disrupted across the world.

 


About Bearing


Welcome to the info and feedback thread for Bearing, a server-only RPG mod that focuses on orienteering and navigation.

 

But first, here are a few things that Bearing is NOT about:

 

  • No Combat
  • No Death
  • No Stamina
  • No Survival

 

Bearing is a relaxed game, and won't be for everyone. If you find these features essential then this game is likely not for you.



Current State

 

The early portions of the game have been implemented, featuring a variety of gameplay mechanics and a small amount of supporting dialogue and story telling.
Most Guides on the island currently have no dialogue, and existing story tasks end after (approximately) a few hours of gameplay.
More features have been partially developed, and will be added to the game later.

 

Expect bugs and occasional server downtime.

 


Features

 

Bearing is essentially a single-player experience.
You may encounter other players from time-to-time, but there are currently no multiplayer features.

 

  •  Tutorial
    • A detailed tutorial story will take you through the basics of playing Bearing
  • Story
    • Follow story tasks to unlock new gameplay mechanics and learn more about what is happening on the island
  • Progression
    • Unlock new equipment, clothing and movement options as you play. Your progress is saved, so you can come back at any time
  • Observation
    • Use map, photo and audio clues to track down new Guides and Transport hubs
  • Cartography
    • Triangulate your position to create your own map with Bearing's Survey Mode
  • Fast Travel
    • Build a transportation network across the island and request a pick-up from any nearby road
  • Power
    • Repair damaged power sources and restore the island's power network [Partially Implemented]
  • Relaxed
    • There is no combat, survival or stamina to worry about, so you can take your time exploring the world

 


Mods/Addons Required

 

None.


You can play Bearing with the base game.
No DLCs are required.

 


Mods Supported

 

None.

 

Bearing's development began with a few self-imposed limitations, one of which was to require no mods before playing.
Another was to develop without supporting other mods, so that I could focus completely on Bearing.
This is unlikely to change in the future.

 


Feedback and Updates

 

This thread will be the best place to submit any general feedback you might have about the game.

 

Follow @BearingMod for content updates and other development pictures and videos.

 


Media

 

EpSdIZRVgAI8ElZ?format=jpg&name=largeEpXwHD9XcAEynaY?format=jpg&name=largeEpcTPJ8W4AMXOz8?format=jpg&name=largeEpw15e7XMAEGPtw?format=jpg&name=large

Ep1Z5ECWwAA-PM0?format=jpg&name=largeEp6t0wXXMAAqpdg?format=jpg&name=largeEp_7hCBW8AAsNhV?format=jpg&name=largeEqFWzU9W8AY4sF3?format=jpg&name=large

EqLLEOqXYAMA2ij?format=jpg&name=largeEqP2DicXEAE9Eco?format=jpg&name=largeEqU89qJXAA4MAFg?format=jpg&name=largeEqZ_0aRW4AEP8Hp?format=jpg&name=large

  • Like 14

Share this post


Link to post
Share on other sites

Map fog mesh generation

 

Probably the most important engine feature for Bearing was the addition of the drawTriangles command.
It was added right around the time I was attempting a fog-of-war overlay using textures, and having a very hard time making it perform well.

 

Swapping textures for a solid mesh meant that performance improved dramatically, enough to increase the resolution of the overlay, and paved the way for the Survey gameplay mechanic.

It also became the core of later map and animating effects.

 

Iqxrd0w.png      zLIwrQT.png

 

Below are some example steps involved in generating the mesh seen in-game.
This mesh is generated on the server DLL, with an array of vertices sent back to the client, and so cuts a few corners to keep the generation time down.

The Marching Squares technique is used to fill the 1x1 cells.

 

 

- 1 -   1VfLG91.png    - 2 -   TRuOn3e.png   - 3 -   D2L4uU4.png   - 4 -   CGD7pwZ.png

 

1. We start with a grid of cells, the corners of which can be visible (green) or non-visible (red)
2. Visibility is handled by adding Survey Mark circles and flagging all corners within them as visible
3. We recurse through the grid, splitting it in half with alternating horizontal/vertical divisions, to look for completely visible or non-visible rectangles
4. So far we have two rectangles that we can flag as non-visible, and skip any further processing on

 

- 5 -   dxxqRp5.png   - 6 -   TZ7KTaE.png   - 7 -   VZl6zeF.png   - 8 -   Gn9L4W5.png

 

5. If we run into a division that is a mix is visible of visible and non-visible corners, we subdivide it further
6. We treat fully visible rectangles the same way as fully non-visible, and skip subdividing it any further
7. More subdivision here
8. We've hit the last of the rectangles we can discard here, the rest will be 1x1 cells

 

- 9 -   2P6WnrZ.png   - 10 -   X5q91yq.png   - 11 -   uUuYWFG.png   - 12 -   kwCb8rS.png

 

9. Here we can discard two visible and one non-visible cell
10. Now we're left with 7 mixed-visibility cells, which we need to generate meshes for
11. There are only 4 different types of cell to worry about
12. Here are all 16 variations there can be, and the predefined mesh for each. The first and last variation will never be used, because we always skip those

 

- 13 -   qLEsTav.png   - 14 -   vQrR6ql.png   - 15 -   CZGf4Lt.png   - 16 -   jUrJQRF.png

 

13. Choose the variation we want. Each variation has a unique ID we can generate with some bitshifting/multiplication, where each corner is 1 or 0: uniqueID = BL + ( BR * 2 ) + ( TR * 4 ) + ( TL * 8 )
14. This is what we get when the meshes are added
15. Back to the previous subdivision view, we have a number of shapes to turn into triangles that drawTriangles can use
16. The final mesh result

 

pqNws7e.png

 

The island map has 1200 cells on each axis. It is pre-divided into 8x8 smaller groups to improve processing time.

The full mesh generation happens on a separate thread on the server DLL, and processing time could be further improved by moving each one of the groups shown into its own thread.

 


Corners cut:

 

Using an alternating horizontal/vertical division. Ideally the technique should evaluate the cells within and select the result which gives the most skippable rectangles. Alternating was picked because it gave generally better results than sticking to just one axis, and was faster than doing it properly.
The edges of the mesh could be smoothed further with some linear interpolation on the vertices, as mentioned in the Marching Squares article, but was skipped due to the performance impact.
A lot of alternative mesh subdivision techniques exist (which can be seen in a later Navmesh post) which would vastly reduce the number of triangles, but I kept this process because the results were good enough for the performance target it needed to hit.

 

These shortcuts could be removed if the processing was done on a client DLL, where processing time is more available.

 

 

Wishlist:

 

drawTriangles being able to take vertex colours (particularly alpha), and UV coords.
Being able to create textures/sprites at runtime from script, and being able to edit pixels individually or filled as a subrect.

  • Like 11

Share this post


Link to post
Share on other sites

Mindblowing!

Really very cool! Love the mechanics. What you've done with the map is amazing - I hope you consider releasing the survey script as a stand alone thing for people to implement in their own missions.

The GUI for eliminating the anomolies is intuitive and practical. As is the menu system and dialogue system. 

 

The visual effects are awesome. 

The writing is something else - borderline psychopathic (but in a good way) funny as hell and paints a picture of a strange civil service of people out of their depth, pretending to work while on a holiday island and its written in such a way that its almost like they know only slightly more of the story than you do and if they weren't all so idle they'd be in our shoes trying to figure out what is going on. And we can be as idle as we like (though incentivised to continue drawing the map and building up our run speed.

I had a couple of hang ups on the map survey screen - but it was simple to abort and rejoin with no progress lost. 

I've just met Ciara. Though it did take about 4 minutes to climb the ladder to speak to her so I'm not sure if I'll be able to achieve the timed mission! 

Looking forward to playing more

Share this post


Link to post
Share on other sites
On 1/19/2021 at 12:41 AM, seacaptainjim said:

Looking forward to playing more

 

Thanks for checking it out! You're two guides away from hitting the end of the existing story content, so I'm stoked you've played it this far!

 

I should make a bigger show of it, but when you climb to the top of a solar tower it unlocks a fast travel action on the ladders, so you can quickly get to the top again. (And the bottom, but jumping over the railing is always more fun)

 

Cheers for all the feedback, it's great to hear, helps me fine tune things and learn what people enjoy for future content/projects.

Was there anything specific about the survey screen that you had issues with? I've love to hear about it.

Share this post


Link to post
Share on other sites

Whoa.

It feels like you've created Myst... in Arma. I would never. Not ever. Consider that combination. Where did you get the idea? 

I mean the inspiration for the theme of isolation isn't hard to fathom, but... the gameplay?

  • Like 1

Share this post


Link to post
Share on other sites

 

On 1/23/2021 at 3:12 AM, im_an_engineer said:

Was there anything specific about the survey screen that you had issues with?


I was trapped in the map screen a couple of times, but i think it was because i was trying to zoom in or out while the script was uncovering the map.
Another time the crosshair for selecting the second land mark appeared but instead of looking at altis I was stuck in the map screen.

 

Thanks for including the teleporter! 

Keep up the good work!

 

Share this post


Link to post
Share on other sites

I haven't seen the server up the times that I've checked over the last couple of days. Is it down for an extended period, or have I just been a bit unlucky?

Share this post


Link to post
Share on other sites
On 1/24/2021 at 12:58 AM, Melody_Mike said:

Whoa.

It feels like you've created Myst... in Arma. I would never. Not ever. Consider that combination. Where did you get the idea? 

I mean the inspiration for the theme of isolation isn't hard to fathom, but... the gameplay?

 

A lot of it started with a game called Miasmata. I'll have another dev post up soon that talks a little about it.

 

On 1/26/2021 at 3:00 PM, seacaptainjim said:

 


I was trapped in the map screen a couple of times, but i think it was because i was trying to zoom in or out while the script was uncovering the map.
Another time the crosshair for selecting the second land mark appeared but instead of looking at altis I was stuck in the map screen.

 

Thanks for including the teleporter! 

Keep up the good work!

 

 

Thanks for the info! Hopefully I can get some decent repro steps together and get it fixed. You never know what kind of bugs will come up when new people try your stuff!

 

19 hours ago, beno_83au said:

I haven't seen the server up the times that I've checked over the last couple of days. Is it down for an extended period, or have I just been a bit unlucky?

 

Server is back up now, for weekends and hopefully weekday evenings from now on. I had to take it down during some busy work hours and forgot to put it back up :S

  • Like 2
  • Thanks 1

Share this post


Link to post
Share on other sites

Survey Mode

 

At its heart, Bearing is a collection of game mechanics that I enjoy and wish would be in more games. Some mechanics came from the real world, like Orienteering (which heavily influenced the Job system gameplay), and others were inspired by existing games. The Survey mechanic is one of the latter.

 

One of my favourite experiences in gaming came from playing DayZMod and realising that I was able to pinpoint where I was in Chernarus just by looking around.
This happened after countless hours of marker-less Veteran servers, scratching my head over the DayZDB map and trying to work out where the hell I was and how to get where I needed to go.
The Veteran servers forced me to learn the map in a way that the easier difficultly servers didn't, where I could just double-click the map and follow a floating icon in the 3D view.

 

I wanted to incorporate a similar map-learning experience in Bearing, somehow.

 

The Job system took care of moving the player around the island to uncommon places and reading the map to find markers, but it still felt like it wasn't enough.
Without the constant danger of other players, like in DayZ, it reduced the need for a player to continually validate their position.


I needed the player to read the map carefully and more frequently.

 

 

Miasmata

 

Back in 2012 I played a game called Miasmata, an early example of the hardcore survival genre that is common these days. You wash up on island, afflicted with a plague that will soon kill you, and must explore the island in search of a cure.
It remains one of the most unique games I've played, with many bold gameplay designs that are worth trying for yourself. It's certainly not a game that everyone will enjoy, and has its fair share of bugs, but it's definitely a fascinating one.

 

The one feature of Miasmata that was the most interesting was its Cartography mechanic.
Much like Bearing, you start with an almost empty map and are expected to fill it in by yourself. This is done through a series of manual triangulation steps, marking locations as 'known' and finding map sketches to copy (which also help guide you through the game).

 

   - 1 -   U6ibROP.jpg   - 2 -   0MdI97Q.jpg   - 3 -   So5VmTd.jpg   - 4 -   arCLXCd.jpg

 

1. Map with unfinished known locations - a structure could be at any point on a line
2. Map detail
3. Marking a location in 3D
4. Marked location shown on map

 

   - 5 -   WbWv25h.jpg   - 6 -   8juYlFB.jpg   - 7 -   mKebvaB.jpg


5. Map before finding sketch
6. Sketch found in book
7. Map after finding sketch

 

(Source video)

 

 

The design of this system can be condensed into a few points:

  • If known structures are nearby, finding out where you are on the map is free. You only have to mark two known structures and your position is shown.
  • Only after 'knowing' your position like this can you then start marking unknown structures. Each unknown structure must be 'seen' from two different positions in order to become known and usable.
  • The map around you is filled automatically after marking two known structures.
  • Studying the map by yourself to determine your position is the backup option. This is the only choice when no decent structures are nearby.
  • Getting lost with no structures nearby can be disastrous, especially at night and away from the coast. This is due to the general obstruction of vision, few inland landmarks, and samey environments.
  • Detailing the map and making structures 'known' are two distinct processes.

 

There are plenty of reasons why this system isn't a great fit for Arma 3, which I'll mention below, but the core appeal is that it encourages a player to regularly check their map and be aware of their position.
I also thought filling out your own map was extremely cool, which accounted for 2/3 of the appeal.
With a decent enough design, having the player fill out their map should naturally mean they're checking it much more often.

 

This was exactly what Bearing needed.

 

 

Bearing

 

The process of designing and iterating on the map survey system in Bearing happened over the course of several years.
Below are the steps involved in the current iteration:

 

   - 1 -   l3efvJ0.jpg   - 2 -   nJocPvL.jpg   - 3 -   jjM5iGB.jpg   - 4 -   Q1gyX8u.jpg

 

1. Starting position - finding a spot in the world, preferably near the map fog boundary, where there is a clear view of two decent structures (both indicated).
2. On the map - the rough starting position (green) and the two indicated structures (red).
3. Choice #1 - choosing the first structure. Each structure has a name and a unique icon.
4. Mark #1 - find the structure on the map and mark it. Being precise is important.

 

   - 5 -   RHdQG1Y.jpg   - 6 -   idrLdnU.jpg   - 7 -   PDDNYpr.jpg   - 8 -   wLIAZ2q.jpg


5. Choice #2 - now the next one.
6. Mark #2 - choosing isolated structures is easier. The shape of this structure makes it easier to pick out.
7. Results - accuracy results and the area revealed is shown. Player position also indicated where the lines cross.
8. Before/after - the revealed portion is shown, because it's satisfying to see exactly how much progress has been made.

 

 

There are a few key differences from the Miasmata system which better suit Arma 3 and how it presents a map like Altis.

 

 

1. Known structures

 

An Arma 3 map already shows all houses and other buildings, and there are far too many to add each one using an 'unknown structure' triangulation system like Miasmata's.
More importantly, all structures are baked into the map, so there is no technical way to hide them.
This meant that structures needed to become known as soon as they were revealed on the map.

 

That single change vastly simplified the design, and condensed Miasmata's two-step process into one. The map is filled and reveals more structures to use, which makes it much simpler to leap-frog across the island.

 

It also meant that the player is free to expand the map in any direction they want, so long as there are suitable structures.
Miasmata's designers had the obligation to add structures in a way that pushed the player in certain directions around the island, something which also entails a lot of upfront story design.
With Altis, I was stuck with where structures already were, although I could add a structure here or there, but because the island is so well populated with structures, I could separate map reveals from any story flow and let players plot their own way to a destination.

 

Removing the necessity for marking structures as known also made the map surveying process feel easier and less satisfying - however, it was offset by the following change.

 

 

2. Marking on the map

 

In Miasmata, the player only needs to mark structures in the 3D view. The game will then draw lines on the map which intersect the player position and the marked structure.
Given that the player 'knows' about these structures by a separate mechanic, it would make sense to automatically know where they are on the map from any direction.

 

In Bearing's case, the player has had this knowledge gifted to them simply by the structures being revealed.
Automatically knowing structure positions just by looking at them then makes it trivial to keep adding map detail. The player never had to validate their knowledge.

 

In order to prove the player's ability to read the map, and to help reinforce the mental relationship between the map and the 3D world, Bearing asks players to also mark the chosen structure on the map.
The player position, while not explicitly marked, can be inferred as known since the player will have had to figure out the other structures' map positions from their own.

 

 

3. Accuracy and height bonuses

 

Marking structures on the 2D map to match those in the 3D view needs to be validated in some way.
For Bearing, in the beginning, this was a pass-fail check. If the mark position was within a hidden distance from the centre of the structure bounding box then they would pass the check.
The problem that arose was that it encouraged a certain amount of sloppiness when marking. Players didn't feel the need to be as accurate as possible, but at the same time would become annoyed when failing a mark because it was slightly over some unknown distance away.

 

After some iteration, this was changed to be an accuracy ratio (0.0x - 1.0x), and is the distance between the outer radius of the structure bounding box to a larger accuracy falloff distance. This ratio is then applied to the standard map reveal radius of 500m.
This change, while making it more punishing to be sloppy, actually encouraged better accuracy from the player, and reduced retry frustration.
Getting 50% accuracy and trying again for an improved result would always feel better than the previous iteration of failing a mark and having to retry.
In this iteration, if you fail a mark then you're likely nowhere near the correct position, when previously you could be very close to it.

 

A later addition to the accuracy check was the height bonus, which is based off how high the player is above the marked structures.
This was added as a thematic improvement, after noticing that revealing a large part of the map while deep in the middle of a city didn't make much sense.
Surveying from an elevated position should result in a better view, although this continued to ignore any sort of occluded vision.

 

The height bonus also encouraged closer map reading. Players would check the map, pick an area they wanted to reveal, and then find the highest point of elevation near to it.
While encouraging exactly what the feature was intended to, it also meant that players would tend to pick the same places to survey from.
This makes for a more homogenised experience, and I'm still uncertain about whether that's a good or a bad thing for Bearing.

 

 

4. Structure names and icons

 

qEezvzU.jpg

 

These were an early addition to the feature, after the initial playtests showed that it was difficult to tell if a smaller structure was highlighted or the larger structure behind it (like an antenna in front of a house).
The combination of name and icon also helps to remove ambiguity, for example, for what kind of 'house' is currently highlighted.
There are 77 unique 16x16 icons, one for each significant structure on the map, saved at 64x64 to maintain their pixel appearance when upscaled in the game.

 

 

5. Too close/far & Visibility

 

   - 1 -   v9brA2k.jpg   - 2 -   JRNyLdm.jpg   - 3 -   6iN4gX0.jpg

 

Very early in development, it was possible to stand outside the visible area of the map and survey from there, so long as the structures used were still visible on the map. (Picture 1.)
It doesn't take much thought to see how it would be realistic to allow that, but it meant that players were able to traverse well over 1km without really having to think about the map fog.
They could reveal large chunks of the map at a much quicker pace than felt right, and was something that also favoured people with existing knowledge of Altis and its terrain.

 

At one point, I had intended to require the player to also mark their own position during a Survey, as a step #3, and the visibility restriction on players was added to support that - you shouldn't be able to mark somewhere you can't see on the map.
I quickly abandoned the step #3 idea, as the Survey process was lengthy enough as it was.
However, the restriction on the player visibility changed the flow of revealing the map in a way that I enjoyed much more, felt more natural and was in line with encouraging map reading.
So I kept it that way. (Picture 2.)

 

There was always going to be a distance limit on structures that could be marked, mostly from a performance stand-point. (Picture 3.)
The interesting part was trying to figure out what that limit should be when trying to survey in areas with very few structures.
Altis has a lot of empty spaces, and sometimes the marking distance limit means that you have to find a less optimal position to start from.
From a gameplay perspective this is great and adds some welcome tactical variety to the process.

 

Similar to the height bonus, preventing players from marking structures too close to each other was another thematic change.
If requiring three positions is necessary for triangulation then having two very near to each other collapsed two of the triangulation points into one.
Stopping players from picking two structures right next to each other, and so only have to properly consider the map position of one of them, also enforce that two-step process.
Players would also, in combination with the marking distance limit, have to balance their starting position against the structures in range.

 

The image below shows a few less desirable marking outcomes, although only the top-right one is actually prevented from happening.

 

6IRyLKZ.jpg

 

A more logical change would be to require structures that are not parallel to each other from the player's perspective. Having them be perpendicular seems like a more natural solution.
I'd attach a ratio multiplier to it, so you would gain reveal radius for more perpendicular markings, similar to the height bonus.
This was never implemented, as it was likely to make the more structure-barren areas of Altis significantly more difficult to survey.

 

A downside to the current behaviour is that the marking intersection lines can still be parallel if one structure is more distant than the other.
This is something that Miasmata also allows (or suffers from, depending on how you look at it).

This makes a good case to implement the perpendicular change mentioned above, but for now I'll make do with the too-close approach.

 

Another option that was considered was to prevent the marking of structures that are too close to the player, as that also collapses two triangulation points into one.
This became a problem during the Survey tutorial where there was a very limited set of structures to choose from, most of which were fairly close to the player.
Adding a new structure to the base camp just for the tutorial would solve this problem, but right now I'm more likely to just not add the limitation.

 

 

6. Wander distance

 

Sometimes players would start the survey process without planning it through - they'd see a decent structure, switch to the Survey mode and mark it on the map, and then realise they can't directly see another structure.
Initially, the entire process would abort if the player moved, which felt very frustrating when all that they needed to do was shift a step or two to the side.

 

D4I0H0g.jpg

 

The solution to this was to allow the player to move a short distance away from where they marked their first structure, resetting the process once the moved past that boundary (currently 10m).
Indicating this boundary is awkward, given that (at the time) it was not possible to show simple shapes in scriptable dimensions.
I'm not happy with how it looks, or the significant performance impact of using it, but drawLine3D was ultimately used to show this boundary. The same effect was once used to indicate Job area boundaries, but was later removed.

 

The boundary feature does encourage a little sloppiness, making it less likely that players will pick both structures before starting to mark them, but the benefit of being able to move slightly during the process more than makes up for it.

 

 

Final notes

 

The process of describing the Survey mode, and how to use it, has been one of the most difficult things to figure out, and I'm still not happy with it.


The game will step through the process with chat dialogue and hints, and also through a densely-packed help screen, and have notes for another method to use in the currently-disabled Handbook.

 

It's not a difficult system, once you've seen it in action, but that's the kind of thing that's best done through a short tutorial video or looping animation.
Neither of these things are easy to pull off in Arma 3 without either leaving the game to show a YouTube video, or an additional download cost in the mod itself.
And once this tutorialisation has been used for one system, the expectation from players will be to see it for all sorts of other systems in the game.

 

Since the mod is still in development, and system changes are likely, it's not worth the time to create either option when they'd only have to be remade at a later date.

 

 

Extra: The system is called 'triangulation' internally. The player-facing term was renamed to Survey because the internal name was too long to fit on the main menu. So was Cartography, my first choice.

  • Like 10

Share this post


Link to post
Share on other sites

Stumbled upon this thread. I definitely need to look into this. I’ve dabbled with Orienteering type of missions and systems since Arma2 although mostly been out of the Arma space for a long time. 

 

I don’t think I ever finished a mission I was creating for Arma3. Involved a scenario where you were shot down in a helo and you only knew your general location and had to determine it via landmarks and triangulation. You also knew a general pickup location along the coast somewhere that you had to escape to. 
 

In the meantime the enemy was converging on your downed location in attempt to capture / kill you. It was a non-combat mission for the most part as you only had a pistol and if you engaged you probably wouldn’t make it. So it was best to stay out of sight and figure out how to navigate and get to the coast. 
 

I was able to write a script to prevent having the compass on screen while you were moving as I considered that not to be realistic. 
 

Of course the mission was totally random each play through. 
 

This just rekindled my interest in this type of thing once again. 

Share this post


Link to post
Share on other sites

Forgot to mention another thing about my script. It nudged the player every so often so that your course would never be exactly the direction you started moving in. In the real world you cannot just select a bearing (eg 23 degrees) and press W on your keyboard and continue in that direction perfectly. This combined with not allowing the compass on the screen encouraged the player to check their direction and use real-world type orienteering concepts to head follow their bearings.

Share this post


Link to post
Share on other sites

Checked it out. Very cool so far. I'm guessing / hoping this saves my progress somehow.

Share this post


Link to post
Share on other sites

Bugs:

Spoiler

 

- One of the conversation logs is wrong I think. One of the ones with Andy, after I finish the markers and ask about "down the road", has the line:

Quote

"After that,@TE" he continues.........

 

- While I was trying to survey, after generating a new map area the map didn't close. I could press "m" and that would apparently return control back to my character, but the survey screen was still visible, so I couldn't do anything. The text boxes also indicated that it was still generating and hadn't given me an accuracy yet. The next time I did it it worked fine though.

 

- Another conversation log. Talking to Tolya after getting him a chair, and a line seems to be missing the word his.

Quote

He drums arrhythmically on his thighs and smiles.

 

 

 

 

Suggestions:

- setDir the guides to be facing you when you talk to them, then maybe turn them back afterwards.

Spoiler

Maybe they could stand up and face you after getting them a chair.

 

 

 

Not very far into it yet, but all your systems seem well made. But the whole thing feels pretty well made. 👍 Wish I didn't have to go to work!

 

@delta99 It saves progress.

  • Like 1

Share this post


Link to post
Share on other sites

 

On 2/1/2021 at 12:46 AM, beno_83au said:

Bugs:

- typos

-map stuck

 

Cheers for the help, typos are now fixed!

I haven't managed to repro the survey map bug yet, I'm starting to think it's some network delay issue I don't see easily because I'm testing locally.

 

On 2/1/2021 at 12:46 AM, beno_83au said:

Suggestions:

- setDir

 

I tried this on the standing guides early on, but it applies across the network and felt a bit weird to have them swinging around when someone else showed up to talk.

I'm sure the same can be said for the seated guides (not to mention all the animation chaining I'd have to figure out to make it happen).

Share this post


Link to post
Share on other sites

Markers and addActions

 

A short(ish) post this time around, looking at an addAction workaround that Bearing uses.

 

 

Jobs and Runs

 

Back when Bearing development began, before it had a name, it was an Orienteering time trial mod.
What used to be timed runs eventually evolved into the Job system, but under the hood it's still mostly the same.

 

9qJieS3.jpg

 

A typical run followed this process:

  • The player talks to an NPC and starts a new run
  • A fixed number of 'controls' are created from a pool of possible positions
  • The player then has to visit each control, tagging it with the correct ID
  • The player returns to the NPC and is scored

 

I'll go into more detail about the above process in a later post.

For now, I'm going to focus on the thing that players need to interact with; the control.

 

 

Controls, then

 

If you've ever tried Orienteering, you'll have seen what a control point looks like.
It's the big orange and white flag you need to find, and usually comes with a stapler-like thing that works like a card punch. You use the stapler on a little card you carry around, punching a hole in a correct slot.

 

(Source: Google Image Search)

1QL9FIe.jpg     C7QI5uD.jpg     jv5e0ex.jpg

 

Arma 3's list of objects doesn't have anything like that, so during development I needed to pick something else that would temporarily do the job.

 

The object needed to be small enough to fit close to bushes and trees and neatly into corners without it poking through walls.
It also had to be large enough so that it wasn't too difficult to interact with.
The player was, in the early versions, always being timed, so it was very important to minimise any interaction struggles.

 

After much experimenting, I eventually settled on the 'camping light' (Land_Camping_Light_off_F).
It wasn't perfect, maybe a little on the small side as far as interactions went, but it was good enough to test with.
The plan was to change it to custom model later, anyway.

 

Each object was given an addAction which would communicate with the server when it was interacted with, showing the control card for players to punch a slot.

 

dA1fQqn.jpg     9DxDoZL.jpg     J4yf56F.jpg

 

Eventually, Bearing became something different and the Job system took shape.
Controls became Markers, and after several years the time came to change the camping light to something else.

 

Extra: They have always been called Markers internally. Controls are already a UI term and I didn't want to confuse myself by having them share a name.

Each area was also supposed to have a sketch-like map as part of the control card. I quickly abandoned this after seeing how long it took to make the first one.

 

 

Markers, now

 

By the time the Job system came along, I had settled on avoiding additional downloads in order to the play Bearing.

That meant no custom model was available for markers.

 

I iterated through several fancy effects to settle on what exists now, many of them relying on drawLine3D, but they all had the same problem - they couldn't be interacted with.

 

Click the images below for video examples

tykqfVN.jpg     zOWjxwW.jpg     8kxhnHD.jpg     gB6xYUh.jpg     LCEqYgE.jpg


All the effects are parented to an empty model, which has no bounding box and meant addActions would never appear.
Even if I kept using the camping light model, the effect would never be big enough to cover it up.

 

An early test attempted to use a separate object for the addAction, but hidden so that the player would only see the effects. Unfortunately, being hidden also meant the addAction wouldn't work for that object.

 

This was going to be a huge problem. Interacting with markers is a large part of what players do in Bearing, and it didn't seem like there was an obvious solution to this issue.

 

We can skip all the head-scratching here and jump straight to the solution:

  • It is possible to move an object into position, perform a successful intersection test (lineIntersects, etc), and then move it away again, all without it appearing on-screen.

 

In order for this to work, the addAction had to exist on the player because there was still no valid object at each marker position to interact with.
With that in mind, the addAction condition could then perform all of the testing for each marker position, and enable itself when one was found.

 

The downside to this approach was that the intersection testing happened on every frame, for as many positions as there were to test.
A quick improvement was to skip intersection tests for positions too far away from the player, mirroring the addActions built-in radius check, but this could still be better.

 

As shown in the example below, a simplified version of the one used in Bearing, I moved the intersection testing into its own loop, which runs at 30FPS.
This allowed me to throttle the testing to a less demanding interval, and keep the addAction condition script as simple as possible.

 

For the intersection loop, I reused a single object and moved it into each marker position before calling lineIntersectSurfaces each time.
The object could be any existing object class type, and would never be seen - so I could pick the one with the best bounding box and not worry about how it looked.
The object I ended up going with was Land_Sack_F, which lives at [0, 0, 0] whenever it's not being tested with.

 

I could have created an object for each marker position, and had only one call to lineIntersectSurfaces to check them all, but the distance check meant that most of the time an intersection test wouldn't be needed anyway.
It was also much easier to keep track of which marker position was valid when testing them one at a time.

 

Below is a simplified version of the script that runs in Bearing:

//Create a control variable to track which marker is highlighted
//Default to -1, which means no marker is highlighted
player setVariable ["TESTVAR_INTERSECTED_MARKER_ID", -1];

//Create an addAction on the player that listens to the above variable
private _interactActionID = player addAction [
	"Interact with the marker",
	{
		//Has a marker been highlighted?
		//NOTE: This is only a safety check because sometimes the conditional check can pass, but then fail at this point if the camera was moving quickly
		if (player getVariable "TESTVAR_INTERSECTED_MARKER_ID" != -1) then
		{
			//Yes
			
			//NOTE: Make sure to reset the variable!
			//This prevents the marker being interacted with multiple times in a single update
			player setVariable ["TESTVAR_INTERSECTED_MARKER_ID", -1];			
			
			//Now do whatever action is needed
			...
		};
	},
	nil, 2.0, true, true, "",
	//The conditional variable check happens here, and enables the addAction
	"(player getVariable ""TESTVAR_INTERSECTED_MARKER_ID"" > -1)",
	-1, false, "", ""
];

//Keep a list of all the positions to test
private _markerPositions = [
	[...],
	[...]
];

//Create the object to intersection-test with
private _markerObj = "Land_Sack_F" createVehicleLocal [0, 0, 0];

//Run a loop to keep testing positions
//NOTE: This would be controlled by another variable
while {true} do
{
	//Start by resetting the variable
	player setVariable ["TESTVAR_INTERSECTED_MARKER_ID", -1];
		
	//We're using a line intersection, so get a line from the centre of the camera to a fixed distance away
	//NOTE: The distance is roughly equivalent to the addAction radius parameter
	private _fromPos = AGLToASL (positionCameraToWorld [0, 0, 0]);
	private _toPos = AGLToASL (positionCameraToWorld [0, 0, TESTDEFINE_INTERSECT_DISTANCE]);

	//Loop the positions
	{
		private _markerPos = _x;
		private _markerID = _forEachIndex;
		private _results = [];
		
		//Skip markers that are too far away to do the expensive test with
		if (_markerPos distance _fromPos < TESTDEFINE_INTERSECT_DISTANCE * 2) then
		{
			//Move the marker into position
			_markerObj setPosASL _markerPos;
			
			//Get all objects we are looking at
			_results = lineIntersectsSurfaces [
				_fromPos, _toPos,
				player, objNull, true, -1, "FIRE", "NONE"
			];
		};

		//The object must be the first result, otherwise a wall or something is blocking it
		if (count _results > 0 && {((_results # 0) # 2) == _markerObj}) exitWith
		{
			//Found it!
			player setVariable ["TESTVAR_INTERSECTED_MARKER_ID", _markerID];
		};
	}
	forEach _markerPositions;

	//Move the marker obj out of the way again
	_markerObj setPosASL [0, 0, 0];
	
	//Sleep for a while between checks (30FPS)
	uiSleep (1.0 / 30.0);
};

//When the loop above has broken the job is over and we should clean things up

//Remove the addAction
player removeAction _interactActionID;
//Clear the variables
player setVariable ["TESTVAR_INTERSECTED_MARKER_ID", nil];
//Get rid of the marker object
deleteVehicle _markerObj;

 

The script must be run from the server, which starts the Jobs anyway so that's fine, in order for createVehicleLocal/deleteVehicle to work.

 

I use this intersection-loop/addAction combo in a few other places, where an object bounding box is either too small or too irregular.

For example, this handset object which has a bounding box that is extremely awkward to interact with, especially when on the table, so I also use Land_Sack_F here.

 

ufLVld1.jpg

  • Like 3

Share this post


Link to post
Share on other sites

Fast Travel

 

Bearing is about walking.
What better way to ensure that a player will look around, take in the scenery and become familiar with it.

 

At the same time, Bearing requires that a player visit almost all of Altis in a way that involves significant backtracking.
Story tasks try to guide players through new areas, but eventually will require that they return to old locations. Sometimes they will even need to visit opposite corners of the island in a single task.


Added to that, the Job system has a variety of jobs that unlock later in the game, so backtracking to complete those becomes necessary. Players are also encouraged to complete Jobs at their own leisure, leaving them to another time if they feel like it.

 

The natural answer to backtracking is to add a fast travel mechanic.

 

 

 

 

TLDR

 

The short description of Bearing fast travel system is:

 

An interconnected network of trucks allows the player to travel across the island.
Travel is automatic and in realtime. It is faster than running, and follows the quickest road path, but is not always direct.
Trucks are located by following the sound of their horns, which are triggered remotely.
Players can request a 'pick-up' which will transport them to the closest available truck. Each truck has a 'catchment area' covering the roads they service for pick-up.

 

Most of the above was fully designed in the first pass, with the pick-up feature being added later.


Below I'll be going into some technical detail about the various elements involved.

I'll keep to the specifics of what was done, otherwise this post would be much longer than it already is.

 

 

 

 

Goals

 

Fast travel mechanics come in many varieties, with plenty of benefits, but also the potential to undermine the goals of Bearing.

 

Here is a short list of requirements that I was aiming for

  • No physical vehicles
    • This is to avoid all the hassle of dealing with vehicles entirely.
    • Along with weapons, this is something that could cause a lot of carnage and cleanup that I was eager to avoid.
  • Not immediate
    • Warping immediately to a location would undermine the here-to-there knowledge that I was looking to strengthen.
    • Something is needed to bring the player to the location, and allow them to take in the environment as they traveled.
    • Some examples of this:
      • Adhoc travel - GTA taxi rides
      • Hub-based travel - WoW bat travel & FF14 chocobo travel
  • No travelling to unexplored locations
    • Requires the player to walk to the fast travel location before it becomes available.
    • A standard way of gating progress in mechanics like this. Fast travel is often limited to discovered locations only.
  • No manual destinations
    • Having fixed destinations for fast travel helps to encourage familiarity with the surrounding territory.
    • It can also provide a hub for other interactions, although this is something Bearing tries to avoid
      • Fast travel locations and Guides are purposely separate, where possible, so they don't share the same location knowledge point.
  • Discovery mechanic
    • Much like with Guides, unlocking fast travel destinations should require some sort of puzzle to keep things interesting.

 

 

Trucks

 

A vehicle was needed for the fast travel mechanic, even if the player was never going to drive it themselves.
The Zamak Transport (Covered) vehicle was chosen because they're very quick to spot, especially with the blue tarp covering.
Smaller civilian vehicles might have made more sense for the story (re-using abandoned vehicles on the island), but would be more awkward to find.

 


CreateVehicle bad

 

To begin with, trucks were spawned at server start-up with createVehicle and made invincible.
There were plenty of problems with this:

  • No mods means no custom vehicle configs, so I was stuck with an inventory at the rear of the truck.
    • The best I could do was block the inventory from opening, which would look like a bug.
    • The tutorial truck was always approached from behind so a player would often hit that issue first.
  • The 'Repair Transport' addAction was only visible at the front of the vehicle, which wasn't always obvious and awkwardly small.
  • If something wonky happened that I wasn't aware of and didn't plan for, then restoring the vehicle to its original position and health would require some extra scripting.
    • Creating a similar restoration solution for Guides also made me really not want to bother with it for trucks.
    • I left doing this work until later, by which time I found a better solution.

 

One upside was that the vehicles would automatically align themselves with the road, along with their wheel vertical positions. This would come in handy below.

 


CreateSimpleObject good

 

After messing around with addActions for markers (see the previous dev post), I realised that I could store the action for Repair/Use Transport on the player, and instead have it perform line intersections with the truck. This immediately made interacting with the truck much easier from any direction.
At the same time, I found I could replace the truck vehicle with its source model, created as a simple object, and remove a lot of the hassle related to vehicles.

That also meant no more inventory action to worry about.

 

The source model comes with a lot of additional detail that needed to be hidden, and unfortunately meant I had to manually align the truck with the road, as well as the wheels.
Since I already had the trucks placed in an SQM, I only had to export their positions and vectors in order to align the truck correctly with the road.

 

Creating the truck was done as below:

//Create truck object
private _truck = createSimpleObject ["A3\soft_f_beta\Truck_02\Truck_02_covered_F.p3d", _positionASL];
//Hide unwanted details
{
	_truck hideSelection [_x, true];
}
forEach [
	"clan", "zasleh", "light_l", "light_r", "zadni svetlo", "brzdove svetlo", "podsvit pristroju", "poskozeni"
];
//Vertically align the wheel suspension
{
	_truck animate [_x, 0.7, true]; 
}
forEach [
	"wheel_1_1_damper", "wheel_1_2_damper", "wheel_1_3_damper",
	"wheel_2_1_damper", "wheel_2_2_damper", "wheel_2_3_damper"
];
//Reset position
_truck setPosASL _positionASL;
//Align to the road
//NOTE: The direction vector needs reversed due to how the model is set up
_truck setVectorDirAndUp [[0,0,0] vectorDiff _vectorDir, _vectorUp];


The wheel vertical was roughly set and not really correctly aligned to the road, but is good enough in most cases.

 

A simplified version of the truck addAction handling was done like this:

//Init a single object we test against. If the object is not null, then we're point at a valid truck
player setVariable ["TESTVAR_TRUCKOBJ_HIGHLIGHTED", objNull];

//Keep a list of truck objects, provided by the server, to check against
private _knownTrucks = [ ... ];

//Create the action on the player that listens for the truck object being highlighted
private _truckAddActionID = [
	player,
	"Repair Transport",
	REPAIR_PAA,
	REPAIR_PAA,
	//Block the action until a truck object is highlighted
	"!(isNull (player getVariable ""TESTVAR_TRUCKOBJ_HIGHLIGHTED""))",
	"!(isNull (player getVariable ""TESTVAR_TRUCKOBJ_HIGHLIGHTED""))",
	{},
	{},
	{ ... Interact script ... },
	{},
	["TESTVAR_TRUCKOBJ_HIGHLIGHTED"],
	TESTVAR_FASTTRAVEL_ACTION_INTERACT_DURATION,
	0,
	false,
	false
] call BIS_fnc_holdActionAdd;

//Run a loop that continually checks for a truck. It runs at an interval so avoid spamming intersection tests
while {TESTVAR_LOOP_ACTIVE} do
{
	//Clear highlighted trucks by default
	player setVariable ["TESTVAR_TRUCKOBJ_HIGHLIGHTED", objNull];
	//Get all objects we are looking at
	private _fromPos = AGLToASL (positionCameraToWorld [0, 0, 0]);
	private _toPos = AGLToASL (positionCameraToWorld [0, 0, TESTVAR_FASTTRAVEL_ACTION_DISTANCE]);
	private _results = lineIntersectsSurfaces [
		_fromPos, _toPos,
		player, objNull,
		true, -1,
		"FIRE", "NONE"
	];
	//The object must be the first result
	if (count _results > 0) then
	{
		private _obj = (_results # 0) # 2;
		if (!(isNull _obj)) then
		{
			//Check if the object is in the known trucks array
			if (_obj in _knownTrucks) then {
				//Yes, assign it to the variable
				player setVariable ["TESTVAR_TRUCKOBJ_HIGHLIGHTED", _obj];
			};
		};
	};
	//Sleep for a little bit before checking again
	uiSleep (1.0 / 30.0);
};

//Remove the action when the loop ends
player removeAction _truckAddActionID;


It's very similar to how the Marker actions are handled for Jobs.

 

 

 

 

Data - Roads and Catchment Areas


Road Data

 

Early on in the planning for fast travel I had to decide how the player would move across the island. Using vehicles and following roads made the most sense, but I needed road data to be able to do that.
Luckily, Arma 3 comes with useful commands like roadsConnectedTo which allowed me to generate a database for pathfinding from any road to another.
Unfortunately, apparently by design, the roads in Altis aren't correctly connected. It wasn't until around June 2020 that an alternate syntax for roadsConnectedTo was added to resolve this.

 

Sadly, this new syntax wasn't available while I was building the fast travel system in June 2018. Nor did the new syntax give great results - instead it lazily connected roads, leaving some intersections with awkward turning connections.

 

 

dAO8B4K.png     0NDYXNi.png


Using a temporary SQM to plot every single road, and drawing the connections between them on the map, I checked every road intersection and manually added ones which were missing.
This took a few days, but was made quicker by highlighting the ends of road segments and checking only those.
I added a few extra connections to make some turnings a little less awkward, but by no means all of them. The client-side movement has some interpolation which smooths out the sharpness of corners so it wasn't worth fixing every example of them.

 

 

NTBuLaV.png     DPwinG8.png


Eventually the single SQM was broken up into separate missions for each type of road (MainRoad, Dirt, etc) and processed when the game data is built. This way the road connections are immediately available as a block of data when the server starts.
While Bearing knows about the different road types, it doesn't use that data as a heuristic weighting - and so prefer using better roads over dirt tracks, etc. The pathfinding is built to support it, but is disabled by default.

 

 

d3Gdb2P.png
The new road connection data is supplied as a text file during building. After parsing the SQM files, the build system then loads this file and applies the new connections before saving out the final road data.

 

 

   - 1 -   YO9TGNK.png   - 2 -   ni7KWFS.png   - 3 -   FGftvYC.png


The above examples show the roadsConnectedTo data for:

  1. original syntax limited connections
  2. 2020 expanded syntax connections
  3. original & 2018 manual connections

 

In the third example, you can see within the circle that I've added additional connections over the expanded syntax. There are plenty of examples in the image where I haven't bothered, especially at the top-left of the image.

 

Catchment Data

 

JLTOXUO.png     pmTCbI1.png


Catchment areas indicate the regions within which a player can request a pick-up. They are then transported from that pick-up road position to the truck it belongs to.
These areas are defined in an SQM file dedicated to constructing catchment areas, shown above.

 

Each catchment area contains a list of all the road segment IDs within it.

This list is exported by a custom script that is enabled on dev builds of the game, which is then manually copied to a text file, also shown above.

This could be implemented better, but the catchment areas change so rarely that there's not much point.

 

A pick-up is handled by first finding the closest road segment to the player, using the nearRoads script command, and then finding which catchment area that road segment belongs to using the DLL.
If the road doesn't belong to one, or the related truck is currently unknown, then the player cannot request a pick-up.

 

The catchment areas are also presented on the client, where they are drawn as polygons on the map.

 

 

 

 

Pathfinding


With the right data set, pathfinding along the island's road network can be quick and easy.
Luckily for this fast travel system, with its limited set of nodes and inter-connections (neighbouring truck-to-truck travel only), the process of travelling from one truck location to another is straight-forward.

 

When the server starts, a direct path between every neighbouring truck connection is generated.

To find the shortest distance between two trucks, the DLL is provided with the closest road segment to each truck, and then runs a pathfinding heuristic over all road segments in the world.

 

   - 1 -   hhC5FBm.png   - 2 -   ImvQN4Q.png


The resulting direct path is then stored to be used later, along with the total length of that path. This allows the truck connections (Image 1 above) to be abstracted to relatively small number of simple PosA->PosB paths and lengths (Image 2 above).
When travelling from truck-to-truck, the DLL uses the simple paths to calculate a series of stops along the route. This is significantly faster than processing every road segment on the map.

 

Once the stops are found, the road segments for each connecting complex path are all concatenated into a single array. This full path is used by the server and client, although in different ways.
A separate list is created for where each stop is, containing names and cumulative distances, so they can be presented on the client.

 

 

hCHRuae.png


Due to how the roads are laid out, travelling from one stop to another can sometimes involve backtracking along some road segments.

This can look very awkward, so in this instance (and only this one) the overlapping segments are pruned out of the full path to smooth out the experience.

Without pruning, the right-side path would continue to the truck, then back to the road, before continuing on.

 

When requesting a pick-up, the DLL must perform pathfinding the hard way and route through the road segments to the truck.
Since the catchment area for pick-ups contains the road segments within it, the processing can be limited to just those segments and be much faster.

 

 

 

 

Server


When the travel path is finally created, the player is then moved along it both on the server and the client.
The path consists of all the road segment positions along the way, and a series of lengths that indicate the cumulative distances to each truck stopping point.

 

The server and client handle moving the player differently; the client will smoothly move a camera along the path, while the server will jump the player to the closest road segment position.
The player is hidden while fast travelling so other players don't see them jumping from position to position.

 

Click the images below for video examples

U7UfmUM.png     EusYCE6.png

 

There are a few reasons for moving the player on the server in this way:

  • a player can disconnect part-way through travelling and have their progress along the path still apply. When they reconnect they will be roughly in the same position that they were
  • the fast travel can be cancelled part-way through travelling. This has the same effect as disconnecting, leaving the player roughly in the position where they requested the stop
  • Guides and some other parts of the game show/hide themselves based on the player position. Keeping the position updated means that there will be no Guide reveal delays after the fast travel ends (particularly noticeable when exiting the Base Camp truck position)
  • the player position is updated every 2 seconds on the server, as opposed to every frame on the client. This keeps the processing on the server-side as low as possible

 

Both server and client move at a fixed 20m/s along the path. This used to be 10m/s, before player run speed boosts were added.
With the speed and truck stop distances, the server and client can then calculate how long it will take to make the full journey.

 

This is a simplified version of the loop that moves the player along the path:

//Handle the server-side processing

TESTVAR_FTP_SPEED = 20;
TESTVAR_FTP_UPDATE_INTERVAL = 2.0;

private _pathData = ... ; //Provided by DLL
_pathData params ["_pathPoints", "_totalDistance"];
private _transportDuration = _totalDistance / TESTVAR_FTP_SPEED;

//Loop until enough time has passed, and while the player still exists
//Move the player along in increments - this is to make sure the player position is saved as they travel so if they
//disconnected they will rejoin partway through (although no longer transporting)
private _startTime = serverTime;
private _endTime = _startTime + _transportDuration;
private _now = _startTime;
private _pointsStartIndex = -1;
private _prevPosASL = getPosASL _player;
//Loop until the time is done, the player no longer exists (disconnected), or the player has requested a transport cancel
while {_now < _endTime && { !(isNull _player) } && { _player getVariable "TESTVAR_FTP_TRANSPORT_RUNNING" }} do
{
	//How far have we moved
	private _travelledDistance = linearConversion [_startTime, _endTime, _now, 0.0, _totalDistance, true];

	//Get the position along the path we now are
	private _newPos = ... FUNC(_pathPoints, _travelledDistance);
	//Flatten and turn to ASL
	private _newPosASL = AGLToASL [_newPos # 0, _newPos # 1, 0.0];
	
	//Get the facing to the new position before we move there
	//This means that if we interrupt the pathing then the player will be facing in the direction of travel
	private _facing = (_player getRelDir _newPosASL) + (getDir _player);
	
	//Assign the new position and facing to the player
	_player setDir _facing;
	_player setPosASL _newPosASL;

	//Store the position
	_prevPosASL = _newPosASL;

	//Wait until the next update interval
	sleep ((_endTime - serverTime) min TESTVAR_FTP_UPDATE_INTERVAL);
	
	//Update the time before we loop again
	_now = serverTime;
};

//Loop complete, set the final player position and facing
...


When the player reaches the end of the path, they are set to a specific position and facing defined by some external truck data.
The position is dictated by which side of the truck the player 'exits' when they arrive.

 

 

Xirp3eu.png


These values are defined in a spreadsheet, as is much of the rest of the game's content. This is processed when the game data is being built, and provided to the DLL in a compressed format.
NOTE: FTP is the internal name for a 'fast travel point' aka a truck
 

 

 

 

Client


The Transport interface, which is the player-facing name for the fast travel mechanic, has several jobs to do.

 

It needs to present:

  • an overview of all repaired and known (but broken) trucks,
  • a secondary view that allows the player to start fast travelling from a truck,
  • another view which is shown while the player is travelling to their destination

 

The overview can be opened anywhere and shows:

  • the repaired transport trucks, and hints for those that are known about but haven't been repaired,
  • the links between the trucks, which can be
    • an as-the-crow-flies direct line to a broken truck hint (to avoid revealing where the truck is), or
    • a complete road segment path
  • the pick-up catchment areas that each truck covers

 

 

0ShQ1qR.png     I4XcI8e.png


This view is also where the player requests a pick-up from the nearest available truck, with the footer portion of the interface being where the player can do this, or be presented with additional information if a pick-up is not possible.
The downside is it can be easily overlooked by players when they first encounter the Transport interface. However, it avoids the need to adding a separate view just for pick-ups.
A redesign of the interface, especially with how it's re-used below, is probably necessary to resolve this. (An update to set the 'Transport' title bar text for the interface use-case was added after writing this)

 

 

q9DlF1V.png


The secondary view, which the player sees when interacting with a truck, re-uses the same interface as before but restricts visible information so only details related to the truck in question are shown.


The player is expected to select a destination (made slightly easier by allowing them to click on catchment areas), and then confirm the travel with a click-and-hold button at the bottom. These buttons act as an alternative to a confirmation prompt, and make sure the player really wants to travel.
This button also indicates how long it will take to reach the destination truck, so the player can know in advance how much of their time needs to be spent finding something else to do.
A number of transfers is shown (i.e. the intermediate truck positions pathed through) and is mostly for show, but also helps to justify early on that the road path taken is not necessarily the most direct one.

 

Trivia: The longest travel path from one truck position to another takes 32 minutes to complete.

 

 

Rzav4Kg.png     ktgj4nW.png     bUkaonm.png


When the player is travelling between truck positions, a new view is used to show both the progress along the path and some additional information about how long is left:

  • A steering wheel icon moves across the screen at the bottom, between the start and end truck position names.
  • Estimated time of arrival is shown in 1 minute increments, until the time falls below 60 seconds and it switches to 5 second increments.
    • For the final 10 seconds of travel, the text is changed to 'Arriving' instead of showing the final few digits.
    • This is intended to encourage the player to stop looking at the time ticking down and start looking ahead to the truck they are approaching.
  • Additional pips for any intermediate truck positions are also shown, spaced out across the screen relative to their positions along the travel path.
    • The truck position name (usually the closest town) briefly fades in as the wheel passes the pip, which can help the player to decide if they want to abort their travel at that truck location.

 

Using the same road segment path data as the server, the client interpolates a camera along the path from beginning to end.
This interpolation:

  • happens separately for both a camera position and a camera look-at position, which generates a target position for both
  • the camera target position is the distance along the path appropriate for how long the player has been travelling
  • the look-at target position is the average of the camera position at +15m and +30m along the path
    • using a look-at position helps to ensure the camera is always looking ahead and smoothly turning to face where the position will be
    • the look-at position pair averaging avoids an issue with single look-at positions, where the view will turn sharply before it reaches the actual corner (shown in a video below)

    

 

ogBASC4.png

 

Road position segments are often widely spaced, making slight changes in direction seem jerky and very noticeable if the camera closely hugs the path of travel. To avoid this, both of the camera positions are additionally interpolated to over time.
The final positions used are calculated at a secondary ratio of 0.05 from current-to-target. For example:

private _startTime = serverTime;
private _endTime = _startTime + _transportDuration;
private _now = _startTime;
private _totalDistance = ... ;

while {true} do {
	//How far have we moved
	private _travelledDistance = linearConversion [_startTime, _endTime, _now, 0.0, _totalDistance, true];
	
	//What're the target positions for the camera and look-at
	private _targetPosition = ... FUNC(_pathPoints, _travelledDistance);
	private _lookAtA = ... FUNC(_pathPoints, _travelledDistance + 15.0);
	private _lookAtB = ... FUNC(_pathPoints, _travelledDistance + 30.0);
	private _targetLookAt = (_lookAtA vectorAdd _lookAtB) vectorMultiply 0.5;
	
	//Ease to the target positions
	_currentPosition = vectorLinearConversion [0, 1, 0.05, _currentPosition, _targetPosition];
	_currentLookAt = vectorLinearConversion [0, 1, 0.05, _currentLookAt, _targetLookAt];
	
	//Additional position height adjustments
	...
	
	//Set the position on the camera
	...

	//Wait until the next update interval (30 FPS)
	uiSleep (1.0 / 30.0);
	
	//Update the time before we loop again
	_now = serverTime;
};


The secondary ratio is carefully balanced against the 20m/s movement speed to give the smoothest possible movement along the path.
However, this smoothing struggles with very sharp corners and can sometimes mean the camera cuts across corners, or goes over walls that are very close to the road.
This ratio interpolation is also frame-rate dependant and suffers when the frame rate is uneven and falls below 30 FPS, making the smoothing look different when comparing a higher-spec and lower-spec PC with differing FPS.

 

The following videos show the differences between the various implementations mentioned above:

 

Click image to open video

sEbVYc7.png

 

  • A single look-at position & no secondary smoothing. Take note of:
    • how the camera swings abruptly just before corners
    • the general jerkiness of the camera as it transitions between road segments

 

Click image to open video

B0kYfcZ.png

 

  • A double look-at position & no secondary smoothing
    • the camera now swings more gently as it approaches a corner

 

Click image to open video

OdsHBe1.png

 

  • A double look-at position & enabled secondary smoothing
    • the camera now loosely follows the path, and feels more like it's gliding along
    • it now also strays slightly over the sides of the road, especially on corners
    • this is the current Live version

 

 

 

Discovery


Much like with finding Guides, the process of finding trucks came from the need to have some kind of interesting puzzle for the player to solve.
Fast travel was one of the earliest gameplay mechanics added, so there were plenty of unused options available for this. However, once I settled on using trucks for fast travel, those options became much more limited.

 

The puzzle needed to be something different from the already existing Guides puzzle (following photo and map hints), and ended up being between two options:

  • following a tracking signal of some kind, or
  • using the sound of a horn to find the truck

 

Some early prototyping work for expanding on the Job system involved a lot of different signal tracking modes, using various types of handset. Rather than let the same system be reused for trucks, I opted to go for the audio-driven option.


Audio puzzles are fairly uncommon in most games, for obvious accessibility reasons, and because there's no guarantee that people will even have audio enabled or at a decent volume. In fact, I wanted to encourage people to play music of their own while playing Bearing.
Nevertheless, I went ahead with using audio for the puzzle mechanic, because I enjoyed the process of finding trucks that way - it reminded me of tracking down people in DayZ using the occasional gunshot.

 

Technically, the horn sound effect was awkward to implement. I initially made use of 'TruckHorn2' only to find that the default sound attenuation range (250m) was too short.
Rather than spending time fiddling with configs and recompiling until it felt right, I implemented an audio attenuation script to do the work for me.
The script requires that all sound configs have a default 100m attenuation, so that it can be readily applied to anything.


NOTE: There is an alternative syntax for say3D that takes a maximum attenuation distance, but found that it didn't work as expected. The script version worked well enough that I never went back to test it again.
I could also probably have read the config attenuation directly, and avoided the 100m requirement.

 

A condensed version of the script is:

//Works out how far away from an ear position that a sound should be positioned, given the falloff range
//Sounds should have a 100m distance limit, so we fudge its position to compensate for needing it to falloff completely when the player is _soundMaximumFalloffDistance away
FUNC_GETSOUNDTRACK3DPOSITION = {
	params ["_earPosASL", "_soundPosASL", "_soundMaximumFalloffDistance"];

	//Get the vector between the two positions and the distance between them
	private _vec = _soundPosASL vectorDiff _earPosASL;
	private _dist = vectorMagnitude _vec;

	//Get the 0-1 ratio of between the distance between the positions, and the maximum attenuation falloff distance
	//This ratio roughly equates to the inverse volume
	//i.e. if the maximum attenuation is 500m, and the distance to the sound is 200m, then the ratio will be 0.4
	private _distRatio = linearConversion [0, _soundMaximumFalloffDistance, _dist, 0, 1];
	//Clamp only the lower end
	if (_distRatio < 0) then { _distRatio = 0; };

	//All custom sound configs have 100 attenutation, so they'll work with this function
	TMPVAR_SOUNDCONFIG_ATTENUATION = 100.0;

	//Convert the distance from the incoming falloff distance range to the 100m range
	_dist = _distRatio * TMPVAR_SOUNDCONFIG_ATTENUATION;
	//Reposition the sound for the new range
	_earPosASL vectorAdd ((vectorNormalized _vec) vectorMultiply _dist);
};

//The function to call when wanting to play a sound
//It can take either a fixed position (if you just want to create a sound with no source) or an object that'll be tracked
FUNC_PLAYSOUND3D = {
	params ["_soundName", "_soundSourcePosOrObj", "_soundMaximumFalloffDistance", "_startDelay", "_duration"];
	
	//Pause for any initial delay
	if (_startDelay > 0.0) then { uiSleep _startDelay; };

	//Get the initial world-space sound position - this can be a specific position or an object (which can move and should be tracked)
	private _soundSourcePosASL = [];
	if (typeName _soundSourcePosOrObj == "ARRAY") then {
		_soundSourcePosASL = +_soundSourcePosOrObj;
	} else {
		_soundSourcePosASL = getPosASL _soundSourcePosOrObj;
	};
	
	//When do we need to stop the sound
	private _startTime = diag_tickTime;
	private _endTime = _startTime + _duration;

	//Get the starting player and sound position - this will be updated constantly below
	private _playerPosASL = AGLToASL (positionCameraToWorld [0, 0, 0]);
	private _soundSpawnPosASL =  [_playerPosASL, _soundSourcePosASL, _soundMaximumFalloffDistance] call FUNC_GETSOUNDTRACK3DPOSITION;

	//Create the tmp object to play the sound from
	private _obj = "Land_HelipadEmpty_F" createVehicleLocal _soundSpawnPosASL;

	//Set the position again to make sure it's correct
	_obj setPosASL _soundSpawnPosASL;
	
	//Play the sound
	_obj say3D _soundName;
	
	//Loop until done
	while {diag_tickTime < _endTime && (typeName _soundSourcePosOrObj == "ARRAY" || {!(isNil "_soundSourcePosOrObj") && !(isNull _soundSourcePosOrObj)})} do
	{
		//Keep updating the position if it's an object
		if (typeName _soundSourcePosOrObj != "ARRAY") then {
			_soundSourcePosASL = getPosASL _soundSourcePosOrObj;
		};
		//Also the player, since they'll also be moving
		_playerPosASL = AGLToASL (positionCameraToWorld [0, 0, 0]);
		//Get the updated sound position
		_soundSpawnPosASL = [_playerPosASL, _soundSourcePosASL, _soundMaximumFalloffDistance] call FUNC_GETSOUNDTRACK3DPOSITION;
		//And apply it
		_obj setPosASL _soundSpawnPosASL;
		//Sleep for a bit or it'll interfere with things like particle effect movement - not sure why
		sleep 0.01;
	};
	
	//Done, delete the tmp object
	deleteVehicle _obj;
};


The distance at which the truck horns can be heard was also tweaked over time, specifically to be generous enough to allow players to make a mistake and not easily wander out of range.
To make things a little more realistic and interesting, a speed of sound delay was provided to the above function.

TMPVAR_SPEED_OF_SOUND = 340.29;
TMPVAR_TRUCK_FALLOFF_DISTANCE = 750.0;
TMPVAR_TRUCK_SOUND_DURATION = 5.0;

private _vec = _truckSoundPosition vectorDiff _playerPosASL;
private _dist = vectorMagnitude _vec;
private _speedOfSoundDelay = _dist / TMPVAR_SPEED_OF_SOUND;

[TMPVAR_SOUNDNAME, _truckSoundPosition, TMPVAR_TRUCK_FALLOFF_DISTANCE, _speedOfSoundDelay, TMPVAR_TRUCK_SOUND_DURATION] call FUNC_PLAYSOUND3D;


Part of the design involved figuring out a way to explain how the truck horns were being set off. 
This is mostly explained away by story - the trucks fitted with devices that sound the horn in response to a signal, like from a car key-fob, because drivers kept leaving them in unexpected places.
That meant a handset had to be designed to send the signals, shown below.

 

Mx5EGXC.png

 

The player opens the handset and clicks the power switch to send a signal. Shortly after this, the handset interface is closed and the closest truck horns will sound.

 

I opted to add a delay between signals, shown by the PWR light flashing as if it's recharging, to prevent the player from spamming it constantly while moving.

Players instead trigger the horns, roughly get the sound source direction, and go walking towards it for a while before getting to try again.


The delay was tweaked over time (currently 12 seconds), as well as the number of times the horn will sound (previously 2, and now 3), to give the player just enough time to get a rough idea of which direction to walk in.

 

For deciding where the trucks should be, aside from always being placed beside a road, the trick to placement was putting them next to scenery so they weren't immediately obvious from a distance.
I would usually take care not to tuck the truck in an alleyway - having to hunt around buildings when you're already close to the truck wasn't fun.
Sometimes I'd place one in the open, either because there was nothing suitable nearby to mask it, or just felt like making one easy to find.

  • Like 6
  • Thanks 1

Share this post


Link to post
Share on other sites

Dynamic Effects

 

Bearing has a custom particle implementation that builds on the existing particle system available in Arma 3.
This is to provide runtime particle effects with additional features, including those only found when using CfgCloudlets classes, like beforeDestroyScript=, postEffects=  and onTimerScript=.

 

 

Effect config setup

 

Each effect config is comprised of a collection of emitters, which can be either a particle system or a light source:

//Each effect is declared as an array on the server, and transmitted to clients on connection
ExampleEffect = [
    [ ... ],    //Emitter #1 - particle system
    [ ... ],    //Emitter #2 - particle system
    [ ... ]     //Emitter #3 - light
];

 

 

Effect emitter setup

 

These particle systems and lights have their own update loop and custom data array.
They also come with pre-/post-creation script events, which comes in useful when an effect needs to perform some logic before/after it starts/ends. This is often positional setup, or spawning a new effect relative to where one expires.

//An example emitter config for a particle system
ExampleEmitter_ParticleSystem = [
    //Common config
    EmitterType,        //Particle system
    PositionOffset,     //ASL offset from the effect spawn position
    LifeTime,           //How long the emitter should last for, assuming there is no lifetime handler script
    
    //Particle config
    ParticleParams,    	//Passed to setParticleParams
    ParticleRandom,    	//Passed to setParticleRandom
    ParticleCircle,     //Passed to setParticleCircle
    DropInterval,       //Interval and variance range pair, which is updated regularly and passed to setDropInterval
    
    //Common config
    UpdateCode,         //Update script, called at 30FPS
    UpdateData,         //A predefined array of data
    PreCreateCode,      //Called after the emitter is created, but before it is initialised
    PostCreateCode,     //Called after the emitter is created, AND after it is initialised
    ExtraData,          //Set externally, by passing in a runtime array of data when spawning the effect
];

//A similar example emitter config for a light
ExampleEmitter_Light = [
    //Common config
    EmitterType,        //Light
    PositionOffset,     //ASL offset from the effect spawn position
    LifeTime,           //How long the emitter should last for, assuming there is no lifetime handler script
    
    //Light config
    LightAmbient,       //Passed to setLightAmbient
    LightColour,        //Passed to setLightColor
    LightIntensity,     //Passed to setLightIntensity
    LightAttenuation,   //Passed to setLightAttenuation
    
    //Common config
    UpdateCode,         //Update script, called at 30FPS
    UpdateData,         //A predefined array of data
    PreCreateCode,      //Called after the emitter is created, but before it is initialised
    PostCreateCode,     //Called after the emitter is created, AND after it is initialised
    ExtraData           //Set externally, by passing in a runtime array of data when spawning the effect
];

 

When an emitter is spawned, it makes a deep copy of its config data which can then be overwritten at runtime.
This deep copy allows effects to be as messy as they need to be. Some amount of data retention is necessary when lerping between values, and it was simpler to duplicate everything than have each emitter have to handle it case-by-case.
Changes are then applied to lights and/or particle objects on every update where appropriate, e.g. with setParticleParams/setLightAmbient/etc

 

 

 

Parent object management


Spawning effects requires a steady collection of semi-disposable objects to attach them to.
Due to client-side CVL restrictions, and because spawning effects directly from the server is too much processing for the server to be saddled with, the following simplified block of code runs on the client at all times:

//This code is run on the client from the server when the player connects

//Arrays to store the objects we need to attach particles and lights to
//NOTE: These objects cannot be re-used and must be deleted after use
TMPVAR_PREGENCVL_PARTICLESOURCE = [];
TMPVAR_PREGENCVL_LIGHTPOINT = [];

//Helipad objects are the root object which all child emitters reference for the effect position
//These objects are also re-added to the array once we are done with them
//Make sure not to attach and child objects to this, or it will mess up how the effects spawn
//NOTE: 'Land_HelipadEmpty_F' is used because it's a simple invisible object
TMPVAR_PREGENCVL_HELIPAD = [];

//Some sensible limits on the minimum number to have around
//These values need to be high because we can create a lot of effects all at once, and we might not get to generate more until the next frame. This behaviour is the core flaw with this system.
TMPVAR_COUNT_HELIPAD = 20;
TMPVAR_COUNT_PARTICLESOURCE = 50;
TMPVAR_COUNT_LIGHTPOINT = 50;

//The function code which creates the objects
FUNC_EFFECTS_POPULATECVL = {
    //Sanity check this code was called from the server
    if (remoteExecutedOwner != 2) exitWith {
        //Some debug output
        diag_log "TRYING TO POPULATE OBJECTS WHEN NOT THE SERVER";
    };
    //Create objects for particle sources and lights to follow
    while {count TMPVAR_PREGENCVL_HELIPAD < TMPVAR_COUNT_HELIPAD} do {
        TMPVAR_PREGENCVL_HELIPAD pushBack ("Land_HelipadEmpty_F" createVehicleLocal [0, 0, 0]);
    };
    //Create objects for particle sources to attach to
    while {count TMPVAR_PREGENCVL_PARTICLESOURCE < TMPVAR_COUNT_PARTICLESOURCE} do {
        TMPVAR_PREGENCVL_PARTICLESOURCE pushBack ("#particlesource" createVehicleLocal [0, 0, 0]);
    };
    //Create objects for lights to attach to
    while {count TMPVAR_PREGENCVL_LIGHTPOINT < TMPVAR_COUNT_LIGHTPOINT} do {
        TMPVAR_PREGENCVL_LIGHTPOINT pushBack ("#lightPoint" createVehicleLocal [0, 0, 0]);
    };
};

[] spawn {
    //Run this loop forever
    while {true} do {
        //Only create new objects when the arrays are under the limit
        waitUntil {
            count TMPVAR_PREGENCVL_HELIPAD < TMPVAR_COUNT_HELIPAD ||
            {count TMPVAR_PREGENCVL_PARTICLESOURCE < TMPVAR_COUNT_PARTICLESOURCE} ||
            {count TMPVAR_PREGENCVL_LIGHTPOINT < TMPVAR_COUNT_LIGHTPOINT}
        };
        //Time to create objects
        [] call FUNC_EFFECTS_POPULATECVL;
    };
};


 

Spawning effects


Most of the effects are developed using a common template script and a blank SQM. That template script is a cut-down version of the script used in Bearing.
Below is a further simplified version of the template script, as an example:

//Config for the effect, stored as an array
TMPVAR_EFFECTCONFIG = [ ... ];

//Some defines to access the config data
#define DEF_EMITTER_TYPE                              0
#define DEF_EMITTER_POSOFFSET                         1
#define DEF_EMITTER_LIFETIME                          2

#define DEF_EMITTER_PARTICLEPARAMS                    3
#define DEF_EMITTER_PARTICLERANDOM                    4
#define DEF_EMITTER_PARTICLECIRCLE                    5
#define DEF_EMITTER_DROPINTERVALDATA                  6

#define DEF_EMITTER_LIGHTAMBIENT                      3
#define DEF_EMITTER_LIGHTCOLOUR                       4
#define DEF_EMITTER_LIGHTINTENSITY                    5
#define DEF_EMITTER_LIGHTATTENUATION                  6

#define DEF_EMITTER_UPDATECODE                        7
#define DEF_EMITTER_UPDATEDATA                        8
#define DEF_EMITTER_PRECREATECODE                     9
#define DEF_EMITTER_POSTCREATECODE                    10
#define DEF_EMITTER_EXTRADATA                         11

//Additional defines
#define DEF_EMITTERTYPE_PARTICLE                      0
#define DEF_EMITTERTYPE_LIGHT                         1
#define DEF_EMITTERDATA_PARTICLEPARAMS_TRACKOBJIDX    18
#define DEF_EMITTERUPDATEINTERVAL                     (1.0 / 30.0)


//A helper function that creates a deep copy of an array, calling itself recursively
FUNC_ARRAYDEEPCOPY = {
    private _arrayOut = [];
    {
        if (typeName _x == "ARRAY") then {
            _arrayOut pushBack (_x call FUNC_ARRAYDEEPCOPY);
        } else {
            _arrayOut pushBack _x;
        };
    }
    forEach _this;
    _arrayOut;
};


//Core function that creates all the emitters and handles all processing until the effect is complete
FUNC_EFFECTSPAWN = {
    params ["_effectConfigData", "_posASL", "_lifetimeParamsAndCode", "_extraData"];
    
    //This variable is optional and can be nil, but will error below if we don't set it to something
    if (isNil "_extraData") then { _extraData = []; };
        
    //The object which all emitters follow
    private _parentObj = TMPVAR_PREGENCVL_HELIPAD deleteAt 0;
    _parentObj setPosASL _posASL;
    
    //Gather the objects for particles and lights in advance, otherwise something can take them while the spawn below is running
    private _useObjects = [];
    {
        //Get the first available object for the appropriate type
        if (_x # DEF_EMITTER_TYPE == DEF_EMITTERTYPE_PARTICLE) then {
            private _emitterParticle = TMPVAR_PREGENCVL_PARTICLESOURCE deleteAt 0;
            _useObjects pushBack _emitterParticle;
        } else {
            private _emitterLight = TMPVAR_PREGENCVL_LIGHTPOINT deleteAt 0;
            _useObjects pushBack _emitterLight;
        };
    }
    forEach _effectConfigData;


    //Spawn something to do all the work
    //Some init parts of this could be done before the spawn{}
    //NOTE: _extraData is supplied separately because it might not exist in _this, and the compiler will complain
    [_this, _parentObj, _useObjects, _extraData] spawn {
        params ["_spawnData", "_parentObj", "_useObjects", "_extraData"];
        _spawnData params ["_effectConfigData", "_posASL", "_lifetimeParamsAndCode"];
        
        //Make a copy of the entire effect config
        private _effectCopyData = _effectConfigData call FUNC_ARRAYDEEPCOPY;

        //Create an array to hold the active emitters and when the effect starts
        private _activeEmitters = [];
        private _startTime = diag_tickTime;

        //Create each emitter
        //NOTE: Both inits are very similiar
        {
            switch (_x # DEF_EMITTER_TYPE) do
            {
                case DEF_EMITTERTYPE_PARTICLE: {
                    //Set the extra data first, because pre/post create code may need it
                    _x set [DEF_EMITTER_EXTRADATA, _extraData];
                    //Get the emitter object from our pre-filled array
                    private _emitterParticle = _useObjects # _forEachIndex;
                    //Run any pre-create code
                    private _preCreateCode = _x # DEF_EMITTER_PRECREATECODE;
                    if (!(isNil "_preCreateCode")) then { [_x, _emitterParticle] call _preCreateCode; };
                    //Position it, including the config's position offset
                    private _emitterPosASL = _posASL vectorAdd (_x # DEF_EMITTER_POSOFFSET);
                    _emitterParticle setPosASL _emitterPosASL;
                    //Set the object on the particle params before we assign it - this is necessary for the particle system to work properly
                    (_x # DEF_EMITTER_PARTICLEPARAMS) set [DEF_EMITTERDATA_PARTICLEPARAMS_TRACKOBJIDX, _emitterParticle];
                    //Set all particle data on the particle object
                    _emitterParticle setParticleParams (_x # DEF_EMITTER_PARTICLEPARAMS);
                    _emitterParticle setParticleRandom (_x # DEF_EMITTER_PARTICLERANDOM);
                    _emitterParticle setParticleCircle (_x # DEF_EMITTER_PARTICLECIRCLE);
                    //Set up the initial drop interval
                    private _dropInterval = _x # DEF_EMITTER_DROPINTERVALDATA;
                    _emitterParticle setDropInterval (_dropInterval # 0);
                    private _dropIntervalData = [];
                    //If there is a variance to the drop interval, then process it here
                    if (_dropInterval # 1 != 0.0) then {
                        //Copy the data directly and append to it
                        _dropIntervalData = +_dropInterval;
                        //Add the time to wait before we change the drop interval again
                        if (count _dropInterval == 2) then {
                            _dropIntervalData pushBack (diag_tickTime + (_dropInterval # 1));
                        } else {
                            _dropIntervalData pushBack (diag_tickTime + (linearConversion [0, 1, random 1.0, _dropInterval # 2, _dropInterval # 3]));
                        };
                    };
                    //Store the emitter and its lifetime, drop interval and data
                    _activeEmitters pushBack [_x, _emitterParticle, _startTime + (_x # DEF_EMITTER_LIFETIME), _dropIntervalData];
                    //Run any post-create code
                    private _postCreateCode = _x # DEF_EMITTER_POSTCREATECODE;
                    if (!(isNil "_postCreateCode")) then { [_x, _emitterParticle] call _postCreateCode; };
                };
                case DEF_EMITTERTYPE_LIGHT: {
                    //Set the extra data first, because pre/post create code may need it
                    _x set [DEF_EMITTER_EXTRADATA, _extraData];
                    //Get the light object from our pre-filled array
                    private _emitterLight = _useObjects # _forEachIndex;
                    //Run any pre-create code
                    private _preCreateCode = _x # DEF_EMITTER_PRECREATECODE;
                    if (!(isNil "_preCreateCode")) then { [_x, _emitterLight] call _preCreateCode; };
                    //Position it, including the config's position offset
                    private _emitterPosASL = _posASL vectorAdd (_x # DEF_EMITTER_POSOFFSET);
                    _emitterLight setPosASL _emitterPosASL;
                    //Set all the light data on the light source object
                    _emitterLight setLightAmbient (_x # DEF_EMITTER_LIGHTAMBIENT);
                    _emitterLight setLightColor (_x # DEF_EMITTER_LIGHTCOLOUR);
                    _emitterLight setLightIntensity (_x # DEF_EMITTER_LIGHTINTENSITY);
                    _emitterLight setLightAttenuation (_x # DEF_EMITTER_LIGHTATTENUATION);
                    //Always visible during daylight
                    _emitterLight setLightDayLight true;
                    //Store the emitter and its lifetime
                    _activeEmitters pushBack [_x, _emitterLight, _startTime + (_x # DEF_EMITTER_LIFETIME)];
                    //Run any post-create code
                    private _postCreateCode = _x # DEF_EMITTER_POSTCREATECODE;
                    if (!(isNil "_postCreateCode")) then { [_x, _emitterLight] call _postCreateCode; };
                };
            };
        }
        forEach _effectCopyData;

        //Default the effect to unhidden
        //NOTE: This has to be on the parent object because #particlesource/#lightpoint objects can't have variables set on them
        //This can be set on the parent at any time, and will move the parent to [0, 0, 0] which all child objects will follow
        _parentObj setVariable ["TMPVAR_EFFECT_HIDDEN", false];

        //Some common code to run below in a loop
        private _updateEmitter = {
            params ["_parentObj", "_emitterData", "_emitterAge"];
            //Update the position, following the parent
            (_emitterData # 1) setPosASL ((getPosASL _parentObj) vectorAdd ((_emitterData # 0) # DEF_EMITTER_POSOFFSET));
            
            //Handle each type of emitter
            
            //Particles
            if ((_emitterData # 0) # DEF_EMITTER_TYPE == DEF_EMITTERTYPE_PARTICLE) then {
                _emitterData params ["_innerData", "_emitterObj", "_end", "_dropIntervalData"];
                //Update drop intervals, if we had any custom data
                if (count _dropIntervalData > 0) then {
                    //Process a new drop interval, depending on the data we have
                    if (count _dropIntervalData == 3) then {
                        _dropIntervalData params ["_dropInterval", "_dropIntervalVar", "_end"];
                        if (diag_tickTime > _end) then {
                            private _newInterval = linearConversion [0.0, 1.0, random 1.0, _dropInterval - (_dropIntervalVar * 0.5), _dropInterval + (_dropIntervalVar * 0.5)];
                            _end = diag_tickTime + _newInterval;
                            _emitterObj setDropInterval _newInterval;
                            _dropIntervalData set [2, _end];
                        };
                    };
                    if (count _dropIntervalData == 5) then {
                        _dropIntervalData params ["_dropInterval", "_dropIntervalVar", "_dropIntervalDurationMin", "_dropIntervalDurationMax", "_end"];
                        if (diag_tickTime > _end) then {
                            private _newInterval = linearConversion [0.0, 1.0, random 1.0, _dropInterval - (_dropIntervalVar * 0.5), _dropInterval + (_dropIntervalVar * 0.5)];
                            _emitterObj setDropInterval _newInterval;
                            _end = diag_tickTime + (linearConversion [0, 1, random 1.0, _dropIntervalDurationMin, _dropIntervalDurationMax]);
                            _dropIntervalData set [4, _end];
                        };
                    };
                };
                //Call the emitter update code
                [_parentObj, _emitterObj, _innerData, _emitterAge] call (_innerData # DEF_EMITTER_UPDATECODE);
            };
            
            //Lights
            if ((_emitterData # 0) # DEF_EMITTER_TYPE == DEF_EMITTERTYPE_LIGHT) then {
                _emitterData params ["_innerData", "_lightObj", "_end"];
                //Call the light update code
                [_parentObj, _lightObj, _innerData, _emitterAge] call (_innerData # DEF_EMITTER_UPDATECODE);
            };
            
            //If the parent object is hidden then move the emitter far away to the origin
            //This is easier than messing around with the drop interval or updating scaling params to hide particles
            if (_parentObj getVariable "TMPVAR_EFFECT_HIDDEN") then {
                (_emitterData # 1) setPosASL [0,0,0];
            };
        };

        //We either want to control the lifetime of the effect with code or its own internal lifetime value
        if (isNil "_lifetimeParamsAndCode") then {
            //No code to handle lifetime for us
            //Update and erase emitters as they expire
            while {count _activeEmitters > 0} do {
                private _emitterAge = diag_tickTime - _startTime;
                {
                    _x params ["_emitterData", "_emitterObj", "_endTime"];
                    if (diag_tickTime > _endTime) then {
                        //We can't reuse the object, so delete it
                        deleteVehicle _emitterObj;
                        //Overwrite the element with objNull, so we can delete them easily below
                        _activeEmitters set [_forEachIndex, objNull];
                    } else {
                        //Still alive, update the emitter
                        [_parentObj, _x, _emitterAge] call _updateEmitter;
                    };
                }
                forEach _activeEmitters;
                //Remove any active emitters elements we deleted
                _activeEmitters = _activeEmitters - [objNull];
                //
                uiSleep DEF_EMITTERUPDATEINTERVAL;
            };
        } else {
            //We have lifetime code to handle things for us
            //Run the code until it's done
            _lifetimeParamsAndCode params ["_lifetimeParams", "_lifetimeCode"];
            //Run until the code returns true
            while {!(_lifetimeParams call _lifetimeCode)} do {
                //Still running, update each of the emitters
                private _emitterAge = diag_tickTime - _startTime;
                { [_parentObj, _x, _emitterAge] call _updateEmitter; } forEach _activeEmitters;
                //
                uiSleep DEF_EMITTERUPDATEINTERVAL;
            };
            //The lifetime handling code is complete
            //Destroy the emitters
            {
                _x params ["_emitterData", "_emitterObj", "_endTime"];
                //We can't reuse the object, so delete it
                deleteVehicle _emitterObj;
            }
            forEach _activeEmitters;
        };

        //Put the parent back in the pool, since we can reuse it
        //Also move it to the origin
        _parentObj setPosASL [0, 0, 0];
        //NOTE: Sometimes the reposition won't happen immediately, or at all, so wait until it does
        waitUntil {(getPos _parentObj) distance2D [0, 0, 0] < 10.0};
        //Put back in the pool
        TMPVAR_PREGENCVL_HELIPAD pushBack _parentObj;

        //Done
    };

    //Return the parent object so we can move it around externally
    _parentObj;
};


//Effect spawn examples
private _effectPosASL = [ ... ];

//An effect with no lifetime code or extra data
//Expires when the config data DEF_EMITTER_LIFETIME elapses
[
    TMPVAR_EFFECTCONFIG,
    _effectPosASL
] call FUNC_EFFECTSPAWN;


//An effect with lifetime code
[
    TMPVAR_EFFECTCONFIG,
    _effectPosASL,
    //Keep the effect alive for 2 seconds
    [
        (diag_tickTime + 2.0),            //End time given as a param
        { diag_tickTime >= _this; }    //Return true once we've exceeded the time
    ]
] call FUNC_EFFECTSPAWN;


//An effect that never ends, with extra data
private _extraData = [ ... ];
[
    TMPVAR_EFFECTCONFIG,
    _effectPosASL,
    [ 0, { false; } ], //Lifetime code that causes the effect to live forever
    _extraData
] call FUNC_EFFECTSPAWN;

 


Effect spawn delay


Due to the limits on existing objects, effects can be created inside spawn{} loops and wait until enough objects are available.
The following code shows how this is achieved:

//Counts all the required emitters and checks that enough objects exist for them
FUNC_EFFECT_CANSPAWN = {
    params ["_effectConfigData"];
    private _particleCount = 0;
    private _lightCount = 0;
    {
        if (_x # DEF_EMITTER_TYPE == DEF_EMITTERTYPE_PARTICLE) then {
            MACRO_INCR(_particleCount);
        } else {
            MACRO_INCR(_lightCount);
        };
    }
    forEach _effectData;

    private _hasEnoughObjects = count TMPVAR_PREGENCVL_HELIPAD > 0 &&
        {count TMPVAR_PREGENCVL_PARTICLESOURCE >= _particleCount} &&
        {count TMPVAR_PREGENCVL_LIGHTPOINT >= _lightCount};
    _hasEnoughObjects;
};

//Create the effect in a spawn{} so it can wait until it's allowed to be created
[] spawn {
    waitUntil { [TMPVAR_EFFECTCONFIG] call FUNC_EFFECT_CANSPAWN; };
    [
        TMPVAR_EFFECTCONFIG,
        _effectPosASL
    ] call FUNC_EFFECTSPAWN;
};


This waiting behaviour is only used on effects that are created from the client.
Otherwise, for effects created from the server, the following is handling is used instead.
This ensures that objects are immediately available:

//This spawn{} is not strictly necessary, but I generally wrap effect spawning in them
[] spawn {
    //Immediately force enough objects to exist - available only if called from the server
    [] call FUNC_EFFECTS_POPULATECVL;
    //Spawn the effect
    [
        TMPVAR_EFFECTCONFIG,
        _effectPosASL
    ] call FUNC_EFFECTSPAWN;
};

 

 

Effect update code


The update code is a powerful additional that allows for all sorts of runtime adjustments to the particle config data.
Some examples are:

  • Hiding particles, by clamping size or greatly increasing the drop interval, until a certain condition has been met
  • Changing the colour of particles over time, by replacing the colour array - e.g. trail smoke starting red and spawning darker after a while
  • Cycling the particle circle spawn radius over time, for an erratic pulsing effect
  • Tuning the particle lifetime and drop interval based on player distance, so that distant effects create less particles which last for longer
    • This is likely similar to the existing particle system's 'Changes dependent on distance' behaviour, which I didn't spend time trying to understand.

 

Below are some examples of code:

 

 

 

Click the image below for a video example

A6OHeGE.png

//This particle update code will adjust an emitter's particle size as the player moves further away:
{
    params ["_parent", "_emitter", "_emitterData", "_age"];
    private _distance = player distance _emitter;
    private _distanceRatio = linearConversion [2, 6, _distance, 0.0, 1.0, true];
        
    private _scaleNearStart = 0.5;
    private _scaleNearEnd = 0.2;
    
    private _scaleFarStart = 5.2;
    private _scaleFarEnd = 0.7;
    
    //Overwrite the config data
    (_emitterData # DEF_EMITTERDATA_PARTICLEPARAMS) set [
        11, //The size element of the particle params array
        [
            linearConversion [0, 1, _distanceRatio, _scaleNearStart, _scaleFarStart, true],
            linearConversion [0, 1, _distanceRatio, _scaleNearEnd, _scaleFarEnd, true]
        ]
    ];
    //And set the data on the particle object, which updates it immediately
    _emitter setParticleParams (_emitterData # DEF_EMITTERDATA_PARTICLEPARAMS);
}

 

 

 

Click the image below for a video example

LMeWOLp.png

//This example, similarly, changes the particle colour based on distance
{
    params ["_parent", "_emitter", "_emitterData", "_age"];
    private _distance = player distance _emitter;
    private _distanceRatio = linearConversion [2, 4, _distance, 0, 1, true];

    //Lerp between black and red
    private _rgb = vectorLinearConversion [0, 1, _distanceRatio, [0.1, 0.1, 0.1], [1, 0.1, 0.1], true];

    //Overwrite the config data
    (_emitterData # DEF_EMITTERDATA_PARTICLEPARAMS) set [
        12, //The colour element of the particle params array
        [
            //Fade to transparent
            [_rgb # 0, _rgb # 1, _rgb # 2, 1.0],
            [_rgb # 0, _rgb # 1, _rgb # 2, 0.0]
        ]
    ];
    //And set the data on the particle object, which updates it immediately
    _emitter setParticleParams (_emitterData # DEF_EMITTERDATA_PARTICLEPARAMS);
}

 

 

 

Click the image below for a video example

hGv1gRc.png

//This update code example changes the particle random velocity
{
    params ["_parent", "_emitter", "_emitterData", "_age"];
    private _distance = player distance _emitter;
    private _velocity = linearConversion [2, 4, _distance, 0.0, 1.35, true];
    
    //Update the config data
    (_emitterData # DEF_EMITTERDATA_PARTICLERANDOM) set [
        2, //The move velocity element of the particle random array
        [_velocity, _velocity, _velocity]
    ];
    
    //Set the data on the particle object
    _emitter setParticleRandom (_emitterData # DEF_EMITTERDATA_PARTICLERANDOM);
}

 

 

Effects in Bearing


Some specific examples of how the effects system is used in Bearing are shown below:

 

 

 

Click the image below for a video example

PZMfNHj.png

 

Marker effect particles scale up from 0 as the player approaches.
This distance can be changed at runtime so that players can later unlock being able to see markers from further away.

 

 

 

Click the image below for a video example

QqYTRW3.png

 

Several emitter patches of 'sunlight' are created at the top of solar plant towers using a smoke particle sprite.
The brightness (alpha) is controlled by how many solar panels are repaired, as well as a large light emitter placed at the top of the tower.
It also dynamically fades out the particles and light as the weather becomes overcast.
The position of the emitters are defined using the preCreateCode and extraData config elements, so that the effect can be reused in any location and orientation.

 

 

 

Click the image below for a video example

c6lCozd.png

 

The pylon 'reveal' effect is an elaborate combination of effects with many emitters, additional moving smoke trails, pre-existing CfgCloudlets and various sound effects.

 

 

 

Click the image below for a video example

KMHAVVv.png

 

Pylons have a 'low quality mode' based on distance, where they emit particles less often but which have a longer lifetime.
This is to allow many pylons effects to be visible using as few particles as possible, helping performance.

 

 


Click the image below for a video example

aLdklwy.png

 

When a solar panel field experiences a 'burnout' it uses an effect that briefly spawns a light.

NOTE: In order for effect lights to be useful, they are forced to always be visible in daylight by default. Since Bearing has no nighttime, this is the only way to make lights visible.

  • Like 2

Share this post


Link to post
Share on other sites

Animation Speeds and Drowning


Animation Speeds


Bearing has a few progression elements, mostly related to new ways to play the game.
One of the other rewards is to increase player running speeds.

 

Their speed increases incrementally each time the running animation loop completes a cycle, until reaching their permitted max speed.
This is so that the player doesn't go from a standing start to immediately running very quickly.

 

Below is a comparison of the player's run speed at the highest and lowest levels.

 

Click the image below for a video example

cmFk11S.png

 

 

Animation speed changes are handled by the server, using the AnimChanged and AnimDone events to track what the player is currently doing.
An additional loop runs for each player, checking what the player is doing and sending the correct animation speed coefficient to the client.

 

This system also handles the swimming animation speed, which 50% faster than the default swimming speed.
It also deals with an issue where the player will automatically equip an item after leaving the water. In Bearing's case, it means the player will keep equipping their binoculars.
This is solved by removing the binoculars from the player's inventory when they start a swimming animation, and adding them back when finished.

 

Click the image below for a video example

UJcm72t.png

 

 

The simplified code below details the event handling portion:

//Animation state types
TMPVAR_PLAYERVAR_ISRUNNING = false;
TMPVAR_PLAYERVAR_ISSWIMMING = false;

//Run speed (anim coefficient) defaults
//The speed coefficient the player is currently moving at, increasing slowly until the maximum allowed
TMPVAR_PLAYERVAR_RUNSPEEDCURRENT = 1.0;
//The top running speed - in Bearing this is based on Job Score and any other speed bonuses. It maxes out at around 1.7
TMPVAR_PLAYERVAR_RUNSPEEDMAX = ...;
//How much the player animation speed coefficient increases after each run animation cycle
TMPVAR_PLAYERVAR_RUNSPEEDINCREMENT = 0.02;

//Track run and swim states on the server by listening for animation changes

//Also handle adding/removing binoculars when going in and out of water so it doesn't try to auto-equip them when swimming stops
//NOTE: We can't use add/removeWeaponGlobal during uniform updates without it erroring on the server, but it seems okay here

_player addEventHandler [
    "AnimChanged",
    {
        private _player = _this # 0;
        private _currentAnimation = _this # 1;
        private _currentAnimationPrefix = _currentAnimation select [1, 3];
        
        //Swimming
        if (_currentAnimationPrefix isEqualTo "swm" || {_currentAnimationPrefix isEqualTo "sdv"} || {_currentAnimationPrefix isEqualTo "ssw"} || {_currentAnimationPrefix isEqualTo "bsw"}) then {
            //Flag that we're swimming
            TMPVAR_PLAYERVAR_ISSWIMMING = true;
            //Remove any binoculars (no need to check if they exist)
            _player removeWeaponGlobal "Binocular";
        } else {
            //Did we just stop swimming?
            if (TMPVAR_PLAYERVAR_ISSWIMMING) then {
                TMPVAR_PLAYERVAR_ISSWIMMING = false;
                //Yes, add the binoculars, if they're unlocked
                if ( ... ) then {
                    //Re-add them after a short delay
                    [_player] spawn {
                        params ["_player"];
                        uiSleep 1.0;
                        //Double-check we didn't start swimming again
                        if (!TMPVAR_PLAYERVAR_ISSWIMMING) then {
                            _player addWeaponGlobal "Binocular";
                        };
                    };
                };
            };
        };
        
        //Running
        if (_currentAnimation isEqualTo "amovpercmevasnonwnondf" || {_currentAnimation isEqualTo "amovpercmevasnonwnondfl"} || {_currentAnimation isEqualTo "amovpercmevasnonwnondfr"}) then {
            //Flag that we're running
            TMPVAR_PLAYERVAR_ISRUNNING= true;
        } else {
            //Reset the run speed if we stop
            TMPVAR_PLAYERVAR_ISRUNNING = false;
            TMPVAR_PLAYERVAR_RUNSPEEDCURRENT = 1.0;
        };
    }
];

//We only get told about animation cycles in AnimDone, as the animation isn't changing
_player addEventHandler [
    "AnimDone",
    {
        private _player = _this # 0;
        private _currentAnimation = _this # 1;
        
        //Each time we finish a run animation cycle we need to increment the run speed
        //Look for forward run animations, as well as the left/right strafing variants
        if (_currentAnimation isEqualTo "amovpercmevasnonwnondf" || {_currentAnimation isEqualTo "amovpercmevasnonwnondfl"} || {_currentAnimation isEqualTo "amovpercmevasnonwnondfr"}) then {
            private _currentSpeed = TMPVAR_PLAYERVAR_RUNSPEEDCURRENT;
            private _maxSpeed = TMPVAR_PLAYERVAR_RUNSPEEDMAX;
            //The max speed can have bonuses
            if ( ... ) then {
                _maxSpeed = _maxSpeed + ...;
            };
            //Increase and clamp to the max speed
            _currentSpeed = _maxSpeed min (_currentSpeed + TMPVAR_PLAYERVAR_RUNSPEEDINCREMENT);
            //Store the new current speed
            TMPVAR_PLAYERVAR_RUNSPEEDCURRENT = _currentSpeed;
        };
    }
];


Once we have our states and target speeds, we can start applying them on the client.
Below is a simplified version of the loop that runs on the server to handle this:

//The default animation coefficient when swimming
TMPVAR_MOVESPEED_SWIMMINGCOEF = 1.5;

[_player] spawn {
    params ["_player"];
    
    //Run forever
    while {true} do
    {
        //The animation coefficient should always be the default 1.0 unless we're wanting to adjust it
        private _coef = 1.0;
        
        //Get the coef for swimming/running
        if (TMPVAR_PLAYERVAR_ISSWIMMING) then {
            _coef = TMPVAR_MOVESPEED_SWIMMINGCOEF;
        } else {
            if (TMPVAR_PLAYERVAR_ISRUNNING) then {
                _coef = TMPVAR_PLAYERVAR_RUNSPEEDCURRENT;
            };
        };

        //Run some code to apply it on the player
        [
            [_coef], {
                params ["_coef"];
                
                //We might be overriding the animation speed for other reasons, which is found in a value stored on the client
                if (TMPVAR_AnimSpeedCoefOverride > -1) then {
                    _coef = TMPVAR_AnimSpeedCoefOverride;
                };
                //Apply the animation speed
                player setAnimSpeedCoef _coef;
            }
        ] remoteExecCall ["call", _player, _player];
        
        //Wait a bit
        sleep (1.0 / 20.0);
    };
};

 


Drowning

 

There is no death in Bearing, and that's something that can be difficult to achieve.
A few basic approaches exist, such as allowDamage command and the HandleDamage event handler. Those handle most cases where a player might take damage and die, in particular falling from heights.
No weapons or vehicles can spawn in the game, so that takes care of external damage sources.

 

One thing that isn't handled easily is drowning.

 

Rather than allow the player to breathe underwater indefinitely, Bearing will instead let the player almost drown and then 'respawn' them at the most recently interacted Guide.
This handling is done with a loop that runs on the server, created during the EntityRespawned event.
The player should never die but in case they do, and the EntityRespawned event happens again, the loop checks 'alive player' so that the loop can end and clean itself up.

 

The code below deals with player drowning. Pay special attention to the delay interval when updating the player oxygen values:

//Track when the player is drowning so we can respawn them before they properly die

//The rate that oxygen decreases is controlled here, so it can be slower if needed.
//In this case drowning is 70% slower than usual. However, this is slightly offset by the respawn cutoff below.
TMPVAR_DROWNING_OXYGENMULTIPLIER = 0.7;

//The oxygen level at which the player is respawned.
//We don't respawn at 0.0 because the server/client will immediately kill the player in a way we can't control ourselves.
TMPVAR_DROWNING_OXYGENCUTOFF_RESPAWN = 0.1;

//Because we're adjusting the oxygen falloff rate, we need to keep updating the oxygen values on the client and server to match
//We can't update the oxygen for the player constantly, otherwise the game gets confused and will drown the player while they still have plenty of oxygen left
//I'm not sure what is causing this, but the server (or client I can't remember which) will ignore calls to setOxygenRemaining if they are made too frequently
//Cope with it by give some breathing room between calls to update the oxygen - our respawn cutoff isn't 0.0 so it should be okay to have a little wiggle room here
TMPVAR_DROWNING_UPDATEINTERVAL = 0.4;

[_player] spawn
{
    params ["_player"];
    private _oxyRemaining = 1.0;
    private _oxyLast = 1.0;
    private _lastUpdatedOxygen = serverTime;
    
    //Loop while the player is alive
    while {alive _player} do
    {
        private _oxyNow = getOxygenRemaining _player;
        //Do something if the value has changed
        if (_oxyNow != _oxyLast) then {
            //Got more oxygen?
            if (_oxyNow > _oxyLast) then {
                //Yes, immediately restore all the oxygen
                _oxyRemaining = 1.0;
                _oxyNow = 1.0;
                _oxyLast = 1.0;
                //Set maximum oxygen both on the server and the client
                _player setOxygenRemaining 1.0;
                [ _player, 1.0 ] remoteExecCall ["setOxygenRemaining", _player, _player];
            } else {
                //No, deduct the oxygen lost while applying the rate multiplier
                private _lost = (_oxyLast - _oxyNow) * TMPVAR_DROWNING_OXYGENMULTIPLIER;
                _oxyRemaining = _oxyRemaining - _lost;

                //Wait a little between updates
                if (serverTime - _lastUpdatedOxygen > TMPVAR_DROWNING_UPDATEINTERVAL) then {
                    _lastUpdatedOxygen = serverTime;
                    _oxyLast = _oxyRemaining;
                    //Set the new oxygen values on the server and client
                    _player setOxygenRemaining _oxyRemaining;
                    [ _player, _oxyRemaining ] remoteExecCall ["setOxygenRemaining", _player, _player];
                } else {
                    _oxyLast = _oxyNow;
                };

                //Catch when the player drowns and deal with that
                if (_oxyRemaining < TMPVAR_DROWNING_OXYGENCUTOFF_RESPAWN) then
                {
                    //Keep the player from actually drowning by capping the oxygen limit
                    _player setOxygenRemaining TMPVAR_DROWNING_OXYGENCUTOFF_RESPAWN;
                    [ _player, TMPVAR_DROWNING_OXYGENCUTOFF_RESPAWN ] remoteExecCall ["setOxygenRemaining", _player, _player];
                    //Fade the screen to white on the player - we'll remove it shortly
                    ...
                    //Wait a bit for that to kick in
                    uiSleep 1.0;
                    //Force the player oxygen to max on the server so we don't have to worry about it anymore
                    _player setOxygenRemaining 1.0;
                    //Do a few things at once on the client
                    [
                        [], {
                            //Max the oxygen
                            player setOxygenRemaining 1.0;
                            //Reset the suffocation effect on the client so it doesn't get in the way of the respawn effect
                            //Normally this would take a few seconds to clear itself
                            BIS_SuffCC ppEffectAdjust [1, 1, 0, [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]];
                            BIS_SuffCC ppEffectCommit 0.0;
                            BIS_SuffRadialBlur ppEffectAdjust [0.0, 0.0, 0.5, 0.5];
                            BIS_SuffRadialBlur ppEffectCommit 0.0;
                            BIS_SuffBlur ppEffectAdjust [0.0];
                            BIS_SuffBlur ppEffectCommit 0.0;
                            //Remove the white fade we applied via the server
                            ...
                        }
                    ] remoteExecCall ["spawn", _player, _player];
                    //Run the player respawn animation - this puts the player on the water surface
                    ...
                };
            };
        };
        //Wait a bit (there should be enough room in the respawn cutoff level so this pause doesn't kill the player
        uiSleep 0.1;
    };
};


To make sure the player understands what's happening, they are also sent warning notifications at intervals.

//This code runs on the client

//The oxygen level at which the player starts to get warning notifications, and the intervals after that where they get reminders
TMPVAR_DROWNING_OXYGENCUTOFF_WARNINGSTART = 0.5;
TMPVAR_DROWNING_OXYGENCUTOFF_WARNINGINTERVAL = 0.1;

//Init some tracking variables
private _prevOxygen = getOxygenRemaining player;
private _currOxygen = _prevOxygen;

//Loop while the player is alive
while {alive player} do
{
    _currOxygen = getOxygenRemaining player;
    //Only warn when oxygen is going down, and is less than the initial cutoff
    if (_currOxygen < _prevOxygen && _currOxygen <= TMPVAR_DROWNING_OXYGENCUTOFF_WARNINGSTART) then
    {
        //Warn each time the oxygen value crosses the interval
        if (_currOxygen % TMPVAR_DROWNING_OXYGENCUTOFF_WARNINGINTERVAL > _prevOxygen % TMPVAR_DROWNING_OXYGENCUTOFF_WARNINGINTERVAL) then
        {
            //Send the notification
            ... "You are drowning!"
        };
    };
    _prevOxygen = _currOxygen;
    //Wait a bit before looping
    uiSleep 0.5;
};

 

Below is an example of how drowning is handled, including notifications and respawning.

 

Click the image below for a video example

fmQ3wTt.png

  • Like 3

Share this post


Link to post
Share on other sites

Great work on the drowning respawn. It is very cool indeed. 😎

Share this post


Link to post
Share on other sites

Guides

 

Spawning

 

Bearing has 157 individual Guide units, all with their own name, identity and list of available jobs.
Not only that, each Guide has a sitting variant, bringing the total of active server units to 314.
These sitting variants are part of another long-term novelty task, requiring the player to look for a wooden chair within line-of-sight of each Guide.

 

uDoAwct.png

 

Arma 3 struggles to maintain that many active units at once, causing client hitching roughly once a second as unit network updates are processed.
A few alternate methods were tested to reduce this hitching, such as a giving each unit a unique group (which worked until the variants were added).

 

The final implementation, which is not without its own problems, involves spawning units only when players are nearby.
A rough version of the spawning code looks like this:

 

/////////////////////////////////////////////////////////////////////
// NPC creation update loop

//Keep a variable around which will force an update on the next server tick
//This is used when a player teleports a long distance and we don't want to wait
//for the update delay to elapse (which causes NPCs to pop into existence)
TMPVAR_NPCVISIBILITY_FORCEUPDATE = false;
//How long to wait between updates
TMPVAR_NPCVISIBILITY_UPDATEINTERVAL = 10.0;

[] spawn {
    //Run forever
    while {true} do
    {
        TMPVAR_NPCVISIBILITY_FORCEUPDATE = false;
        if (count allPlayers > 0) then
        {
            //Call the NPC creation handling code only if there are players
            ...
        };
        private _time = serverTime + TMPVAR_NPCVISIBILITY_UPDATEINTERVAL;
        //Wait for the delay to expire, or if the force boolean is set
        waitUntil {serverTime > _time || TMPVAR_NPCVISIBILITY_FORCEUPDATE};
    };
};

/////////////////////////////////////////////////////////////////////
// NPC creation handling

//The distance at which the NPC should be created
TMPVAR_NPCVISIBILITY_CREATEDISTANCE = 600;

//Collect all player positions
private _playerPositionsASL = [];
{
    if (alive _x) then { _playerPositionsASL pushBack (getPosASL _x); };
}
forEach allPlayers;

//Split NPC configs into visible and not-visible lists
private _visible = [];
private _notVisible = [];
{
    private _isVisible = false;
    private _npcPosASL = _x # NPCPOSASL;
    {
        if (_npcPosASL distance2D _x < TMPVAR_NPCVISIBILITY_CREATEDISTANCE) exitWith {
            _isVisible = true;
        };
    }
    forEach _playerPositionsASL;

    //Add to the correct list
    if (_isVisible) then { _visible pushBack _forEachIndex; }
    else { _notVisible pushBack _forEachIndex; };
}
forEach NPC_CONFIG;

//Delete everything that's no longer visible
{
    private _npcData = NPC_CONFIG # _x;
    //All unit objects are stored in the config, so we can easily delete anything that's in there
    private _objects = _npcData # NPCOBJECTS;
    if (count _objects > 0) then {
        { deleteVehicle _x; } forEach _objects;
        //Remember to clear the array
        _npcData set [NPCOBJECTS, []];
    };
}
forEach _notVisible;

//Created units are spread across all sides except west (which the players are on)
//This is to reduce hitching that comes from having lots of units on one side
private _sides = [ east, civilian, independent ];

//Create units that are newly visible
{
    private _npcData = NPC_CONFIG # _x;
    private _objects = _npcData # NPCOBJECTS;
    //Only create units if we haven't already
    if (count _objects == 0) then {
        //Start with the group, both unit variants will be assigned to this
        //Remember to delete the group when the unit is deleted
        private _unitGroup = createGroup [_sides # (_forEachIndex % (count _sides)), true];

        //Variants are standing and sitting

        //Start with the standing unit
        private _unitStanding = _unitGroup createUnit [ _npcData # NPCCLASSTYPE, ASLToAGL (_npcData # NPCPOSASL), [], 0, "NONE" ];
        //Stop the AI from wandering around
        _unitStanding disableAI "ALL";
        _unitStanding enableAI "ANIM";
        //Try to make the unit stop talking (doesn't always work)
        _unitStanding disableConversation true;
        _unitStanding setSpeaker "NoVoice";

        //Apply any loadout changes we have for this NPC (such as unique headgear, etc)
        //NOTE: Don't apply uniform elements individually because we get server warnings about
        //missing objects - use setUnitLoadout instead to apply it all at once
        
        //Get the current loadout, which is the base class loadout, and create an empty one
        private _currentLoadout = getUnitLoadout _unit;
        private _newLoadout = [[],[],[],[],[],[],"","",[],["","","","","",""]];
        //Copy the uniform
        if (count (_currentLoadout # 3) > 0) then {
            _newLoadout set [3, [(_currentLoadout # 3) # 0, []]];
        };
        //Set anything else (currently only headgear, but can be expanded to googles)
        private _headgearType = _npcData # NPCHEADGEARTYPE;
        if (count _headgearType > 0) then {
            _newLoadout set [6, _headgearType];
        };

        //Set anything remaining
        _unitStanding setDir (_npcData # NPCFACING);
        _unitStanding setPosASL (_npcData # NPCPOSASL);
        _unitStanding setIdentity (_npcData # NPCIDENTITY);
        //Make invincible
        _unitStanding allowDamage false;
        //Finally, apply the loadout
        _unitStanding setUnitLoadout _newLoadout;

        //Now we need to create the sitting variant
        
        //Begin with creating a chair for the unit to sit on
        private _chairObj = "Land_ChairWood_F" createVehicle (_npcData # NPCPOSASL);
        //NOTE: The model for this particular chair is facing in the wrong direction
        //so we need to turn it around to line up with the sitting animation
        _chairObj setDir (180 + (_npcData # NPCFACING));
        _chairObj setPosASL (_npcData # NPCPOSASL);

        //We need to create a marker to help seat the NPC
        //The vertical offset is tuned for use with Land_ChairWood_F
        private _markerPosASL = _chairObj modelToWorldWorld [0, 0, 0.55];
        private _markerObj = "Sign_Arrow_Blue_F" createVehicle [0, 0, 0];
        _markerObj setDir _facing;
        _markerObj setPosASL _markerPosASL;

        //Create the sitting unit, reusing most of the detail from the standing variant
        private _unitSitting = _unitGroup createUnit [ _npcData # NPCCLASSTYPE, ASLToAGL (_npcData # NPCPOSASL), [], 0, "NONE" ];
        //NOTE: Set the loadout early or it might not appear before the sitting animation plays
        _unitSitting setUnitLoadout _newLoadout;
        _unitSitting disableAI "ALL";
        _unitSitting enableAI "ANIM";
        _unitSitting disableConversation true;
        _unitSitting setSpeaker "NoVoice";
        _unitSitting setPosASL (_npcData # NPCPOSASL);
        _unitSitting setIdentity (_npcData # NPCIDENTITY);
        _unitSitting allowDamage false;

        //Sit the unit on the marker, not the chair
        [_unitSitting, "SIT", "ASIS", _markerObj] remoteExecCall ["BIS_fnc_ambientAnim", 0, true];

        //Force everything to hidden while we're here
        _unitStanding hideObjectGlobal true;
        _unitSitting hideObjectGlobal true;
        _chairObj hideObjectGlobal true;
        _markerObj hideObjectGlobal true;

        //Store all the objects for easier deletion later
        _npcData set [NPCOBJECTS,
            [_unitStanding, _unitSitting, _chairObj, _markerObj]
        ];
        
        _newUnitsCreated = true;
    };
}
forEach _visible;

 

A few unexplained issues with this system are:

  • NPC will sometimes (quietly) speak out target positions, even when told not to by using ' NoVoice' via setSpeaker and their Identity
  • Some NPCs will not animate after spawning, while others are okay. Calling setIdentity multiple times on a unit also seems to cause this

 

 

Animations

 

Once the NPCs have been created, they need to be revealed to the player. To avoid some issues with starting animations being incorrect, the distance for reveal (500m) is shorter than creation (600m).
Unfortunately, the player can warp around during a respawn and immediately reveal the NPC, leading to problems.


The code below helps to hide the units until their animations are in a suitable state:

//Called for both the sitting and standing NPCs
if (isObjectHidden _unit) then {
    [_unit, _chair] spawn {
        params ["_unit", "_chair"];
        //Don't reveal any NPC until it has finished getting to the correct animation
        if (!(isNil "_chair")) then {
            //NOTE: This can bug out as soon as we reveal the NPC and make the NPC unconscious for a second,
            //so we catch that further below by rehiding the NPC and chair
            waitUntil {
                private _animation = animationState _unit;
                (
                    (_unit distance2D _chair < 0.1) &&
                    {
                        _animation isEqualTo "hubsittingchairb_idle1" ||
                        _animation isEqualTo "hubsittingchairb_idle2" ||
                        _animation isEqualTo "hubsittingchairb_idle3" ||
                        _animation isEqualTo "hubsittingchairb_move1"
                    }
                );
            };
            //Wait for a bit because there's some amount of position warping (that we can't really test for)
            sleep 0.5;
        } else {
            //NPCs are expected to spawn with weapons, so they will always play a holstering animation in Bearing
            //Wait until that's finished and they're in the idle animation
            waitUntil {
                (animationState _unit) isEqualTo "amovpercmstpsnonwnondnon";
            };
        };

        //Reveal the NPC
        _unit enableDynamicSimulation true;
        _unit hideObject false;

        if (!(isNil "_chair")) then {
            //Reveal the chair
            _chair enableDynamicSimulation true;
            _chair hideObject false;

            //Create a loop to catch if the NPC was made unconscious and hide them until they're not
            //This needs to be in a spawn so the state has enough time to change to unconscious
            [_unit, _chair] spawn {
                params ["_unit", "_chair"];
                if ((animationState _unit) isEqualTo "unconscious") then {
                    _unit hideObject true;
                    _chair hideObject true;
                    waitUntil {
                        !((animationState _unit) isEqualTo "unconscious");
                    };
                    _unit hideObject false;
                    _chair hideObject false;
                };
            };
        };
    };
};

 

 

Collisions

 

As shown above, Guides are made invincible so they're always available. We also need to make sure that players can't nudge them around.
This is handled by disabling collisions between players and Guides, and using an additional loop which regularly checks the Guide position and moves them back.


Collisions between players is done to prevent players being able to block doorways, etc. This is also why all doors on the island are locked in the open position.

 

The collision handling loop is shown below, along with reference to an engine issue:

///////////////////////////////////////////////////////////////////
// Update loop for player VS player and player VS NPC collision
// NOTE: There are issues with disableCollisionWith, there seems to be an upper limit
// on how many things can be disabled at once - https://feedback.bistudio.com/T66204
// Because of this, we just apply as many calls to disableCollisionWith as we can, making sure
// the NPC ones are last because they're the most important

//How often we update collisions
TMPVAR_PLAYER_COLLISION_UPDATE_INTERVAL = 10.0;
//How close does the player need to be to NPCs and other players for collisions to be disabled
TMPVAR_PLAYER_COLLISION_UPDATE_DISTANCE = 50.0;

[] spawn {
    while {true} do
    {
        if (allPlayers > 0) then
        {
            //Loop all players
            //NOTE: No JIP on these remoteExecCall's as this is called frequently
            {
                private _activePlayer = _x;
                //Only if still alive
                if (alive _activePlayer) then
                {
                    //Against other players
                    //Avoid duplicate calls by only comparing with players later in the list
                    for "_i" from (_forEachIndex + 1) to ((count allPlayers) - 1) do {
                        private _otherPlayer = allPlayers # _i;
                        if (alive _otherPlayer && _otherPlayer distance2D _activePlayer < TMPVAR_PLAYER_COLLISION_UPDATE_DISTANCE) then {
                            [_otherPlayer, _activePlayer] remoteExecCall ["disableCollisionWith", _otherPlayer];
                            [_otherPlayer, _activePlayer] remoteExecCall ["disableCollisionWith", _activePlayer];
                        };
                    };

                    //Against NPCs
                    {
                        private _objects = _x # BRGD_NPCDATA_OBJECTS;
                        if (count _objects > 0) then {
                            {
                                _x disableCollisionWith _activePlayer;
                                [_activePlayer, _x] remoteExecCall ["disableCollisionWith", _activePlayer];
                            }
                            //We only need to disable collisions with the standing unit and the chair
                            forEach [
                                _objects # NPCOBJECT_UNITSTANDING,
                                _objects # NPCOBJECT_CHAIR
                            ];
                        };
                    }
                    //Gather NPCs that are close enough
                    forEach (NPC_CONFIG select {(_x # NPCPOSASL) distance2D _activePlayer < TMPVAR_PLAYER_COLLISION_UPDATE_DISTANCE});
                };
            }
            forEach allPlayers;
        };

        sleep TMPVAR_PLAYER_COLLISION_UPDATE_INTERVAL;
    };
};

 

 

Guide Hints

 

Interacting with Guides will unlock information about their neighbouring colleagues.
This information is an approximate position, shown as a circular map hint, and a photo view to help guide players to the area they can be found in.

 

OZyLjWt.png

 

8CbbfnO.png

 

The images above show two examples of Guide hints. Both photo hints show that each guide is close to water, and helps to rule out a large portion of the map hint.

 

These steps were added to make the process of finding Guides more interesting, rather than just provide exact locations directly to the player.

 

Importantly, the photo views needed to both be close enough to the Guide and provide enough clues in the image to help pinpoint their location.
All Guides had been placed on the island before photo views were added, and meant that some of them had to be re-positioned because their original locations were too generic for a suitable photo view to be found.

 

Ultimately, the addition of Guide hints and chair hunting is meant to make Bearing more fun to play.

It also strengthens the core goal of the mod, by getting the player to look around the island and absorb more map knowledge.

  • Like 3

Share this post


Link to post
Share on other sites

World Views

 

Intro

 

One of my favourite features of the 3den editor is that each new SQM places the camera at a random location.
I thought this was a great way to see new parts of the island, and I wanted to have something similar in Bearing.

 

Early ideas started with an idle animation - if a player stood still for too long, a series of locations would be shown until the player was ready to start playing again.
Eventually this was later restructured as an intro feature, to be shown while the game was loading in a connecting player.

There are 188 world views that can be shown during the intro.

 

After a while, the WorldView system was redesigned to be generic and usable by other features in the game.
It was also one of the first large pieces of SQL written for Bearing, so the code is a lot sloppier than I would like.

 

The system is structured as follows:

  • A series of (1 or more) camera views are gathered, each consisting of a position and a facing direction
  • These are submitted to the WorldView system, which creates a new camera view on the player
  • The view order is shuffled, and then displayed one at a time
  • After all views are seen, the order is again reshuffled and the system starts over
  • A number of scripts can also be submitted to the system, which run at certain times:
    • loginCode - runs when the system starts, currently only used by the login process to preload PAAs and show header/footer text
    • inputCode - without this, the WorldView can never be closed. Listens for set conditions (variables changed, keys pressed, etc) and run the close code
    • closeCode - prep for any upcoming features, and showing the 'BEARING' splash notification when the player gains control after login
  • Views can optionally have a slight camera sway added to them
  • Cinema borders are also manually implemented, because the in-engine ones vary in size and are difficult to align text on top of

 

Views also come with view distances, which override the default camera/shadow view distances. These are disabled, however, because switching distances causes the client to stutter as it recalculates various things that rely on them.

Some views had to be removed form the list because they only had terrain in the far distance, which would be hidden by a default view distance.

 

The camera sway was introduced as a permanent feature to add some parallax to the intro views, and feels slightly more natural than a fixed camera position.

 

Click the image below for a video example

e3ggYvn.png

 

 

Photo hints

 

Guide photo hints were added before the WorldView system was written.
At that stage, I had included 157 JPGs in the mod, which would be shown to the player as a static 'photo' image.
This made the mod download gigantic (and polluted my Git repo with a 1GB+ PSD file).

 

With the WorldView system now available, I revisited all photo locations, matching the positions and directions, and assigned those view values to each Guide.
The WorldView system was tweaked to allow for a single view, with no camera sway, and that was used to present photos to players.

 

Click the image below for a video example

IcTiWWJ.png

 

 

Bench views

 

As mentioned in an earlier post, Bearing features a mechanic which increases the player's maximum run speed by completing Jobs.
While not mentioned directly, there is another way to do this - by resting at specific benches dotted around the island.

 

These benches also make use of the WorldView system to show off pleasant views of the surrounding area. This took the place of the idle animation feature mentioned at the start of this post (although that may still return).
Resting at a bench for long enough, again not referenced anywhere, will grant the player a 30 minute run speed boost.

 

Views chosen for benches were tricky to select, as I had decided there should be 10 views for each of them.
This meant that benches were often moved around until a suitably varied area could be found, and meant I had to make use of a lot of landscape and distant views.

 

Click the image below for a video example

zGRuCuM.png

 

 

Code


The script below details the bulk of how the WorldView system works. Again, it's early code, so apologies in advance.

//////////////////////////////////////////////
// Extremely messy code below

params ["_worldViews", "_isLogin", "_loginCode", "_swayData", "_showCinemaBorders", "_startFadeDuration", "_startWaitDuration", "_worldViewWaitDuration", "_worldViewTitleFadeDuration", "_closeCode"];

if (_isLogin) then {
	//Execute the login code
	//This is usually preloading PAA images
	[] call _loginCode;
};

//Various common functions used in the main loop

//Aligns text vertically to the upper/lower cinema borders
FUNC_REALIGNCTRL = {
	params ["_ctrl"];
	private _ctrlOldSize = ctrlPosition _ctrl;
	[_ctrl, 0, 0] call BIS_fnc_ctrlFitToTextHeight;
	private _ctrlNewSize = ctrlPosition _ctrl;
	_ctrlNewSize set [1, (_ctrlOldSize # 1) + (((_ctrlOldSize # 3) - (_ctrlNewSize # 3)) / 2)];
	_ctrl ctrlSetPosition _ctrlNewSize;
	_ctrl ctrlCommit 0.0;
};

FUNC_STOPLOOPCODEMOUSE = {
	if (isGameFocused) then {
		player setVariable ["TMPVAR_WORLDVIEW_STOP", 1];
	};
};

//Only used in non-login views
//Checks for Spacebar and Tab keydowns
FUNC_STOPLOOPCODEKEY = {
	params ["_disp", "_key", "_shift", "_ctrl", "_alt"];
	if (!(_key in (player getVariable "TMPVAR_WORLDVIEW_KEYSDOWN"))) then {
		if (_key == DIKCODE_SPACE || _key == DIKCODE_TAB) then {
			player setVariable ["TMPVAR_WORLDVIEW_STOP", 1];
		};
	};
};
FUNC_KEYUPCHECKCODE = {
	params ["_disp", "_key", "_shift", "_ctrl", "_alt"];
	player setVariable ["TMPVAR_WORLDVIEW_KEYSDOWN", (player getVariable "TMPVAR_WORLDVIEW_KEYSDOWN") - [_key]];
};

FUNC_SETCAMERAVIEW = {
	params ["_globalCamera", "_viewData"];
	if (count _viewData == 3) then {
		_viewData params ["_camPos", "_camDir", "_camViewDistances"];

		_globalCamera setPos _camPos;
		player setVariable ["TMPVAR_WORLDVIEW_CAMERA_POSITION", _camPos];
		//Set the look vector now
		//_camDir can be a direction or a vector
		if (count _camDir == 1) then {
			//Direction
			_globalCamera setVectorDirAndUp [
				vectorNormalized ([[1, 0, 0], (_camDir # 0)] call BIS_fnc_rotateVector2D),
				[0, 0, 1]
			];
		} else {
			//Vector
			_globalCamera setVectorDirAndUp [vectorNormalized _camDir, [0, 0, 1]];
		};
		//Set the view distances
		//NOTE: This is disabled, because it causes the client to stutter for a short period of time
		//setViewDistance (_camViewDistances # 0);
		//setObjectViewDistance [_camViewDistances # 1, getObjectViewDistance # 1];
	} else {
		_viewData params ["_camPos", "_camVecDir", "_camUpDir", "_camFOV"];

		_globalCamera setPos _camPos;
		player setVariable ["TMPVAR_WORLDVIEW_CAMERA_POSITION", _camPos];
		_globalCamera setVectorDirAndUp [_camVecDir, _camUpDir];
		_globalCamera camSetFov _camFOV;
	};
};

FUNC_UPDATECAMERASWAY = {
	params ["_thisTime", "_globalCamera", "_swayData"];
	(player getVariable "TMPVAR_WORLDVIEW_CAMERA_SWAYDATA") params [
		"_swayTimeStart", "_swayTimeEnd",
		"_swayFromX", "_swayFromY", "_swayFromZ",
		"_swayToX", "_swayToY", "_swayToZ"
	];
	_swayData params ["_swapDurationMax", "_swapDurationMin", "_swapRadiusMax", "_swapEasePower"];
	if (_thisTime > _swayTimeEnd) then
	{
		_swayTimeStart = _swayTimeEnd;
		_swayTimeEnd = _swayTimeStart + (_swapDurationMin + ((random 1.0) * (_swapDurationMax - _swapDurationMin)));

		_swayFromX = _swayToX;
		_swayFromY = _swayToY;
		_swayFromZ = _swayToZ;

		private _swayToPos = [] call FUNC_GETRANDOMPOINTINUNITSPHERE;
		_swayToX = (_swayToPos # 0) * _swapRadiusMax;
		_swayToY = (_swayToPos # 1) * _swapRadiusMax;
		_swayToZ = (_swayToPos # 2) * _swapRadiusMax;
	};
	//Work out the sway offset
	private _swayRatio = linearConversion [_swayTimeStart, _swayTimeEnd, _thisTime, 0, 1, true];
	_swayRatio = [_swayRatio, _swapEasePower] call FUNC_GETEASEINOUT;
	private _swayOffset = [
		linearConversion [0, 1, _swayRatio, _swayFromX, _swayToX],
		linearConversion [0, 1, _swayRatio, _swayFromY, _swayToY],
		linearConversion [0, 1, _swayRatio, _swayFromZ, _swayToZ]
	];

	//Set the new camera position
	_globalCamera setPos ((player getVariable "TMPVAR_WORLDVIEW_CAMERA_POSITION") vectorAdd _swayOffset);
	//Replace sway data
	player setVariable [
		"TMPVAR_WORLDVIEW_CAMERA_SWAYDATA",
		[
			_swayTimeStart, _swayTimeEnd,
			_swayFromX, _swayFromY, _swayFromZ,
			_swayToX, _swayToY, _swayToZ
		]
	];
};

//Do the initial fade
titleText ["", "BLACK", _startFadeDuration];
uiSleep _startWaitDuration;

TMPVAR_WORLDVIEW_CANSTOP = false;

private _allowedToStop = false;
private _stopWorldView = false;

private _lastTime = time;
private _worldViewChangeStart = _lastTime + _worldViewWaitDuration + _worldViewTitleFadeDuration;
private _worldViewChangeEnd = _worldViewChangeStart + _worldViewTitleFadeDuration;
//Store the next index we'll be going to here
private _worldViewNextIndex = floor (random (count _worldViews));
//We use that to preload the next location while we look at the current one
//Preload early, while we can
preloadCamera ((_worldViews # _worldViewNextIndex) # 0);

private _swayTimeStart = _lastTime;
private _swayTimeEnd = _lastTime;
private _swayFromX = 0;
private _swayFromY = 0;
private _swayFromZ = 0;
private _swayToX = 0;
private _swayToY = 0;
private _swayToZ = 0;

_lastTime = _lastTime - 0.1; //Go back a little so things kick off

//Run this to get rid of the title
titleText ["", "BLACK IN", 0.0001];
//Show the overlay, starts with a black screen
("WORLDVIEW_LAYERNAME" call BIS_fnc_rscLayer) cutRsc ["WORLDVIEW_CLASSNAME", "PLAIN", -1, false];
//Get the display and the controls
private _disp = uiNamespace getVariable "TMPVAR_WORLDVIEW_VARNAME";
private _ctrlTopBar = _disp displayCtrl TMPVAR_WORLDVIEW_IDC_BARTOP;
private _ctrlBottomBar = _disp displayCtrl TMPVAR_WORLDVIEW_IDC_BARBOTTOM;
private _ctrlTop = _disp displayCtrl TMPVAR_WORLDVIEW_IDC_TEXTTOP;
private _ctrlBottom = _disp displayCtrl TMPVAR_WORLDVIEW_IDC_TEXTBOTTOM;
private _ctrlBlack = _disp displayCtrl TMPVAR_WORLDVIEW_IDC_BG;
private _mouseEventHandlerID = -1;
private _keyUpEventHandlerID = -1;
private _keyDownEventHandlerID = -1;

//Set the variable that keeps the loop running
player setVariable ["TMPVAR_WORLDVIEW_STOP", 0];

//Replace the player camera with one we can move around
private _globalCamera = missionNamespace getVariable "TMPVAR_INIT_CAMERA";
if (isNil "_globalCamera") then {
	_globalCamera = "camera" camCreate [0, 0, 0];
	_globalCamera cameraEffect ["internal", "back"];
	missionNamespace setVariable ["TMPVAR_INIT_CAMERA", _globalCamera];
};

//Hide the engine camera border so we can supply our own and be certain of how tall it is
showCinemaBorder false;

//Set the sway variables
player setVariable ["TMPVAR_WORLDVIEW_CAMERA_SWAYDATA", [time, time, 0, 0, 0, 0, 0, 0]];
player setVariable ["TMPVAR_WORLDVIEW_CAMERA_POSITION", (_worldViews # _worldViewNextIndex) # 0];

//Only add text if world view is opened for login
if (_isLogin) then
{
	_ctrlTop ctrlSetStructuredText (parseText "<t>  BEARING</t>");
	_ctrlTop call FUNC_REALIGNCTRL;
	_ctrlBottom ctrlSetStructuredText (parseText "<t>Loading player data...  </t>");
	_ctrlBottom call FUNC_REALIGNCTRL;
	//Show the bars by default if logging in
	_ctrlTopBar ctrlShow true;
	_ctrlBottomBar ctrlShow true;
}
else
{
	_ctrlTop ctrlShow false;
	_ctrlBottom ctrlShow false;
	//Immediately set as ready to stop
	TMPVAR_WORLDVIEW_CANSTOP = true;
	//Show the bars if requested
	_ctrlTopBar ctrlShow _showCinemaBorders;
	_ctrlBottomBar ctrlShow _showCinemaBorders;
};

//Kick off the first fade
_ctrlBlack ctrlSetFade 1.0;
_ctrlBlack ctrlSetPosition (ctrlPosition _ctrlBlack);
_ctrlBlack ctrlCommit _worldViewTitleFadeDuration;

//Keep track of which views we've seen
//Views are moved into this array to make randomly selecting the next one simpler
private _worldViewsSeen = [];

//Set the first view so the vector gets applied
private _startViewData = _worldViews # _worldViewNextIndex;
[_globalCamera, _startViewData] call FUNC_SETCAMERAVIEW;
//Also remove the view from the list
_worldViewsSeen pushBack _startViewData;
_worldViews deleteAt _worldViewNextIndex;
//And get the next view we want
_worldViewNextIndex = floor (random (count _worldViews));

//Loop until we're told to stop
while {true} do
{
	if (!_allowedToStop && TMPVAR_WORLDVIEW_CANSTOP) then
	{
		_allowedToStop = true;
		if (_isLogin) then
		{
			_ctrlBottom ctrlSetStructuredText (parseText "<t>Click to continue  </t>");
		};
		private _dispMain = findDisplay 46;
		_mouseEventHandlerID = _dispMain displayAddEventHandler ["MouseButtonDown", FUNC_STOPLOOPCODEMOUSE];
		if (!_isLogin) then {
			private _keysDown = [];
			if (([DIKCODE_TAB] call FUNC_KEYTRACK_KEYSTATE) == 1) then { _keysDown pushBack DIKCODE_TAB; };
			if (([DIKCODE_SPACE] call FUNC_KEYTRACK_KEYSTATE) == 1) then { _keysDown pushBack DIKCODE_SPACE; };
			player setVariable ["TMPVAR_WORLDVIEW_KEYSDOWN", _keysDown];
			_keyDownEventHandlerID = _dispMain displayAddEventHandler ["KeyDown", FUNC_STOPLOOPCODEKEY];
			_keyUpEventHandlerID = _dispMain displayAddEventHandler ["KeyUp", FUNC_KEYUPCHECKCODE];
		};
	};

	//Exit the loop when asked
	if (player getVariable "TMPVAR_WORLDVIEW_STOP" == 1) exitWith
	{
		private _dispMain = findDisplay 46;
		_dispMain displayRemoveEventHandler ["MouseButtonDown", _mouseEventHandlerID];
		if (!_isLogin) then {
			_dispMain displayRemoveEventHandler ["KeyDown", _keyDownEventHandlerID];
			_dispMain displayRemoveEventHandler ["KeyUp", _keyUpEventHandlerID];
		};
		_stopWorldView = true;
		//Swap the text over if we're logging in
		if (_isLogin) then {
			_ctrlBottom ctrlSetStructuredText (parseText "<t>Starting...  </t>");
		};
	};

	_thisTime = time;

	//Handle photo transitions
	if (_lastTime < _worldViewChangeEnd && _thisTime >= _worldViewChangeEnd) then
	{
		//Get the next view and move to the seen array
		private _viewData = _worldViews # _worldViewNextIndex;
		_worldViewsSeen pushBack _viewData;
		_worldViews deleteAt _worldViewNextIndex;
		//If we're out of elements then swap the arrays and randomise
		if (count _worldViews == 0) then
		{
			//Swap back the seen views in a randomised order
			_worldViews = _worldViewsSeen call BIS_fnc_arrayShuffle;
			_worldViewsSeen = [];
		};
		
		[_globalCamera, _viewData] call FUNC_SETCAMERAVIEW;
		
		//Set the new target times
		_worldViewChangeStart = _thisTime + _worldViewWaitDuration + _worldViewTitleFadeDuration;
		_worldViewChangeEnd = _worldViewChangeStart + _worldViewTitleFadeDuration;
		//Start the fade
		_ctrlBlack ctrlSetFade 1.0;
		_ctrlBlack ctrlSetPosition (ctrlPosition _ctrlBlack);
		_ctrlBlack ctrlCommit _worldViewTitleFadeDuration;

		//Get the next place we want to go to and preload it
		_worldViewNextIndex = floor (random (count _worldViews));
		preloadCamera ((_worldViews # _worldViewNextIndex) # 0);
	}
	else
	{
		//Do we want to start fading out?
		if (_lastTime < _worldViewChangeStart && _thisTime >= _worldViewChangeStart) then
		{
			//Yes, adjust the time to fit how long we have left so that the camera
			//doesn't move before the fade has finished
			_ctrlBlack ctrlSetFade 0.0;
			_ctrlBlack ctrlSetPosition (ctrlPosition _ctrlBlack);
			_ctrlBlack ctrlCommit (_worldViewChangeEnd - _thisTime);
		};
		
		//Keep trying to preload the next place we'll be going to
		preloadCamera ((_worldViews # _worldViewNextIndex) # 0);
	};

	//Handle camera sway
	if (count _swayData > 0) then
	{
		[_thisTime, _globalCamera, _swayData] call FUNC_UPDATECAMERASWAY;
	};

	//Wait a bit
	_lastTime = _thisTime;
	uiSleep (1.0 / 60.0);
};

//Clear out the variables
TMPVAR_WORLDVIEW_CANSTOP = false;
player setVariable ["TMPVAR_WORLDVIEW_STOP", nil];
player setVariable ["TMPVAR_WORLDVIEW_KEYSDOWN", nil];

//Fade out before we switch cameras
_ctrlBlack ctrlSetFade 0.0;
_ctrlBlack ctrlSetPosition (ctrlPosition _ctrlBlack);
_ctrlBlack ctrlCommit _worldViewTitleFadeDuration;

//Keep swaying during the fade, if we need to
if (count _swayData > 0) then
{
	_thisTime = time + _worldViewTitleFadeDuration;
	while {time < _thisTime} do
	{
		[time, _globalCamera, _swayData] call FUNC_UPDATECAMERASWAY;
		uiSleep (1.0 / 60.0);
	};
} else {
	//Otherwise just wait for the fade duration
	uiSleep _worldViewTitleFadeDuration;
};

//Remove the overlay
("WORLDVIEW_LAYERNAME" call BIS_fnc_rscLayer) cutRsc ["Default", "PLAIN", -1, false];

//Put the view distances back
[CLIENTSETTINGS_VIEWDISTANCE_ID, false] call FUNC_CLIENTSETTINGS_LOAD;
[CLIENTSETTINGS_OBJECTVIEWDISTANCE_ID, false] call FUNC_CLIENTSETTINGS_LOAD;

//Reset the player camera and delete the one we created
player cameraEffect ["terminate", "back"];
camDestroy _globalCamera;
missionNamespace setVariable ["TMPVAR_INIT_CAMERA", nil];

player setVariable ["TMPVAR_WORLDVIEW_CAMERA_SWAYDATA", nil];
player setVariable ["TMPVAR_WORLDVIEW_CAMERA_POSITION", nil];

//And finally fade back in
titleText ["", "BLACK IN", _worldViewTitleFadeDuration];
uiSleep _worldViewTitleFadeDuration;

//Run the close code
[] call _closeCode;

 

  • Like 1

Share this post


Link to post
Share on other sites
On 2/6/2021 at 3:00 PM, im_an_engineer said:

Arma 3's list of objects doesn't have anything like that, so during development I needed to pick something else that would temporarily do the job.

 

I'm way behind on this thread. For some reason not getting notified on new messages. I believe I used this flag in my old old orienteering missions way back when. I believe you can use any graphic and replace the texture of the flag. Give it a search and if you can't find anything easy to do this I'll see if I can dig into my old code. If I can find it!!!

Share this post


Link to post
Share on other sites

Jobs

 

As mentioned before in the Markers and addActions post, the Jobs system began as an orienteering time trial.
Every trial was against the clock, and the player would lose a point if they failed to find all markers and return in time.

 

So what should happen if the player wanted to try again?
A real orienteering trial is a one-shot competition against others, so giving players the exact same markers to find on a 2nd attempt felt like the wrong thing to do.

 

The eventual decision was to make each trial (and later on each Job) be unique, with a fresh selection of markers each time.
This was also the root of why Bearing took 4 years to reach its alpha release, among various other reasons.

 


Getting started in 2016

 

Before I started prototyping the various ways of populating a list of markers to find, I already felt that randomly placing markers in the world was the wrong approach.

 

Technically this would be straight-forward:

  • randomly select a position on the ground
  • run various tests to make sure nothing intersects with the position, like a tree or building
  • repeat until the desired number of markers have been placed

 
The problem is that it becomes very obvious to the player that these positions are randomised and, if you're anything like me, find that the gameplay experience is immediately devalued.

Additionally, it would make placing markers inside buildings with multiple floors tricky to do well.

 

Curated marker positions, while requiring much more time and effort, allow for markers that feel more naturally placed.
This is a closely akin to the process of placing secrets in game worlds.

 

My go-to example for well-curated secrets is GTA: Vice City's Hidden Packages. They can be found in places, such as alleyways and behind buildings, that feel like something hidden belongs there.
It is very satisfying to suspect something is hidden in a location, and then finding it there - almost as if you are on the same thought wavelength as the level designer.

 

Tr0pmOq.jpg

 

 

Adding curated markers is extremely time consuming, and selecting just a few from a random list meant that a wide coverage of markers would be needed.
The processes of positioning them is detailed later in this post.

 

This still left the problem of how to select the markers to present to the player, which would be better addressed much later in development.
To start with, marker selection was kept simple so that playtesting could start early.

 


Selecting markers

 

The first set of 6 trials were created to determine whether the trials would be fun, and tested a variety of marker selection methods:

  • fixed list - all markers are the same when trials are replayed
  • marker groups - one marker is picked from each group
  • random list - a large number of markers, randomly selected from

 
za4NtuQ.png

 

TwFzgd6.png

 

YmA4Ux3.png

 

 

The fixed list version was used for the first 3 tutorial trials only, some of which were carried over to Bearing's job system tutorial.

 

After some experimentation, the marker groups option was discarded. It was a lot of work to group markers in the 3DEN editor, especially once the marker count went above double-digits.
Additionally, if the number of markers in a trial needed to be changed it then required the groups to be scrapped and reorganised completely.
I would come back to marker groups later, in the much simpler to manage form of voronoi groups.

 

What remained was trials being randomly generated from a large list of markers, which was tested and replayed enough to know the system would work well (but not play well, as I found later).

 


Markers and areas

 

Markers are added as Sign_Arrow_Yellow_F objects to an SQM file, grouped into layers by trial ID and numbered sequentially for debugging purposes.

 

fNjBUy6.jpg

 

 

This SQM is then processed, extracting all the marker positions for each trial, so that the data could be provided as arrays of position vectors.

 

Trials were initially tested as a simple scattering of markers, shown on the main map as numbers and using the placeholder camping lamp (as shown in the Markers and addActions post).
During testing, I felt it might be easy to stray out of the play area and search for markers where they couldn't be. It wasn't clear that each trial was restricted to a specific area, and that they never overlapped.
To remedy this, play area boundaries were added to the test jobs.

 

 

   - 1 -   GSNHUPy.jpg   - 2 -   fEZic1R.jpg   - 3 -   Bn4N8eP.jpg   - 4 -   wEsRIUk.jpg

 

1. Markers defining the play area boundary

2. In-game markers without a boundary

3. In-game markers with a boundary

4. Boundary shown in Job preview

 

 

Area outlines were shown on the in-game map during the trial, and also in a pre-trial preview dialog so that players would have a general idea of the area they might have to search.
They were also shown in-game as boundary 'fences', drawn with drawLine3D, that appeared as the player approached them.

 

Click the image below for a video example

WIlD1bt.jpg

 

 

The in-game lines were eventually removed, as they felt too 'gamey', looked terrible, and performed badly.
They also conflicted with the design rule to avoid HUD or in-game elements that felt unrealistic (or unauthentic, depending on your stance).
The player should ideally be able to understand if they are inside the play area by looking at their map so, even though during testing it felt awkward, I went back to having no area indicators.

 

The tech behind rendering boundary lines can still be found when using the Survey system and leaving the wander distance (see the earlier post on Survey Mode's 'Wander distance' section).
This breaks the realism rule, but I couldn't think of anything better at the time and haven't felt like addressing it since then.

 


18 months

 

Once I'd proved that the trial system functioned well enough, I got started on creating trials for the rest of the island.
This took 1.5 years to finish.

 

By degrees this basic time trial mod turned into a much larger beast. Trials became Jobs, NPCs became Guides, and much more.
The bulk of that time, however, was spent manually adding arrow objects to Altis. The repetitive process became zen-like after a while, but there was still plenty of downtime as it was also very draining.
I would often take week-long breaks doing nothing for the mod, or prototyping other features like Chat, Transport and map fog.

 

Some stats:

  • 18 months
  • 157 guides
  • ~780 jobs
  • 294320 markers

 

   - 1 -   E4Vvc2N.jpg   - 2 -   oLFQUe1.jpg   - 3 -   X261ngb.jpg

 

1. All Job areas on Altis

2. Large areas across fields

3. Smaller areas in towns

 

 

Unfortunately, the 3DEN Editor struggles with SQMs containing many thousands of marker arrow objects; specifically the Entities tree view, which must be kept folded at all times.
This meant that the Jobs had to be split across multiple SQM files, organised alphabetically by Guide location, resulting in 28 individual files.

 

C9pMjXN.png

 

 

Extra note: 3DEN parses SQMs when browsing through folders the Scenario->Open... dialog. The marker SQMs are so large that it takes 15+ seconds to even look at the files in that folder.

 

The editor also had to be restarted frequently (or simply reopen the active SQM) to clear the undo history, as this would gradually impact the editor performance.

 


Buildings and marker presets

 

Adding new marker objects is very straight-forward. Copy an existing object, then Ctrl+V will place a new one on the ground where the mouse pointer is.
This was great for outdoor areas, with the camera angled downward, but became an issue when adding markers to buildings.

 

Context switching between placing outdoor markers and building markers was frustrating, with both requiring a different approach to camera placement and placement logic.
When tackling a new Job area, I would either start with the buildings or leave them all to the end.

 

Click the image below for a video example

wXHn6sb.jpg

 

 

After a few months, and several hundred buildings, I spent some time trying to streamline the process of adding markers to buildings.
This led to the marker preset system, where I pre-defined several groups of markers within each possible building classtype on the map.
These presets are stored in an SQM and exported to a data file for a tools DLL to read from.

 

qYTaOVn.jpg

 

 

By holding the mouse cursor over a building and pressing Ctrl+1, the following would happen:

  • the building classtype would be determined
  • the correct marker preset, if any, would be found
  • a random marker would be chosen from each group layer in the preset (usually 2 markers per-layer)
  • all of these chosen markers would then be created in the editor

 

Below is a simplified version of the script that runs in Bearing:

createMarkerAtPosition = {
	params ["_pos", "_markerPrefix", "_parentLayer"];
	private _entity = create3DENEntity ["Object", "Sign_Arrow_Yellow_F", _pos];
	private _markerCount = count (get3DENLayerEntities _parentLayer);
	while {((_entity get3DENAttribute "Name") select 0) == ""} do
	{
		_markerCount = _markerCount + 1; //This should make sure that markers start at 1
		_entity set3DENAttribute ["Name", format ["%1%2", _markerPrefix, _markerCount]];
	};
	_entity set3DENLayer _parentLayer;
	_entity setPosATL _pos;
};

//Get the layer we are adding the markers to
//This should be selected and start with MarkerGroup_
private _layers = get3DENSelected "layer";
//Check selection
if (count _layers == 0) exitwith { systemChat "No core layer selected"; };
if (count _layers > 1) exitwith { systemChat "More than 1 core layer selected"; };

private _coreLayer = _layers select 0;
private _coreLayerName = (_coreLayer get3DENAttribute "name") select 0;
//Validate the name
if (count _coreLayerName < 12 || (_coreLayerName select [0, 12]) != "MarkerGroup_") exitWith
{
	systemChat format ["Invalid layer selected ""%1"", must be a MarkerGroup_* layer", _coreLayerName];
};
//Trim off MarkerGroup_ and replace with Marker_
private _markerPrefix = "Marker_" + (_coreLayerName select [12]) + "_";

//Get the world object the markers are for
_objects = lineIntersectsObjs [(eyePos get3DENCamera),(ATLtoASL screenToWorld getMousePosition),objNull,objNull,false,4];
//Check selection
if (count _objects == 0) exitwith { systemChat "No world object under mouse"; };
if (count _objects > 1) exitwith { systemChat "More than 1 world object under mouse"; };
private _worldObject = _objects select 0;

//Parse the name of the object
private _name = "";
{
	if (_x find ".p3d" != -1) exitWith
	{
		_name = _x select [0, (count _x) - 4];
	};
}
forEach ((str _worldObject) splitString " ");
//Validate the name
if (count _name == 0) exitWith { systemChat "No valid world object found"; };

//Find the marker data for this object
private _result = ("BearingMarkerPresets" callExtension ["getMarkerPreset", [_name]]);
//Process the data
private _data = ...;

//Loop and create markers
{
	//Choose a random element
	private _chosenElement = selectRandom _x;
	//Is it a sub-array?
	if (typeName (_chosenElement select 0) == "ARRAY") then
	{
		//Yes, a group of markers
		{
			//This is a single marker
			[(_worldObject modelToWorld _x), _markerPrefix, _coreLayer] call createMarkerAtPosition;
		}
		forEach _chosenElement;
	}
	else
	{
		//This is a single marker
		private _pos = _worldObject modelToWorld _chosenElement;
		[_pos, _markerPrefix, _coreLayer] call createMarkerAtPosition;
	}
}
forEach _data;

 

This process meant that adding markers to a building took several seconds instead of many minutes.
The downside, however, is that the possible combinations of markers that would be placed in buildings was limited by how many markers I added to the source preset.

 

Click the image below for a video example

wsLmDlL.jpg

 

 

Most of the early Jobs players will do, ones linked to Guides close to the starting area, have building markers from the original manual process and will appear more varied.
Any Jobs created after the marker preset change might appear noticeably similar.
This was a trade-off I could live with, given how time consuming it is to place building markers, and especially since all the remaining outdoor markers, which vastly outnumber the building markers, still needed to be manually placed.

 


Playtesting

 

By the time the final Jobs were created, and the map fog system was implemented, I spent some time playtesting the Jobs again.
This is the point where some alarm bells should be ringing - that it took me 18 months to have another go at playing the backbone content of the game.

I have a bad habit of testing individual pieces of a system during development, leaving testing the overall state of it until much later.

 

It became obvious very quickly that the pure-random nature of selecting markers for a Job wasn't great to play, particularly for larger play areas. 
The cumulative travel distance varied dramatically between runs of the same Job, and made the target time very difficult to tune.

 

The earlier example of random output is on a small-to-medium scale, at a larger scale the difference in distance is much more significant.
A Job's completion time could vary by several minutes based solely on the generated marker positions.

 

The major concern here is that players could cancel and restart a Job to get a more favourable arrangement of markers, not just for meeting the time limit but also for generally reducing how long they take to complete.

 

This led to two major changes:

  • most Jobs had their time limit removed, in order to:
    • encourage a more casual approach to Jobs 
    • allow for players to detour to other things mid-Job
  • revisiting marker groups as a solution to marker selection

 

Now that I was done placing markers for Jobs, I had a good idea of how much work it would take to create manually marker groups.
The answer was still: too much work.

 

 

Voronoi

 

The solution ended up being something that I'd seen in the past, although only in images here and there.
I needed a way to group markers in some automated way, and the voronoi diagram shown might allow that to be possible.

 

(Source: Google Image Search)

lMr71Z9.jpg

 

 

The process behind the diagram generates evenly-spaced polygons around a series of points - which could be used to quickly isolate markers into groups when applied to a Job play area.
In Bearing's case, these diagram points would be calculated from 'hotspots' of marker positions, as a useful starting step, and then each point moved around to refine the generated polygons.

 

Each polygon would ideally encompass the same number of markers, hopefully making the output spread as even as possible. (The cumulative distance between markers within each group might be a better metric, but I didn't try it.)
I could have output the number of markers in each polygon, as a shorthand to present the spread of markers, but ended up using 'standard deviation' (SD), which I've not really used before.
The definition for SD is 'a measure of the amount of variation or dispersion of a set of values' which is what I was looking for, so - why the hell not?
All I would need to do was adjust the points, and regenerate the polygons, until the SD value as low as I could make it.

 

I'd not seen this voronoi process applied to games data in this way, but it felt like a good place to start.

 

I prototyped some code in Unity, shown below, then ported it to the Bearing tools DLL, along with some map rendering scripting, to use in a running instance of the map.

 

Click the image below for a video example

JssG4mV.jpg

 

 

The process of creating marker groups within the game is:

  1. switch to the desired Job ID and set the number of required markers, which focuses the map on the Job area
  2. all marker positions are tested to find where the densest hotspots are
  3. a voronoi point is placed at each of these hotspots
  4. the diagram polygons are generated from the voronoi points
  5. for each polygon, the number of markers within are tallied
  6. these values are compared and the SD is calculated
  7. voronoi points are manually tweaked, updating the polygons and SD in realtime, until the SD is suitablely low

 

Click the image below for a video example

FnEDIpm.jpg

 

 

After getting used to the process, shown above, I was able to create marker groups for all ~780 jobs in a matter of days.
Examples of the marker selection output can be seen below.

 

BSk5mez.jpg

 

 

I got lucky with voronoi diagrams being the first and last prototype I needed to do, but the solution is not without its drawbacks.

 

While the spread of markers is more even between runs of the same Job, the markers selected across all Jobs could appear as somewhat 'samey' or bland.
I'm not sure if that's noticeable to the players, especially since they won't have seen how markers were selected previously, but it remains a downside for me.

 


Scope creep

 

I had mentioned above that Bearing took 4 years to reach a releasable state, due to the decision to make each set of markers unique between Job attempts.
The feature prototyping work done between filling marker areas turned a smaller, simpler, idea into a much larger and undefined project.
This is known as scope creep, where a project bloats with new ideas and features, all with the goal to make the project 'better'.

 

The correct approach, if I wanted to have something out sooner, would have been to hand-pick the marker positions and keep them the same - to not care about re-attempts being identical.
That alone would have cut the Job development time down by over a year, and give less time for scope creep to wreak havoc.

 

I'm a different person now than when I started working on Bearing, and have less time to play games and engage with them on the level that I did before.
Sometimes, when playing other games, I just want to make some slight progress and go back to other things. My suspicion now for Bearing is that making progress through randomised Jobs can never feel rewarding - at least past a certain point.
Trying to make Jobs feel rewarding led to adding run-speed boosts, for example, stacking the house-of-cards of features even higher.

 

I don't regret having allowed Bearing to fall into this trap, as it lead to prototyping really interesting tech systems (map fog/voronoi/obfuscation/navmesh/GOAP AI) and game design systems (tasks/UI/chat/effects).
Eventually, however, Bearing would have to either stop development entirely or be cut short and shown in an incomplete 'alpha' state.

 

I drew a line (with over 3 years of dev already done) for the alpha release of the mod at the end of 2020, with a few hours of completed starting content available to players.
I had already structured some of the prototyped work to appear later the game, as plot allowed, so I could focus on polishing what can currently be found in the game.

 

Some of these new gameplay systems (like HFDF/LORAN) will never be released - I don't intend to add any further content to Bearing.
Those systems, along with other plot ideas, will go in my back pocket and maybe become part of some (smaller!) project later in the future.

 

Until then, I'll continue to write posts with some background behind the various gameplay and tech features found in Bearing.

  • Like 5

Share this post


Link to post
Share on other sites

Pylons and Guards

 

The core gameplay element of Bearing, the Jobs that required the player to clear thousands of markers, has a problem; variety.
A number of alternative handset types had been planned, giving subtly different experiences when it came to finding markers, but what Jobs lacked was urgency.
Using a time limit for Jobs, something that became less common as it became less interesting, wasn't the silver bullet I thought it might be during early development.

 

What I needed was a way to up the stakes, to give a different way to approach clearing markers. Something much less laid back.
At the same time, it had to be something that would work with any of the existing or planned handset types and wouldn't require a totally new base mechanic.

 

One answer to this was allowing Pylons, the floating orbs found scattered across Altis, to overlap into Job areas.
This meant players would need to be careful when moving around and clearing markers, but weren't quite the constant danger I was looking for.
The next solution, along similar lines, came out of other Pylon variants I had designed in the past.

 

Early Designs

 

Pylons had four variations during early design. Two were developed, although only one was kept for the alpha release, while the rest would come later if needed:

  • Spike
    • the Pylon found in the alpha, which used to have a different look. (Changed after the Contact addon was revealed and the alien craft was similar to the previous look)
      • static position
      • bobs up and down, to add line-of-sight variance
      • omni-directional vision

 

Click the image below for a video example

dmxGYVe.png

 

A longer look at the change in appearance for the Spike Pylon can be found here

 

 

  • Thorn
    • similar to spike but has a vision arc
    • static position
    • slowly rotates in place
    • has a faster attack rate
    • implemented, but disabled until plot was added to explain them

The video below shows the Thorn, but with the Spike orb effect included. I forgot to disable this when I recorded the video.

 

Click the image below for a video example

hgKiXYg.jpg

 

 

  • Maypole
    • static position
    • emits a series of 'lines' outwards along its height, aimed towards the ground
    • the lines rotate with the Pylon, sweeping across the ground and through buildings
    • players are attacked immediately if they are touched by any of the lines

The video below shows a late prototype of how the Maypole might look.

 

Click the image below for a video example

dWBSQWC.png
 

 

  • Watchdog
    • patrols a set path, either a circuit or back and forth
    • will detect and chase the player for a short distance

No prototype was made for the Watchdog design.

 

The Watchdog design would eventually became Guard, a new Pylon type that would exist only during a Job, and only for the player clearing it.


A Guard would patrol the entire job area, not just a set path, and chase the player on sight.
(A later plot implication is that the Guards are protecting markers, and that markers eventually become more Guards)

 

CV5UGLs.png

 

 

Guards were meant to be the next feature in Bearing, coming shortly after the alpha launch.

 

They are mechanically functional, but were left out of the alpha release because the plot didn't support them yet. A few additional tasks to bridge the plot event that activates them were still needed.
There were also technical issues on the audio end that were discovered close to the alpha release, and those also needed to be addressed first.

 

When designing Guards, I knew they would need four things:

  • AI - a way to make decisions about patrolling and chasing the player
  • NavMesh & Pathfinding - an understanding of the area it can move in, and how to get around in it
  • Detection - a clear behaviour that can be understood by the player when they are spotted
  • Appearance - a distinctive look and sound using a limited set of tools


AI

 

The AI implementation for Guards is based on GOAP. It stands for Goal-Oriented Action Planning, and is mostly known for being the AI architecture used in F.E.A.R. (2005).
I had an interest in AI systems several years ago, and prototyped a version of GOAP in Unity to see how it worked.
The games industry has moved on to more sophisticated approaches, but I started there because I liked F.E.A.R.'s AI and wanted to see if I could replicate it.

 

For more info go to http://alumni.media.mit.edu/~jorkin/goap.html and read '3 States and a Plan: The AI of F.E.A.R. (GDC 2006)', or Google 'fear AI goap'. There are plenty of AI videos on the topic.

 

The basic rundown for how the AI for Guards works is:

  • Guards need a goal, which is an objective they need to achieve
  • Goals are selected based on the Guard's internal knowledge (where/when the player was last seen, etc)
  • Each goal is built from a series of actions, executed sequentially, which achieve that objective
  • Goals are constantly refreshing, flushing and recreating new actions as needed, and can change to another goal at any time, depending on outside knowledge (sight, etc)

 

Guards needed only three goals:

  • Chase - the player has been spotted and the Guard is moving to their last known position
  • Search - the Guard has lost track of the player, it will search the nearby area or buildings for a short period of time
  • Wander - the default Guard state, where it occasionally picks a random position to travel to

 

This limited set of goals allows the list of available actions to remain sensibly low.


Each goals can be achieved using some the following actions:

  • Idle
    • do nothing
    • waits for a random period of time or until something happens to change the current goal
  • MovePath
    • generates a navmesh path to follow
    • the target position can be:
      • a random position (for Wander)
      • the last-known player position (for Chase)
      • a position somewhere nearby to this last-known position (for Search)
    • target positions are always exterior, LerpPath is used with interiors
  • MoveWarp
    • teleport to a random position (for Wander) after a short delay
    • used by the Guard to keep the player off-guard
    • has a 10% chance to happen
  • LerpPath
    • Chase and Search goals use this action to investigate buildings
    • each building class has a pregenerated set of 'portals' which represent the doors and windows, with an exterior and interior position
    • this action moves along the exterior->interior path, and requires a second action to lerp from interior->exterior
    • portals are explained further below
  • PortalIdle
    • special-case action used in the middle of the LerpPath action pair
    • like Idle, it waits for a certain length of time before completing
    • additionally, it will remain in position as long as it can see the player, allowing the Guard to attack

 

A more complex AI design might have even more specific actions that can only be used in certain circumstances, such as interactions, traversal or death.

 

The Guard AI logic updates at 30FPS, and will continually select what it wants its current goal to be.
Below is the decision-making behind selecting goals:

  • Chase
    • has seen the player recently and wants to move to the last known position
    • can interrupt all active goal actions (including an already active Chase) with a fresh position to move to
  • Search
    • hasn't seen the player at their last-known position in the last 5 seconds
    • builds a list of places to search, either exterior or interior (never both)
    • runs until all places are searched (moving to Wander) or sees the player again (back to Chase)
  • Wander
    • default starting state
    • remains here until another goal becomes active, which logically can only be Chase

 

 

9Ry2uA0.png

 

This goal switching flow translates to:        

// Some common vars
private _guardIdx = ...
private _currentGoalType = ...
private _allActions = ...
private _numActions = count _allActions;

// Get the goal we're trying to achieve
private _targetGoalType = -1;

// If we've seen the player recently then chase them
if (
	_lastChasedSeenTime != _nowSeenTime					// _lastChasedSeenTime is updated elsewhere to match _nowSeenTime, so long as the Chase goal is active and the player can be seen
										// If they don't match, then we've just acquired sight of the player again
) then {
	_targetGoalType = TESTVAR_GOALTYPE_CHASE;
};

// If we're currently chasing, but haven't seen the player for a while, switch to searching for them
if (
	_targetGoalType == -1 &&								// Didn't set the target goal above
	_currentGoalType == TESTVAR_GOALTYPE_CHASE && 						// Only if we were chasing
	_numActions == 0 &&									// Only once the chase is complete (arrived at the player's last known position)
	(_now - _lastChasedSeenTime) > BRGD_PYLONGUARD_GOAL_SEARCH_START_ELAPSED		// Only if it's time to search (means the Guard can idle for a bit before moving from Chase -> Search)
) then {
	_targetGoalType = TESTVAR_GOALTYPE_SEARCH;
};

// If we're searching, and we run out of actions, drop to wandering
if (
	_targetGoalType == -1 &&								// Didn't set the target goal above
	_currentGoalType == TESTVAR_GOALTYPE_SEARCH &&						// Only if we were searching
	_numActions == 0									// Only once the search is complete (all positions/portals investigated)
) then {
	_targetGoalType = TESTVAR_GOALTYPE_WANDER;
};

// Try to switch to the target goal, which might be blocked for various reasons
private _canChangeGoal = ...

if (_canChangeGoal) then {
	// Switch to the goal, if new
	if (_targetGoalType != -1 && _currentGoalType != _targetGoalType) then {
		// Deal with any ongoing things first
		// Are any actions currently active?
		if (_numActions > 0) then {
			// Yes, cancel the current action
			private _currentActionData = _allActions # 0;
			_currentActionData call FUNC_CANCELACTION;
			// Clear all actions
			_allActions = [];
		};
		
		// Set the new goal
		switch (_targetGoalType) do {
			case TESTVAR_GOALTYPE_CHASE: { [_guardIdx, _currentGoalType] call FUNC_SETGOALCHASE; };
			case TESTVAR_GOALTYPE_SEARCH: { [_guardIdx, _currentGoalType] call FUNC_SETGOALSEARCH; };
			case TESTVAR_GOALTYPE_WANDER: { [_guardIdx, _currentGoalType] call FUNC_SETGOALWANDER; };
		};
	};
};

     
The active goal might change at any time, depending on what the Guard knows or can see, so there sometimes needs to be extra handling for aborting a running action.
Some actions block and cannot be interrupted (like LerpPath/PortalIdle), meaning they must finish before the goal can change.

 

Each goal will build a list of actions which will allow it to achieve that goal

  • Chase (e.g. MovePath, Idle)
  • Search (e.g. exterior: MovePath, Idle, MovePath, Idle, MovePath, Idle or interior: MovePath, LerpPath, PortalIdle, LerpPath, MovePath, LerpPath, PortalIdle, LerpPath)
  • Wander (e.g. MovePath, Idle or MoveWarp, Idle)

 

Once all actions are completed the goal might change (Chase -> Search), or it might repeat the same goal (Wander) and repopulate with actions to achieve that goal.

 

Extra stuff

  • A Guard will keep updating the player's last known position for an additional leeway of 0.5 seconds after it loses sight of them
    • this allows the Guard to 'predict' which way the player was moving more easily than having to track movement vectors
  • When a player clears a marker, the Guard is assigned the marker position with a last-seen time of 0.0
    • this forces the Guard to switch to its Chase goal, and immediately moves towards it
    • the transition to Chase also plays the alert SFX, indicating to the player that the Guard is coming
    • the same also happens if the player uses the FTP handset nearby to the Guard
  • The Wander goal will move the Guard between 20-200m each time it moves
  • The Search goal moves within 5-15m of the last-known position, if doing exterior searches
  • A Guard will always search a building if the player was last seen inside one
    • it will spend 3-30 seconds on just the portals (the windows and doors) that can see the last-known player position. This can help the Guard appear smarter than it is.
    • if no portals can see the player position it will pick two random portals in that building


The video below shows an example of a Guard searching a building after clearing a marker:

 

Click the image below for a video example
j2P6BPQ.png

 

  • The action set for searching buildings is:
    • the Guard moves to a predefined position outside of the door or window (MovePath)
    • moves in slightly (LerpPath)
    • waits there for a moment, or longer if the player is visible (PortalIdle)
    • moves back out (LerpPath)

 

The images below show an SQM where all definitions for building portals are kept.
Variations of houses are overlaid at the same position, and a number of arrow objects are placed indicating where the Guard can enter, and in which direction to move.
This SQM is processed during project builds to extract the relative portal positions and facings, so they can be applied to any building on the map at runtime.

 

TtugdQ6.jpg     2xHK5qq.jpg

 

  • Guards only live as long as the Job lasts, so will be destroyed when the Job is completed or aborted (which includes when the player respawns)
  • Guard attacks work the same way that the other Pylons do:
    • if the player is visible for long enough then the job ends and they 'respawn' at the previously met Guide
    • there are no special attack actions or goals, the Guard simply tries to keep the player in view long enough for the attack duration to elapse

The video below shows a Guard attacking the player, which happens much faster than a Pylon attack does.

 

Click the image below for a video example (audio enabled)

OvEZ2qu.png

 

 

NavMesh & Pathfinding

 

For a pathfinding solution, I initially tried to use the built-in pathfinding functionality (calculatePath), but I didn't have enough control to get the type of path I needed.
Guards aren't people or vehicles, and are able to go over many obstacles like walls and fences.
 I was also unable to restrict the pathing to the Job area bounds, so the calculated path would often travel outside of the area a Guard might be restricted to.
 
I ended up rolling my own pathfinding solution. This wasn't so bad, because I've done it several times in the past and had plenty of reusable code.
There were a few issues, however:

  • my experience in pathfinding was limited to basic A* over weighted grids (mud/roads/forest making some paths easier than others)
  • my prototyped GOAP implementation in Unity used the internal Unity navmesh to do pathfinding
  • this meant I needed to implement a navmesh of my own, which I'd not implemented before

 

The journey to creating the navmesh was not an easy one.

 

The first implementation of the navmesh was a hand-built mesh, finely subdivided (as much as I felt necessary, at least).
One way to handle pathing in a navmesh is to create paths that travel from the centre of triangles, and to do this well often requires smaller, evenly sized triangles.


I built a monolithic SQF script that allowed me to build the mesh in 3DEN. The example mesh shown below was the test case, and took days to create.
 

yvOs0JP.png

 

 

The path ended up being unsuitable, because the Guard would appear to zig-zag along seeming straight paths.
I ultimately threw the entire thing away.

 

The second implementation was much more sensible.


I started with the outlines of the area and the inner shapes, and triangulated the inner spaces (again manually).
The triangulation could have been automated, but the resulting mesh was often messy.


Here I began using the vertex-to-vertex connections as available path segments, abandoning the centre-of-triangle pathing method from above.
The mesh itself was only used for validating if positions where in the navmesh, by PointInTriangle testing every triangle in the mesh.

 

yHaAVaV.png    

 

 

The third implementation is the one I ended up using in Bearing.


Once I realised that the only thing that mattered was the links between vertices, I didn't need a mesh or even manually created triangles anymore.
I could use the original shapes to perform PointInTriangle tests much more quickly, as overall there would be fewer triangles to test.


As I only needed to build outlines, I could build a navmesh for a Job in a matter of minutes, instead of days.

 

The procedure to create a navmesh is:

  • start with the job boundary points in 3DEN, shown below

B4oifAc.jpg 

 

  • create shapes:
    • for the area which suits where the Guard should be allowed to travel (giving wiggle-room for line-of-sight mostly), which roughly matches the job area
    • for the places where the Guard cannot go, like buildings, tree or special areas

U06VLjP.jpg     IExQAHa.jpg

 

  • a mesh is automatically generated for the main area outline and each blocking inner shape
    • these meshes are not used directly for pathing
    • they only confirm if positions are valid or invalid (using PointInTriangle tests)

Z1zxUFf.png

 

  • for every vertex in the mesh, a list of visible vertices are kept, along with the distance between them
    • the image below shows two examples of the links connecting to a vertex

ZL21Gjs.jpg 

 

  • these lists of vertex connections serve as the 'paths' around the mesh, and can be used with a standard A* algorithm (that FTP route finding already uses)
  • the start and end positions of the requested path usually link directly to one of these vertices, unless there is a direct line-of-sight between the two points

 

hucq1pv.jpg

 

 

Since the Guard is centred on the movement path (which follows the navmesh edges), and with no special adjustments for size of the Guard, it will seem like the Guard is hugging the edges of the navmesh.
For this reason, it's important to offset the edges of the inner blocking shapes a little distance away from buildings and other things that the Guard shouldn't move through.


The image below shows the additional spacing added around buildings:

wPf7oGM.jpg

 

 

Care must be taken when dealing with alleyways - a Guard may not be able to travel down it if neighbouring blocking shapes overlap.

 

All navmeshes exist in SQM files, which are processed into extra data when the project is built.

This extra navmesh data is bundled into a file for the Bearing DLL to load. This DLL is also where all the pathfinding calculations happen.

 

To request a path, the server passes a start & end position to the DLL and gets back the shortest path, if possible:

// Input data
private _navMeshID = ...
private _fromPosASL = [...];
private _toPosASL = [...];

private _result = ["NavMeshCalcPath", [_navMeshID, _fromPosASL, _toPosASL]] call FUNC_CALLEXTENSION;
if (...) exitWith {
    // Handle not being able to get a valid path within this navmesh
    ...
};

private _path = _result # ...


Once the server has the path data, each point in the path is then updated with the correct height, as the DLL is unable to access the terrain heightmap data.


It's important to note that Guards don't always follow the terrain curvature as they move - instead they might instead move in a straight line from start to finish, for example when they are approaching an upper window.
To differentiate between the two movement types (following the curvature, or ignoring it) the ATL height of the start and end positions are compared.
If the heights are equal, typically a value of 2.5 meters above the terrain, then the path is assumed to adhere to the terrain's height.

 

The path's point heights are processed as follows:

private _fromATL = (ASLToATL _fromPosASL) # 2;
private _toATL = (ASLToATL _toPosASL) # 2;

if (_fromATL == _toATL) then {
    // If both start and end are the same ATL height, meaning the path is simply moving across the terrain, then all points can have same ATL height
    {
        private _pos = _x;
        _pos set [2, _fromATL];
        _path set [_forEachIndex, ATLToASL _pos];
    }
    forEach _path;
} else {
    // A bit more complicated - work out the length of each path step and apply the height offset
    // Start by getting the path length at each position
    ([_path] call FUNC_NAVGETPATHLENGTH2DSTEPS) params ["_pathLength2DTotal", "_length2DSteps"];
    private _heightDiff = _toATL - _fromATL;
    {
        //Get the ATL height it needs to be at
        private _heightATL = linearConversion [0, _pathLength2DTotal, _length2DSteps # _forEachIndex, _fromATL, _toATL];
        //Update the ASL height
        _x set [2, _heightATL];
        _path set [_forEachIndex, ATLToASL _x];
          
        // NOTE: The above could also interpolate the ASL height directly to be a little faster
    }
    forEach _path;
};

 

A good example of the path not following the terrain would be if the Guard is travelling from a door to an open window on the opposite side of a building.
The path will need to travel around the corners of the building, and the height at these corner points should be gradually moving upwards to the window height.

 

One issue with this height adjustment system, when following a terrain that undulates, is that long distances between points will seem to not change in height at all.
This could be resolved by subdividing the path into smaller segments, allowing each inner point to reflect the terrain height more correctly.

 

Below: matching ATL heights at the start and end of the path mean all path points should share the same ATL height

OABGPV7.jpg

 

Below: if the start and end points do not have matching ATL heights, a straight-line path is assumed

TLPxVjY.jpg

 

Below: a subdivided path would conform more closely to the terrain height

U36Z6PF.jpg

 

 

 

Detection

 

Here I will cover how both Pylons and Guards detect and attack players.

 

 

Pylons

 

The method which Guards use to spot and pursue players differs slightly from the one used by the regular Pylons.
Players will already have some experience with Pylons, and the rules their detection method plays by, so learning the differences should be straight-forward.

 

Pylons have a cumulative 'fire' ratio that increases (from 0.0 to 1.0) so long as the player is visible to the Pylon.
The ratio will slowly decrease to 0.0 if the player cannot be seen by the Pylon. Players can simply wait behind cover until this happens.

 

The rate of increase is based on visibility:

  • a visibility check tests the player's visibility from various positions:
    • Pylon top/middle/bottom -> player top/middle/bottom (top is eyePos, bottom is feet)
    • this gives a better representation of overall visibility, rather from Pylon center -> player center, and deals with cover much better
    • uses IFIRE
    • returns 0.0 to 1.0
  • this visibility is then scaled by whether the player is standing, crouched or prone, making a lower stance the best option when moving around the Pylon

 

The image below shows how the visibility checks are made between the player and a Pylon.
This 0.0 to 1.0 ratio comes from how many of the 9 LoS checks pass, between with <= 2 being 0.0, and >= 8 being 1.0

 

fH62ftc.jpg

 

 

The code for calculating the visibility is:

// Variables passed to the function
private _player = ...;
private _pylonMidASL = ...;
private _pylonRadius = ...;

// The minimum value needed to pass the visibility check
TESTVAR_PYLON_VISCHECK_THRESHOLD = 0.2;
// The type of visibility test (see *checkVisibility* URL)
TESTVAR_PYLON_VISCHECK_LODTYPE = "IFIRE";

// Get the pylon visibility test positions
private _pylonTopASL = _pylonASL vectorAdd [0, 0, _pylonRadius];
private _pylonBottomASL = _pylonASL vectorAdd [0, 0, -_pylonRadius];

// Get the player visibility test positions
private _playerTopASL = eyePos _player;
private _playerBottomASL = getPosASL _player;
private _playerMidASL = (_playerTopASL vectorAdd _playerBottomASL) vectorMultiply 0.5;

// And test LoS between the player and pylon test positions (3 for each, 9 tests total)
private _seen = 0;

// NOTE: Remember to ignore the player - or the visibility check will fail because it hits them (usually the player mid point)
// From pylon top
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonTopASL, _playerTopASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonTopASL, _playerMidASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonTopASL, _playerBottomASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
// From pylon middle
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonMidASL, _playerTopASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonMidASL, _playerMidASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonMidASL, _playerBottomASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
// From pylon bottom
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonBottomASL, _playerTopASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonBottomASL, _playerMidASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };
if ([_player, TESTVAR_PYLON_VISCHECK_LODTYPE] checkVisibility [_pylonBottomASL, _playerBottomASL] > TESTVAR_PYLON_VISCHECK_THRESHOLD) then { _seen = _seen + 1; };

// Turn the seen count into a 0.0 to 1.0 visibility
// Minimum 2 passes, maximum 8 passes
private _visibility = linearConversion [2.0, 8.0, _seen, 0.0, 1.0, true];

//Get the vertical impact of the player on the visibility
//NOTE: being prone is *almost* invisible - min cap is set by TESTVAR_PYLON_VISCHECK_VERT_RATIOMIN
TESTVAR_PYLON_VISCHECK_VERT_RATIOMIN = 0.01;
TESTVAR_PYLON_VISCHECK_VERT_HEIGHTMIN = 0.35;
TESTVAR_PYLON_VISCHECK_VERT_HEIGHTMAX = 1.6;

private _verticalDiff = (_playerTopASL # 2) - (_playerBottomASL # 2);
private _verticalScale = linearConversion [TESTVAR_PYLON_VISCHECK_VERT_HEIGHTMIN, TESTVAR_PYLON_VISCHECK_VERT_HEIGHTMAX, _verticalDiff, TESTVAR_PYLON_VISCHECK_VERT_RATIOMIN, 1.0, true];
//Apply to the visibility and return
_visibility * _verticalScale;

 

The fire ratio is then increased using the following:

// The interval of time between pylon updates, as a fraction of a second
TESTVAR_PYLON_UPDATE_INTERVAL = ...
// How quickly the rate increases, per second
TESTVAR_PYLON_FIRERATE_INCR = 0.06;

private _lastFireRatio = ...
private _increaseFireAmount = _visibility * TESTVAR_PYLON_UPDATE_INTERVAL * TESTVAR_PYLON_FIRERATE_INCR;
private _newFireRatio = _lastFireRatio + _increaseFireAmount;

 

Once the fire ratio is >= 1.0, the Pylon will 'attack' the player. This plays a short post-effect animation and teleports the player back to the last Guide they spoke to, allowing the various ratios to reset.

 

 

Guards

 

Guards differ from Pylons in that they have two ratios to manage instead of one; the track ratio and the fire ratio.

 

The visibility check has the same Pylon logic but:

  • visibility tests are simpler (shown below), and are always from Guard centre to player top/middle/bottom
  • this is because the Guard visibility tests are run much more frequently than Pylon tests are, and need to be cheaper
  • uses VIEW instead of IFIRE
  • no player height multiplier

zlGYfIv.jpg

 

The simplified visibility code looks like this:

// Variables passed to the function
private _player = ...;

// Get the player visibility test positions
private _playerTopASL = eyePos _player;
private _playerBottomASL = getPosASL _player;
private _playerMidASL = (_playerTopASL vectorAdd _playerBottomASL) vectorMultiply 0.5;

// The type of visibility test (see *checkVisibility* URL)
TESTVAR_GUARD_VISCHECK_LODTYPE = "VIEW";

private _visibility = 
    ([_player, TESTVAR_GUARD_VISCHECK_LODTYPE] checkVisibility [_guardASL, _playerTopASL]) +
    ([_player, TESTVAR_GUARD_VISCHECK_LODTYPE] checkVisibility [_guardASL, _playerMidASL]) +
    ([_player, TESTVAR_GUARD_VISCHECK_LODTYPE] checkVisibility [_guardASL, _playerBottomASL]);

//Return it
_visibility / 3.0;

 

Rather than increase the fire ratio, which attacks the player, the visibility is used to increase the track ratio.
The track ratio is an indication of how long the Guard has kept sight of the player. This ratio increases so long as the player is in view, just like the fire ratio does for Pylons.

 

The track and fire ratios for Guards increases much faster than the fire ratio does for Pylons, and uses the following:

// Needed for later
private _currentGoalType = ...

// The interval of time between guard updates, as a fraction of a second
TESTVAR_GUARD_UPDATE_INTERVAL = ...
// How quickly the track rate increases, per second
TESTVAR_GUARD_TRACKRATE_INCR = 0.75;
// How quickly the fire rate increases, per second
TESTVAR_GUARD_FIRERATE_INCR = 0.4;
// The distance falloff ranges for the fire ratio
TESTVAR_GUARD_FIRE_DISTANCE_MIN = 10.0;
TESTVAR_GUARD_FIRE_DISTANCE_MAX = 40.0;

// TRACK RATIO

private _lastTrackRatio = ...
private _increaseTrackAmount = _visibility * TESTVAR_GUARD_UPDATE_INTERVAL * TESTVAR_GUARD_TRACKRATE_INCR;
private _newTrackRatio = _lastTrackRatio + _increaseTrackAmount;

// FIRE RATIO

// Only increase the fire ratio when the track ratio is at max, and if we're chasing the player
if (_newTrackRatio >= 1.0 && _currentGoalType == TESTVAR_GOALTYPE_CHASE) then
{
    // The fire ratio is also scaled by distance
    private _distanceRatio = linearConversion [TESTVAR_GUARD_FIRE_DISTANCE_MIN, TESTVAR_GUARD_FIRE_DISTANCE_MAX, (ASLToAGL _fromPosASL) distance _player, 1.0, 0.0, true];
    if (_distanceRatio > 0.0) then
    {
        private _lastFireRatio = ...
        private _increaseFireAmount = _visibility * _distanceRatio * TESTVAR_GUARD_UPDATE_INTERVAL * TESTVAR_GUARD_TRACKRATE_INCR;
        private _newFireRatio = _lastFireRatio + _increaseFireAmount;
    };
};

 

Once the track ratio has reached 1.0, then the fire ratio will begin to increase, but only so long as the track ratio stays at 1.0. This means the Guard must keep constant sight of the player, while both are moving, in order to attack.
Additionally, a distance modifier is applied to the fire ratio increase rate. This makes it quicker to attack when closer to the player, but specifically prevents the player being unfairly attacked from long distances when running out in the open.

 

A track ratio of 1.0 will also keep track of the players current position (which is where the ratio name comes from), giving the Guard a destination to path towards even after the player has evaded it.
The Guard must also maintain a value of 1.0 to store this position, so that players who run behind cover, like walls or trees, get the impression that they are evading the Guard. Otherwise the Guard will exactly track their position even if it can only see a small part of the player.

 

The separation of the track and fire ratios makes attacking the player a two-step process; spot & chase. This gives the Guards a more organic behaviour and allows the player to have a more fair experience evading them.
It might not be obvious to the player that this is the underlying logic, but so long as there is enough flexibility for them to evade and escape then the AI will have been successful at its goal.

 


Appearance

 

Much like markers and Pylons, the look for Guards was born out of the limitations of the addon-less server restriction.
Having no custom models or textures meant that I was restricted to particles and other special debug effects to build what a Guard could look like.

 

From the start, it was clear that using particles made the Guards difficult to notice at a distance.
Larger particles weren't an option, to avoid them clipping through buildings, and flashing particles helped but didn't entirely solve the issue.
At the time I didn't have the effects system fully implemented. If I had, I would have been able to better control the sizes of the particles based on the distance from the player.

 

I improved the look slightly by adding larger refractive particles to the effect, distorting the area surrounding the Guard. The refractive effect suffers less from the building clipping issue and so could be made fairly large.
Unfortunately, the improvement is very minor, and wasn't enough. The Guard effects were still difficult to make out and felt ungrounded in the world.

 

I was working on different looks for markers at the time, including the one shown below and seen in an earlier post.

This variation has 'grabbing' lines that reach out to the player when they get too close. I ended up lifting this effect and putting it at the centre of the Guard to help flesh out the appearance more.

 

Click the image below for a video example

gB6xYUh.jpg

 

 

I initially added the grabbing lines thinking they could reach out to the player when they were being attacked. However, the player would rarely get close enough for them to notice it was happening.
While playing with the effect I tried reaching the lines out in a variety of ways:

  • random directions - looked too chaotic
  • based on the Guard movement direction - looked like the Guard was 'feeling' its way around
  • 'grabbing' onto nearby surfaces like the ground and walls - looked like the Guard was pulling itself around

 

The best part about the grabbing nature of the lines was that it really helped to ground the effect in the world. The Guard stopped looking like a detached floating effect and began to feel like it had some weight to it.

 

After some extra testing, I broke each 'leg' into several kinks that would bend/straighten out when the Guard moved to/from the grab points.
This is my favourite part of the Guard appearance, giving the impression that the Guard is walking around and climbing walls.
An extra benefit was seeing the Guard legs reach through windows and grab onto the inside walls in a way that appeared natural.

 

The series of debug videos below shows the guard in action during development.

 

Click the images below for video examples

DBNrLwH.png     lt9nIeQ.png     8NHl6E6.png

 

 

During development of the AI and path-finding, before the Guard appearance work had started, I was using a bunch of debug lines to help me locate the Guard - specifically a vertical line stretching far above the rooftops.
I needed the player to have different ways to detect a Guard at near, medium and far distances, and this vertical line was a great way to spot Guards that were behind buildings.
A rising line that trailed after the Guard was added late in development, and is called a 'tether'. it doesn't give the exact position while the Guard is moving, due to how the top of the line lags behind the movement, and feels like a fair compromise.

 

A video showing the tether, along with a later, bolder variation on the particle effect, can be seen below:

 

Click the image below for a video example

8yjq7Pb.png

 

 

The final assortment of features for identifying Guards is split into:

  • Near: LoS, particles
  • Medium: audio, legs
  • Far: flashing particles, tether

 

All of these components in combination result in a suitably visible object when at near-medium range, but still struggles greatly due to a lack of thicker, solid shapes - something that some sort of drawTri3D would give.

 

 

Audio

 

The audio for Pylons and Guards were developed at roughly the same time.

 

Pylon audio consists of three 'rumble' audio clips that loop forever. Each has a separate range start & end range, so that the rumble can be tweaked for near/middle/far distances.
These clips are pitched-down versions of the 'earthquake' audio clips that come with Arma 3.

 

class CfgSounds
{
    sounds[] = {
        TESTSFX_snd_SFXRUMBLE1,
        TESTSFX_snd_SFXRUMBLE2,
        TESTSFX_snd_SFXRUMBLE3,
        TESTSFX_snd_SFXWIND
    };
    
    // Some extra volume for all clips, as they're quiet by default
    #define SFXVOLUMEBOOST              5
    // Create rumble and wind effects by lowering the pitch to 20%
    #define SFXPITCH                    0.2
    // All custom sounds are set to the same volume range, so we can move the sound object manually to do runtime attenuation adjustments via scripts
    #define SFXRANGE                    100
    
    class TESTSFX_snd_SFXRUMBLE1 { name = "TESTSFX_snd_SFXRUMBLE1"; sound[]= { "@A3\sounds_f\environment\ambient\quakes\earthquake2", SFXVOLUMEBOOST, SFXPITCH, SFXRANGE }; titles[]={}; };
    class TESTSFX_snd_SFXRUMBLE2 { name = "TESTSFX_snd_SFXRUMBLE2"; sound[]= { "@A3\sounds_f\environment\ambient\quakes\earthquake3", SFXVOLUMEBOOST, SFXPITCH, SFXRANGE }; titles[]={}; };
    class TESTSFX_snd_SFXRUMBLE3 { name = "TESTSFX_snd_SFXRUMBLE3"; sound[]= { "@A3\sounds_f\environment\ambient\quakes\earthquake4", SFXVOLUMEBOOST, SFXPITCH, SFXRANGE }; titles[]={}; };
    
    class TESTSFX_snd_SFXWIND { name = "TESTSFX_snd_SFXWIND"; sound[]= { "@A3\sounds_f\characters\ingame\parachute\parachute_glide_loop", SFXVOLUMEBOOST, SFXPITCH, SFXRANGE }; titles[]={}; };
};

 

Rather than have a separate set of clips running for each Pylon, the same set is re-used for all of them. The only important bit of information is the position of the closest Pylon, which controls all of the volumes.

 

Each 'loop' is actually a new clip being played - each clip fades out as it ends, via scripting, and in again when it starts. This helps to disguise the transition.
A new sound clip, one for all four sounds listed, is spawned every 5 seconds and each lasts for 15 seconds. They have 5 seconds of fade at the start and end, so the overlap volume envelope looks like:

 

aNur1a8.jpg

 

For variety, the three rumble clips have a 'wave' volume adjustment, again controlled by script. This was added to fit with the 'pulse' effect that the Pylon shows when you approach it.

 

An additional 'wind' audio clip plays over the top across the entire audio distance range, to add some extra flavour.
This wind also plays, much more quietly and with a very short attenuation range, on all markers. This sort of audio sharing attempts to aurally relate markers and Pylons together, although likely goes unnoticed.

 

The final result sounds like this:

 

Click the image below for a video example (audio enabled)

LDibOg6.png

 

 

The script which controls the loop fade, the range fade, and the wave fade is shown below. This is supplied as an optional parameter to FUNC_PLAYSOUND3D, which was shown in an earlier post:

//Add a loop to update the audio for pylons
//We do it in this loop instead of a draw EH so that it'll work when the map is open
[] spawn {
    //Start by creating an object for all sounds to play and track from
    //Moved to 0,0,0 if we're far enough away from any pylons
    private _pylonTrackingObj =  "Land_HelipadEmpty_F" createVehicleLocal [0,0,0];

    //Spawn something that just creates the audio bits to play so that it can sleep at a different rate than the position is updated
    //NOTE: we constantly generate audio for a single pylon, even when we aren't near any
    [_pylonTrackingObj] spawn {
        params ["_pylonTrackingObj"];

        //Loop forever
        while {true} do {
            //The rumble is made up of 3 different sounds played at different distances
            {
                _x params ["_soundName", "_rangeData"];
                [
                    _soundName, _pylonTrackingObj, 30000, 0, 15,
                    [{
                        params ["_inElapsed", "_inDuration", "_inEarPosASL", "_inSourcePosASL", "_volumeData"];
                        _volumeData params ["_rangeNearStart", "_rangeNearEnd", "_rangeFarStart", "_rangeFarEnd"];
                        
                        //Fade the start and end of the sound so we can loop it evenly
                        private _fadeInDuration = 5;
                        private _fadeOutDuration = 5;
                        private _valIn = linearConversion [0, _fadeInDuration, _inElapsed, 0, 1, true];
                        private _valOut = linearConversion [_inDuration - _fadeOutDuration, _inDuration, _inElapsed, 1, 0, true];
                        
                        //A pulse-like volume adjustment
                        //TODO: Sync to the ppeffect wave
                        private _waveInDuration = 0.5;
                        private _waveOutDuration = 2;
                        private _waveInterval = 3;
                        private _waveIntervalOffset = -0.125;
                        private _waveMin = 0.75;
                        private _waveIn = linearConversion [0, _waveInDuration, (time + _waveIntervalOffset) % _waveInterval, _waveMin, 1, true];
                        private _waveOut = linearConversion [_waveInterval - _waveOutDuration, _waveInterval, (time + _waveIntervalOffset) % _waveInterval, 1, _waveMin, true];

                        //Handle the volume range limits
                        private _dist = _inEarPosASL distance _inSourcePosASL;
                        private _rangeIn = linearConversion [_rangeNearStart, _rangeNearEnd, _dist, 0, 1, true];
                        _rangeIn = 1.0 - ((1.0 - _rangeIn) ^ 4);
                        private _rangeOut = linearConversion [_rangeFarStart, _rangeFarEnd, _dist, 1, 0, true];
                        _rangeOut = 1.0 - ((1.0 - _rangeOut) ^ 4);

                        (_valIn min _valOut) * (_waveIn min _waveOut) * (_rangeIn min _rangeOut);
                    }, _rangeData]
                ] call FUNC_PLAYSOUND3D;
            }
            forEach [
                ["TESTSFX_snd_SFXRUMBLE1", [0, 1, 45, 70]],
                ["TESTSFX_snd_SFXRUMBLE2", [30, 55, 95, 120]],
                ["TESTSFX_snd_SFXRUMBLE3", [80, 105, 165, 250]]
            ];

            //Handle the wind effect separately
            [
                "TESTSFX_snd_SFXWIND", _pylonTrackingObj, 500, 0, 15,
                [{
                    params ["_inElapsed", "_inDuration", "_inEarPosASL", "_inSourcePosASL", "_volumeData"];
                    private _fadeInDuration = 5;
                    private _fadeOutDuration = 5;
                    private _valIn = linearConversion [0, _fadeInDuration, _inElapsed, 0, 1, true];
                    private _valOut = linearConversion [_inDuration - _fadeOutDuration, _inDuration, _inElapsed, 1, 0, true];
                    
                    (_valIn min _valOut);
                }, []]
            ] call FUNC_PLAYSOUND3D;

            //Since both the wind and rumble are 15sec loops with 5sec of padding each side,
            //we can create both loops in here and sleep for the same duration
            uiSleep 5.0;
        };

    };

    while {true} do {
        //Check how far away we are from the closest pylon
        private _closestPosASL = ...
        private _earPosASL = AGLToASL (positionCameraToWorld [0, 0, 0]);
        //Our maximum range is 500m, so anything larger can be put somewhere far away
        if (_closestPosASL distance _earPosASL < 500) then {
            _pylonTrackingObj setPosASL _closestPosASL;
        } else {
            _pylonTrackingObj setPosASL [0, 0, 0];
        };

        //Update the audio positioning at 30FPS
        uiSleep (1.0 / 30.0);
    };
};


Guards would need to be something different; they are much more dynamic that markers or Pylons, and being aware of their position through audio is much more important.

 

When playtesting Guards, it became apparent that they needed a constant 'idle' audio effect to help locate them and track their movements. Having no audio, or an occasional bark, felt unnatural and meant a Guard would appear to change position unexpectedly.
The first attempt at an idle audio used a basic radio static effect, centred on the Guard and falling off to silence a reasonable distance away. This quickly became irritating, as it was too repetitive to listen to at length.

 

After various iterations, the answer was for Guards to play a set of 6 'radio' audio clips that slightly overlap, much like how the Pylon rumble clips do, but choosing a random clip to play each time.
These clips are created from some actual radio audio that I recorded and heavily processed.

 

To help with understanding when the Guard changes to an important state, a few variations of 'alert' audio clips play when the Guard switches to the Chase goal for the first time, and a slightly different alert when it transitions back from Search -> Chase.

 

An example of the radio clips, and the alert sample when both being spotted and clearing a marker, can be found in the video below:

 

Click the image below for a video example (audio enabled)

1k8HXSN.png

  • Like 1

Share this post


Link to post
Share on other sites

This post is focused on some of the pipeline and backend utility features of Bearing.
These are a few things that won't be visible to anyone playing the mod, but had a huge impact on development in one way or another.

 

You might find some of it interesting or useful!

 


Fragments - Handling large data from a DLL 

 

If you're like me, you might be generating large amounts of data on a DLL and need to pass it back to the server/client.
Unfortunately, there is a limit on the amount of data that can be sent at any one time, so special handling is needed to deal with it.

 

See callExtension (https://community.bistudio.com/wiki/callExtension) for more info on the data limits.

 

This is a common problem for many extension developers, and there are likely many ways to approach it.


My solution is to split the data into fragments on the DLL, and request each fragment from the DLL one-by-one. The fragments are then combined into the final result on the server/client.

 

//DLL C++

//Function results slightly longer than this value cause problems, so use it to
//break results into multiple parts
#define MAX_RESULT_FRAGMENT_LENGTH        8000

//A special prefix to indicate that the result is broken into multiple parts
#define PREFIX_FRAGMENT                    std::string("FRAGSTART||")

//Keep a tally of fragment groups, so a unique ID can be assigned to each one
uint s_fragmentGroupID = 0;
//Result fragments are stored in a global map
//The ID is stored as a string to avoid parsing it each time a fragment is requested
std::map<std::string, std::vector<std::string>> s_resultFragmentGroups;


//The core DLL function that handles incoming functions and arguments, and returns a result
std::string Bearing::Process(const std::string& functionName, const std::vector<std::string>& functionArguments)
{
    //Catch a fragment request
    if (functionName.compare("fragment") == 0)
    {
        //Should be one argument
        std::string fragmentGroupID = functionArguments[0];

        //Trim off any surrounding quotes
        if (fragmentGroupID[0] == '"') fragmentGroupID = fragmentGroupID.substr(1, fragmentGroupID.size() - 2);

        //Return the next result fragment
        return GetNextResultFragment(fragmentGroupID, false);
    }
    //Otherwise run the function
    else
    {
        //Find and run the function we're looking for
        std::string result = ...;

        //Is the result short enough to send in one go?
        if (result.length() <= MAX_RESULT_FRAGMENT_LENGTH)
        {
            //Yes, return the result immediately
            return result;
        }
        else
        {
            //No, break the result into fragments and return the first one
            return CreateResultFragments(result);
        }
    }
}


//Breaks a result into multiple fragments, stores them, and returns the first fragment
std::string Bearing::CreateResultFragments(std::string result)
{
    //Create the group with a unique ID (as a string, so future lookups don't need to be parsed)
    std::string fragmentGroupID = Common::Format("%u", s_fragmentGroupID);
    s_resultFragmentGroups[fragmentGroupID] = {};
    s_fragmentGroupID++;
    //Reset the fragment ID after a while to stop Arma 3 turning it into an EXP elsewhere
    if (s_fragmentGroupID > 50000) s_fragmentGroupID = 0;

    //Break up the string
    do
    {
        std::string fragment = result.substr(0, MAX_RESULT_FRAGMENT_LENGTH);
        s_resultFragmentGroups[fragmentGroupID].push_back(fragment);
        result = result.substr(MAX_RESULT_FRAGMENT_LENGTH);

    } while (result.length() > MAX_RESULT_FRAGMENT_LENGTH);
    //Get the trailing bit
    s_resultFragmentGroups[fragmentGroupID].push_back(result);

    //Return the first fragment
    return GetNextResultFragment(fragmentGroupID, true);
}


//Get the next available result fragment, deleting it afterward (and the parent group when empty)
std::string Bearing::GetNextResultFragment(const std::string& fragmentGroupID, bool isFirstFragment)
{
    //Sanity check the group ID
    if (s_resultFragmentGroups.find(fragmentGroupID) == s_resultFragmentGroups.end())
    {
        //Error handling here for missing fragment groups
        return ...;
    }
    //Get the first fragment in the group
    std::string fragment = s_resultFragmentGroups[fragmentGroupID][0];
    //Erase it
    s_resultFragmentGroups[fragmentGroupID].erase(s_resultFragmentGroups[fragmentGroupID].begin());
    //Is the group now empty?
    if (s_resultFragmentGroups[fragmentGroupID].empty())
    {
        //Delete the group
        s_resultFragmentGroups.erase(fragmentGroupID);
        return fragment;
    }
    else
    {
        //More to go, use the correct prefix and include the group ID and how many fragments are left (excluding this one)
        if (isFirstFragment)
        {
            //NOTE: Only the first fragment needs to have the prefix, fragment group ID and fragment count
            return PREFIX_FRAGMENT + fragmentGroupID + "||" + Common::Format("%d", s_resultFragmentGroups[fragmentGroupID].size()) + "||" + fragment;
        }
        else return fragment;
    }
}
// SERVER SCRIPT

//The body for callExtension.sqf
//Usage:
//
// private _result = ["DLL_FUNCTION_NAME", [...]] call ModName_fnc_CallExtension;

params ["_function", ["_args", []]];

private _resultStr = "";

if (count _args == 0) then {
    _resultStr = "DLL_NAME" callExtension _function;
} else {
    //Avoid the engine trimming off decimal places before submitting arguments by forcing at least 6
    toFixed 6;
    _resultStr = ("DLL_NAME" callExtension [_function, _args]) # 0;
    //And revert it back
    toFixed -1;
};

//Check for fragments
if (count _resultStr >= 11 && (_resultStr select [0, 11]) isEqualTo "FRAGSTART||") then
{
    //Get the prefix
    private _tmpStr = _resultStr select [11];
    
    //Chop out the fragment group ID
    private _cutIdx = _tmpStr find "||";
    private _fragmentGroupIDParam = [_tmpStr select [0, _cutIdx]];
    _tmpStr = _tmpStr select [_cutIdx + 2];
    
    //Chop out the fragment count
    _cutIdx = _tmpStr find "||";
    private _fragmentCount = [_tmpStr select [0, _cutIdx]] call BIS_fnc_parseNumber;

    //Collect all the fragments in an array, then use joinString to assemble the fragments
    //NOTE: This is SIGNIFICANTLY faster (by about 1200% for 413 fragments) than concating strings one by one to the result
    private _strArray = [];
    _strArray pushBack (_tmpStr select [_cutIdx + 2]);

    //Keep calling for fragments until the count is reached
    for "_i" from 1 to _fragmentCount do
    {
        _strArray pushBack (("DLL_NAME" callExtension ["fragment", _fragmentGroupIDParam]) # 0);
    };
    
    //Build the string
    _resultStr = _strArray joinString "";
};

//Now return the complete result string...
_resultStr;


Fairly straight-forward, but the joinString and toFixed usage in the server script is worth highlighting.

 

 

Call Queue - queuing unscheduled script from a scheduled environment

 

Sometimes you might be within a scheduled environment, like a spawn{}, and need to run a hefty block of code.

 

The problem you will encounter is that this code now follows the rules of the scheduled environment, which means two things:

  • the processing time is limited, and can be spread over multiple frames, which means
  • it's very slow when doing lots of work

 

To get around this, I added some functionality I call a Call Queue.
This is a missionEventHandler that runs on "EachFrame" and processes an array of deferred scripts.

 

While in any spawn{} script, I wrap my work up into a script and submit it to the Call Queue, so that it will run unscheduled (i.e. as fast as possible) at the next opportunity.

 

I have a separate Call Queue for both the server and the client. The server implementation looks like this:

//Init the array
GAME_CALLQUEUE = [];

//Init the server-side call queue loop
private _callQueueEHID = addMissionEventHandler ["EachFrame", {
    //Create a new array to store scripts that need to be kept until the next frame
    private _newArr = [];
    
    //Loop everything
    {
        _x params [
            "_testParams", "_testScript",
            "_params", "_script",
            "_doneParams", "_doneScript"];
            
        //Are the test conditions met?
        if (_testParams call _testScript) then
        {
            //Yes, run the core block of script
            _params call _script;
            
            //Re-add the data to the queue if it is not 'done'
            if (!(_doneParams call _doneScript)) then {
                _newArr pushBack _x;
            };
        } else {
            //No, put back in the queue for the next frame
            _newArr pushBack _x;
        }
    }
    forEach GAME_CALLQUEUE;
    
    //Update the array
    GAME_CALLQUEUE = _newArr;
}];


A basic example of how to use it is:

//Update loop for creating/destroying and unhiding/hiding NPC units for players
//The outer code runs in a spawn, so it can have a delay between calls
[] spawn {
    while {true} do
    {
        GAME_CALLQUEUE pushBack [
            [], {true}, //No test script
            [], {
                _this call ModName_fnc_UpdateNPCVisibility; //Simply calls an existing function, but now in an unscheduled environment
            },
            [], {true} //No done script
        ];
        
        //Wait before we run again
        sleep 5.0;
    };
};


A slightly more complicated pseudo-example, which runs when a condition is met:

//Run some code that executes when the player nears a waypoint position
private _player = ...
private _waypointPosition = [ ... ]

WAYPOINT_DISTANCE_CHECK = 100.0;

GAME_CALLQUEUE pushBack [
    [_player, _waypointPosition], {
        //Test returns true when the player is close enough to the waypoint
        (_this # 0) distance2D (_this # 1) < WAYPOINT_DISTANCE_CHECK
    },
    [_player], {
        //Do something to the player
        ...
    },
    [], {true} //No done script
];


Another example, which runs constantly until a condition is met:

//Reset the player's health until they leave the waypoint
private _player = ...
private _waypointPosition = [ ... ]

WAYPOINT_DISTANCE_CHECK = 100.0;

GAME_CALLQUEUE pushBack [
    [], {true}, //No test script
    [_player], {
        //Keep the player alive
        (_this # 0) setDamage 0.0;
    },
    [_player, _waypointPosition], {
        //Test returns true, and deletes the script, when the player is far enough from the waypoint
        (_this # 0) distance2D (_this # 1) > WAYPOINT_DISTANCE_CHECK
    }
];


While all of these examples could (and probably should) be run on a spawn{} with a while{} loop, I should repeat that Call Queue is meant for hefty code than needs to run on a single frame.

 

I use this system a lot on the client to wait for server responses, usually receiving lots of data, and then process it immediately on the next frame. This was often important for creating many UI controls quickly, otherwise it might take several frames to populate with data and appear very laggy.
On the server, all update loops (such as those for player/NPC/AI handling) make use of Call Queue, sleeping for intervals and then doing the heavy processing in the unscheduled environment.

 

I think a new EH was added to Arma 3, very late into Bearing's development, that could do something similar to the above, but I kept using this system as it was more flexible (I think, I don't remember much about the new EH).

 

 

SQM Merging - Dealing with multiple mission SQMs

 

The base Bearing SQM has a lot of entities, so many that doing edits and testing around all the custom objects can be frustrating.
To deal with this, I split the workload into multiple non-binary SQM files and merge them together when building the Bearing project.

 

AFAIK there is no existing way to automate this, so I wrote my own.

 

He6yLR9.jpg

 

A list of SQM files are provided, and parsed for Layers that begin with "EXPORT".
Anything inside those layers is stored, while everything else is discarded. This allows each SQM to have debug objects, if needed, without having to worry about them being included in the final mission file.

 

hJZYunO.jpg     99ISAAs.jpg     BYinlLe.jpg

 

 

The C# code below shows how the list of mission files is parsed and assembled:

//NOTE: The first SQM provided must be the one with the player spawn data, which is a minor limitation

//Read all input SQM files into Lists of strings
Dictionary<string, List<string>> missionLines = new Dictionary<string, List<string>>();
foreach (string sqmPath in s_sqmFilePaths)
{
    if (!Program.GetLinesFromSQM(sqmPath, ref missionLines, false)) return false;
}

//Gather the data into buckets which we'll combine at the end
//Objects are standard entities, groups are for player spawn data
List<List<string>> objects = new List<List<string>>();
List<List<string>> groups = new List<List<string>>();

//Process the SQM lines and find relevant mission entities
foreach (KeyValuePair<string, List<string>> kvp in missionLines)
{
    //Get where the entities start - this is also the point at which the header data will stop
    int idxStart = kvp.Value.FindIndex(x => x.Contains("class Entities"));
    if (idxStart == -1)
    {
        Program.WriteError("Failed to find entity start line for mission data file \"" + kvp.Key + "\"");
        return false;
    }
    //If this is the first mission in the list, then this is the one we want to extract the group/player entities from
    bool isPlayerData = kvp.Key.CompareTo(s_sqmFilePaths[0]) == 0;
    if (isPlayerData)
    {
        //Copy the header directly to the mission data
        s_missionData.Clear();
        for (int i = 0; i < idxStart; i++) s_missionData.Add(kvp.Value[i]);
    }
    //Loop the rest of the data looking for groups (if necessary) or objects (if in a layer flagged to export)
    string exportLayerCloseCheck = "";
    int lastExtractedCount = 0;
    for (int i = idxStart; i < kvp.Value.Count; i++)
    {
        //We've found an object or group
        bool isObject = kvp.Value[i].Contains("dataType=\"Object\"");
        bool isGroup = kvp.Value[i].Contains("dataType=\"Group\"");
        bool isLayer = kvp.Value[i].Contains("dataType=\"Layer\"");
        bool isLayerClose = kvp.Value[i].CompareTo(exportLayerCloseCheck) == 0;
        if (isLayer)
        {
            //Get the name of the layer, which is always the line after
            string nameLine = kvp.Value[i + 1];
            if (!nameLine.Contains("name=\""))
            {
                Program.WriteError("Mission layer found with no name in file \"" + kvp.Key + "\"");
                return false;
            }
            //Check for the layer starting with EXPORT
            //NOTE: It might just be that, so don't check for any trailing characters
            bool wasExportLayer = exportLayerCloseCheck.Length > 0;
            int idx = nameLine.IndexOf("name=\"EXPORT");
            if (idx > -1)
            {
                //Error if we're already exporting - no nesting allowed
                if (wasExportLayer)
                {
                    Program.WriteError("Mission export layer found while already in export mode in file \"" + kvp.Key + "\"");
                    return false;
                }
                //Trim off the tabs and close the bracket
                exportLayerCloseCheck = nameLine.Substring(0, idx) + "};";
            }
        }
        //Extract if we're an object or a group (but only when we are looking for them)
        if ((isObject && exportLayerCloseCheck.Length > 0) || (isGroup && isPlayerData))
        {
            //Get the end line of the object to look for
            string endLine = kvp.Value[i - 1];
            endLine = endLine.Substring(0, kvp.Value[i - 1].Length - 1) + "};";
            //Get the start/end range for the lines
            int startIdx = i - 2;
            int endIdx = kvp.Value.FindIndex(startIdx, x => x.CompareTo(endLine) == 0);
            if (endIdx == -1) return false;
            //Copy the lines out
            List<string> tmpLines = new List<string>();
            for (int j = startIdx; j <= endIdx; j++)
            {
                tmpLines.Add(kvp.Value[j]);
            }
            if (isObject) objects.Add(tmpLines);
            else groups.Add(tmpLines);
            //Skip over all the lines we've taken
            i = endIdx;
            if (isObject) lastExtractedCount++;
        }
        //End any exporting
        if (isLayerClose)
        {
            if (exportLayerCloseCheck.Length == 0)
            {
                Program.WriteError("Mission export layer closing when not in export mode in file \"" + kvp.Key + "\"");
                return false;
            }
            exportLayerCloseCheck = "";
            //Sanity check layers were correctly extracted
            if (lastExtractedCount == 0)
            {
                Program.WriteError("Mission layer failed to export any objects in file \"" + kvp.Key + "\"");
                return false;
            }
            lastExtractedCount = 0;
        }
    }
}

//Shuffle the objects and groups, just because
//This means we get a different mission every time we build, but the entire project is obfuscated too, so who cares
System.Random r = new System.Random();
objects = objects.OrderBy(x => r.Next()).ToList();
groups = groups.OrderBy(x => r.Next()).ToList();

//Start to add everything
s_missionData.Add("class Entities{items=" + (objects.Count + groups.Count) + ";");
//Add all of the objects first
int id = 1; //Starts at 1
for (int i = 0; i < objects.Count; i++)
{
    //Now add the rest of the lines, trim out the brackets and add those manually
    //Replace the item number for the first line
    string line = "class Item" + i + "{";
    for (int j = 2; j < objects[i].Count - 1; j++)
    {
        string tmp = objects[i][j].Trim();
        //Replace the id
        if (tmp.Contains("id="))
        {
            line += "id=" + (id++) + ";";
        }
        else if (tmp.Contains("name="))
        {
            //Skip
        }
        else line += tmp;
    }
    s_missionData.Add(line + "};");
}
//Then the groups
for (int i = 0; i < groups.Count; i++)
{
    //Now add the rest of the lines, trim out the brackets and add those manually
    //Replace the item number for the first line
    string line = "class Item" + (objects.Count + i + "{");
    for (int j = 2; j < groups[i].Count - 1; j++)
    {
        string tmp = groups[i][j].Trim();
        //Replace the id
        if (tmp.Contains("id="))
        {
            line += "id=" + (id++) + ";";
        }
        else line += tmp;
    }
    s_missionData.Add(line + "};");
}
//End the brackets
s_missionData.Add("\t};");
s_missionData.Add("};");
s_missionData.Add("");

//Finally, write the mission file
string outputMissionPath = Program.missionRootPath + "mission.sqm";
FileStream missionFile = new FileStream(outputMissionPath, FileMode.Create, FileAccess.Write, FileShare.Read);
StreamWriter missionStreamWriter = new StreamWriter(missionFile);
foreach (string line in s_missionData)
{
    //Add each line
    missionStreamWriter.WriteLine(line);
}
//Done
missionStreamWriter.Close();
missionFile.Close();

 

The final output mission is then binarised by MakePBO.

 

 

Obfuscation - Protecting your scripts (kinda) from theft (sorta)

 

Where I've worked in the past, game obfuscation is part of the job.
While considering that, along with an apparent history of 'script theft' in the Arma community, I wondered just how much something like Bearing could be obfuscated.

 

Obfuscation doesn't really protect anything for long, so it was up to me to go as far as I wanted with it. It ended up being pretty fun, and I learned a lot.

 

There are a few things that Bearing does to help make things difficult for someone wanting to be cheeky, and I'll highlight a few of them here.

 

 

Function Obfuscation

 

When you connect to a server, you're often downloading a PBO which contains all the assets necessary to play on that server.
This PBO can include many things, such as textures, audio, and scripts.
Bearing, however, contains barely any scripts at all.

 

If someone can poke around in a PBO, they can see your scripts. So what if the script and the functions within them only existed for as long as you were connected to the server?
Functions are just variables, so if the server can set a variable on the player then it can also set a script.

 

Taking advantage of Arma 3's behaviour of clearing all variables when leaving a server (which doesn't actually work properly) I decided to try serving all necessary functions and variables to the player only when they connect.
The command allVariables is blocked on non-dev servers, so in theory both functions and variables should be safe from inspection. I wouldn't be surprised if there was a way around it, but I didn't go looking to find out.

 

On the server mod, all client-side scripts are defined like this:

[
    "BRGF_FUNCTIONNAME",                //The name of the function - the BRGF_ prefix allows other tools to find functions easily for processing later
    runAsSpawn,                         //Whether the function should be run as a 'call' or a 'spawn' by default
    {
        //The actual function script
        ...
    }
];

 

A working example of this is:

GAME_CLIENT_FUNCTIONS pushBack [
    "BRGF_GETCLOSESTPOINTINAREA2D",
    false,
    {
        disableSerialization;
        params ["_inPos", "_inAreaPoints"];
        private _closestPos = [0,0,0];
        private _closestDistance = 1000000;
        private _pointsCount = count _inAreaPoints;
        for "_i" from 0 to (_pointsCount - 1) do {
            private _startPos = _inAreaPoints # _i;
            private _endPos = _inAreaPoints # ((_i + 1) % _pointsCount);
            //NOTE: BIS_fnc_nearestPoint doesn't work properly
            private _tmpPos = [_inPos, _startPos, _endPos, true] call BRGF_GETCLOSESTPOINTONLINE2D;
            private _tmpDist = _tmpPos distance2D _inPos;
            if (_tmpDist < _closestDistance) then {
                _closestDistance = _tmpDist;
                _closestPos = _tmpPos;
            };
        };
        //Return the position and distance
        [_closestPos, _closestDistance];
    }
];

 

The function BRGF_GETCLOSESTPOINTONLINE2D doesn't strictly exist, as we declared it in an array elsewhere, so the line that calls it would typically fail.
Later on, during the preprocessing stage, this function name will be replaced with something else that allows the call to work (shown later in the local variable obfuscation section).

 

Each of these GAME_CLIENT_FUNCTIONS is processed and sent to the client like this:

{
    //Get the function name (which has already been obfuscated by this point)
    private _functionName = _x # 0;
    
    //Get the code and convert to a string
    private _codeString = str (_x # 2);

    //Use the DLL to handle the find/replace of various obfuscated names
    _codeString = ...

    //And to obfuscate the local variables
    _codeString = ...

    //The compileFinal will wrap the code in more {}s so take advantage of that to add call or spawn to make sure the inner code is executed
    //Do we want a spawn or call?
    if (_x # 1) then { _codeString = "_this spawn " + _codeString; }
    else { _codeString = "_this call " + _codeString; };
    //Compile it
    private _code = compileFinal _codeString;

    //Set each code variable individually
    [
        [
            _functionName,
            _code
        ],
        {
            //Add the function variable to the mission namespace, which will allow it to be deleted (sort of) after leaving the server
            missionNamespace setVariable _this;
        }
    ] remoteExecCall ["call", _player, _player];
}
forEach GAME_CLIENT_FUNCTIONS;


There are two undefined steps in the above function:

  • find/replace obfuscated names
  • obfuscate the local variables

 

When the client connects, a random selection of strings is generated for them:

//Get the total of new strings needed
private _neededTotal = (count GAME_SERVER_FUNCTIONS) + (count GAME_CLIENT_FUNCTIONS) + (count GAME_CLIENT_VARIABLES);

//Request them all at once
private _names = [
    _neededTotal,             //How many strings are needed
    8,                        //Min length
    16,                       //Max length
    true                      //Allow mixed case letters
] call GiragastBearing_fnc_GenerateRandomStrings; //Get the DLL to generate some strings for us

//Assign them to the player
_player setVariable ["BRGSV_SHUFFLENAMES_SERVERFUNCTIONS", _names select [0, count GAME_SERVER_FUNCTIONS]];
_player setVariable ["BRGSV_SHUFFLENAMES_CLIENTFUNCTIONS", _names select [count GAME_SERVER_FUNCTIONS, count GAME_CLIENT_FUNCTIONS]];
_player setVariable ["BRGSV_SHUFFLENAMES_CLIENTVARIABLES", _names select [(count GAME_SERVER_FUNCTIONS) + (count GAME_CLIENT_FUNCTIONS), count GAME_CLIENT_VARIABLES]];

 

These randomised strings are used to replace the server function names, client function names, and client variable names, before they are compiled and sent to the client.

 

This set of strings is recreated every time the player connects, meaning that every function and variable must be re-obfuscated on every connection.
There is an implied overhead in doing this - we have to send dozens of hefty scripts (made slightly smaller by obfuscation) from the server, instead of them being cached in a PBO that is downloaded only once.
If I wasn't hosting the alpha server myself, I'd be concerned about chewing through a server hosting data allowance. However, I don't have any data to back up this concern, so it may also not be an actual problem.

 

A second issue is that, due to an Arma 3 bug (or intended behaviour), the memory taken up by these scripts and variables isn't cleared properly until the client goes back to the main menu - disconnecting to the server browser isn't enough!
This meant that during testing, where I'd disconnect and sit on the server browser most of the time, the memory used would increase because each connection makes unique scripts and variables - until Arma 3 crashed.

This was a problem I didn't manage to find a workaround for.

 

Meanwhile, obfuscating the local variables is an interesting process, which attempts to take all local variables (those with a leading underscore) and turn them into gibberish.
While I could have used random strings, I thought I'd try something silly and ended up keeping it.

 

A before-after comparison of the example client function above is shown below:

INFO: In:

{
    disableSerialization;
    params [""_inPos"", ""_inAreaPoints""];
    private _closestPos = [0,0,0];
    private _closestDistance = 1000000;
    private _pointsCount = count _inAreaPoints;
    for ""_i"" from 0 to (_pointsCount - 1) do {
        private _startPos = _inAreaPoints # _i;
        private _endPos = _inAreaPoints # ((_i + 1) % _pointsCount);
        private _tmpPos = [_inPos, _startPos, _endPos, true] call aPgqeVum;    //Note the already-obfuscated function name here!
        private _tmpDist = _tmpPos distance2D _inPos;
        if (_tmpDist < _closestDistance) then {
            _closestDistance = _tmpDist;
            _closestPos = _tmpPos;
        };
    };
    [_closestPos, _closestDistance];
}

INFO: Out (440 -> 378): //Shows the string length difference. Ideally we want the number to always be smaller after obfuscation

{disableSerialization;params[""_lii"",""_LiIiLI""];private _llLLl=[0,0,0];private _III=1000000;private _LILI=count _LiIiLI;for""_IlLll""from 0 to(_LILI-1)do{private _iLL=_LiIiLI#_IlLll;private _LIlIli=_LiIiLI#((_IlLll+1)%_LILI);private _lLILi=[_lii,_iLL,_LIlIli,true]call aPgqeVum;private _LLi=_lLILi distance2D _lii;if(_LLi<_III)then{_III=_LLi;_llLLl=_lLILi;};};[_llLLl,_III];}

 

All white-space is removed in the final result, so here it is again with line breaks:

{
    disableSerialization;
    params[""_lii"",""_LiIiLI""];
    private _llLLl=[0,0,0];
    private _III=1000000;
    private _LILI=count _LiIiLI;
    for""_IlLll""from 0 to(_LILI-1)do{
        private _iLL=_LiIiLI#_IlLll;
        private _LIlIli=_LiIiLI#((_IlLll+1)%_LILI);
        private _lLILi=[_lii,_iLL,_LIlIli,true]call aPgqeVum;
        private _LLi=_lLILi distance2D _lii;
        if(_LLi<_III)then{
            _III=_LLi;
            _llLLl=_lLILi;
        };
    };
    [_llLLl,_III];
}

 

Not impossible to figure out, but the mix of I, i, L and l helps to make it a little more annoying. (A quick find & replace will quickly make that more readable).

 

NOTE: make sure to always prefix your local variables with private.
It's not supposed to happen, but the script compiler will sometimes have variable type errors, like string VS control, for local variables that share the same name but are in different functions.
I have no idea why it sometimes does this, as the compiler should be checking for the leading underscore and scopes. Making sure to always use private solves this.

 

All the above happens on the server while the client is connecting, but there is still plenty we can do beforehand.

 


Preprocessor Obfuscation

 

Using a preprocessor (which was the first time I'd written one of my own), I was able to make all sorts of tweaks to the scripts before they were built into the project.

 

If you've had to use Arma 3's UI, you'll know that the built-in interfaces and controls have hardcoded IDs.
That's great if you want to make some changes to them, which I do mostly in the main map view, but it's something that makes your UI vulnerable.
Bearing wasn't going to have any real problems with people making scripts that hooked into the custom UI (everything is validated on the server), but why not make it tricky anyway?

 

Every Bearing UI element, whether it's a display or a control, has a #define created for it.
The define is where the ID is set, and also where the preprocessor randomises it each time the addon is built.

 

The source define looks like:

#T#RINTGS,100,5000,8,true#
#define BRGD_SUBMENU_HANDBOOK_IDC_UNAVAILABLETEXT            #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_LEFTCONTROLGROUP           #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_RIGHTCONTROLGROUP    	     #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_LEFTBACKGROUND             #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_RIGHTBACKGROUND            #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_TREE                       #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_TEXTBOXBG                  #T#RINTGV#
#define BRGD_SUBMENU_HANDBOOK_IDC_TEXTBOXTEXT                #T#RINTGV#
#T#RINTGE#

 

The output we can get from this is:

#define BRGD_SUBMENU_HANDBOOK_IDC_UNAVAILABLETEXT            1622
#define BRGD_SUBMENU_HANDBOOK_IDC_LEFTCONTROLGROUP           1969
#define BRGD_SUBMENU_HANDBOOK_IDC_RIGHTCONTROLGROUP          2053
#define BRGD_SUBMENU_HANDBOOK_IDC_LEFTBACKGROUND             2920
#define BRGD_SUBMENU_HANDBOOK_IDC_RIGHTBACKGROUND            3092
#define BRGD_SUBMENU_HANDBOOK_IDC_TREE                       3528
#define BRGD_SUBMENU_HANDBOOK_IDC_TEXTBOXBG                  3790
#define BRGD_SUBMENU_HANDBOOK_IDC_TEXTBOXTEXT                4322

 

#T#RINTGS,100,5000,8,true#            tells the preprocessor to generate 8 random integers, from 100 to 5000, in a linear order (because that matters for UI sorting)
#T#RINTGV#                            selects the next generated value
#T#RINTGE#                            ends the randomise block (so we can error check that all values were used)

 

This should effectively prevent external scripts from using fixed IDs, so long as project rebuilds are frequent enough.

 

But what if my scripts are referencing Arma 3's hardcoded IDs?
Luckily for us, Arma 3 is very flexible when it comes to processing numbers.

 

The example below will take the given value (12 or 51 here) and swizzle it into something else:

private _dispMap = findDisplay #T#INTMASK,12#;
private _ctrlMap = _dispMap displayCtrl #T#INTMASK,51#;

 

This becomes:

private _dispMap = findDisplay 0xC;
private _ctrlMap = _dispMap displayCtrl 0x33;

Or:

private _dispMap = findDisplay (0x4 + 0x8);
private _ctrlMap = _dispMap displayCtrl ($11 * 0x3);

Or:

private _dispMap = findDisplay (0x6 + $6);
private _ctrlMap = _dispMap displayCtrl ($E + 37);

 

Or any combination of decimal, hexadecimal, addition, subtraction, division and multiplication.

 

It can also generate random strings, which is mostly used to create unique UI display and control names:

//Replacement defines for standard controls
#define BRGRSC_Text                        #T#RSTR,6,13,MIXED,controls#
#define BRGRSC_StructuredText              #T#RSTR,6,13,MIXED,controls#
#define BRGRSC_Picture                     #T#RSTR,6,13,MIXED,controls#
...

//Custom controls
#define BRGRSC_TextRight                   #T#RSTR,6,13,MIXED,controls#
#define BRGRSC_TextMiddleRight             #T#RSTR,6,13,MIXED,controls#
#define BRGRSC_TextCenterMiddle            #T#RSTR,6,13,MIXED,controls#
...

 

This becomes:

#define BRGRSC_Text                        nJuFcuZK
#define BRGRSC_StructuredText              hAsVRJUBEsO
#define BRGRSC_Picture                     IoLRuL
...
#define BRGRSC_TextRight                   PbMPFbfiDYk
#define BRGRSC_TextMiddleRight             PjtBHWDB
#define BRGRSC_TextCenterMiddle            yksrLUHxnLCO
...

 

#T#RSTR,6,13,MIXED,controls#               This asks for a 6-13 length string, with mixed casing, and to store the output in a group called 'controls'

 

The group name makes sure that there can be no repeated results for any string within that group (although very unlikely in this example).

 

I also never use the base class name for standard controls when creating them, and only reference them by a BRGRSC_ that clones it:

class BRGRSC_Text : RscText {};
class BRGRSC_StructuredText : RscStructuredText {};
class BRGRSC_Picture : RscPicture {};

 

This allows a script, before we even send it to MakePBO, to look like this:

private _disp = uiNamespace getVariable "rWQfaKKuKxO";
private _ctrl = _disp ctrlCreate ["nJuFcuZK", 0x19E];

 

MakePBO has its own obfuscation techniques, but that's a closed system and not particularly useful for learning.

 

The preprocessor has a few additional benefits:

  • Imports all #includes in advance, reporting any missing files
  • Errors on duplicate #defines
  • Warns about all unreferenced #defines, so they can be cleaned up
  • Strips all comments
  • Errors on remaining BRGX_ prefixes which weren't obfuscated properly (very useful, suggests there are missing defines)

 

Although it's handled by the C# project builder (BearingDataCompiler), and not the preprocessor (ModPreprocessor), I also rename all .paa and .ogg files to random strings:

 

TDfYhHV.jpg

 

 

And generate defines to accompany them. The defines are automatically named based on the source image filename:

#define BRGP_AREATEXTURE        hvsXIiSGzRO.paa
#define BRGP_AREATEXTURE_SLIM   tjmtUjVZWx.paa
#define BRGP_ARROWBIG_LEFT      jscZsuASH.paa
...

 

These are automatically included, and means I only need to reference a .paa by its define to make use of it - such as areatexture.paa being referenced by BRGP_AREATEXTURE:

#define BRGD_SUBMENU_GEAR_UNLOCKCELL_INACTIVE_PAA                    ((parsingNamespace getVariable "MISSION_ROOT") + "BRGP_AREATEXTURE")

_ctrlPicture ctrlSetText BRGD_SUBMENU_GEAR_UNLOCKCELL_INACTIVE_PAA;


 

Bearing is a four year old project, so there are countless interesting little scripting and coding things that evolved over time.

Hopefully you enjoyed reading about some of them here.

Share this post


Link to post
Share on other sites

Please sign in to comment

You will be able to leave a comment after signing in



Sign In Now

×