im_an_engineer
Member-
Content Count
20 -
Joined
-
Last visited
-
Medals
Community Reputation
73 ExcellentAbout im_an_engineer
-
Rank
Private First Class
Recent Profile Visitors
The recent visitors block is disabled and is not being shown to other users.
-
I had a peek at reddit, but it was pretty clear at no self-promotion, so I dropped a few screenshots in Discord and posted here. Seemed like the best option at the time! How much RAM have you got? Do you know if you're playing the 64bit client?
-
It's time for the final Bearing development post. This one is going to be a collection of smaller examples and prototypes that don't need an entire post about them. Underwater beacons There are a few repair tasks in Bearing that involve diving underwater and 'tagging' some wave power generators. Arma 3's water is very murky, which is great, but also makes it a little more difficult that I'd like to find the generators. I hooked up an effect to the Fast Travel Handset which activates a flashing light and particle effect. It's cool seeing the lights in the gloom. Click the image below for a video example Sun spots An unused effect - staring at the sun too long will harm your eyes for a little bit. This is an interface that creates lots of stacked RscPicture controls referencing "\a3\ui_f\data\igui\cfg\radar\radarbackground_ca.paa" and adjusts their colour over 18 seconds. The position of each sun spot is found by using llw_fnc_getSunAngle (found here) to get the sun angle and azimuth. A vector built from the sun values is then extended 2000.0 from eyePos and the end position is translated to UI coords. Click the image below for a video example Isolines This test came from trying to implement some sort of manual isolines (map contour lines). Click the image below for a video example This was done by generating a grid of RscText controls and then setting the background colour of them based on their heights against some 'isoline boundaries'. When the average height (from the 4 corners) of each grid cell overlapped one of the boundary heights, it was coloured black - otherwise white. As you can tell, it's very laggy. But, it did lead to another much cooler prototype. Click the image below for a video example Here the lines are pinched by 'nulls' which act a bit like a black hole. A little interpolation between the boundary line distances was added, along with alternating colours, so they appear to be moving outwards. Imagine a handset that detects STALKER-like anomalies, and you'll get where I was headed with this. This was effect done by using a blank Map control along with drawTriangle, which is how I pull off most of my favourite things about Bearing. Such as the next example! drawTriangle If you've played Bearing, you've seen this: Click the image below for a video example drawTriangle is by far my favourite command in SQF. Here's a config for a blank Map control, which took a little while to figure out (I used to draw a fill rectangle over the standard map before figuring this out): class MapControlBlank : RscMapControl { showMarkers = 0; maxSatelliteAlpha = 0.0; colorGrid[] = {0,0,0,0}; colorGridMap[] = {0,0,0,0}; //NOTE: 1.90 bug needs this line added widthRailWay = 0; colorBackground[] = { 0.00, 0.00, 0.00, 1.00 }; colorText[] = { 0.00, 0.00, 0.00, 0.00 }; colorSea[] = { 0.00, 0.00, 0.00, 0.00 }; colorForest[] = { 0.00, 0.00, 0.00, 0.00 }; colorRocks[] = { 0.00, 0.00, 0.00, 0.00 }; colorCountlines[] = { 0.00, 0.00, 0.00, 0.00 }; colorMainCountlines[] = { 0.00, 0.00, 0.00, 0.00 }; colorCountlinesWater[] = { 0.00, 0.00, 0.00, 0.00 }; colorMainCountlinesWater[] = { 0.00, 0.00, 0.00, 0.00 }; colorForestBorder[] = { 0.00, 0.00, 0.00, 0.00 }; colorRocksBorder[] = { 0.00, 0.00, 0.00, 0.00 }; colorPowerLines[] = { 0.00, 0.00, 0.00, 0.00 }; colorNames[] = { 0.00, 0.00, 0.00, 0.00 }; colorInactive[] = { 0.00, 0.00, 0.00, 0.00 }; colorLevels[] = { 0.00, 0.00, 0.00, 0.00 }; colorRailWay[] = { 0.00, 0.00, 0.00, 0.00 }; colorOutside[] = { 0.00, 0.00, 0.00, 0.00 }; colorTracks[] = { 0.00, 0.00, 0.00, 0.00 }; colorRoads[] = { 0.00, 0.00, 0.00, 0.00 }; colorMainRoads[] = { 0.00, 0.00, 0.00, 0.00 }; colorTracksFill[] = { 0.00, 0.00, 0.00, 0.00 }; colorRoadsFill[] = { 0.00, 0.00, 0.00, 0.00 }; colorMainRoadsFill[] = { 0.00, 0.00, 0.00, 0.00 }; colorTrails[] = { 0.00, 0.00, 0.00, 0.00 }; colorTrailsFill[] = { 0.00, 0.00, 0.00, 0.00 }; //Watertower = {}; sizeEx = 0; sizeExLabel = 0; sizeExGrid = 0; sizeExUnits = 0; sizeExNames = 0; sizeExInfo = 0; sizeExLevel = 0; showCountourInterval = 0; drawObjects = false; class Legend { x=0;y=0;w=0;h=0; font="RobotoCondensed"; sizeEx=1; colorBackground[]={0,0,0,0}; color[]={0,0,0,0}; }; }; Keep in mind that you'll have to convert everything to map coords, respecting the current zoom level, so it's not very straight-forward. You need to get the top-left and bottom-right UI positions in world space and use that: //Get some worldspace positions from the map to size everything to private _layoutMap = ctrlPosition _ctrlMap; private _mapTL = _ctrlMap ctrlMapScreenToWorld [_layoutMap # 0, _layoutMap # 1]; private _mapBR = _ctrlMap ctrlMapScreenToWorld [(_layoutMap # 0) + (_layoutMap # 2), (_layoutMap # 1) + (_layoutMap # 3)]; Having a drawTriangleUI, which takes UI coord values and doesn't require a Map control, would be amazing for fancy UI animations. Binocular shine This was added to that other players could be more easily spotted from a distance, and introduce a little position awareness. Bearing doesn't have any PVP-like elements, but if it did (and some were planned), I wanted something to help make things more interesting. The prototype adds a sun glint when other players were using binoculars, which also correctly used the sun's position (again using llw_fnc_getSunAngle). I specifically didn't want a sniper-scope-like flash that happens no matter where the sun is. I used the NPC head direction (via eyeDirection) in the test example below, so the glint would respect the minor angle changes that come from the NPC idle animation. This subtle variation in angle is what makes the glint flash that bit more authentic. Click the image below for a video example Unfortunately, it doesn't work perfectly because Arma 3 applies a vertical offset to units, which changes depending on what surface is being stood on. This allows for units to 'sink' into grass when prone, etc. That vertical offset information isn't available to scripts, and the offset doesn't apply to commands like eyePos so there's nothing that can be done to correctly sync the position of the unit model and the flash. Click the image below for a video example At a distance it's fine because the glint is quite large, but using binoculars quickly reveals the problem. This is why the feature didn't make it into Bearing. Filament walker This was the first test I made using drawLine3D, before I started on any of the Pylon designs and visuals. I called them 'filaments' at the time, due to the thin line effect. Click the image below for a video example Made over a day or two, figuring out how to do IK limbs in Unity and then porting the code over to SQF, it walks along the terrain and properly aligns to the height of the ground it's standing on. I'm still a big fan of how the lines break up and disintegrate at the end. The plan was to have them appear all across Altis, once every 9 hours, during the brief period when time 'resets' in Bearing (which might have become a time-loop backstory later on, but I left it unexplained). Rock spin While developing the effects system, I thought I'd try out creating some objects to move around with them. Click the image below for a video example The rocks have collision and you can briefly stand on top of them, if you try hard enough. Signal strength This map feature already exists in Bearing, but is locked behind the last available NPC in the alpha content. The subsequent tasks, if more content was added, involved the NPC repairing a signal monitor handset for you, which revealed the impact that Pylons and markers were having on radio signal absorption. Click the image below for a video example It helps to give a way to track Job progression across the player's lifetime, with the map becoming clearer as more Jobs are completed and showing the player that they are having an impact on the world. The two examples below show earlier tests. The first is made from a bunch of different shapes overlaid on top of each other - you can also see how laggy it gets when regenerating every so often. The second is built from banded areas of signal, like the example above. Both also animate slightly, something which is disabled in the final version because it was distracting (although cool). Click the images below for video examples Chat scripts Bearing's chat system was inspired by Inkle's Ink scripting language but eventually became its own awkward and sprawling thing. Each conversation begins with a single starting chat script, which then forks out to others based on the available responses. These responses are often made available by testing player data, such as tasks completed, and can trigger code both on the server and the client to support their content. Most of the unlocks a player receives happens through these chat scripts. The example below shows one such chat script, which is owned by the first NPC that the player talks to. When this chat script is selected, the top script block runs on the server, and the next runs on the client. The block of text below that, which is full of various tags that the preprocessor will convert to proper NPC names and bold/colour tags, is shown on the client. The final array lists all of the available responses that the player can choose. The next example shows how chat responses can be hidden based on the result of any script that the developer (me) cares to write. There is extra functionality to the system, added in the first development pass, that I ultimately didn't find a use for - such as code that runs when the player chooses or doesn't choose a response. All chat scripts were written by hand - had Bearing's development had continued I would have written a visual tool to help put these scripts together more easily. This tool would have also included an in-tool preview system so all tags and text can be inspected without having to launch the client and check in-game. That sort of thing is a significant time-saver. Task scripts Tasks are the 'quests' of Bearing, and these scripts are what do the heavy lifting behind the scenes. They attach themselves to 'event listeners' which pass along event information as it happens. If the event matches what it's looking for, then the task script will process the event. The available task event listeners in Bearing, and the data they transfer, are: #define TESTVAR_LISTENERTYPE_NPCFIRSTMET 0 //[_player, _npcID] #define TESTVAR_LISTENERTYPE_NPCCHAT 1 //[_player, _npcID, _chatID] #define TESTVAR_LISTENERTYPE_JOBCOMPLETE 2 //[_player, _jobID, _jobTotalScore] #define TESTVAR_LISTENERTYPE_JOBFIRSTCOMPLETE 3 //[_player, _jobID, _jobTotalScore] #define TESTVAR_LISTENERTYPE_TRIGGERENTER 4 //[_trigger, _triggerList] #define TESTVAR_LISTENERTYPE_TRIGGEREXIT 5 //[_trigger, _triggerList] #define TESTVAR_LISTENERTYPE_TRIANGULATEADD 6 //[_player, _triangulateMark] #define TESTVAR_LISTENERTYPE_FTPUNLOCKED 7 //[_player, _ftpID] #define TESTVAR_LISTENERTYPE_POWERCHANGED 8 //[_player] #define TESTVAR_LISTENERTYPE_FTPTRAVELLED 9 //[player, _distance, _wasPickup] #define TESTVAR_LISTENERTYPE_FTPHANDSETUSED 10 //[player] There are various types of task: #define TESTVAR_TASKTYPE_NPCSMET 0 //Has met specific NPCs, e.g. NPCREF_FACTORIES_WEST, attaches to listener TESTVAR_LISTENERTYPE_NPCFIRSTMET #define TESTVAR_TASKTYPE_NPCCHAT 1 //Has started a specific chat script with a specific NPC, e.g. NPCREF_BASECAMP_DOCK,CHATREF_basecamp_dock_needmap2, attaches to listener TESTVAR_LISTENERTYPE_NPCCHAT #define TESTVAR_TASKTYPE_JOBSCOMPLETE 2 //Has completed a specific set of Jobs, e.g. JOBREF_BASECAMP_DOCK_J1,JOBREF_BASECAMP_DOCK_J3, attaches to listeners TESTVAR_LISTENERTYPE_JOBFIRSTCOMPLETE #define TESTVAR_TASKTYPE_INTERACT 3 //Has interacted with a world object, e.g. UniformCrate_1, doesn't attach to a listener #define TESTVAR_TASKTYPE_TRIGGERENTER 4 //Has entered a trigger volume, e.g. 4228.113,10794.56,192.551,5,5,0,false,2.0 (manually defined bounds), attaches to listener TESTVAR_LISTENERTYPE_TRIGGERENTER #define TESTVAR_TASKTYPE_TRIGGEREXIT 5 //Has exited a trigger volume, attaches to listener TESTVAR_LISTENERTYPE_TRIGGEREXIT #define TESTVAR_TASKTYPE_TRIANGULATEADD 6 //Has created a new Survey mark, params are tested within the script, attaches to listener TESTVAR_LISTENERTYPE_TRIANGULATEADD #define TESTVAR_TASKTYPE_FTPSUNLOCKED 7 //Has unlocked specific Fast Travel locations, e.g. FTPREF_BASETRAIL, attaches to listener TESTVAR_LISTENERTYPE_FTPUNLOCKED #define TESTVAR_TASKTYPE_JOBSCORE 8 //Has reached a specific Job score, e.g. 100, attaches to listeners TESTVAR_LISTENERTYPE_JOBCOMPLETE #define TESTVAR_TASKTYPE_NPCSCORE 9 //Has met a specific *number* of NPCs, e.g. 20, attaches to listener TESTVAR_LISTENERTYPE_NPCFIRSTMET #define TESTVAR_TASKTYPE_MANUAL 10 //Doesn't hook into any listener, the data is passed raw to the task script for it to parse, e.g. TASKREF_POWER_HELIOSTATREPAIR_ZAROS,SolarGlow_Zaros_Near,SolarGlow_Zaros_Far #define TESTVAR_TASKTYPE_POWERED 11 //Has powered a specific power network node, e.g. PWB1833, attaches to listener TESTVAR_LISTENERTYPE_POWERCHANGED Custom scripts exists for: TESTVAR_LISTENERTYPE_POWERCHANGED to update the map's power data visuals when something changes TESTVAR_LISTENERTYPE_FTPTRAVELLED to check when the total distance traveled is far enough to unlock a specific outfit piece TESTVAR_LISTENERTYPE_FTPHANDSETUSED to tell various things about a signal being sent, setting off FTP alarms or alerting nearby Pylons or Guards Like most games, a lot of the Bearing's data exists in spreadsheets. Here are the task definitions for the entirety of Bearing's tutorial: Each task has a list of scripts it can call when certain things happen to it, such as: #define TESTVAR_TASKSCRIPTTYPE_INITSERVER 0 //Run on every task when the server boots, does many things such as creating objects or linking to existing ones #define TESTVAR_TASKSCRIPTTYPE_INITINACTIVE 1 //Run on the client for each task that hasn't been started yet, can set up things like smoke effects on broken objects #define TESTVAR_TASKSCRIPTTYPE_INITACTIVE 2 //Run on currently active but incomplete task #define TESTVAR_TASKSCRIPTTYPE_INITCOMPLETE 3 //Run on all completed tasks, used to clear up various things (like effects, hide objects) #define TESTVAR_TASKSCRIPTTYPE_STEP 4 //Many tasks require multiple things to happen, this script is called each time one of them happens - a listener event will typically call this script //(also used to send update notifications and change progress bars, using TESTVAR_TASKSCRIPTTYPE_PROGRESS) #define TESTVAR_TASKSCRIPTTYPE_PROGRESS 5 //Specifically used to gather and arrange task progress data, can be called at any time #define TESTVAR_TASKSCRIPTTYPE_COMPLETE 6 //Run when the player completes a task #define TESTVAR_TASKSCRIPTTYPE_COMPLETECHECK 7 //If a task changes in the future, and the player has already completed it, this script exists to validate that the player has still completed it Here is an example of the task script handling for commonJobScore, which listens for job score changes. The scripts are broken up by headers, to avoid having to wrap individual scripts in {} and declare empty scripts. The headers are split into proper scripts and put into arrays during the project build, and the script names validated against the task spreadsheet data. ////////////////////////////////////////////////////////////////////////////// //SCRIPTREF_commonJobScore_initServer params ["_task", "_taskData"]; _taskData params ["_jobScore"]; //Register with the listener (GAME_LISTENERS # TESTVAR_LISTENERTYPE_JOBCOMPLETE) pushBack [ //Does this step script need to be called? Calls the next script if true { (_this # 0) params ["_player", "_inJobID", "_inJobLastTotalScore", "_inJobTotalScore"]; (_this # 1) params ["_taskID", "_reqJobScore"]; ( //Pass if the score has increased so we get progress updates _inJobTotalScore > _inJobLastTotalScore && {_player in (GAME_TASKSACTIVEPLAYERS # _taskID)} ); }, //The main listener script call { (_this # 0) params ["_player", "_inJobID", "_inJobLastTotalScore", "_inJobTotalScore"]; (_this # 1) params ["_taskID", "_reqJobScore"]; { [_player, _taskID, [_inJobLastTotalScore, _inJobTotalScore, _reqJobScore]] call (GAME_TASKSCRIPTS # _x); } forEach (((GAME_TASKS # _taskID) # TESTVAR_TASKDEF_SCRIPTS) # TESTVAR_TASKSCRIPTTYPE_STEP); }, //Some data to pass around [_task # TESTVAR_TASKDEF_TASKID, _jobScore] ]; //Task-specific data is not added to the task by default, do it now _task pushBack _jobScore; ////////////////////////////////////////////////////////////////////////////// //SCRIPTREF_commonJobScore_progress params ["_player", "_taskID"]; private _targetJobScore = (GAME_TASKS # _taskID) # TESTVAR_TASKDEF_TASKTYPEDATA; private _playerUID = _player getVariable "TESTVAR_PLAYERVAR_UID"; private _playerAccountID = _player getVariable "TESTVAR_PLAYERVAR_ACCOUNTID"; //Get all the current NPC states private _result = ["PlayerGetJobScore", [_playerAccountID]] call GiragastBearing_fnc_CallExtension; if (!(_result # 0)) exitWith { [_player, format ["Call to PlayerGetJobScore, as part of task %1 'progress' script, failed for player account ID ""%2"", kicking player (uid %3)\n\tReason:\n\t%4", _taskID, _playerAccountID, _playerUID, _result # 1]] call GiragastBearing_fnc_ErrorKick; }; private _jobScore = _result # 1; //No map hints private _mapHints = []; //Return the progress text [format ["%1 of %2 Completed", _jobScore, _targetJobScore], _mapHints]; ////////////////////////////////////////////////////////////////////////////// //SCRIPTREF_commonJobScore_step params ["_player", "_taskID", "_stepParams"]; _stepParams params ["_jobLastTotalScore", "_jobTotalScore", "_jobTargetScore"]; //If the score is met then deal with that if (_jobTotalScore >= _jobTargetScore) then { { [_player, _taskID] call (GAME_TASKSCRIPTS # _x); } forEach (((GAME_TASKS # _taskID) # TESTVAR_TASKDEF_SCRIPTS) # TESTVAR_TASKSCRIPTTYPE_COMPLETE); } else { //Not finished yet, notify about the task progress [_player, _taskID, _jobLastTotalScore, _jobTotalScore, _jobTargetScore] call GiragastBearing_fnc_NotifyTaskProgress; }; ////////////////////////////////////////////////////////////////////////////// //SCRIPTREF_commonJobScore_completeCheck params ["_player", "_taskID"]; private _targetJobScore = (GAME_TASKS # _taskID) # TESTVAR_TASKDEF_TASKTYPEDATA; private _playerUID = _player getVariable "TESTVAR_PLAYERVAR_UID"; private _playerAccountID = _player getVariable "TESTVAR_PLAYERVAR_ACCOUNTID"; //Get all the current NPC states private _result = ["PlayerGetJobScore", [_playerAccountID]] call GiragastBearing_fnc_CallExtension; if (!(_result # 0)) exitWith { [_player, format ["Call to PlayerGetJobScore, as part of task %1 'completeCheck' script, failed for player account ID ""%2"", kicking player (uid %3)\n\tReason:\n\t%4", _taskID, _playerAccountID, _playerUID, _result # 1]] call GiragastBearing_fnc_ErrorKick; }; private _jobScore = _result # 1; //Return whether the score has been met _jobScore >= _targetJobScore; It looks complex, and it is, but it's powerful and flexible enough to do almost every dynamic thing in the mod. That might just be the programmer in me talking, though. Who knows how a content scripter might react to it. Last words I had fun putting Bearing together over the past 4 years, although it would have been cool to get some more feedback so I could iterate on the designs some more. To date, two or three people have reached the end of the alpha content, and about 20 more played for over an hour and made some good progress. The vast majority, which is expected, play for a few minutes, then leave. (Strangely, since I moved house and have a worse internet connection, it's almost always exactly 5 minutes - maybe there's a bug I don't know about) There's a lot of fun stuff you can do with SQF, and hopefully Arma 4 will have same flexibility in whatever form the new language takes. Cheers.
-
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. 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. 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: 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.
-
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 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 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 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) 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 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 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. 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) 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. 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. 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 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 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) 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 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 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: 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 Below: if the start and end points do not have matching ATL heights, a straight-line path is assumed Below: a subdivided path would conform more closely to the terrain height 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 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 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 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 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 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: 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) 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)
-
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. 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 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. 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 - - 2 - - 3 - - 4 - 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 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 - - 2 - - 3 - 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. 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 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. 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 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) 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 The process of creating marker groups within the game is: switch to the desired Job ID and set the number of required markers, which focuses the map on the Job area all marker positions are tested to find where the densest hotspots are a voronoi point is placed at each of these hotspots the diagram polygons are generated from the voronoi points for each polygon, the number of markers within are tallied these values are compared and the SD is calculated 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 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. 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.
-
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 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 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 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;
-
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. 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. 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.
-
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 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 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
-
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 //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 //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 //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 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 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 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 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 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.
-
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. 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. 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. 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 - - 2 - - 3 - The above examples show the roadsConnectedTo data for: original syntax limited connections 2020 expanded syntax connections 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 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 - - 2 - 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. 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 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. 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 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) 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. 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) 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 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 A double look-at position & no secondary smoothing the camera now swings more gently as it approaches a corner Click image to open video 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. 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.
-
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. 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) 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. 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 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.
-
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. 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).
-
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 - - 2 - - 3 - - 4 - 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 - - 6 - - 7 - 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 - - 2 - - 3 - - 4 - 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 - - 6 - - 7 - - 8 - 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 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 - - 2 - - 3 - 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. 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. 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.
- 33 replies
-
- 10
-
A lot of it started with a game called Miasmata. I'll have another dev post up soon that talks a little about it. 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! 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
-
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.