Build a Birdhouse
Game Summary
This project is a modular Unity training and simulation game built as a reusable framework for interactive, procedural learning. Instead of hardcoding a single experience, I designed the game around a data-driven scenario system where each module defines its own interactables, targets, steps, environment, and presentation. At runtime, the game reads the selected module, builds the scene dynamically, spawns the required gameplay objects, and connects them to shared systems for input, placement, reset behavior, progression tracking, and scoring. This structure allows the same core architecture to support very different scenarios while keeping the gameplay loop consistent and scalable.
Play Online
Launch the live WebGL build of the project in your browser.
Table of Contents
Game Overview
Scripts
Architecture Overview
Purpose:
This project is designed as a flexible platform for transforming real-world procedures into interactive, hands-on training simulations. Rather than building a single hardcoded experience, the system focuses on scalability, allowing new modules to be created and integrated without modifying core gameplay systems.
Design Approach:
The architecture emphasizes reusable systems, data-driven design, and a clear separation between content and runtime logic. Modules define their own interactables, targets, steps, and environments, while shared systems handle input, placement, progression, reset behavior, and scoring. This structure allows the project to scale into a larger library of training scenarios with minimal code changes.
What to Expect:
The sections below break down the project from both a high-level and system-level perspective. It begins with an overview of the core architecture, then walks through each major runtime layer including data setup, module loading, scene construction, interaction systems, progression tracking, and final results. Each core script is also examined to show how the systems connect and work together as a cohesive pipeline.
1. Data Layer
Purpose:
These scripts define the content of the game before runtime. They describe what exists in a module, what steps must be completed, what interactables can appear, and which modules are available to the player.
Key Scripts:
ModuleDatabase.cs
Stores the full list of playable modules available on the start screen. This is the top-level data source the menu uses to build the module selection UI.
ModuleData.cs
Defines one complete playable scenario. It contains the module name, interactable list, target configurations, ordered steps, environment settings, ambient audio, preview material, and UI theme data. This is the core asset that bundles everything needed to build one module.
InteractableData.cs
Defines one interactable type. It stores the prefab, optional source object, display settings, stock amount, placement behavior, runtime subclass name, and reset-interactable positioning data. This lets one shared spawn pipeline create many different object types with different behaviors.
StepData.cs
Defines one gameplay step and the requirements needed to complete it. The system supports both a simple legacy single interactable-target requirement and a more advanced requirement list that can include quantities and hammered-state conditions.
2. Start Screen and Module Selection
Purpose:
These scripts handle how the player chooses a module before gameplay begins.
Key Scripts:
StartScreenController.cs
Builds the start screen dynamically from the ModuleDatabase. It clears old buttons, creates one button per module, positions them, and initializes each button with the correct module data. This makes the menu scalable and data-driven.
ModuleButton3D.cs
Represents one selectable module card on the start screen. It applies the module’s label and preview material, responds to pointer interaction, and when clicked stores the chosen module and loads the gameplay scene.
3. Scene Construction and Module Loading
Purpose:
Once a module is selected, these scripts build the playable scene.
Key Scripts:
ScenarioLoader.cs
This is the scene assembly script. It resolves which module should load, applies module environment settings like skybox and ambient audio, spawns interactables, spawns targets, and then initializes the step system with the selected module’s data. This is one of the main scripts that turns stored module data into a playable scenario.
InteractableSpawner.cs
Acts as the interactable factory. It instantiates prefabs, resolves the correct runtime component type from data, swaps or adds the correct interactable script, and then applies shared runtime references such as the StepManager, InteractionManager, and reset system. This lets one spawn path support standard draggable objects, nails, hammers, and other custom subclasses.
SourceSpawnObject.cs
Represents a persistent source object that spawns interactables on demand, such as a box that creates one piece at a time. It controls click-to-spawn behavior, tracks active spawned items, prevents duplicate active spawns, and can immediately hand a newly spawned interactable into the input system for dragging.
4. Placement World and Target System
Purpose:
These scripts define where objects can be placed and how grouped targets behave.
Key Scripts:
Target.cs
Represents a valid placement location in the world. It tracks what interactables are placed there, checks whether another interactable can be accepted, exposes placed interactables to other systems, and updates its occupied visual state.
TargetGroup.cs
Links multiple targets together so they can behave as a group. It supports grouped auto-fill behavior, shared group tracking, and locking connected targets when nails are hammered. This is what allows one placement to produce multiple linked placements in grouped scenarios.
5. Base Interactable System
Purpose:
These scripts define how gameplay objects can be moved, placed, and reset.
Key Scripts:
DraggableInteractable.cs
This is the base class for movable gameplay objects. It handles initialization, dragging, live movement updates, nearest-target detection, placement preview, placement requests, snapping onto targets, and resetting back to original state. Most interactable objects in the project build on this shared behavior.
HammerInteractable.cs
Extends the base draggable behavior so the hammer acts like a tool. When released, it scans for nearby placed nails and applies hammer logic while avoiding duplicate processing of the same group.
NailInteractable.cs
Extends the base draggable behavior so nails can be hammered after placement. It supports both single nails and grouped nails, changes hammered visual state, and locks connected placed objects like panels when the nails are hammered in.
6. Input and Click Handling
Purpose:
These scripts connect player input to 3D object interaction.
Key Scripts:
BaseInputManager.cs
This is the main input controller for the game. It handles both desktop and mobile input, manages clicking and dragging, routes interactions into either clickable 3D objects or draggable interactables, blocks scene input when UI is in front, supports object selection, selected-object orbit and focus behavior, and drives pickup hover indicators. It is one of the main orchestration scripts in the project.
HybridClickableBase.cs
Provides a reusable click foundation for objects that need to work through both UI graphics and 3D colliders. Child classes only need to implement what the click actually does, while the base handles click routing and optional sound behavior.
7. Placement, Reset, and Runtime Object Coordination
Purpose:
These scripts manage what happens after an object is placed or reset.
Key Scripts:
ResetInteractable.cs
Represents the reset control for a specific interactable type. It tracks remaining reset stock, decides whether anything associated with that type is eligible for reset, shows or hides itself depending on that state, and sends reset requests into the interaction system when clicked.
InteractionManager.cs
This is the central runtime coordinator for placement and reset behavior. It finalizes valid placements, tracks placed objects per reset interactable, spawns replacement interactables, triggers grouped auto-fill behavior, routes reset requests differently for Guided and FreePlay modes, and resets grouped or individual interactables while keeping targets, reset objects, and step progression synchronized. This is one of the most important scripts connecting gameplay systems together.
8. Step Progression System
Purpose:
These scripts determine whether the player is completing the scenario correctly.
Key Scripts:
StepManager.cs
This is the progression system of the game. It loads the selected module’s steps, tracks which steps are complete, validates whether interactables are allowed on certain targets, responds to placement, reset, and hammer events, manages Guided and FreePlay mode behavior, builds the FreePlay step list UI, and powers auto-place functionality. It is the main bridge between scene interaction and gameplay progression.
9. Score, Timer, and Final Results
Purpose:
These scripts manage timer-based tracking and final end-of-run summary display.
Key Scripts:
SimpleScoreManager.cs
Tracks timer state, elapsed time, completed steps recorded while the timer is active, and builds the final summary text displayed at the end of a run.
SimpleScoreObserver.cs
Bridges the step system to the score system by watching for mode changes and newly completed steps, then recording those results in the score manager.
SimpleScoreTimerButton.cs
Provides the clickable timer control that starts or stops timing and updates its own text and material to reflect the timer state.
SimpleScoreUI.cs
Displays the final results panel using the final summary text and formatted time generated by the score manager.
10. Full Runtime Flow
Key Scripts:
ModuleDatabase.cs, ModuleData.cs, InteractableData.cs, StepData.cs, StartScreenController.cs, ModuleButton3D.cs, ScenarioLoader.cs, InteractableSpawner.cs, SourceSpawnObject.cs, Target.cs, TargetGroup.cs, DraggableInteractable.cs, HammerInteractable.cs, NailInteractable.cs, BaseInputManager.cs, HybridClickableBase.cs, ResetInteractable.cs, InteractionManager.cs, StepManager.cs, SimpleScoreManager.cs, SimpleScoreObserver.cs, SimpleScoreTimerButton.cs, and SimpleScoreUI.cs.
Start screen and module selection:
The game begins on the start screen, where StartScreenController.cs reads modules from ModuleDatabase.cs.
One button is created per ModuleData.cs, each controlled by ModuleButton3D.cs, which stores the selection and loads the gameplay scene.
Scene setup and module construction:
ScenarioLoader.cs resolves the selected module, applies environment settings, and builds the scene.
It spawns targets, interactables, source objects, and reset interactable objects. InteractableSpawner.cs instantiates each object from InteractableData.cs and assigns the correct runtime behavior.
Targets and object setup:
Target.cs defines valid placement locations and tracks occupancy.
TargetGroup.cs links targets into groups for shared behavior like auto-fill or locking.
SourceSpawnObject.cs provides dynamic spawning systems like a nail box that generates new interactables during gameplay.
Step initialization:
StepManager.cs loads the module’s StepData.cs, initializes Guided or FreePlay mode, and prepares progression tracking.
Player input and interaction:
BaseInputManager.cs handles all input, including mouse, touch, joystick, selection, and UI blocking.
Clickable objects route through HybridClickableBase.cs, while draggable objects are controlled by DraggableInteractable.cs.
Placement and interaction resolution:
When released, objects request placement through InteractionManager.cs, which validates and finalizes the result.
Specialized behaviors extend this system, including HammerInteractable.cs and NailInteractable.cs.
Progression updates:
StepManager.cs reevaluates steps after each placement, reset, or hammer event.
Guided mode advances step-by-step, while FreePlay updates the full checklist.
Reset and recovery:
ResetInteractable.cs and UI controls trigger resets through InteractionManager.cs.
The system restores objects, updates targets, refreshes stock, and keeps progression synchronized.
Score and final results:
SimpleScoreTimerButton.cs controls timing, while SimpleScoreManager.cs tracks elapsed time and completed steps.
SimpleScoreObserver.cs records progress, and SimpleScoreUI.cs displays the final summary.
Overall flow:
Data defines the module → the scene is built → the player interacts → systems validate and track progress → results are recorded and displayed.
ModuleDatabase.cs
This script acts as the top-level registry for all modules the game can load.
ModuleDatabase.cs
This script acts as the top-level registry for all modules the game can load.
1. Module Registry
Code Block: Module registry
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "Scenario/Module Database")]
public class ModuleDatabase : ScriptableObject
{
public List<ModuleData> modules;
}
Why this is core:
This is the master list of all playable modules. Instead of hardcoding scenarios into runtime scripts, the game can reference this database and load different ModuleData assets from one shared entry point. That makes the game expandable without rewriting the core systems.
Summary:
ModuleDatabase is the project’s top-level scenario registry. It stores the list of all module definitions the game can access, making it easy to add new training scenarios through data instead of hardcoded logic.
InteractableData.cs
This script defines the full setup for a single interactable, including what prefab to spawn, how it appears, how many can exist, how it behaves after placement, and whether it should become a specialized runtime type like a nail or hammer.
InteractableData.cs
This script defines the full setup for a single interactable, including what prefab to spawn, how it appears, how many can exist, how it behaves after placement, and whether it should become a specialized runtime type like a nail or hammer.
1. Identity and Prefab Definition
Code Block: Identity and prefab definition
[CreateAssetMenu(menuName = "Scenario/Interactable")]
public class InteractableData : ScriptableObject
{
[Header("Identity")]
public InteractableID id;
[Header("Prefab")]
[Tooltip("Main interactable prefab spawned into the scenario.")]
public GameObject prefab;
[Tooltip("Display name shown for the interactable itself.")]
public string displayName;
[Header("Interactable Label")]
public TMP_FontAsset titleFont;
public float titleFontSize = 3f;
public Vector3 titleLocalPositionOffset;
[Tooltip("How many of this interactable can be spawned/used.")]
public int remainingAmount = 1;
[Tooltip("Optional sound played on correct placement.")]
public AudioClip placementSound;
[Header("Optional Runtime Subclass")]
[Tooltip("Optional runtime component type name to add, such as NailInteractable or HammerInteractable.")]
public string runtimeClassName;
[Header("Reset Interactable Visual")]
[Tooltip("Visual offset used when placing the reset resetObj near this interactable.")]
public Vector3 resetInteractablePositionOffset = new Vector3(0f, 0.5f, 0f);
}
Why this is core:
This gives each interactable a stable ID and links it to the prefab the scenario loader and spawner will instantiate. Without this block, the rest of the system has no reliable way to identify or spawn the correct object.
Summary:
InteractableData defines one interactable’s full setup. It stores the object ID, prefab, optional source object, display data, spawn limits, placement rules, and optional runtime subclass. This lets the scenario loader and spawner build different object types from data while keeping the system modular and reusable.
StepData.cs
This script defines how a step is described, how requirements are represented, how legacy and advanced formats are supported, and how the game checks whether an interactable and target pair belongs to a step.
StepData.cs
This script defines how a step is described, how requirements are represented, how legacy and advanced formats are supported, and how the game checks whether an interactable and target pair belongs to a step.
1. PlacementRequirement Data Model
Code Block: PlacementRequirement data model
[System.Serializable]
public class PlacementRequirement
{
[Header("What must be placed")]
public InteractableID interactableID;
public TargetID targetID;
[Header("How many are needed")]
public int requiredCount = 1;
[Header("What state counts as complete")]
public RequirementState requiredState = RequirementState.Placed;
}
Why this is core:
This is the unit that describes what a step requires. Instead of a step only being one interactable and one target, the game can support more advanced requirements that include quantity and required completion state, such as merely placed versus hammered.
2. Step Metadata and Legacy/Advanced Requirement Fields
Code Block: Step metadata and legacy/advanced requirement fields
[CreateAssetMenu(menuName = "Scenario/Step")]
public class StepData : ScriptableObject
{
[Header("Step Info")]
[TextArea(2, 4)]
public string description;
public int scoreValue = 10;
[Header("Legacy Single Requirement")]
[Tooltip("Used when Advanced Requirements is disabled.")]
public InteractableID requiredInteractable;
[Tooltip("Used when Advanced Requirements is disabled.")]
public TargetID requiredTarget;
[Tooltip("Used only when Advanced Requirements is disabled. If true, the single requirement completes only after the interactable is hammered.")]
public bool requiresHammered = false;
[Header("Requirement Set (Multi-Condition Mode)")]
[Tooltip("Enable this to use multiple placement requirements instead of the single required interactable/target fields.")]
public bool useAdvancedRequirements = false;
[Tooltip("A list of placement conditions that must be satisfied to complete this step.")]
public List<PlacementRequirement> requirements = new List<PlacementRequirement>();
Why this is core:
This is the data structure that makes the step system flexible. It keeps simple steps easy to configure with one interactable and one target, while also supporting multi-requirement steps without forcing the whole project to convert at once.
3. Requirement State Enum
Code Block: Requirement state enum
public enum RequirementState
{
Placed,
Hammered
}
Why this is core:
This small enum is important because it defines what “complete” means for a requirement. Some objects only need to be placed, while others are not finished until they reach a second state like being hammered.
4. Step Matching Logic
Code Block: Step matching logic
/// <summary>
/// Returns true if the given interactable/target pair matches this step.
/// In legacy mode, checks the single required pair.
/// In advanced mode, checks whether any listed requirement matches the pair.
/// </summary>
public bool IsMatch(InteractableID interactable, TargetID target)
{
if (!useAdvancedRequirements)
{
return interactable == requiredInteractable &&
target == requiredTarget;
}
if (requirements == null || requirements.Count == 0)
return false;
foreach (PlacementRequirement requirement in requirements)
{
if (requirement == null)
continue;
if (requirement.interactableID == interactable &&
requirement.targetID == target)
{
return true;
}
}
return false;
}
Why this is core:
This is the first real gameplay logic in the file. It is the bridge between runtime placement events and step validation, answering the question: “Does this object on this target belong to this step?” That makes it a core part of Guided and FreePlay progression.
5. Requirement Normalization Logic
Code Block: Requirement normalization logic
/// <summary>
/// Returns the active requirement set for this step.
/// If advanced requirements are not enabled, a legacy single requirement
/// is wrapped into a list so callers can use one path.
/// </summary>
public List<PlacementRequirement> GetRequirements()
{
if (useAdvancedRequirements && requirements != null && requirements.Count > 0)
{
return requirements;
}
return new List<PlacementRequirement>
{
new PlacementRequirement
{
interactableID = requiredInteractable,
targetID = requiredTarget,
requiredCount = 1,
requiredState = legacyRequiresHammered
? RequirementState.Hammered
: RequirementState.Placed
}
};
}
}
Why this is core:
This is the second major logic block. It converts both legacy and advanced step definitions into one consistent requirement list so the rest of the code can read step requirements through one shared path. That keeps the runtime systems simpler and makes the step system backward-compatible.
Summary:
StepData defines how a training step is described and validated. It supports both older single-requirement steps and newer multi-requirement steps, while helper methods like IsMatch() and GetRequirements() let the rest of the game validate placements through one consistent workflow.
ModuleData.cs
This script defines a full playable module. It ties together the interactables, targets, steps, environment settings, audio, preview material, and menu theme assets for one scenario.
ModuleData.cs
This script defines a full playable module. It ties together the interactables, targets, steps, environment settings, audio, preview material, and menu theme assets for one scenario.
1. Core Module Structure
Code Block: Core module structure
[CreateAssetMenu(menuName = "Scenario/Module")]
public class ModuleData : ScriptableObject
{
[Header("Module Info")]
public string moduleName;
[Header("Interactables")]
[Tooltip("All interactables available in this module.")]
public List<InteractableData> interactables;
[Header("Target Prefab Configurations")]
[Tooltip("Target configuration prefabs used to build the scenario layout.")]
public List<GameObject> targetConfigurations;
[Header("Step Sequence")]
[Tooltip("The ordered list of steps for Guided mode and shared progression logic.")]
public List<StepData> steps;
Why this is core:
This is the heart of a scenario definition. It says what objects exist in the module, what target layout gets spawned, and what ordered step sequence drives progression.
2. Environment, Audio, and Preview Setup
Code Block: Environment, audio, and preview setup
[Header("Environment")]
public Material skyboxMaterial;
[Header("Audio")]
public AudioClip ambientLoop;
[Header("Preview")]
[Tooltip("Material used for previewing this module on selection UI.")]
public Material previewMaterial;
Why this is core:
This gives each module its own visual and audio identity. The gameplay structure is not just the interactables and steps; each module also carries its own environment settings and preview presentation for selection UI.
Summary:
ModuleData is the full definition of a playable scenario. It combines gameplay data like interactables, targets, and steps with presentation data like skybox, ambient audio, preview material, and menu themes, allowing one shared engine to load very different modules from data.
StartScreenController.cs
This script is the start menu builder. Its core job is to read the ModuleDatabase, create one button for each module, and place those buttons into the menu layout.
StartScreenController.cs
This script is the start menu builder. Its core job is to read the ModuleDatabase, create one button for each module, and place those buttons into the menu layout.
1. Start Entry Point
Code Block: Start entry point
private void Start()
{
// Build the module button list when the start screen first loads.
SpawnButtonsFromModules();
}
Why this is core:
This is the entry point for the whole start screen flow. When the menu scene loads, this kicks off the button generation process.
2. Spawn One Button per Module
Code Block: Spawn one button per module
// Creates one button for each module in the database and lays them out under the parent.
private void SpawnButtonsFromModules()
{
if (!ValidateReferences())
return;
// Clear old children first so reloading the screen does not duplicate buttons.
ClearExistingButtons();
int moduleCount = moduleDatabase.modules != null ? moduleDatabase.modules.Count : 0;
if (moduleCount == 0)
{
Debug.LogWarning("StartScreenController: No modules found in database.");
return;
}
float startOffset = GetStartOffset(moduleCount);
for (int i = 0; i < moduleCount; i++)
{
ModuleData module = moduleDatabase.modules[i];
ModuleButton buttonInstance = Instantiate(buttonPrefab, buttonParent);
PositionButton(buttonInstance, i, startOffset);
buttonInstance.Initialize(module);
}
}
Why this is core:
This is the main runtime logic of the script. It validates the setup, clears old buttons, reads every module from the database, spawns a button for each one, positions it, and passes the module data into the button. This is the bridge between the data-driven module system and the visible start screen UI.
3. Layout Offset and Button Positioning
Code Block: Layout offset and button positioning
// Calculates the starting offset used to center the row or column of buttons.
private float GetStartOffset(int moduleCount)
{
if (!centerButtons || moduleCount <= 1)
return 0f;
return (moduleCount - 1) * spacing * 0.5f;
}
// Positions a spawned button either as a UI RectTransform or a normal transform fallback.
private void PositionButton(ModuleButton buttonInstance, int index, float startOffset)
{
RectTransform rect = buttonInstance.GetComponent<RectTransform>();
if (rect != null)
{
rect.anchoredPosition = horizontalLayout
? new Vector2((index * spacing) - startOffset, 0f)
: new Vector2(0f, (-index * spacing) + startOffset);
rect.localScale = Vector3.one;
rect.localRotation = Quaternion.identity;
return;
}
// Fallback for non-UI/world-space usage.
buttonInstance.transform.localPosition = horizontalLayout
? new Vector3(index * 0.35f, 0f, 0f)
: new Vector3(0f, -index * 0.35f, 0f);
buttonInstance.transform.localRotation = Quaternion.identity;
buttonInstance.transform.localScale = Vector3.one;
}
Why this is core:
This is the layout logic that controls how the module buttons are arranged on screen. It supports both UI RectTransform positioning and a fallback world-space transform layout, which makes the start screen more flexible.
Summary:
StartScreenController builds the module selection menu from data. When the start screen loads, it reads the module database, creates one button per module, clears any previous buttons, and positions the new buttons using either UI or world-space layout logic.
ModuleButton.cs
This script is the interactive button for one module card. Its job is to display the module name and preview material, handle hover and press visuals, and select the module when clicked.
ModuleButton.cs
This script is the interactive button for one module card. Its job is to display the module name and preview material, handle hover and press visuals, and select the module when clicked.
1. Class Role and References
Code Block: Class role and references
public class ModuleButton : MonoBehaviour,
// Implements Unity UI pointer interfaces so this world-space module button
// can respond to hover, press, release, and click events without using Update.
// Unity calls the matching OnPointer... function automatically when the
// GraphicRaycaster/EventSystem detects pointer interaction with this UI object.
IPointerEnterHandler,
IPointerExitHandler,
IPointerDownHandler,
IPointerUpHandler,
IPointerClickHandler
{
[Header("References")]
public TMP_Text label;
[Tooltip("Main clickable image used to display the module preview material.")]
public Image clickableImage;
[Tooltip("Optional highlight shown while hovering/pressing.")]
public GameObject highlightBorder;
private ModuleData module;
private bool isPointerOver;
private bool isPointerDown;
Why this is core:
This block shows that the button is a UI-driven interactive element. It stores the display references, the assigned module, and the input state used for hover and press behavior.
2. Initialize the Button from Module Data
Code Block: Initialize the button from module data
// Called by the start screen setup when this button gets assigned a module.
// This fills in the label, preview material, and resets the hover state.
public void Initialize(ModuleData moduleData)
{
module = moduleData;
if (label != null)
label.text = module != null ? module.moduleName : string.Empty;
if (clickableImage != null)
clickableImage.material = module != null ? module.previewMaterial : null;
isPointerOver = false;
isPointerDown = false;
SetHighlight(false);
}
Why this is core:
This is how each spawned button gets its actual content. It connects the visual card to a specific ModuleData asset by setting the label, preview material, and initial state.
3. Pointer State Handling
Code Block: Pointer state handling
// Called by Unity UI when the pointer first moves onto this button.
public void OnPointerEnter(PointerEventData eventData)
{
isPointerOver = true;
SetHighlight(true);
}
// Called by Unity UI when the pointer leaves this button.
public void OnPointerExit(PointerEventData eventData)
{
isPointerOver = false;
// If the pointer is no longer being held down, remove the highlight.
if (!isPointerDown)
SetHighlight(false);
}
// Called by Unity UI when the player presses down on this button.
public void OnPointerDown(PointerEventData eventData)
{
isPointerDown = true;
SetHighlight(true);
}
// Called by Unity UI when the player releases the pointer.
public void OnPointerUp(PointerEventData eventData)
{
isPointerDown = false;
// Keep the highlight only if the pointer is still hovering.
SetHighlight(isPointerOver);
}
Why this is core:
This is the user feedback layer for the module button. It tracks hover and press state so the button feels interactive and visually responsive before the click action happens.
4. Click Action and Module Selection
Code Block: Click action and module selection
// Called by Unity UI when the button is clicked.
// This routes into the shared module selection logic below.
public void OnPointerClick(PointerEventData eventData)
{
Select();
}
// Selects this module and loads the active module scene.
// This is the main click action for the button.
public void Select()
{
if (module == null)
{
Debug.LogError("ModuleButton3D: ModuleData not assigned.");
return;
}
SelectedModule.ActiveModule = module;
SceneManager.LoadScene("ActiveModule");
}
Why this is core:
This is the main job of the button. It stores the selected module into shared state and loads the gameplay scene, which hands control over to the scenario loading pipeline.
Summary:
ModuleButton3D represents one selectable scenario card on the start screen. It receives module data from the start screen controller, updates its label and preview image, shows hover and press feedback, and when clicked stores the selected module and loads the main gameplay scene.
ScenarioLoader.cs
This script is the scene boot loader that reads the selected module and constructs the playable scenario by applying environment settings, spawning interactables, spawning targets, creating reset interactable objects, and initializing the step system.
ScenarioLoader.cs
This script is the scene boot loader that reads the selected module and constructs the playable scenario by applying environment settings, spawning interactables, spawning targets, creating reset interactable objects, and initializing the step system.
1. Core References, Spawn Setup, and Selected Module State
Code Block: Core references, spawn setup, and selected module state
public class ScenarioLoader : MonoBehaviour
{
[Header("Spawn Areas")]
[Tooltip("Parent/location used for interactable spawning.")]
public Transform interactableSpawnArea;
[Tooltip("Parent/location used for target spawning.")]
public Transform targetSpawnArea;
[Header("Layout")]
[Tooltip("Spacing offset between target configuration prefabs.")]
public Vector3 targetSpacing = new Vector3(2f, 0f, 0f);
[Tooltip("Number of columns used for interactable grid spawning.")]
public int columns = 4;
[Tooltip("Spacing between interactables in the simple spawn grid.")]
public float spacing = 1f;
[HideInInspector] public List<GameObject> spawnedInteractables = new();
[HideInInspector] public List<GameObject> spawnedTargets = new();
[Header("References")]
[SerializeField] private InteractableSpawner interactableSpawner;
[SerializeField] private InteractionManager interactionManager;
[SerializeField] private StepManager stepManager;
[SerializeField] private BaseInputManager inputManager;
[Tooltip("Prefab used to create reset objects.")]
public GameObject resetInteractablePrefab;
private ModuleData module;
public ModuleData CurrentModule => module;
}
Why this is core:
This block defines everything the loader needs to construct a scenario: where objects spawn, how they are laid out, what manager references are needed, and which module should be loaded.
2. Scene Boot Sequence
Code Block: Scene boot sequence
private void Awake()
{
// Pick which module should be loaded for this scene.
ResolveModule();
if (module == null)
{
Debug.LogError("ScenarioLoader: No module selected.");
return;
}
// Apply module setup, then build the scene contents.
ApplyModuleEnvironment();
SpawnInteractables();
SpawnTargets();
InitializeStepManager();
}
Why this is core:
This is the main startup flow for the gameplay scene. It chooses the active module, validates it, applies module-wide setup, spawns the scene contents, and finally hands the module to the step system.
2. Resolve Which Module to Load
Code Block: Resolve which module to load
// Chooses the active module for this scene.
// Uses the easy-start module in testing, otherwise uses the globally selected module.
private void ResolveModule()
{
module = easyStartBool ? moduleSelectedEasyStart : SelectedModule.ActiveModule;
}
Why this is core:
This decides where the module data comes from. In normal flow it uses the module selected on the start screen, but it also supports a direct easy-start test path for development.
3. Apply Module Environment
Code Block: Apply module environment
// Applies module-level scene settings like ambient audio and skybox.
private void ApplyModuleEnvironment()
{
if (SoundManager.Instance != null)
{
if (module.ambientLoop != null)
SoundManager.Instance.PlayAmbient(module.ambientLoop, 0.5f);
else
SoundManager.Instance.StopAmbient();
}
if (module.skyboxMaterial != null)
{
RenderSettings.skybox = module.skyboxMaterial;
DynamicGI.UpdateEnvironment();
}
else
{
Debug.LogWarning($"ScenarioLoader: Module '{module.moduleName}' has no skybox assigned.");
}
}
Why this is core:
This gives each module its own environmental identity by applying its ambient audio and skybox settings before gameplay begins.
4. Initialize the Step System
Code Block: Initialize the step system
// Initializes the StepManager after the module data has been loaded.
// This is the main handoff that gives the step system its scenario content.
private void InitializeStepManager()
{
if (stepManager != null)
{
if (targetSpawnArea != null)
stepManager.allTargets = new List<Target>(targetSpawnArea.GetComponentsInChildren<Target>(true));
stepManager.Initialize(module);
}
else
{
Debug.LogError("ScenarioLoader: StepManager not assigned!");
}
}
Why this is core:
This is the handoff from scene construction into gameplay progression. After targets are spawned, the step manager is given the target list and the module step data so Guided and FreePlay logic can begin.
5. Main Interactable Spawn Loop
Code Block: Main interactable spawn loop
// Spawns all interactables for the selected module.
// This handles both normal interactables and source-object setups like the nail box.
private void SpawnInteractables()
{
if (module.interactables == null || module.interactables.Count == 0)
return;
if (interactableSpawnArea == null)
{
Debug.LogError("ScenarioLoader: Interactable spawn area is missing.");
return;
}
if (interactableSpawner == null)
{
Debug.LogError("ScenarioLoader: InteractableSpawner is missing.");
return;
}
interactableSpawner.stepManager = stepManager;
interactableSpawner.interactionManager = interactionManager;
interactableSpawner.tipsTutorialManager = tipsTutorialManager;
for (int i = 0; i < module.interactables.Count; i++)
{
InteractableData data = module.interactables[i];
if (data == null)
{
Debug.LogWarning($"ScenarioLoader: Interactable entry at index {i} is null.");
continue;
}
if (data.prefab == null && data.sourceSpawnObject == null)
{
Debug.LogWarning($"ScenarioLoader: InteractableData '{data.name}' has no prefab or source object assigned.");
continue;
}
Vector3 spawnPos = GetInteractableSpawnPosition(i);
// Some interactables come from a persistent source object instead of spawning directly.
if (data.sourceSpawnObject != null)
{
SpawnSourceObjectSetup(data, spawnPos);
continue;
}
SpawnStandardInteractable(data, spawnPos);
}
}
Why this is core:
This is one of the biggest core systems in the loader. It loops through every interactable in the selected module, validates the data, computes spawn positions, and decides whether to build a normal interactable or a source-object-based setup like a nail box.
6. Standard Interactable Spawn Flow
Code Block: Standard interactable spawn flow
// Spawns one normal interactable directly into the scene and wires up its reset interactable object.
// Spawns one normal interactable directly into the scene and wires up its reset resetObj.
// This is called from the main interactable spawn loop for non-source-object items.
private void SpawnStandardInteractable(InteractableData data, Vector3 spawnPos)
{
DraggableInteractable draggable = interactableSpawner.Spawn(data, spawnPos, interactableSpawnArea);
if (draggable == null)
return;
spawnedInteractables.Add(draggable.gameObject);
ResetInteractable resetInteractable = CreateConfiguredResetInteractable(data, draggable.transform.position + data.resetInteractablePositionOffset);
if (resetInteractable != null)
{
resetInteractable.interactableTypeList.Add(draggable);
draggable.resetInteractable = resetInteractable;
}
interactionManager?.RegisterInteractable(draggable);
}
Why this is core:
This is the normal spawn path for regular interactables. It creates the object, attaches its reset interactable object, links the reset interactable object back to the interactable, and registers the interactable with the interaction manager.
7. Reset Interactable Object Creation and Shared Configuration
Code Block: Reset interactable object creation and shared configuration
// Creates a reset object for interactables and applies the shared setup used by both normal interactables and source objects.
private ResetInteractable CreateConfiguredResetInteractable(InteractableData data, Vector3 position)
{
if (data == null)
return null;
if (resetInteractablePrefab == null)
{
Debug.LogError("ScenarioLoader: Reset resetObj prefab is missing.");
return null;
}
GameObject resetInteractableObj = Instantiate(resetInteractablePrefab, position, Quaternion.identity);
ResetInteractable resetInteractable = resetInteractableObj.GetComponent<ResetInteractable>();
if (resetInteractable == null)
{
Debug.LogError("ScenarioLoader: Reset resetObj prefab is missing a ResetInteractable component.");
return null;
}
resetInteractable.stepManager = stepManager;
resetInteractable.interactionManager = interactionManager;
if (resetInteractable == null)
{
Debug.LogError("ScenarioLoader: Reset resetObj prefab is missing a ResetInteractable component.");
return null;
}
resetInteractable.name = data.id + "_ResetObj";
resetInteractable.resetInteractableID = data.id.ToString();
resetInteractable.SetResetAmount(data.remainingAmount, true);
// Keep the existing display wording used by this loader.
if (resetInteractable.resetText != null)
resetInteractable.resetText.text = "Amount: " + resetInteractable.resetAmount;
if (interactionManager != null)
interactionManager.resetInteractableList.Add(resetInteractable);
return resetInteractable;
}
Why this is core:
This is shared infrastructure for both source-object and normal interactables. It creates the reset interactable object, connects it to the step and interaction systems, names it, initializes its reset count, and registers it with the interaction manager.
8. Target Spawn Flow
Code Block: Target spawn flow
// Spawns all target configuration prefabs for the selected module.
// These are the places interactables can be placed during gameplay.
private void SpawnTargets()
{
if (targetSpawnArea == null)
{
Debug.LogWarning("ScenarioLoader: Target spawn area is missing.");
return;
}
if (module.targetConfigurations == null || module.targetConfigurations.Count == 0)
return;
for (int i = 0; i < module.targetConfigurations.Count; i++)
{
GameObject prefab = module.targetConfigurations[i];
if (prefab == null)
{
Debug.LogWarning($"ScenarioLoader: Target prefab at index {i} is null.");
continue;
}
Vector3 position = targetSpawnArea.position + (i * targetSpacing);
GameObject targetObj = Instantiate(prefab, position, Quaternion.identity, targetSpawnArea);
spawnedTargets.Add(targetObj);
}
}
}
Why this is core:
This builds the placement side of the scenario by instantiating the target configuration prefabs defined by the module. Once this is done, the module has the places where interactables can be dropped and validated.
Summary:
ScenarioLoader is the scene construction script for gameplay. It loads the selected module, applies environment settings like skybox and ambient audio, spawns interactables and targets, creates reset interactable objects, and then initializes the step manager so progression can begin. It is the main bridge between module data and a playable scenario.
InteractableSpawner.cs
This script is a core part of the spawn pipeline because it turns InteractableData into real runtime objects, assigns the correct interactable type, and wires the new object into the rest of the gameplay systems.
InteractableSpawner.cs
This script is a core part of the spawn pipeline because it turns InteractableData into real runtime objects, assigns the correct interactable type, and wires the new object into the rest of the gameplay systems.
1. Core References and Type Caches
Code Block: Core references and type caches
public class InteractableSpawner : MonoBehaviour
{
[SerializeField] private Transform defaultParent;
[HideInInspector] public StepManager stepManager;
[HideInInspector] public InteractionManager interactionManager;
[HideInInspector] public TipsTutorialManager tipsTutorialManager;
// Caches resolved runtime types so we do not scan all assemblies every spawn.
private static readonly Dictionary<string, Type> cachedRuntimeTypes = new();
// Fallback interactable type used when no valid runtime subclass is provided.
private static readonly Type fallbackInteractableType = typeof(DraggableInteractable);
Why this is core:
This block defines the shared references and the type-resolution cache the spawner relies on. The manager references let spawned interactables immediately connect into the rest of the game, while the cached type dictionary avoids repeated reflection scans when spawning many objects.
2. Main Spawn Entry Point
Code Block: Main spawn entry point
// Spawns a new interactable from InteractableData and applies its runtime setup.
// Primarily called by ScenarioLoader when the scene/module is being built.
public DraggableInteractable Spawn(
InteractableData data,
Vector3 position,
Transform parentOverride,
ResetInteractable resetInteractable = null)
{
if (!CanSpawn(data))
return null;
Transform parentToUse = GetParentToUse(parentOverride);
Quaternion spawnRotation = Quaternion.Euler(data.spawnRotationOffset);
GameObject obj = Instantiate(data.prefab, position, spawnRotation, parentToUse);
// Makes sure the spawned object ends up with the correct interactable script on it.
DraggableInteractable interactable = ConfigureInteractableComponent(obj, data);
if (interactable == null)
{
Debug.LogError($"InteractableSpawner: Spawn failed. Could not configure interactable for '{data.name}'.");
Destroy(obj);
return null;
}
// Applies the shared runtime references and data after the object is created.
ApplySpawnData(interactable, data, resetInteractable);
interactionManager?.RegisterInteractable(interactable);
return interactable;
}
Why this is core:
This is the main function of the whole script. It validates the data, chooses the parent, applies spawn rotation, instantiates the prefab, ensures the spawned object has the right interactable component, applies all shared runtime data, and finally registers the object with the interaction system.
3. Add the Resolved Interactable Type Safely
Code Block: Add the resolved interactable type safely
// Adds the requested interactable component type to the spawned object.
// This is only called after the type has been resolved.
private DraggableInteractable AddInteractableComponent(GameObject obj, Type interactableType)
{
if (obj == null || interactableType == null)
return null;
// Extra safety check so only valid DraggableInteractable types get added.
if (!typeof(DraggableInteractable).IsAssignableFrom(interactableType))
{
Debug.LogWarning($"InteractableSpawner: Type '{interactableType.Name}' is not a DraggableInteractable. Falling back.");
interactableType = fallbackInteractableType;
}
return (DraggableInteractable)obj.AddComponent(interactableType);
}
Why this is core:
This is the final component-attachment step. It ensures only valid DraggableInteractable subclasses are added and falls back safely if the resolved type is invalid.
4. Resolve Runtime Class Names into Real C# Types
Code Block: Resolve runtime class names into real C# types
// Resolves the runtime class name from InteractableData into a real C# Type.
// This lets different interactables spawn with custom subclasses like NailInteractable or HammerInteractable.
private Type ResolveInteractableType(InteractableData data)
{
if (data == null || string.IsNullOrWhiteSpace(data.runtimeClassName))
return fallbackInteractableType;
string className = data.runtimeClassName.Trim();
// Use the cached result first so repeated spawns do not keep scanning assemblies.
if (cachedRuntimeTypes.TryGetValue(className, out Type cachedType))
return cachedType;
Type resolvedType = null;
try
{
// Searches loaded assemblies for a type whose class name matches the data value.
resolvedType = AppDomain.CurrentDomain
.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.FirstOrDefault(type => type.Name == className);
}
catch (ReflectionTypeLoadException ex)
{
Debug.LogWarning($"InteractableSpawner: Reflection type load issue while resolving '{className}'. {ex.Message}");
}
// If the name is missing, invalid, or not a DraggableInteractable subclass, fall back safely.
if (resolvedType == null || !typeof(DraggableInteractable).IsAssignableFrom(resolvedType))
{
Debug.LogWarning(
$"InteractableSpawner: Invalid runtime class '{className}' for '{data.name}'. Using base DraggableInteractable instead.");
resolvedType = fallbackInteractableType;
}
cachedRuntimeTypes[className] = resolvedType;
return resolvedType;
}
Why this is core:
This is one of the most important systems in the file. It lets the data decide which runtime behavior class gets attached to a spawned object, such as a nail or hammer subclass, while caching the result for performance and falling back safely when the class name is invalid.
5. Apply Shared Runtime Data After Spawning
Code Block: Apply shared runtime data after spawning
// Applies the main data and scene references after the interactable is spawned.
private void ApplySpawnData(
DraggableInteractable interactable,
InteractableData data,
ResetInteractable resetInteractable)
{
if (interactable == null || data == null)
return;
interactable.data = data;
interactable.resetInteractable = resetInteractable;
interactable.name = data.name;
interactable.stepManager = stepManager;
interactable.interactionManager = interactionManager;
interactable.tipsTutorialManager = tipsTutorialManager;
ConfigureResetInteractableTutorialTarget(data, resetInteractable);
}
Why this is core:
This is where the newly spawned interactable actually becomes part of the rest of the game. It receives its source data, reset interactable object reference, name, and links to the step, interaction, and tips systems.
Summary:
InteractableSpawner spawns interactables from InteractableData, assigns the correct runtime interactable class, and wires each spawned object into the core gameplay systems.
DraggableInteractable.cs
This is the base gameplay object script. It is one of the most important files in the system because it defines how a piece stores its runtime state, how it is dragged, how it previews valid targets, how it gets placed, and how it resets back to its original state.
DraggableInteractable.cs
This is the base gameplay object script. It is one of the most important files in the system because it defines how a piece stores its runtime state, how it is dragged, how it previews valid targets, how it gets placed, and how it resets back to its original state.
1. Core Data, State, and Events
Code Block: Core data, state, and events
public class DraggableInteractable : MonoBehaviour
{
[Header("Data")]
[Tooltip("Data describing this interactable type.")]
public InteractableData data;
[Tooltip("Reference to the step system used to validate placements.")]
public StepManager stepManager;
[Tooltip("Reset interactable associated with this interactable type.")]
public ResetInteractable resetInteractable;
[Header("State")]
[Tooltip("True after the player has dragged this object at least once.")]
public bool hasBeenMoved;
[Tooltip("True when this object has been successfully placed on a valid target.")]
public bool placedCorrectly;
[Tooltip("Used by nail logic to prevent movement/reset of locked objects.")]
public bool isLockedByNail = false;
[Tooltip("Optional extra state for insert-style interactions.")]
public bool isInserted = false;
[field: SerializeField]
[Tooltip("Tracks whether this interactable has been hammered.")]
public bool isHammered { get; protected set; }
[HideInInspector] public List<DraggableInteractable> groupMembers = new();
[HideInInspector] public bool stepCompleted = false;
[Tooltip("Which target this interactable was last placed onto.")]
public TargetID lastPlacedTargetID;
public event Action<DraggableInteractable> OnPlaced;
public event Action<DraggableInteractable> OnReset;
public event Action<DraggableInteractable> RegisterReset;
Why this is core:
This block defines the interactable’s identity, gameplay state, placement/reset state, grouped behavior support, label support, and the events other systems listen to. It is the shared runtime state model for almost every interactable in the game.
2. Awake and Start Initialization Flow
Code Block: Awake and Start initialization flow
protected virtual void Awake()
{
// Cache the object's starting setup before gameplay changes anything.
CacheOriginalTransformState();
CacheRendererState();
SetupRigidbodyIfNeeded();
InitializeRuntimeState();
CacheLabelReference();
}
protected virtual void Start()
{
// Grab common scene references once the scene is up.
mainCamera = Camera.main;
ApplyLabel();
// Lets other systems know this interactable exists and can be tracked for reset.
RegisterReset?.Invoke(this);
}
Why this is core:
This is the object startup flow. It captures the original transform, prepares collision support if needed, initializes runtime state, applies label settings, and notifies the reset system that the interactable exists.
3. Begin Drag
Code Block: Begin drag
// Called when the player first starts dragging this object.
// This sets up drag values but does not move the object yet.
public virtual void BeginDrag(Vector3 mousePos)
{
wasCameraVerticalInputActiveLastFrame = false;
if (placedCorrectly)
return;
Camera cam = GetMainCamera();
if (cam == null)
return;
isDragging = true;
mobileVerticalDragOffset = 0f;
// Save the starting drag setup so movement feels stable while dragging.
dragStartCameraY = cam.transform.position.y;
startPosition = transform.position;
manualVerticalOffset = 0f;
lastDragPointerPosition = new Vector2(mousePos.x, mousePos.y);
lastDragCameraPosition = cam.transform.position;
// Drag against a real horizontal plane so the object can move cleanly in both X and Z.
dragPlaneY = startPosition.y;
dragPlane = new Plane(Vector3.up, new Vector3(0f, dragPlaneY, 0f));
Ray ray = cam.ScreenPointToRay(mousePos);
if (dragPlane.Raycast(ray, out float enter))
{
Vector3 worldPoint = ray.GetPoint(enter);
offset = startPosition - worldPoint;
offset.y = 0f;
}
else
{
offset = Vector3.zero;
}
if (SoundManager.Instance != null)
SoundManager.Instance.PlayGrabSound();
}
Why this is core:
This starts the drag interaction. It blocks dragging already-placed objects, caches the drag start state, sets up a horizontal drag plane, calculates the pointer-to-object offset, and plays the grab sound. This is the first half of the object interaction flow.
4. Live Drag Movement
Code Block: Live drag movement
// Called every frame while the player is dragging this object.
// This updates the object's position and refreshes the drop-zone preview.
public virtual void Drag(Vector3 mousePos)
{
if (!isDragging)
return;
if (!this || !gameObject || !gameObject.activeInHierarchy)
return;
Camera cam = GetMainCamera();
if (cam == null)
return;
Vector3 previousPosition = transform.position;
bool verticalInputActive = IsCameraVerticalInputActive();
// Rebase when entering Q/E vertical mode so Y continues smoothly
// from the object's current position.
if (verticalInputActive && !wasCameraVerticalInputActiveLastFrame)
{
startPosition = previousPosition;
dragStartCameraY = cam.transform.position.y;
dragPlaneY = previousPosition.y;
dragPlane = new Plane(Vector3.up, new Vector3(0f, dragPlaneY, 0f));
Ray rebaseRay = cam.ScreenPointToRay(mousePos);
if (dragPlane.Raycast(rebaseRay, out float rebaseEnter))
{
Vector3 rebasePoint = rebaseRay.GetPoint(rebaseEnter);
offset = previousPosition - rebasePoint;
offset.y = 0f;
}
else
{
offset = Vector3.zero;
}
}
// Rebase when leaving Q/E so planar drag resumes from the object's
// current position instead of snapping to an old ray/offset anchor.
else if (!verticalInputActive && wasCameraVerticalInputActiveLastFrame)
{
startPosition = previousPosition;
dragStartCameraY = cam.transform.position.y;
dragPlaneY = previousPosition.y;
dragPlane = new Plane(Vector3.up, new Vector3(0f, dragPlaneY, 0f));
Ray rebaseRay = cam.ScreenPointToRay(mousePos);
if (dragPlane.Raycast(rebaseRay, out float rebaseEnter))
{
Vector3 rebasePoint = rebaseRay.GetPoint(rebaseEnter);
offset = previousPosition - rebasePoint;
offset.y = 0f;
}
else
{
offset = Vector3.zero;
}
}
bool pointerMoved =
Vector2.Distance(lastDragPointerPosition, new Vector2(mousePos.x, mousePos.y)) > 0.01f;
bool cameraPlanarMoved =
Vector2.Distance(
new Vector2(lastDragCameraPosition.x, lastDragCameraPosition.z),
new Vector2(cam.transform.position.x, cam.transform.position.z)
) > 0.0001f;
if (verticalInputActive)
{
// Keep the planar drag surface at the object's live height while Q/E moves it.
dragPlaneY = previousPosition.y;
dragPlane = new Plane(Vector3.up, new Vector3(0f, dragPlaneY, 0f));
// Re-anchor from the PREVIOUS pointer position so the current frame's mouse
// delta still produces movement instead of cancelling itself out.
if (pointerMoved)
{
Ray rebaseRay = cam.ScreenPointToRay(lastDragPointerPosition);
if (dragPlane.Raycast(rebaseRay, out float rebaseEnter))
{
Vector3 rebasePoint = rebaseRay.GetPoint(rebaseEnter);
offset = previousPosition - rebasePoint;
offset.y = 0f;
}
}
}
Ray ray = cam.ScreenPointToRay(mousePos);
if (!dragPlane.Raycast(ray, out float enter))
return;
Vector3 worldPoint = ray.GetPoint(enter);
Vector3 desiredPos = previousPosition;
if (!pointerMoved && !cameraPlanarMoved)
{
desiredPos.x = previousPosition.x;
desiredPos.z = previousPosition.z;
}
else
{
desiredPos.x = worldPoint.x + offset.x;
desiredPos.z = worldPoint.z + offset.z;
}
if (allowManualVerticalDrag && !verticalInputActive)
{
float scroll = Input.mouseScrollDelta.y;
if (Mathf.Abs(scroll) > 0.001f)
{
float previousManualVerticalOffset = manualVerticalOffset;
manualVerticalOffset += scroll * manualVerticalDragSpeed;
manualVerticalOffset = Mathf.Clamp(
manualVerticalOffset,
-maxManualVerticalOffset,
maxManualVerticalOffset
);
if (!Mathf.Approximately(previousManualVerticalOffset, manualVerticalOffset))
NotifyInteractableMoveVerticalScrollToTutorial();
}
}
float cameraDeltaY = cam.transform.position.y - dragStartCameraY;
desiredPos.y = startPosition.y + cameraDeltaY + manualVerticalOffset + mobileVerticalDragOffset;
transform.position = desiredPos;
lastDragPointerPosition = new Vector2(mousePos.x, mousePos.y);
lastDragCameraPosition = cam.transform.position;
wasCameraVerticalInputActiveLastFrame = verticalInputActive;
UpdateDropZone();
}
Why this is core:
This is the main movement loop for dragged objects. It handles planar dragging, vertical dragging, re-basing when switching between drag modes, camera-relative motion, and then updates the drop-zone preview. This is one of the most important gameplay blocks in the entire interaction system.
5. End Drag and Attempt Placement
Code Block: End drag and attempt placement
// Called when the player releases the object.
// This stops dragging and tries to place the object if it is over a valid target.
public virtual void EndDrag()
{
if (!isDragging)
return;
if (!this || !gameObject)
return;
isDragging = false;
hasBeenMoved = true;
manualVerticalOffset = 0f;
mobileVerticalDragOffset = 0f;
HandlePlacement();
if (!placedCorrectly)
EnsureResetInteractableVisibleForMovedItem();
}
Why this is core:
This completes the drag cycle. It ends the drag state, marks the object as moved, resets temporary drag offsets, attempts placement, and shows the reset interactable if the object was moved but not placed.
6. Placement Validation and Request
Code Block: Placement validation and request
// Runs after drag ends to see whether this object should be placed onto a target.
protected virtual void HandlePlacement()
{
if (ignorePlacementChecks)
return;
if (closestTarget == null || stepManager == null || data == null)
{
HideDropZone();
return;
}
if (!stepManager.ValidatePlacement(data.id, closestTarget.targetID))
{
HideDropZone();
return;
}
// Even if a target is valid, the object still has to be close enough to snap.
if (Vector3.Distance(transform.position, closestTarget.transform.position) > dropZoneRadius)
{
HideDropZone();
return;
}
InteractionManager manager = GetInteractionManager();
if (manager == null)
{
HideDropZone();
return;
}
bool placed = manager.RequestPlacement(this, closestTarget, wasForcePlaced: false);
if (placed && SoundManager.Instance != null && data != null)
SoundManager.Instance.PlayPlacementSound(data);
if (!placed)
HideDropZone();
}
Why this is core:
This is the actual placement gate. It checks whether placement should be ignored, whether a valid target exists, whether the step system allows the placement, whether the object is close enough, and then asks the interaction manager to complete the placement.
7. Apply Placed State
Code Block: Apply placed state
// Applies the placed state after InteractionManager accepts the placement.
// "forced" is true when the object was auto-placed by gameplay logic instead of the player.
public virtual void ApplyPlacedState(Target target, bool forced)
{
if (target == null)
return;
Transform placementTransform =
target.orientPosRotTarget != null
? target.orientPosRotTarget.transform
: target.transform;
// Snap to the target's placement point and keep the original scale.
transform.position = placementTransform.position;
transform.rotation = placementTransform.rotation;
transform.localScale = originalScale;
transform.SetParent(target.transform, true);
wasForcePlaced = forced;
placedCorrectly = true;
hasBeenMoved = false;
lastPlacedTargetID = target.targetID;
target.RegisterPlacedInteractable(this);
NotifyPlacedCorrectlyToTutorial();
HideDropZone();
// Lets other systems react when this object has been placed successfully.
OnPlaced?.Invoke(this);
}
Why this is core:
This is what turns a moving object into a placed object. It snaps the object into the target’s placement transform, preserves scale, updates state flags, records the target ID, registers the placement with the target, and fires the placed event for other systems.
8. Reset Logic
Code Block: Reset logic
// Restores this object to its original transform and base runtime state.
// This is the shared reset logic used by normal reset flows.
public virtual void ApplyResetState()
{
isDragging = false;
closestTarget = null;
manualVerticalOffset = 0f;
mobileVerticalDragOffset = 0f;
HideDropZone();
transform.SetParent(originalParent);
transform.localPosition = originalPosition;
transform.localRotation = originalRotation;
transform.localScale = originalScale;
placedCorrectly = false;
hasBeenMoved = false;
lastPlacedTargetID = default;
stepCompleted = false;
isHammered = false;
if (cachedRenderer != null)
cachedRenderer.material.color = originalColor;
gameObject.SetActive(true);
}
// Main reset entry point. Child classes can override this if they need custom reset behavior.
public virtual void ResetToOriginalState()
{
ApplyResetState();
}
Why this is core:
This is the base reset system. It restores the object’s original transform, clears placement state, resets hammered state, restores visuals, and reactivates the object. Child classes can override the outer reset method if they need special reset behavior.
Summary:
DraggableInteractable is the base object interaction script. It stores runtime state, handles drag setup and movement, previews nearby valid targets, requests placement through the interaction manager, applies placed state when accepted, and restores the object to its original setup when reset. Most interactable objects in the game inherit or depend on this shared behavior.
9. Extending DraggableInteractable (Custom Subclasses)
Code Block: GlueInteractable subclass
using UnityEngine;
public class GlueInteractable : DraggableInteractable
{
public override void ApplyPlacedState(Target target, bool forced)
{
base.ApplyPlacedState(target, forced);
GlueReceiver receiver = target.GetComponentInParent<GlueReceiver>();
if (receiver == null)
receiver = target.GetComponentInChildren<GlueReceiver>();
if (receiver != null)
receiver.ApplyGlue();
}
}
Why this is core:
New gameplay behaviors are created by extending DraggableInteractable into small, focused subclasses that override only the logic they need. For example, GlueInteractable keeps all default drag and placement behavior, but adds a custom rule after placement by marking a target as “glued” through a GlueReceiver component. Other objects, like a peg, can then check that glued state before allowing placement. This keeps the system modular and scalable, allowing new interaction types to be added without modifying existing core systems.
HammerInteractable.cs
This subclass extends DraggableInteractable so the hammer can do more than just move and place. Its special job is to hammer nearby nails when released.
HammerInteractable.cs
This subclass extends DraggableInteractable so the hammer can do more than just move and place. Its special job is to hammer nearby nails when released.
1. Release Hammer, Then Hammer Nearby Nails
Code Block: EndDrag()
public override void EndDrag()
{
// Run the normal drag release logic first.
base.EndDrag();
// After letting go of the hammer, check if any nearby placed nails should be hammered in.
HammerNearbyNails();
}
Why this is core:
This is the hammer’s main behavior override. It preserves the normal drag-release logic from the base class, then adds the extra hammer action when the player lets go near nails.
2. Hammer Nearby Nails
Code Block: HammerNearbyNails()
// Looks for placed nails near the hammer when it is released and hammers them in.
// For grouped nails, this only processes the group once.
private void HammerNearbyNails()
{
Collider[] hits = Physics.OverlapSphere(transform.position, hammerRadius);
// These keep us from hammering the same group or single nail more than once.
HashSet<TargetGroup> handledGroups = new HashSet<TargetGroup>();
HashSet<NailInteractable> handledSingles = new HashSet<NailInteractable>();
bool hammeredSomething = false;
foreach (var hit in hits)
{
NailInteractable nail = FindNearbyNail(hit);
if (nail == null || !nail.placedCorrectly)
continue;
TargetGroup group = nail.GetComponentInParent<TargetGroup>();
if (group != null)
{
if (handledGroups.Contains(group))
continue;
// Use the first placed nail in the group as the entry point so the whole group
// can handle its own hammer logic from there.
NailInteractable firstNail = GetFirstPlacedGroupNail(group);
if (firstNail != null)
{
firstNail.ApplyHammerDirectly();
handledGroups.Add(group);
hammeredSomething = true;
}
}
else
{
if (handledSingles.Contains(nail))
continue;
nail.ApplyHammerDirectly();
handledSingles.Add(nail);
hammeredSomething = true;
}
}
// Only play the hammer sound if at least one nail was actually hammered.
if (hammeredSomething && SoundManager.Instance != null && data != null)
SoundManager.Instance.PlayPlacementSound(data, hammerVolume);
}
Why this is core:
This is the actual hammer mechanic. It searches for nearby nails, prevents double-processing of groups or singles, uses one nail as the entry point for grouped hammering, and only plays sound if at least one nail was actually hammered.
3. Nail Lookup Helpers
Code Block: FindNearbyNail() and GetFirstPlacedGroupNail()
// Tries to find a nail from the collider we hit.
// Some colliders may be on a parent or child object, so we check both directions.
private NailInteractable FindNearbyNail(Collider hit)
{
NailInteractable nail = hit.GetComponentInParent<NailInteractable>();
if (nail == null)
nail = hit.GetComponentInChildren<NailInteractable>();
return nail;
}
// Finds the first placed nail in a target group.
// This helps grouped nails use the original/source nail as the one that drives group hammering.
private NailInteractable GetFirstPlacedGroupNail(TargetGroup group)
{
if (group == null || group.targets == null)
return null;
foreach (var target in group.targets)
{
if (target == null)
continue;
List<DraggableInteractable> placedItems = target.GetPlacedInteractables();
foreach (var item in placedItems)
{
NailInteractable nail = item as NailInteractable;
if (nail != null && nail.placedCorrectly)
return nail;
}
}
return null;
}
Why this is core:
These helpers support the hammer mechanic by locating nails from physics hits and finding the correct entry nail for grouped hammering.
HammerInteractable extends the base draggable object so the hammer can trigger nearby nail hammering when released. It searches for placed nails within range, handles grouped nails only once, and routes the hammer action into each nail’s hammering logic.
NailInteractable.cs
This subclass extends DraggableInteractable so nails can be hammered in after placement and can lock related pieces when hammered.
NailInteractable.cs
This subclass extends DraggableInteractable so nails can be hammered in after placement and can lock related pieces when hammered.
1. Main Hammer Entry Point
Code Block: ApplyHammerDirectly()
public class NailInteractable : DraggableInteractable
{
// Called by the hammer after release.
// This is the main entry point that decides whether to hammer or unhammer this nail,
// and whether that should affect a whole group or just this one nail.
public void ApplyHammerDirectly()
{
if (!placedCorrectly)
return;
bool hammer = !isHammered;
// In Guided mode, nails can be hammered in, but not toggled back out.
if (!hammer &&
stepManager != null &&
stepManager.currentMode == StepManager.PlayMode.Guided)
{
return;
}
TargetGroup group = GetComponentInParent<TargetGroup>();
if (group != null)
{
ApplyHammerToGroup(group, hammer);
SetConnectedTargetsLocked(group, hammer);
}
else
{
ApplyHammerSingle(hammer);
SetParentPanelLocked(hammer);
}
}
Why this is core:
This is the nail’s main hammer behavior. It decides whether the nail should hammer or unhammer, enforces Guided mode restrictions, and routes the action either to a full target group or to the single nail case.
2. Group Hammering
Code Block: ApplyHammerToGroup()
// If this nail belongs to a target group, hammer or unhammer every placed nail in that group.
private void ApplyHammerToGroup(TargetGroup group, bool hammer)
{
if (group == null)
return;
foreach (var target in group.targets)
{
if (target == null)
continue;
List<DraggableInteractable> placedItems = target.GetPlacedInteractables();
foreach (var item in placedItems)
{
NailInteractable nail = item as NailInteractable;
if (nail != null && nail.placedCorrectly)
nail.ApplyHammerSingle(hammer);
}
}
}
Why this is core:
This supports grouped nails by applying the hammer action across all placed nails in the group rather than treating each nail independently.
3. Lock Connected Placed Objects
Code Block: SetConnectedTargetsLocked() and SetParentPanelLocked()
// Locks or unlocks any connected placed pieces tied to this nail group.
// This is what stops linked panels from moving once the nails are hammered in.
private void SetConnectedTargetsLocked(TargetGroup group, bool locked)
{
if (group == null || group.connectedTargets == null)
return;
foreach (var connected in group.connectedTargets)
{
if (connected == null)
continue;
List<DraggableInteractable> placedItems = connected.GetPlacedInteractables();
foreach (var item in placedItems)
{
if (item == null || item is NailInteractable)
continue;
item.isLockedByNail = locked;
}
}
}
// For a single loose nail that is not part of a group, lock or unlock the other placed
// object sitting on the same target. This is usually the panel the nail was placed into.
private void SetParentPanelLocked(bool locked)
{
if (transform.parent == null)
return;
Target parentTarget = transform.parent.GetComponent<Target>();
if (parentTarget == null)
return;
List<DraggableInteractable> placedItems = parentTarget.GetPlacedInteractables();
foreach (var item in placedItems)
{
if (item == null || item == this || item is NailInteractable)
continue;
item.isLockedByNail = locked;
}
}
Why this is core:
This is what gives nails gameplay consequences beyond their own state. When nails are hammered, they can lock connected panels or other placed objects so those pieces can no longer be moved until the nail is released or reset.
4. Apply Hammer State to a Single Nail
Code Block: ApplyHammerSingle()
// Applies the actual hammered state to this one nail only.
// Group handling is done elsewhere, then this function updates the individual nail state,
// visuals, tutorial progress, and hammer notifications.
public void ApplyHammerSingle(bool hammer)
{
if (!placedCorrectly)
return;
if (isHammered == hammer)
return;
isHammered = hammer;
if (hammer)
ApplyHammerVisualState();
else
RemoveHammerVisualState();
if (hammer)
NotifyHammeredToTutorial();
if (interactionManager != null)
interactionManager.NotifyNailHammerChanged(this, hammer);
}
Why this is core:
This is the single-nail state update. It changes the hammered flag, updates visuals, notifies other systems, and serves as the final hammer-state application point whether the action came from a grouped or single nail flow.
5. Hammered and Unhammered Visual State
Code Block: ApplyHammerVisualState() and RemoveHammerVisualState()
// Pushes the nail inward to show the hammered-in visual state.
private void ApplyHammerVisualState()
{
transform.localPosition += new Vector3(0f, data.hammerDepth, 0f);
if (cachedRenderer != null)
{
// Color swap was tested here before, but is currently unused.
// cachedRenderer.material.color = Color.yellow;
}
placedCorrectly = true;
}
// Pulls the nail back out to its unhammered visual state.
private void RemoveHammerVisualState()
{
transform.localPosition -= new Vector3(0f, data.hammerDepth, 0f);
if (cachedRenderer != null)
{
// Color swap was tested here before, but is currently unused.
// cachedRenderer.material.color = Color.green;
}
placedCorrectly = true;
stepCompleted = false;
}
}
Why this is core:
This is the visual feedback for the hammer mechanic. It moves the nail inward or outward based on hammerDepth, which is what makes the hammer action visibly change the object in the scene.
NailInteractable extends the base draggable object so nails can be hammered after placement. It supports both single nails and grouped nails, updates the hammered visual state, and can lock connected placed objects such as panels once the nails are hammered in.
TargetGroup.cs
This script manages groups of related targets. Its main jobs are to collect the targets in the group, lock connected targets when needed, and auto-fill matching interactables across the rest of the group after one source interactable is placed.
TargetGroup.cs
This script manages groups of related targets. Its main jobs are to collect the targets in the group, lock connected targets when needed, and auto-fill matching interactables across the rest of the group after one source interactable is placed.
1. Group Target and Connected Target Lists
Code Block: Group target and connected target lists
public class TargetGroup : MonoBehaviour
{
[Header("Targets In This Group")]
[Tooltip("All targets that belong to this group.")]
public List<Target> targets = new List<Target>();
[Header("Connected Targets")]
[Tooltip("Other targets affected when nails in this group are hammered/locked.")]
public List<Target> connectedTargets = new List<Target>();
Why this is core:
This defines the structure of a target group. One list holds the main grouped placement targets, while the second list holds other targets affected by the group’s hammered and locked behavior.
2. Lock and Unlock Connected Targets
Code Block: Lock and unlock connected targets
// Convenience helper to lock any placed interactables on connected targets.
public void LockConnectedTargets()
{
SetConnectedTargetsLocked(true);
}
// Convenience helper to unlock any placed interactables on connected targets.
public void UnlockConnectedTargets()
{
SetConnectedTargetsLocked(false);
}
// Applies the locked state to interactables sitting on the connected targets.
// This is shared by the public lock/unlock helpers above.
private void SetConnectedTargetsLocked(bool locked)
{
foreach (var target in connectedTargets)
{
if (target == null)
continue;
List<DraggableInteractable> placedItems = target.GetPlacedInteractables();
foreach (var placedInteractable in placedItems)
{
if (placedInteractable == null)
continue;
// When locking, only lock things that were actually placed correctly.
// When unlocking, clear the lock regardless.
if (!locked || placedInteractable.placedCorrectly)
placedInteractable.isLockedByNail = locked;
}
}
}
Why this is core:
This is the group-level lock system. It lets the group lock or unlock interactables on connected targets, which is how hammered nail groups can prevent linked pieces from moving.
3. Auto-Fill the Rest of the Group
Code Block: Auto-fill the rest of the group
// When one interactable in the group is placed, auto-fill matching copies onto the other open targets.
public void AutoFillGroup(DraggableInteractable source, InteractionManager manager, ResetInteractable resetObj)
{
if (!CanAutoFill(source, manager, resetObj))
return;
Target sourceTarget = source.GetComponentInParent<Target>();
if (sourceTarget == null)
return;
// Make sure the original placed item owns the shared group list first.
EnsureSharedGroupList(source);
List<Target> fillableTargets = GetFillableTargets(sourceTarget, source);
foreach (var target in fillableTargets)
{
DraggableInteractable extra = SpawnGroupMember(source, manager, resetObj);
if (extra == null)
continue;
extra.sourceObject = source.sourceObject;
PlaceGroupMember(extra, target);
LinkToSourceGroup(source, extra);
// Intentionally not consuming resetObj amount here.
// Stock/return behavior is handled by your existing placement/reset flow.
manager.NotifyAutoFilledPlacement(extra, target);
}
}
Why this is core:
This is the main grouped placement mechanic. When one interactable is placed into a grouped target setup, this method creates matching copies for the other valid targets, places them automatically, and links them back to the same shared group list.
4. Spawn, Place, and Link Group Members
Code Block: Spawn, place, and link group members
// Spawns one extra interactable that will be auto-placed onto another target in the group.
private DraggableInteractable SpawnGroupMember(
DraggableInteractable source,
InteractionManager manager,
ResetInteractable resetObj
{
DraggableInteractable extra = manager.spawner.Spawn(
source.data,
resetObj.transform.position,
null,
resetObj
);
if (extra == null)
return null;
extra.sourceObject = source.sourceObject;
manager.RegisterInteractable(extra);
return extra;
}
// Places the spawned copy onto its target as a forced placement.
private void PlaceGroupMember(DraggableInteractable extra, Target target)
{
if (extra == null || target == null)
return;
extra.ApplyPlacedState(target, forced: true);
// Grouped auto-filled copies do not mark themselves complete here.
// StepManager reevaluates completion separately.
extra.stepCompleted = false;
}
// Makes sure the source interactable has a shared list that will track the whole group.
private void EnsureSharedGroupList(DraggableInteractable source)
{
if (source.groupMembers == null)
source.groupMembers = new List<DraggableInteractable>();
if (!source.groupMembers.Contains(source))
source.groupMembers.Add(source);
}
// Links a spawned copy into the same shared group list as the source interactable.
private void LinkToSourceGroup(DraggableInteractable source, DraggableInteractable extra)
{
if (source == null || extra == null || source.groupMembers == null)
return;
extra.groupMembers = source.groupMembers;
if (!source.groupMembers.Contains(extra))
source.groupMembers.Add(extra);
}
}
Why this is core:
This is the rest of the group autofill pipeline. It spawns each extra interactable, force-places it onto the matching target, and links all copies into the same shared group list so reset and group logic can treat them as one unit.
Summary:
TargetGroup allows multiple targets to behave as one grouped placement system. It automatically finds its child targets, can lock connected placed objects, and supports auto-filling matching interactables across the rest of the group after one source interactable is placed.
Target.cs
This script represents one placement target in the scene. Its main job is to identify the target, define its placement rules, track which interactables are currently placed on it, and expose helper methods the rest of the system uses during placement and reset.
Target.cs
This script represents one placement target in the scene. Its main job is to identify the target, define its placement rules, track which interactables are currently placed on it, and expose helper methods the rest of the system uses during placement and reset.
1. Target Identity, Snap Transform, and Runtime Rules
Code Block: Target identity, snap transform, and runtime rules
[Tooltip("Unique ID used by StepManager to match steps to the correct target.")]
public TargetID targetID;
Why this is core:
This block defines what a target is. It gives the target its ID for step matching show here along with an optional exact snap transform, placement rules like single or multiple occupancy, and optional display label information.
2. Register and Unregister Placed Interactables
Code Block: Register and unregister placed interactables
// Called when something is successfully placed onto this target.
// This is typically used by DraggableInteractable.ApplyPlacedState during placement.
public void RegisterPlacedInteractable(DraggableInteractable interactable)
{
if (interactable == null)
return;
CleanupNulls();
if (!placedInteractables.Contains(interactable))
placedInteractables.Add(interactable);
RefreshOccupiedState();
}
// Called when something is removed or reset off this target.
// This is typically used during reset flows before the interactable is moved away.
public void UnregisterPlacedInteractable(DraggableInteractable interactable)
{
if (interactable == null)
return;
CleanupNulls();
placedInteractables.Remove(interactable);
RefreshOccupiedState();
}
Why this is core:
This is how the target keeps its placed-item list accurate. Interactables call into these methods when they are placed or removed, so the target always knows what is sitting on it.
3. Placement Rule Checks and Accessors
Code Block: Placement rule checks and accessors
// Checks whether this target can currently accept another interactable.
// Placement validation uses this alongside step validation.
public bool CanAcceptInteractable(DraggableInteractable interactable)
{
CleanupNulls();
if (!allowMultipleInteractables)
return placedInteractables.Count == 0;
return true;
}
// Returns how many interactables are currently placed on this target.
public int GetPlacedCount()
{
CleanupNulls();
return placedInteractables.Count;
}
// Checks whether this target already contains a specific interactable type.
public bool HasInteractableType(InteractableID interactableID)
{
CleanupNulls();
foreach (var item in placedInteractables)
{
if (item != null && item.data != null && item.data.id == interactableID)
return true;
}
return false;
}
// Returns a safe copy of the placed interactables list.
public List<DraggableInteractable> GetPlacedInteractables()
{
CleanupNulls();
return new List<DraggableInteractable>(placedInteractables);
}
Why this is core:
These are the main query methods the rest of the game uses to work with targets. They determine whether a target can accept another interactable, how many are already placed there, whether a specific type is present, and provide access to the placed interactables list.
4. Occupancy State and Cleanup
Code Block: Occupancy state and cleanup
// Manually sets the occupied flag and updates the target mesh visibility to match.
public void SetOccupied(bool occupied)
{
HasPlacedInteractable = occupied;
if (meshRenderer != null)
meshRenderer.enabled = !occupied;
}
// Recalculates occupied state from the current placed list.
// This is used after placing, resetting, or cleaning up destroyed references.
private void RefreshOccupiedState()
{
CleanupNulls();
HasPlacedInteractable = placedInteractables.Count > 0;
if (meshRenderer != null)
meshRenderer.enabled = !HasPlacedInteractable;
}
// Removes destroyed interactables from the runtime list so occupancy checks stay accurate.
private void CleanupNulls()
{
for (int i = placedInteractables.Count - 1; i >= 0; i--)
{
if (placedInteractables[i] == null)
placedInteractables.RemoveAt(i);
}
}
}
Why this is core:
This keeps the target’s occupancy state accurate both logically and visually. It updates the placed flag, toggles the target mesh, and removes destroyed references so placement checks stay correct over time.
Summary:
Target represents one placement location in the scene. It stores the target ID used for step validation, defines occupancy rules, tracks which interactables are currently placed there, and updates its occupied state so the rest of the system can validate placements correctly.
ResetInteractable.cs
This script is the core of the reset interaction system. It tracks the remaining reset and spawn amount for one interactable type, decides when the reset object should be visible, and routes reset requests into the interaction manager when clicked.
ResetInteractable.cs
This script is the core of the reset interaction system. It tracks the remaining reset and spawn amount for one interactable type, decides when the reset object should be visible, and routes reset requests into the interaction manager when clicked.
1. Mode Mapping from StepManager
Code Block: Mode mapping from StepManager
// Converts StepManager's mode into this script's local enum.
// If StepManager is missing, this safely defaults to Guided.
private PlayMode CurrentMode
{
get
{
if (stepManager != null)
{
return stepManager.currentMode == StepManager.PlayMode.FreePlay
? PlayMode.FreePlay
: PlayMode.Guided;
}
return PlayMode.Guided;
}
}
Why this is core:
The reset object behaves differently in Guided and FreePlay, so it needs a way to read the current game mode. This property converts the step manager’s mode into the reset object’s local mode logic.
2. Reset Amount Management
Code Block: Reset amount management
// Sets the current reset amount directly.
// "isInitial" is used when the starting max should also be updated.
public void SetResetAmount(int amount, bool isInitial = false)
{
resetAmount = amount;
if (isInitial)
originalResetAmount = amount;
// Keep the value within the valid runtime range.
resetAmount = Mathf.Clamp(resetAmount, 0, originalResetAmount);
RefreshResetText();
}
// Adds or removes from the current amount, then refreshes the UI text.
public void UpdateResetAmount(int delta)
{
resetAmount += delta;
resetAmount = Mathf.Clamp(resetAmount, 0, originalResetAmount);
RefreshResetText();
}
// Gives one reset/spawn back, but never above the original starting amount.
public void RestoreOneResetAmount()
{
if (resetAmount < originalResetAmount)
{
resetAmount++;
RefreshResetText();
}
}
Why this is core:
This is the amount system for the reset object. It handles setting the starting count, incrementing or decrementing the count during gameplay, clamping it safely, and refreshing the UI each time the value changes.
3. Click Handling and Reset Request
Code Block: Click handling and reset request
// Called by the 3D click system when this reset object is clicked in the scene.
public void OnClicked(Collider clicked)
{
// Only respond if this reset object's own collider was the one clicked.
if (clicked == null || clicked.gameObject != gameObject)
return;
if (SoundManager.Instance != null && clickSound != null)
SoundManager.Instance.PlayClip(clickSound, clickVolume);
ResetForCurrentMode();
}
// Sends the reset request to InteractionManager, then refreshes this reset object's visuals.
public void ResetForCurrentMode()
{
if (interactionManager == null)
return;
interactionManager.RequestResetFromResetInteractable(this);
RefreshVisibility();
}
Why this is core:
This is the main interaction path for the reset object. When clicked, it plays its sound and routes the reset request into the interaction manager, which is where the actual reset logic is handled.
4. Determine Whether Any Interactable Can Currently Be Reset
Code Block: Determine whether any interactable can currently be reset
// Checks whether this reset object currently has anything the player is allowed to reset.
public bool AnyInteractableEligible()
{
foreach (var interactable in interactableTypeList)
{
if (interactable == null)
continue;
if (CurrentMode == PlayMode.Guided)
{
// Guided mode only shows reset when an item was moved but not successfully placed.
if (interactable.hasBeenMoved && !interactable.IsPlacedCorrectly())
return true;
}
else
{
// FreePlay allows resetting either moved items or already placed items.
if (interactable.hasBeenMoved || interactable.IsPlacedCorrectly())
return true;
}
}
return false;
}
Why this is core:
This is the reset object's visibility rule. It checks whether any tracked interactable currently qualifies for reset, and that rule changes depending on whether the game is in Guided or FreePlay mode.
Summary:
ResetInteractable tracks the reset and spawn amount for one interactable type, decides when the reset object should be visible, and sends reset requests into the interaction manager when clicked.
BaseInputManager.cs
This is one of the most important systems in the whole project. It handles desktop and mobile input, decides whether the player is clicking UI, a draggable object, or a 3D button, controls drag ownership, supports mobile camera gestures and virtual controls, manages selected interactables, and updates pickup hover feedback.
BaseInputManager.cs
This is one of the most important systems in the whole project. It handles desktop and mobile input, decides whether the player is clicking UI, a draggable object, or a 3D button, controls drag ownership, supports mobile camera gestures and virtual controls, manages selected interactables, and updates pickup hover feedback.
1. Startup Initialization and Singleton-Style Duplicate Protection
Code Block: Startup initialization and singleton-style duplicate protection
private void Awake()
{
mainCamera = Camera.main;
CacheBlockingUiRaycasters();
CacheFreeCamera();
if (activeInputManager != null && activeInputManager != this)
{
Debug.LogWarning($"Disabling duplicate BaseInputManager: {GetType().Name} on {gameObject.name}");
enabled = false;
return;
}
activeInputManager = this;
isMobile = Application.isMobilePlatform && !Application.isEditor;
if (joystickRoot != null)
joystickRoot.SetActive(useVirtualMobileControls && isMobile);
if (isMobile)
{
Input.multiTouchEnabled = true;
Input.simulateMouseWithTouches = false;
}
}
Why this is core:
This prepares the input manager when the scene starts. It caches the main camera and free camera, builds the initial UI-blocking raycaster list, prevents duplicate input managers from running at once, detects whether the build is mobile, and enables mobile-specific input behavior.
2. Main Update Loop
Code Block: Main update loop
private void Update()
{
// Do not process input if there is no active camera.
if (!IsCameraActive())
return;
RefreshSelectedInteractableState();
UpdateSelectedInteractableCameraFocus();
UpdatePickupHover();
if (isMobile)
{
HandleTouch();
}
else
{
TrackDesktopTutorialInput();
HandleMouse();
}
}
Why this is core:
This is the main runtime entry point for input. Each frame it verifies the camera is valid, refreshes selection state, updates selected-object camera focus, updates the pickup hover helper, and then routes control into either the desktop mouse pipeline or the mobile touch pipeline. for BaseInputManager, using Update() is appropriate because this script is responsible for live input polling: mouse buttons, touch count, joystick movement, hover previews, drag state, and camera-follow behavior all need to be checked every frame while the player is interacting. Your Update() is also mostly acting as a clean router: it checks for an active camera, refreshes selected state, updates hover/focus visuals, then branches into mobile or desktop input handling.
That said, Update() should not be used just because it is convenient. Anything that only needs to happen after a specific action — like a step changing, a button being clicked, an item being placed, or a menu opening — is usually better handled with events. Events avoid unnecessary every-frame checks and make the script easier to reason about. But for input, Update() is usually better than alternatives because Unity’s input state is frame-based, and drag/hover/camera movement must feel immediate. The better optimization is not removing Update() here, but keeping the work inside it lightweight and delegating heavier logic to focused functions, which your script already mostly does.
3. Desktop Mouse Input Flow
Code Block: Desktop mouse input flow
// Handles desktop mouse input.
private Vector2 lastMouseDragPosition;
private void HandleMouse()
{
if (Input.GetMouseButtonDown(0))
{
if (BeginPointerInteraction(Input.mousePosition))
{
lastMouseDragPosition = Input.mousePosition;
if (activeDraggable != null) activeDraggable.hasBeenMoved = false;
}
}
if (Input.GetMouseButton(0) && activeDraggable != null)
{
Vector2 currentMousePos = Input.mousePosition;
Vector2 delta = currentMousePos - lastMouseDragPosition;
if (delta.sqrMagnitude > 0.25f)
{
activeDraggable.hasBeenMoved = true;
TryNotifyTutorialManagerMethod("NotifyInteractableMovePlanarMouse");
}
activeDraggable.Drag(currentMousePos);
lastMouseDragPosition = currentMousePos;
}
if (Input.GetMouseButtonUp(0) && activeDraggable != null && !activeDraggable.hasBeenMoved)
ToggleSelectedInteractable(activeDraggable);
if (selectedDraggable == activeDraggable)
TryNotifyTutorialManagerMethod("NotifySelectInteractable");
if (Input.GetMouseButtonUp(0))
EndPointerInteraction();
}
Why this is core:
This is the desktop interaction pipeline. It begins a click or drag when the mouse is pressed, continues dragging while held, distinguishes a drag from a tap, toggles selection on a tap, and ends the interaction on release.
4. Mobile Touch Entry and High-Level Routing
Code Block: Mobile touch entry and high-level routing
// Handles mobile touch input.
private void HandleTouch()
{
RefreshMobileTutorialTracking();
mobileSecondaryVerticalActiveThisFrame = false;
mobileSecondaryHeldThisFrame = false;
SyncMobileTouches();
if (Input.touchCount == 0)
{
if (activeDraggable != null)
EndPointerInteraction();
ClearMobileFingerRoles();
mobileTouches.Clear();
return;
}
if (activeDraggable != null && activeFingerId != -1)
{
HandleActiveMobileDrag();
}
else if (useVirtualMobileControls && !allowLegacySceneTouchesWithVirtualControls)
{
HandleMobileWorldTouchesOnly();
}
else
{
HandleMobileSceneTouches();
}
CleanupEndedMobileTouches();
}
Why this is core:
This is the main mobile input router. It keeps the touch data synchronized, clears state when all touches end, chooses whether the player is currently dragging an interactable or controlling the scene, and then routes into the appropriate mobile input path.
5. Active Mobile Drag Handling
Code Block: Active mobile drag handling
private void HandleActiveMobileDrag()
{
lookFingerId = -1;
moveFingerId = -1;
scenePanFingerId = -1;
if (!TryGetTouchByFingerId(activeFingerId, out Touch dragTouch, out MobileTouchData dragData))
{
EndPointerInteraction();
verticalAssistFingerId = -1;
return;
}
if (!TryGetTouchByFingerId(verticalAssistFingerId, out Touch helperTouch, out MobileTouchData helperData))
{
verticalAssistFingerId = FindVerticalAssistFinger(activeFingerId);
TryGetTouchByFingerId(verticalAssistFingerId, out helperTouch, out helperData);
}
if (verticalAssistFingerId != -1 && helperData != null)
{
float helperDeltaY = helperTouch.position.y - helperData.lastPosition.y;
if (Mathf.Abs(helperDeltaY) > 0.001f)
{
activeDraggable.AdjustMobileVerticalOffset(helperDeltaY * mobileVerticalUnitsPerPixel);
TryNotifyTutorialManagerMethod("NotifyInteractableMoveVerticalTouch");
}
}
if (dragTouch.phase == TouchPhase.Ended || dragTouch.phase == TouchPhase.Canceled)
{
EndPointerInteraction();
verticalAssistFingerId = -1;
return;
}
activeDraggable.Drag(dragTouch.position);
}
Why this is core:
This is the mobile drag loop when the player is actively dragging an interactable. One finger owns the drag while an optional second finger can provide vertical offset input, and the drag ends automatically when the primary drag finger is released.
6. World Click and Drag Hit Detection
Code Block: World click and drag hit detection
// Starts either a click interaction or a drag, depending on what was hit first.
private bool BeginPointerInteraction(Vector2 screenPos, int pointerId = -1)
{
// Stop scene interaction if the pointer is currently over UI.
if (IsScreenPositionOverUI(screenPos))
return false;
if (!TryGetWorldHitTargets(screenPos, out IClickable3D clickable, out DraggableInteractable draggable, out Collider hitCollider))
return false;
if (clickable != null)
{
clickable.OnClicked(hitCollider);
return true;
}
if (draggable != null)
{
activeDraggable = draggable;
activeDraggable.BeginDrag(screenPos);
return true;
}
return false;
}
private bool TryGetWorldHitTargets(Vector2 screenPos, out IClickable3D clickable, out DraggableInteractable draggable, out Collider hitCollider)
{
clickable = null;
draggable = null;
hitCollider = null;
Ray ray = mainCamera.ScreenPointToRay(screenPos);
RaycastHit[] hits = Physics.RaycastAll(ray, Mathf.Infinity);
if (hits == null || hits.Length == 0)
return false;
// Sort hits so the closest valid object gets checked first.
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
// Check front-to-back so objects in front block objects behind them.
for (int i = 0; i < hits.Length; i++)
{
RaycastHit hit = hits[i];
Collider currentCollider = hit.collider;
if (currentCollider == null)
continue;
// Ignore target colliders so placed objects can still be clicked and dragged.
if (currentCollider.GetComponentInParent<Target>() != null)
continue;
IClickable3D currentClickable = currentCollider.GetComponentInParent<IClickable3D>();
if (currentClickable != null)
{
clickable = currentClickable;
hitCollider = currentCollider;
return true;
}
DraggableInteractable currentDraggable = currentCollider.GetComponentInParent<DraggableInteractable>();
if (currentDraggable != null)
{
draggable = currentDraggable;
hitCollider = currentCollider;
return true;
}
}
return false;
}
Why this is core:
This is the shared world hit-resolution logic used by both desktop and mobile. It blocks scene interaction through UI, raycasts into the world, ignores target colliders so placed objects can still be interacted with, and then picks the closest clickable 3D object or draggable interactable.
7. UI Blocking for World-Space Menus
Code Block: UI blocking for world-space menus
private void CacheBlockingUiRaycasters()
{
blockingUiRaycasters.Clear();
GraphicRaycaster[] allRaycasters = FindObjectsOfType<GraphicRaycaster>(true);
for (int i = 0; i < allRaycasters.Length; i++)
{
GraphicRaycaster raycaster = allRaycasters[i];
if (raycaster != null && raycaster.isActiveAndEnabled)
blockingUiRaycasters.Add(raycaster);
}
}
// Checks the exact screen position against all UI raycasters.
// This is more reliable than IsPointerOverGameObject for world space UI.
private bool IsScreenPositionOverUI(Vector2 screenPos)
{
if (EventSystem.current == null)
return false;
// Refresh so menus enabled or spawned after Awake still block input.
CacheBlockingUiRaycasters();
PointerEventData eventData = new PointerEventData(EventSystem.current)
{
position = screenPos
};
for (int i = 0; i < blockingUiRaycasters.Count; i++)
{
GraphicRaycaster raycaster = blockingUiRaycasters[i];
if (raycaster == null || !raycaster.isActiveAndEnabled)
continue;
uiRaycastResults.Clear();
raycaster.Raycast(eventData, uiRaycastResults);
if (uiRaycastResults.Count > 0)
return true;
}
return false;
}
Why this is core:
This is the fix that stops clicks and touches from passing through world-space UI into scene objects behind it. It actively refreshes the list of GraphicRaycasters so newly enabled or spawned menus also block input correctly.
8. Mobile Virtual Camera and Object Control Methods
Code Block: Mobile virtual camera and object control methods
public void ApplyMobileJoystickMove(Vector2 stick)
{
if (!CanUseMobileCameraControls())
return;
float magnitude = Mathf.Clamp01(stick.magnitude);
if (magnitude <= 0.0001f)
return;
Vector3 flatForward = Vector3.ProjectOnPlane(mainCamera.transform.forward, Vector3.up).normalized;
Vector3 flatRight = Vector3.ProjectOnPlane(mainCamera.transform.right, Vector3.up).normalized;
Vector3 worldMove = flatForward * stick.y + flatRight * stick.x;
ApplyMobileCameraMove(worldMove, magnitude);
}
public void ApplyMobileJoystickLook(Vector2 stick)
{
if (!CanUseMobileCameraControls())
return;
if (stick.sqrMagnitude <= 0.0001f)
return;
Vector2 delta = stick * mobileJoystickLookUnitsPerSecond * Time.deltaTime;
if (activeDraggable == null && CanUseSelectedInteractable(selectedDraggable) && ShouldOrbitSelectedInteractable())
{
ApplySelectedOrbitLook(delta);
return;
}
ApplyMobileCameraLook(delta);
}
public void ApplyMobileCameraVerticalAxis(float axis)
{
if (Mathf.Abs(axis) <= 0.0001f)
return;
if (CanUseSelectedInteractable(selectedDraggable))
{
float appliedDelta = ApplySelectedInteractableVerticalDelta(
axis * mobileVerticalAxisUnitsPerSecond * Time.deltaTime
);
if (Mathf.Abs(appliedDelta) <= 0.0001f)
return;
ApplyMobileCameraVerticalWorld(appliedDelta);
return;
}
if (!CanUseMobileCameraControls() || activeDraggable != null)
return;
ApplyMobileCameraVerticalWorld(axis * mobileVerticalAxisUnitsPerSecond * Time.deltaTime);
}
}
Why this is core:
These methods are the bridge between on-screen mobile controls and actual gameplay behavior. They move the camera, rotate the camera, orbit the selected object when enabled, and support vertical movement for either the selected interactable or the camera depending on context.
Summary:
BaseInputManager is the central input controller for the game. It routes desktop mouse and mobile touch input into dragging, clicking, camera control, object selection, and hover feedback. It also blocks clicks through world-space UI, supports mobile virtual controls, and acts as the bridge between raw player input and gameplay objects like interactables, source objects, and clickable 3D UI elements.
InteractionManager.cs
This script is the central runtime manager for placement, reset, grouped reset behavior, replacement spawning, and nail hammer notifications. It acts as the bridge between interactables, targets, reset interactable objects, and the step system.
InteractionManager.cs
This script is the central runtime manager for placement, reset, grouped reset behavior, replacement spawning, and nail hammer notifications. It acts as the bridge between interactables, targets, reset interactable objects, and the step system.
1. Core Scene References, Runtime Tracking, and Events
Code Block: Core scene references, runtime tracking, and events
public class InteractionManager : MonoBehaviour
{
public event Action<DraggableInteractable, Target, bool> InteractablePlaced;
public event Action<DraggableInteractable, ResetReason> InteractableReset;
public event Action<NailInteractable, bool> NailHammerStateChanged;
public enum ResetReason
{
ResetInteractable,
GuidedBack,
FreePlayStepToggle,
ManualReset,
InvalidMoveReturn
}
Why this is core:
This defines the manager’s role in the scene. It stores the spawner reference used for replacement objects, the list of active reset objects, the placed-item stacks used for last-in-first-out reset behavior, and the events other systems rely on for placement, reset, and hammer-state updates.
2. Register Interactables with Matching Reset Objects
Code Block: Register interactables with matching reset interactable objects
// Assigns an interactable to the matching reset object based on its interactable ID.
// This is used when scene objects already exist or when something new gets spawned.
public void RegisterInteractable(DraggableInteractable interactable)
{
if (interactable == null || interactable.data == null)
return;
foreach (ResetInteractable resetObj in resetInteractableList)
{
if (resetObj == null || resetObj.resetInteractableID != interactable.data.id.ToString())
continue;
resetObj.RegisterInteractable(interactable);
}
}
Why this is core:
This is how new or pre-existing interactables become associated with the correct reset object for their type. That connection is required for reset visibility, reset counts, and reset behavior to work correctly.
3. Main Placement Entry Point
Code Block: Main placement entry point
// Main placement entry point used after drag/drop validation succeeds.
// This applies placed state, tracks the placement for reset, and handles follow-up logic like spawning replacements.
public bool RequestPlacement(DraggableInteractable interactable, Target target, bool wasForcePlaced)
{
if (!CanPlaceInteractable(interactable, target) || interactable.placedCorrectly)
return false;
if (interactable.resetInteractable == null)
{
foreach (ResetInteractable resetObj in resetInteractableList)
{
if (resetObj == null || resetObj.resetInteractableID != interactable.data.id.ToString())
continue;
interactable.resetInteractable = resetObj;
resetObj.RegisterInteractable(interactable);
break;
}
}
// Nails start their own group list so grouped resets can treat the nail set as one unit.
if (interactable.data.id == InteractableID.Nail)
interactable.groupMembers = new List<DraggableInteractable> { interactable };
interactable.ApplyPlacedState(target, wasForcePlaced);
TrackPlacedInteractable(interactable);
// Manual placements should make the reset resetObj visible right away.
if (!wasForcePlaced && interactable.resetInteractable != null)
interactable.resetInteractable.UpdateVisibility(true);
TrySpawnReplacement(interactable);
TryAutoFillGroup(interactable, target);
interactable.resetInteractable?.NotifyPlacementChanged();
InteractablePlaced?.Invoke(interactable, target, wasForcePlaced);
return true;
}
Why this is core:
This is the main placement pipeline for the game. Once a drag/drop passes validation, this method links the interactable to the correct reset object, prepares nail grouping, applies the placed state, records the placement for reset tracking, optionally spawns a replacement, optionally auto-fills grouped targets, updates reset objects visibility, and broadcasts the placement event.
4. Final Placement Validation
Code Block: Final placement validation
// Final safety check before allowing a placement.
// RequestPlacement calls this so the actual placement logic stays cleaner.
private bool CanPlaceInteractable(DraggableInteractable interactable, Target target)
{
if (interactable == null || target == null || interactable.stepManager == null || interactable.data == null)
return false;
return interactable.stepManager.ValidatePlacement(interactable.data.id, target.targetID) &&
target.CanAcceptInteractable(interactable);
}
Why this is core:
This is the last gate before placement happens. It combines step validation with target occupancy rules so only interactables that are valid for the current step and acceptable for that target can be placed.
5. Track Placements So the Most Recent Can Be Undone First
Code Block: Track placements for newest-first reset behavior
// Pushes the newly placed interactable onto that reset objects stack.
// FreePlay uses this to reset the most recently placed item first.
private void TrackPlacedInteractable(DraggableInteractable interactable)
{
ResetInteractable resetObj = interactable.resetInteractable;
if (resetObj == null)
return;
if (!placedStacks.ContainsKey(resetObj))
placedStacks[resetObj] = new Stack<DraggableInteractable>();
placedStacks[resetObj].Push(interactable);
}
Why this is core:
This is the data structure that makes FreePlay reset behavior feel natural. Each placement is stored so that the most recently placed item is always the first one undone, matching how users expect an “undo” action to work.
6. Replacement Spawning and Target-Group Autofill
Code Block: Replacement spawning and target-group autofill
// Spawns a fresh replacement back at the reset objects after something is placed.
// Source-spawned nails are skipped because their source object handles that flow instead.
private void TrySpawnReplacement(DraggableInteractable interactable)
{
if (interactable == null || interactable.data == null || IsSourceNail(interactable))
return;
ResetInteractable resetObj = interactable.resetInteractable;
if (resetObj == null)
return;
// Once the resetObj runs out, clamp the count and stop spawning replacements.
if (resetObj.resetAmount <= 0)
{
resetObj.SetResetAmount(0);
return;
}
resetObj.UpdateResetAmount(-1);
SpawnReplacementAtResetPoint(interactable);
}
// Lets grouped targets auto-fill matching interactables when needed.
// Nails use this so one placement can fill the rest of the group.
private void TryAutoFillGroup(DraggableInteractable interactable, Target target)
{
if (interactable == null || target == null || interactable.resetInteractable == null)
return;
TargetGroup group = target.GetComponentInParent<TargetGroup>();
if (group != null)
group.AutoFillGroup(interactable, this, interactable.resetInteractable);
}
Why this is core:
This block handles the main follow-up behavior after placement. For regular interactables, it consumes one reset amount and spawns a fresh replacement at the reset object. For grouped targets, it lets one placement automatically fill the rest of the group.
7. Broadcast Placement and Hammer-State Changes
Code Block: Broadcast placement and hammer-state changes
// Broadcasts an auto-filled placement so anything listening reacts the same way as a normal placement.
public void NotifyAutoFilledPlacement(DraggableInteractable interactable, Target target)
{
InteractablePlaced?.Invoke(interactable, target, false);
}
// Broadcasts hammer state changes so step logic or other systems can react.
public void NotifyNailHammerChanged(NailInteractable nail, bool hammered)
{
NailHammerStateChanged?.Invoke(nail, hammered);
}
Why this is core:
This lets other systems respond consistently whether a placement was manual or auto-filled, and it also broadcasts nail hammer-state changes so step completion or other systems can react.
8. Reset Entry Points
Code Block: Reset entry points
// Routes a reset object click into the correct reset flow for the active play mode.
public void RequestResetFromResetInteractable(ResetInteractable resetObj)
{
if (resetObj == null)
return;
if (stepManager == null)
return;
if (stepManager.currentMode == StepManager.PlayMode.FreePlay)
ResetMostRecentForResetInteractable(resetObj);
else
ResetMostRecentGuidedForResetInteractable(resetObj);
}
// Resets the most recent interactable that belongs to a specific step.
// FreePlay step toggles use this when a player clicks a completed step to undo it.
public bool RequestResetStep(StepData step)
{
if (step == null)
return false;
DraggableInteractable lastMatching = FindLastPlacedInteractableForStep(step);
if (lastMatching == null || lastMatching.isLockedByNail)
return false;
List<DraggableInteractable> resetSet = lastMatching.GetGroupedMembersSnapshot();
if (resetSet == null || resetSet.Count == 0)
return false;
// If any item in the set is locked, skip the whole reset.
foreach (DraggableInteractable item in resetSet)
{
if (item != null && item.isLockedByNail)
return false;
}
ResetInteractable resetObj = lastMatching.resetInteractable;
ResetInteractableSet(
resetSet,
resetObj,
ResetReason.FreePlayStepToggle,
refundPlacedItems: true,
moveToResetObj: !IsSourceNail(lastMatching)
);
RemoveInteractablesFromPlacedStack(resetObj, resetSet);
return true;
}
Why this is core:
These are the main public reset entry points into the system. One routes a reset objects click into the correct mode-specific reset flow, while the other lets a completed FreePlay step undo its most recent matching interactable set.
9. Guided and FreePlay Reset Behavior
Code Block: Guided and FreePlay reset behavior
// Guided reset uses the resetObj's own interactable list instead of the placed stack.
// It prefers resetting a moved-but-not-placed item first, then falls back to the last placed item.
private void ResetMostRecentGuidedForResetInteractable(ResetInteractable resetObj)
{
if (resetObj == null || resetObj.interactableTypeList == null)
return;
PruneDestroyedReferences(resetObj);
DraggableInteractable lastEligible =
FindLastMovedNotPlacedInteractable(resetObj, includeLocked: true) ??
FindLastPlacedInteractable(resetObj, includeLocked: true);
if (lastEligible == null)
return;
ResetSingleInteractable(lastEligible, ResetReason.ResetInteractable);
CleanupGuidedDuplicates(resetObj, lastEligible);
PruneDestroyedReferences(resetObj);
resetObj.NotifyPlacementChanged();
}
// Resets the most recently placed interactable for this resetObj in FreePlay.
// Grouped items reset together if the top item belongs to a group.
private void ResetLastPlacedForResetInteractable(ResetInteractable resetObj)
{
if (resetObj == null)
return;
PruneDestroyedReferences(resetObj);
DraggableInteractable lastPlaced = PeekValidPlacedInteractable(resetObj);
if (lastPlaced == null || lastPlaced.isLockedByNail)
return;
List<DraggableInteractable> resetSet = lastPlaced.GetGroupedMembersSnapshot();
ResetInteractableSet(
resetSet,
resetObj,
ResetReason.ResetInteractable,
refundPlacedItems: true,
moveToResetObj: !IsSourceNail(lastPlaced)
);
RemoveInteractablesFromPlacedStack(resetObj, resetSet);
// Clear extra state left over from grouped placements.
lastPlaced.hasBeenMoved = false;
lastPlaced.groupMembers?.Clear();
PruneDestroyedReferences(resetObj);
if (placedStacks.TryGetValue(resetObj, out Stack<DraggableInteractable> stack) && stack.Count == 0)
resetObj.UpdateVisibility(false);
resetObj.NotifyPlacementChanged();
}
Why this is core:
This block shows the difference between Guided and FreePlay reset behavior. Guided reset checks the reset object's interactable list and prefers a moved-but-unplaced item first, while FreePlay reset undoes the most recently placed interactable or grouped set first.
13. Replacement Spawning at the Reset Object
Code Block: Replacement spawning at the reset object
// Spawns a replacement interactable back at the reset object after one is placed.
private void SpawnReplacementAtResetPoint(DraggableInteractable interactable)
{
if (spawner == null || interactable == null || interactable.resetInteractable == null)
return;
Vector3 spawnPosition = interactable.resetInteractable.transform.position;
DraggableInteractable newInteractable = spawner.Spawn(
interactable.data,
spawnPosition,
null,
interactable.resetInteractable
);
if (newInteractable == null)
return;
newInteractable.transform.localScale = newInteractable.originalScale;
// RegisterInteractable(newInteractable);
if (!interactable.resetInteractable.interactableTypeList.Contains(newInteractable))
interactable.resetInteractable.interactableTypeList.Add(newInteractable);
}
Why this is core:
This is the physical replacement step after a placement consumes one available item. It creates a fresh interactable at the reset objects position and adds it back to that reset object's tracked interactables so the player has another spare available.
Summary:
InteractionManager is the main coordinator for placement and reset behavior. It accepts valid placements, links interactables to reset objects, tracks placed items in stack order for FreePlay resets, spawns replacement interactables, handles grouped auto-fill behavior, and routes all reset requests through one shared reset pipeline that keeps targets, steps, and reset objects synchronized.
StepManager.cs
This script manages the full step system for both Guided and FreePlay modes. It loads the active module’s steps, validates placements against the current mode, listens for placement, reset, and hammer events from the InteractionManager, tracks which steps are complete, updates the UI, and supports auto-place, reset, previous/next, and bulk step actions.
StepManager.cs
This script manages the full step system for both Guided and FreePlay modes. It loads the active module’s steps, validates placements against the current mode, listens for placement, reset, and hammer events from the InteractionManager, tracks which steps are complete, updates the UI, and supports auto-place, reset, previous/next, and bulk step actions.
1. Core Step Data, Mode, UI, and Runtime Tracking
Code Block: Core step data, mode, UI, and runtime tracking
public class StepManager : MonoBehaviour
{
public enum PlayMode
{
Guided,
FreePlay
}
// Event other systems can listen to when step progress changes.
public event System.Action OnStepProgressChanged;
[Header("Module Data")]
[Tooltip("Currently loaded module.")]
public ModuleData moduleData;
[Header("Module Steps")]
[Tooltip("Runtime copy of the module's steps.")]
public List<StepData> steps = new List<StepData>();
[Header("Mode")]
public PlayMode currentMode = PlayMode.Guided;
public bool allowStepNavigation = true;
[Header("Managers")]
public InteractionManager interactionManager;
[Header("Runtime Tracking")]
[Tooltip("Interactables currently known as placed in the scene.")]
public List<DraggableInteractable> placedInteractables = new List<DraggableInteractable>();
// Tracks whether each step is currently complete.
private readonly Dictionary<StepData, bool> stepCompletion = new Dictionary<StepData, bool>();
// Keeps each FreePlay row tied to the step it represents.
private readonly List<FreePlayRowEntry> freePlayRows = new List<FreePlayRowEntry>();
private int currentStepIndex = 0;
public int CurrentStepIndex => currentStepIndex;
public StepData CurrentStep { get; private set; }
Why this is core:
This block defines the entire runtime model for step progression. It stores the current play mode, the active module’s steps, the current guided step index, the placed interactables list used for requirement evaluation, the completion state for every step, and the UI references for both Guided and FreePlay modes.
2. Event Subscription to Placement, Reset, and Hammer Changes
Code Block: Event subscription to placement, reset, and hammer changes
private void OnEnable()
{
if (interactionManager == null)
return;
// Listen for placement/reset/hammer events so step completion stays in sync.
interactionManager.InteractablePlaced += HandleInteractablePlaced;
interactionManager.InteractableReset += HandleInteractableReset;
interactionManager.NailHammerStateChanged += HandleNailHammerStateChanged;
}
private void OnDisable()
{
if (interactionManager == null)
return;
interactionManager.InteractablePlaced -= HandleInteractablePlaced;
interactionManager.InteractableReset -= HandleInteractableReset;
interactionManager.NailHammerStateChanged -= HandleNailHammerStateChanged;
}
Why this is core:
This connects the step system to the interaction system. Without these event hooks, the step manager would not know when something was placed, reset, or hammered, so it could not keep progression and completion state synchronized with gameplay.
3. Handle Placement Events
Code Block: Handle placement events
// Handles normal placement events coming from the InteractionManager.
private void HandleInteractablePlaced(DraggableInteractable interactable, Target target, bool forced)
{
if (interactable == null || target == null || interactable.data == null)
return;
RegisterPlacement(interactable);
if (currentMode == PlayMode.Guided)
{
if (CurrentStep == null)
return;
ReevaluateStep(CurrentStep);
bool currentStepRequiresHammered = StepRequiresHammered(CurrentStep, interactable.data.id, target.targetID);
// If this requirement still needs hammering, do not complete the step yet.
if (currentStepRequiresHammered && interactable.data.requiresHammer && !interactable.isHammered)
{
RefreshHintIfActive();
return;
}
if (IsStepMarkedComplete(CurrentStep))
{
interactable.stepCompleted = true;
CompleteCurrentStep();
}
else
{
RefreshHintIfActive();
}
return;
}
// FreePlay re-checks all steps because placements can happen in any order.
ReevaluateAllSteps();
StepData matchingStep = FindFirstStepUsingPair(interactable.data.id, target.targetID);
if (matchingStep != null && IsStepMarkedComplete(matchingStep))
interactable.stepCompleted = true;
RefreshHintIfActive();
}
Why this is core:
This is one of the main progression handlers. Whenever the interaction manager reports a placement, this method registers the interactable, reevaluates step completion, handles the special case where a requirement still needs hammering, auto-completes the current guided step when satisfied, and reevaluates all FreePlay steps when placements can happen in any order.
4. Handle Reset and Hammer-State Events
Code Block: Handle reset and hammer-state events
// Handles reset events coming from the InteractionManager and updates step state.
private void HandleInteractableReset(DraggableInteractable interactable, InteractionManager.ResetReason reason)
{
if (interactable == null || interactable.data == null)
return;
UnregisterPlacement(interactable);
interactable.stepCompleted = false;
if (currentMode == PlayMode.Guided)
{
if (CurrentStep != null)
ReevaluateStep(CurrentStep);
RefreshHintIfActive();
return;
}
ReevaluateAllSteps();
RefreshHintIfActive();
}
// Called by InteractionManager when a nail's hammered state changes.
// This lets hammering count toward step completion, especially for nail steps.
private void HandleNailHammerStateChanged(NailInteractable nail, bool hammered)
{
if (nail == null || nail.data == null)
return;
if (currentMode == PlayMode.Guided)
{
if (CurrentStep == null)
return;
ReevaluateStep(CurrentStep);
if (hammered && IsStepMarkedComplete(CurrentStep))
{
nail.stepCompleted = true;
CompleteCurrentStep();
}
else
{
if (!hammered)
nail.stepCompleted = false;
RefreshHintIfActive();
}
return;
}
ReevaluateAllSteps();
StepData matchingStep = FindFirstStepUsingPair(nail.data.id, nail.lastPlacedTargetID);
if (matchingStep != null)
nail.stepCompleted = hammered && IsStepMarkedComplete(matchingStep);
else
nail.stepCompleted = false;
RefreshHintIfActive();
}
Why this is core:
These are the other major event handlers. Resets remove interactables from the placed list and force step reevaluation, while nail hammer-state changes allow steps that require the hammered state to complete correctly in both Guided and FreePlay.
5. Mode Loading, Step Loading, and Runtime Reset Helpers
Code Block: Mode loading, step loading, and runtime reset helpers
// Loads the saved play mode from PlayerPrefs when the scenario starts.
private void LoadModeFromPrefs()
{
if (PlayerPrefs.HasKey("SelectedPlayMode"))
currentMode = (PlayMode)PlayerPrefs.GetInt("SelectedPlayMode");
else
currentMode = PlayMode.Guided;
allowStepNavigation = true;
}
// Copies the selected module's step list into this runtime manager.
// ScenarioLoader calls Initialize, and Initialize calls this, so this is not redundant.
private void LoadStepsFromModule(ModuleData module)
{
steps.Clear();
if (module.steps != null && module.steps.Count > 0)
steps.AddRange(module.steps);
}
// Resets the completion dictionary so every loaded step starts incomplete.
private void ResetStepCompletion()
{
stepCompletion.Clear();
foreach (var step in steps)
{
if (step != null)
stepCompletion[step] = false;
}
}
// Clears runtime placement/hammer state on scene interactables during initialization.
private void ResetSceneInteractables()
{
if (interactionManager == null)
return;
foreach (ResetInteractable resetObj in interactionManager.resetInteractableList)
{
if (resetObj == null || resetObj.interactableTypeList == null)
continue;
foreach (DraggableInteractable interactable in resetObj.interactableTypeList)
{
if (interactable == null)
continue;
interactable.placedCorrectly = false;
interactable.stepCompleted = false;
interactable.ResetHammered();
}
}
Why this is core:
This block prepares the step manager’s runtime state when a module loads. It restores the chosen mode, loads the module’s step list, rebuilds the completion dictionary, and clears old placement and hammer state from scene interactables.
6. Placement Validation and Placed Interactable Tracking
Code Block: Placement validation and placed interactable tracking
// Main placement validation entry point used by interactables and InteractionManager.
// Guided checks only the current step. FreePlay checks whether any step uses that pair.
public bool ValidatePlacement(InteractableID interactableID, TargetID targetID)
{
return currentMode == PlayMode.Guided
? CurrentStep != null && CurrentStep.IsMatch(interactableID, targetID)
: FindFirstStepUsingPair(interactableID, targetID) != null;
}
// Adds or moves an interactable to the end of the runtime placed list.
public void RegisterPlacement(DraggableInteractable interactable)
{
if (interactable == null)
return;
placedInteractables.Remove(interactable);
placedInteractables.Add(interactable);
}
// Removes an interactable from the runtime placed list.
public void UnregisterPlacement(DraggableInteractable interactable)
{
if (interactable == null)
return;
placedInteractables.Remove(interactable);
}
Why this is core:
This is the main validation and tracking gateway used by the rest of the gameplay systems. Guided mode only accepts placements for the current step, FreePlay accepts any placement used by any step, and the placed interactables list is kept updated so requirement checks can examine the current scene state.
7. Step State Updates and Guided Progression
Code Block: Step state updates and guided progression
public void MarkStepComplete(StepData step)
{
SetStepMarkedState(step, true);
}
public void MarkStepIncomplete(StepData step)
{
SetStepMarkedState(step, false);
}
// Shared helper for updating a step's complete/incomplete state.
private void SetStepMarkedState(StepData step, bool isComplete)
{
if (step == null || !stepCompletion.ContainsKey(step))
return;
stepCompletion[step] = isComplete;
UpdateFreePlayUI();
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
// Finishes the current Guided step, awards score, and advances to the next step.
public void CompleteCurrentStep()
{
if (CurrentStep == null)
return;
SimpleScoreManager.Instance?.AddScore(CurrentStep.scoreValue);
AdvanceStep();
}
// Existing Guided "next" behavior is really an auto-place for the current step.
public void NextStep()
{
if (CurrentStep == null)
return;
AutoPlaceStep(CurrentStep);
}
// Moves Guided mode forward to the next step and refreshes step UI.
public void AdvanceStep()
{
currentStepIndex++;
LoadStep(currentStepIndex);
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
// Moves Guided mode back one step, then asks InteractionManager to undo that step's placement.
public void PreviousStep()
{
if (currentStepIndex <= 0 || interactionManager == null)
return;
currentStepIndex--;
LoadStep(currentStepIndex);
OnStepProgressChanged?.Invoke();
StepData stepBeingReverted = steps[currentStepIndex];
interactionManager.RequestResetStep(stepBeingReverted);
RefreshHintIfActive();
}
Why this is core:
This is the core progression flow for Guided mode. It marks steps complete or incomplete, awards score when the current step finishes, advances to the next step, and supports moving backward by reloading the previous step and asking the interaction manager to undo its placement.
8. Step Toggling and Auto-Place for One Step
Code Block: Step toggling and auto-place for one step
// FreePlay row toggle behavior.
// If the step is complete, clicking it resets it. If not, it auto-places it.
public void ToggleStep(StepData step)
{
if (step == null)
return;
bool isComplete = IsStepMarkedComplete(step);
if (isComplete)
{
interactionManager?.RequestResetStep(step);
RefreshHintIfActive();
return;
}
AutoPlaceStep(step);
}
public void AutoPlaceCurrentStep()
{
if (CurrentStep == null)
return;
AutoPlaceStep(CurrentStep);
}
// Tries to satisfy each requirement in a step by placing or spawning what is needed.
public void AutoPlaceStep(StepData step)
{
if (step == null || interactionManager == null)
return;
List<PlacementRequirement> requirements = step.GetRequirements();
if (requirements == null || requirements.Count == 0)
return;
foreach (var requirement in requirements)
{
if (IsRequirementSatisfied(requirement))
continue;
Target target = FindTarget(requirement.targetID);
if (target == null)
continue;
if (requirement.requiredState == RequirementState.Hammered)
{
NailInteractable placedNail = FindPlacedNailForRequirement(requirement);
if (placedNail != null && !placedNail.isHammered)
{
placedNail.ApplyHammerDirectly();
continue;
}
}
DraggableInteractable interactable = FindAvailableInteractable(requirement.interactableID);
if (interactable == null)
{
SourceSpawnObject source = FindSourceSpawnObject(requirement.interactableID);
if (source != null)
interactable = source.SpawnInteractable(false);
}
if (interactable == null)
{
Debug.Log($"[AUTO PLACE FAIL] No available interactable found for {requirement.interactableID}");
continue;
}
bool placed = interactionManager.RequestPlacement(interactable, target, wasForcePlaced: true);
if (!placed)
continue;
// If the requirement needs a hammered nail, apply that right after placement.
if (requirement.requiredState == RequirementState.Hammered &&
interactable is NailInteractable nail &&
!nail.isHammered)
{
nail.ApplyHammerDirectly();
}
}
RefreshHintIfActive();
}
Why this is core:
This is the main auto-place system for both Guided and FreePlay step actions. It checks each requirement in the step, skips already-satisfied requirements, finds the right target, reuses an existing nail if the step only needs it hammered, finds an available interactable or spawns one from a source object, places it through the interaction manager, and then hammers it if the requirement needs the hammered state.
9. Guided and FreePlay Bulk Implementations
Code Block: Guided and FreePlay bulk implementations
// Auto-places every incomplete step in FreePlay.
public void AutoPlaceAllStepsFreePlay()
{
if (currentMode != PlayMode.FreePlay)
return;
foreach (var step in steps)
{
if (step == null)
continue;
if (IsStepMarkedComplete(step))
continue;
AutoPlaceStep(step);
}
ReevaluateAllSteps();
RefreshHintIfActive();
}
// Resets every completed FreePlay step in reverse order.
public void ResetAllStepsFreePlay()
{
if (currentMode != PlayMode.FreePlay || interactionManager == null)
return;
for (int i = steps.Count - 1; i >= 0; i--)
{
StepData step = steps[i];
if (step == null)
continue;
if (!IsStepMarkedComplete(step))
continue;
interactionManager.RequestResetStep(step);
}
ReevaluateAllSteps();
RefreshHintIfActive();
}
// Auto-places Guided steps one by one until it cannot progress any further.
public void AutoPlaceAllStepsGuided()
{
if (currentMode != PlayMode.Guided)
return;
int safety = steps.Count + 10;
while (CurrentStep != null && safety > 0)
{
int beforeIndex = currentStepIndex;
AutoPlaceStep(CurrentStep);
// If auto-place did not advance the step on its own, re-check it manually.
if (CurrentStep != null && currentStepIndex == beforeIndex)
{
ReevaluateStep(CurrentStep);
if (IsStepMarkedComplete(CurrentStep))
CompleteCurrentStep();
else
break;
}
safety--;
}
RefreshHintIfActive();
}
// Resets Guided mode back to the beginning by repeatedly stepping backward.
public void ResetAllStepsGuided()
{
if (currentMode != PlayMode.Guided || interactionManager == null)
return;
while (currentStepIndex > 0)
PreviousStep();
if (CurrentStep != null)
ReevaluateStep(CurrentStep);
RefreshHintIfActive();
}
Why this is core:
These methods show how bulk behavior differs by mode. FreePlay can auto-place or reset steps in any order, while Guided mode progresses or rewinds through the step sequence itself, including a safety cap when auto-placing forward through all steps.
10. Step Completion Queries
Code Block: Step completion queries
public bool AreAllStepsComplete()
{
if (steps == null || steps.Count == 0)
return false;
foreach (var step in steps)
{
if (step == null)
continue;
if (!IsStepMarkedComplete(step))
return false;
}
return true;
}
public bool HasAnyCompletedSteps()
{
if (steps == null || steps.Count == 0)
return false;
foreach (var step in steps)
{
if (step != null && IsStepMarkedComplete(step))
return true;
}
return false;
}
// Returns the first incomplete step in FreePlay, used for collapsed list mode.
public StepData GetFirstIncompleteStep()
{
foreach (var step in steps)
{
if (step == null)
continue;
if (!stepCompletion.ContainsKey(step) || !stepCompletion[step])
return step;
}
return null;
}
public bool IsStepMarkedComplete(StepData step)
{
return step != null &&
stepCompletion.ContainsKey(step) &&
stepCompletion[step];
}
Why this is core:
These helpers answer core progression questions: whether all steps are complete, whether any are complete, which step is the first incomplete one, whether a specific step is currently complete, and whether a given interactable/target requirement still needs the hammered state before counting as done.
11. Build and Update the FreePlay List UI
Code Block: Build and update the FreePlay list UI
// Builds the runtime FreePlay step list UI.
private void BuildFreePlayList()
{
if (freePlayStepListParent == null || freePlayStepRowPrefab == null)
{
Debug.LogWarning("StepManager: FreePlay list references are missing.");
return;
}
// Clear old rows first so rebuilding does not duplicate them.
foreach (Transform child in freePlayStepListParent)
Destroy(child.gameObject);
freePlayRows.Clear();
for (int i = 0; i < steps.Count; i++)
{
StepData step = steps[i];
if (step == null)
continue;
FreePlayStepRow row = Instantiate(freePlayStepRowPrefab, freePlayStepListParent);
row.Initialize(this, step, i);
freePlayRows.Add(new FreePlayRowEntry
{
step = step,
text = row.stepText
});
}
UpdateFreePlayUI();
}
// Updates the FreePlay row colors to match each step's completion state.
private void UpdateFreePlayUI()
{
foreach (var row in freePlayRows)
{
if (row == null || row.text == null || row.step == null)
continue;
bool complete = stepCompletion.ContainsKey(row.step) && stepCompletion[row.step];
row.text.color = complete ? stepTextCompleteColor : stepTextNormalColor;
}
UpdateFreePlayVisibility();
}
// Handles collapsed vs expanded visibility for the FreePlay step list.
private void UpdateFreePlayVisibility()
{
if (currentMode != PlayMode.FreePlay)
return;
if (!isCollapsed)
{
foreach (var row in freePlayRows)
{
if (row != null && row.text != null)
row.text.gameObject.SetActive(true);
}
return;
}
StepData firstIncompleteStep = GetFirstIncompleteStep();
foreach (var row in freePlayRows)
{
if (row != null && row.text != null)
row.text.gameObject.SetActive(row.step == firstIncompleteStep);
}
}
Why this is core:
This is the runtime UI builder for FreePlay. It creates one row per step, keeps each row linked to its StepData, colors rows based on completion, and supports collapsed mode where only the first incomplete step remains visible.
Summary:
StepManager is the progression controller for the game. It loads the selected module’s steps, switches between Guided and FreePlay behavior, validates whether placements are allowed, listens for placement, reset, and hammer events, tracks which steps are complete, updates the current step and FreePlay step list UI, and supports both single-step and bulk auto-place/reset behavior.
ScoreManager.cs
This is the core runtime data holder for the timer and final-results summary, exposed as a singleton for global access. Even though the class is called a score manager, its current real job is tracking timer usage, elapsed time, completed steps while the timer was running, and generating the final result text.
ScoreManager.cs
This is the core runtime data holder for the timer and final-results summary, exposed as a singleton for global access. Even though the class is called a score manager, its current real job is tracking timer usage, elapsed time, completed steps while the timer was running, and generating the final result text.
1. Singleton, Timer Event, and Runtime State
Code Block: Singleton, timer event, and runtime state
public class ScoreManager : MonoBehaviour
{
// Singleton so other systems can easily access the active score manager.
public static ScoreManager Instance { get; private set; }
// Event fired whenever timer display-related data changes.
public delegate void TimerUpdatedHandler();
public event TimerUpdatedHandler TimerUpdated;
[Header("Optional UI")]
// Optional UI script that can display final results.
public ScoreUI scoreUI;
[Header("Runtime")]
// Whether the timer is currently running.
[SerializeField] private bool timerRunning = false;
// Whether the timer was used at all during this session.
[SerializeField] private bool timerWasUsed = false;
// Elapsed timer value in seconds.
[SerializeField] private float elapsedTime = 0f;
// Stores the current play mode name for the final summary.
[SerializeField] private string currentModeName = "Guided";
// Stores step labels in the order they were completed while timer was running.
private readonly List<string> completedStepNames = new List<string>();
// Prevents the same step from being recorded more than once.
private readonly HashSet<StepData> completedStepSet = new HashSet<StepData>();
[Header("Final Summary Options")]
public bool showCompletedStepDescriptions = true;
[SerializeField] private int totalStepsInMode = 0;
Why this is core:
This is the manager’s full runtime model. It stores whether the timer is running, whether it was used during the session, the elapsed time, the active mode name, the set of steps completed while the timer was active, and the summary configuration used for final results.
2. Singleton Setup and Timer Update Loop
Code Block: Singleton setup and timer update loop
private void Awake()
{
// Standard singleton setup so only one score manager exists.
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
private void Update()
{
// Advance the timer only while it is running.
if (timerRunning)
{
elapsedTime += Time.deltaTime;
TimerUpdated?.Invoke();
}
}
Why this is core:
This makes the score manager globally accessible and updates the timer every frame while it is active. The TimerUpdated event is also fired here so timer displays can refresh live.
3. Timer Controls and Mode Reset
Code Block: Timer controls and mode reset
public void SetTotalSteps(int totalSteps)
{
// Cache how many steps exist in the current mode for summary purposes.
totalStepsInMode = Mathf.Max(0, totalSteps);
}
public void ToggleTimer()
{
// Toggle timer on/off.
timerRunning = !timerRunning;
// Once the timer has been started at least once,
// mark that this session used the timer.
if (timerRunning)
timerWasUsed = true;
TimerUpdated?.Invoke();
}
public void StopTimer()
{
if (!timerRunning)
return;
// Stop the timer without resetting elapsed time.
timerRunning = false;
TimerUpdated?.Invoke();
}
public void ResetForMode(string modeName)
{
// Clear all runtime score/timer data for a fresh mode session.
timerRunning = false;
timerWasUsed = false;
elapsedTime = 0f;
currentModeName = modeName;
completedStepNames.Clear();
completedStepSet.Clear();
TimerUpdated?.Invoke();
}
Why this is core:
This is the timer and session control block. It sets the total step count, toggles the timer on and off, stops the timer cleanly, and resets all runtime tracking when the play mode changes or a fresh session begins.
4. Record Completed Steps While Timer Is Running
Code Block: Record completed steps while timer is running
public void RecordCompletedStep(StepData step, int fallbackIndex = -1)
{
// Only record steps while timer is running,
// and only if a valid step exists.
if (!timerRunning || step == null)
return;
// Prevent duplicate recordings of the same step.
if (completedStepSet.Contains(step))
return;
completedStepSet.Add(step);
// Build a readable label for the final summary.
string label = !string.IsNullOrWhiteSpace(step.description)
? step.description
: fallbackIndex >= 0
? $"Step {fallbackIndex + 1}"
: "Unnamed Step";
completedStepNames.Add(label);
Debug.Log("SimpleScoreManager: Recorded completed step: " + label);
}
Why this is core:
This is what makes the final summary meaningful. It only records steps while the timer is actively running, prevents duplicates, and stores readable labels in the order the steps were completed.
Summary:
SimpleScoreManager is the central timer and results data manager. It tracks whether the timer is running, records which steps were completed while the timer was active, formats the elapsed time, and builds the final summary text shown at the end of a session.
ScoreObserver.cs
This script listens for step progress changes from StepManager, detects when the play mode changes, tracks which steps just became complete, and tells SimpleScoreManager when to reset totals or record completed steps.
ScoreObserver.cs
This script listens for step progress changes from StepManager, detects when the play mode changes, tracks which steps just became complete, and tells SimpleScoreManager when to reset totals or record completed steps.
1. Watch for Mode Changes and Newly Completed Steps
Code Block: Watch for mode changes and newly completed steps
private void OnEnable()
{
SubscribeToStepManager();
}
private void Start()
{
if (stepManager == null || ScoreManager.Instance == null)
return;
SubscribeToStepManager();
lastMode = stepManager.currentMode;
ScoreManager.Instance.ResetForMode(lastMode.ToString());
ScoreManager.Instance.SetTotalSteps(stepManager.steps != null ? stepManager.steps.Count : 0);
CacheCurrentStepCompletion();
HandleStepProgressChanged();
}
private void OnDisable()
{
if (stepManager != null && isSubscribed)
{
stepManager.OnStepProgressChanged -= HandleStepProgressChanged;
isSubscribed = false;
}
}
private void SubscribeToStepManager()
{
if (stepManager == null || isSubscribed)
return;
stepManager.OnStepProgressChanged += HandleStepProgressChanged;
isSubscribed = true;
}
private void HandleStepProgressChanged()
{
if (stepManager == null || ScoreManager.Instance == null)
return;
WatchModeChange();
WatchCompletedSteps();
}
private void WatchModeChange()
{
if (stepManager.currentMode != lastMode)
{
lastMode = stepManager.currentMode;
ScoreManager.Instance.ResetForMode(lastMode.ToString());
ScoreManager.Instance.SetTotalSteps(stepManager.steps != null ? stepManager.steps.Count : 0);
CacheCurrentStepCompletion();
}
}
private void WatchCompletedSteps()
{
if (stepManager.steps == null)
return;
for (int i = 0; i < stepManager.steps.Count; i++)
{
StepData step = stepManager.steps[i];
if (step == null)
continue;
bool nowComplete = stepManager.IsStepMarkedComplete(step);
if (!cachedCompletion.TryGetValue(step, out bool oldComplete))
oldComplete = nowComplete;
if (!oldComplete && nowComplete)
ScoreManager.Instance.RecordCompletedStep(step, i);
cachedCompletion[step] = nowComplete;
}
}
Why this is core:
This is the observer’s main job. Every frame it checks whether the play mode changed, resets the score manager when it does, and detects newly completed steps so they can be recorded by the timer and results system.
2. Cache Current Step Completion State
Code Block: Cache current step completion state
private void CacheCurrentStepCompletion()
{
cachedCompletion.Clear();
if (stepManager == null || stepManager.steps == null)
return;
for (int i = 0; i < stepManager.steps.Count; i++)
{
StepData step = stepManager.steps[i];
if (step == null)
continue;
cachedCompletion[step] = stepManager.IsStepMarkedComplete(step);
}
}
}
Why this is core:
This resets the observer’s local baseline so it knows which steps are already complete and can correctly detect future completion changes without recording duplicates.
Summary:
ScoreObserver connects the step system to the timer and results system. It watches the current play mode and step completion states, resets the score manager when the mode changes, and records steps when they become newly complete.
ScoreTimerButton.cs
This script is the clickable timer toggle UI. It inherits from HybridClickableBase, so it can use the project’s shared click pattern, and its job is to turn the timer on and off while updating its visual state.
ScoreTimerButton.cs
This script is the clickable timer toggle UI. It inherits from HybridClickableBase, so it can use the project’s shared click pattern, and its job is to turn the timer on and off while updating its visual state.
1. Subscribe to Timer Updates and Refresh Visuals
Code Block: Subscribe to timer updates and refresh visuals
private void OnEnable()
{
if (ScoreManager.Instance != null)
ScoreManager.Instance.TimerUpdated += RefreshVisuals;
RefreshVisuals();
}
private void OnDisable()
{
if (ScoreManager.Instance != null)
ScoreManager.Instance.TimerUpdated -= RefreshVisuals;
}
private void Update()
{
if (ScoreManager.Instance == null)
return;
if (!ScoreManager.Instance.TimerRunning)
return;
RefreshVisuals();
}
Why this is core:
This keeps the button visuals synchronized with the score manager. It listens for timer updates, refreshes immediately when enabled, unsubscribes safely when disabled, and also refreshes every frame while the timer is actively running so the elapsed time display stays live.
2. Click Action and Visual Refresh
Code Block: Click action and visual refresh
protected override void HandleClick()
{
if (ScoreManager.Instance == null)
return;
ScoreManager.Instance.ToggleTimer();
RefreshVisuals();
}
private void RefreshVisuals()
{
RefreshTimerText();
RefreshImageMaterial();
}
private void RefreshTimerText()
{
if (timerText == null || ScoreManager.Instance == null)
return;
timerText.text = ScoreManager.Instance.TimerRunning
? onPrefix + ScoreManager.FormatTime(ScoreManager.Instance.ElapsedTime)
: offPrefix + ScoreManager.FormatTime(ScoreManager.Instance.ElapsedTime);
}
private void RefreshImageMaterial()
{
if (targetImage == null || ScoreManager.Instance == null)
return;
targetImage.material = ScoreManager.Instance.TimerRunning
? onImageMaterial
: offImageMaterial;
}
}
Why this is core:
This is the actual timer button behavior. Clicking the button toggles the timer in the score manager, then updates both the text and image material so the UI reflects whether the timer is currently on or off.
Summary:
SimpleScoreTimerButton is the clickable timer control. It uses the shared hybrid click system, toggles the session timer through the score manager, and updates its text and material so the button visually reflects the timer’s current state.
© Copyright David Phillips Game Producer