Implement MVP for the statue decoration minigame (#65)
MVP implemented with: - placing, removing etc. decorations - saving the state, displaying it on the map, restoring when game restarts - saving screenshots to folder on device Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com> Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com> Reviewed-on: #65
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
using Core;
|
||||
using Core.Lifecycle;
|
||||
using Minigames.StatueDressup.Controllers;
|
||||
using Minigames.StatueDressup.Data;
|
||||
using UnityEngine;
|
||||
using Utils;
|
||||
|
||||
namespace Minigames.StatueDressup.Display
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads decoration metadata and reconstructs decorations on a statue sprite.
|
||||
/// Place this component on a GameObject with a SpriteRenderer showing the statue.
|
||||
/// On Start, loads all DecorationData via Addressables label, then spawns decorations from metadata.
|
||||
/// </summary>
|
||||
public class StatueDecorationLoader : ManagedBehaviour
|
||||
{
|
||||
[Header("Settings")]
|
||||
[SerializeField] private SpriteRenderer statueSpriteRenderer;
|
||||
|
||||
[Tooltip("Load specific photo ID, or leave empty to load latest")]
|
||||
[SerializeField] private string specificPhotoId = "";
|
||||
|
||||
[Tooltip("Apply pivot offset to position decorations relative to sprite's visual center instead of pivot point")]
|
||||
[SerializeField] private bool applyPivotOffset = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool showDebugInfo = true;
|
||||
|
||||
internal override void OnManagedStart()
|
||||
{
|
||||
base.OnManagedStart();
|
||||
|
||||
if (statueSpriteRenderer == null)
|
||||
statueSpriteRenderer = GetComponent<SpriteRenderer>();
|
||||
|
||||
if (statueSpriteRenderer == null)
|
||||
{
|
||||
Logging.Error("[StatueDecorationLoader] No SpriteRenderer found! Please assign statueSpriteRenderer.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for decoration data manager to be ready (static method handles null instance)
|
||||
DecorationDataManager.WhenReady(() =>
|
||||
{
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug("[StatueDecorationLoader] DecorationData ready, displaying decorations");
|
||||
}
|
||||
LoadAndDisplayDecorations();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load decoration metadata and spawn decorations
|
||||
/// </summary>
|
||||
public void LoadAndDisplayDecorations()
|
||||
{
|
||||
// Check if DecorationData is loaded via manager
|
||||
if (DecorationDataManager.Instance == null || !DecorationDataManager.Instance.IsReady)
|
||||
{
|
||||
Logging.Warning("[StatueDecorationLoader] DecorationDataManager not ready. Cannot display decorations.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load metadata
|
||||
StatueDecorationData data = string.IsNullOrEmpty(specificPhotoId)
|
||||
? PhotoManager.LoadLatestDecorationMetadata<StatueDecorationData>(CaptureType.StatueMinigame)
|
||||
: PhotoManager.LoadDecorationMetadata<StatueDecorationData>(CaptureType.StatueMinigame, specificPhotoId);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
Logging.Warning("[StatueDecorationLoader] No decoration metadata found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Loading {data.placements.Count} decorations from {data.photoId}");
|
||||
Logging.Debug($"[StatueDecorationLoader] Source coordinate system: {data.coordinateSystem}, statue size: {data.sourceStatueSize}");
|
||||
}
|
||||
|
||||
// Clear existing decorations (in case reloading)
|
||||
ClearDecorations();
|
||||
|
||||
// Calculate coordinate conversion factor if needed
|
||||
float conversionFactor = CalculateCoordinateConversion(data, out Vector2 targetStatueWorldSize);
|
||||
|
||||
// Spawn each decoration synchronously (data already loaded)
|
||||
int successCount = 0;
|
||||
foreach (var placement in data.placements)
|
||||
{
|
||||
if (SpawnDecoration(placement, conversionFactor, data.sourceStatueSize, targetStatueWorldSize))
|
||||
{
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Successfully loaded {successCount}/{data.placements.Count} decorations");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate coordinate conversion factor between source and target coordinate systems
|
||||
/// </summary>
|
||||
private float CalculateCoordinateConversion(StatueDecorationData data, out Vector2 targetStatueWorldSize)
|
||||
{
|
||||
// If source was world space and we're also world space, no conversion needed
|
||||
if (data.coordinateSystem == CoordinateSystemType.WorldSpace)
|
||||
{
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug("[StatueDecorationLoader] No coordinate conversion needed (WorldSpace → WorldSpace)");
|
||||
}
|
||||
targetStatueWorldSize = Vector2.one;
|
||||
return 1f;
|
||||
}
|
||||
|
||||
// Source was UI RectTransform (pixels), target is WorldSpace (units)
|
||||
// Need to convert from source statue pixel size to target statue VISUAL world size
|
||||
|
||||
// Get target statue VISUAL size (including transform scale)
|
||||
Vector2 spriteNativeSize = statueSpriteRenderer.sprite.bounds.size;
|
||||
Vector3 spriteScale = statueSpriteRenderer.transform.localScale;
|
||||
targetStatueWorldSize = new Vector2(
|
||||
spriteNativeSize.x * spriteScale.x,
|
||||
spriteNativeSize.y * spriteScale.y
|
||||
);
|
||||
|
||||
// Calculate conversion factor (target size / source size)
|
||||
float conversionX = targetStatueWorldSize.x / data.sourceStatueSize.x;
|
||||
float conversionY = targetStatueWorldSize.y / data.sourceStatueSize.y;
|
||||
|
||||
// Use average of X and Y for uniform scaling (or could use separate X/Y)
|
||||
float conversionFactor = (conversionX + conversionY) / 2f;
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Coordinate conversion: UI({data.sourceStatueSize}px) → World({targetStatueWorldSize}units)");
|
||||
Logging.Debug($"[StatueDecorationLoader] Sprite native: {spriteNativeSize}, scale: {spriteScale}, conversion factor: {conversionFactor:F3}");
|
||||
}
|
||||
|
||||
return conversionFactor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn a single decoration from placement data
|
||||
/// Looks up DecorationData from manager and applies coordinate conversion
|
||||
/// </summary>
|
||||
private bool SpawnDecoration(DecorationPlacement placement, float conversionFactor, Vector2 sourceStatueSize, Vector2 targetStatueWorldSize)
|
||||
{
|
||||
// Look up DecorationData from manager
|
||||
if (!DecorationDataManager.Instance.TryGetData(placement.decorationId, out DecorationData decorationData))
|
||||
{
|
||||
Logging.Warning($"[StatueDecorationLoader] DecorationData not found for ID: {placement.decorationId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get sprite from DecorationData
|
||||
Sprite decorationSprite = decorationData.DecorationSprite;
|
||||
|
||||
if (decorationSprite == null)
|
||||
{
|
||||
Logging.Warning($"[StatueDecorationLoader] DecorationData has null sprite: {placement.decorationId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create GameObject for decoration as child of statue sprite renderer
|
||||
GameObject decorationObj = new GameObject($"Decoration_{placement.decorationId}");
|
||||
decorationObj.transform.SetParent(statueSpriteRenderer.transform, false);
|
||||
|
||||
// Add SpriteRenderer
|
||||
SpriteRenderer spriteRenderer = decorationObj.AddComponent<SpriteRenderer>();
|
||||
spriteRenderer.sprite = decorationSprite;
|
||||
spriteRenderer.sortingLayerName = "Foreground";
|
||||
spriteRenderer.sortingOrder = statueSpriteRenderer.sortingOrder + placement.sortingOrder;
|
||||
|
||||
// ===== POSITION CALCULATION =====
|
||||
// Calculate pivot offset - decorations should be positioned relative to sprite's visual center, not pivot
|
||||
Sprite statueSprite = statueSpriteRenderer.sprite;
|
||||
Bounds spriteBounds = statueSprite.bounds;
|
||||
|
||||
// Sprite.bounds.center gives us the offset from pivot to visual center in local space
|
||||
Vector2 pivotToCenterOffset = spriteBounds.center;
|
||||
|
||||
// Convert UI pixel position to world space position
|
||||
Vector3 worldPosition = placement.localPosition * conversionFactor;
|
||||
|
||||
// Convert world position to local position (accounting for parent scale)
|
||||
Vector3 parentScale = statueSpriteRenderer.transform.localScale;
|
||||
Vector3 localPosition = new Vector3(
|
||||
worldPosition.x / parentScale.x,
|
||||
worldPosition.y / parentScale.y,
|
||||
worldPosition.z
|
||||
);
|
||||
|
||||
// Apply pivot offset IN LOCAL SPACE (after conversion from world to local)
|
||||
// This ensures both values are in the same coordinate system
|
||||
if (applyPivotOffset)
|
||||
{
|
||||
localPosition += new Vector3(pivotToCenterOffset.x, pivotToCenterOffset.y, 0f);
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Pivot offset APPLIED: {pivotToCenterOffset} (bounds center: {spriteBounds.center}, pivot normalized: {statueSprite.pivot / statueSprite.rect.size})");
|
||||
}
|
||||
}
|
||||
else if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Pivot offset SKIPPED (applyPivotOffset = false)");
|
||||
}
|
||||
|
||||
// ===== SCALE CALCULATION =====
|
||||
Vector3 localScale = placement.localScale;
|
||||
|
||||
if (placement.sizeDelta != Vector2.zero)
|
||||
{
|
||||
// Calculate relative size in UI (decoration size / statue size)
|
||||
Vector2 relativeSizeUI = new Vector2(
|
||||
placement.sizeDelta.x / sourceStatueSize.x,
|
||||
placement.sizeDelta.y / sourceStatueSize.y
|
||||
);
|
||||
|
||||
// Calculate target world size for decoration (relative size × statue world size)
|
||||
Vector2 targetDecorationWorldSize = new Vector2(
|
||||
relativeSizeUI.x * targetStatueWorldSize.x,
|
||||
relativeSizeUI.y * targetStatueWorldSize.y
|
||||
);
|
||||
|
||||
// Get decoration sprite's native world size
|
||||
Vector2 decorationNativeWorldSize = decorationSprite.bounds.size;
|
||||
|
||||
// Calculate world scale needed to achieve target size
|
||||
Vector2 worldScaleNeeded = new Vector2(
|
||||
targetDecorationWorldSize.x / decorationNativeWorldSize.x,
|
||||
targetDecorationWorldSize.y / decorationNativeWorldSize.y
|
||||
);
|
||||
|
||||
// Apply saved scale multiplier
|
||||
worldScaleNeeded = new Vector2(
|
||||
worldScaleNeeded.x * placement.localScale.x,
|
||||
worldScaleNeeded.y * placement.localScale.y
|
||||
);
|
||||
|
||||
// Convert world scale to local scale (accounting for parent scale)
|
||||
localScale = new Vector3(
|
||||
worldScaleNeeded.x / parentScale.x,
|
||||
worldScaleNeeded.y / parentScale.y,
|
||||
1f
|
||||
);
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Size calc: UI sizeDelta={placement.sizeDelta}, relativeUI={relativeSizeUI}");
|
||||
Logging.Debug($"[StatueDecorationLoader] Target world size={targetDecorationWorldSize}, native={decorationNativeWorldSize}, worldScale={worldScaleNeeded}, localScale={localScale}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No sizeDelta saved, just apply saved scale divided by parent scale
|
||||
localScale = new Vector3(
|
||||
placement.localScale.x / parentScale.x,
|
||||
placement.localScale.y / parentScale.y,
|
||||
1f
|
||||
);
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] No sizeDelta saved, using scale directly (compensated): {localScale}");
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transform
|
||||
decorationObj.transform.localPosition = localPosition;
|
||||
decorationObj.transform.localScale = localScale;
|
||||
decorationObj.transform.localEulerAngles = new Vector3(0, 0, placement.rotation);
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Logging.Debug($"[StatueDecorationLoader] Spawned: {placement.decorationId}");
|
||||
Logging.Debug($"[StatueDecorationLoader] Position: UI={placement.localPosition} → world={worldPosition} → local={localPosition}");
|
||||
Logging.Debug($"[StatueDecorationLoader] Parent scale: {parentScale}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all existing decorations (children of statue sprite renderer that are decorations)
|
||||
/// </summary>
|
||||
public void ClearDecorations()
|
||||
{
|
||||
if (statueSpriteRenderer == null) return;
|
||||
|
||||
Transform parent = statueSpriteRenderer.transform;
|
||||
|
||||
// Remove all children that are decorations (identified by name pattern)
|
||||
for (int i = parent.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
GameObject child = parent.GetChild(i).gameObject;
|
||||
|
||||
// Only destroy objects that look like decorations (by name pattern)
|
||||
if (child.name.StartsWith("Decoration_"))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
Destroy(child);
|
||||
}
|
||||
else
|
||||
{
|
||||
DestroyImmediate(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup handled by DecorationDataManager - no need for OnDestroy here
|
||||
|
||||
/// <summary>
|
||||
/// Reload decorations (useful for testing)
|
||||
/// </summary>
|
||||
[ContextMenu("Reload Decorations")]
|
||||
public void ReloadDecorations()
|
||||
{
|
||||
LoadAndDisplayDecorations();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load specific photo's decorations
|
||||
/// </summary>
|
||||
public void LoadSpecificPhoto(string photoId)
|
||||
{
|
||||
specificPhotoId = photoId;
|
||||
LoadAndDisplayDecorations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50d0f4591bbd40fc81dc615fa465e0c5
|
||||
timeCreated: 1764163758
|
||||
Reference in New Issue
Block a user