Making a Flat Ride - ParkitectNexus/ParkitectNexusClient GitHub Wiki
This is going to walk you through how to make a very basic Flat Ride. It won't cover loading models or Asset Bundles -- instead, we're going to use a default Unity Cube Primitive, and you can add your own models later on.
The ride we're going to make is going to be a little 1x1 white box on a larger pad. Guests will walk up to the box, disappear inside of it, and then pop out a little while later.
This tutorial does expect that you have at least a working knowledge of how the Unity Engine works -- namely, how GameObjects
and Components
work. It also expects that you've at least been introduced to programming: You should know how a for
loop works and what classes
, enums
, fields
, functions
, and methods
are.
First, if you haven't already, follow the steps under Getting Started, including setting up Main.cs
and mod.json
. For this example, our mod will be called MagicSquare
, so you can call it that if you wish. You won't need to add their ExampleBehaviour
script; we'll be doing it later in this tutorial.
By the end of it, this is what your Main
class should look like:
using UnityEngine;
public class Main : IMod
{
private GameObject _go;
public void onEnabled()
{
_go = new GameObject();
}
public void onDisabled()
{
UnityEngine.Object.Destroy(_go);
}
public string Name
{
get { return "Magic Square"; }
}
public string Description
{
get { return "Bigger on the inside"; }
}
public string Identifier { get; set; }
public string Path { get; set;}
}
Our Flat Ride will require 3 classes total: a "Main" class, a "Loader" class, and a "Ride" class:
-
The Main class should already have been set up -- it's going to inherit from
IMod
and create a new GameObject, then attach our Loader class to it. -
The "Loader" class is going to be the class which initializes our FlatRide and changes most of its settings. It's going to be in charge of fetching the model, modifying basic ride details like the price, intensity, name, etc., adding waypoints so our guests can pathfind on the ride's "pad", and adding seats for our guests to sit in.
-
The Ride class will inherit from
FlatRide
and will be the thing we actually build in Parkitect. It'll be in control of the current ride "State" and the ride's current duration.
We're going to start by making our ride class. Let's call it MagicSquare
and let it inherit from FlatRide
. You'll notice the moment we inherit from FlatRide
that it gets a red squiggle underneath -- FlatRide
is an abstract class, and it requires us to have the Tick
method. Let's implement that now:
[Serialized]
private float runTime = 0.0f;
public override void tick(StationController stationController)
{
runTime += Time.deltaTime;
}
This is called every frame, and is going to be the main way we control the ride. Right now, we've set it up so it keeps track of how long the ride's been running and stores it in runTime
-- make sure you put using UnityEngine;
at the top of your script so you can access Time.deltaTime
. We put that weird [Serialize]
thing above our runTime field so we can tell Parkitect to save the runTime when we save the game (and load it again when we load the game). Otherwise, your ride will reset back to 0 every time the game was loaded.
However, as it stands now, this isn't doing anything. We're going to need to define a couple states for our ride to be in. Let's use an enum for this, and call our states "Running" and "Stopped".
public enum CycleState
{
Running,
Stopped
}
If you call your enum State
, keep in mind that you may get an error -- there's already Attraction.State
, which does something completely different. You can still call it State
if you'd like, but you have to say public new enum State
.
Anyway, let's continue:
[Serialized]
public CycleState cyclingState;
public float maxRunTime = 60.0f;
[Serialized]
private float runTime = 0.0f;
public override void tick(StationController stationController)
{
if (cyclingState == CycleState.Running)
{
runTime += Time.deltaTime;
if(runTime >= maxRunTime)
{
runTime = 0.0f;
cyclingState = CycleState.Stopped;
}
}
}
Now, as long as we're in the Running
state, we'll increment the run time. As soon as our run time exceeds the maximum run time, it'll switch us to the Stopped
state, the ride will stop, and the run time will reset to 0 until the next time we begin running again.
Now, let's get some initialization done:
public override void Start()
{
guestsCanRaiseArms = false;
cyclingState = CycleState.Stopped;
base.Start();
}
public override void onStartRide()
{
base.onStartRide();
cyclingState = CycleState.Running;
}
What this does is initialize the ride in the Stopped
state. As soon as the underlying FlatRide
class gets the okay to go, it'll swap the state into the Running
state, which will begin incrementing our runTime
variable until we hit the maxRunTime
and go back into the Stopped
state. This creates what computer geeks call a "State Machine", and they're used everywhere.
However, keep in mind that the underlying ride doesn't know anything about our state machine. We have to tell it when we can let guests in or kick them out. Let's do that now:
public override bool shouldLetGuestsIn(StationController stationController)
{
return cyclingState == CycleState.Stopped;
}
public override bool shouldLetGuestsOut()
{
return cyclingState == CycleState.Stopped;
}
These 2 methods are pretty simple -- all they do is just tell Parkitect "When I say the ride is stopped, you can let guests on/off." If you really wanted to, you could get more complex than this, add more states to our CurrentState enum and have a delay between people getting out and people getting in -- but let's keep it like this for the purposes of our tutorial.
That sums up the "Ride" part of the tutorial! This is going to be the only thing which Parkitect will "know" about -- the rest will be our mod that adds this ride into the list of FlatRides Parkitect knows about.
This class will load our MagicSquare
ride into Parkitect and set some basic settings. Let's create a class called MagicSquareLoader
and let it inherit from MonoBehaviour
. We're going to create a basic method which will return the model we'll be using for our ride -- in our case, a default Unity cube primitive.
using UnityEngine;
class MagicSquareLoader : MonoBehaviour
{
public GameObject LoadRideModel()
{
return GameObject.CreatePrimitive(PrimitiveType.Cube);
}
}
If you want to move to using AssetBundles to add models/animations in the future, this is where you'd load your AssetBundle and return the model.
Now, we're going to create the method we're going to call to create our entire Flat Ride:
public void LoadFlatRide()
{
GameObject asset = LoadRideModel();
asset.AddComponent<Waypoints>();
}
What this does is creates our ride's model, then adds a Waypoints
Component to it. Waypoints
is the class which holds all the waypoints our guests use to move around our ride.
Now that we have our Waypoints
Component added to our model, we should create the waypoints. We're going to create a grid of them programmatically, but if you want to use AssetBundles you can have child Transforms representing where you want your waypoints to be. This is going to get rather complex, so I'll post the code and then walk you through what's going on.
public void SetWaypoints(GameObject asset, bool debug)
{
Waypoints points = asset.GetComponent<Waypoints>();
// This defines how much spacing there is between waypoints -- make it too small and guests will get confused and stuck!
float spacingAmount = 1.0f;
// This is half the size our waypoint grid is going to be. In our case, our waypoint grid will be 5x5, so we set this to 2.5 (or 5 divided by 2).
float gridSize = 2.5f;
// Create a new Dictionary, which holds an XY position of our waypoint and the waypoint itself.
// Make sure to add "using System.Collections.Generic;" to the top of your script!
Dictionary<KeyValuePair<float, float>, Waypoint> waypointsDictionary = new Dictionary<KeyValuePair<float, float>, Waypoint>();
// Go through the grid, line-by-line:
for (float x = -gridSize; x <= gridSize; x += spacingAmount)
{
for (float y = -gridSize; y <= gridSize; y += spacingAmount)
{
// Make a new Waypoint object -- this isn't a Component, so you can use the "new" keyword.
Waypoint wp = new Waypoint();
// Set the local position to our XY coordinates.
wp.localPosition = new Vector3(x, 0, y);
// If it's close to the center of the pad, mark it as a "Rabbit Hole Goal" -- it's a place guests can "disappear" into
wp.isRabbitHoleGoal = x < 1.0f && y < 1.0f && x > -1.0f && y > -1.0f;
// Add it to our waypoint dictionary
waypointsDictionary.Add(new KeyValuePair<float, float>(x, y), wp);
// Add it to our Waypoints component so Parkitect knows about it
points.waypoints.Add(wp);
}
}
// Now we have to go through and define waypoint connections, so our guests know where to go.
foreach (KeyValuePair<KeyValuePair<float, float>, Waypoint> pair in waypointsDictionary)
{
// Go through every key in the waypoints dictionary and try to find neighboring waypoints
bool outer = false;
for (float x = -spacingAmount; x <= spacingAmount; x += spacingAmount)
{
for (float y = -spacingAmount; y <= spacingAmount; y += spacingAmount)
{
if (x == 0 && y == 0)
{
// This is us -- we don't border ourselves
continue;
}
// Try to get the value of the waypoint at these XY coordinates from the Dictionary
Waypoint otherWp;
waypointsDictionary.TryGetValue(
new KeyValuePair<float, float>(pair.Value.localPosition.x + x, pair.Value.localPosition.z + y),
out otherWp);
if (otherWp != null)
{
// We found another waypoint, so tell Parkitect that we're connected to it
pair.Value.connectedTo.Add(points.waypoints.FindIndex(a => a == otherWp));
}
else
{
// We're on the outer edge, which is important to know as well
outer = true;
}
}
}
// If we were on the outer edge, we should mark the waypoint we're currently considering as an outer waypoint
pair.Value.isOuter = outer;
}
// This is a handy debug tool. It visualizes all our waypoints, so we can see if it worked or not.
if (debug)
{
foreach (Waypoint waypoint in points.waypoints)
{
// For every waypoint, get its position and place a small cube there.
Vector3 worldPosition = waypoint.getWorldPosition(asset.transform);
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.localScale = new Vector3(0.1f, 0.1f, 0.1f);
// Now we're going to color the cube to tell us properties of that waypoint
Renderer cubeRenderer = cube.GetComponent<Renderer>();
if (waypoint.isRabbitHoleGoal)
{
// If it's a Rabbit Hole goal, we're going to make it green
cubeRenderer.material.color = Color.green;
}
else if (waypoint.isOuter)
{
// If it's an outer edge, it'll be yellow
cubeRenderer.material.color = Color.yellow;
}
else
{
// If there's nothing special about it, it'll be red
cubeRenderer.material.color = Color.red;
}
// Parent the cube to the FlatRide's transform and move it to the waypoint's position.
cube.transform.parent = asset.transform;
cube.transform.position = worldPosition;
}
}
}
Whew! Don't worry, that was the most complicated part of our entire mod. Basically, we just went through and added a grid of waypoints, marking the center ones as a place for our guests to want to go to (more on that later). We also added some handy debugging information so we could go back and make sure our waypoints are working properly.
Now, we set up our Waypoints in our LoadFlatRide
function:
public void LoadFlatRide()
{
GameObject asset = LoadRideModel();
asset.AddComponent<Waypoints>();
SetWaypoints(asset, true);
asset.transform.position = new Vector3(0, 999, 0);
}
In addition to setting up our waypoints (with debugging information enabled -- later, if you'd like, you can change it to SetWaypoints(asset, false)
to get rid of our debug squares), we're also moving our ride's model waaaay up into the sky, so nobody will ever see it (hopefully).
Now, let's start using that weird RabbitHole thing we mentioned when we started setting up our waypoints:
protected void InitializeRideData(GameObject asset)
{
RabbitHole rabbitHole = asset.AddComponent<RabbitHole>();
rabbitHole.seatCount = 40;
}
If you haven't gotten it by now, basically, adding a RabbitHole Component will make our guests disappear when they reach a isRabbitHoleGoal
Waypoint
-- as if they entered a rabbit hole. For our purposes, this will make guests go away when they go into our little square in the center. If you were making, say, a Ferris Wheel or something with visible seats, this may not be the behavior you want. For a basic tutorial, however, this will work.
Let's continue:
public string rideName = "Magic Square";
public int price = 600;
public float excitement = 0.8f;
public float intensity = 0.2f;
public float nausea = 0.1f;
public int xSize = 6;
public int ySize = 6;
protected void InitializeRideData(GameObject asset)
{
RabbitHole rabbitHole = asset.AddComponent<RabbitHole>();
rabbitHole.seatCount = 40;
MagicSquare flatRide = asset.AddComponent<MagicSquare>();
flatRide.price = price;
flatRide.excitementRating = excitement;
flatRide.intensityRating = intensity;
flatRide.nauseaRating = nausea;
flatRide.setDisplayName(rideName);
flatRide.xSize = xSize;
flatRide.zSize = ySize;
}
This is the fun part -- making your ride! This adds the MagicSquare
FlatRide
Component we made earlier to our model, then gives it a price, ratings, a name, and the size of the pad we're putting it on. Right now, our pad is pretty big -- if you wanted, you could make it smaller, but keep in mind you might have to play with the SetWaypoints
function a bit, too.
Keep in mind as well that excitement, intensity, and nausea are all supposed to be decimal values between 0 and 1. Parkitect will multiply whatever value you have by 100 -- so 0.5 would give you a rating of 50.
Now, we're going to add some more boring things:
private List<BuildableObject> _sceneryObjects = new List<BuildableObject>();
protected void InitializeRideData(GameObject asset)
{
RabbitHole rabbitHole = asset.AddComponent<RabbitHole>();
rabbitHole.seatCount = 40;
MagicSquare flatRide = asset.AddComponent<MagicSquare>();
flatRide.price = price;
flatRide.excitementRating = excitement;
flatRide.intensityRating = intensity;
flatRide.nauseaRating = nausea;
flatRide.setDisplayName(rideName);
flatRide.xSize = xSize;
flatRide.zSize = ySize;
flatRide.categoryTag = "Attractions/Flat Ride";
_sceneryObjects.Add(flatRide);
flatRide.fenceStyle = AssetManager.Instance.rideFenceStyles.rideFenceStyles[0].identifier;
flatRide.entranceGO = AssetManager.Instance.attractionEntranceGO;
flatRide.exitGO = AssetManager.Instance.attractionExitGO;
flatRide.entranceExitBuilderGO = AssetManager.Instance.flatRideEntranceExitBuilderGO;
}
Now, we've told Parkitect what category the ride is in, what fence/entrance/exit to use, and we've added it to a list of things this loader has added (so we can clean it up). Keep in mind that if something breaks, it'll probably break here -- this was written during Pre-Alpha 8, and there's a good chance if you're coming from the future the Parkitect devs might change something and break this.
But for now, let's revisit our LoadFlatRide
method:
public void LoadFlatRide()
{
GameObject asset = LoadRideModel();
asset.AddComponent<Waypoints>();
SetWaypoints(asset, true);
asset.transform.position = new Vector3(0, 999, 0);
InitializeRideData(asset);
BuildableObject buildableObject = asset.GetComponent<BuildableObject>();
buildableObject.dontSerialize = true;
buildableObject.isPreview = true;
AssetManager.Instance.registerObject(asset.GetComponent<FlatRide>());
}
Now, we've initialized our ride and also marked it as a "preview" -- in other words, it's something you can build and make copies of. We've also registered our FlatRide in the AssetManager
, telling Parkitect that it exists.
The more astute observers among you might notice that we're calling GetComponent
to get a BuildableObject
, but we never added any BuildableObject
anywhere. Well, our MagicSquare
ride inherits from FlatRide
, but FlatRide
inherits from Attraction
and Attraction
inherits from BuildableObject
. So really we're grabbing a reference to our MagicSquare
class, but for clarity's sake we're just casting it to a BuildableObject
. This'll also make it easier to make the code "generic" so it can be used with all kinds of rides, not just what we're making now.
There's just one thing left to do, and that's clean up:
public void UnloadScenery()
{
foreach (BuildableObject deco in _sceneryObjects)
{
AssetManager.Instance.unregisterObject(deco);
DestroyImmediate(deco.gameObject);
}
}
This goes through everything in that list we made earlier, tells Parkitect to forget about it, then destroys it. This way, everything gets cleaned up properly when our mod gets turned off or the game ends.
We're almost there! This was by far the most difficult class in this tutorial, but now there's only one more thing to do:
We've covered a lot of this earlier, but now we actually get to add our Loader to our mod. It's just a couple lines of code to add it and clean up:
using UnityEngine;
public class Main : IMod
{
private GameObject _go;
public void onEnabled()
{
_go = new GameObject();
MagicSquareLoader rideLoader = _go.AddComponent<MagicSquareLoader>();
rideLoader.LoadFlatRide();
}
public void onDisabled()
{
_go.GetComponent<MagicSquareLoader>().UnloadScenery();
UnityEngine.Object.Destroy(_go);
}
public string Name
{
get { return "Magic Square"; }
}
public string Description
{
get { return "Bigger on the inside"; }
}
public string Identifier { get; set; }
public string Path { get; set; }
}
We're done! Let's test it!
If all went well, you should see your mod on the Flat Rides building tab:
You can place it:
And people can navigate in/out of it:
Those little colored squares on the asphalt are our debugging information, telling us where the waypoints are, where the edges are, and where the Rabbit Hole squares are. You can get rid of them by going to LoadFlatRide
in our MagicSquareLoader
class and changing SetWaypoints(asset, true);
to SetWaypoints(asset, false);
If, for whatever reason, your ride doesn't work, there's a couple places for you to look at where you're going wrong. The first is in your mod's directory -- there should be a file called mod.log
. Open it up in Notepad -- you should be able to see a list of every time your mod was loaded. If, for whatever reason, your mod failed to load, there might be a note in there.
If there isn't, there is one more place you can look: In Parkitect's logs themselves. Under <Parkitect Install Directory>/Parkitect/Parkitect_Data
, there is a file called output_log.txt
. This is automatically generated by Unity and will give a record of everything that has happened in Parkitect. If you see an error message in there and all other Parkitect mods are disabled, it's a good chance that it's from your game. It'll give you a "stack trace" of all the calls leading up to the error. Follow it down and see if your mod is listed somewhere. It'll give a function call -- try to look at that function and see what might be going wrong.
If you made it this far, congratulations! You did it! It works! That being said, there is a bug...
If you play around with it for a bit, you'll notice that you can build on top of the path! This is because we haven't set up a Bounding Box yet, but it's an easy fix. Let's return to our MagicSquareLoader
class:
public void LoadFlatRide()
{
GameObject asset = LoadRideModel();
asset.AddComponent<Waypoints>();
SetWaypoints(asset, false);
asset.transform.position = new Vector3(0, 999, 0);
InitializeRideData(asset);
BuildableObject buildableObject = asset.GetComponent<BuildableObject>();
buildableObject.dontSerialize = true;
buildableObject.isPreview = true;
AssetManager.Instance.registerObject(asset.GetComponent<FlatRide>());
AddBoundingBox(asset, xSize, ySize);
}
public void AddBoundingBox(GameObject asset, float x, float z)
{
BoundingBox bb = asset.AddComponent<BoundingBox>();
bb.isStatic = false;
bb.layers = BoundingVolume.Layers.Buildvolume;
Bounds b = new Bounds();
b.center = new Vector3(0, 1, 0);
b.size = new Vector3(x - .01f, 2, z - .01f);
bb.setBounds(b);
bb.isStatic = true;
}
We just added one line at the end of LoadFlatRide
that loaded a Bounding Box. This Bounding Box is just a little smaller than our ride's base, so now other objects can block our ride from being built.
Yay! You did it! Now, if you'd like, you can use AssetBundles to add models and animations to your ride. You can also play around with some of the settings and experiment to see what you can do -- we've only barely scratched the surface of what's possible.
Now, try to make the ride prettier! For example, try to go back into the LoadRideModel
method in our MagicSquareLoader
script and return a box which is blue and stretched on the y axis. I'll give you a hint on how to change the scale/color: Look at the last section of our SetWaypoints
function, where we add all those debugging cubes.
If you want to get really fancy, you can use the 3D modeling program of your choice to create a custom model for your ride. One thing to keep in mind: our SetWaypoints
function will put waypoints all over the pad, including ones "inside" your model. What you can do is add BoxColliders
to your model in Unity, then in SetWaypoints
do something like this inside that first nested for
loop:
Vector3 localPosition = new Vector3(x, 0, y);
if (Physics.Raycast(asset.transform.position + localPosition + (Vector3.up * 3.0f), Vector3.down, 3.0f))
{
continue;
}
Waypoint wp = new Waypoint();
wp.localPosition = localPosition;
wp.isRabbitHoleGoal = x < 1.0f && y < 1.0f && x > -1.0f && y > -1.0f;
waypoints.Add(new KeyValuePair<float, float>(x, y), wp);
points.waypoints.Add(wp);
In this example, the BoxCollider
is centered on a y value of 0 and is 2.5 units tall. You can add multiple child Colliders
to your AssetBundle if you want to make different shapes -- just keep in mind that the colliders don't actually work in Parkitect; it's based off of your waypoints. Also keep in mind that unless you set up your own seats, you should mark at least one waypoint as isRabbitHoleGoal
-- otherwise your guests won't have anywhere to go when they get on your ride.
If you wanted to get really, really fancy, you could add animations to your model and set them up in Mechanim. When your ride is in the Running
state, you could adjust a bool
value on your Animator
component to make it play an animation. You could even modify our CurrentState
state machine in conjunction with setting values in your Animator
to make your ride have multiple different "cycles" -- one where it spins around, one where it moves up and down, etc.