MiniGame4: Stick Quest ********************** The player explores a small map and must find 10 gems. https://opengameart.org/content/medieval-village-megakit Setup ===== - Download and install - `Medieval village megakit - By quaternius `_ - `Modular Terrain - By Keith `_ Bridge & ground =============== - Create a folder to store your corrected prefab - Add colliders to the Prop_Bridge_Rope_End prefab and save the corrected prefab .. image:: bridge.png - Add colliders to the Prop_Bridge_Rope_Middle prefab and save the corrected prefab .. image:: middle.png - Add colliders to the Prop_Docks_Steps prefab and save the corrected prefab .. image:: steps.png - Create the bridge to traverse the river - Use three cubes to create a river and the ground - Remove the river collider so the character can sink into it - Place a cube below the river to limit the character fall Character ========= - Create a capsule with a cap for our character - Add a Character Controller and attach the Mover script we use before - Change the camera for a top view position - Run the game and check all your collider You might get a surprise: the bridge rope has its own collider, BUT the character could walk onto it like a simple step! .. image:: bug.png - Create a 3D ruler, by creating a cube and setting its scale to 1 0.1 0.1, this ruler is 1 meter long - The dock appear to be 1 meter high. Thus, the dock steps are 25 cm high: .. image:: 1meter.png - The rope of the bridge seems to be about 40 cm above the walkway: .. image:: ruler2.png The dock steps are 25 cm high, the rope of the bridge is 40 cm above the walkway. The Character Controller’s Step Offset is set to 30 cm, so everything should work. But that is not the case. When the character reaches the top of the dock, he is slightly higher than the middle of the bridge. As he moves forward to cross it, he does not really walk — he slowly “falls” onto the bridge. If he touches the rope during that moment, that slight fall combined with the Step Offset is enough to make him step over the rope. So what’s the conclusion? Is the physics engine broken? Not really. If you look closely at the scene, the rope is only 40 cm above the walkway. In reality, a bridge designed like this would provide no protection — people would fall off IRL. The problem actually comes from the model: the rope was placed too low for aesthetic reasons. It looks nice, but in practice our character falls off the bridge. What are our options? - Raise the bridge rope by modifying the model. This would be time-consuming and the result might not look great - Do nothing and let the character feed the fish - Use a simple trick: add a higher collider to prevent this situation. The character will no longer be able to jump off the bridge, but that’s acceptable. We choose the third option: - Add a box collider to the bridge and put it above the slope .. image:: collider.png - Duplicate this collider and move it on the other side by changing its transform values .. image:: collider2.png - Run the game and test again .. image:: test.gif House ===== Kit overview ------------ We now work with the Medieval megakit. Let's have a look into the FBX folder: - The **Floor** files contain 12 floors with different shapes and textures: .. image:: floor.png - The **Roof_RoundTiles_WxL** files contain roofs of different sizes. Here are some examples with a width of 4: .. image:: roof.png - The **Roof_Front_BrickW** corresponds to the triangle that supports the roof: .. image:: triangles.png - The **Props** files include various decorative objects: .. image:: props.png .. note:: What makes this pack particularly interesting is that the artist created both an exterior and an interior face for each wall. Usually, house packs contain nice buildings that you cannot enter because they are essentially just cubes. On the other hand, interior packs provide walls, chimneys, and furniture but nothing for the outside. This pack is quite rare because it allows you to create houses that look good from the outside and can also be explored from the inside. - The **Wall_plaster** files include various walls with windows or doors entry: .. image:: wall_plaster.png - The **Wall_UnevenBrick** files include somes brick walls with windows or doors entry: .. image:: brick.png - The **Door** files contain 8 different shapes for the entrance door: .. image:: doors.png - The **DoorFrame** files contain 4 different door frames: .. image:: doorframes.png - The **Window** files contain 4 different window shapes: .. image:: window.png - The **Shutter** files contain shutters for all window shapes, in two versions: open and closed: .. image:: shutter.png There are other resources such as stairs, balconies, and overhangs. Create your own house --------------------- - First, create the floor using: - Floor_WoodDark - Create two walls : - Wall_Plaster_Straight - Wall_Plaster_Door_Round - Snap each exterior corner of the walls to the corner of the floor. (snapping: use the V key with the translation tool) .. image:: snap.png - Some connection problems persist - highlighted in blue in the image - Add a wooden pillar to form the outer corners using: - Corner_Exterior_Wood .. image:: snap2.png - Create a complete house .. image:: house.png Environment =========== - Add some trees and props to decorate the scene .. image:: scene2.png Player Controls =============== We want the A and E keys to raise and lower the character’s head. For this: - We maintain a variable **pitch** that stores the vertical rotation of the camera - Each time the player moves the mouse vertically, this value is updated based on the mouse sensitivity **tiltSpeed** - The angle is clamped between -60° and +60° - After updating the value, the new angle is applied to the camera’s rotation .. code-block:: csharp public Transform cameraPivot; public float tiltSpeed = 120f; public float minmax = 60f; float pitch = 0f; void Update() { ... // US keycode if (Input.GetKey(KeyCode.Q)) pitch += tiltSpeed * Time.deltaTime; if (Input.GetKey(KeyCode.E)) pitch -= tiltSpeed * Time.deltaTime; pitch = Mathf.Clamp(pitch, -minmax, minmax); // localRotation.Xrot = pitch cameraPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f); } Visor ===== - In the hiearchy panel, Right Click > UI > Canvas - In the Inspector Window - Check that *Render Mode* is set to Screen Space - Overlay. - Set *UI Scale Mode* to *Scale With Screen Size* - Reference Resolution → 1920 x 1080 - In the hiearchy panel, Right Click → UI → Text - TextMeshPro - Import TMP Essentials if requested - Center and align correctly .. image:: align.png - Add + in the text field to create a visor .. image:: visor.png :scale: 70% Collecting Sticks ================= Create sticks ------------- Our scenario is to collect some branches from the ground to light a chimney fire. - Add a **Prop_Branch_1** prefab in the scene - Add a box collider to this object - Enable the **Is Trigger** checkbox We gather all interactive objects in a specific layer for optimization. - In the Inspector window, click on the Layer dropdown and select Add Layer: .. image:: addlayer.png - Create a new layer named Collectible - Set the Stick object's layer to Collectible Detect sticks ------------- - Create a Picker script for our character: .. code-block:: csharp using UnityEngine; using TMPro; public class Picker : MonoBehaviour { public Camera playerCamera; public float interactionDistance = 3f; public LayerMask interactableLayer; public TextMeshProUGUI interactionText; void Update() { Ray ray = new Ray(playerCamera.transform.position, playerCamera.transform.forward); if (Physics.Raycast(ray, out RaycastHit hit, interactionDistance, interactableLayer)) interactionText.text = "Object in range"; else interactionText.text = "+"; } } Make sure all the required information is correctly linked to the script: .. image:: picker.png :scale: 150% - Run, move and test .. image:: range.png OOP design ---------- Our character will collect and interact with various objects. For this, we cast a ray from the camera into the scene. If the ray hits a collectible object, we display a message on the interface such as “Press R to collect”. We could manage everything from a single script attached to the character. However, this approach quickly leads to several problems: - The script would become very large, because we would need a separate **if** condition for each type of object. - In addition, every time a new object is added to the game, we would need to modify the character script; otherwise, the interaction would not work. A better solution is to use a more generic approach. The raycast still belongs to the character, since it is the character who detects the object. However, the logic that defines how the object can be used should belong to the object itself. Each interactive object is therefore responsible for handling its own interaction behavior. Interactable Objects -------------------- We define a common interface for all interactable objects. This interface specifies that an object must be able to return the message that should be displayed on screen. Any object that can be interacted with will implement this interface. - Asset window → Right click → Create → C# Script - Script name : IInteractable .. code-block:: csharp public interface IInteractable { string GetInteractionText(); } This script is not attached to any GameObject. It simply defines a contract for interactive objects. - Create a script *Stick* and attach it to the branch on the ground .. code-block:: csharp using UnityEngine; public class Stick : MonoBehaviour, IInteractable { public string GetInteractionText() { return "Press R to collect"; } } - Update the picker script: .. code-block:: csharp void Update() { Ray ray = new Ray(playerCamera.transform.position, playerCamera.transform.forward); if (Physics.Raycast(ray, out RaycastHit hit, interactionDistance, interactableLayer)) { IInteractable interactable = hit.collider.GetComponent(); if (interactable != null) interactionText.text = interactable.GetInteractionText(); } else interactionText.text = "+"; } - Run and test: .. image:: collect.png Inventory ========= UI -- Layout ^^^^^^ - In the hierarchy window, right click on the *Canvas* and select UI > Panel - Rename this Panel: **InventoryUI** - Selec this *InventoryUI* and in the Inspector Window select: Add Component → Horizontal Layout Group - In the Inspector Window, disable the background *Image* of the *InventoryUI* .. image:: disableimage.png Sprite ^^^^^^ - :download:`Download the stick sprite ` and store it in the asset folder - In the asset folder, select this image - In the Inspector window, change *Texture Type > Default* to *Sprite 2D/UI* .. image:: sprite.png Images ^^^^^^ - In the Hierarchy window, right click on the *InventoryUI* and select *UI > Image* - In the Hierarchy window, select the *Image* UI element - Drag and drop the sprite asset to the Inspector Window > Source Image - Select the image in the Hiearchy, duplicate it 9 times to obtain 10 slots The sprites are distributed horizontally; we would like them to be aligned on the left. - In the hiearchy window, select the *InventoryUI* - In the Inspector window, in the *Horizontal Layer Group* - Unselect *Child Force Expand* - Set the *Spacing* to 10 .. image:: layer.png - Check that you obtain the following result: .. image:: layout.png :scale: 90% Backpack ======== Our character has a backpack used to store objects, so we create a separate script to manage it. - Create a new Backpack script - Copy paste this code inside it: .. code-block:: csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class Backpack : MonoBehaviour { private List items = new List { "stick", "stick" }; public Transform inventoryUI; public List sprites; void Start() { RefreshUI(); } void RefreshUI() { int i = 0; foreach (Transform child in inventoryUI) { Image img = child.GetComponent(); if (i < items.Count) { img.sprite = sprites.Find(s => s.name == items[i]); img.enabled = true; i++; } else { img.sprite = null; img.enabled = false; } } } void Update() { } } - Link the *InventoryUI* to the script - Insert the *stick* sprite into the list - Run the game, you should obtain this result: .. image:: test3.png Interaction design ================== We will now handle the interactions between the *character* and collectible objects. When the player presses a key to interact with an object, an important design question arises: should the interaction be handled by the character or by the object itself? Interactions can become quite complex. Consider the example of lighting a fireplace, we have to follow this small scenario: - Is the fireplace already lit? If so, do nothing - Does the player have three pieces of wood? If not, do nothing - Remove the three pieces of wood from the backpack - Add the three pieces of wood to the fireplace - Light the fireplace Whe have three design options: 1. Handle the interaction in the character ------------------------------------------ The character script manages all interaction logic. When the player interacts with an object, the character checks the object type and performs the corresponding actions. .. list-table:: :header-rows: 1 * - **Advantages** - **Disadvantages** * - - Simple to implement for small projects - All interaction logic is centralized in one place: the character - Easy to understand at the beginning - - The character script quickly becomes very large - Each new interactive object requires modifying the character code - The system becomes difficult to maintain and extend 2. Handle the interaction inside the object ------------------------------------------- Each interactive object manages its own behavior. The character only detects the object and triggers the interaction. .. list-table:: :header-rows: 1 * - **Advantages** - **Disadvantages** * - - Good separation of responsibilities - Each object encapsulates its own logic - Easy to add new types of interactive objects - - Some interactions may require access to player systems such as the backpack - Communication between objects and the character must be handled carefully 3. Handle the interaction in a dedicated interaction class ---------------------------------------------------------- A separate class or system manages the interaction between the character and objects. .. list-table:: :header-rows: 1 * - **Advantages** - **Disadvantages** * - - Clear separation between gameplay systems - Easier to implement complex interactions involving multiple objects - Interaction logic can be reused in different contexts - - Slightly more complex architecture - Requires additional structure to manage communication between systems Conclusion ---------- For our game architectures, the second approach --- handling interactions inside the objects --- is the most practical. The character triggers the interaction, but each object is responsible for defining how it reacts. Interaction System ================== - Update the *IInteractable* interface to add a new function: .. code-block:: csharp public interface IInteractable { string GetInteractionText(); void PerformAction(KeyCode c, GameObject Character); } - In the stick script, implement this new function: .. code-block:: csharp public void PerformAction(KeyCode c, GameObject Character) { if (c == KeyCode.R) { Backpack backpack = Character.GetComponent(); if (backpack != null) { backpack.AddItem("stick"); Destroy(gameObject); } } } - In the Backpack script, add the AddItem function: .. code-block:: csharp public void AddItem(string name) { items.Add(name); RefreshUI(); } - In the Picker script, modify the main function: .. code-block:: csharp if (interactable != null) { interactionText.text = interactable.GetInteractionText(); if (Input.GetKeyDown(KeyCode.R)) interactable.PerformAction(KeyCode.R, gameObject); } - Run the game and pick a stick .. image:: final.gif Last steps ========== Sticks ------ - Create a stick prefab - Add more sticks around the character Door ---- - Create a new interaction with the door: by pressing R, it should open - Pressing R again should close it - Pressing R again should open it... Fireplace --------- - Used a *DoorFrame_Flat_Brick* to create a flat fireplace inside the house - Create a black 3D cube to create the interior .. image:: fireplace.png - Implement the logic to light the fire - Does the player have five pieces of wood? If not, do nothing - Remove the five pieces of wood from the backpack - Light the fireplace - If the fireplace is lit, change the color of its interior to red/yellow .. image:: fireplace2.png