Gameplay Video
A quick look at the project running in action.
Play Online
Launch the live WebGL build of the project in your browser.
How My Game Is Built
This project is a modular Unity training and simulation game built around a data-driven scenario system. Each scenario is defined through module data, step data, interactable data, and target configurations. At runtime, the game loads a selected module, builds the scene, spawns the required interactables and targets, and then uses shared systems to manage dragging, placement, progression, reset behavior, and scoring.
Architecture Overview
Overview:
The architecture is designed so the same core systems can support different training scenarios without rewriting gameplay logic. Instead of hardcoding each experience, the game uses reusable scripts that interpret module data and build the correct scenario dynamically.
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.
ModuleDatabase
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
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
Defines one interactable type. It stores the prefab, optional source object, display settings, stock amount, placement behavior, runtime subclass name, and reset-cube positioning data. This lets one shared spawn pipeline create many different object types with different behaviors.
StepData
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.
StartScreenController
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
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.
ScenarioLoader
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
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
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.
Target
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
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.
DraggableInteractable
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
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
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.
BaseInputManager
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
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.
ResetCube
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
This is the central runtime coordinator for placement and reset behavior. It finalizes valid placements, tracks placed objects per reset cube, 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 cubes, 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.
StepManager
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.
SimpleScoreManager
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
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
Provides the clickable timer control that starts or stops timing and updates its own text and material to reflect the timer state.
SimpleScoreUI
Displays the final results panel using the final summary text and formatted time generated by the score manager.
10. Full Runtime Flow
How the systems connect:
A module is first selected from the start screen. The selected ModuleData is stored and then loaded by the gameplay scene. The ScenarioLoader reads that module and builds the world by spawning interactables, source objects, reset cubes, and targets. The StepManager then loads the module’s steps and sets up Guided or FreePlay progression.
When the player interacts with the scene, the BaseInputManager decides whether they clicked a 3D button, selected an interactable, or began dragging one. DraggableInteractable handles the object’s movement and placement preview, and once dropped, InteractionManager validates and finalizes the placement. Targets track occupancy, reset cubes track available stock and reset eligibility, and grouped targets can auto-fill additional linked placements.
As placements, resets, and hammer actions occur, the StepManager reevaluates progression. In Guided mode it only cares about the current active step, while in FreePlay it tracks completion across the full step list. If the timer is active, the score system records completed steps and later generates a final end-of-run summary.
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.
Website-ready 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.
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;
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.
2. Optional Source Object Setup
Code Block: Optional source object setup
[Header("Optional Source Object")]
[Tooltip("Optional persistent source object, such as a nail box.")]
public GameObject sourceSpawnObject;
[Tooltip("Local/world offset applied when spawning the source object.")]
public Vector3 sourceSpawnPositionOffset;
[Tooltip("Rotation offset applied when spawning the source object.")]
public Vector3 sourceSpawnRotationOffset;
Why this is core:
This supports interactables that do not just appear from nowhere, but instead come from a persistent source in the scene, like a nail box. That is a core part of the architecture because some objects are spawned from reusable source objects rather than only existing as free-floating pieces.
3. Display and Label Data
Code Block: Display and label data
[Header("Display / UI")]
[Tooltip("Display name shown for the interactable itself.")]
public string displayName;
[Tooltip("Display name shown for the source object, if different from the interactable name.")]
public string sourceDisplayName;
public Sprite icon;
[Header("Interactable Label")]
public TMP_FontAsset titleFont;
public float titleFontSize = 3f;
public Vector3 titleLocalPositionOffset;
[Header("Source Object Label")]
public TMP_FontAsset sourceTitleFont;
public float sourceTitleFontSize = 3f;
public Vector3 sourceTitleLocalPositionOffset;
Why this is core:
This controls how the interactable is presented to the player. It separates gameplay identity from visual presentation, which is useful because the same object can have its own display name, icon, and label settings, while its source object can have a different label setup.
4. Spawn Limits and Placement Behavior
Code Block: Spawn limits and placement behavior
[Header("Spawn Info")]
[Tooltip("How many of this interactable can be spawned/used.")]
public int remainingAmount = 1;
[Tooltip("Rotation offset applied to spawned interactables.")]
public Vector3 spawnRotationOffset;
[Header("Placement")]
[Tooltip("If true, this interactable requires hammering after placement.")]
public bool requiresHammer;
[Tooltip("How far the interactable should move when hammered.")]
public float hammerDepth = 0.5f;
[Tooltip("If true, rigidbody-based collision logic is used.")]
public bool useRigidbodyForCollisions = false;
[Tooltip("Optional sound played on correct placement.")]
public AudioClip placementSound;
Why this is core:
This block defines the actual gameplay behavior of the object after it is spawned. It determines quantity limits, spawn rotation, whether the object needs a second action like hammering, how deep it moves when hammered, whether it uses rigidbody collision behavior, and what feedback sound plays on correct placement.
5. Runtime Specialization and Reset Cube Placement
Code Block: Runtime specialization and reset cube placement
[Header("Optional Runtime Subclass")]
[Tooltip("Optional runtime component type name to add, such as NailInteractable or HammerInteractable.")]
public string runtimeClassName;
[Header("Reset Cube Visual")]
[Tooltip("Visual offset used when placing the reset cube near this interactable.")]
public Vector3 resetCubePositionOffset = new Vector3(0f, 0.5f, 0f);
}
Why this is core:
This is what allows one data asset to spawn not just a generic interactable, but a specialized runtime behavior class when needed. It also stores the reset cube offset, which matters because reset interaction is part of the core object workflow.
Website-Ready Summary
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.
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("If true, the legacy requirement only completes once hammered.")]
public bool legacyRequiresHammered = false;
[Header("Advanced Requirements")]
[Tooltip("When enabled, the Requirements list is used instead of the legacy single requirement fields.")]
public bool useAdvancedRequirements = false;
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.
Website-Ready Summary
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.
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.
3. Module-Specific UI Theme Assets
Code Block: Module-specific UI theme assets
[Header("UI Themes")]
public OptionsMenuTheme optionsMenuTheme;
public FreePlayStepRootMenuTheme freePlayStepRootMenuTheme;
public TimerCanvasMenuTheme timerCanvasMenuTheme;
public StepTextGuidedMenuTheme stepTextGuidedMenuTheme;
public ScoreMenuTheme scoreMenuTheme;
public WorldSpaceStepMenuTheme worldSpaceStepMenuTheme;
public TipsTutorialMenuTheme tipsTutorialMenuTheme;
public ModeSelectorMenuTheme modeSelectorMenuTheme;
public FinishSequenceTheme finishSequenceTheme;
}
Why this is core:
This block lets a module define how its menus and UI appear without changing the shared systems. In this architecture, theming is part of module setup, so it belongs in the core explanation of how a module is constructed.
Website-Ready Summary
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.
1. Core References and Layout Settings
Code Block: Core references and layout settings
public class StartScreenController : MonoBehaviour
{
[Header("Data")]
[Tooltip("Database containing all modules available from the start screen.")]
public ModuleDatabase moduleDatabase;
[Header("Button Prefab")]
[Tooltip("Prefab used to create one button/card per module.")]
public ModuleButton3D buttonPrefab;
[Tooltip("Parent transform where module buttons will be spawned.")]
public Transform buttonParent;
[Header("Layout")]
[Tooltip("Distance in UI units between module cards.")]
public float spacing = 300f;
[Tooltip("If true, spawn buttons left-to-right. Otherwise spawn top-to-bottom.")]
public bool horizontalLayout = true;
[Tooltip("If true, center the full row/column around the parent origin.")]
public bool centerButtons = true;
Why this is core:
This block defines everything the start screen needs to generate the module selection UI: the module source data, the button prefab to clone, where the buttons should spawn, and how they should be laid out.
2. 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.
3. 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];
ModuleButton3D 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.
4. Reference Validation
Code Block: Reference validation
// Basic setup check before trying to spawn any buttons.
private bool ValidateReferences()
{
if (moduleDatabase == null)
{
Debug.LogError("StartScreenController: ModuleDatabase is missing.");
return false;
}
if (buttonPrefab == null)
{
Debug.LogError("StartScreenController: Button prefab is missing.");
return false;
}
if (buttonParent == null)
{
Debug.LogError("StartScreenController: Button parent is missing.");
return false;
}
return true;
}
Why this is core:
This prevents the start screen from trying to build itself with missing setup. It is small, but it protects the module selection system from failing silently.
5. Rebuild Cleanup
Code Block: Rebuild cleanup
// Removes any buttons already under the parent so the list can be rebuilt cleanly.
private void ClearExistingButtons()
{
for (int i = buttonParent.childCount - 1; i >= 0; i--)
{
Destroy(buttonParent.GetChild(i).gameObject);
}
}
Why this is core:
This keeps the start screen from duplicating buttons if the list is rebuilt. It makes the UI generation safe and repeatable.
6. 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(ModuleButton3D 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.
Website-Ready Summary
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.
ModuleButton3D.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 ModuleButton3D : MonoBehaviour,
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.
5. Highlight Helper
Code Block: Highlight helper
// Small helper that turns the hover/press highlight on or off.
private void SetHighlight(bool state)
{
if (highlightBorder != null)
highlightBorder.SetActive(state);
}
}
Why this is core:
This is the visual state switch used by all pointer events. It is a small helper, but it is central to the button’s feedback loop.
Website-Ready Summary
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 cubes, 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 TipsTutorialManager tipsTutorialManager;
[SerializeField] private BaseInputManager inputManager;
[SerializeField] private HintController hintController;
[Tooltip("Prefab used to create reset cubes.")]
public GameObject resetCubePrefab;
[Header("EZ Start")]
[Tooltip("Optional quick-start module used during testing.")]
public ModuleData moduleSelectedEasyStart;
[Tooltip("If true, load the easy-start module instead of the selected module.")]
public bool easyStartBool;
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.
3. 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.
4. 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.
5. 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.
6. 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.
7. Interactable Grid Positioning
Code Block: Interactable grid positioning
// Calculates the grid position for an interactable based on its index in the module list.
private Vector3 GetInteractableSpawnPosition(int index)
{
int safeColumns = Mathf.Max(1, columns);
int row = index / safeColumns;
int column = index % safeColumns;
return interactableSpawnArea.position + new Vector3(column * spacing, 0f, row * spacing);
}
Why this is core:
This controls the default layout of interactables in the scene. It turns the module list order into actual world positions using a simple grid layout.
8. Source Object Setup Flow
Code Block: Source object setup flow
// Sets up an interactable that uses a source object, like a box that spawns pieces on demand.
// This also creates and links that interactable type's reset cube.
private void SpawnSourceObjectSetup(InteractableData data, Vector3 spawnPos)
{
if (resetCubePrefab == null)
{
Debug.LogError($"ScenarioLoader: Reset cube prefab is missing for source object '{data.name}'.");
return;
}
GameObject sourceObj = Instantiate(
data.sourceSpawnObject,
spawnPos + data.sourceSpawnPositionOffset,
Quaternion.Euler(data.sourceSpawnRotationOffset),
interactableSpawnArea
);
SourceSpawnObject sourceSpawn = sourceObj.GetComponent<SourceSpawnObject>();
if (sourceSpawn != null)
{
sourceSpawn.data = data;
sourceSpawn.inputManager = inputManager;
sourceSpawn.interactableSpawner = interactableSpawner;
sourceSpawn.interactionManager = interactionManager;
sourceSpawn.hintController = hintController;
}
spawnedInteractables.Add(sourceObj);
Vector3 resetCubePosition = spawnPos + data.resetCubePositionOffset;
ResetCube resetCube = CreateConfiguredResetCube(data, resetCubePosition);
if (resetCube == null)
return;
if (sourceSpawn != null)
sourceSpawn.resetCube = resetCube;
}
Why this is core:
This handles a key special case in the architecture: interactables that come from a persistent source object instead of spawning directly. It wires the source object into the input, spawning, interaction, hint, and reset systems.
9. Standard Interactable Spawn Flow
Code Block: Standard interactable spawn flow
// Spawns one normal interactable directly into the scene and wires up its reset cube.
// 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);
ResetCube resetCube = CreateConfiguredResetCube(data, draggable.transform.position + data.resetCubePositionOffset);
if (resetCube != null)
{
resetCube.interactableTypeList.Add(draggable);
draggable.resetCube = resetCube;
}
interactionManager?.RegisterInteractable(draggable);
}
Why this is core:
This is the normal spawn path for regular interactables. It creates the object, attaches its reset cube, links the reset cube back to the interactable, and registers the interactable with the interaction manager.
10. Reset Cube Creation and Shared Configuration
Code Block: Reset cube creation and shared configuration
// Creates a reset cube and applies the shared setup used by both normal interactables and source objects.
private ResetCube CreateConfiguredResetCube(InteractableData data, Vector3 position)
{
if (data == null)
return null;
if (resetCubePrefab == null)
{
Debug.LogError("ScenarioLoader: Reset cube prefab is missing.");
return null;
}
GameObject resetCubeObj = Instantiate(resetCubePrefab, position, Quaternion.identity);
ResetCube resetCube = resetCubeObj.GetComponent<ResetCube>();
if (resetCube == null)
{
Debug.LogError("ScenarioLoader: Reset cube prefab is missing a ResetCube component.");
return null;
}
resetCube.stepManager = stepManager;
resetCube.interactionManager = interactionManager;
if (resetCube == null)
{
Debug.LogError("ScenarioLoader: Reset cube prefab is missing a ResetCube component.");
return null;
}
resetCube.name = data.id + "_ResetCube";
resetCube.resetCubeID = data.id.ToString();
resetCube.SetResetAmount(data.remainingAmount, true);
// Keep the existing display wording used by this loader.
if (resetCube.resetText != null)
resetCube.resetText.text = "Amount: " + resetCube.resetAmount;
if (interactionManager != null)
interactionManager.resetCubeList.Add(resetCube);
return resetCube;
}
Why this is core:
This is shared infrastructure for both source-object and normal interactables. It creates the reset cube, connects it to the step and interaction systems, names it, initializes its reset count, and registers it with the interaction manager.
11. 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.
Website-Ready Summary
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 cubes, 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.
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,
ResetCube resetCube = 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, resetCube);
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. Spawn Validation and Parent Selection
Code Block: Spawn validation and parent selection
// Basic safety check before trying to spawn.
// A spawn only works if the data exists and has a prefab assigned.
private bool CanSpawn(InteractableData data)
{
if (data != null && data.prefab != null)
return true;
Debug.LogError($"InteractableSpawner: Spawn failed. Missing prefab for '{data?.name}'.");
return false;
}
// Chooses which parent the spawned object should live under.
// Uses the override first, otherwise falls back to the default parent on this spawner.
private Transform GetParentToUse(Transform parentOverride)
{
return parentOverride != null ? parentOverride : defaultParent;
}
Why this is core:
These are the setup checks that make the spawn process safe and reusable. The spawner only proceeds when valid data exists, and it supports both caller-provided parenting and a default fallback parent.
4. Configure the Interactable Component on the Spawned Prefab
Code Block: Configure the interactable component on the spawned prefab
// Makes sure the spawned prefab has the correct DraggableInteractable type on it.
// This is used during Spawn after the prefab is instantiated.
private DraggableInteractable ConfigureInteractableComponent(GameObject obj, InteractableData data)
{
Type targetType = ResolveInteractableType(data);
DraggableInteractable existing = obj.GetComponent<DraggableInteractable>();
if (existing != null)
return ReuseOrReplaceExistingInteractable(obj, existing, targetType);
return AddInteractableComponent(obj, targetType);
}
Why this is core:
This is the block that makes the spawn system flexible. It allows a prefab to end up with the correct runtime interactable type based on data instead of forcing every prefab to be manually configured ahead of time.
5. Reuse or Replace an Existing Interactable Script
Code Block: Reuse or replace an existing interactable script
// Reuses the existing interactable if it is already the correct type.
// Otherwise, removes the old one and adds the runtime type this data expects.
private DraggableInteractable ReuseOrReplaceExistingInteractable(
GameObject obj,
DraggableInteractable existing,
Type targetType)
{
if (existing.GetType() == targetType)
return existing;
DestroyImmediate(existing);
return AddInteractableComponent(obj, targetType);
}
Why this is core:
This supports prefabs that may already have a DraggableInteractable on them. If it is the correct subclass, the spawner reuses it. If not, it swaps it for the correct type. That keeps prefab setup flexible while still enforcing the runtime behavior the data expects.
6. 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.
7. 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.
8. 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,
ResetCube resetCube)
{
if (interactable == null || data == null)
return;
interactable.data = data;
interactable.resetCube = resetCube;
interactable.name = data.name;
interactable.stepManager = stepManager;
interactable.interactionManager = interactionManager;
interactable.tipsTutorialManager = tipsTutorialManager;
ConfigureResetCubeTutorialTarget(data, resetCube);
}
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 cube reference, name, and links to the step, interaction, and tips systems.
9. Reset Cube Tutorial Target Setup
Code Block: Reset cube tutorial target setup
// Configures the reset cube's tutorial target info for this interactable type, if one is being used.
private void ConfigureResetCubeTutorialTarget(InteractableData data, ResetCube resetCube)
{
if (data == null || resetCube == null)
return;
TutorialHintTarget tutorialTarget = resetCube.GetComponent<TutorialHintTarget>();
if (tutorialTarget == null)
{
Debug.LogWarning($"InteractableSpawner: Reset cube '{resetCube.name}' has no TutorialHintTarget component.");
return;
}
tutorialTarget.targetID = data.resetCubeTutorialTargetID;
// If this interactable uses a reset-cube tutorial target, set up the visual hint behavior too.
if (!string.IsNullOrWhiteSpace(tutorialTarget.targetID))
{
tutorialTarget.hintVisualType = TutorialHintTarget.HintVisualType.LineAndPulse;
if (tutorialTarget.pulseWorldTarget == null)
tutorialTarget.pulseWorldTarget = resetCube.transform;
}
}
}
Why this is core:
This is less central than the main spawn pipeline, but it is still part of the final runtime setup for spawned interactables. It configures tutorial hint behavior on the interactable’s reset cube when that feature is being used.
Website-Ready Summary
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.
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;
[HideInInspector] public SourceSpawnObject sourceObject;
[Tooltip("Reference to the step system used to validate placements.")]
public StepManager stepManager;
[Tooltip("Reset cube associated with this interactable type.")]
public ResetCube resetCube;
[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("True if this object was auto-placed rather than manually dragged.")]
public bool wasForcePlaced = false;
[Tooltip("Used during reset flows to temporarily ignore placement checks.")]
public bool ignorePlacementChecks = false;
[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;
public Vector3 OriginalPosition => originalPosition;
[Tooltip("True if the object is no longer sitting at its original local position.")]
public bool removedFromStart => transform.localPosition != OriginalPosition;
[Tooltip("Which target this interactable was last placed onto.")]
public TargetID lastPlacedTargetID;
[Header("Label")]
[Tooltip("Optional floating text label shown on the interactable.")]
public TextMeshPro label;
[Header("Drop Zone")]
[Tooltip("How close the object must be to a valid target to snap/place.")]
public float dropZoneRadius = 2f;
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. Cached Transform and Drag State
Code Block: Cached transform and drag state
// Saved starting transform values so reset can put the object back where it began.
protected Vector3 originalPosition;
protected Quaternion originalRotation;
public Vector3 originalScale;
public Transform originalParent;
// Cached references used often during play.
protected Renderer cachedRenderer;
protected Camera mainCamera;
protected Color originalColor;
protected Target closestTarget;
// Dragging state used while the player is actively moving the object.
public bool isDragging = false;
private Vector3 offset;
private float dragStartCameraY;
private Vector3 startPosition;
private float manualVerticalOffset;
Why this is core:
This is the internal state the drag and reset system relies on. It stores the original transform so the piece can return to its starting position, and it stores the drag values used to make movement stable while the player is interacting with the object.
3. 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.
4. Initial Setup Helpers
Code Block: Initial setup helpers
// Stores the transform values the object should return to on reset.
private void CacheOriginalTransformState()
{
originalParent = transform.parent;
originalPosition = transform.localPosition;
originalRotation = transform.localRotation;
originalScale = transform.localScale;
}
// Caches renderer info so visual state can be restored later if needed.
private void CacheRendererState()
{
cachedRenderer = GetComponent<Renderer>();
if (cachedRenderer != null)
originalColor = cachedRenderer.material.color;
}
// Adds a kinematic rigidbody when this interactable needs collision support.
private void SetupRigidbodyIfNeeded()
{
if (data == null || !data.useRigidbodyForCollisions)
return;
Rigidbody rb = GetComponent<Rigidbody>();
if (rb == null)
rb = gameObject.AddComponent<Rigidbody>();
rb.isKinematic = true;
}
// Resets runtime-only state when the object first starts up.
private void InitializeRuntimeState()
{
placedCorrectly = false;
stepCompleted = false;
lastPlacedTargetID = default;
hasBeenMoved = false;
isHammered = false;
manualVerticalOffset = 0f;
mobileVerticalDragOffset = 0f;
}
// Finds the label automatically if one was not assigned in the inspector.
private void CacheLabelReference()
{
if (label == null)
label = GetComponentInChildren<TextMeshPro>();
}
// Applies the display name and label styling from InteractableData.
private void ApplyLabel()
{
if (label == null || data == null)
return;
label.text = data.displayName;
if (data.titleFont != null)
{
label.font = data.titleFont;
label.fontSharedMaterial = data.titleFont.material;
}
label.fontSize = data.titleFontSize;
label.transform.localPosition = data.titleLocalPositionOffset;
label.ForceMeshUpdate();
}
Why this is core:
These setup helpers build the interactable’s baseline state: saved transform values for reset, renderer info for restoring visuals, optional rigidbody setup, runtime flags, and label appearance. This is the base initialization that every spawned interactable depends on.
5. 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;
accumulatedTutorialInteractablePlanarMouse = 0f;
tutorialInteractablePlanarMouseSent = false;
accumulatedTutorialInteractableVerticalTouch = 0f;
tutorialInteractableVerticalTouchSent = false;
accumulatedTutorialInteractablePlanarCamera = 0f;
tutorialInteractablePlanarCameraSent = false;
accumulatedTutorialInteractableVerticalCamera = 0f;
tutorialInteractableVerticalCameraSent = false;
// 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.
6. 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.
7. 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)
EnsureResetCubeVisibleForMovedItem();
}
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 cube if the object was moved but not placed.
8. Drop-Zone Preview and Closest Valid Target Search
Code Block: Drop-zone preview and closest valid target search
// Updates the placement preview while dragging.
// This finds the nearest valid target and shows or hides the drop-zone marker.
protected void UpdateDropZone()
{
if (!isDragging || ignorePlacementChecks)
{
HideDropZone();
closestTarget = null;
return;
}
closestTarget = FindClosestValidTarget();
if (closestTarget != null &&
stepManager != null &&
data != null &&
stepManager.ValidatePlacement(data.id, closestTarget.targetID))
{
ShowDropZone(closestTarget.transform.position);
}
else
{
HideDropZone();
}
}
// Finds the nearest target this interactable is currently allowed to place onto.
// Used while dragging to drive both placement checks and the drop-zone preview.
protected Target FindClosestValidTarget()
{
if (stepManager == null || data == null)
return null;
Collider[] hits = Physics.OverlapSphere(transform.position, dropZoneRadius);
Target nearest = null;
float nearestDist = float.MaxValue;
foreach (var hit in hits)
{
Target target = hit.GetComponent<Target>();
if (target == null)
continue;
if (!stepManager.ValidatePlacement(data.id, target.targetID))
continue;
if (!target.CanAcceptInteractable(this))
continue;
float dist = Vector3.Distance(transform.position, target.transform.position);
if (dist < nearestDist)
{
nearestDist = dist;
nearest = target;
}
}
return nearest;
}
Why this is core:
This is the live placement preview system. While dragging, it searches for nearby valid targets, filters them through the step system and target acceptance rules, and shows the shared drop-zone marker for the nearest valid option.
9. 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.
10. 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.
11. 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.
12. Group Support and Helper State Methods
Code Block: Group support and helper state methods
// Returns a safe copy of this interactable's grouped members.
// Used when one placed object created or controls a whole group.
public virtual List<DraggableInteractable> GetGroupedMembersSnapshot()
{
List<DraggableInteractable> result = new List<DraggableInteractable>();
if (groupMembers == null || groupMembers.Count == 0)
{
result.Add(this);
return result;
}
foreach (var member in groupMembers)
{
if (member != null && !result.Contains(member))
result.Add(member);
}
if (!result.Contains(this))
result.Add(this);
return result;
}
// Clears just the hammered flag without resetting anything else.
public void ResetHammered()
{
isHammered = false;
}
// Simple helper used by other systems to update placement state directly.
public void MarkPlacedCorrectly(bool value)
{
placedCorrectly = value;
}
Why this is core:
These methods support systems that operate on grouped interactables, hammered state, and direct placement state changes. They are smaller than the drag, place, and reset flow, but they are still important because other managers rely on them to control interactables safely.
Website-Ready Summary
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.
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. Hammer Settings
Code Block: Hammer settings
public class HammerInteractable : DraggableInteractable
{
[SerializeField] private float hammerRadius = 1f;
[SerializeField] private float hammerVolume = 1f;
private bool tutorialResetCubeInitialized = false;
Why this is core:
This defines the hammer-specific behavior radius and sound volume, which control how nearby nails are detected and how loud the hammer feedback is.
2. 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.
3. 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.
4. 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.
Website-Ready Summary
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.
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.
Website-Ready Summary
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.
SourceSpawnObject.cs
This script is the core of the source-based spawning system. Instead of an interactable always existing directly in the scene, this object acts like a persistent source, such as a nail box, that can create one interactable on demand and optionally begin dragging it immediately.
1. Core References and Active-Spawn Tracking
Code Block: Core references and active-spawn tracking
public class SourceSpawnObject : MonoBehaviour, IClickable3D
{
public InteractableData data;
public Transform parentForSpawnedInteractable;
public ResetCube resetCube;
[Header("Label")]
[SerializeField] private TextMeshPro label;
[HideInInspector] public BaseInputManager inputManager;
[HideInInspector] public InteractableSpawner interactableSpawner;
[HideInInspector] public InteractionManager interactionManager;
[HideInInspector] public HintController hintController;
private readonly List<DraggableInteractable> activeSpawnedInteractables = new();
Why this is core:
This block defines everything the source object needs to function: the interactable data it can spawn, where spawned objects should live, the linked reset cube, references to the main gameplay systems, and the list that tracks whether it already has an active spawned object out in the scene.
2. Startup Label Setup
Code Block: Startup label setup
private void Start()
{
if (label == null)
label = GetComponentInChildren<TextMeshPro>(true);
ApplyLabel();
}
Why this is core:
This is the setup step that makes sure the source object can display its label correctly in the scene, even if the label reference was not manually assigned.
3. Pickup Hover Support
Code Block: Pickup hover support
[Header("Pickup Hover")]
[SerializeField] private bool allowPickupHover = true;
[SerializeField] private Transform pickupHoverPoint;
public virtual bool CanShowPickupHover()
{
return allowPickupHover && gameObject.activeInHierarchy;
}
public virtual Vector3 GetPickupIndicatorPosition()
{
if (pickupHoverPoint != null)
return pickupHoverPoint.position;
Renderer sourceRenderer = GetComponentInChildren<Renderer>();
if (sourceRenderer != null)
return sourceRenderer.bounds.center;
return transform.position;
}
Why this is core:
This controls whether the source object can show pickup and hover feedback and where that indicator should appear. Since the source object behaves like an interactable entry point for the player, this is part of its core interaction behavior.
4. Spawned Interactable Tracking
Code Block: Spawned interactable tracking
public void RegisterSpawnedInteractable(DraggableInteractable interactable)
{
if (interactable == null)
return;
CleanupDestroyedSpawnedInteractables();
if (!activeSpawnedInteractables.Contains(interactable))
activeSpawnedInteractables.Add(interactable);
}
public void NotifyReturnedToSource(DraggableInteractable interactable)
{
if (interactable == null)
return;
activeSpawnedInteractables.Remove(interactable);
CleanupDestroyedSpawnedInteractables();
}
Why this is core:
This is what allows the source object to keep track of which interactable it currently has “out.” That is especially important for source objects like a nail box, where only one active spawned item should exist at a time before allowing a new one to be created.
5. Click to Spawn Entry Point
Code Block: Click to spawn entry point
public void OnClicked(Collider clickedCollider)
{
SpawnInteractable(true);
}
Why this is core:
This is the user interaction entry point. When the player clicks the source object, it immediately routes into the spawn flow and requests that the new object begin dragging right away.
6. Main Spawn Flow
Code Block: Main spawn flow
public DraggableInteractable SpawnInteractable(bool beginDrag)
{
if (!CanSpawnNewInteractable())
return null;
if (data == null || data.prefab == null || interactableSpawner == null)
return null;
if (beginDrag && inputManager == null)
return null;
Vector3 spawnPosition = transform.position + data.sourceSpawnPositionOffset;
Transform parentToUse = parentForSpawnedInteractable != null ? parentForSpawnedInteractable : transform.parent;
DraggableInteractable spawned = interactableSpawner.Spawn(
data,
spawnPosition,
parentToUse,
resetCube);
if (spawned == null)
return null;
spawned.sourceObject = this;
spawned.transform.rotation = Quaternion.Euler(data.spawnRotationOffset);
RegisterSpawnedInteractable(spawned);
if (resetCube != null && !resetCube.interactableTypeList.Contains(spawned))
resetCube.interactableTypeList.Add(spawned);
if (interactionManager != null)
interactionManager.RegisterInteractable(spawned);
if (beginDrag && inputManager != null)
{
#if UNITY_ANDROID || UNITY_IOS
if (Input.touchCount > 0)
inputManager.ForceBeginDrag(spawned, Input.GetTouch(0).position, Input.GetTouch(0).fingerId);
else
inputManager.ForceBeginDrag(spawned, Input.mousePosition);
#else
inputManager.ForceBeginDrag(spawned, Input.mousePosition);
#endif
}
RefreshHintIfActive();
return spawned;
}
Why this is core:
This is the heart of the source object. It checks whether a new item can be spawned, validates required references, chooses a spawn position and parent, spawns the interactable through the shared spawner, links the interactable back to this source object, adds it to reset tracking, optionally begins dragging immediately, and refreshes active hints. This is the full source-spawn gameplay loop.
7. Source Object Label Setup
Code Block: Source object label setup
private void ApplyLabel()
{
if (label == null || data == null)
return;
label.text = string.IsNullOrWhiteSpace(data.sourceDisplayName)
? data.displayName
: data.sourceDisplayName;
TMP_FontAsset fontToUse = data.sourceTitleFont != null ? data.sourceTitleFont : data.titleFont;
float fontSizeToUse = data.sourceTitleFont != null ? data.sourceTitleFontSize : data.titleFontSize;
Vector3 posToUse = data.sourceTitleFont != null ? data.sourceTitleLocalPositionOffset : data.titleLocalPositionOffset;
if (fontToUse != null)
{
label.font = fontToUse;
label.fontSharedMaterial = fontToUse.material;
}
label.fontSize = fontSizeToUse;
label.transform.localPosition = posToUse;
label.ForceMeshUpdate();
}
Why this is core:
This controls how the source object presents itself visually. It supports a separate display name and styling for the source object compared with the spawned interactable, which is important in cases like a nail box versus the nail itself.
8. Spawn Limit Rule and Cleanup
Code Block: Spawn limit rule and cleanup
private bool CanSpawnNewInteractable()
{
CleanupDestroyedSpawnedInteractables();
return activeSpawnedInteractables.Count == 0;
}
private void CleanupDestroyedSpawnedInteractables()
{
for (int i = activeSpawnedInteractables.Count - 1; i >= 0; i--)
{
DraggableInteractable interactable = activeSpawnedInteractables[i];
if (interactable == null ||
!interactable.gameObject.activeInHierarchy ||
interactable.placedCorrectly)
{
activeSpawnedInteractables.RemoveAt(i);
}
}
}
}
Why this is core:
This is the rule that limits the source object to one active spawned interactable at a time. It cleans up old references and only allows a new spawn when nothing active is currently out. That behavior is central to how the source-based workflow functions.
Website-Ready Summary
Summary:
SourceSpawnObject represents a persistent source object, such as a nail box, that can spawn one interactable on demand and optionally begin dragging it immediately.
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. Cache Child Targets on Startup
Code Block: Cache child targets on startup
private void Awake()
{
// Grab all child targets once when this group wakes up.
CacheTargets();
}
// Rebuilds the group target list from child Target components.
private void CacheTargets()
{
targets.Clear();
targets.AddRange(GetComponentsInChildren<Target>());
}
Why this is core:
This automatically builds the target list from child Target components, so the group does not need every target assigned manually. It makes the grouped placement system easier to maintain.
3. 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.
4. 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, ResetCube cube)
{
if (!CanAutoFill(source, manager, cube))
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, cube);
if (extra == null)
continue;
extra.sourceObject = source.sourceObject;
PlaceGroupMember(extra, target);
LinkToSourceGroup(source, extra);
// Intentionally not consuming cube 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.
5. Auto-Fill Validation and Target Filtering
Code Block: Auto-fill validation and target filtering
// Quick safety check before trying to auto-fill the rest of the group.
private bool CanAutoFill(DraggableInteractable source, InteractionManager manager, ResetCube cube)
{
if (source == null || source.data == null || manager == null || cube == null)
return false;
if (manager.spawner == null)
return false;
return true;
}
// Returns every target in this group that can receive an auto-filled copy.
public List<Target> GetFillableTargets(Target sourceTarget, DraggableInteractable source)
{
List<Target> fillableTargets = new List<Target>();
foreach (var target in targets)
{
if (CanFillTarget(target, sourceTarget, source))
fillableTargets.Add(target);
}
return fillableTargets;
}
// Checks whether this target should get one of the auto-filled copies.
private bool CanFillTarget(Target target, Target sourceTarget, DraggableInteractable source)
{
if (target == null || source == null)
return false;
if (target == sourceTarget)
return false;
if (!target.CanAcceptInteractable(source))
return false;
return true;
}
Why this is core:
This block filters the group down to the targets that should actually receive auto-filled copies. It prevents the source target from being reused and respects each target’s placement rules.
6. 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,
ResetCube cube)
{
DraggableInteractable extra = manager.spawner.Spawn(
source.data,
cube.transform.position,
null,
cube
);
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.
Website-Ready Summary
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.
1. Target Identity, Snap Transform, and Runtime Rules
Code Block: Target identity, snap transform, and runtime rules
public class Target : MonoBehaviour
{
[Header("Identity")]
[Tooltip("Unique ID used by StepManager to match steps to the correct target.")]
public TargetID targetID;
[Header("Placement Transform")]
[Tooltip("Optional snap point used for exact position/rotation when something is placed here.")]
public GameObject orientPosRotTarget;
[Header("Runtime State")]
[Tooltip("Simple runtime flag showing whether anything is currently placed here.")]
public bool HasPlacedInteractable = false;
[Header("Placement Rules")]
[Tooltip("If false, only one interactable can be placed here at a time.")]
public bool allowMultipleInteractables = false;
[Header("Optional Label")]
public TextMeshPro label;
public string displayName;
Why this is core:
This block defines what a target is. It gives the target its ID for step matching, an optional exact snap transform, placement rules like single or multiple occupancy, and optional display label information.
2. Runtime Occupancy Tracking
Code Block: Runtime occupancy tracking
// Cached renderer used to show or hide the target mesh based on occupancy.
private MeshRenderer meshRenderer;
// Runtime list of interactables currently sitting on this target.
private readonly List<DraggableInteractable> placedInteractables = new List<DraggableInteractable>();
Why this is core:
This is the target’s live runtime state. It tracks the interactables currently placed on the target and the renderer used to visually reflect whether the target is occupied.
3. Startup Setup
Code Block: Startup setup
private void Awake()
{
meshRenderer = GetComponent<MeshRenderer>();
// If no custom snap point is assigned, use this target object itself.
if (orientPosRotTarget == null)
orientPosRotTarget = gameObject;
}
private void Start()
{
// Set the visible label and sync the occupied state when the scene starts.
UpdateLabel();
RefreshOccupiedState();
}
// Updates the optional text label shown on this target.
private void UpdateLabel()
{
if (label != null)
label.text = displayName;
}
Why this is core:
This setup makes sure the target has a valid snap transform, initializes its visual label, and synchronizes its occupied state at startup.
4. 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.
5. 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.
6. 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.
Website-Ready Summary
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.
ResetCube.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 cube should be visible, and routes reset requests into the interaction manager when clicked.
1. Core Data, Amount Tracking, and Scene References
Code Block: Core data, amount tracking, and scene references
public class ResetCube : MonoBehaviour, IClickable3D
{
public enum PlayMode
{
Guided,
FreePlay
}
[Header("Sound")]
[Tooltip("Optional click sound played when the reset cube is pressed.")]
public AudioClip clickSound;
[Range(0f, 1f)]
public float clickVolume = 1f;
[Header("UI & Data")]
[Tooltip("Text showing how many resets/spawns remain for this interactable type.")]
public TextMeshPro resetText;
[Tooltip("Current remaining amount tied to this reset cube.")]
public int resetAmount = 3;
[Tooltip("String ID used to match this reset cube to an interactable type.")]
public string resetCubeID;
[Header("Interactables")]
[Tooltip("All interactables associated with this reset cube's type.")]
public List<DraggableInteractable> interactableTypeList = new();
// Saved starting amount so runtime updates always clamp back to the intended max.
private int originalResetAmount;
private Renderer rend;
private Collider col;
[HideInInspector] public StepManager stepManager;
[HideInInspector] public InteractionManager interactionManager;
Why this is core:
This block defines what the reset cube is responsible for: its reset amount, the text UI that displays it, the list of interactables it controls, and the references it needs to communicate with the step and interaction systems.
2. Startup Initialization
Code Block: Startup initialization
private void Awake()
{
// Cache scene references and remember the starting reset amount.
rend = GetComponent<Renderer>();
col = GetComponent<Collider>();
originalResetAmount = resetAmount;
RefreshResetText();
RefreshVisibility();
}
Why this is core:
This initializes the reset cube’s runtime state. It caches the renderer and collider, stores the original reset amount so later changes can clamp back to the intended maximum, and immediately syncs the text and visibility.
3. 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 cube 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 cube’s local mode logic.
4. 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 cube. 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.
5. Click Handling and Reset Request
Code Block: Click handling and reset request
// Called by the 3D click system when this cube is clicked in the scene.
public void OnClicked(Collider clicked)
{
// Only respond if this cube'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 cube's visuals.
public void ResetForCurrentMode()
{
if (interactionManager == null)
return;
interactionManager.RequestResetFromCube(this);
RefreshVisibility();
// Lets the tutorial system know this reset cube was clicked.
TutorialHintTarget tutorialTarget = GetComponent<TutorialHintTarget>();
if (tutorialTarget == null)
tutorialTarget = GetComponentInParent<TutorialHintTarget>();
if (tutorialTarget == null)
tutorialTarget = GetComponentInChildren<TutorialHintTarget>();
if (tutorialTarget != null)
tutorialTarget.NotifyClicked();
}
Why this is core:
This is the main interaction path for the cube. When clicked, it plays its sound and routes the reset request into the interaction manager, which is where the actual reset logic is handled.
6. Determine Whether Any Interactable Can Currently Be Reset
Code Block: Determine whether any interactable can currently be reset
// Checks whether this cube 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 cube’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.
7. Visibility Control and Interactable Registration
Code Block: Visibility control and interactable registration
// Shows or hides the cube's renderer and collider together.
public void UpdateVisibility(bool shouldShow)
{
if (rend != null)
rend.enabled = shouldShow;
if (col != null)
col.enabled = shouldShow;
}
// Adds a new interactable to this cube's tracking list if it is not already there.
public void RegisterInteractable(DraggableInteractable interactable)
{
if (interactable == null)
return;
if (!interactableTypeList.Contains(interactable))
interactableTypeList.Add(interactable);
RefreshVisibility();
}
// Called when placement state changes so the cube can re-check whether it should stay visible.
public void NotifyPlacementChanged()
{
RefreshVisibility();
}
Why this is core:
This block makes the cube responsive to gameplay changes. It shows or hides the cube visually, registers new interactables to track, and re-checks visibility whenever placement state changes.
8. Reset Text, Visibility Refresh, and Cleanup
Code Block: Reset text, visibility refresh, and cleanup
// Updates the visible reset count text.
private void RefreshResetText()
{
if (resetText != null)
resetText.text = "Resets: " + resetAmount;
}
// Re-checks cube visibility after clearing out any destroyed references.
private void RefreshVisibility()
{
CleanupNulls();
UpdateVisibility(AnyInteractableEligible());
}
// Removes destroyed interactables from the tracking list so visibility checks stay clean.
private void CleanupNulls()
{
for (int i = interactableTypeList.Count - 1; i >= 0; i--)
{
if (interactableTypeList[i] == null)
interactableTypeList.RemoveAt(i);
}
}
}
Why this is core:
This keeps the cube’s UI and tracking state clean over time. It updates the count text, refreshes visibility based on current eligibility, and removes destroyed interactables from the tracking list so stale references do not affect reset behavior.
Website-Ready Summary
Summary:
ResetCube tracks the reset and spawn amount for one interactable type, decides when the reset cube 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.
1. Core Input State, Camera References, and Mode Setup
Code Block: Core input state, camera references, and mode setup
public class BaseInputManager : MonoBehaviour
{
private class MobileTouchData
{
public int fingerId;
public Vector2 startPosition;
public Vector2 lastPosition;
public float startTime;
public bool beganOverUI;
public bool wasConsumedByImmediateClick;
public DraggableInteractable candidateDraggable;
}
[Header("Mobile UI Controls")]
[SerializeField] private bool useVirtualMobileControls = true;
[SerializeField] private bool allowLegacySceneTouchesWithVirtualControls = true;
private Camera mainCamera;
private bool isMobile;
private FreeCamera freeCamera;
private DraggableInteractable activeDraggable;
private DraggableInteractable selectedDraggable;
private int activeFingerId = -1;
private int verticalAssistFingerId = -1;
private int lookFingerId = -1;
private int moveFingerId = -1;
private int scenePanFingerId = -1;
private int primarySceneFingerId = -1;
private readonly Dictionary<int, MobileTouchData> mobileTouches = new Dictionary<int, MobileTouchData>();
[Header("UI Blocking")]
[SerializeField] private List<GraphicRaycaster> blockingUiRaycasters = new List<GraphicRaycaster>();
private readonly List<RaycastResult> uiRaycastResults = new List<RaycastResult>();
private static BaseInputManager activeInputManager;
Why this is core:
This is the shared state model for the input system. It stores the active camera references, whether the game is running in mobile mode, the currently active or selected interactable, finger-role ownership for touch input, and the list of UI raycasters used to block scene input when the player is touching UI.
2. 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.
3. 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.
4. Selected Interactable Management
Code Block: Selected interactable management
private bool CanUseSelectedInteractable(DraggableInteractable draggable)
{
return draggable != null
&& draggable.gameObject != null
&& draggable.gameObject.activeInHierarchy
&& !draggable.IsPlacedCorrectly()
&& !draggable.isLockedByNail;
}
private void DeselectInteractable()
{
selectedDraggable = null;
selectedMobileVerticalOffset = 0f;
smoothFocusSelectedInteractable = false;
if (freeCamera != null && isMobile)
freeCamera.enabled = false;
}
private void SelectInteractable(DraggableInteractable draggable)
{
if (!CanUseSelectedInteractable(draggable))
{
DeselectInteractable();
return;
}
smoothFocusSelectedInteractable = mainCamera != null && ShouldSmoothFocusOnSelect();
selectedDraggable = draggable;
selectedMobileVerticalOffset = 0f;
if (freeCamera == null)
CacheFreeCamera();
if (freeCamera != null && isMobile)
freeCamera.enabled = true;
}
private void ToggleSelectedInteractable(DraggableInteractable draggable)
{
if (selectedDraggable == draggable)
DeselectInteractable();
else
SelectInteractable(draggable);
}
private void RefreshSelectedInteractableState()
{
if (!CanUseSelectedInteractable(selectedDraggable))
DeselectInteractable();
}
Why this is core:
This is the selected-object system. It determines whether an interactable is eligible to stay selected, handles selecting and deselecting, toggles selection on repeat taps or clicks, and keeps selection state synchronized with runtime changes like placement or locking.
5. 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.
6. 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.
7. Mobile Touch Synchronization and World Hit Detection
Code Block: Mobile touch synchronization and world hit detection
private void SyncMobileTouches()
{
for (int i = 0; i < Input.touchCount; i++)
{
Touch touch = Input.GetTouch(i);
if (touch.phase == TouchPhase.Began)
{
MobileTouchData data = new MobileTouchData
{
fingerId = touch.fingerId,
startPosition = touch.position,
lastPosition = touch.position,
startTime = Time.time,
beganOverUI = IsScreenPositionOverUI(touch.position)
};
int currentPrimaryCameraFingerId = moveFingerId != -1 ? moveFingerId : lookFingerId;
bool ignoreNewSceneTouch = activeDraggable == null
&& currentPrimaryCameraFingerId != -1
&& touch.fingerId != currentPrimaryCameraFingerId;
if (!data.beganOverUI && !ignoreNewSceneTouch)
{
if (TryGetWorldHitTargets(touch.position, out IClickable3D clickable, out DraggableInteractable draggable, out Collider hitCollider))
{
if (clickable != null)
{
clickable.OnClicked(hitCollider);
data.wasConsumedByImmediateClick = true;
}
else if (draggable != null)
{
data.candidateDraggable = draggable;
}
}
}
mobileTouches[touch.fingerId] = data;
continue;
}
}
}
Why this is core:
This is where raw mobile touches first become gameplay interactions. On touch-begin, it records the touch state, checks whether the touch started over UI, and then either sends the touch to a clickable 3D object immediately or marks a draggable as a drag candidate.
8. Promote Touch to Drag or Scene Camera Gesture
Code Block: Promote touch to drag or scene camera gesture
private void TryPromoteTouchToDrag(Touch touch, MobileTouchData data)
{
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
return;
float movedDistance = Vector2.Distance(touch.position, data.startPosition);
if (movedDistance < mobileDragStartThreshold)
return;
if (data.candidateDraggable == null)
return;
activeDraggable = data.candidateDraggable;
activeFingerId = touch.fingerId;
verticalAssistFingerId = -1;
lookFingerId = -1;
moveFingerId = -1;
scenePanFingerId = -1;
activeDraggable.BeginDrag(touch.position);
}
private void TryPromoteTouchToSceneCameraGesture(Touch touch, MobileTouchData data)
{
if (touch.fingerId != GetPrimarySceneFingerId())
return;
if (lookFingerId != -1 || moveFingerId != -1)
return;
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
return;
Vector2 displacement = touch.position - data.startPosition;
float elapsed = Time.time - data.startTime;
if (elapsed >= mobileMoveHoldDelay && displacement.magnitude <= mobileLookStartThreshold)
{
moveFingerId = touch.fingerId;
scenePanFingerId = -1;
return;
}
if (displacement.magnitude >= mobileLookStartThreshold)
lookFingerId = touch.fingerId;
}
Why this is core:
This is one of the key mobile decision points. A touch can become a drag on an interactable, a movement finger, or a look finger depending on what it hit and how far or how long it moves.
9. 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.
10. 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.
11. Force External Systems to Begin Drag
Code Block: Force external systems to begin drag
// Starts dragging from another script instead of waiting for normal input detection.
public void ForceBeginDrag(DraggableInteractable draggable, Vector2 screenPos)
{
ForceBeginDrag(draggable, screenPos, -1);
}
// Same as above, but also lets mobile code pass through the finger id that owns the drag.
public void ForceBeginDrag(DraggableInteractable draggable, Vector2 screenPos, int fingerId)
{
if (draggable == null)
return;
if (IsScreenPositionOverUI(screenPos))
return;
activeDraggable = draggable;
activeFingerId = fingerId;
verticalAssistFingerId = -1;
lookFingerId = -1;
moveFingerId = -1;
scenePanFingerId = -1;
primarySceneFingerId = -1;
activeDraggable.BeginDrag(screenPos);
}
Why this is core:
This allows other systems, like source-spawn objects, to instantly hand a newly created interactable into the drag system without waiting for the usual hit-detection flow. That is a major part of the spawn-and-immediately-drag behavior.
12. End Interaction and Clear Drag Ownership
Code Block: End interaction and clear drag ownership
// Ends the current drag and clears the active input state.
private void EndPointerInteraction()
{
DraggableInteractable endedDraggable = activeDraggable;
if (activeDraggable != null)
{
activeDraggable.EndDrag();
activeDraggable = null;
}
if (selectedDraggable == endedDraggable)
selectedMobileVerticalOffset = 0f;
RefreshSelectedInteractableState();
activeFingerId = -1;
verticalAssistFingerId = -1;
scenePanFingerId = -1;
primarySceneFingerId = -1;
}
Why this is core:
This cleanly ends an active drag, lets the interactable finish its release logic, refreshes selected-object state, and clears the finger-role ownership values so new interactions can start cleanly.
13. 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.
14. Pickup Hover Helper
Code Block: Pickup hover helper
// Updates the shared pickup helper sphere based on what the pointer is near.
private void UpdatePickupHover()
{
if (PickupZoneManager.Instance == null)
return;
// Do not show the pickup helper while already dragging something.
if (activeDraggable != null)
{
PickupZoneManager.Instance.Hide();
return;
}
if (CanUseSelectedInteractable(selectedDraggable))
{
PickupZoneManager.Instance.Show(selectedDraggable.GetPickupIndicatorPosition());
return;
}
if (!TryGetHoverPointerData(out Vector2 screenPos, out int pointerId))
{
PickupZoneManager.Instance.Hide();
return;
}
// Do not show pickup helpers through UI.
if (IsScreenPositionOverUI(screenPos))
{
PickupZoneManager.Instance.Hide();
return;
}
float hoverRadius = isMobile ? touchPickupHoverRadius : mousePickupHoverRadius;
DraggableInteractable nearest = FindNearestPickupable(screenPos, hoverRadius);
if (nearest != null)
{
PickupZoneManager.Instance.Show(nearest.GetPickupIndicatorPosition());
return;
}
if (HasLooseUnplacedNailInScene())
{
PickupZoneManager.Instance.Hide();
return;
}
SourceSpawnObject nearestSource = FindNearestPickupHoverSource(screenPos, hoverRadius);
if (nearestSource != null)
PickupZoneManager.Instance.Show(nearestSource.GetPickupIndicatorPosition());
else
PickupZoneManager.Instance.Hide();
}
Why this is core:
This drives the pickup helper sphere in the scene. It chooses whether to show the helper on the selected interactable, the nearest draggable, or a source spawn object, while also respecting UI blocking and active drags.
15. 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.
Website-Ready Summary
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.
HybridClickableBase.cs
This script is the shared base class for clickable objects that can be activated either through a UI graphic or through a 3D collider in the scene. It standardizes how click sources are handled and where the actual click behavior lives.
1. Shared Click Settings and Optional References
Code Block: Shared click settings and optional references
public abstract class HybridClickableBase : MonoBehaviour, IPointerClickHandler, IClickable3D
{
[Header("Click Sources")]
[SerializeField] protected bool allowUIClicks = true;
[SerializeField] protected bool allowColliderClicks = true;
[Header("Optional")]
[SerializeField] protected Collider targetCollider;
[Header("Optional Sound")]
[SerializeField] protected AudioClip clickSound;
[SerializeField][Range(0f, 1f)] protected float clickVolume = 1f;
Why this is core:
This defines how hybrid clickables are configured. A derived object can choose whether it responds to UI clicks, collider clicks, or both, and it can optionally target a specific collider and sound.
2. Startup Setup
Code Block: Startup setup
protected virtual void Awake()
{
// If no specific collider was assigned, use the one on this object.
if (targetCollider == null)
targetCollider = GetComponent<Collider>();
// Falls back to the default button click sound in Resources.
if (clickSound == null)
clickSound = Resources.Load<AudioClip>("Audio/ButtonClickSound");
}
Why this is core:
This gives the clickable sensible defaults. If no collider is assigned, it uses its own collider, and if no click sound is assigned, it falls back to the default button sound.
3. UI Click Path
Code Block: UI click path
// Called by Unity when this object is clicked through a UI Graphic.
public void OnPointerClick(PointerEventData eventData)
{
if (!allowUIClicks)
return;
PlayClickSoundInternal();
HandleClick();
}
Why this is core:
This is the UI-driven click path. If UI clicks are allowed, it plays the configured click sound and then routes into the shared click behavior.
4. Collider Click Path
Code Block: Collider click path
// Called by the 3D click system when this object's collider is clicked in the scene.
public void OnClicked(Collider clicked)
{
if (!allowColliderClicks)
return;
// If a specific collider was assigned, only respond when that collider was the one hit.
if (targetCollider != null && clicked != targetCollider)
return;
PlayClickSoundInternal();
HandleClick();
}
Why this is core:
This is the 3D-world click path. It lets the same object respond through the collider-based input system used by scene objects, while optionally restricting the click to one specific collider.
5. Shared Sound Playback and Abstract Click Action
Code Block: Shared sound playback and abstract click action
// Plays the assigned click sound before the actual click action runs.
private void PlayClickSoundInternal()
{
if (clickSound == null || SoundManager.Instance == null)
return;
SoundManager.Instance.PlayClip(clickSound, clickVolume);
}
// Child classes put their actual click behavior here.
protected abstract void HandleClick();
}
Why this is core:
This is the shared endpoint for hybrid clickables. It centralizes sound playback and forces derived classes to define the actual click behavior in HandleClick(), which keeps clickable objects consistent across the project.
Website-Ready Summary
Summary:
HybridClickableBase is the shared base class for buttons and clickable objects that can be triggered either through world-space UI or through 3D collider clicks. It standardizes click filtering, sound playback, and the shared HandleClick() pattern that child classes implement for their actual behavior.
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 cubes, 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
{
[Header("Scene References")]
[Tooltip("Spawner used when a placed interactable should be replaced with a fresh copy.")]
public InteractableSpawner spawner;
[Tooltip("All reset cubes currently active in the scene.")]
public List<ResetCube> resetCubeList = new();
[Header("Runtime Tracking")]
[Tooltip("Tracks placed interactables per reset cube so FreePlay reset can undo the most recently placed item of that type.")]
public Dictionary<ResetCube, Stack<DraggableInteractable>> placedStacks = new();
public event Action<DraggableInteractable, Target, bool> InteractablePlaced;
public event Action<DraggableInteractable, ResetReason> InteractableReset;
public event Action<NailInteractable, bool> NailHammerStateChanged;
public enum ResetReason
{
ResetCube,
GuidedBack,
FreePlayStepToggle,
ManualReset,
InvalidMoveReturn
}
[SerializeField] private StepManager stepManager;
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 cubes, 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 Cubes
Code Block: Register interactables with matching reset cubes
// Assigns an interactable to the matching reset cube 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 (ResetCube cube in resetCubeList)
{
if (cube == null || cube.resetCubeID != interactable.data.id.ToString())
continue;
cube.RegisterInteractable(interactable);
}
}
Why this is core:
This is how new or pre-existing interactables become associated with the correct reset cube 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.resetCube == null)
{
foreach (ResetCube cube in resetCubeList)
{
if (cube == null || cube.resetCubeID != interactable.data.id.ToString())
continue;
interactable.resetCube = cube;
cube.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 cube visible right away.
if (!wasForcePlaced && interactable.resetCube != null)
interactable.resetCube.UpdateVisibility(true);
TrySpawnReplacement(interactable);
TryAutoFillGroup(interactable, target);
interactable.resetCube?.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 cube, prepares nail grouping, applies the placed state, records the placement for reset tracking, optionally spawns a replacement, optionally auto-fills grouped targets, updates cube 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 in LIFO Order
Code Block: Track placements in LIFO order
// Pushes the newly placed interactable onto that cube's stack.
// FreePlay uses this to reset things in last-placed order.
private void TrackPlacedInteractable(DraggableInteractable interactable)
{
ResetCube cube = interactable.resetCube;
if (cube == null)
return;
if (!placedStacks.ContainsKey(cube))
placedStacks[cube] = new Stack<DraggableInteractable>();
placedStacks[cube].Push(interactable);
}
Why this is core:
This is the data structure that makes FreePlay last-placed reset behavior work. Every successful placement is pushed onto the stack for that reset cube so the newest item can be undone first.
6. Replacement Spawning and Target-Group Autofill
Code Block: Replacement spawning and target-group autofill
// Spawns a fresh replacement back at the reset cube 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;
ResetCube cube = interactable.resetCube;
if (cube == null)
return;
// Once the cube runs out, clamp the count and stop spawning replacements.
if (cube.resetAmount <= 0)
{
cube.SetResetAmount(0);
return;
}
cube.UpdateResetAmount(-1);
SpawnReplacementAtCube(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.resetCube == null)
return;
TargetGroup group = target.GetComponentInParent<TargetGroup>();
if (group != null)
group.AutoFillGroup(interactable, this, interactable.resetCube);
}
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 cube. 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-cube click into the correct reset flow for the active play mode.
public void RequestResetFromCube(ResetCube cube)
{
if (cube == null)
return;
if (stepManager == null)
return;
if (stepManager.currentMode == StepManager.PlayMode.FreePlay)
ResetLastForCubeSafely(cube);
else
RequestGuidedResetFromCube(cube);
}
// 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;
}
ResetCube cube = lastMatching.resetCube;
ResetInteractableSet(
resetSet,
cube,
ResetReason.FreePlayStepToggle,
refundPlacedItems: true,
moveToCube: !IsSourceNail(lastMatching)
);
RemoveInteractablesFromPlacedStack(cube, resetSet);
return true;
}
// Resets a specific interactable or grouped set directly.
// This is a lower-level reset entry point for systems that already know exactly what should be reset.
public void RequestResetInteractable(DraggableInteractable interactable, ResetReason reason)
{
if (interactable == null)
return;
ResetInteractableSet(
interactable.GetGroupedMembersSnapshot(),
interactable.resetCube,
reason,
refundPlacedItems: false,
moveToCube: false
);
}
Why this is core:
These are the public reset routes into the system. They let a reset cube trigger mode-specific reset logic, let a FreePlay step toggle undo the most recent matching interactable set, and let other systems directly request a reset for a specific interactable or group.
9. Guided and FreePlay Reset Behavior
Code Block: Guided and FreePlay reset behavior
// Guided reset uses the cube'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 RequestGuidedResetFromCube(ResetCube cube)
{
if (cube == null || cube.interactableTypeList == null)
return;
PruneDestroyedReferences(cube);
DraggableInteractable lastEligible =
FindLastMovedNotPlacedInteractable(cube, includeLocked: true) ??
FindLastPlacedInteractable(cube, includeLocked: true);
if (lastEligible == null)
return;
ResetSingleInteractable(lastEligible, ResetReason.ResetCube);
CleanupGuidedDuplicates(cube, lastEligible);
PruneDestroyedReferences(cube);
cube.NotifyPlacementChanged();
}
// FreePlay reset entry point for reset-cube clicks.
// This first tries to undo a loose moved item, then falls back to the last placed item on the stack.
public void ResetLastForCubeSafely(ResetCube cube)
{
if (cube == null)
return;
PruneDestroyedReferences(cube);
DraggableInteractable movedOnly = FindLastMovedNotPlacedInteractable(cube, includeLocked: false);
if (movedOnly != null)
{
ResetSingleInteractable(movedOnly, ResetReason.ResetCube);
cube.NotifyPlacementChanged();
return;
}
ResetLastPlacedForCube(cube);
}
// Resets the most recently placed interactable for this cube in FreePlay.
// Grouped items reset together if the top item belongs to a group.
private void ResetLastPlacedForCube(ResetCube cube)
{
if (cube == null)
return;
PruneDestroyedReferences(cube);
DraggableInteractable lastPlaced = PeekValidPlacedInteractable(cube);
if (lastPlaced == null || lastPlaced.isLockedByNail)
return;
List<DraggableInteractable> resetSet = lastPlaced.GetGroupedMembersSnapshot();
ResetInteractableSet(
resetSet,
cube,
ResetReason.ResetCube,
refundPlacedItems: true,
moveToCube: !IsSourceNail(lastPlaced)
);
RemoveInteractablesFromPlacedStack(cube, resetSet);
// Clear extra state left over from grouped placements.
lastPlaced.hasBeenMoved = false;
lastPlaced.groupMembers?.Clear();
PruneDestroyedReferences(cube);
if (placedStacks.TryGetValue(cube, out Stack<DraggableInteractable> stack) && stack.Count == 0)
cube.UpdateVisibility(false);
cube.NotifyPlacementChanged();
}
Why this is core:
This block shows the difference between Guided and FreePlay reset behavior. Guided reset uses the cube’s interactable list and prefers resetting moved-but-unplaced items first, while FreePlay reset uses the placed stack so the most recently placed item or grouped set is undone first.
10. Shared Reset Pipeline
Code Block: Shared reset pipeline
// Shared reset routine used by the different reset entry points.
// This handles unregistering placement state, applying local reset, refunding counts, and cleaning up source nails.
private void ResetInteractableSet(
List<DraggableInteractable> interactables,
ResetCube cube,
ResetReason reason,
bool refundPlacedItems,
bool moveToCube)
{
if (interactables == null || interactables.Count == 0)
return;
HashSet<Target> touchedTargets = new();
foreach (DraggableInteractable interactable in interactables)
{
if (interactable == null || interactable.isLockedByNail)
continue;
bool wasPlaced = interactable.placedCorrectly;
PrepareInteractableForReset(interactable, touchedTargets);
NotifySystemsBeforeReset(interactable);
ApplyLocalReset(interactable);
// Source nails return to the source object and get destroyed instead of sitting at the cube.
if (IsSourceNail(interactable))
{
if (refundPlacedItems && cube != null && wasPlaced)
cube.UpdateResetAmount(1);
if (cube != null)
cube.interactableTypeList.Remove(interactable);
interactable.sourceObject.NotifyReturnedToSource(interactable);
InteractableReset?.Invoke(interactable, reason);
Destroy(interactable.gameObject);
continue;
}
if (moveToCube && cube != null)
interactable.transform.position = cube.transform.position;
// Make sure only one idle copy is left sitting at the cube.
if (cube != null)
EnsureSingleIdleInstanceAtCube(cube, interactable);
if (refundPlacedItems && cube != null && wasPlaced)
cube.UpdateResetAmount(1);
InteractableReset?.Invoke(interactable, reason);
}
RefreshTouchedTargets(touchedTargets);
cube?.NotifyPlacementChanged();
}
Why this is core:
This is the shared reset engine used by all the public reset flows. It unregisters placement state, notifies other systems, applies the interactable’s local reset, refunds reset counts when needed, handles special source-spawned nail cleanup, optionally moves items back to the cube, and updates targets afterward.
11. Reset Helper Methods That Keep Placement and Step State in Sync
Code Block: Reset helper methods that keep placement and step state in sync
// Applies the interactable's own reset logic while temporarily ignoring placement checks.
private void ApplyLocalReset(DraggableInteractable interactable)
{
if (interactable == null)
return;
interactable.ignorePlacementChecks = true;
interactable.ApplyResetState();
interactable.ignorePlacementChecks = false;
}
// Notifies other systems before the object is reset so step and placement state stay in sync.
private void NotifySystemsBeforeReset(DraggableInteractable interactable)
{
if (interactable == null || interactable.stepManager == null || interactable.data == null)
return;
StepManager manager = interactable.stepManager;
if (manager.currentMode == StepManager.PlayMode.FreePlay)
manager.UnregisterPlacement(interactable);
if (interactable.stepCompleted)
manager.MarkStepIncomplete(FindFirstMatchingStep(manager, interactable));
}
// Clears target occupancy and unlocks linked targets before the interactable is reset.
private void PrepareInteractableForReset(DraggableInteractable interactable, HashSet<Target> touchedTargets)
{
if (interactable == null)
return;
UnlockConnectedTargetsIfNeeded(interactable);
Target target = interactable.GetComponentInParent<Target>();
if (target != null)
{
touchedTargets.Add(target);
target.UnregisterPlacedInteractable(interactable);
}
}
Why this is core:
These helper methods are what keep the rest of the game synchronized during reset. They temporarily disable placement checks, remove placement registration, mark completed steps incomplete again, unlock connected targets when resetting nails, and unregister the interactable from its current target.
12. Stack Cleanup and Lookup Helpers
Code Block: Stack cleanup and lookup helpers
// Cleans null references out of cube lists and placed stacks after destroys.
private void PruneDestroyedReferences(ResetCube cube)
{
if (cube == null)
return;
if (cube.interactableTypeList != null)
{
for (int i = cube.interactableTypeList.Count - 1; i >= 0; i--)
{
if (cube.interactableTypeList[i] == null)
cube.interactableTypeList.RemoveAt(i);
}
}
if (placedStacks.TryGetValue(cube, out Stack<DraggableInteractable> stack))
{
Stack<DraggableInteractable> rebuilt = new();
DraggableInteractable[] items = stack.ToArray();
// Rebuild the stack in the same order, skipping destroyed items.
for (int i = items.Length - 1; i >= 0; i--)
{
if (items[i] != null)
rebuilt.Push(items[i]);
}
placedStacks[cube] = rebuilt;
}
}
// Returns the current top valid placed interactable for this cube's stack.
// Null entries get popped away as they are found.
private DraggableInteractable PeekValidPlacedInteractable(ResetCube cube)
{
if (cube == null || !placedStacks.TryGetValue(cube, out Stack<DraggableInteractable> stack))
return null;
while (stack.Count > 0)
{
DraggableInteractable top = stack.Peek();
if (top != null)
return top;
stack.Pop();
}
return null;
}
// Rebuilds the placed stack without the interactables that were just reset.
private void RemoveInteractablesFromPlacedStack(ResetCube cube, List<DraggableInteractable> removedItems)
{
if (cube == null || removedItems == null || !placedStacks.TryGetValue(cube, out Stack<DraggableInteractable> stack))
return;
HashSet<DraggableInteractable> removedSet = new(removedItems);
Stack<DraggableInteractable> rebuilt = new();
DraggableInteractable[] items = stack.ToArray();
// Rebuild the stack in the same order, skipping anything from the removed reset set.
for (int i = items.Length - 1; i >= 0; i--)
{
DraggableInteractable item = items[i];
if (item == null || removedSet.Contains(item))
continue;
rebuilt.Push(item);
}
placedStacks[cube] = rebuilt;
}
Why this is core:
These methods maintain the reset stacks over time. They remove destroyed entries, safely peek the newest valid placed interactable, and rebuild the stack after an item or grouped set has been reset so FreePlay LIFO behavior stays correct.
13. Replacement Spawning at the Cube
Code Block: Replacement spawning at the cube
// Spawns a replacement interactable back at the reset cube after one is placed.
private void SpawnReplacementAtCube(DraggableInteractable interactable)
{
if (spawner == null || interactable == null || interactable.resetCube == null)
return;
Vector3 spawnPosition = interactable.resetCube.transform.position;
DraggableInteractable newInteractable = spawner.Spawn(
interactable.data,
spawnPosition,
null,
interactable.resetCube
);
if (newInteractable == null)
return;
newInteractable.transform.localScale = newInteractable.originalScale;
if (!interactable.resetCube.interactableTypeList.Contains(newInteractable))
interactable.resetCube.interactableTypeList.Add(newInteractable);
}
Why this is core:
This is the physical replacement step after a placement consumes one available cube item. It creates a fresh interactable at the cube position and adds it back to that cube’s tracked interactables so the player has another spare available.
14. Source-Spawned Nail Special Case
Code Block: Source-spawned nail special case
// True when this interactable is a nail spawned from a source object instead of a normal cube spare.
private bool IsSourceNail(DraggableInteractable interactable)
{
return interactable != null &&
interactable.sourceObject != null &&
interactable.data != null &&
interactable.data.id == InteractableID.Nail;
}
}
Why this is core:
This special-case check matters because source-spawned nails do not follow the same spare-at-cube logic as normal interactables. They are treated differently during placement replacement and reset flows.
Website-Ready Summary
Summary:
InteractionManager is the main coordinator for placement and reset behavior. It accepts valid placements, links interactables to reset cubes, 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 cubes 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.
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
}
[System.Serializable]
private class FreePlayRowEntry
{
public StepData step;
public TMP_Text text;
}
[Header("Step Text Colors")]
public Color stepTextNormalColor = Color.white;
public Color stepTextCompleteColor = Color.green;
// Event other systems can listen to when step progress changes.
public event System.Action OnStepProgressChanged;
[Header("UI")]
[Tooltip("Guided mode text display showing the current step description.")]
public TMP_Text stepDisplay;
public GameObject stepDisplayParent;
[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("Target Display")]
[Tooltip("Optional mode where only targets used by the current step stay visible.")]
public bool onlyShowCurrentStepTarget = false;
public List<Target> allTargets = new List<Target>();
[Header("Managers")]
public InteractionManager interactionManager;
[Header("FreePlay UI")]
[Tooltip("Parent for the runtime-built list of FreePlay rows.")]
public Transform freePlayStepListParent;
public FreePlayStepRow freePlayStepRowPrefab;
public GameObject freePlayStepListParentRoot;
public bool isCollapsed = false;
[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. Main Initialization Flow
Code Block: Main initialization flow
// Main setup entry point called by ScenarioLoader after the module is chosen.
// This loads mode + steps, clears runtime state, and builds the correct UI.
public void Initialize(ModuleData module)
{
if (module == null)
{
Debug.LogError("StepManager.Initialize: module is null.");
return;
}
moduleData = module;
LoadModeFromPrefs();
LoadStepsFromModule(module);
currentStepIndex = 0;
isCollapsed = false;
placedInteractables.Clear();
ResetStepCompletion();
ResetSceneInteractables();
LoadStep(currentStepIndex);
RefreshModeUI();
if (currentMode == PlayMode.FreePlay)
BuildFreePlayList();
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
Why this is core:
This is the startup entry point for the step system. It loads the selected mode, copies in the module’s steps, clears old runtime state, resets all completion flags, resets scene interactables, loads the first guided step, switches the UI to match the mode, and builds the FreePlay list when needed.
3. 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.
4. 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.
5. 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.
6. 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 (ResetCube cube in interactionManager.resetCubeList)
{
if (cube == null || cube.interactableTypeList == null)
continue;
foreach (DraggableInteractable interactable in cube.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.
7. Guided Step Loading and Current-Step Target Visibility
Code Block: Guided step loading and current-step target visibility
// Shows the Guided UI or FreePlay UI based on the current mode.
private void RefreshModeUI()
{
if (stepDisplayParent != null)
stepDisplayParent.SetActive(currentMode == PlayMode.Guided);
if (freePlayStepListParentRoot != null)
freePlayStepListParentRoot.SetActive(currentMode == PlayMode.FreePlay);
}
// Loads one specific step as the current active Guided step.
// This is different from LoadStepsFromModule, which loads the full list.
private void LoadStep(int index)
{
if (index < 0 || index >= steps.Count)
{
CurrentStep = null;
if (stepDisplay != null)
stepDisplay.text = "All steps completed!";
if (onlyShowCurrentStepTarget)
UpdateTargetVisibility();
RefreshHintIfActive();
return;
}
CurrentStep = steps[index];
if (stepDisplay != null)
stepDisplay.text = $"{index + 1}/{steps.Count}: {CurrentStep.description}";
if (onlyShowCurrentStepTarget)
UpdateTargetVisibility();
RefreshHintIfActive();
}
// Shows only targets used by the current step when that option is enabled.
private void UpdateTargetVisibility()
{
foreach (var target in allTargets)
{
if (target == null)
continue;
bool show = !onlyShowCurrentStepTarget;
if (!show && CurrentStep != null)
{
List<PlacementRequirement> requirements = CurrentStep.GetRequirements();
if (requirements != null)
{
foreach (var requirement in requirements)
{
if (requirement != null && requirement.targetID == target.targetID)
{
show = true;
break;
}
}
}
}
target.gameObject.SetActive(show);
}
}
Why this is core:
This controls how Guided mode is presented. It switches between Guided and FreePlay UI, loads the current guided step into the step display, and can optionally hide all targets except the ones used by the current step.
8. 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.
9. 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.
10. 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.
11. Bulk Step Actions
Code Block: Bulk step actions
// Used by the Place All / Reset All button behavior.
// It chooses which bulk action should happen based on mode and current completion state.
public void TriggerBulkStepAction()
{
if (currentMode == PlayMode.Guided)
{
if (AreAllStepsComplete())
ResetAllStepsGuided();
else
AutoPlaceAllStepsGuided();
}
else
{
if (HasAnyCompletedSteps())
ResetAllStepsFreePlay();
else
AutoPlaceAllStepsFreePlay();
}
UpdateFreePlayUI();
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
// Explicit bulk auto-place entry point used by outside UI/scripts.
public void AutoPlaceAllSteps()
{
if (currentMode == PlayMode.Guided)
AutoPlaceAllStepsGuided();
else
AutoPlaceAllStepsFreePlay();
UpdateFreePlayUI();
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
// Explicit bulk reset entry point used by outside UI/scripts.
public void ResetAllSteps()
{
if (currentMode == PlayMode.Guided)
ResetAllStepsGuided();
else
ResetAllStepsFreePlay();
UpdateFreePlayUI();
OnStepProgressChanged?.Invoke();
RefreshHintIfActive();
}
Why this is core:
This block supports the project’s “place all / reset all” style behavior. Depending on the mode and whether steps are already complete, it decides whether the bulk button should auto-place everything or reset everything, then refreshes the step UI and broadcasts progress changes.
12. 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.
13. Step Completion Queries and Hammered Requirement Check
Code Block: Step completion queries and hammered requirement check
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];
}
// Helper used by placement logic to see whether this interactable/target pair
// still needs the hammered state before the step can count as complete.
private bool StepRequiresHammered(StepData step, InteractableID interactableID, TargetID targetID)
{
if (step == null)
return false;
List<PlacementRequirement> requirements = step.GetRequirements();
if (requirements == null)
return false;
foreach (var requirement in requirements)
{
if (requirement == null)
continue;
if (requirement.interactableID == interactableID &&
requirement.targetID == targetID)
{
return requirement.requiredState == RequirementState.Hammered;
}
}
return false;
}
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.
14. 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.
15. Reevaluate Steps and Requirements from the Current Scene State
Code Block: Reevaluate steps and requirements from the current scene state
// Re-checks every step against the current placed interactables list.
private void ReevaluateAllSteps()
{
foreach (var step in steps)
ReevaluateStep(step);
}
// Re-checks one step and updates progress events if its completion changed.
private void ReevaluateStep(StepData step)
{
if (step == null || !stepCompletion.ContainsKey(step))
return;
bool oldComplete = stepCompletion[step];
bool newComplete = IsStepSatisfied(step);
stepCompletion[step] = newComplete;
UpdateFreePlayUI();
if (oldComplete != newComplete)
OnStepProgressChanged?.Invoke();
}
// Returns true only if every requirement on the step is currently satisfied.
private bool IsStepSatisfied(StepData step)
{
if (step == null)
return false;
List<PlacementRequirement> requirements = step.GetRequirements();
if (requirements == null || requirements.Count == 0)
return false;
foreach (var requirement in requirements)
{
if (!IsRequirementSatisfied(requirement))
return false;
}
return true;
}
// Checks whether the current scene state satisfies one requirement entry.
private bool IsRequirementSatisfied(PlacementRequirement requirement)
{
if (requirement == null)
return false;
int count = 0;
foreach (var interactable in placedInteractables)
{
if (interactable == null || interactable.data == null)
continue;
if (!interactable.placedCorrectly)
continue;
if (interactable.data.id != requirement.interactableID)
continue;
if (interactable.lastPlacedTargetID != requirement.targetID)
continue;
if (requirement.requiredState == RequirementState.Hammered)
{
StepData placeStep = steps.FirstOrDefault(step =>
step != null &&
step.GetRequirements().Any(r =>
r != null &&
r.interactableID == requirement.interactableID &&
r.targetID == requirement.targetID &&
r.requiredState == RequirementState.Placed));
if (placeStep != null && !IsStepMarkedComplete(placeStep))
continue;
if (!interactable.isHammered)
continue;
}
if (requirement.requiredState == RequirementState.Hammered && !interactable.isHammered)
continue;
count++;
}
return count >= Mathf.Max(1, requirement.requiredCount);
}
Why this is core:
This is the actual step completion engine. It reevaluates all steps or one step at a time, checks whether all requirements on a step are satisfied, and counts matching interactables in the current placed list while also handling the special case where hammered requirements depend on a prior placed step being complete first.
16. Lookup Helpers Used by Validation and Auto-Place
Code Block: Lookup helpers used by validation and auto-place
// Finds the first step in the module that uses this interactable/target pair.
private StepData FindFirstStepUsingPair(InteractableID interactableID, TargetID targetID)
{
return steps.FirstOrDefault(step => step != null && step.IsMatch(interactableID, targetID));
}
// Finds a target in the current scene by its TargetID.
private Target FindTarget(TargetID targetID)
{
return allTargets
.FirstOrDefault(target => target != null && target.targetID == targetID);
}
// Finds an available unplaced interactable of the requested type.
private DraggableInteractable FindAvailableInteractable(InteractableID interactableID)
{
if (interactionManager == null)
return null;
foreach (ResetCube cube in interactionManager.resetCubeList)
{
if (cube == null || cube.interactableTypeList == null)
continue;
foreach (DraggableInteractable interactable in cube.interactableTypeList)
{
if (interactable == null || interactable.data == null)
continue;
if (interactable.data.id == interactableID && !interactable.placedCorrectly)
return interactable;
}
}
return null;
}
Why this is core:
These helpers support the rest of the step system by finding which step uses a given interactable/target pair, finding scene targets by TargetID, and locating an available unplaced interactable for auto-place behavior.
Website-Ready Summary
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.
SimpleScoreManager.cs
This is the core runtime data holder for the timer and final-results summary. 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 SimpleScoreManager : MonoBehaviour
{
// Singleton so other systems can easily access the active score manager.
public static SimpleScoreManager 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 SimpleScoreUI 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.
5. Final Summary and Time Formatting
Code Block: Final summary and time formatting
public string GetFinalTimeText()
{
// Return final timer value in formatted mm:ss:hh style.
return FormatTime(elapsedTime);
}
public string GetFinalSummary()
{
StringBuilder sb = new StringBuilder();
// Build the text block shown on the final results screen.
sb.AppendLine("BUILD RESULTS");
sb.AppendLine("-------------");
sb.AppendLine($"Mode: {currentModeName}");
sb.AppendLine($"Timer Used: {(timerWasUsed ? "Yes" : "No")}");
sb.AppendLine($"Elapsed Time: {FormatTime(elapsedTime)}");
sb.AppendLine();
if (showCompletedStepDescriptions)
{
if (completedStepNames.Count == 0)
{
sb.AppendLine("Steps completed while timer was running:");
sb.AppendLine("None");
}
else
{
sb.AppendLine("Steps completed while timer was running:");
for (int i = 0; i < completedStepNames.Count; i++)
sb.AppendLine($"{i + 1}. {completedStepNames[i]}");
}
}
else
{
sb.AppendLine("Steps completed while timer was running:");
sb.AppendLine($"{completedStepNames.Count}/{totalStepsInMode}");
}
return sb.ToString();
}
public static string FormatTime(float time)
{
// Convert seconds into mm:ss:hh format.
int minutes = Mathf.FloorToInt(time / 60f);
int seconds = Mathf.FloorToInt(time % 60f);
int hundredths = Mathf.FloorToInt((time * 100f) % 100f);
return $"{minutes:00}:{seconds:00}:{hundredths:00}";
}
public void AddScore(int amount)
{
// Placeholder for future score logic if you expand beyond timer tracking.
}
}
Why this is core:
This generates the final output shown to the player. It formats the elapsed time, builds the summary text block, optionally lists completed step descriptions, and includes a placeholder hook for future score expansion.
Website-Ready Summary
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.
SimpleScoreObserver.cs
This script watches the StepManager and reports step-completion progress into the score manager. It acts as the bridge between progression and the timer and results system.
1. References and Cached Completion State
Code Block: References and cached completion state
public class SimpleScoreObserver : MonoBehaviour
{
[Header("References")]
public StepManager stepManager;
private StepManager.PlayMode lastMode;
private readonly Dictionary<StepData, bool> cachedCompletion = new Dictionary<StepData, bool>();
Why this is core:
This stores the StepManager reference, the last observed play mode, and a cached completion map so the observer can detect when a step changes from incomplete to complete.
2. Initial Setup for Mode and Step Count
Code Block: Initial setup for mode and step count
private void Start()
{
if (stepManager == null || SimpleScoreManager.Instance == null)
return;
lastMode = stepManager.currentMode;
SimpleScoreManager.Instance.ResetForMode(lastMode.ToString());
SimpleScoreManager.Instance.SetTotalSteps(stepManager.steps != null ? stepManager.steps.Count : 0);
CacheCurrentStepCompletion();
}
Why this is core:
This initializes the score manager for the current mode at the start of the session and captures the current completion state of all steps so later changes can be detected cleanly.
3. Watch for Mode Changes and Newly Completed Steps
Code Block: Watch for mode changes and newly completed steps
private void Update()
{
if (stepManager == null || SimpleScoreManager.Instance == null)
return;
WatchModeChange();
WatchCompletedSteps();
}
private void WatchModeChange()
{
if (stepManager.currentMode != lastMode)
{
lastMode = stepManager.currentMode;
SimpleScoreManager.Instance.ResetForMode(lastMode.ToString());
SimpleScoreManager.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)
SimpleScoreManager.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.
4. 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.
Website-Ready Summary
Summary:
SimpleScoreObserver 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.
SimpleScoreTimerButton.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. Timer Display and Image References
Code Block: Timer display and image references
public class SimpleScoreTimerButton : HybridClickableBase
{
[Header("Optional Test Display")]
[SerializeField] private TMP_Text timerText;
[SerializeField] private string offPrefix = "Timer: OFF ";
[SerializeField] private string onPrefix = "Timer: ON ";
[Header("Optional Image Material")]
[SerializeField] private Image targetImage;
[SerializeField] private Material offImageMaterial;
[SerializeField] private Material onImageMaterial;
Why this is core:
This block stores the references and formatting needed to show the timer’s current state in text and image form.
2. Subscribe to Timer Updates and Refresh Visuals
Code Block: Subscribe to timer updates and refresh visuals
private void OnEnable()
{
if (SimpleScoreManager.Instance != null)
SimpleScoreManager.Instance.TimerUpdated += RefreshVisuals;
RefreshVisuals();
}
private void OnDisable()
{
if (SimpleScoreManager.Instance != null)
SimpleScoreManager.Instance.TimerUpdated -= RefreshVisuals;
}
private void Update()
{
if (SimpleScoreManager.Instance == null)
return;
if (!SimpleScoreManager.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.
3. Click Action and Visual Refresh
Code Block: Click action and visual refresh
protected override void HandleClick()
{
if (SimpleScoreManager.Instance == null)
return;
SimpleScoreManager.Instance.ToggleTimer();
RefreshVisuals();
}
private void RefreshVisuals()
{
RefreshTimerText();
RefreshImageMaterial();
}
private void RefreshTimerText()
{
if (timerText == null || SimpleScoreManager.Instance == null)
return;
timerText.text = SimpleScoreManager.Instance.TimerRunning
? onPrefix + SimpleScoreManager.FormatTime(SimpleScoreManager.Instance.ElapsedTime)
: offPrefix + SimpleScoreManager.FormatTime(SimpleScoreManager.Instance.ElapsedTime);
}
private void RefreshImageMaterial()
{
if (targetImage == null || SimpleScoreManager.Instance == null)
return;
targetImage.material = SimpleScoreManager.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.
Website-Ready Summary
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.
SimpleScoreUI.cs
This script is the final-results display layer. It reads the summary and time text from SimpleScoreManager and shows them on the final panel.
1. Final Results UI References
Code Block: Final results UI references
public class SimpleScoreUI : MonoBehaviour
{
[Header("Final Panel")]
public GameObject finalPanel;
public TMP_Text finalStatsText;
public TMP_Text finalTimeText;
Why this is core:
This block stores the UI objects used to display the final results panel, the summary text, and the final formatted time.
2. Show Final Results
Code Block: Show final results
public void ShowFinal()
{
Debug.Log("SimpleScoreUI.ShowFinal called");
if (SimpleScoreManager.Instance == null)
{
Debug.LogWarning("SimpleScoreUI: SimpleScoreManager.Instance is null");
return;
}
if (finalPanel != null)
{
finalPanel.SetActive(true);
}
else
{
Debug.LogWarning("SimpleScoreUI: finalPanel is null");
}
if (finalStatsText != null)
finalStatsText.text = SimpleScoreManager.Instance.GetFinalSummary();
else
Debug.LogWarning("SimpleScoreUI: finalStatsText is null");
if (finalTimeText != null)
finalTimeText.text = SimpleScoreManager.Instance.GetFinalTimeText();
else
Debug.LogWarning("SimpleScoreUI: finalTimeText is null");
}
Why this is core:
This is the main UI display action. It opens the final panel and fills in the final summary and time fields using the current data from the score manager.
3. Hide Final Results
Code Block: Hide final results
public void HideFinal()
{
if (finalPanel != null)
finalPanel.SetActive(false);
}
}
Why this is core:
This is the matching close and hide behavior for the final results panel.
Website-Ready Summary
Summary:
SimpleScoreUI is the display layer for final session results. It shows the final results panel and fills it with the summary and final time provided by the score manager.
© Copyright David Phillips Game Producer