pooper_minigame (#62)

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #62
This commit is contained in:
2025-11-20 15:16:57 +00:00
parent 6ebf46fe8b
commit 058af331e0
113 changed files with 9398 additions and 1930 deletions

View File

@@ -17,6 +17,7 @@ namespace AppleHillsCamera
public enum AnchorEdge
{
Top,
Middle,
Bottom,
Left,
Right
@@ -49,6 +50,10 @@ namespace AppleHillsCamera
[Tooltip("Whether to account for this object's size in positioning")]
public bool accountForObjectSize = true;
[Header("Custom Anchor Point")]
[Tooltip("Optional: Use this child Transform's world position as the anchor point instead of calculated bounds")]
public Transform customAnchorPoint;
[Header("Visualization")]
[Tooltip("Whether to show the anchor visualization in the editor")]
public bool showVisualization = true;
@@ -302,10 +307,22 @@ namespace AppleHillsCamera
}
/// <summary>
/// Get the combined bounds of all renderers on this object and its children
/// Get the combined bounds of all renderers on this object and its children.
/// If customAnchorPoint is set, returns a zero-size bounds at the anchor point's world position.
/// </summary>
private Bounds GetObjectBounds()
{
// If custom anchor point is specified, use its world position as the bounds center
if (customAnchorPoint != null)
{
// Return zero-size bounds centered at the custom anchor point
// This makes the anchor point the exact position that will snap to the edge
Bounds customBounds = new Bounds(customAnchorPoint.position, Vector3.zero);
_objectBounds = customBounds;
return customBounds;
}
// Default behavior: calculate bounds from renderers
Bounds bounds = new Bounds(transform.position, Vector3.zero);
// Get all renderers in this object and its children
@@ -404,6 +421,8 @@ namespace AppleHillsCamera
{
case AnchorEdge.Top:
return referenceMarker.topMargin;
case AnchorEdge.Middle:
return 0f; // Middle has no margin
case AnchorEdge.Bottom:
return referenceMarker.bottomMargin;
case AnchorEdge.Left:
@@ -445,6 +464,10 @@ namespace AppleHillsCamera
// For top edge, offset is negative (moving down) by the top extent
offsetY = -extents.y - centerOffset.y;
break;
case AnchorEdge.Middle:
// For middle, no offset needed - object centers on middle
offsetY = -centerOffset.y;
break;
case AnchorEdge.Bottom:
// For bottom edge, offset is positive (moving up) by the bottom extent
offsetY = extents.y - centerOffset.y;
@@ -468,6 +491,11 @@ namespace AppleHillsCamera
newPosition.y = cameraPosition.y + cameraOrthoSize - margin + offsetY;
break;
case AnchorEdge.Middle:
// Position at the vertical center of the screen
newPosition.y = cameraPosition.y + offsetY;
break;
case AnchorEdge.Bottom:
// Position from the bottom of the screen
// When margin is 0, object's bottom edge is exactly at the bottom screen edge
@@ -518,6 +546,14 @@ namespace AppleHillsCamera
objectPosition.z
);
case AnchorEdge.Middle:
// Point at vertical center with same X coordinate as the object
return new Vector3(
objectPosition.x,
cameraPosition.y,
objectPosition.z
);
case AnchorEdge.Bottom:
// Point on bottom edge with same X coordinate as the object
return new Vector3(

View File

@@ -171,6 +171,7 @@ namespace Core
var minigameSettings = SettingsProvider.Instance.LoadSettingsSynchronous<DivingMinigameSettings>();
var cardSystemSettings = SettingsProvider.Instance.LoadSettingsSynchronous<CardSystemSettings>();
var sortingGameSettings = SettingsProvider.Instance.LoadSettingsSynchronous<CardSortingSettings>();
var birdPooperSettings = SettingsProvider.Instance.LoadSettingsSynchronous<BirdPooperSettings>();
// Register settings with service locator
if (playerSettings != null)
@@ -222,9 +223,19 @@ namespace Core
{
Debug.LogError("Failed to load CardSystemSettings");
}
if (birdPooperSettings != null)
{
ServiceLocator.Register<IBirdPooperSettings>(birdPooperSettings);
Logging.Debug("BirdPooperSettings registered successfully");
}
else
{
Debug.LogError("Failed to load BirdPooperSettings");
}
// Log success
_settingsLoaded = playerSettings != null && interactionSettings != null && minigameSettings != null && cardSystemSettings != null;
_settingsLoaded = playerSettings != null && interactionSettings != null && minigameSettings != null && cardSystemSettings != null && birdPooperSettings != null;
if (_settingsLoaded)
{
Logging.Debug("All settings loaded and registered with ServiceLocator");

View File

@@ -0,0 +1,89 @@
using AppleHills.Core.Settings;
using UnityEngine;
namespace Core.Settings
{
[CreateAssetMenu(fileName = "BirdPooperSettings", menuName = "AppleHills/Settings/BirdPooper", order = 5)]
public class BirdPooperSettings : BaseSettings, IBirdPooperSettings
{
[Header("Player Controller")]
[Tooltip("Gravity acceleration in units/s²")]
[SerializeField] private float gravity = 20f;
[Tooltip("Upward velocity applied on flap in units/s")]
[SerializeField] private float flapForce = 8f;
[Tooltip("Maximum fall speed (terminal velocity) in units/s")]
[SerializeField] private float maxFallSpeed = 15f;
[Tooltip("Minimum Y boundary")]
[SerializeField] private float minY = -5f;
[Tooltip("Maximum Y boundary")]
[SerializeField] private float maxY = 5f;
[Header("Rotation")]
[Tooltip("Maximum rotation angle in degrees (positive = up, negative = down)")]
[SerializeField] private float maxRotationAngle = 30f;
[Tooltip("Speed of rotation interpolation (higher = snappier)")]
[SerializeField] private float rotationSpeed = 8f;
[Header("Obstacles")]
[Tooltip("Obstacle scroll speed in units/s")]
[SerializeField] private float obstacleMoveSpeed = 5f;
[Tooltip("Time between obstacle spawns in seconds")]
[SerializeField] private float obstacleSpawnInterval = 2f;
[Tooltip("X position where obstacles spawn (off-screen right)")]
[SerializeField] private float obstacleSpawnXPosition = 12f;
[Tooltip("X position where obstacles are destroyed (off-screen left)")]
[SerializeField] private float obstacleDestroyXPosition = -12f;
[Tooltip("Minimum Y position for obstacle spawns")]
[SerializeField] private float obstacleMinSpawnY = -3f;
[Tooltip("Maximum Y position for obstacle spawns")]
[SerializeField] private float obstacleMaxSpawnY = 3f;
[Header("Poop Projectile")]
[Tooltip("Poop fall speed in units/s")]
[SerializeField] private float poopFallSpeed = 10f;
[Tooltip("Y position where poop is destroyed (off-screen bottom)")]
[SerializeField] private float poopDestroyYPosition = -10f;
// Interface implementation
public float Gravity => gravity;
public float FlapForce => flapForce;
public float MaxFallSpeed => maxFallSpeed;
public float MinY => minY;
public float MaxY => maxY;
public float MaxRotationAngle => maxRotationAngle;
public float RotationSpeed => rotationSpeed;
public float ObstacleMoveSpeed => obstacleMoveSpeed;
public float ObstacleSpawnInterval => obstacleSpawnInterval;
public float ObstacleSpawnXPosition => obstacleSpawnXPosition;
public float ObstacleDestroyXPosition => obstacleDestroyXPosition;
public float ObstacleMinSpawnY => obstacleMinSpawnY;
public float ObstacleMaxSpawnY => obstacleMaxSpawnY;
public float PoopFallSpeed => poopFallSpeed;
public float PoopDestroyYPosition => poopDestroyYPosition;
public override void OnValidate()
{
base.OnValidate();
// Validation logic
gravity = Mathf.Max(0f, gravity);
flapForce = Mathf.Max(0f, flapForce);
maxFallSpeed = Mathf.Max(0f, maxFallSpeed);
maxRotationAngle = Mathf.Clamp(maxRotationAngle, 0f, 90f);
rotationSpeed = Mathf.Max(0.1f, rotationSpeed);
obstacleSpawnInterval = Mathf.Max(0.1f, obstacleSpawnInterval);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e7d3f5b948b3454681fa573071bee978
timeCreated: 1763596494

View File

@@ -0,0 +1,33 @@
namespace Core.Settings
{
/// <summary>
/// Settings interface for Bird Pooper minigame.
/// Accessed via GameManager.GetSettingsObject<IBirdPooperSettings>()
/// </summary>
public interface IBirdPooperSettings
{
// Player Controller
float Gravity { get; }
float FlapForce { get; }
float MaxFallSpeed { get; }
float MinY { get; }
float MaxY { get; }
// Rotation
float MaxRotationAngle { get; }
float RotationSpeed { get; }
// Obstacles
float ObstacleMoveSpeed { get; }
float ObstacleSpawnInterval { get; }
float ObstacleSpawnXPosition { get; }
float ObstacleDestroyXPosition { get; }
float ObstacleMinSpawnY { get; }
float ObstacleMaxSpawnY { get; }
// Poop Projectile
float PoopFallSpeed { get; }
float PoopDestroyYPosition { get; }
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 66509e37e0a549a79de6fe3fa710fd63
timeCreated: 1763596482

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b74b4580a37241779a6995f59d93e304
timeCreated: 1763648961

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 504a790db6b1440aacd1b137f4850461
timeCreated: 1763648961

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 12ea065dca5840acae696a40c0cd96dc
timeCreated: 1763632462

View File

@@ -0,0 +1,33 @@
using UnityEngine;
namespace Interactions
{
/// <summary>
/// Simple glow effect component for booster pack pickups.
/// Attach to a GameObject with a SpriteRenderer for basic glow visual.
/// The BoosterPackPickup will handle the scale animation.
/// </summary>
[RequireComponent(typeof(SpriteRenderer))]
public class BoosterGlowEffect : MonoBehaviour
{
[Header("Glow Settings")]
[SerializeField] private Color glowColor = Color.yellow;
[SerializeField] private float baseAlpha = 0.5f;
private SpriteRenderer spriteRenderer;
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
// Apply glow color with alpha
Color color = glowColor;
color.a = baseAlpha;
spriteRenderer.color = color;
// Set sorting order to be behind the item
spriteRenderer.sortingOrder = -1;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f6488b58253b4e6789103e341090ca2f
timeCreated: 1763639769

View File

@@ -0,0 +1,299 @@
using UnityEngine;
using System.Collections;
using Unity.Cinemachine;
using Data.CardSystem;
using Pixelplacement;
using Core;
using UI;
namespace Interactions
{
/// <summary>
/// Special pickup that plays a fancy sequence when collected:
/// - Follower picks up normally
/// - Pauses follower movement
/// - Zooms camera in
/// - Grows/pulses the booster pack
/// - Animates to backpack icon
/// - Gives player a booster pack
/// - Restores camera and resumes movement
/// </summary>
public class BoosterPackPickup : Pickup
{
[Header("Booster Pack Sequence")]
[SerializeField] private GameObject glowVisualPrefab;
[SerializeField] private CinemachineCamera zoomCamera;
[SerializeField] private int boosterPackCount = 1;
[Header("Sequence Timing")]
[SerializeField] private float cameraBlendWait = 0.3f;
[SerializeField] private float growDuration = 0.25f;
[SerializeField] private float pulseDuration = 0.3f;
[SerializeField] private int pulseCount = 3;
[SerializeField] private float disappearDuration = 0.5f;
[Header("Animation Settings")]
[SerializeField] private float growScale = 1.5f;
[SerializeField] private float pulseScale = 1.2f;
private GameObject glowInstance;
private bool sequencePlaying;
protected override bool DoInteraction()
{
// Let the normal pickup flow happen first
bool success = base.DoInteraction();
// If pickup was successful and we're not already playing the sequence, start it
// IMPORTANT: We start the coroutine on the FollowerController, not on this object,
// because this object gets disabled immediately after pickup
if (success && !sequencePlaying)
{
var follower = FollowerController.FindInstance();
if (follower != null)
{
follower.StartCoroutine(BoosterSequence(follower));
}
else
{
Logging.Warning("[BoosterPackPickup] No follower found, cannot start sequence");
}
}
return success;
}
private IEnumerator BoosterSequence(FollowerController follower)
{
sequencePlaying = true;
// IMPORTANT: Disable all children on the original pickup GameObject
// This prevents glow effects and other child objects from appearing in Pulver's hand
foreach (Transform child in transform)
{
child.gameObject.SetActive(false);
}
// Wait one frame to ensure pickup is fully processed
yield return null;
// Verify follower still exists (safety check)
if (follower == null)
{
Logging.Warning("[BoosterPackPickup] Follower destroyed during sequence, aborting");
sequencePlaying = false;
yield break;
}
Transform heldItemTransform = follower.GetHeldItemTransform();
if (heldItemTransform == null)
{
Logging.Warning("[BoosterPackPickup] No held item transform found, skipping sequence");
sequencePlaying = false;
yield break;
}
// 1. Pause follower movement
follower.PauseMovement();
// 2. Activate zoom camera if assigned
if (zoomCamera != null)
{
// Unparent the camera from the booster pack (if it's parented)
zoomCamera.transform.SetParent(null);
// Position camera at follower's location
zoomCamera.transform.position = new Vector3(
follower.transform.position.x,
follower.transform.position.y,
zoomCamera.transform.position.z
);
// Make the blend instant by setting a custom blend hint
var brain = Camera.main?.GetComponent<CinemachineBrain>();
CinemachineBlendDefinition originalBlend = default;
if (brain != null)
{
originalBlend = brain.DefaultBlend;
brain.DefaultBlend = new CinemachineBlendDefinition(CinemachineBlendDefinition.Styles.EaseInOut, 1);
}
zoomCamera.Priority = 20;
zoomCamera.gameObject.SetActive(true);
// Wait a frame for the cut to take effect
yield return null;
// Restore original blend settings
if (brain != null)
{
brain.DefaultBlend = originalBlend;
}
}
// 3. Grow animation (no glow during this)
Vector3 originalScale = heldItemTransform.localScale;
Tween.LocalScale(heldItemTransform, originalScale * growScale, growDuration, 0f, Tween.EaseOutBack);
yield return new WaitForSeconds(growDuration);
// 4. Instantiate and pulse glow
if (glowVisualPrefab != null)
{
glowInstance = Instantiate(glowVisualPrefab, heldItemTransform);
glowInstance.transform.localPosition = Vector3.zero;
glowInstance.transform.localScale = Vector3.one;
for (int i = 0; i < pulseCount; i++)
{
Tween.LocalScale(glowInstance.transform, Vector3.one * pulseScale, pulseDuration, 0f, Tween.EaseIn);
yield return new WaitForSeconds(pulseDuration);
Tween.LocalScale(glowInstance.transform, Vector3.one, pulseDuration, 0f, Tween.EaseInOut);
yield return new WaitForSeconds(pulseDuration);
}
// Delete glow before flying to button
Destroy(glowInstance);
glowInstance = null;
}
// 5. Disappear to scrapbook button (backpack icon) - straight line movement
GameObject scrabookButton = PlayerHudManager.Instance?.GetScrabookButton();
if (scrabookButton != null)
{
// Get start position in world space
Vector3 startPos = heldItemTransform.position;
// Convert UI button position to world space
RectTransform buttonRect = scrabookButton.GetComponent<RectTransform>();
Vector3 targetWorldPos;
if (buttonRect != null)
{
// UI element - get screen position correctly
Vector3[] corners = new Vector3[4];
buttonRect.GetWorldCorners(corners);
// Get center of the button in screen space
Vector3 buttonCenter = (corners[0] + corners[2]) / 2f;
// For Screen Space Overlay, the corners are already in screen space
Canvas canvas = buttonRect.GetComponentInParent<Canvas>();
Vector3 buttonScreenPos;
if (canvas != null && canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
// Already in screen space
buttonScreenPos = buttonCenter;
}
else
{
// Convert from world to screen space
buttonScreenPos = RectTransformUtility.WorldToScreenPoint(canvas?.worldCamera ?? Camera.main, buttonCenter);
}
// Convert screen position to world space at the same Z as the booster pack
targetWorldPos = Camera.main.ScreenToWorldPoint(new Vector3(buttonScreenPos.x, buttonScreenPos.y, Mathf.Abs(Camera.main.transform.position.z - startPos.z)));
Logging.Debug($"[BoosterPackPickup] Button screen pos: {buttonScreenPos}, converted world pos: {targetWorldPos}");
}
else
{
// Not a UI element, use direct world position
targetWorldPos = scrabookButton.transform.position;
}
Logging.Debug($"[BoosterPackPickup] Flying from {startPos} to {targetWorldPos} (button: {scrabookButton.name})");
// Use custom tween with linear interpolation for straight line
float elapsed = 0f;
Vector3 startScale = heldItemTransform.localScale;
while (elapsed < disappearDuration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / disappearDuration);
// Linear interpolation for straight line movement
heldItemTransform.position = Vector3.Lerp(startPos, targetWorldPos, t);
// Ease in for scale (gets smaller as it approaches)
float scaleT = t * t; // Simple ease-in curve
heldItemTransform.localScale = Vector3.Lerp(startScale, Vector3.zero, scaleT);
yield return null;
}
// Ensure final position/scale
heldItemTransform.position = targetWorldPos;
heldItemTransform.localScale = Vector3.zero;
}
else
{
Logging.Warning("[BoosterPackPickup] No scrapbook button found from PlayerHudManager, skipping fly-to animation");
}
// 7. Give booster pack to player
if (CardSystemManager.Instance != null)
{
CardSystemManager.Instance.AddBoosterPack(boosterPackCount);
Logging.Debug($"[BoosterPackPickup] Gave {boosterPackCount} booster pack(s) to player");
}
else
{
Logging.Warning("[BoosterPackPickup] CardSystemManager not found, cannot give booster pack");
}
// 8. Cleanup glow
if (glowInstance != null)
{
Destroy(glowInstance);
}
// 9. Blend back to main camera and cleanup
if (zoomCamera != null)
{
// Lower priority to blend back to main camera
zoomCamera.Priority = 0;
// Wait for blend back to complete (using default blend settings)
var brain = Camera.main?.GetComponent<CinemachineBrain>();
if (brain != null && brain.IsBlending)
{
// Wait for the blend to finish
while (brain.IsBlending)
{
yield return null;
}
}
else
{
// If not blending or no brain, just wait a short moment
yield return new WaitForSeconds(0.3f);
}
// Destroy the zoom camera
Destroy(zoomCamera.gameObject);
}
// 10. Clear the follower's held item since we "consumed" it
follower.ClearHeldItem();
// 11. Resume follower movement
follower.ResumeMovement();
sequencePlaying = false;
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Cleanup glow if still exists
if (glowInstance != null)
{
Destroy(glowInstance);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 11629418bd2d44fa9da229ad62c04332
timeCreated: 1763639641

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fa5389b421944c6e8f43d7fad84ef470
timeCreated: 1763596581

View File

@@ -0,0 +1,69 @@
using UnityEngine;
namespace Minigames.BirdPooper
{
/// <summary>
/// Listens to BirdPlayerController flap events and triggers wing flap animation.
/// Uses Animator with a trigger parameter to play flap animation from idle state.
/// </summary>
public class BirdFlapAnimator : MonoBehaviour
{
[SerializeField] private Animator animator;
[Tooltip("Name of the trigger parameter in the Animator")]
[SerializeField] private string flapTriggerName = "Flap";
private BirdPlayerController birdController;
void Awake()
{
// Auto-assign animator component if not set
if (animator == null)
{
animator = GetComponent<Animator>();
if (animator == null)
{
Debug.LogError("[BirdFlapAnimator] No Animator component found! Please add an Animator component or assign one in the Inspector.");
}
}
// Find parent BirdPlayerController
birdController = GetComponentInParent<BirdPlayerController>();
if (birdController == null)
{
Debug.LogError("[BirdFlapAnimator] No BirdPlayerController found in parent! This component must be a child of the bird GameObject.");
}
}
void OnEnable()
{
// Subscribe to flap event
if (birdController != null && birdController.OnFlap != null)
{
birdController.OnFlap.AddListener(OnBirdFlapped);
Debug.Log("[BirdFlapAnimator] Subscribed to OnFlap event");
}
}
void OnDisable()
{
// Unsubscribe from flap event
if (birdController != null && birdController.OnFlap != null)
{
birdController.OnFlap.RemoveListener(OnBirdFlapped);
}
}
/// <summary>
/// Called when the bird flaps. Triggers the flap animation.
/// </summary>
private void OnBirdFlapped()
{
if (animator == null) return;
// Trigger the flap animation
animator.SetTrigger(flapTriggerName);
Debug.Log($"[BirdFlapAnimator] Triggered animation: {flapTriggerName}");
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9ba8e12a2b8b4d468ccfa995ed56980c
timeCreated: 1763600531

View File

@@ -0,0 +1,197 @@
using Core;
using Core.Lifecycle;
using Core.Settings;
using UnityEngine;
namespace Minigames.BirdPooper
{
/// <summary>
/// Bird player controller with Flappy Bird-style flight mechanics.
/// Responds to tap input to flap, with manual gravity simulation.
/// </summary>
public class BirdPlayerController : ManagedBehaviour, ITouchInputConsumer
{
[Header("Events")]
public UnityEngine.Events.UnityEvent OnFlap;
public UnityEngine.Events.UnityEvent OnPlayerDamaged;
private Rigidbody2D rb;
private IBirdPooperSettings settings;
private float verticalVelocity = 0f;
private bool isDead = false;
private float fixedXPosition; // Store the initial X position from the scene
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Initialize events
if (OnFlap == null)
OnFlap = new UnityEngine.Events.UnityEvent();
if (OnPlayerDamaged == null)
OnPlayerDamaged = new UnityEngine.Events.UnityEvent();
// Load settings
settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
if (settings == null)
{
Debug.LogError("[BirdPlayerController] BirdPooperSettings not found!");
return;
}
// Get Rigidbody2D component (Dynamic with gravityScale = 0)
rb = GetComponent<Rigidbody2D>();
if (rb != null)
{
rb.gravityScale = 0f; // Disable Unity physics gravity
rb.bodyType = RigidbodyType2D.Kinematic; // Kinematic = manual movement, no physics forces
// Store the initial X position from the scene
fixedXPosition = rb.position.x;
Debug.Log($"[BirdPlayerController] Fixed X position set to: {fixedXPosition}");
}
else
{
Debug.LogError("[BirdPlayerController] Rigidbody2D component not found!");
return;
}
// Register as override consumer to capture ALL input (except UI button)
// Register as override consumer to capture ALL input (except UI button)
if (Input.InputManager.Instance != null)
{
Input.InputManager.Instance.RegisterOverrideConsumer(this);
Debug.Log("[BirdPlayerController] Registered as override input consumer");
}
else
{
Debug.LogError("[BirdPlayerController] InputManager instance not found!");
}
}
private void Update()
{
if (!isDead && settings != null && rb != null)
{
// Apply manual gravity
verticalVelocity -= settings.Gravity * Time.deltaTime;
// Cap fall speed (terminal velocity)
if (verticalVelocity < -settings.MaxFallSpeed)
verticalVelocity = -settings.MaxFallSpeed;
// Update position manually
Vector2 newPosition = rb.position;
newPosition.y += verticalVelocity * Time.deltaTime;
newPosition.x = fixedXPosition; // Keep X fixed at scene-configured position
// Clamp Y position to bounds
newPosition.y = Mathf.Clamp(newPosition.y, settings.MinY, settings.MaxY);
rb.MovePosition(newPosition);
// Update rotation based on velocity
UpdateRotation();
}
}
#region ITouchInputConsumer Implementation
public void OnTap(Vector2 tapPosition)
{
if (!isDead && settings != null)
{
verticalVelocity = settings.FlapForce;
Debug.Log($"[BirdPlayerController] Flap! velocity = {verticalVelocity}");
// Emit flap event
OnFlap?.Invoke();
}
}
public void OnHoldStart(Vector2 position) { }
public void OnHoldMove(Vector2 position) { }
public void OnHoldEnd(Vector2 position) { }
#endregion
#region Rotation
/// <summary>
/// Updates the bird's rotation based on vertical velocity.
/// Bird tilts up when flapping, down when falling.
/// </summary>
private void UpdateRotation()
{
if (settings == null) return;
// Map velocity to rotation angle
// When falling at max speed (-MaxFallSpeed): -MaxRotationAngle (down)
// When at flap velocity (+FlapForce): +MaxRotationAngle (up)
float velocityPercent = Mathf.InverseLerp(
-settings.MaxFallSpeed,
settings.FlapForce,
verticalVelocity
);
float targetAngle = Mathf.Lerp(
-settings.MaxRotationAngle,
settings.MaxRotationAngle,
velocityPercent
);
// Get current angle (handle 0-360 wrapping to -180-180)
float currentAngle = transform.rotation.eulerAngles.z;
if (currentAngle > 180f)
currentAngle -= 360f;
// Smooth interpolation to target
float smoothedAngle = Mathf.Lerp(
currentAngle,
targetAngle,
settings.RotationSpeed * Time.deltaTime
);
// Apply rotation to Z axis only (2D rotation)
transform.rotation = Quaternion.Euler(0, 0, smoothedAngle);
}
#endregion
#region Trigger-Based Collision Detection
/// <summary>
/// Called when a trigger collider enters this object's trigger.
/// Used for detecting obstacles without physics interactions.
/// </summary>
private void OnTriggerEnter2D(Collider2D other)
{
// Check if the colliding object is tagged as an obstacle
if (other.CompareTag("Obstacle"))
{
HandleDeath();
}
}
private void HandleDeath()
{
// Only process death once
if (isDead) return;
isDead = true;
verticalVelocity = 0f;
Debug.Log("[BirdPlayerController] Bird died!");
// Emit damage event - let the game manager handle UI
OnPlayerDamaged?.Invoke();
}
#endregion
#region Public Properties
public bool IsDead => isDead;
#endregion
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2d56e44aad9044f8808b892c7a5cfc50
timeCreated: 1763596581

View File

@@ -0,0 +1,118 @@
using UnityEngine;
using Core.Lifecycle;
namespace Minigames.BirdPooper
{
/// <summary>
/// Central game manager for Bird Pooper minigame.
/// Manages game flow, UI, obstacle spawning, and reacts to player events.
/// Singleton pattern for easy access.
/// </summary>
public class BirdPooperGameManager : ManagedBehaviour
{
private static BirdPooperGameManager _instance;
public static BirdPooperGameManager Instance => _instance;
[Header("References")]
[SerializeField] private BirdPlayerController player;
[SerializeField] private ObstacleSpawner obstacleSpawner;
[SerializeField] private GameOverScreen gameOverScreen;
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Set singleton instance
if (_instance != null && _instance != this)
{
Debug.LogWarning("[BirdPooperGameManager] Multiple instances detected! Destroying duplicate.");
Destroy(gameObject);
return;
}
_instance = this;
// Validate references
if (player == null)
{
Debug.LogError("[BirdPooperGameManager] Player reference not assigned!");
}
if (obstacleSpawner == null)
{
Debug.LogError("[BirdPooperGameManager] ObstacleSpawner reference not assigned!");
}
if (gameOverScreen == null)
{
Debug.LogError("[BirdPooperGameManager] GameOverScreen reference not assigned!");
}
else
{
// Hide game over screen on start
gameOverScreen.gameObject.SetActive(false);
}
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Subscribe to player events
if (player != null)
{
player.OnPlayerDamaged.AddListener(HandlePlayerDamaged);
Debug.Log("[BirdPooperGameManager] Subscribed to player damaged event");
}
// Start obstacle spawning
if (obstacleSpawner != null)
{
obstacleSpawner.StartSpawning();
Debug.Log("[BirdPooperGameManager] Started obstacle spawning");
}
}
internal override void OnManagedDestroy()
{
// Unsubscribe from player events
if (player != null)
{
player.OnPlayerDamaged.RemoveListener(HandlePlayerDamaged);
}
// Clear singleton
if (_instance == this)
{
_instance = null;
}
base.OnManagedDestroy();
}
/// <summary>
/// Called when player takes damage/dies.
/// Shows game over screen.
/// </summary>
private void HandlePlayerDamaged()
{
Debug.Log("[BirdPooperGameManager] Player damaged - showing game over screen");
// Stop spawning obstacles
if (obstacleSpawner != null)
{
obstacleSpawner.StopSpawning();
}
// Show game over screen
if (gameOverScreen != null)
{
gameOverScreen.Show();
}
else
{
Debug.LogError("[BirdPooperGameManager] GameOverScreen reference missing!");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8cb19f77b49b4e299fac404c56e0455a
timeCreated: 1763636566

View File

@@ -0,0 +1,101 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using Core;
using UI;
namespace Minigames.BirdPooper
{
/// <summary>
/// Game over screen for Bird Pooper minigame.
/// Displays when the player dies and allows restarting the level.
/// Uses unscaled time for UI updates (works when Time.timeScale = 0).
/// </summary>
public class GameOverScreen : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private Button dismissButton;
[SerializeField] private CanvasGroup canvasGroup;
private void Awake()
{
// Subscribe to button click
if (dismissButton != null)
{
dismissButton.onClick.AddListener(OnDismissClicked);
}
else
{
Debug.LogError("[GameOverScreen] Dismiss button not assigned!");
}
// Get or add CanvasGroup for fade effects
if (canvasGroup == null)
{
canvasGroup = GetComponent<CanvasGroup>();
if (canvasGroup == null)
{
canvasGroup = gameObject.AddComponent<CanvasGroup>();
}
}
// Hide by default
gameObject.SetActive(false);
}
private void OnDestroy()
{
// Unsubscribe from button
if (dismissButton != null)
{
dismissButton.onClick.RemoveListener(OnDismissClicked);
}
}
/// <summary>
/// Show the game over screen and pause the game.
/// </summary>
public void Show()
{
gameObject.SetActive(true);
// Set canvas group for interaction
if (canvasGroup != null)
{
canvasGroup.alpha = 1f;
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
}
// Pause the game (set timescale to 0)
// PauseMenu uses unscaled time for tweens, so it will still work
Time.timeScale = 0f;
Debug.Log("[GameOverScreen] Game Over - Time.timeScale set to 0");
}
/// <summary>
/// Called when dismiss button is clicked. Reloads the level.
/// </summary>
private async void OnDismissClicked()
{
Debug.Log("[GameOverScreen] Dismiss button clicked - Reloading level");
// Hide this screen first
if (canvasGroup != null)
{
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
}
gameObject.SetActive(false);
// Reset time scale BEFORE reloading
Time.timeScale = 1f;
// Now reload the current scene with fresh state - skipSave=true prevents re-saving cleared data
var progress = new Progress<float>(p => Logging.Debug($"Loading progress: {p * 100:F0}%"));
await SceneManagerService.Instance.ReloadCurrentScene(progress, autoHideLoadingScreen: true, skipSave: true);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5896ecc85e77484599b2f2c7ac240991
timeCreated: 1763636057

View File

@@ -0,0 +1,300 @@
using UnityEngine;
using Core;
using Core.Settings;
using AppleHillsCamera;
namespace Minigames.BirdPooper
{
/// <summary>
/// Individual obstacle behavior for Bird Pooper minigame.
/// Scrolls left at constant speed and self-destructs when reaching despawn position.
/// Uses trigger colliders for collision detection (no Rigidbody2D needed).
/// Uses EdgeAnchor for vertical positioning (Top/Middle/Bottom).
/// </summary>
[RequireComponent(typeof(Collider2D))]
[RequireComponent(typeof(EdgeAnchor))]
public class Obstacle : MonoBehaviour
{
[Header("Positioning")]
[Tooltip("Which vertical edge to anchor to (Top/Middle/Bottom)")]
[SerializeField] private EdgeAnchor.AnchorEdge verticalAnchor = EdgeAnchor.AnchorEdge.Middle;
private IBirdPooperSettings settings;
private float despawnXPosition;
private bool isInitialized;
private EdgeAnchor edgeAnchor;
/// <summary>
/// Initialize the obstacle with despawn position and EdgeAnchor references.
/// Called by ObstacleSpawner immediately after instantiation.
/// </summary>
/// <param name="despawnX">X position where obstacle should be destroyed</param>
/// <param name="referenceMarker">ScreenReferenceMarker for EdgeAnchor</param>
/// <param name="cameraAdapter">CameraScreenAdapter for EdgeAnchor</param>
public void Initialize(float despawnX, ScreenReferenceMarker referenceMarker, CameraScreenAdapter cameraAdapter)
{
despawnXPosition = despawnX;
isInitialized = true;
// Load settings
settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
if (settings == null)
{
Debug.LogError("[Obstacle] BirdPooperSettings not found!");
}
// Tag all child GameObjects with colliders as "Obstacle" for trigger detection
TagChildCollidersRecursive(transform);
// Configure and update EdgeAnchor
edgeAnchor = GetComponent<EdgeAnchor>();
if (edgeAnchor != null)
{
// Assign references from spawner
edgeAnchor.referenceMarker = referenceMarker;
edgeAnchor.cameraAdapter = cameraAdapter;
// Only allow Top, Middle, or Bottom anchoring
if (verticalAnchor == EdgeAnchor.AnchorEdge.Left || verticalAnchor == EdgeAnchor.AnchorEdge.Right)
{
Debug.LogWarning("[Obstacle] Invalid anchor edge (Left/Right not supported). Defaulting to Middle.");
verticalAnchor = EdgeAnchor.AnchorEdge.Middle;
}
edgeAnchor.anchorEdge = verticalAnchor;
edgeAnchor.useReferenceMargin = false; // No custom offset
edgeAnchor.customMargin = 0f;
edgeAnchor.preserveOtherAxes = true; // Keep X position (for scrolling)
edgeAnchor.accountForObjectSize = true;
// Trigger position update
edgeAnchor.UpdatePosition();
Debug.Log($"[Obstacle] EdgeAnchor configured to {verticalAnchor} at position {transform.position}");
}
else
{
Debug.LogError("[Obstacle] EdgeAnchor component not found! Make sure the prefab has an EdgeAnchor component.");
}
Debug.Log($"[Obstacle] Initialized at position {transform.position} with despawn X: {despawnX}");
}
/// <summary>
/// Recursively tag all GameObjects with Collider2D as "Obstacle" for player collision detection.
/// </summary>
private void TagChildCollidersRecursive(Transform current)
{
// Tag this GameObject if it has a collider
Collider2D col = current.GetComponent<Collider2D>();
if (col != null && !current.CompareTag("Obstacle"))
{
current.tag = "Obstacle";
Debug.Log($"[Obstacle] Tagged '{current.name}' as Obstacle");
}
// Recurse to children
foreach (Transform child in current)
{
TagChildCollidersRecursive(child);
}
}
#if UNITY_EDITOR
/// <summary>
/// Called when values are changed in the Inspector (Editor only).
/// Updates EdgeAnchor configuration to match Obstacle settings.
/// Also finds and assigns ScreenReferenceMarker and CameraScreenAdapter for visual updates.
/// </summary>
private void OnValidate()
{
// Only run in editor, not during play mode
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode)
return;
EdgeAnchor anchor = GetComponent<EdgeAnchor>();
if (anchor != null)
{
// Auto-find and assign references if not set (for editor-time visual updates)
if (anchor.referenceMarker == null)
{
anchor.referenceMarker = FindAnyObjectByType<ScreenReferenceMarker>();
if (anchor.referenceMarker == null)
{
Debug.LogWarning("[Obstacle] No ScreenReferenceMarker found in scene. EdgeAnchor positioning won't work in editor.");
}
}
if (anchor.cameraAdapter == null)
{
anchor.cameraAdapter = FindAnyObjectByType<CameraScreenAdapter>();
// CameraScreenAdapter is optional - EdgeAnchor can auto-find camera
}
// Validate and set anchor edge
if (verticalAnchor == EdgeAnchor.AnchorEdge.Left || verticalAnchor == EdgeAnchor.AnchorEdge.Right)
{
Debug.LogWarning("[Obstacle] Invalid anchor edge (Left/Right not supported). Defaulting to Middle.");
verticalAnchor = EdgeAnchor.AnchorEdge.Middle;
}
// Configure EdgeAnchor to match Obstacle settings
anchor.anchorEdge = verticalAnchor;
anchor.useReferenceMargin = false;
anchor.customMargin = 0f;
anchor.preserveOtherAxes = true;
anchor.accountForObjectSize = true;
// Mark as dirty so Unity saves the changes
UnityEditor.EditorUtility.SetDirty(anchor);
}
// Tag all child GameObjects with colliders as "Obstacle" for collision detection
TagChildCollidersRecursiveEditor(transform);
}
/// <summary>
/// Editor version of recursive tagging for child colliders.
/// </summary>
private void TagChildCollidersRecursiveEditor(Transform current)
{
// Tag this GameObject if it has a collider
Collider2D col = current.GetComponent<Collider2D>();
if (col != null && !current.CompareTag("Obstacle"))
{
current.tag = "Obstacle";
UnityEditor.EditorUtility.SetDirty(current.gameObject);
}
// Recurse to children
foreach (Transform child in current)
{
TagChildCollidersRecursiveEditor(child);
}
}
#endif
private void Update()
{
if (!isInitialized || settings == null) return;
MoveLeft();
CheckBounds();
}
/// <summary>
/// Move obstacle left at constant speed (manual movement, no physics).
/// </summary>
private void MoveLeft()
{
transform.position += Vector3.left * (settings.ObstacleMoveSpeed * Time.deltaTime);
}
/// <summary>
/// Check if obstacle has passed despawn position and destroy if so.
/// </summary>
private void CheckBounds()
{
if (transform.position.x < despawnXPosition)
{
Debug.Log($"[Obstacle] Reached despawn position, destroying at X: {transform.position.x}");
Destroy(gameObject);
}
}
#if UNITY_EDITOR
/// <summary>
/// Draw debug visualization of the obstacle's anchor point.
/// Red horizontal line through custom anchor point OR bounds edge (top/bottom).
/// </summary>
private void OnDrawGizmos()
{
EdgeAnchor anchor = GetComponent<EdgeAnchor>();
if (anchor == null) return;
// Determine what Y position to visualize
float visualY;
// If using custom anchor point, draw line through it
if (anchor.customAnchorPoint != null)
{
visualY = anchor.customAnchorPoint.position.y;
}
else
{
// Get bounds and determine which edge to visualize
Bounds bounds = GetVisualBounds();
// Check which vertical anchor is configured
EdgeAnchor.AnchorEdge edge = anchor.anchorEdge;
if (edge == EdgeAnchor.AnchorEdge.Top)
{
// Show top edge of bounds
visualY = bounds.max.y;
}
else if (edge == EdgeAnchor.AnchorEdge.Bottom)
{
// Show bottom edge of bounds
visualY = bounds.min.y;
}
else // Middle
{
// Show center of bounds
visualY = bounds.center.y;
}
}
// Draw thick red horizontal line through the anchor point
Color oldColor = Gizmos.color;
Gizmos.color = Color.red;
// Draw multiple lines to make it thicker
float lineLength = 2f; // Extend 2 units on each side
Vector3 leftPoint = new Vector3(transform.position.x - lineLength, visualY, transform.position.z);
Vector3 rightPoint = new Vector3(transform.position.x + lineLength, visualY, transform.position.z);
// Draw 5 lines stacked vertically to create thickness
for (int i = -2; i <= 2; i++)
{
float offset = i * 0.02f; // Small vertical offset for thickness
Vector3 offsetLeft = leftPoint + Vector3.up * offset;
Vector3 offsetRight = rightPoint + Vector3.up * offset;
Gizmos.DrawLine(offsetLeft, offsetRight);
}
Gizmos.color = oldColor;
}
/// <summary>
/// Get bounds for visualization purposes (works in editor without initialized settings).
/// </summary>
private Bounds GetVisualBounds()
{
// Get all renderers in this object and its children
Renderer[] renderers = GetComponentsInChildren<Renderer>();
if (renderers.Length > 0)
{
Bounds bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++)
{
bounds.Encapsulate(renderers[i].bounds);
}
return bounds;
}
// Fallback to collider bounds
Collider2D col = GetComponent<Collider2D>();
if (col != null)
{
return col.bounds;
}
// Default small bounds
return new Bounds(transform.position, new Vector3(0.5f, 0.5f, 0.1f));
}
#endif
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 759c93e488a34f8d8f7abf6d8af5c73d
timeCreated: 1763629702

View File

@@ -0,0 +1,184 @@
using UnityEngine;
using Core;
using Core.Settings;
using Core.Lifecycle;
using AppleHillsCamera;
namespace Minigames.BirdPooper
{
/// <summary>
/// Spawns obstacles at regular intervals for Bird Pooper minigame.
/// Uses Transform references for spawn and despawn positions instead of hardcoded values.
/// All obstacles are spawned at Y = 0 (prefabs should be authored accordingly).
/// </summary>
public class ObstacleSpawner : ManagedBehaviour
{
[Header("Spawn Configuration")]
[Tooltip("Transform marking where obstacles spawn (off-screen right)")]
[SerializeField] private Transform spawnPoint;
[Tooltip("Transform marking where obstacles despawn (off-screen left)")]
[SerializeField] private Transform despawnPoint;
[Header("EdgeAnchor References")]
[Tooltip("ScreenReferenceMarker to pass to spawned obstacles")]
[SerializeField] private ScreenReferenceMarker referenceMarker;
[Tooltip("CameraScreenAdapter to pass to spawned obstacles")]
[SerializeField] private CameraScreenAdapter cameraAdapter;
[Header("Obstacle Prefabs")]
[Tooltip("Array of obstacle prefabs to spawn randomly")]
[SerializeField] private GameObject[] obstaclePrefabs;
private IBirdPooperSettings settings;
private float spawnTimer;
private bool isSpawning;
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Load settings
settings = GameManager.GetSettingsObject<IBirdPooperSettings>();
if (settings == null)
{
Debug.LogError("[ObstacleSpawner] BirdPooperSettings not found!");
return;
}
// Validate references
if (spawnPoint == null)
{
Debug.LogError("[ObstacleSpawner] Spawn Point not assigned! Please assign a Transform in the Inspector.");
}
if (despawnPoint == null)
{
Debug.LogError("[ObstacleSpawner] Despawn Point not assigned! Please assign a Transform in the Inspector.");
}
if (obstaclePrefabs == null || obstaclePrefabs.Length == 0)
{
Debug.LogError("[ObstacleSpawner] No obstacle prefabs assigned! Please assign at least one prefab in the Inspector.");
}
if (referenceMarker == null)
{
Debug.LogError("[ObstacleSpawner] ScreenReferenceMarker not assigned! Obstacles need this for EdgeAnchor positioning.");
}
if (cameraAdapter == null)
{
Debug.LogWarning("[ObstacleSpawner] CameraScreenAdapter not assigned. EdgeAnchor will attempt to auto-find camera.");
}
Debug.Log("[ObstacleSpawner] Initialized successfully");
}
private void Update()
{
if (!isSpawning || settings == null || spawnPoint == null) return;
spawnTimer += Time.deltaTime;
if (spawnTimer >= settings.ObstacleSpawnInterval)
{
SpawnObstacle();
spawnTimer = 0f;
}
}
/// <summary>
/// Spawn a random obstacle at the spawn point position (Y = 0).
/// </summary>
private void SpawnObstacle()
{
if (obstaclePrefabs == null || obstaclePrefabs.Length == 0)
{
Debug.LogWarning("[ObstacleSpawner] No obstacle prefabs to spawn!");
return;
}
if (despawnPoint == null)
{
Debug.LogWarning("[ObstacleSpawner] Cannot spawn obstacle without despawn point reference!");
return;
}
// Select random prefab
GameObject selectedPrefab = obstaclePrefabs[Random.Range(0, obstaclePrefabs.Length)];
// Spawn at spawn point position with Y = 0
Vector3 spawnPosition = new Vector3(spawnPoint.position.x, 0f, 0f);
GameObject obstacleObj = Instantiate(selectedPrefab, spawnPosition, Quaternion.identity);
// Initialize obstacle with despawn X position and EdgeAnchor references
Obstacle obstacle = obstacleObj.GetComponent<Obstacle>();
if (obstacle != null)
{
obstacle.Initialize(despawnPoint.position.x, referenceMarker, cameraAdapter);
}
else
{
Debug.LogError($"[ObstacleSpawner] Spawned prefab '{selectedPrefab.name}' does not have Obstacle component!");
Destroy(obstacleObj);
}
Debug.Log($"[ObstacleSpawner] Spawned obstacle '{selectedPrefab.name}' at position {spawnPosition}");
}
/// <summary>
/// Start spawning obstacles.
/// Spawns the first obstacle immediately, then continues with interval-based spawning.
/// </summary>
public void StartSpawning()
{
isSpawning = true;
spawnTimer = 0f;
// Spawn the first obstacle immediately
SpawnObstacle();
Debug.Log("[ObstacleSpawner] Started spawning (first obstacle spawned immediately)");
}
/// <summary>
/// Stop spawning obstacles.
/// </summary>
public void StopSpawning()
{
isSpawning = false;
Debug.Log("[ObstacleSpawner] Stopped spawning");
}
/// <summary>
/// Check if spawner is currently active.
/// </summary>
public bool IsSpawning => isSpawning;
#if UNITY_EDITOR
/// <summary>
/// Draw gizmos in editor to visualize spawn/despawn points.
/// </summary>
private void OnDrawGizmos()
{
if (spawnPoint != null)
{
Gizmos.color = Color.green;
Gizmos.DrawLine(spawnPoint.position + Vector3.up * 10f, spawnPoint.position + Vector3.down * 10f);
Gizmos.DrawWireSphere(spawnPoint.position, 0.5f);
}
if (despawnPoint != null)
{
Gizmos.color = Color.red;
Gizmos.DrawLine(despawnPoint.position + Vector3.up * 10f, despawnPoint.position + Vector3.down * 10f);
Gizmos.DrawWireSphere(despawnPoint.position, 0.5f);
}
}
#endif
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2cfe555fe3074f75ab3c7b33bf3446e0
timeCreated: 1763629720

View File

@@ -654,6 +654,15 @@ public class FollowerController : ManagedBehaviour
{
return _cachedPickupObject;
}
/// <summary>
/// Gets the transform of the held item's visual representation.
/// Used for animating the held item sprite.
/// </summary>
public Transform GetHeldItemTransform()
{
return heldObjectRenderer?.transform;
}
/// <summary>
/// Set held item from a GameObject. Extracts Pickup component and sets up visuals.

View File

@@ -234,7 +234,7 @@ namespace UI
case "Quarry":
currentUIMode = UIMode.Puzzle;
break;
case "DivingForPictures" or "CardQualityControl":
case "DivingForPictures" or "CardQualityControl" or "BirdPoop":
currentUIMode = UIMode.Minigame;
break;
}