Trash maze MVP (#79)

Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com>
Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #79
This commit is contained in:
2025-12-10 11:14:10 +00:00
parent 4f8c8ff563
commit 082ce98f79
58 changed files with 4624 additions and 1502 deletions

View File

@@ -452,7 +452,6 @@ namespace UI.CardSystem
if (cardData == null) return null;
var allSlots = FindObjectsByType<AlbumCardSlot>(FindObjectsSortMode.None);
foreach (var slot in allSlots)
{
if (slot.TargetCardDefinition != null &&

View File

@@ -182,7 +182,7 @@ namespace Core
// Register settings with service locator
if (playerSettings != null)
{
ServiceLocator.Register<IPlayerFollowerSettings>(playerSettings);
ServiceLocator.Register<IPlayerMovementConfigs>(playerSettings);
Logging.Debug("PlayerFollowerSettings registered successfully");
}
else

View File

@@ -3,37 +3,79 @@
namespace AppleHills.Core.Settings
{
/// <summary>
/// Settings related to player and follower behavior
/// Settings related to player and follower behavior.
/// Implements IPlayerMovementConfigs to provide separate configurations for different movement contexts.
/// </summary>
[CreateAssetMenu(fileName = "PlayerFollowerSettings", menuName = "AppleHills/Settings/Player & Follower", order = 1)]
public class PlayerFollowerSettings : BaseSettings, IPlayerFollowerSettings
public class PlayerFollowerSettings : BaseSettings, IPlayerMovementConfigs
{
[Header("Default Player Movement (Overworld)")]
[SerializeField] private PlayerMovementSettingsData defaultPlayerMovement = new PlayerMovementSettingsData();
[Header("Trash Maze - Pulver Movement")]
[SerializeField] private PlayerMovementSettingsData trashMazeMovement = new PlayerMovementSettingsData();
[Header("Follower Settings")]
[SerializeField] private FollowerSettingsData followerMovement = new FollowerSettingsData();
// IPlayerMovementConfigs implementation
public IPlayerMovementSettings DefaultPlayerMovement => defaultPlayerMovement;
public IPlayerMovementSettings TrashMazeMovement => trashMazeMovement;
public IFollowerSettings FollowerMovement => followerMovement;
public override void OnValidate()
{
base.OnValidate();
defaultPlayerMovement?.Validate();
trashMazeMovement?.Validate();
followerMovement?.Validate();
}
}
/// <summary>
/// Serializable data for player movement settings
/// </summary>
[System.Serializable]
public class PlayerMovementSettingsData : IPlayerMovementSettings
{
[Header("Player Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float moveAcceleration = 10000f;
[SerializeField] private float maxAcceleration = 10000f;
[SerializeField] private float stopDistance = 0.1f;
[SerializeField] private bool useRigidbody = true;
[SerializeField] private HoldMovementMode defaultHoldMovementMode = HoldMovementMode.Pathfinding;
[Header("Follower Settings")]
public float MoveSpeed => moveSpeed;
public float MaxAcceleration => maxAcceleration;
public float StopDistance => stopDistance;
public bool UseRigidbody => useRigidbody;
public HoldMovementMode DefaultHoldMovementMode => defaultHoldMovementMode;
public void Validate()
{
moveSpeed = Mathf.Max(0.1f, moveSpeed);
maxAcceleration = Mathf.Max(0.1f, maxAcceleration);
stopDistance = Mathf.Max(0.01f, stopDistance);
}
}
/// <summary>
/// Serializable data for follower settings
/// </summary>
[System.Serializable]
public class FollowerSettingsData : IFollowerSettings
{
[SerializeField] private float followDistance = 1.5f;
[SerializeField] private float manualMoveSmooth = 8f;
[SerializeField] private float thresholdFar = 2.5f;
[SerializeField] private float thresholdNear = 0.5f;
[SerializeField] private float stopThreshold = 0.1f;
[Header("Backend Settings")]
[Tooltip("Technical parameters, not for design tuning")]
[SerializeField] private float followUpdateInterval = 0.1f;
[SerializeField] private float followerSpeedMultiplier = 1.2f;
[SerializeField] private float heldIconDisplayHeight = 2.0f;
[Header("Trash Maze Vision")]
[SerializeField] private float trashMazeVisionRadius = 8f;
// IPlayerFollowerSettings implementation
public float MoveSpeed => moveSpeed;
public float MaxAcceleration => moveAcceleration;
public float StopDistance => stopDistance;
public bool UseRigidbody => useRigidbody;
public HoldMovementMode DefaultHoldMovementMode => defaultHoldMovementMode;
public float FollowDistance => followDistance;
public float ManualMoveSmooth => manualMoveSmooth;
public float ThresholdFar => thresholdFar;
@@ -42,14 +84,19 @@ namespace AppleHills.Core.Settings
public float FollowUpdateInterval => followUpdateInterval;
public float FollowerSpeedMultiplier => followerSpeedMultiplier;
public float HeldIconDisplayHeight => heldIconDisplayHeight;
public override void OnValidate()
public float TrashMazeVisionRadius => trashMazeVisionRadius;
public void Validate()
{
base.OnValidate();
// Validate values
moveSpeed = Mathf.Max(0.1f, moveSpeed);
followDistance = Mathf.Max(0.1f, followDistance);
manualMoveSmooth = Mathf.Max(0.1f, manualMoveSmooth);
thresholdFar = Mathf.Max(0.1f, thresholdFar);
thresholdNear = Mathf.Max(0.01f, thresholdNear);
stopThreshold = Mathf.Max(0.01f, stopThreshold);
followUpdateInterval = Mathf.Max(0.01f, followUpdateInterval);
followerSpeedMultiplier = Mathf.Max(0.1f, followerSpeedMultiplier);
heldIconDisplayHeight = Mathf.Max(0f, heldIconDisplayHeight);
trashMazeVisionRadius = Mathf.Max(1f, trashMazeVisionRadius);
}
}
}

View File

@@ -14,18 +14,33 @@ namespace AppleHills.Core.Settings
}
/// <summary>
/// Interface for player and follower settings
/// Interface for player movement settings (used by all player controllers)
/// </summary>
public interface IPlayerFollowerSettings
public interface IPlayerMovementSettings
{
// Player settings
float MoveSpeed { get; }
float MaxAcceleration { get; } // Added new property for player acceleration
float MaxAcceleration { get; }
float StopDistance { get; }
bool UseRigidbody { get; }
HoldMovementMode DefaultHoldMovementMode { get; }
// Follower settings
}
/// <summary>
/// Container interface that holds multiple player movement configurations.
/// Allows different controllers to use the same base settings interface with different values.
/// </summary>
public interface IPlayerMovementConfigs
{
IPlayerMovementSettings DefaultPlayerMovement { get; }
IPlayerMovementSettings TrashMazeMovement { get; }
IFollowerSettings FollowerMovement { get; }
}
/// <summary>
/// Interface for follower-specific settings (completely separate from player movement)
/// </summary>
public interface IFollowerSettings
{
float FollowDistance { get; }
float ManualMoveSmooth { get; }
float ThresholdFar { get; }
@@ -34,6 +49,7 @@ namespace AppleHills.Core.Settings
float FollowUpdateInterval { get; }
float FollowerSpeedMultiplier { get; }
float HeldIconDisplayHeight { get; }
float TrashMazeVisionRadius { get; }
}
/// <summary>

View File

@@ -0,0 +1,330 @@
using UnityEngine;
using Pathfinding;
using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
namespace Input
{
/// <summary>
/// Base class for player movement controllers.
/// Handles tap-to-move and hold-to-move input with pathfinding or direct movement.
/// Derived classes can override to add specialized behavior (e.g., shader updates).
/// </summary>
public abstract class BasePlayerMovementController : ManagedBehaviour, ITouchInputConsumer
{
[Header("Movement")]
[SerializeField] protected float moveSpeed = 5f;
[Header("Collision Simulation")]
[SerializeField] protected LayerMask obstacleMask;
[SerializeField] protected float colliderRadius = 0.5f;
// Movement state
protected Vector3 _targetPosition;
protected Vector3 _directMoveVelocity;
protected bool _isHolding;
protected Vector2 _lastHoldPosition;
protected Coroutine _pathfindingDragCoroutine;
protected float _pathfindingDragUpdateInterval = 0.1f;
// Settings reference (populated by derived classes in LoadSettings)
protected IPlayerMovementSettings _movementSettings;
protected IPlayerMovementSettings Settings => _movementSettings;
// Abstract method for derived classes to load their specific settings
protected abstract void LoadSettings();
// Movement tracking
protected bool _isMoving;
public bool IsMoving => _isMoving;
public bool IsHolding => _isHolding;
public event System.Action OnMovementStarted;
public event System.Action OnMovementStopped;
// Components
protected AIPath _aiPath;
protected Animator _animator;
protected Transform _artTransform;
protected SpriteRenderer _spriteRenderer;
// Animation tracking
protected Vector3 _lastDirectMoveDir = Vector3.right;
public Vector3 LastDirectMoveDir => _lastDirectMoveDir;
protected float _lastDirX;
protected float _lastDirY = -1f;
protected LogVerbosity _logVerbosity = LogVerbosity.Warning;
internal override void OnManagedAwake()
{
base.OnManagedAwake();
LoadSettings(); // Let derived class load appropriate settings
InitializeComponents();
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Register with InputManager
if (InputManager.Instance != null)
{
InputManager.Instance.SetDefaultConsumer(this);
Logging.Debug($"[{GetType().Name}] Registered as default input consumer");
}
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().inputLogVerbosity;
}
protected virtual void InitializeComponents()
{
_aiPath = GetComponent<AIPath>();
_artTransform = transform.Find("CharacterArt");
if (_artTransform != null)
_animator = _artTransform.GetComponent<Animator>();
else
_animator = GetComponentInChildren<Animator>();
if (_artTransform != null)
_spriteRenderer = _artTransform.GetComponent<SpriteRenderer>();
if (_spriteRenderer == null)
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
}
protected virtual void Update()
{
UpdateMovementState();
UpdateAnimation();
}
#region ITouchInputConsumer Implementation
public virtual void OnTap(Vector2 worldPosition)
{
Logging.Debug($"[{GetType().Name}] OnTap at {worldPosition}");
if (_aiPath != null)
{
_aiPath.enabled = true;
_aiPath.canMove = true;
_aiPath.isStopped = false;
SetTargetPosition(worldPosition);
_directMoveVelocity = Vector3.zero;
_isHolding = false;
}
}
public virtual void OnHoldStart(Vector2 worldPosition)
{
Logging.Debug($"[{GetType().Name}] OnHoldStart at {worldPosition}");
_lastHoldPosition = worldPosition;
_isHolding = true;
if (Settings.DefaultHoldMovementMode == HoldMovementMode.Pathfinding && _aiPath != null)
{
_aiPath.enabled = true;
if (_pathfindingDragCoroutine != null) StopCoroutine(_pathfindingDragCoroutine);
_pathfindingDragCoroutine = StartCoroutine(PathfindingDragUpdateCoroutine());
}
else // Direct movement
{
if (_aiPath != null) _aiPath.enabled = false;
_directMoveVelocity = Vector3.zero;
}
}
public virtual void OnHoldUpdate(Vector2 worldPosition)
{
if (!_isHolding) return;
_lastHoldPosition = worldPosition;
if (Settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
if (_aiPath != null && _aiPath.enabled) _aiPath.enabled = false;
MoveDirectlyTo(worldPosition);
}
}
public virtual void OnHoldMove(Vector2 worldPosition)
{
// Alias for OnHoldUpdate for interface compatibility
OnHoldUpdate(worldPosition);
}
public virtual void OnHoldEnd(Vector2 worldPosition)
{
Logging.Debug($"[{GetType().Name}] OnHoldEnd at {worldPosition}");
_isHolding = false;
_directMoveVelocity = Vector3.zero;
if (_aiPath != null && Settings.DefaultHoldMovementMode == HoldMovementMode.Pathfinding)
{
if (_pathfindingDragCoroutine != null)
{
StopCoroutine(_pathfindingDragCoroutine);
_pathfindingDragCoroutine = null;
}
}
if (_aiPath != null && Settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
_aiPath.enabled = false;
}
}
#endregion
#region Movement Methods
protected virtual void SetTargetPosition(Vector2 worldPosition)
{
if (_aiPath != null)
{
_aiPath.destination = worldPosition;
_aiPath.maxSpeed = Settings.MoveSpeed;
_aiPath.maxAcceleration = Settings.MaxAcceleration;
_aiPath.canMove = true;
_aiPath.isStopped = false;
}
}
protected virtual void MoveDirectlyTo(Vector2 worldPosition)
{
if (_aiPath == null) return;
Vector3 current = transform.position;
Vector3 target = new Vector3(worldPosition.x, worldPosition.y, current.z);
Vector3 toTarget = (target - current);
Vector3 direction = toTarget.normalized;
float maxSpeed = Settings.MoveSpeed;
float acceleration = Settings.MaxAcceleration;
_directMoveVelocity = Vector3.MoveTowards(_directMoveVelocity, direction * maxSpeed, acceleration * Time.deltaTime);
if (_directMoveVelocity.magnitude > maxSpeed)
{
_directMoveVelocity = _directMoveVelocity.normalized * maxSpeed;
}
Vector3 move = _directMoveVelocity * Time.deltaTime;
if (move.magnitude > toTarget.magnitude)
{
move = toTarget;
}
// Collision simulation
Vector3 adjustedVelocity = AdjustVelocityForObstacles(current, _directMoveVelocity);
Vector3 adjustedMove = adjustedVelocity * Time.deltaTime;
if (adjustedMove.magnitude > toTarget.magnitude)
{
adjustedMove = toTarget;
}
transform.position += adjustedMove;
_lastDirectMoveDir = _directMoveVelocity.normalized;
}
protected virtual Vector3 AdjustVelocityForObstacles(Vector3 position, Vector3 velocity)
{
if (velocity.sqrMagnitude < 0.0001f)
return velocity;
float moveDistance = velocity.magnitude * Time.deltaTime;
Vector2 origin = new Vector2(position.x, position.y);
Vector2 dir = velocity.normalized;
float rayLength = colliderRadius + moveDistance;
RaycastHit2D hit = Physics2D.Raycast(origin, dir, rayLength, obstacleMask);
if (hit.collider != null)
{
Vector2 tangent = new Vector2(-hit.normal.y, hit.normal.x);
float slideAmount = Vector2.Dot(velocity, tangent);
Vector3 slideVelocity = tangent * slideAmount;
return slideVelocity;
}
return velocity;
}
protected virtual System.Collections.IEnumerator PathfindingDragUpdateCoroutine()
{
while (_isHolding && _aiPath != null)
{
SetTargetPosition(_lastHoldPosition);
yield return new WaitForSeconds(_pathfindingDragUpdateInterval);
}
}
#endregion
#region State and Animation
protected virtual void UpdateMovementState()
{
bool isCurrentlyMoving = false;
if (_isHolding && Settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
isCurrentlyMoving = _directMoveVelocity.sqrMagnitude > 0.001f;
}
else if (_aiPath != null && _aiPath.enabled)
{
isCurrentlyMoving = _aiPath.velocity.sqrMagnitude > 0.001f;
}
if (isCurrentlyMoving && !_isMoving)
{
_isMoving = true;
OnMovementStarted?.Invoke();
Logging.Debug($"[{GetType().Name}] Movement started");
}
else if (!isCurrentlyMoving && _isMoving)
{
_isMoving = false;
OnMovementStopped?.Invoke();
Logging.Debug($"[{GetType().Name}] Movement stopped");
}
}
protected virtual void UpdateAnimation()
{
if (_animator == null || _aiPath == null) return;
float normalizedSpeed = 0f;
Vector3 velocity = Vector3.zero;
float maxSpeed = Settings.MoveSpeed;
if (_isHolding && Settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
normalizedSpeed = _directMoveVelocity.magnitude / maxSpeed;
velocity = _directMoveVelocity;
}
else if (_aiPath.enabled)
{
normalizedSpeed = _aiPath.velocity.magnitude / maxSpeed;
velocity = _aiPath.velocity;
}
_animator.SetFloat("Speed", Mathf.Clamp01(normalizedSpeed));
if (velocity.sqrMagnitude > 0.01f)
{
Vector3 normalizedVelocity = velocity.normalized;
_lastDirX = normalizedVelocity.x;
_lastDirY = normalizedVelocity.y;
_animator.SetFloat("DirX", _lastDirX);
_animator.SetFloat("DirY", _lastDirY);
}
else
{
_animator.SetFloat("DirX", _lastDirX);
_animator.SetFloat("DirY", _lastDirY);
}
}
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 059db1587c2c42389f98a1d3a52bec4b
timeCreated: 1765206060

View File

@@ -1,8 +1,6 @@
using UnityEngine;
using Pathfinding;
using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
namespace Input
{
@@ -19,341 +17,43 @@ namespace Input
/// <summary>
/// Handles player movement in response to tap and hold input events.
/// Supports both direct and pathfinding movement modes, and provides event/callbacks for arrival/cancellation.
/// Extends BasePlayerMovementController with save/load and MoveToAndNotify functionality.
/// </summary>
public class PlayerTouchController : ManagedBehaviour, ITouchInputConsumer
public class PlayerTouchController : BasePlayerMovementController
{
// --- Movement State ---
private Vector3 targetPosition;
private Vector3 directMoveVelocity; // default is Vector3.zero
internal bool isHolding;
private Vector2 lastHoldPosition;
private Coroutine pathfindingDragCoroutine;
private float pathfindingDragUpdateInterval = 0.1f; // Interval in seconds
[Header("Collision Simulation")]
public LayerMask obstacleMask;
public float colliderRadius = 0.5f;
// --- Settings Reference ---
private IPlayerFollowerSettings _settings;
// --- Movement Events ---
private bool _isMoving = false;
public bool IsMoving => _isMoving;
public event System.Action OnMovementStarted;
public event System.Action OnMovementStopped;
// --- Unity/Component References ---
private AIPath aiPath;
// Note: String-based property lookup is flagged as inefficient, but is common in Unity for dynamic children.
private Animator animator;
private Transform artTransform;
private SpriteRenderer spriteRenderer;
// --- Last direct movement direction ---
private Vector3 _lastDirectMoveDir = Vector3.right;
public Vector3 LastDirectMoveDir => _lastDirectMoveDir;
// --- Last movement directions for animation blend tree ---
private float _lastDirX = 0f; // -1 (left) to 1 (right)
private float _lastDirY = -1f; // -1 (down) to 1 (up)
// --- MoveToAndNotify State ---
// --- PlayerTouchController-specific features (MoveToAndNotify) ---
public delegate void ArrivedAtTargetHandler();
private Coroutine moveToCoroutine;
private Coroutine _moveToCoroutine;
public event ArrivedAtTargetHandler OnArrivedAtTarget;
public event System.Action OnMoveToCancelled;
private bool interruptMoveTo;
private LogVerbosity _logVerbosity = LogVerbosity.Warning;
private bool _interruptMoveTo;
// Save system configuration
public override bool AutoRegisterForSave => true;
// Scene-specific SaveId - each level has its own player state
public override string SaveId => $"{gameObject.scene.name}/PlayerController";
internal override void OnManagedStart()
protected override void LoadSettings()
{
aiPath = GetComponent<AIPath>();
artTransform = transform.Find("CharacterArt");
if (artTransform != null)
animator = artTransform.GetComponent<Animator>();
else
animator = GetComponentInChildren<Animator>();
// Cache SpriteRenderer for flipping
if (artTransform != null)
spriteRenderer = artTransform.GetComponent<SpriteRenderer>();
if (spriteRenderer == null)
spriteRenderer = GetComponentInChildren<SpriteRenderer>();
// Initialize settings reference using GetSettingsObject
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
// Set default input consumer
InputManager.Instance?.SetDefaultConsumer(this);
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().inputLogVerbosity;
var configs = GameManager.GetSettingsObject<IPlayerMovementConfigs>();
_movementSettings = configs.DefaultPlayerMovement;
}
/// <summary>
/// Handles tap input. Always uses pathfinding to move to the tapped location.
/// Cancels any in-progress MoveToAndNotify.
/// </summary>
public void OnTap(Vector2 worldPosition)
#region ITouchInputConsumer Overrides (Add InterruptMoveTo)
public override void OnTap(Vector2 worldPosition)
{
InterruptMoveTo();
Logging.Debug($"OnTap at {worldPosition}");
if (aiPath != null)
{
aiPath.enabled = true;
aiPath.canMove = true;
aiPath.isStopped = false;
SetTargetPosition(worldPosition);
directMoveVelocity = Vector3.zero;
isHolding = false;
}
base.OnTap(worldPosition);
}
/// <summary>
/// Handles the start of a hold input. Begins tracking the finger and uses the correct movement mode.
/// Cancels any in-progress MoveToAndNotify.
/// </summary>
public void OnHoldStart(Vector2 worldPosition)
public override void OnHoldStart(Vector2 worldPosition)
{
InterruptMoveTo();
Logging.Debug($"OnHoldStart at {worldPosition}");
lastHoldPosition = worldPosition;
isHolding = true;
if (_settings.DefaultHoldMovementMode == HoldMovementMode.Pathfinding &&
aiPath != null)
{
aiPath.enabled = true;
if (pathfindingDragCoroutine != null) StopCoroutine(pathfindingDragCoroutine);
pathfindingDragCoroutine = StartCoroutine(PathfindingDragUpdateCoroutine());
}
else // Direct movement
{
if (aiPath != null) aiPath.enabled = false;
directMoveVelocity = Vector3.zero;
}
}
/// <summary>
/// Handles hold move input. Updates the target position for direct or pathfinding movement.
/// /// </summary>
public void OnHoldMove(Vector2 worldPosition)
{
if (!isHolding) return;
lastHoldPosition = worldPosition;
if (_settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
if (aiPath != null && aiPath.enabled) aiPath.enabled = false;
MoveDirectlyTo(worldPosition);
}
// If pathfinding, coroutine will update destination
}
/// <summary>
/// Handles the end of a hold input. Stops tracking and disables movement as needed.
/// </summary>
public void OnHoldEnd(Vector2 worldPosition)
{
Logging.Debug($"OnHoldEnd at {worldPosition}");
isHolding = false;
directMoveVelocity = Vector3.zero;
if (aiPath != null && _settings.DefaultHoldMovementMode ==
HoldMovementMode.Pathfinding)
{
if (pathfindingDragCoroutine != null)
{
StopCoroutine(pathfindingDragCoroutine);
pathfindingDragCoroutine = null;
}
}
if (aiPath != null && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
aiPath.enabled = false;
}
}
/// <summary>
/// Sets the target position for pathfinding movement.
/// </summary>
private void SetTargetPosition(Vector2 worldPosition)
{
if (aiPath != null)
{
aiPath.destination = worldPosition;
// Apply both speed and acceleration from settings
aiPath.maxSpeed = _settings.MoveSpeed;
aiPath.maxAcceleration = _settings.MaxAcceleration;
aiPath.canMove = true;
aiPath.isStopped = false;
}
}
/// <summary>
/// Moves the player directly towards the specified world position.
/// </summary>
public void MoveDirectlyTo(Vector2 worldPosition)
{
if (aiPath == null)
{
return;
}
Vector3 current = transform.position;
Vector3 target = new Vector3(worldPosition.x, worldPosition.y, current.z);
Vector3 toTarget = (target - current);
Vector3 direction = toTarget.normalized;
// Get speed and acceleration directly from settings
float maxSpeed = _settings.MoveSpeed;
float acceleration = _settings.MaxAcceleration;
directMoveVelocity = Vector3.MoveTowards(directMoveVelocity, direction * maxSpeed, acceleration * Time.deltaTime);
if (directMoveVelocity.magnitude > maxSpeed)
{
directMoveVelocity = directMoveVelocity.normalized * maxSpeed;
}
Vector3 move = directMoveVelocity * Time.deltaTime;
if (move.magnitude > toTarget.magnitude)
{
move = toTarget;
}
// --- Collision simulation ---
Vector3 adjustedVelocity = AdjustVelocityForObstacles(current, directMoveVelocity);
Vector3 adjustedMove = adjustedVelocity * Time.deltaTime;
if (adjustedMove.magnitude > toTarget.magnitude)
{
adjustedMove = toTarget;
}
transform.position += adjustedMove;
// Cache the last direct movement direction
_lastDirectMoveDir = directMoveVelocity.normalized;
}
/// <summary>
/// Simulates collision with obstacles by raycasting in the direction of velocity and projecting the velocity if a collision is detected.
/// </summary>
/// <param name="position">Player's current position.</param>
/// <param name="velocity">Intended velocity for this frame.</param>
/// <returns>Adjusted velocity after collision simulation.</returns>
private Vector3 AdjustVelocityForObstacles(Vector3 position, Vector3 velocity)
{
if (velocity.sqrMagnitude < 0.0001f)
return velocity;
float moveDistance = velocity.magnitude * Time.deltaTime;
Vector2 origin = new Vector2(position.x, position.y);
Vector2 dir = velocity.normalized;
float rayLength = colliderRadius + moveDistance;
RaycastHit2D hit = Physics2D.Raycast(origin, dir, rayLength, obstacleMask);
Debug.DrawLine(origin, origin + dir * rayLength, Color.red, 0.1f);
if (hit.collider != null)
{
// Draw normal and tangent for debug
Debug.DrawLine(hit.point, hit.point + hit.normal, Color.green, 0.2f);
Vector2 tangent = new Vector2(-hit.normal.y, hit.normal.x);
Debug.DrawLine(hit.point, hit.point + tangent, Color.blue, 0.2f);
// Project velocity onto tangent to simulate sliding
float slideAmount = Vector2.Dot(velocity, tangent);
Vector3 slideVelocity = tangent * slideAmount;
return slideVelocity;
}
return velocity;
}
void Update()
{
UpdateMovementState();
if (animator != null && aiPath != null)
{
float normalizedSpeed = 0f;
Vector3 velocity = Vector3.zero;
float maxSpeed = _settings.MoveSpeed;
if (isHolding && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
normalizedSpeed = directMoveVelocity.magnitude / maxSpeed;
velocity = directMoveVelocity;
}
else if (aiPath.enabled)
{
normalizedSpeed = aiPath.velocity.magnitude / maxSpeed;
velocity = aiPath.velocity;
}
// Set speed parameter as before
animator.SetFloat("Speed", Mathf.Clamp01(normalizedSpeed));
// Calculate and set X and Y directions for 2D blend tree
if (velocity.sqrMagnitude > 0.01f)
{
// Normalize the velocity vector to get direction
Vector3 normalizedVelocity = velocity.normalized;
// Update the stored directions when actively moving
_lastDirX = normalizedVelocity.x;
_lastDirY = normalizedVelocity.y;
// Set the animator parameters
animator.SetFloat("DirX", _lastDirX);
animator.SetFloat("DirY", _lastDirY);
}
else
{
// When not moving, keep using the last direction
animator.SetFloat("DirX", _lastDirX);
animator.SetFloat("DirY", _lastDirY);
}
}
}
/// <summary>
/// Checks if the player is currently moving and fires appropriate events when movement state changes.
/// </summary>
private void UpdateMovementState()
{
bool isCurrentlyMoving = false;
// Check direct movement
if (isHolding && _settings.DefaultHoldMovementMode == HoldMovementMode.Direct)
{
isCurrentlyMoving = directMoveVelocity.sqrMagnitude > 0.001f;
}
// Check pathfinding movement
else if (aiPath != null && aiPath.enabled)
{
isCurrentlyMoving = aiPath.velocity.sqrMagnitude > 0.001f;
}
// Fire events only when state changes
if (isCurrentlyMoving && !_isMoving)
{
_isMoving = true;
OnMovementStarted?.Invoke();
Logging.Debug("Movement started");
}
else if (!isCurrentlyMoving && _isMoving)
{
_isMoving = false;
OnMovementStopped?.Invoke();
Logging.Debug("Movement stopped");
}
}
/// <summary>
/// Coroutine for updating the AIPath destination during pathfinding hold movement.
/// </summary>
private System.Collections.IEnumerator PathfindingDragUpdateCoroutine()
{
while (isHolding && aiPath != null)
{
aiPath.destination = new Vector3(lastHoldPosition.x, lastHoldPosition.y, transform.position.z);
yield return new WaitForSeconds(pathfindingDragUpdateInterval);
}
base.OnHoldStart(worldPosition);
}
#endregion
/// <summary>
/// Moves the player to a specific target position and notifies via events when arrived or cancelled.
@@ -362,20 +62,20 @@ namespace Input
public void MoveToAndNotify(Vector3 target)
{
// Cancel any previous move-to coroutine
if (moveToCoroutine != null)
if (_moveToCoroutine != null)
{
StopCoroutine(moveToCoroutine);
StopCoroutine(_moveToCoroutine);
}
interruptMoveTo = false;
_interruptMoveTo = false;
// Ensure pathfinding is enabled for MoveToAndNotify
if (aiPath != null)
if (_aiPath != null)
{
aiPath.enabled = true;
aiPath.canMove = true;
aiPath.isStopped = false;
_aiPath.enabled = true;
_aiPath.canMove = true;
_aiPath.isStopped = false;
}
moveToCoroutine = StartCoroutine(MoveToTargetCoroutine(target));
_moveToCoroutine = StartCoroutine(MoveToTargetCoroutine(target));
}
/// <summary>
@@ -383,11 +83,11 @@ namespace Input
/// </summary>
public void InterruptMoveTo()
{
interruptMoveTo = true;
isHolding = false;
directMoveVelocity = Vector3.zero;
if (_settings.DefaultHoldMovementMode == HoldMovementMode.Direct && aiPath != null)
aiPath.enabled = false;
_interruptMoveTo = true;
_isHolding = false;
_directMoveVelocity = Vector3.zero;
if (Settings.DefaultHoldMovementMode == HoldMovementMode.Direct && _aiPath != null)
_aiPath.enabled = false;
OnMoveToCancelled?.Invoke();
}
@@ -396,19 +96,19 @@ namespace Input
/// </summary>
private System.Collections.IEnumerator MoveToTargetCoroutine(Vector3 target)
{
if (aiPath != null)
if (_aiPath != null)
{
aiPath.destination = target;
aiPath.maxSpeed = _settings.MoveSpeed;
aiPath.maxAcceleration = _settings.MaxAcceleration;
_aiPath.destination = target;
_aiPath.maxSpeed = Settings.MoveSpeed;
_aiPath.maxAcceleration = Settings.MaxAcceleration;
}
while (!interruptMoveTo)
while (!_interruptMoveTo)
{
Vector2 current2D = new Vector2(transform.position.x, transform.position.y);
Vector2 target2D = new Vector2(target.x, target.y);
float dist = Vector2.Distance(current2D, target2D);
if (dist <= _settings.StopDistance + 0.2f)
if (dist <= Settings.StopDistance + 0.2f)
{
break;
}
@@ -416,8 +116,8 @@ namespace Input
yield return null;
}
moveToCoroutine = null;
if (!interruptMoveTo)
_moveToCoroutine = null;
if (!_interruptMoveTo)
{
OnArrivedAtTarget?.Invoke();
}

View File

@@ -64,7 +64,6 @@ namespace Interactions
// Settings reference
private IInteractionSettings interactionSettings;
private IPlayerFollowerSettings playerFollowerSettings;
/// <summary>
/// Read-only access to the current slotted item state.
@@ -180,7 +179,6 @@ namespace Interactions
// Initialize settings references
interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
playerFollowerSettings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
}
#if UNITY_EDITOR
@@ -580,7 +578,8 @@ namespace Interactions
slotRenderer.sprite = slottedData.mapSprite;
// Scale sprite to desired height, preserve aspect ratio, compensate for parent scale
float desiredHeight = playerFollowerSettings?.HeldIconDisplayHeight ?? 2.0f;
var configs = GameManager.GetSettingsObject<IPlayerMovementConfigs>();
float desiredHeight = configs?.FollowerMovement?.HeldIconDisplayHeight ?? 2.0f;
var sprite = slottedData.mapSprite;
float spriteHeight = sprite.bounds.size.y;
Vector3 parentScale = slotRenderer.transform.parent != null

View File

@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
using Core;
using UnityEngine;
using Input;
using AppleHills.Core.Settings;
namespace Minigames.TrashMaze.Core
{
/// <summary>
/// Controls Pulver character movement in the Trash Maze.
/// Inherits from BasePlayerMovementController for tap-to-move and hold-to-move.
/// Updates global shader properties for vision radius system.
/// </summary>
public class PulverController : BasePlayerMovementController
{
public static PulverController Instance { get; private set; }
// Cached shader property IDs for performance
private static readonly int PlayerWorldPosID = Shader.PropertyToID("_PlayerWorldPos");
private static readonly int VisionRadiusID = Shader.PropertyToID("_VisionRadius");
// Vision radius loaded from settings
private float _visionRadius;
// Public accessors for other systems
public static Vector2 PlayerPosition => Instance != null ? Instance.transform.position : Vector2.zero;
public static float VisionRadius => Instance != null ? Instance._visionRadius : 8f;
internal override void OnManagedAwake()
{
// Singleton pattern
if (Instance != null && Instance != this)
{
Logging.Warning("[PulverController] Duplicate instance detected. Destroying duplicate.");
Destroy(gameObject);
return;
}
Instance = this;
base.OnManagedAwake();
Logging.Debug("[PulverController] Initialized");
}
protected override void LoadSettings()
{
var configs = GameManager.GetSettingsObject<IPlayerMovementConfigs>();
_movementSettings = configs.TrashMazeMovement;
// Load vision radius from follower settings
_visionRadius = configs.FollowerMovement.TrashMazeVisionRadius;
Logging.Debug($"[PulverController] Loaded vision radius from settings: {_visionRadius}");
}
protected override void Update()
{
base.Update(); // Call base for movement and animation
// Update global shader properties for vision system
UpdateShaderGlobals();
}
/// <summary>
/// Updates global shader properties used by visibility shaders
/// </summary>
private void UpdateShaderGlobals()
{
Shader.SetGlobalVector(PlayerWorldPosID, transform.position);
Shader.SetGlobalFloat(VisionRadiusID, _visionRadius);
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
if (Instance == this)
{
Instance = null;
}
}
/// <summary>
/// Set vision radius at runtime
/// </summary>
public void SetVisionRadius(float radius)
{
_visionRadius = Mathf.Max(0.1f, radius);
}
}
}

View File

@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,168 @@
using Core;
using Core.Lifecycle;
using UnityEngine;
namespace Minigames.TrashMaze.Core
{
/// <summary>
/// Main controller for the Trash Maze minigame.
/// Initializes the vision system and manages game flow.
/// </summary>
public class TrashMazeController : ManagedBehaviour
{
public static TrashMazeController Instance { get; private set; }
[Header("Player")]
[SerializeField] private PulverController pulverPrefab;
[SerializeField] private Transform startPosition;
[Header("Background")]
[Tooltip("Background sprite renderer - world size and center are inferred from its bounds")]
[SerializeField] private SpriteRenderer backgroundRenderer;
[Header("Exit")]
[SerializeField] private Transform exitPosition;
// Cached shader property IDs for performance
private static readonly int WorldSizeID = Shader.PropertyToID("_WorldSize");
private static readonly int WorldCenterID = Shader.PropertyToID("_WorldCenter");
private PulverController _pulverInstance;
private bool _mazeCompleted;
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Singleton pattern
if (Instance != null && Instance != this)
{
Logging.Warning("[TrashMazeController] Duplicate instance detected. Destroying duplicate.");
Destroy(gameObject);
return;
}
Instance = this;
}
internal override void OnManagedStart()
{
base.OnManagedStart();
Logging.Debug("[TrashMazeController] Initializing Trash Maze");
InitializeMaze();
}
private void InitializeMaze()
{
// Infer world bounds from background renderer and set shader globals
ApplyBackgroundBoundsToShader();
// Spawn player
SpawnPulver();
Logging.Debug("[TrashMazeController] Trash Maze initialized");
}
/// <summary>
/// Automatically infers world size and center from the background sprite renderer bounds
/// and applies them to shader global properties.
/// This handles scaled, rotated, or transformed backgrounds correctly.
/// </summary>
private void ApplyBackgroundBoundsToShader()
{
if (backgroundRenderer == null)
{
Logging.Error("[TrashMazeController] Background renderer not assigned! World bounds cannot be inferred.");
Logging.Warning("[TrashMazeController] Using fallback bounds: size=(100,100), center=(0,0)");
// Fallback values
Shader.SetGlobalVector(WorldSizeID, new Vector4(100f, 100f, 0f, 0f));
Shader.SetGlobalVector(WorldCenterID, new Vector4(0f, 0f, 0f, 0f));
return;
}
// Get the material instance (avoid modifying shared asset)
Material material = backgroundRenderer.material;
if (material == null)
{
Logging.Warning("[TrashMazeController] Background material missing on renderer.");
return;
}
// Use renderer.bounds (world-space, accounts for scale/rotation/parent transforms)
Bounds bounds = backgroundRenderer.bounds;
Vector3 worldSize = bounds.size;
Vector3 worldCenter = bounds.center;
// Apply to shader globals (used by both background and object shaders)
Shader.SetGlobalVector(WorldSizeID, new Vector4(worldSize.x, worldSize.y, 0f, 0f));
Shader.SetGlobalVector(WorldCenterID, new Vector4(worldCenter.x, worldCenter.y, 0f, 0f));
// Also apply directly to background material for its shader
material.SetVector(WorldSizeID, new Vector4(worldSize.x, worldSize.y, 0f, 0f));
material.SetVector(WorldCenterID, new Vector4(worldCenter.x, worldCenter.y, 0f, 0f));
Logging.Debug($"[TrashMazeController] World bounds inferred from background: " +
$"Size=({worldSize.x:F2}, {worldSize.y:F2}), Center=({worldCenter.x:F2}, {worldCenter.y:F2})");
}
private void SpawnPulver()
{
if (pulverPrefab == null)
{
Logging.Error("[TrashMazeController] Pulver prefab not assigned!");
return;
}
Vector3 spawnPosition = startPosition != null ? startPosition.position : Vector3.zero;
_pulverInstance = Instantiate(pulverPrefab, spawnPosition, Quaternion.identity);
Logging.Debug($"[TrashMazeController] Pulver spawned at {spawnPosition}");
}
/// <summary>
/// Called when player reaches the maze exit
/// </summary>
public void OnExitReached()
{
if (_mazeCompleted)
{
Logging.Debug("[TrashMazeController] Maze already completed");
return;
}
_mazeCompleted = true;
Logging.Debug("[TrashMazeController] Maze completed! Player reached exit.");
// TODO: Trigger completion events
// - Award booster packs collected
// - Open gate for Trafalgar
// - Switch control to Trafalgar
}
/// <summary>
/// Called when player collects a booster pack
/// </summary>
public void OnBoosterPackCollected()
{
Logging.Debug("[TrashMazeController] Booster pack collected");
// TODO: Integrate with card album system
// CardAlbum.AddBoosterPack();
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
if (Instance == this)
{
Instance = null;
}
}
}
}

View File

@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3a935f5e791c46df8920c2c33f1c24c0
timeCreated: 1765361215

View File

@@ -0,0 +1,99 @@
using UnityEngine;
using UnityEditor;
using Minigames.TrashMaze.Objects;
namespace Minigames.TrashMaze.Editor
{
[CustomEditor(typeof(RevealableObject))]
public class RevealableObjectEditor : UnityEditor.Editor
{
private RenderTexture _cachedStampTexture;
private Texture2D _previewTexture;
public override void OnInspectorGUI()
{
DrawDefaultInspector();
// Only show debug info in play mode
if (!Application.isPlaying)
{
return;
}
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Progressive Reveal Debug (Play Mode Only)", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("Shows the current stamp texture for Progressive reveal mode.", MessageType.Info);
RevealableObject revealableObject = (RevealableObject)target;
// Use reflection to get private _revealStampTexture field
var field = typeof(RevealableObject).GetField("_revealStampTexture",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (field != null)
{
RenderTexture stampTexture = field.GetValue(revealableObject) as RenderTexture;
if (stampTexture != null)
{
// Display stamp texture info
EditorGUILayout.LabelField("Stamp Texture:", $"{stampTexture.width}x{stampTexture.height}");
// Show preview of stamp texture
GUILayout.Label("Reveal Mask Preview (White = Revealed):");
// Create preview texture if needed
if (_cachedStampTexture != stampTexture || _previewTexture == null)
{
_cachedStampTexture = stampTexture;
if (_previewTexture != null)
{
DestroyImmediate(_previewTexture);
}
_previewTexture = new Texture2D(stampTexture.width, stampTexture.height, TextureFormat.R8, false);
_previewTexture.filterMode = FilterMode.Point;
}
// Copy RenderTexture to Texture2D for preview
RenderTexture.active = stampTexture;
_previewTexture.ReadPixels(new Rect(0, 0, stampTexture.width, stampTexture.height), 0, 0);
_previewTexture.Apply();
RenderTexture.active = null;
// Display preview with fixed size
float previewSize = 256f;
float aspectRatio = (float)stampTexture.height / stampTexture.width;
Rect previewRect = GUILayoutUtility.GetRect(previewSize, previewSize * aspectRatio);
EditorGUI.DrawPreviewTexture(previewRect, _previewTexture, null, ScaleMode.ScaleToFit);
// Auto-refresh in play mode
if (Application.isPlaying)
{
Repaint();
}
}
else
{
EditorGUILayout.HelpBox("No stamp texture found. Make sure object is in Progressive reveal mode.", MessageType.Warning);
}
}
else
{
EditorGUILayout.HelpBox("Could not access stamp texture via reflection.", MessageType.Error);
}
}
private void OnDisable()
{
// Clean up preview texture
if (_previewTexture != null)
{
DestroyImmediate(_previewTexture);
_previewTexture = null;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1d081993ee424269bf8eae99db36a54c
timeCreated: 1765361215

View File

@@ -0,0 +1,559 @@
using Core;
using Minigames.TrashMaze.Core;
using UnityEngine;
using System.Collections;
using AppleHills.Core.Settings;
namespace Minigames.TrashMaze.Objects
{
/// <summary>
/// Reveal mode for object visibility
/// </summary>
public enum RevealMode
{
Binary, // Simple on/off reveal (current system)
Progressive // Pixel-by-pixel progressive reveal with stamp texture
}
/// <summary>
/// Component for objects that need reveal memory (obstacles, booster packs, treasures).
/// Tracks if object has been revealed and updates material properties accordingly.
/// </summary>
[RequireComponent(typeof(SpriteRenderer))]
public class RevealableObject : MonoBehaviour
{
[Header("Reveal Settings")]
[SerializeField] private RevealMode revealMode = RevealMode.Binary;
[Header("Textures")]
[SerializeField] private Sprite normalSprite;
[SerializeField] private Sprite outlineSprite;
[Header("Progressive Reveal Settings")]
[SerializeField, Range(0.5f, 3f)] private float stampWorldRadius = 1.5f;
[Tooltip("Size of each stamp in world units. Smaller = more gradual reveal. Should be smaller than vision radius.")]
[Header("Object Type")]
[SerializeField] private bool isBoosterPack = false;
[SerializeField] private bool isExit = false;
private SpriteRenderer _spriteRenderer;
private Material _instanceMaterial;
private bool _hasBeenRevealed = false;
private bool _isCollected = false;
// Material property IDs (cached for performance)
private static readonly int IsRevealedID = Shader.PropertyToID("_IsRevealed");
private static readonly int IsInVisionID = Shader.PropertyToID("_IsInVision");
private static readonly int RevealMaskID = Shader.PropertyToID("_RevealMask");
// Progressive reveal system
private RenderTexture _revealStampTexture;
private Coroutine _stampCoroutine;
private Bounds _objectBounds;
private float _activationDistance;
// Binary reveal system
private Coroutine _binaryRevealCoroutine;
private void Awake()
{
_spriteRenderer = GetComponent<SpriteRenderer>();
if (_spriteRenderer.material == null)
{
Logging.Error($"[RevealableObject] No material assigned to {gameObject.name}");
return;
}
// Create instance material
_instanceMaterial = new Material(_spriteRenderer.material);
_spriteRenderer.material = _instanceMaterial;
// Call mode-specific initialization - COMPLETELY SEPARATE
if (revealMode == RevealMode.Binary)
{
InitializeBinaryMode();
}
else if (revealMode == RevealMode.Progressive)
{
InitializeProgressiveMode();
}
}
// ========================================
// BINARY MODE INITIALIZATION
// ========================================
private void InitializeBinaryMode()
{
// Validate Binary shader
string shaderName = _instanceMaterial.shader.name;
if (!shaderName.Contains("ObjectVisibility") || shaderName.Contains("Progressive"))
{
Logging.Error($"[RevealableObject] {gameObject.name} Binary mode needs shader 'TrashMaze/ObjectVisibility', currently: {shaderName}");
}
// Set initial Binary mode properties
_instanceMaterial.SetFloat(IsRevealedID, 0f);
_instanceMaterial.SetFloat(IsInVisionID, 0f);
// Set textures
if (normalSprite != null)
{
_instanceMaterial.SetTexture("_MainTex", normalSprite.texture);
}
if (outlineSprite != null)
{
_instanceMaterial.SetTexture("_OutlineTex", outlineSprite.texture);
}
Logging.Debug($"[RevealableObject] {gameObject.name} Binary mode initialized");
}
// ========================================
// PROGRESSIVE MODE INITIALIZATION
// ========================================
private void InitializeProgressiveMode()
{
// Validate Progressive shader
string shaderName = _instanceMaterial.shader.name;
if (!shaderName.Contains("ObjectVisibilityProgressive"))
{
Logging.Error($"[RevealableObject] {gameObject.name} Progressive mode needs shader 'TrashMaze/ObjectVisibilityProgressive', currently: {shaderName}");
}
// Initialize progressive reveal system
InitializeProgressiveReveal();
}
/// <summary>
/// Initialize progressive reveal system with dynamic texture sizing
/// </summary>
private void InitializeProgressiveReveal()
{
// Get object bounds for UV calculations
_objectBounds = _spriteRenderer.bounds;
// Load activation distance from settings (use vision radius from follower settings)
var configs = GameManager.GetSettingsObject<IPlayerMovementConfigs>();
_activationDistance = configs.FollowerMovement.TrashMazeVisionRadius;
Logging.Debug($"[RevealableObject] {gameObject.name} loaded activation distance from settings: {_activationDistance}");
// Dynamically determine texture size from sprite
int textureWidth = 128;
int textureHeight = 128;
if (normalSprite != null && normalSprite.texture != null)
{
// Use sprite's texture resolution (match 1:1 for pixel-perfect)
textureWidth = normalSprite.texture.width;
textureHeight = normalSprite.texture.height;
}
else
{
Logging.Warning($"[RevealableObject] {gameObject.name} in Progressive mode but normalSprite not assigned! Using default 128x128 texture. Assign Normal Sprite in inspector!");
}
// Create reveal stamp texture (R8 format = 8-bit grayscale, minimal memory)
_revealStampTexture = new RenderTexture(textureWidth, textureHeight, 0, RenderTextureFormat.R8);
_revealStampTexture.filterMode = FilterMode.Point; // Sharp edges for binary reveals
_revealStampTexture.wrapMode = TextureWrapMode.Clamp;
_revealStampTexture.Create(); // Explicitly create the texture
// Clear to black (nothing revealed initially)
RenderTexture previousActive = RenderTexture.active;
RenderTexture.active = _revealStampTexture;
GL.Clear(false, true, Color.black);
RenderTexture.active = previousActive;
Logging.Debug($"[RevealableObject] {gameObject.name} cleared reveal texture to black - should be invisible initially");
// Set Progressive shader properties
_instanceMaterial.SetTexture(RevealMaskID, _revealStampTexture);
// Progressive shader uses global _PlayerWorldPos and _VisionRadius (set by PulverController)
// No need to set _IsInVision - shader does per-pixel distance checks
// Set textures
if (normalSprite != null)
{
_instanceMaterial.SetTexture("_MainTex", normalSprite.texture);
}
if (outlineSprite != null)
{
_instanceMaterial.SetTexture("_OutlineTex", outlineSprite.texture);
}
Logging.Debug($"[RevealableObject] {gameObject.name} Progressive mode initialized: {textureWidth}x{textureHeight} reveal texture");
}
private void Start()
{
if (PulverController.Instance == null)
{
Logging.Error($"[RevealableObject] {gameObject.name} cannot start - PulverController not found!");
return;
}
// Start mode-specific runtime logic - COMPLETELY SEPARATE
if (revealMode == RevealMode.Binary)
{
StartBinaryModeTracking();
}
else if (revealMode == RevealMode.Progressive)
{
StartProgressiveModeTracking();
}
}
// ========================================
// BINARY MODE: Start tracking
// ========================================
private void StartBinaryModeTracking()
{
_binaryRevealCoroutine = StartCoroutine(BinaryRevealTrackingCoroutine());
Logging.Debug($"[RevealableObject] {gameObject.name} Binary mode tracking started");
}
// ========================================
// PROGRESSIVE MODE: Start tracking
// ========================================
private void StartProgressiveModeTracking()
{
// Subscribe to movement events for stamping
PulverController.Instance.OnMovementStarted += OnPlayerMovementStarted;
PulverController.Instance.OnMovementStopped += OnPlayerMovementStopped;
// NO vision tracking coroutine - Progressive shader does per-pixel distance checks using global _PlayerWorldPos
Logging.Debug($"[RevealableObject] {gameObject.name} Progressive mode tracking started");
}
// ========================================
// BINARY MODE: Vision-based reveal coroutine
// ========================================
/// <summary>
/// Binary mode coroutine - tracks player distance and updates vision/reveal flags
/// Runs continuously, checks every 0.1s for performance
/// </summary>
private IEnumerator BinaryRevealTrackingCoroutine()
{
while (!_isCollected && _instanceMaterial != null)
{
// Calculate distance to player
float distance = Vector2.Distance(transform.position, PulverController.PlayerPosition);
bool isInRadius = distance < PulverController.VisionRadius;
// Set real-time vision flag (controls shader color vs outline)
_instanceMaterial.SetFloat(IsInVisionID, isInRadius ? 1f : 0f);
// Set reveal flag (once revealed, stays revealed)
if (isInRadius && !_hasBeenRevealed)
{
_hasBeenRevealed = true;
_instanceMaterial.SetFloat(IsRevealedID, 1f);
Logging.Debug($"[RevealableObject] {gameObject.name} revealed!");
}
// Wait before next check (reduces CPU load)
yield return new WaitForSeconds(0.1f);
}
}
// ========================================
// PROGRESSIVE MODE: Event-based stamp reveal
// ========================================
/// <summary>
/// Called when player starts moving - begin stamping if near object
/// </summary>
private void OnPlayerMovementStarted()
{
if (_isCollected || revealMode != RevealMode.Progressive) return;
// Check if player's vision circle could overlap with object bounds
// Use closest point on bounds to check distance
Vector2 playerPos = PulverController.PlayerPosition;
Vector2 closestPoint = _objectBounds.ClosestPoint(playerPos);
float distanceToBounds = Vector2.Distance(playerPos, closestPoint);
// Start stamping if vision radius could reach the object
// Add padding to account for vision radius overlap
float activationThreshold = _activationDistance + _objectBounds.extents.magnitude;
if (distanceToBounds < activationThreshold && _stampCoroutine == null)
{
_stampCoroutine = StartCoroutine(StampRevealCoroutine());
Logging.Debug($"[RevealableObject] {gameObject.name} started stamping coroutine (distanceToBounds: {distanceToBounds:F2}, threshold: {activationThreshold:F2})");
}
}
/// <summary>
/// Called when player stops moving - stop stamping
/// </summary>
private void OnPlayerMovementStopped()
{
if (_stampCoroutine != null)
{
StopCoroutine(_stampCoroutine);
_stampCoroutine = null;
}
}
/// <summary>
/// Coroutine that stamps reveal texture while player is moving and near object
/// </summary>
private IEnumerator StampRevealCoroutine()
{
while (!_isCollected)
{
// Check if player's vision circle overlaps with object bounds
Vector2 playerPos = PulverController.PlayerPosition;
Vector2 closestPoint = _objectBounds.ClosestPoint(playerPos);
float distanceToBounds = Vector2.Distance(playerPos, closestPoint);
// Calculate activation threshold with padding
float activationThreshold = _activationDistance + _objectBounds.extents.magnitude;
// If player moved too far away, stop stamping
if (distanceToBounds >= activationThreshold)
{
Logging.Debug($"[RevealableObject] {gameObject.name} stopping stamping coroutine (too far)");
_stampCoroutine = null;
yield break;
}
// Stamp if player's vision radius reaches any part of the object
if (distanceToBounds < PulverController.VisionRadius)
{
StampPlayerPosition();
}
// Wait before next stamp (reduces GPU writes)
yield return new WaitForSeconds(0.1f);
}
}
/// <summary>
/// Stamp the player's current position onto the reveal texture
/// Direct CPU-based stamping - calculates circle-rectangle intersection in world space
/// </summary>
private void StampPlayerPosition()
{
if (_revealStampTexture == null)
{
Logging.Warning($"[RevealableObject] {gameObject.name} cannot stamp: texture is null");
return;
}
// Get player position and vision radius in world space
Vector2 playerWorldPos = PulverController.PlayerPosition;
float visionRadius = PulverController.VisionRadius;
// Get object bounds in world space
Vector2 boundsMin = _objectBounds.min;
Vector2 boundsSize = _objectBounds.size;
// Get texture dimensions
int texWidth = _revealStampTexture.width;
int texHeight = _revealStampTexture.height;
// Calculate the bounding box of the circle in world space
Vector2 circleMin = playerWorldPos - Vector2.one * visionRadius;
Vector2 circleMax = playerWorldPos + Vector2.one * visionRadius;
// Calculate intersection of circle bounding box with object bounds
Vector2 intersectMin = new Vector2(
Mathf.Max(circleMin.x, boundsMin.x),
Mathf.Max(circleMin.y, boundsMin.y)
);
Vector2 intersectMax = new Vector2(
Mathf.Min(circleMax.x, boundsMin.x + boundsSize.x),
Mathf.Min(circleMax.y, boundsMin.y + boundsSize.y)
);
// Check if there's any intersection
if (intersectMin.x >= intersectMax.x || intersectMin.y >= intersectMax.y)
{
return; // No intersection, nothing to stamp
}
// Convert world space intersection to texture pixel coordinates
int pixelMinX = Mathf.FloorToInt((intersectMin.x - boundsMin.x) / boundsSize.x * texWidth);
int pixelMaxX = Mathf.CeilToInt((intersectMax.x - boundsMin.x) / boundsSize.x * texWidth);
int pixelMinY = Mathf.FloorToInt((intersectMin.y - boundsMin.y) / boundsSize.y * texHeight);
int pixelMaxY = Mathf.CeilToInt((intersectMax.y - boundsMin.y) / boundsSize.y * texHeight);
// Clamp to texture bounds
pixelMinX = Mathf.Max(0, pixelMinX);
pixelMaxX = Mathf.Min(texWidth, pixelMaxX);
pixelMinY = Mathf.Max(0, pixelMinY);
pixelMaxY = Mathf.Min(texHeight, pixelMaxY);
// Read current texture data
RenderTexture.active = _revealStampTexture;
Texture2D tempTex = new Texture2D(texWidth, texHeight, TextureFormat.R8, false);
tempTex.ReadPixels(new Rect(0, 0, texWidth, texHeight), 0, 0);
tempTex.Apply();
// Stamp pixels within the circle
bool anyPixelStamped = false;
float radiusSquared = visionRadius * visionRadius;
for (int py = pixelMinY; py < pixelMaxY; py++)
{
for (int px = pixelMinX; px < pixelMaxX; px++)
{
// Convert pixel coordinates back to world space
float worldX = boundsMin.x + (px / (float)texWidth) * boundsSize.x;
float worldY = boundsMin.y + (py / (float)texHeight) * boundsSize.y;
// Check if this pixel is within the circle
float dx = worldX - playerWorldPos.x;
float dy = worldY - playerWorldPos.y;
float distSquared = dx * dx + dy * dy;
if (distSquared <= radiusSquared)
{
// Stamp this pixel (set to white)
tempTex.SetPixel(px, py, Color.white);
anyPixelStamped = true;
}
}
}
if (anyPixelStamped)
{
// Upload modified texture back to GPU
tempTex.Apply();
Graphics.CopyTexture(tempTex, _revealStampTexture);
Logging.Debug($"[RevealableObject] {gameObject.name} stamped pixels at world pos ({playerWorldPos.x:F2}, {playerWorldPos.y:F2}), radius {visionRadius:F2}");
}
RenderTexture.active = null;
Destroy(tempTex);
}
private void OnTriggerEnter2D(Collider2D other)
{
// Check if player is interacting
if (other.CompareTag("Player") && _hasBeenRevealed && !_isCollected)
{
HandleInteraction();
}
}
private void HandleInteraction()
{
if (isBoosterPack)
{
CollectBoosterPack();
}
else if (isExit)
{
ActivateExit();
}
}
private void CollectBoosterPack()
{
_isCollected = true;
Logging.Debug($"[RevealableObject] Booster pack collected: {gameObject.name}");
// Notify controller
if (TrashMazeController.Instance != null)
{
TrashMazeController.Instance.OnBoosterPackCollected();
}
// Destroy object
Destroy(gameObject);
}
private void ActivateExit()
{
Logging.Debug($"[RevealableObject] Exit activated: {gameObject.name}");
// Notify controller
if (TrashMazeController.Instance != null)
{
TrashMazeController.Instance.OnExitReached();
}
}
private void OnDestroy()
{
// Stop Binary mode coroutine
if (_binaryRevealCoroutine != null)
{
StopCoroutine(_binaryRevealCoroutine);
}
// Unsubscribe from Progressive mode movement events
if (revealMode == RevealMode.Progressive && PulverController.Instance != null)
{
PulverController.Instance.OnMovementStarted -= OnPlayerMovementStarted;
PulverController.Instance.OnMovementStopped -= OnPlayerMovementStopped;
}
// Stop Progressive mode stamping coroutine
if (_stampCoroutine != null)
{
StopCoroutine(_stampCoroutine);
}
// Clean up progressive reveal resources
if (_revealStampTexture != null)
{
_revealStampTexture.Release();
Destroy(_revealStampTexture);
}
// Clean up instance material
if (_instanceMaterial != null)
{
Destroy(_instanceMaterial);
}
}
/// <summary>
/// Check if object is currently visible to player
/// </summary>
public bool IsVisible()
{
float distance = Vector2.Distance(transform.position, PulverController.PlayerPosition);
return distance < PulverController.VisionRadius;
}
/// <summary>
/// Check if object has been revealed at any point
/// </summary>
public bool HasBeenRevealed()
{
return _hasBeenRevealed;
}
/// <summary>
/// Force reveal the object (for debugging or special cases)
/// </summary>
public void ForceReveal()
{
_hasBeenRevealed = true;
if (_instanceMaterial != null)
{
_instanceMaterial.SetFloat(IsRevealedID, 1f);
}
}
}
}

View File

@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -38,7 +38,7 @@ public class FollowerController : ManagedBehaviour
public float manualMoveSmooth = 8f;
// Settings reference
private IPlayerFollowerSettings _settings;
private IFollowerSettings _settings;
private IInteractionSettings _interactionSettings;
private GameObject _playerRef;
@@ -123,7 +123,8 @@ public class FollowerController : ManagedBehaviour
}
// Initialize settings references
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
var configs = GameManager.GetSettingsObject<IPlayerMovementConfigs>();
_settings = configs.FollowerMovement;
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
}
@@ -295,7 +296,7 @@ public class FollowerController : ManagedBehaviour
moveDir = _playerAIPath.velocity.normalized;
_lastMoveDir = moveDir;
}
else if (_playerTouchController != null && _playerTouchController.isHolding && _playerTouchController.LastDirectMoveDir.sqrMagnitude > 0.01f)
else if (_playerTouchController != null && _playerTouchController.IsHolding && _playerTouchController.LastDirectMoveDir.sqrMagnitude > 0.01f)
{
moveDir = _playerTouchController.LastDirectMoveDir;
_lastMoveDir = moveDir;

View File

@@ -0,0 +1,148 @@
using UnityEditor;
using UnityEngine;
namespace Utils
{
public class Measurements : MonoBehaviour
{
public enum MeasurementUnit
{
UnityUnits,
Centimeters,
Meters,
Kilometers,
Inches,
Feet,
Yards,
Miles
}
public enum MeasurementSource
{
Collider,
Renderer,
Mesh
}
public MeasurementUnit measurementUnit = MeasurementUnit.Feet;
public MeasurementSource measurementSource = MeasurementSource.Collider;
public GameObject distanceObject;
internal Vector3 Dimensions;
internal float CenterToCenter;
internal float EdgeToEdge;
void Update()
{
CalculateDimensions();
if (distanceObject != null)
{
CalculateDistances();
}
}
void CalculateDimensions()
{
Bounds bounds = new Bounds();
switch (measurementSource)
{
case MeasurementSource.Collider:
Collider objectCollider = GetComponent<Collider>();
if (objectCollider != null) bounds = objectCollider.bounds;
break;
case MeasurementSource.Renderer:
Renderer objectRenderer = GetComponent<Renderer>();
if (objectRenderer != null) bounds = objectRenderer.bounds;
break;
case MeasurementSource.Mesh:
MeshFilter meshFilter = GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null) bounds = meshFilter.mesh.bounds;
break;
}
Dimensions = ConvertToSelectedUnit(bounds.size);
}
void CalculateDistances()
{
Vector3 thisPosition = transform.position;
Vector3 otherPosition = distanceObject.transform.position;
CenterToCenter = ConvertToSelectedUnit(Vector3.Distance(thisPosition, otherPosition));
Bounds thisBounds = GetComponent<Collider>().bounds;
Bounds otherBounds = distanceObject.GetComponent<Collider>().bounds;
Vector3 closestPoint1 = thisBounds.ClosestPoint(otherPosition);
Vector3 closestPoint2 = otherBounds.ClosestPoint(thisPosition);
EdgeToEdge = ConvertToSelectedUnit(Vector3.Distance(closestPoint1, closestPoint2));
}
float ConvertToSelectedUnit(float unityUnits)
{
switch (measurementUnit)
{
case MeasurementUnit.Centimeters:
return unityUnits * 100f;
case MeasurementUnit.Meters:
return unityUnits;
case MeasurementUnit.Kilometers:
return unityUnits / 1000f;
case MeasurementUnit.Inches:
return unityUnits * 39.3701f;
case MeasurementUnit.Feet:
return unityUnits * 3.28084f;
case MeasurementUnit.Yards:
return unityUnits * 1.09361f;
case MeasurementUnit.Miles:
return unityUnits * 0.000621371f;
default:
return unityUnits;
}
}
Vector3 ConvertToSelectedUnit(Vector3 unityUnits)
{
return new Vector3(
ConvertToSelectedUnit(unityUnits.x),
ConvertToSelectedUnit(unityUnits.y),
ConvertToSelectedUnit(unityUnits.z)
);
}
}
#if UNITY_EDITOR
[CustomEditor(typeof(Measurements))]
public class MeasurementsEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
Measurements measurements = (Measurements)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Dimensions", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"Width\t\t\t<b>{measurements.Dimensions.x:F6} {measurements.measurementUnit}</b>", new GUIStyle(EditorStyles.label) { richText = true });
EditorGUILayout.LabelField($"Height\t\t\t<b>{measurements.Dimensions.y:F6} {measurements.measurementUnit}</b>", new GUIStyle(EditorStyles.label) { richText = true });
EditorGUILayout.LabelField($"Depth\t\t\t<b>{measurements.Dimensions.z:F6} {measurements.measurementUnit}</b>", new GUIStyle(EditorStyles.label) { richText = true });
if (measurements.distanceObject != null)
{
EditorGUILayout.Space();
EditorGUILayout.LabelField($"Distance to {measurements.distanceObject.name}", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"Center to Center\t\t<b>{measurements.CenterToCenter:F6} {measurements.measurementUnit}</b>", new GUIStyle(EditorStyles.label) { richText = true });
EditorGUILayout.LabelField($"Edge to Edge\t\t<b>{measurements.EdgeToEdge:F6} {measurements.measurementUnit}</b>", new GUIStyle(EditorStyles.label) { richText = true });
}
// This will update the inspector view every frame
if (Application.isPlaying)
{
Repaint();
}
}
}
#endif
}

View File

@@ -0,0 +1,18 @@
fileFormatVersion: 2
guid: efe9f7f2c8df3c0408ff67a95354f616
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
AssetOrigin:
serializedVersion: 1
productId: 294420
packageName: MeasureMaster
packageVersion: 1.0
assetPath: Assets/MeasureMaster/Scripts/Measurements.cs
uploadId: 691048