// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Standard Library
using System;
using System.Collections.Generic;
using System.IO;
// Unity
using UnityEditor;
using UnityEngine;
// GameKit
using AWS.GameKit.Common;
using AWS.GameKit.Editor.AchievementsAdmin;
using AWS.GameKit.Editor.GUILayoutExtensions;
using AWS.GameKit.Editor.FileStructure;
using AWS.GameKit.Editor.Utils;
using AWS.GameKit.Runtime.Features.GameKitAchievements;
using AWS.GameKit.Runtime.Utils;
namespace AWS.GameKit.Editor.Windows.Settings.Pages.Achievements
{
///
/// Indicates whether an achievement's local and cloud definitions are identical.
///
/// The method determines whether two achievements are identical.
///
/// Local and cloud achievements are compared to each other if they have the same , which is the "primary key" for achievement definitions.
///
/// The SyncStatus is refreshed whenever the buttons "Save data" or "Get latest" are clicked.
///
public enum SyncStatus
{
///
/// It is unknown whether this local achievement is synchronized with the cloud.
///
Unknown,
///
/// This local achievement is synchronized with the cloud. The cloud and local definitions are identical, as compared by .
///
Synchronized,
///
/// This local achievement is unsynchronized with the cloud. The cloud and local definitions are different, or the achievement doesn't exist in the cloud.
///
Unsynchronized
}
///
/// A GUI element which renders a single, editable Achievement.
///
[Serializable]
public class AchievementWidget : IHasSerializedPropertyOfSelf
{
private static IFileManager _fileManager = new FileManager();
public SerializedProperty SerializedPropertyOfSelf { get; set; }
// UI Slider Min/Max Values
// These are not hard limits for your game. You can increase these values if you'd like. These limits only exist to make the UI sliders look nice with a reasonable range of values.
internal const int POINTS_MAX = 200;
internal const int POINTS_MIN = 1;
internal const int STEPS_MAX = 1000;
internal const int STEPS_MIN = 1;
internal const int SORT_MAX = 200;
internal const int SORT_MIN = 1;
// UI Fields
public string Id = string.Empty;
public string Title = string.Empty;
public int Points = POINTS_MIN;
public string DescriptionLocked = string.Empty;
public string DescriptionUnlocked = string.Empty;
public string IconPathLocked = string.Empty;
public string IconPathUnlocked = string.Empty;
public int NumberOfStepsToEarn = STEPS_MIN;
public bool IsInvisibleToPlayers = false;
public bool CanBeAchieved = true;
public int SortOrder = SORT_MIN;
public bool IsMarkedForDeletion = false;
// When we first create a new achievement, we know its not on the cloud, thus we implicitly know its Unsynchronized
public SyncStatus SyncStatus = SyncStatus.Unsynchronized;
[SerializeField] private Vector2 _scrollPosition;
[SerializeField] private bool _isExpanded = false;
// These should explicitly never be serialized, we what the texture to only buffer while Unity is running, but refresh on each start.
[NonSerialized] private Texture _lockedTexture = null;
[NonSerialized] private Texture _unlockedTexture = null;
public static implicit operator AdminAchievement(AchievementWidget achievementWidget)
{
return new AdminAchievement()
{
AchievementId = achievementWidget.Id,
Title = achievementWidget.Title,
LockedDescription = achievementWidget.DescriptionLocked,
UnlockedDescription = achievementWidget.DescriptionUnlocked,
LockedIcon = achievementWidget.IconPathLocked,
UnlockedIcon = achievementWidget.IconPathUnlocked,
RequiredAmount = (uint)achievementWidget.NumberOfStepsToEarn,
Points = (uint)achievementWidget.Points,
OrderNumber = (uint)achievementWidget.SortOrder,
IsStateful = achievementWidget.NumberOfStepsToEarn > 1,
IsSecret = achievementWidget.IsInvisibleToPlayers,
IsHidden = !achievementWidget.CanBeAchieved,
};
}
public static implicit operator AchievementWidget(AdminAchievement adminAchievement)
{
return new AchievementWidget()
{
Id = adminAchievement.AchievementId,
Title = adminAchievement.Title,
DescriptionLocked = adminAchievement.LockedDescription,
DescriptionUnlocked = adminAchievement.UnlockedDescription,
IconPathLocked = adminAchievement.LockedIcon,
IconPathUnlocked = adminAchievement.UnlockedIcon,
NumberOfStepsToEarn = (int)adminAchievement.RequiredAmount,
Points = (int)adminAchievement.Points,
SortOrder = (int)adminAchievement.OrderNumber,
IsInvisibleToPlayers = adminAchievement.IsSecret,
CanBeAchieved = !adminAchievement.IsHidden
};
}
public static implicit operator Achievement(AchievementWidget achievementWidget)
{
return new Achievement()
{
AchievementId = achievementWidget.Id,
Title = achievementWidget.Title,
LockedDescription = achievementWidget.DescriptionLocked,
UnlockedDescription = achievementWidget.DescriptionUnlocked,
LockedIcon = achievementWidget.IconPathLocked,
UnlockedIcon = achievementWidget.IconPathUnlocked,
CurrentValue = 0,
RequiredAmount = achievementWidget.NumberOfStepsToEarn,
Points = achievementWidget.Points,
OrderNumber = achievementWidget.SortOrder,
IsSecret = achievementWidget.IsInvisibleToPlayers,
IsHidden = !achievementWidget.CanBeAchieved,
IsEarned = false,
IsNewlyEarned = false,
EarnedAt = string.Empty,
UpdatedAt = string.Empty
};
}
public static implicit operator AchievementWidget(Achievement achievement)
{
return new AchievementWidget()
{
Id = achievement.AchievementId,
Title = achievement.Title,
DescriptionLocked = achievement.LockedDescription,
DescriptionUnlocked = achievement.UnlockedDescription,
IconPathLocked = achievement.LockedIcon,
IconPathUnlocked = achievement.UnlockedIcon,
NumberOfStepsToEarn = achievement.RequiredAmount,
Points = achievement.Points,
SortOrder = achievement.OrderNumber,
IsInvisibleToPlayers = achievement.IsSecret,
CanBeAchieved = !achievement.IsHidden
};
}
///
/// Return true if the local achievement and cloud achievement are exactly the same.
///
public static bool AreSame(AchievementWidget localAchievement, AchievementWidget cloudAchievement)
{
return localAchievement.Id == cloudAchievement.Id
&& localAchievement.Title == cloudAchievement.Title
&& localAchievement.Points == cloudAchievement.Points
&& localAchievement.DescriptionLocked == cloudAchievement.DescriptionLocked
&& localAchievement.DescriptionUnlocked == cloudAchievement.DescriptionUnlocked
&& localAchievement.IconPathLocked.Equals(cloudAchievement.IconPathLocked)
&& localAchievement.IconPathUnlocked.Equals(cloudAchievement.IconPathUnlocked)
&& localAchievement.NumberOfStepsToEarn == cloudAchievement.NumberOfStepsToEarn
&& localAchievement.IsInvisibleToPlayers == cloudAchievement.IsInvisibleToPlayers
&& localAchievement.CanBeAchieved == cloudAchievement.CanBeAchieved
&& localAchievement.SortOrder == cloudAchievement.SortOrder;
}
///
/// Copies the values which are safe to copy from another widget into this widget.
///
/// Widget to copy from.
public void CopyNonUniqueValues(AchievementWidget achievementWidget)
{
Title = achievementWidget.Title;
DescriptionLocked = achievementWidget.DescriptionLocked;
DescriptionUnlocked = achievementWidget.DescriptionUnlocked;
IconPathLocked = achievementWidget.IconPathLocked;
IconPathUnlocked = achievementWidget.IconPathUnlocked;
NumberOfStepsToEarn = achievementWidget.NumberOfStepsToEarn;
Points = achievementWidget.Points;
SortOrder = achievementWidget.SortOrder;
IsInvisibleToPlayers = achievementWidget.IsInvisibleToPlayers;
CanBeAchieved = achievementWidget.CanBeAchieved;
SyncStatus = achievementWidget.SyncStatus;
IsMarkedForDeletion = achievementWidget.IsMarkedForDeletion;
// The icons will need to be reloaded
ClearTextures();
}
///
/// Public method for collapsing an expanded widget.
///
public void CollapseWidget()
{
_isExpanded = false;
}
///
/// Public method for clearing the current icon textures and forcing a reload.
///
public void ClearTextures()
{
_lockedTexture = null;
_unlockedTexture = null;
}
///
/// Draw the achievement on the screen.
///
/// True if the achievement's attributes can be edited, false if not.
public void OnGUI(bool isEditable)
{
if (IsMarkedForDeletion || SerializedPropertyOfSelf == null)
{
// Don't display this achievement
return;
}
using (new EditorGUILayout.VerticalScope(_isExpanded ? SettingsGUIStyles.Achievements.BodyExpanded : SettingsGUIStyles.Achievements.BodyCollapsed))
{
using (new EditorGUILayout.HorizontalScope())
{
_isExpanded = EditorGUILayout.Foldout(_isExpanded, string.Empty, true, EditorStyles.foldout);
GUILayout.Label(Title, SettingsGUIStyles.Achievements.Header);
Rect headerTitleRect = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(headerTitleRect.x - SettingsGUIStyles.Achievements.HEADER_ICON_HORIZONTAL_PADDING,
headerTitleRect.y + SettingsGUIStyles.Achievements.HEADER_ICON_VERTICAL_PADDING,
SettingsGUIStyles.Achievements.HEADER_ICON_WIDTH,
SettingsGUIStyles.Achievements.HEADER_ICON_HEIGHT),
GetAchievementHeader(),
EditorStyles.label);
GUILayout.FlexibleSpace();
Texture deleteTexture = EditorGUIUtility.IconContent("TreeEditor.Trash").image;
using (new EditorGUI.DisabledScope(!isEditable))
{
if (GUILayout.Button(new GUIContent(deleteTexture, L10n.Tr("Delete this achievement locally. To delete from the cloud, click \"Save data\" after clicking this button.")), SettingsGUIStyles.Achievements.DeleteButton))
{
IsMarkedForDeletion = true;
}
}
}
if (!_isExpanded)
{
return;
}
using (new EditorGUILayout.VerticalScope(SettingsGUIStyles.Achievements.Expanded))
{
UpdateSyncStatus(Id, PropertyField(nameof(Id), L10n.Tr("ID (primary key)"), string.Empty, isEnabled: false));
UpdateSyncStatus(Title, PropertyField(nameof(Title), L10n.Tr("Title"), string.Empty, isEditable));
using (new EditorGUILayout.HorizontalScope())
{
Points = UpdateSyncStatus(Points, EditorGUILayoutElements.OverrideSlider(L10n.Tr("Points"), Points, POINTS_MIN, POINTS_MAX, isEnabled: isEditable));
}
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
DescriptionLocked = UpdateSyncStatus(DescriptionLocked, DescriptionField(L10n.Tr("Description\n(when locked)"), L10n.Tr("Enter a description for this achievement while it is still locked"), DescriptionLocked, isEditable));
DescriptionUnlocked = UpdateSyncStatus(DescriptionUnlocked, DescriptionField(L10n.Tr("Description\n(when achieved)"), L10n.Tr("Enter a description for this achievement for after it is achieved"), DescriptionUnlocked, isEditable));
_lockedTexture = ImageField(L10n.Tr("Image/icon\n(when locked)"), L10n.Tr("Select image/icon for locked achievement"), _lockedTexture, nameof(IconPathLocked), ref IconPathLocked, isEditable);
_unlockedTexture = ImageField(L10n.Tr("Image/icon\n(when achieved)"), L10n.Tr("Select image/icon for achieved achievement"), _unlockedTexture, nameof(IconPathUnlocked), ref IconPathUnlocked, isEditable);
using (new EditorGUILayout.HorizontalScope())
{
NumberOfStepsToEarn = UpdateSyncStatus(NumberOfStepsToEarn, EditorGUILayoutElements.OverrideSlider(L10n.Tr("No. steps to earn"), NumberOfStepsToEarn, STEPS_MIN, STEPS_MAX, isEnabled: isEditable));
}
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.PrefixLabel(L10n.Tr("Visibility"), SettingsGUIStyles.Achievements.VisibilityLabel);
EditorGUILayoutElements.KeepPreviousPrefixLabelEnabled();
IsInvisibleToPlayers = UpdateSyncStatus(IsInvisibleToPlayers, EditorGUILayoutElements.ToggleLeft(L10n.Tr("Invisible to players"), IsInvisibleToPlayers, isEnabled: isEditable));
CanBeAchieved = UpdateSyncStatus(CanBeAchieved, EditorGUILayoutElements.ToggleLeft(L10n.Tr("Can be achieved"), CanBeAchieved, isEnabled: isEditable));
GUILayout.FlexibleSpace();
}
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
using (new EditorGUILayout.HorizontalScope())
{
SortOrder = UpdateSyncStatus(SortOrder, EditorGUILayoutElements.OverrideSlider(L10n.Tr("Sort order"), SortOrder, SORT_MIN, SORT_MAX, isEnabled: isEditable));
}
}
}
}
private string PropertyField(string propertyPath, string label, string placeholder = "", bool isEnabled = true, params GUILayoutOption[] options)
{
SerializedProperty property = SerializedPropertyOfSelf.FindPropertyRelative(propertyPath);
if (property == null)
{
// when adding items quickly, there is a race condition where the property may not exist yet, skip this element for this render loop, it will render on the next loop once the property is available.
return string.Empty;
}
EditorGUILayoutElements.PropertyField(label, property, 1, placeholder, isEnabled, options);
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
return property.stringValue;
}
private string DescriptionField(string title, string placeholder, string description, bool isEnabled)
{
GUIContent content = new GUIContent(description);
float minWidth, maxWidth;
SettingsGUIStyles.Achievements.Description.CalcMinMaxWidth(content, out minWidth, out maxWidth);
float calculatedHeight = SettingsGUIStyles.Achievements.Description.CalcHeight(content, maxWidth);
float height = Math.Max(calculatedHeight, SettingsGUIStyles.Achievements.DESCRIPTION_MIN_HEIGHT);
string value = EditorGUILayoutElements.DescriptionField(
title,
description,
1,
placeholder,
isEnabled,
SettingsGUIStyles.Achievements.DescriptionLabel,
SettingsGUIStyles.Achievements.Description,
GUILayout.MinHeight(height)
);
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
return value;
}
private Texture ImageField(string label, string windowText, Texture currentImage, string nameOfImagePath, ref string imagePath, bool isEnabled)
{
Texture defaultIcon = EditorGUIUtility.IconContent(L10n.Tr("d_Texture Icon")).image;
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUILayout.HorizontalScope(SettingsGUIStyles.Achievements.ImageBody))
{
EditorGUILayout.PrefixLabel(label, SettingsGUIStyles.Achievements.ImageLabel, SettingsGUIStyles.Achievements.ImageLabel);
EditorGUILayoutElements.KeepPreviousPrefixLabelEnabled();
EditorGUILayout.LabelField(new GUIContent(currentImage ?? defaultIcon), SettingsGUIStyles.Achievements.Image);
if (EditorGUILayoutElements.Button(L10n.Tr("Browse"), isEnabled))
{
string file = EditorUtility.OpenFilePanel(windowText, string.Empty, "png,PNG,jpeg,JPEG,jpg,JPG");
if (file.Length > 0)
{
SerializedPropertyOfSelf.FindPropertyRelative(nameOfImagePath).stringValue = file;
SetToUnsynchronized();
currentImage = null;
}
}
}
string oldPath = SerializedPropertyOfSelf.FindPropertyRelative(nameOfImagePath).stringValue;
imagePath = UpdateSyncStatus(oldPath, PropertyField(nameOfImagePath, string.Empty, L10n.Tr("or enter your icon's path"), isEnabled));
if (!oldPath.Equals(imagePath))
{
currentImage = null;
}
}
GUILayout.Space(SettingsGUIStyles.Achievements.SPACING);
if (currentImage == null)
{
if (_fileManager.FileExists(imagePath))
{
if (!IsFileAvailable(imagePath))
{
// This case occurs if the file is still open from download while we are trying to read it. Keep as null and try again on the next frame.
return null;
}
byte[] imageBytes = _fileManager.ReadAllBytes(imagePath);
Texture2D newImage = new Texture2D(1, 1);
if (!ImageConversion.LoadImage(newImage, imageBytes, false))
{
Logging.LogInfo(L10n.Tr($"Could not load image at {imagePath}"));
return defaultIcon;
}
return newImage;
}
else if (!string.IsNullOrEmpty(imagePath))
{
Logging.LogInfo(L10n.Tr($"File not found at {imagePath}"));
return defaultIcon;
}
else
{
// this is the case where the achievement has just been created and has no image paths
return defaultIcon;
}
}
else
{
return currentImage;
}
}
private bool IsFileAvailable(string filePath)
{
try
{
FileInfo fileInfo = new FileInfo(filePath);
using (FileStream stream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.None))
{
stream.Close();
}
}
catch (IOException)
{
// File is not available.
return false;
}
// File is free.
return true;
}
private GUIContent GetAchievementHeader()
{
if (SyncStatus == SyncStatus.Unknown)
{
return new GUIContent(EditorResources.Textures.Colors.Transparent.Get());
}
else if (SyncStatus == SyncStatus.Synchronized)
{
return new GUIContent(EditorResources.Textures.FeatureStatusSuccess.Get(), L10n.Tr("Synced with cloud"));
}
else // Unsynchronized
{
return new GUIContent(EditorResources.Textures.Unsynchronized.Get(), L10n.Tr("Not in sync with cloud"));
}
}
private void SetToUnsynchronized()
{
// SyncStatus is serialized, so we need to change its serialized property, if we just assign it a value instead then it will be overridden when Unity updates its serialized values
SerializedPropertyOfSelf.FindPropertyRelative(nameof(SyncStatus)).enumValueIndex = (int)SyncStatus.Unsynchronized;
}
private T UpdateSyncStatus(T oldValue, T newValue)
{
if (!EqualityComparer.Default.Equals(oldValue, newValue))
{
SetToUnsynchronized();
}
return newValue;
}
}
}