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.

It's bigger on the inside.

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.

Getting Started

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;}
	}

Classes

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.

The Ride Class

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.

The Loader Class

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:

The Main Class

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!

Ingame

If all went well, you should see your mod on the Flat Rides building tab:

It's alive!

You can place it:

The game didn't crash!

And people can navigate in/out of it:

Guests didn't get stuck!

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);

Help! My Ride Doesn't Work!

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.

We Have a Bug

If you made it this far, congratulations! You did it! It works! That being said, there is a bug...

Uh-oh!

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.

Much better!

You Did It!

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.

Next Steps

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.

⚠️ **GitHub.com Fallback** ⚠️