using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using System.Threading; using UnityEngine; namespace UnityEngine { #if UNITY_XENON_API && ENABLE_XENON_SOCIALAPI // TODO: We have no concept of achievement types like xbox has, not exposed // TODO: No concept of user privilege levels, not exposed // NOTE: There doesn't seem to be any way to explicitly set your own state (online/offline/away/busy/playing) public class XboxLive : ISocial { private static LocalUser s_LocalUser; private Action m_AchievementDescriptionCallback; private Action m_AchievementReportingCallback; private static GameObject s_SessionObject; private static GameObject s_LeaderboardRoutine; internal const uint kDefaultUserIndex = 0; public static bool onlineMode { get; set; } public XboxLive() { if (X360Core.IsUserSignedIn(0, true)) { Debug.Log("User already signed in, in online mode"); onlineMode = true; } // DEBUG: Log when these callbacks are triggered but have not been set elsewhere X360Achievements.OnAchievementsEnumerated = () => { Debug.Log("OnAchievementsEnumerated called"); }; X360Achievements.OnUserAchievementsUpdated = (id) => { Debug.Log("OnUserAchievementsUpdated called for user " + id); }; X360Achievements.OnAward = (uid, aid, status) => { Debug.Log("OnAward called for user=" + uid + " achievement=" + aid + " status=" + status); }; } public void ShowAchievementsUI() { X360Achievements.ShowUI(kDefaultUserIndex); } // TODO: These should include an action which is triggered (OnSystemUIVisibilityChange) when the UI state changes (UI is dismissed) public void ShowAchievementsUI(uint userIndex) { X360Achievements.ShowUI(userIndex); } public void ShowLeaderboardUI() { Debug.Log("Not implemented"); } public void ShowFriendsUI(uint userIndex) { X360Friends.ShowFriendsUI(userIndex); } // NOTE: localUser.underage is not implemented on Xbox Live public LocalUser localUser { get { if (s_LocalUser == null) s_LocalUser = new LocalUser(); GetLocalUser(kDefaultUserIndex, ref s_LocalUser); return s_LocalUser; } } // TODO: Cache user so he's not recreated every time // TODO: Maybe set this up as a generic list, the list is populated as users are logged in and valid, no need to create on access, no need for seperate GetCount() type functions public LocalUser this[uint index] { get { LocalUser user = new LocalUser(); GetLocalUser(index, ref user); return user; } } private void GetLocalUser(uint index, ref LocalUser user) { user.m_Authenticated = X360Core.IsUserSignedIn(index, onlineMode); if (!user.m_Authenticated) { //Debug.Log("Must be logged in before getting local user details"); return; } user.m_UserName = X360Core.GetUserName(index); user.m_UserId = X360Core.GetUserOnlinePlayerId(index).Raw.ToString(); user.m_Friends = GetFriendsList(index); user.m_Image = X360Core.GetUserGamerPicture(index, true); } public void Authenticate(Action callback) { // Request online login of exactly 1 user X360Core.RequestSignIn(1, 1, onlineMode); X360Core.OnUserStateChange = delegate() { callback(true); }; } // Request sign-in for min users up to max user count public void Authenticate(uint minUsers, uint maxUsers, Action callback) { X360Core.RequestSignIn(minUsers, maxUsers, onlineMode); // Xbox core doesn't report success/failure of sign-in attempts // so lets just wrap it in another delegate which always reports true // I assume this means all users have signed in X360Core.OnUserStateChange = delegate() { callback(true); }; } // This is kind of pointless here as the friends list is not loaded seperately public void LoadFriends(Action callback) { /*if (X360Friends.IsInitialized(0)) { Debug.Log("Not initialized yet... "); X360Friends.OnFriendsUpdated = delegate(uint index) { callback(true); }; return; }*/ callback(true); Debug.Log("Friends list is always populated in the local user automatically"); } private UserProfile[] GetFriendsList(uint index) { var friends = new List(); for (uint i = 0; i < X360Friends.GetFriendCount(index); i++) { UserProfile friend = new UserProfile(); friend.m_UserName = X360Friends.GetFriendName(index, i); friend.m_UserId = X360Friends.GetFriendPlayerId(index, i).Raw.ToString(); friend.m_IsFriend = true; X360FriendState state = X360Friends.GetFriendState(index, i); friend.m_State = ConvertState(state); friend.m_Image = X360Core.GetPlayerGamerPicture(index, X360Friends.GetFriendPlayerId(index, i), false); friends.Add(friend); } return friends.ToArray(); } private UserState ConvertState(X360FriendState state) { if (state.IsOnline) return UserState.Online; if (state.IsOnlineAndAway) return UserState.OnlineAndAway; if (state.IsOnlineAndBusy) return UserState.OnlineAndBusy; if (state.IsPlaying) return UserState.Playing; return UserState.Offline; } // Achievements are set up with the Xbox 360 and LIVE Authoring Submission Tool (XLAST) // The points (or gamescore) of each one depends on the title type (retail/arcade). // They are actually loaded automatically at startup, so this doesn't actually load // anything. public void LoadAchievementDescriptions(Action callback) { if (!X360Achievements.IsEnumerated()) { m_AchievementDescriptionCallback = callback; X360Achievements.OnAchievementsEnumerated = CallbackAchivementDescriptionLoader; } else if (X360Achievements.GetCount() == 0) { Debug.Log("No achievement descriptions found"); callback(new AchievementDescription[0]); } else { callback(PopulateAchievementDescriptions()); } } private void CallbackAchivementDescriptionLoader() { if (m_AchievementDescriptionCallback != null) m_AchievementDescriptionCallback(PopulateAchievementDescriptions()); } private AchievementDescription[] PopulateAchievementDescriptions() { var achievements = new List(); for (uint i = 0; i < X360Achievements.GetCount(); ++i) { // TODO: Should the points maybe just be an uint like xbox uses? Not like you get negative points ever. X360Achievement xboxAchoo = new X360Achievement(i); AchievementDescription achievement = new AchievementDescription( xboxAchoo.Id.ToString(), xboxAchoo.Label, xboxAchoo.Picture, xboxAchoo.Description, xboxAchoo.Unachieved, xboxAchoo.ShowUnachieved, (int)xboxAchoo.Cred); achievements.Add(achievement); } return achievements.ToArray(); } // Apparently xbox has no concept of unhiding achievements by reporting 0 progress // TODO: This is printed in the log: // '[XUI] Warning: XuiControlPlayOptionalVisual: no fallback for control: PopupControl. Trying to play: "Normal"->"EndNormal"' public void ReportProgress(string id, double progress, Action callback) { uint numericId; if (!XboxLiveUtil.TryExtractId(id, out numericId)) return; ReportProgress(numericId, progress, callback); } public void ReportProgress(uint id, double progress, Action callback) { m_AchievementReportingCallback = callback; X360Achievements.OnAward = CallbackAchivementReported; X360Achievements.AwardUser(kDefaultUserIndex, id); } private void CallbackAchivementReported(uint userIndex, uint achievementId, X360AchievementStatus status) { // TODO: Check if the desired achievement ID actually got updated. // TODO: We should have enums here instead of bools if (m_AchievementReportingCallback != null) { if (status == X360AchievementStatus.AlreadyAwarded || status == X360AchievementStatus.Succeeded) m_AchievementReportingCallback(true); else m_AchievementReportingCallback(false); } } public void LoadAchievements(Action callback) { // TODO: This should really be communicated back with an enum, but since users can re-enumerate // it's kind of useless. We should allow that or automatically handle it. if (!X360Achievements.IsEnumerated()) { Debug.Log("Achievements not yet enumerated"); callback(new Achievement[0]); } if (X360Achievements.GetCount() == 0) { Debug.Log("No achievements found or achieved"); callback(new Achievement[0]); } else { callback(PopulateAchievements(kDefaultUserIndex)); } } private Achievement[] PopulateAchievements(uint index) { var achievements = new List(); for (uint i = 0; i < X360Achievements.GetCount(); ++i) { X360Achievement xboxAchoo = new X360Achievement(i); if (X360Achievements.IsUnlocked(index, xboxAchoo.Id, true)) { Achievement achievement = new Achievement( xboxAchoo.Id.ToString(), 100.0, true, false, X360Achievements.GetUnlockTime(index, xboxAchoo.Id)); achievements.Add(achievement); } } return achievements.ToArray(); } // Got this: // WRN[XGI]: Mismatched types for property 0x10000001. XUSER_PROPERTY.value.type = 2 but property type is 1. Skipping write. Pass the correct type or 0. public void ReportScore(long score, string board, Action callback) { uint boardId; if (!XboxLiveUtil.TryExtractId(board, out boardId)) return; // There must be a Score property assigned to this leaderboard and it must expect a 64 bit value uint propertyId; if (XboxLiveUtil.TryExtractId("PROPERTY_SCORE", out propertyId)) { Debug.Log("Found Score property: " + propertyId); var properties = new X360UserProperty[1]; properties[0].Id = propertyId; properties[0].Value.Type = X360UserDataType.Int64; properties[0].Value.ValueInt64 = score; ReportScore(boardId, properties, callback); } else Debug.LogError("Failed to report score to " + board); } // TODO: The API is set up so you have one leaderboard object, and muliple ones if you need to report/read // to/from multiple leaderboards. Xbox has the X360StatsViewProperties value which allows you to report // scores etc to multiple leaderboard views at a time. Here we always have one such property. public void ReportScore(uint boardId, X360UserProperty[] props, Action callback) { if (X360Core.GetTotalOnlineUsers() == 0) { Debug.Log("ERROR: Leaderboards can only be used when the user is logged in online (online=" + X360Core.GetTotalOnlineUsers() + " signed-in=" + X360Core.GetTotalSignedInUsers() + ")"); callback(false); return; } LeaderboardRoutine routine = GetRoutine(); XboxScore xboxScore = new XboxScore(); xboxScore.boardId = boardId; xboxScore.properties = props; routine.ReportScore(xboxScore, callback); } public void LoadScores(string category, Action callback) { uint categoryId; if (!XboxLiveUtil.TryExtractId(category, out categoryId)) return; // This will find all columns assigned to this leaderboard, if specific columns are desired // you need to use the xbox specific LoadScores call which has the columns parameter ushort[] columnIds; if (XboxLiveUtil.TryExtractId("STATS_COLUMN_" + category.ToUpper(), out columnIds)) LoadScores(categoryId, columnIds, callback); else Debug.LogError("Failed to load scores from " + category); } // We convert X360StatsRow + X360StatsColumn objects to Score objects. // NOTE: Each row contains user info + all the columns for him. Here we will only support the user info + 1 single column (value/score/points) // NOTE: We only support getting long (64bit) values, not floats etc. // NOTE: The date field in the Score class is not populated, not supported here unless we support it as a custom column in the leaderboard config. // TODO: Fix formattedValue field, it should be possible to populate based on the localized string accociated with a column // TODO: Unused row fields: Rating + Gamertag // NOTE: Arbitrated leaderboards + the TrueSkill system complicates matters here. An // arbitrated session must be started to be able to report scores on an arbitrated leaderboard. // When using TrueSkill, every player must report all scores (also for other players) and the server // verifies they are correct. public void LoadScores(uint boardId, ushort[] columnIds, Action callback) { LeaderboardRoutine routine = GetRoutine(); routine.LoadScores(boardId, columnIds, callback); } private LeaderboardRoutine GetRoutine() { LeaderboardRoutine routine; if (s_LeaderboardRoutine == null) { s_LeaderboardRoutine = new GameObject(); routine = s_LeaderboardRoutine.AddComponent(); } else routine = s_LeaderboardRoutine.GetComponent(); return routine; } internal static XboxLiveSession GetSession() { XboxLiveSession session; if (s_SessionObject == null) { Debug.Log("Creating new session object"); s_SessionObject = new GameObject(); session = s_SessionObject.AddComponent(); } else { Debug.Log("Reusing old session"); session = s_SessionObject.GetComponent(); } return session; } public void LoadScores(Leaderboard board, Action callback) { board.m_Loading = true; var routine = GetRoutine(); routine.LoadScores((XboxLeaderboard) board, callback); } public bool GetLoading(Leaderboard board) { return board.m_Loading; } public static void OpenSession(Action callback) { XboxLiveSession session = GetSession(); if (session.running) { callback(true); return; } session.SetupSession(callback); } public static void CloseSession(Action callback) { if (s_SessionObject == null) { callback(true); return; } var routine = s_SessionObject.GetComponent(); routine.Cleanup(callback); } } class LeaderboardRoutine : MonoBehaviour { private Action m_ScoresCallback; private Action m_LeaderboardCallback; // If it's possible with the Xbox SDK to do parallel leaderboard queries, then expand this into a boardID=>board hashtable private XboxLeaderboard m_CurrentBoard; public void ReportScore(XboxScore score, Action callback) { StartCoroutine(DoReportScore(score, callback)); } // TODO: Public/private slots needs to be exposed somehow, but this is only relevant for multiplayer games // TODO: No callbacks are accociated with writes (as they happen later)? // TODO: Deal with this error: WRN[XGI]: Invalid leaderboard id: 0x00000000 public IEnumerator DoReportScore(XboxScore score, Action callback) { /*yield return StartCoroutine(SetupSession()); Debug.Log("Session setup done"); if (m_Session == null) { callback(false); yield break; }*/ XboxLiveSession session = XboxLive.GetSession(); if (!session.running) { Debug.Log("Must open a session first"); callback(false); yield break; } var viewProp = new X360StatsViewProperties[1]; viewProp[0] = new X360StatsViewProperties { ViewId = score.boardId, Properties = score.properties }; X360PlayerId onlineId = X360Core.GetUserOnlinePlayerId(XboxLive.kDefaultUserIndex); if (!session.activeSession.WriteStats(onlineId, viewProp)) { Debug.LogError("Failed to send leaderboard data to server"); //yield return StartCoroutine(TearDownSession()); callback(false); } // DEBUG: Maybe also skip this yield yield return new WaitForSeconds(0.1F); Debug.Log(DateTime.Now + ": Waiting for session to become available."); yield return !session.activeSession.IsIdle(); //Debug.Log("Flush stats"); //m_Session.FlushStats(); //yield return new WaitForSeconds(0.1F); //Debug.Log(DateTime.Now + ": Waiting for session to become available."); //yield return !m_Session.IsIdle(); // TODO: Was it actually a success? callback(true); } public void LoadScores(uint categoryId, ushort[] columnIds, Action callback) { m_ScoresCallback = callback; XboxLeaderboard board = new XboxLeaderboard(); board.boardId = categoryId; board.columnIds = columnIds; board.playerScope = PlayerScope.FriendsOnly; StartCoroutine(DoLoadScores(board)); } public void LoadScores(XboxLeaderboard leaderboard, Action callback) { m_LeaderboardCallback = callback; m_CurrentBoard = leaderboard; string categoryString = ""; if (!XboxLiveUtil.TryExtractString(leaderboard.boardId, "STATS_VIEW_", out categoryString)) Debug.Log("Failed to set leaderboard category name"); else m_CurrentBoard.category = categoryString; StartCoroutine(DoLoadScores(leaderboard)); } public IEnumerator DoLoadScores(XboxLeaderboard board) { XboxLiveSession session = XboxLive.GetSession(); if (!session.running) { Debug.Log("Must open a session first"); yield break; } /*yield return StartCoroutine(SetupSession()); if (m_Session == null) { Debug.Log("Session setup failed"); FinishCallbacks(false, new Score[0]); yield break; }*/ if (board.timeScope != TimeScope.AllTime) Debug.Log("Time scope filtering is not supported, scores from any time are always used."); // TODO: Figure out what should be supported, ReadPlayerStats only returns to score // of the local player, this should go into Leaderboard.localPlayerScore if (board.playerScope == PlayerScope.FriendsOnly) Debug.Log("Friends only support not implemented yet, loading from all players"); X360Stats.ReadLeaderboardByIndex( board.boardId, board.columnIds, (uint) board.range.from, (uint) board.range.count, ProcessLeaderboardResult); // TODO: If the scores returned do not contain the local player score we need to fetch it // seperately so the localPlayerScore property can be populated. //X360Stats.ReadPlayerStats(board.boardId, board.columnIds, m_OnlineID, PopulateLocalPlayerScore); Debug.Log(DateTime.Now + ": Waiting for session to become available."); yield return !session.activeSession.IsIdle(); if (session.activeSession.LastFunctionFailed()) { Debug.Log("Failed to read leaderboard info"); //m_Session = null; yield break; } } private void FinishCallbacks(bool result, Score[] scores) { if (m_CurrentBoard != null) { m_CurrentBoard.m_Scores = scores; m_CurrentBoard.m_Loading = false; if (m_LeaderboardCallback != null) { m_LeaderboardCallback(result); m_LeaderboardCallback = null; } m_CurrentBoard = null; } else if (m_ScoresCallback != null) { m_ScoresCallback(scores); m_ScoresCallback = null; } } // TODO: This currently assumes one and only one value (column) per score element private void ProcessLeaderboardResult(UInt32 viewId, UInt32 totalRows, X360StatsRow[] rows) { Debug.Log("Received " + rows.Length + " out of " + totalRows + " rows for id " + viewId); m_CurrentBoard.m_MaxRange = totalRows; var scores = new List(); foreach (X360StatsRow row in rows) { string playerId = row.Xuid.ToString(); long value = -1; if (row.Columns.Length >= 1) value = row.Columns[0].Value.ValueInt64; DateTime date = DateTime.Now; int rank = (int)row.Rank; Score score = new Score(viewId.ToString(), value, playerId, date, value.ToString(), rank); scores.Add(score); } FinishCallbacks(true, scores.ToArray()); } } public class XboxScore : Score { public X360UserProperty[] properties { get; set; } public uint boardId { get; set; } } public class XboxLeaderboard : Leaderboard { public uint boardId { get; set; } public ushort[] columnIds { get; set; } public XboxLeaderboard() { boardId = 0; columnIds = new ushort[0]; } public override string ToString() { return base.ToString() + " BoardID: '" + boardId + "' ColumnIds: '" + columnIds.Length + "'"; } } internal class XboxLiveSession : MonoBehaviour { private X360Session m_Session; private bool m_SessionRunning; private X360PlayerId m_OnlineID; private bool m_HazError; internal bool running { get { return m_SessionRunning; } } internal X360Session activeSession { get { return m_Session; } } XboxLiveSession() { Debug.Log("XboxLiveSession GO created"); // Get the online ID, sanity checks is performed earlier to ensure that the user is actually online m_OnlineID = X360Core.GetUserOnlinePlayerId(XboxLive.kDefaultUserIndex); } public void Cleanup(Action callback) { StartCoroutine(TearDownSession(callback)); } internal void SetupSession(System.Action callback = null) { StartCoroutine(DoSetupSession(callback)); } // TODO: These sessions calls should all be returning success boolean... // TODO: Deal with this error: WRN[XGI]: User at index 0 is already a member of a presence session. private IEnumerator DoSetupSession(System.Action callback) { if (m_SessionRunning) { Debug.Log("Skip session creation as we already have one running"); yield break; } m_HazError = false; if (m_Session == null || m_Session.IsDead()) { Debug.Log(DateTime.Now + ": Creating session."); m_Session = X360Session.CreateSinglePlayerSessionWithStats(XboxLive.kDefaultUserIndex, 4, 0); if (m_Session == null) yield break; } yield return StartCoroutine(ValidateOperation("Session creation failed", callback)); if (m_HazError) yield break; Debug.Log(DateTime.Now + ": Session is ready. Joining."); if (!m_Session.Join(m_OnlineID, false)) { Debug.LogError("Failed to join session"); m_Session = null; yield break; } yield return StartCoroutine(ValidateOperation("Session joining failed", callback)); if (m_HazError) yield break; Debug.Log(DateTime.Now + ": Session is ready. Starting."); m_Session.Start(); yield return StartCoroutine(ValidateOperation("Session failed to start", callback)); if (m_HazError) yield break; Debug.Log("Session now running"); m_SessionRunning = true; if (callback != null) callback(true); } // We do not delete the running session completely but only end it's operation. It will // be reused if OpenSession is called again. private IEnumerator TearDownSession(Action callback = null) { if (m_Session == null || m_Session.IsDead()) { if (callback != null) callback(true); yield break; } m_HazError = false; Debug.Log("Leaving running session."); m_Session.Leave(m_OnlineID, false); yield return StartCoroutine(ValidateOperation("Failed to leave session", callback)); if (m_HazError) yield break; Debug.Log("End running session"); m_Session.End(); yield return StartCoroutine(ValidateOperation("Failed to end session", callback)); if (m_HazError) yield break; //Debug.Log("Deleting running session."); //m_Session.Delete(); //yield return StartCoroutine(ValidateOperation("Failed to delete session", callback)); //if (m_HazError) yield break; m_SessionRunning = false; Debug.Log("Successfully tore down session."); if (callback != null) callback(true); } private IEnumerator ValidateOperation(string error, Action callback) { //Debug.Log(DateTime.Now + ": Waiting for session to become available."); yield return !m_Session.IsIdle(); if (m_Session.LastFunctionFailed()) { Debug.Log(error); m_Session = null; if (callback != null) callback(false); m_HazError = true; yield break; } } } static internal class XboxLiveUtil { // TODO: Also look out for javascript/boo assemblies internal static Assembly s_UserScriptingAssembly = Assembly.Load(new AssemblyName("Assembly-CSharp")); static internal bool TryExtractId(string id, out ushort[] shorts) { List foundShorts = new List(); bool success = false; foreach (FieldInfo info in GetSpaConfigFields()) { //Debug.Log("Haz " + info); if (info.Name.Contains(id.ToUpper())) { // The reflected enum contains uint values, so it must first be cast to that type ushort value = (ushort)(uint)info.GetValue(info.GetType()); foundShorts.Add(value); success = true; //Debug.Log("Found desired ID: " + value); } } shorts = foundShorts.ToArray(); if (!success) Debug.Log("Failed to find " + id + " in SPAConfig enum"); return success; } static internal bool TryExtractId(string id, out uint numericId) { if (uint.TryParse(id, out numericId)) return true; foreach (FieldInfo info in GetSpaConfigFields()) { //Debug.Log("Haz " + info); if (info.Name.Contains(id.ToUpper())) { numericId = (uint)info.GetValue(info.GetType()); //Debug.Log("Found desired ID: " + numericId); return true; } } Debug.Log("Failed to find " + id + " in SPAConfig enum"); return false; } static internal bool TryExtractString(uint numericId, string pattern, out string outputString) { outputString = ""; foreach (FieldInfo info in GetSpaConfigFields()) { if (info.Name.Contains(pattern) && (uint)info.GetValue(info.GetType()) == numericId) { outputString = info.Name; return true; } } Debug.Log("Failed to find " + pattern + " field in SPAConfig enum which matched " + numericId); return false; } static internal FieldInfo[] GetSpaConfigFields() { if (s_UserScriptingAssembly == null) return new FieldInfo[0]; object spaObject = s_UserScriptingAssembly.CreateInstance("spaconfig", true); if (spaObject == null) return new FieldInfo[0]; Type spaType = spaObject.GetType(); FieldInfo[] spaFields = spaType.GetFields(); return spaFields; } } #endif }