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:
2025-11-27 13:21:22 +00:00
parent 5ad84ca3e8
commit 83aa3d5e6d
71 changed files with 6421 additions and 976 deletions

View File

@@ -1,10 +1,10 @@
using System.Collections;
using System.Collections;
using System.Collections.Generic;
using Core;
using Core.Lifecycle;
using Minigames.StatueDressup.Utils;
using UnityEngine;
using UnityEngine.UI;
using Utils;
namespace Minigames.StatueDressup.Controllers
{
@@ -15,52 +15,81 @@ namespace Minigames.StatueDressup.Controllers
/// </summary>
public class StatuePhotoGalleryController : ManagedBehaviour
{
public static StatuePhotoGalleryController Instance { get; private set; }
[Header("Gallery UI")]
[SerializeField] private Transform gridContainer;
[SerializeField] private PhotoGridItem gridItemPrefab;
[SerializeField] private ScrollRect scrollRect;
[Header("Enlarged View")]
[SerializeField] private GameObject enlargedViewPanel;
[SerializeField] private Image enlargedPhotoImage;
[SerializeField] private Button closeEnlargedButton;
[SerializeField] private Button deletePhotoButton;
[SerializeField] private Text photoInfoText;
[SerializeField] private Transform enlargedContainer; // Container for enlarged preview (top layer)
[SerializeField] private GameObject backdrop; // Dark backdrop for enlarged view
[SerializeField] private GameObject enlargedPreviewPrefab; // Prefab for enlarged preview (same as grid item)
[Header("Pagination")]
[SerializeField] private Button loadMoreButton;
[SerializeField] private Text statusText;
[SerializeField] private Button previousPageButton;
[SerializeField] private Button nextPageButton;
[SerializeField] private Text pageStatusText;
[Header("Settings")]
[SerializeField] private int itemsPerPage = 20;
[SerializeField] private int thumbnailSize = 256;
[SerializeField] private int maxCachedThumbnails = 50; // Keep recent thumbnails in memory
private int currentPage;
private List<string> allPhotoIds = new List<string>();
private Dictionary<string, PhotoGridItem> activeGridItems = new Dictionary<string, PhotoGridItem>();
private Dictionary<string, Texture2D> thumbnailCache = new Dictionary<string, Texture2D>();
private Dictionary<string, Texture2D> fullPhotoCache = new Dictionary<string, Texture2D>(); // Cache full photos for enlargement
private Queue<string> thumbnailCacheOrder = new Queue<string>();
private bool isLoadingPage;
private PhotoEnlargeController enlargeController;
private int _currentPage = 0;
private List<string> _allPhotoIds = new List<string>();
private Dictionary<string, PhotoGridItem> _activeGridItems = new Dictionary<string, PhotoGridItem>();
private Dictionary<string, Texture2D> _thumbnailCache = new Dictionary<string, Texture2D>();
private Queue<string> _thumbnailCacheOrder = new Queue<string>();
private string _currentEnlargedPhotoId = null;
private Texture2D _currentEnlargedTexture = null;
internal override void OnManagedAwake()
{
base.OnManagedAwake();
// Singleton pattern
if (Instance != null && Instance != this)
{
Logging.Warning("[StatuePhotoGalleryController] Duplicate instance detected. Destroying duplicate.");
Destroy(gameObject);
return;
}
Instance = this;
}
internal override void OnManagedStart()
{
base.OnManagedStart();
// Setup buttons
if (closeEnlargedButton != null)
closeEnlargedButton.onClick.AddListener(CloseEnlargedView);
// Wait for data manager to be ready before initializing
DecorationDataManager.WhenReady(() =>
{
InitializeGallery();
});
}
/// <summary>
/// Initialize gallery once data manager is ready
/// </summary>
private void InitializeGallery()
{
var settings = DecorationDataManager.Instance?.Settings;
if (deletePhotoButton != null)
deletePhotoButton.onClick.AddListener(DeleteCurrentPhoto);
// Initialize enlarge controller
enlargeController = new PhotoEnlargeController(backdrop, enlargedContainer,
settings?.GalleryAnimationDuration ?? StatueDressupConstants.DefaultAnimationDuration);
if (loadMoreButton != null)
loadMoreButton.onClick.AddListener(LoadNextPage);
// Setup page navigation buttons
if (previousPageButton != null)
previousPageButton.onClick.AddListener(OnPreviousPageClicked);
// Hide enlarged view initially
if (enlargedViewPanel != null)
enlargedViewPanel.SetActive(false);
if (nextPageButton != null)
nextPageButton.onClick.AddListener(OnNextPageClicked);
// Hide backdrop initially
if (backdrop != null)
backdrop.SetActive(false);
// Clear grid initially (in case there are leftover items from scene setup)
ClearGrid();
// Load first page
RefreshGallery();
@@ -72,35 +101,35 @@ namespace Minigames.StatueDressup.Controllers
public void RefreshGallery()
{
// Clear existing items
ClearGallery();
ClearGrid();
// Get all photo IDs
_allPhotoIds = StatuePhotoManager.GetAllPhotoIds();
_currentPage = 0;
allPhotoIds = PhotoManager.GetAllPhotoIds(CaptureType.StatueMinigame);
currentPage = 0;
Logging.Debug($"[StatuePhotoGalleryController] Gallery refreshed: {_allPhotoIds.Count} photos");
Logging.Debug($"[StatuePhotoGalleryController] Gallery refreshed: {allPhotoIds.Count} photos");
// Load first page
LoadNextPage();
// Display first page
DisplayCurrentPage();
}
/// <summary>
/// Load next page of photos
/// Display the current page of photos (clears grid and shows only current page)
/// </summary>
private void LoadNextPage()
private void DisplayCurrentPage()
{
List<string> pagePhotoIds = StatuePhotoManager.GetPhotoIdsPage(_currentPage, itemsPerPage);
if (isLoadingPage) return;
if (pagePhotoIds.Count == 0)
{
if (loadMoreButton != null)
loadMoreButton.gameObject.SetActive(false);
UpdateStatusText($"All photos loaded ({_allPhotoIds.Count} total)");
return;
}
isLoadingPage = true;
Logging.Debug($"[StatuePhotoGalleryController] Loading page {_currentPage}: {pagePhotoIds.Count} items");
// Clear current grid
ClearGrid();
// Get photos for current page
int itemsPerPage = DecorationDataManager.Instance?.Settings?.GalleryItemsPerPage ?? StatueDressupConstants.DefaultGalleryItemsPerPage;
List<string> pagePhotoIds = PhotoManager.GetPhotoIdsPage(CaptureType.StatueMinigame, currentPage, itemsPerPage);
Logging.Debug($"[StatuePhotoGalleryController] Displaying page {currentPage + 1}: {pagePhotoIds.Count} items");
// Spawn grid items for this page
foreach (string photoId in pagePhotoIds)
@@ -108,14 +137,64 @@ namespace Minigames.StatueDressup.Controllers
SpawnGridItem(photoId);
}
_currentPage++;
// Update button states
UpdatePageButtons();
// Update UI state
bool hasMore = _currentPage * itemsPerPage < _allPhotoIds.Count;
if (loadMoreButton != null)
loadMoreButton.gameObject.SetActive(hasMore);
// Update status text
int totalPages = Mathf.CeilToInt((float)allPhotoIds.Count / itemsPerPage);
UpdateStatusText($"Page {currentPage + 1}/{totalPages} ({allPhotoIds.Count} photos)");
UpdateStatusText($"Showing {_activeGridItems.Count} of {_allPhotoIds.Count} photos");
isLoadingPage = false;
}
/// <summary>
/// Update page navigation button states
/// </summary>
private void UpdatePageButtons()
{
int itemsPerPage = DecorationDataManager.Instance?.Settings?.GalleryItemsPerPage ?? StatueDressupConstants.DefaultGalleryItemsPerPage;
int totalPages = Mathf.CeilToInt((float)allPhotoIds.Count / itemsPerPage);
// Enable/disable previous button
if (previousPageButton != null)
{
previousPageButton.interactable = currentPage > 0;
}
// Enable/disable next button
if (nextPageButton != null)
{
nextPageButton.interactable = currentPage < totalPages - 1;
}
}
/// <summary>
/// Navigate to previous page
/// </summary>
private void OnPreviousPageClicked()
{
if (currentPage > 0)
{
currentPage--;
DisplayCurrentPage();
Logging.Debug($"[StatuePhotoGalleryController] Navigated to previous page: {currentPage}");
}
}
/// <summary>
/// Navigate to next page
/// </summary>
private void OnNextPageClicked()
{
int itemsPerPage = DecorationDataManager.Instance?.Settings?.GalleryItemsPerPage ?? StatueDressupConstants.DefaultGalleryItemsPerPage;
int totalPages = Mathf.CeilToInt((float)allPhotoIds.Count / itemsPerPage);
if (currentPage < totalPages - 1)
{
currentPage++;
DisplayCurrentPage();
Logging.Debug($"[StatuePhotoGalleryController] Navigated to next page: {currentPage}");
}
}
/// <summary>
@@ -123,7 +202,7 @@ namespace Minigames.StatueDressup.Controllers
/// </summary>
private void SpawnGridItem(string photoId)
{
if (_activeGridItems.ContainsKey(photoId))
if (activeGridItems.ContainsKey(photoId))
{
Logging.Warning($"[StatuePhotoGalleryController] Grid item already exists: {photoId}");
return;
@@ -132,7 +211,7 @@ namespace Minigames.StatueDressup.Controllers
PhotoGridItem gridItem = Instantiate(gridItemPrefab, gridContainer);
gridItem.Initialize(photoId, this);
_activeGridItems[photoId] = gridItem;
activeGridItems[photoId] = gridItem;
// Load thumbnail asynchronously
StartCoroutine(LoadThumbnailAsync(photoId, gridItem));
@@ -144,7 +223,7 @@ namespace Minigames.StatueDressup.Controllers
private IEnumerator LoadThumbnailAsync(string photoId, PhotoGridItem gridItem)
{
// Check cache first
if (_thumbnailCache.TryGetValue(photoId, out Texture2D cachedThumbnail))
if (thumbnailCache.TryGetValue(photoId, out Texture2D cachedThumbnail))
{
gridItem.SetThumbnail(cachedThumbnail);
yield break;
@@ -154,7 +233,7 @@ namespace Minigames.StatueDressup.Controllers
yield return null;
// Load full photo
Texture2D fullPhoto = StatuePhotoManager.LoadPhoto(photoId);
Texture2D fullPhoto = PhotoManager.LoadPhoto(CaptureType.StatueMinigame, photoId);
if (fullPhoto == null)
{
@@ -163,7 +242,8 @@ namespace Minigames.StatueDressup.Controllers
}
// Create thumbnail
Texture2D thumbnail = StatuePhotoManager.CreateThumbnail(fullPhoto, thumbnailSize);
int thumbSize = DecorationDataManager.Instance?.Settings?.GalleryThumbnailSize ?? StatueDressupConstants.DefaultThumbnailSize;
Texture2D thumbnail = PhotoManager.CreateThumbnail(fullPhoto, thumbSize);
// Destroy full photo immediately (we only need thumbnail)
Destroy(fullPhoto);
@@ -184,211 +264,162 @@ namespace Minigames.StatueDressup.Controllers
private void CacheThumbnail(string photoId, Texture2D thumbnail)
{
// Add to cache
_thumbnailCache[photoId] = thumbnail;
_thumbnailCacheOrder.Enqueue(photoId);
thumbnailCache[photoId] = thumbnail;
thumbnailCacheOrder.Enqueue(photoId);
// Evict oldest if over limit
while (_thumbnailCache.Count > maxCachedThumbnails && _thumbnailCacheOrder.Count > 0)
int maxCached = DecorationDataManager.Instance?.Settings?.GalleryMaxCachedThumbnails ?? StatueDressupConstants.DefaultMaxCachedThumbnails;
while (thumbnailCache.Count > maxCached && thumbnailCacheOrder.Count > 0)
{
string oldestId = _thumbnailCacheOrder.Dequeue();
string oldestId = thumbnailCacheOrder.Dequeue();
if (_thumbnailCache.TryGetValue(oldestId, out Texture2D oldThumbnail))
if (thumbnailCache.TryGetValue(oldestId, out Texture2D oldThumbnail))
{
Destroy(oldThumbnail);
_thumbnailCache.Remove(oldestId);
thumbnailCache.Remove(oldestId);
Logging.Debug($"[StatuePhotoGalleryController] Evicted thumbnail from cache: {oldestId}");
}
}
}
/// <summary>
/// Show enlarged view of a photo (called by PhotoGridItem)
/// Enlarge a photo (called by PhotoGridItem)
/// </summary>
public void ShowEnlargedView(string photoId)
public void OnGridItemClicked(PhotoGridItem gridItem, string photoId)
{
if (enlargedViewPanel == null || enlargedPhotoImage == null)
if (enlargeController == null)
{
Logging.Warning("[StatuePhotoGalleryController] Enlarged view UI not configured");
Logging.Error("[StatuePhotoGalleryController] Enlarge controller not initialized");
return;
}
Logging.Debug($"[StatuePhotoGalleryController] Showing enlarged view: {photoId}");
// Clear previous enlarged texture
if (_currentEnlargedTexture != null)
// If already enlarged, shrink it
if (enlargeController.IsPhotoEnlarged)
{
Destroy(_currentEnlargedTexture);
_currentEnlargedTexture = null;
}
// Load full-size photo
_currentEnlargedTexture = StatuePhotoManager.LoadPhoto(photoId);
if (_currentEnlargedTexture == null)
{
Logging.Error($"[StatuePhotoGalleryController] Failed to load enlarged photo: {photoId}");
enlargeController.ShrinkPhoto();
return;
}
// Create sprite from texture
Sprite enlargedSprite = Sprite.Create(
_currentEnlargedTexture,
new Rect(0, 0, _currentEnlargedTexture.width, _currentEnlargedTexture.height),
new Vector2(0.5f, 0.5f)
);
Logging.Debug($"[StatuePhotoGalleryController] Enlarging photo: {photoId}");
enlargedPhotoImage.sprite = enlargedSprite;
_currentEnlargedPhotoId = photoId;
float enlargedScale = DecorationDataManager.Instance?.Settings?.GalleryEnlargedScale ?? StatueDressupConstants.DefaultEnlargedScale;
// Update photo info
UpdatePhotoInfo(photoId);
// Show panel
enlargedViewPanel.SetActive(true);
}
/// <summary>
/// Close enlarged view
/// </summary>
private void CloseEnlargedView()
{
if (enlargedViewPanel != null)
enlargedViewPanel.SetActive(false);
// Clean up texture
if (_currentEnlargedTexture != null)
// Check cache first
if (fullPhotoCache.TryGetValue(photoId, out Texture2D fullPhoto))
{
Destroy(_currentEnlargedTexture);
_currentEnlargedTexture = null;
}
_currentEnlargedPhotoId = null;
}
/// <summary>
/// Delete currently viewed photo
/// </summary>
private void DeleteCurrentPhoto()
{
if (string.IsNullOrEmpty(_currentEnlargedPhotoId))
{
Logging.Warning("[StatuePhotoGalleryController] No photo selected for deletion");
return;
}
string photoIdToDelete = _currentEnlargedPhotoId;
// Close enlarged view first
CloseEnlargedView();
// Delete photo
bool deleted = StatuePhotoManager.DeletePhoto(photoIdToDelete);
if (deleted)
{
// Remove from grid
if (_activeGridItems.TryGetValue(photoIdToDelete, out PhotoGridItem gridItem))
{
Destroy(gridItem.gameObject);
_activeGridItems.Remove(photoIdToDelete);
}
// Remove from cache
if (_thumbnailCache.TryGetValue(photoIdToDelete, out Texture2D thumbnail))
{
Destroy(thumbnail);
_thumbnailCache.Remove(photoIdToDelete);
}
// Refresh photo list
_allPhotoIds.Remove(photoIdToDelete);
UpdateStatusText($"Photo deleted. {_allPhotoIds.Count} photos remaining");
Logging.Debug($"[StatuePhotoGalleryController] Photo deleted: {photoIdToDelete}");
}
}
/// <summary>
/// Update photo info text in enlarged view
/// </summary>
private void UpdatePhotoInfo(string photoId)
{
if (photoInfoText == null) return;
StatuePhotoManager.PhotoMetadata metadata = StatuePhotoManager.GetPhotoMetadata(photoId);
if (metadata != null)
{
System.DateTime timestamp = System.DateTime.Parse(metadata.timestamp);
string dateStr = timestamp.ToString("MMM dd, yyyy hh:mm tt");
float fileSizeMB = metadata.fileSizeBytes / (1024f * 1024f);
photoInfoText.text = $"Date: {dateStr}\n" +
$"Decorations: {metadata.decorationCount}\n" +
$"Size: {fileSizeMB:F2} MB";
// Use cached photo
enlargeController.EnlargePhoto(gridItem, enlargedPreviewPrefab != null ? enlargedPreviewPrefab : gridItem.gameObject, fullPhoto, enlargedScale);
}
else
{
photoInfoText.text = "Photo information unavailable";
// Load full-size photo
fullPhoto = PhotoManager.LoadPhoto(CaptureType.StatueMinigame, photoId);
if (fullPhoto == null)
{
Logging.Error($"[StatuePhotoGalleryController] Failed to load photo: {photoId}");
return;
}
// Cache it (limited cache)
if (fullPhotoCache.Count < 10) // Keep only recent 10 full photos
{
fullPhotoCache[photoId] = fullPhoto;
}
enlargeController.EnlargePhoto(gridItem, enlargedPreviewPrefab != null ? enlargedPreviewPrefab : gridItem.gameObject, fullPhoto, enlargedScale);
}
}
/// <summary>
/// Cleanup when gallery is closed
/// </summary>
public void CleanupGallery()
{
if (enlargeController != null)
{
enlargeController.Cleanup();
}
// Clean up cached full photos
foreach (var photo in fullPhotoCache.Values)
{
if (photo != null)
{
Destroy(photo);
}
}
fullPhotoCache.Clear();
}
/// <summary>
/// Update status text
/// </summary>
private void UpdateStatusText(string message)
{
if (statusText != null)
statusText.text = message;
if (pageStatusText != null)
pageStatusText.text = message;
Logging.Debug($"[StatuePhotoGalleryController] Status: {message}");
}
/// <summary>
/// Clear all grid items and cached data
/// Clear only the grid items (used when switching pages)
/// </summary>
private void ClearGallery()
private void ClearGrid()
{
// Destroy grid items
foreach (var gridItem in _activeGridItems.Values)
foreach (var gridItem in activeGridItems.Values)
{
if (gridItem != null)
Destroy(gridItem.gameObject);
}
_activeGridItems.Clear();
activeGridItems.Clear();
Logging.Debug("[StatuePhotoGalleryController] Grid cleared");
}
/// <summary>
/// Clear all grid items and cached data (full cleanup)
/// </summary>
private void ClearGallery()
{
ClearGrid();
// Clear thumbnail cache
foreach (var thumbnail in _thumbnailCache.Values)
foreach (var thumbnail in thumbnailCache.Values)
{
if (thumbnail != null)
Destroy(thumbnail);
}
_thumbnailCache.Clear();
_thumbnailCacheOrder.Clear();
thumbnailCache.Clear();
thumbnailCacheOrder.Clear();
Logging.Debug("[StatuePhotoGalleryController] Gallery cleared");
Logging.Debug("[StatuePhotoGalleryController] Gallery fully cleared");
}
internal override void OnManagedDestroy()
{
base.OnManagedDestroy();
// Cleanup
// Singleton cleanup
if (Instance == this)
{
Instance = null;
}
// Clean up cached textures
ClearGallery();
CloseEnlargedView();
CleanupGallery();
// Unsubscribe buttons
if (closeEnlargedButton != null)
closeEnlargedButton.onClick.RemoveListener(CloseEnlargedView);
if (previousPageButton != null)
previousPageButton.onClick.RemoveListener(OnPreviousPageClicked);
if (deletePhotoButton != null)
deletePhotoButton.onClick.RemoveListener(DeleteCurrentPhoto);
if (loadMoreButton != null)
loadMoreButton.onClick.RemoveListener(LoadNextPage);
if (nextPageButton != null)
nextPageButton.onClick.RemoveListener(OnNextPageClicked);
}
}
}