Unity example project demonstrating modular project architecture. A tiny golf game on a procedural map.
The goal of this game is to use data-oriented design patterns and modular coding patterns to allow the different systems to exist independently, and be built up over time.
'Stroke' is a POCO that describes a single hit on the golf ball. It's used to store previous hits and edited at runtime. (Caddy scriptable object). CurrentStroke is what the trajectory prediction system is using to figure out what force might get added, for example.
"Active" scriptable objects (ActiveGolfConfiguration, InputReader) are objects used for storing references and accessing objects in the scene, instead of singletons, managers, or other such patterns to solve this problem. There is probably just one of these.
"Game Data" scriptable objects (Clubs), on the other hand, is just data. Settings, etc.
The Generator is a little of both. It's basically settings for generation, but it also stores a generation as an image texture sub asset.
Uses new input system. InputActions have a c# class generated, and interfacing with the input actions is done entirely by an InputReader scriptable object. The rest of the project only interfaces with this, which provides convenient actions, process functions, and read-only properties. It keeps the rest of the code agnostic of which input system we are using. It lets us send 'fake' inputs in a non-jank way easily, either with inspector scripts or with public functions.
This technique is inspired from Unity's own 'modular game architecture with scriptableobjects' ebook (repo).
Basically, the input reader serves the role of the PlayerInput component.
Using a scriptableobject to "wrap" an inputactions has the further advantages.
Disadvantages
Really, I just like this method for the sake of the rest of the code base. Doing a lot of XR development, it's always useful to have test buttons in the scene (although not needed for a project like this one). I like completely compartmentalizing input away - it tends to be a place where complexity grows over time, and lots of little input handling scripts has always been a headache.
HUD reads from the 'caddy'. With the scriptable object, it is completely independent from any other element. in-scene UI is handled by the trajectory prediction system.
Basically entirelly in a single script/child of the player, GolfHitPreviewLine.cs.
Uses multi-scene physics to simulate the balls path and draw a line for each tick of that simulation. See the TNTC video for a breakdown of the technique.
CameraSystem is a state machine. GolfCamera is the base class for a state. Actual camera switching is done via Cinemachine, changing the priority of the cameras, to use their blends.
I'm not very happy with the system right now, need to take advantage of cinemachine more - blending to a tee camera when the ball is close to a tee, blending to an overview when the ball is high; but using our own script for when aiming. The plan is some appropriate mix of high level custom state machine with lower level cinemachine.
[ReadOnly] and [Layer] are not attributes that are built into Unity (although they should be). I implemented these as custom attributes, see the utilities folder. Each one is in it's own folder/namespace because I imagine you may want to directly copy them into your own projects. Go for it. That's what I do.
Map generation happens in a Generator scriptable Object (generator.cs). This creates a random level and saves it as a sub-asset, as a Texture2D. So anything can just read those files. We run a series of processes on the grid of pixels. See This RedBlogGames article for more information, and my own 2DRoguelikeLevelGenerator package.
Tee positions (and player spawn) are done by trying to randomly place non-overlapping circles onto the map on valid locations. We keep trying while decreasing the radius of the circle. It's poisson disk sampling with a search, and I would describe it as "good enough for now". It's slow and could have a tee spawn on an island of just one square.
MapGenerator.cs is a simple script to listen for generation (we can regenerate levels at runtime clicking a button in the inspector, very helpful for testing) that spawns in cubes on a grid.
There are two major tricks on display in the MapTileset. The first is obvious, the custom inspector. It's neat, but really just enough to do the job. Architecturally, the main trick used for modular development is a delegate. The Map Tileset's job is to tell us which prefab to spawn as a convenient ScriptableObject. To do this, it would need to know how we are saving map data, so it give it the map, and it can parse that to find the neighbors.
Instead, we use a delegate, a function we can pass in as an argument. This one is called 'NeighborTest'. So the tileset object here is written independently from how the map data is stored. We could re-write the way map data is stored entirely without having to worry about this prefab picking system at all.
The way the MapTileset works is as a series of rules, which it checks in order, looking for the first appropriate one. The rule describes what the spaces neighbors can be.
Not an architecture note, but for the curious:
I basically used this tutorial: https://medium.com/@jannik_boysen/procedural-skybox-shader-137f6b0cb77c. I had to turn off "Cast Shadows" in the graph inspector to get the UV mapping to behave as it does in the older version of Unity used in that tutorial.