Last active
December 13, 2024 21:09
-
-
Save DrewFitz/0c84edfda7ed195f367f0162f15d4c9b to your computer and use it in GitHub Desktop.
SharePlay in Unity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Assets | |
-> Scripts | |
-> PluginHelper.cs and other game-specific code | |
-> Plugins | |
-> iOS | |
-> UnityIosPlugin | |
-> Editor | |
-> SwiftPostProcess.cs | |
-> Source | |
-> SharePlayPlugin.h | |
-> SharePlayPlugin.m | |
-> GroupActivityStuff.swift |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// GroupActivityStuff.swift | |
// GroupActivityStuff | |
// | |
// Created by Drew Fitzpatrick on 7/29/21. | |
// | |
import GroupActivities | |
import Combine | |
import Foundation | |
// MARK: - Activity | |
struct MyActivity: GroupActivity { | |
let ownerID: UUID | |
var metadata: GroupActivityMetadata { | |
get { | |
var meta = GroupActivityMetadata() | |
meta.title = "MyActivity" | |
meta.type = .generic | |
return meta | |
} | |
} | |
} | |
// MARK: - Messages | |
struct SetupMessage: Codable { | |
var hostParticipantID: UUID | |
} | |
struct PlayerNamesMessage: Codable { | |
var names: [UUID : String] | |
} | |
struct RegisteredPlayersMessage: Codable { | |
var playerIDs: [UUID] | |
} | |
struct GameStartMessage: Codable { } | |
struct SelectedPromptMessage: Codable { | |
var promptIndex: Int | |
} | |
struct SelectedSoundMessage: Codable { | |
var sound1Index: Int | |
var sound2Index: Int | |
} | |
struct ShowResolveStepMessage: Codable { } | |
struct PickWinnerMessage: Codable { | |
var winnerIndex: Int | |
} | |
struct NewRoundMessage: Codable { } | |
struct NewLeaderMessage: Codable { | |
let newLeaderID: UUID | |
} | |
@objc public class MyActivityController: NSObject { | |
private let names = [ | |
"Ada Lovelace", | |
"Steve Jobs", | |
"The Woz", | |
"Dr. Zoidberg", | |
"Marie Curie", | |
"Nikola Tesla", | |
"Thomas Edison" | |
] | |
@objc public static var shared = MyActivityController() | |
// MARK: - Private SharePlay State | |
private var sessionTask: Task<Void, Never>? | |
private var tasks = [Task<Void, Never>]() | |
private var subscriptions = [AnyCancellable]() | |
private var messenger: GroupSessionMessenger? | |
private var currentSession: GroupSession<MyActivity>? | |
// MARK: - Public Game State | |
public var hostParticipantID: UUID? | |
public var leaderParticipantID: UUID? | |
public var playerChoices = [UUID: (Int, Int)]() | |
public var playerNames = [UUID: String]() | |
private lazy var myActivityUUID = UUID() | |
@objc public var isLeader: Bool { | |
leaderParticipantID == localParticipant?.id | |
} | |
@objc public var isHost: Bool { | |
guard let localID = localParticipant?.id else { return false } | |
return hostParticipantID == localID | |
} | |
// MARK: - Private Game State | |
private var localParticipant: Participant? { | |
currentSession?.localParticipant | |
} | |
var registeredParticipants = [UUID]() | |
// MARK: - Public Functions | |
@objc public var startGameHandler: (() -> Void)? | |
@objc public var selectedPromptHandler: ((Int) -> Void)? | |
@objc public var showResolveHandler: (() -> Void)? | |
@objc public var pickWinnerHandler: ((Int) -> Void)? | |
@objc public var playerChoiceHandler: ((Int, Int, Int) -> Void)? | |
/// Create and activate an activity | |
@objc public func begin() async { | |
MyActivity(ownerID: myActivityUUID).activate() | |
} | |
lazy var observer = GroupStateObserver() | |
/// Prepare the callback listeners and session joining logic | |
@objc public func setup() { | |
observer.$isEligibleForGroupSession.sink { isEligible in | |
print(isEligible ? "IS ELIGIBLE" : "NOT ELIGIBLE") | |
}.store(in: &subscriptions) | |
sessionTask?.cancel() | |
reset() | |
print("setting up!") | |
let task = Task { | |
for await session in MyActivity.sessions() { | |
if session.activity.ownerID == myActivityUUID { | |
self.hostParticipantID = session.localParticipant.id | |
self.leaderParticipantID = session.localParticipant.id | |
} else { | |
self.hostParticipantID = nil | |
} | |
print("GOT SESSION") | |
print("local participant ID - \(session.localParticipant.id)") | |
join(session: session) | |
print("joined session!") | |
} | |
} | |
sessionTask = task | |
} | |
@objc public func localPlayerIndex() -> Int { | |
guard let localParticipant = localParticipant else { | |
return -1 | |
} | |
return registeredParticipants.firstIndex(of: localParticipant.id) ?? -1 | |
} | |
@objc public func playerCount() -> Int { | |
return registeredParticipants.count | |
} | |
@objc public func nameForPlayer(index: Int) -> String { | |
let id = registeredParticipants[index] | |
return playerNames[id] ?? "Unknown Player Name" | |
} | |
// MARK: - Public Message Sending | |
@objc public func startGame() { | |
sendToAll(GameStartMessage()) | |
} | |
@objc public func selectSounds(index1: Int, index2: Int) { | |
sendToAll(SelectedSoundMessage(sound1Index: index1, sound2Index: index2)) | |
} | |
@objc public func selectPrompt(index: Int) { | |
sendToAll(SelectedPromptMessage(promptIndex: index)) | |
} | |
@objc public func showResolve() { | |
sendToAll(ShowResolveStepMessage()) | |
} | |
@objc public func pickWinner(index: Int) { | |
sendToAll(PickWinnerMessage(winnerIndex: index)) | |
} | |
@objc public func newRound() { | |
guard self.isHost else { return } | |
let leader = self.leaderParticipantID! | |
let leaderIndex = self.registeredParticipants.firstIndex(of: leader)! | |
let newLeader = self.registeredParticipants[(leaderIndex + 1) % self.registeredParticipants.count] | |
self.leaderParticipantID = newLeader | |
self.sendToAll(NewLeaderMessage(newLeaderID: newLeader)) | |
} | |
// MARK: - Private Functions | |
private func reset() { | |
playerNames = [:] | |
registeredParticipants = [] | |
messenger = nil | |
tasks.forEach { $0.cancel() } | |
tasks = [] | |
subscriptions = [] | |
if currentSession != nil { | |
currentSession?.leave() | |
currentSession = nil | |
} | |
} | |
// MARK: - Private Message Sending | |
private func sendToAll<T: Encodable & Decodable>(_ message: T) { | |
messenger?.send(message) { _ in } | |
} | |
// MARK: - Event Listeners | |
private func join(session: GroupSession<MyActivity>) { | |
reset() | |
let messenger = GroupSessionMessenger(session: session) | |
self.messenger = messenger | |
subscribe(to: SetupMessage.self) { message in | |
self.hostParticipantID = message.0.hostParticipantID | |
self.leaderParticipantID = self.hostParticipantID | |
} | |
subscribe(to: GameStartMessage.self) { message in | |
// trigger game start | |
self.startGameHandler?() | |
} | |
subscribe(to: SelectedPromptMessage.self) { message in | |
// save prompt | |
self.selectedPromptHandler?(message.0.promptIndex) | |
} | |
subscribe(to: SelectedSoundMessage.self) { message in | |
// save sound selection for user | |
let id = message.1.source.id | |
self.playerChoices[id] = (message.0.sound1Index, message.0.sound2Index) | |
let index = self.registeredParticipants.firstIndex(of: id)! | |
self.playerChoiceHandler?(index, message.0.sound1Index, message.0.sound2Index) | |
} | |
subscribe(to: ShowResolveStepMessage.self) { message in | |
// trigger resolve step | |
self.showResolveHandler?() | |
} | |
subscribe(to: PickWinnerMessage.self) { message in | |
// update score and trigger winner screen | |
self.pickWinnerHandler?(message.0.winnerIndex) | |
} | |
subscribe(to: NewRoundMessage.self) { message in | |
// do nothing | |
} | |
subscribe(to: PlayerNamesMessage.self) { message in | |
self.playerNames = message.0.names | |
} | |
subscribe(to: RegisteredPlayersMessage.self) { message in | |
self.registeredParticipants = message.0.playerIDs | |
} | |
subscribe(to: NewLeaderMessage.self) { message in | |
self.leaderParticipantID = message.0.newLeaderID | |
} | |
session.$activeParticipants.sink { participants in | |
print("ACTIVE PARTICIPANTS UPDATED") | |
for person in participants { | |
print("\(person.id)") | |
} | |
guard self.isHost else { return } | |
let newParticipants = participants.subtracting(session.activeParticipants) | |
if !newParticipants.isEmpty, let hostID = self.hostParticipantID { | |
messenger.send(SetupMessage(hostParticipantID: hostID), to: .only(newParticipants)) { error in /* todo: log error */ } | |
for newbie in newParticipants { | |
let newbieID = newbie.id | |
let name = self.names[self.registeredParticipants.count] | |
self.playerNames[newbieID] = name | |
self.registeredParticipants.append(newbieID) | |
} | |
} | |
print("sending player names") | |
self.sendToAll(PlayerNamesMessage(names: self.playerNames)) | |
self.sendToAll(RegisteredPlayersMessage(playerIDs: self.registeredParticipants)) | |
}.store(in: &subscriptions) | |
session.$state.sink { state in | |
switch state { | |
case .waiting: | |
print("STATE = WAITING") | |
case .joined: | |
print("STATE = JOINED") | |
case .invalidated: | |
print("STATE = INVALIDATED") | |
self.reset() | |
@unknown default: | |
break | |
} | |
}.store(in: &subscriptions) | |
currentSession = session | |
session.join() | |
} | |
private func subscribe<T: Encodable & Decodable>(to messageType: T.Type, action: @escaping ((T, GroupSessionMessenger.MessageContext)) -> Void) { | |
let task = Task { | |
for await message in messenger!.messages(of: messageType) { | |
DispatchQueue.main.async { | |
action(message) | |
} | |
} | |
} | |
tasks.append(task) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using System.Runtime.InteropServices; | |
using AOT; | |
using TMPro; | |
using UnityEngine; | |
public class PluginHelper : MonoBehaviour | |
{ | |
#region Platform-Specific DLL Importing | |
#if UNITY_IOS | |
private const string dll = "__Internal"; | |
#else | |
private const string dll = "NativeUnityPlugin"; | |
#endif | |
#endregion | |
#region Singleton Setup | |
public static PluginHelper Singleton; | |
public struct ChoiceTuple | |
{ | |
public int first; | |
public int second; | |
} | |
private Dictionary<int, ChoiceTuple> choiceTable = new Dictionary<int, ChoiceTuple>(); | |
private void Awake() | |
{ | |
if (Singleton != null) | |
{ | |
Debug.LogError("TRIED TO REPLACE SINGLETON"); | |
Destroy(this); | |
return; | |
} | |
Singleton = this; | |
#if !UNITY_EDITOR | |
RegisterStartGameHandler(StartGameMessageReceived); | |
RegisterSelectedPromptHandler(SelectedPromptMessageReceived); | |
RegisterShowResolveHandler(ShowResolveMessageReceived); | |
RegisterPickWinnerHandler(PickWinnerMessageReceived); | |
RegisterPlayerChoiceHandler(PlayerChoiceMessageReceived); | |
#endif | |
} | |
#endregion | |
#region Unity Callbacks | |
private void Update() | |
{ | |
#if UNITY_EDITOR | |
return; | |
#endif | |
if (Time.frameCount % 30 == 0) | |
{ | |
string displayString = ""; | |
for (int i = 0; i < PlayerCount(); i++) | |
{ | |
displayString += PlayerNameAtIndex(i); | |
displayString += "\n"; | |
} | |
debugText.text = displayString; | |
} | |
} | |
#endregion | |
public TMP_Text debugText; | |
#region DLL Callbacks | |
private delegate void StartGameDelegate(); | |
private delegate void SelectedPromptDelegate(int index); | |
private delegate void ShowResolveDelegate(); | |
private delegate void PickWinnerDelegate(int index); | |
private delegate void PlayerChoiceDelegate(int player, int one, int two); | |
[DllImport(dll)] | |
private static extern void RegisterPlayerChoiceHandler(PlayerChoiceDelegate callback); | |
[DllImport(dll)] | |
private static extern void RegisterSelectedPromptHandler(SelectedPromptDelegate callback); | |
[DllImport(dll)] | |
private static extern void RegisterStartGameHandler(StartGameDelegate callback); | |
[DllImport(dll)] | |
private static extern void RegisterShowResolveHandler(ShowResolveDelegate callback); | |
[DllImport(dll)] | |
private static extern void RegisterPickWinnerHandler(PickWinnerDelegate callback); | |
[MonoPInvokeCallback(typeof(PlayerChoiceDelegate))] | |
private static void PlayerChoiceMessageReceived(int player, int one, int two) | |
{ | |
Singleton.choiceTable[player] = new ChoiceTuple | |
{ | |
first = one, | |
second = two | |
}; | |
// all players have picked | |
if (Singleton.choiceTable.Count == PlayerCount() - 1) | |
{ | |
GameManager.Singleton.TransitionToNextState(); | |
} | |
} | |
[MonoPInvokeCallback(typeof(SelectedPromptDelegate))] | |
private static void SelectedPromptMessageReceived(int index) | |
{ | |
Debug.Log($"Got selected prompt message: {index}"); | |
Singleton.DoSelectPrompt(index); | |
} | |
[MonoPInvokeCallback(typeof(StartGameDelegate))] | |
private static void StartGameMessageReceived() | |
{ | |
Debug.Log("Got Start Game Message"); | |
Singleton.DoGameStart(); | |
} | |
[MonoPInvokeCallback(typeof(ShowResolveDelegate))] | |
private static void ShowResolveMessageReceived() | |
{ | |
GameManager.Singleton.TransitionToNextState(); | |
Debug.Log($"Got show resolve message"); | |
} | |
[MonoPInvokeCallback(typeof(PickWinnerDelegate))] | |
private static void PickWinnerMessageReceived(int index) | |
{ | |
Debug.Log($"Got Pick Winner Message {index}"); | |
GameManager.Singleton.WinnerWasPicked(index); | |
} | |
#endregion | |
#region DLL Function References | |
[DllImport(dll)] | |
private static extern void SetupSharePlay(); | |
[DllImport(dll)] | |
private static extern void BeginSharePlay(); | |
[DllImport(dll)] | |
private static extern bool IsLeader(); | |
[DllImport(dll)] | |
private static extern bool IsHost(); | |
[DllImport(dll)] | |
private static extern void StartGame(); | |
[DllImport(dll)] | |
private static extern void SelectSounds(int index1, int index2); | |
[DllImport(dll)] | |
private static extern void SelectPrompt(int index); | |
[DllImport(dll)] | |
private static extern void ShowResolve(); | |
[DllImport(dll)] | |
private static extern void PickWinner(int index); | |
[DllImport(dll)] | |
private static extern void NewRound(); | |
[DllImport(dll)] | |
private static extern int GetLocalPlayerIndex(); | |
[DllImport(dll)] | |
private static extern int PlayerCount(); | |
[DllImport(dll)] | |
private static extern string PlayerNameAtIndex(int index); | |
#endregion | |
#region Public Interface | |
public void SetupSession() | |
{ | |
SetupSharePlay(); | |
} | |
public void BeginSession() | |
{ | |
BeginSharePlay(); | |
} | |
public void SendStartGame() | |
{ | |
StartGame(); | |
} | |
public void DebugSendSelectedPrompt() | |
{ | |
SelectPrompt(69); | |
} | |
public void SendSelectPrompt(int index) | |
{ | |
promptIndex = index; | |
SelectPrompt(index); | |
} | |
public void SendShowResolve() | |
{ | |
ShowResolve(); | |
} | |
public void SendPickWinner() | |
{ | |
PickWinner(420); | |
} | |
public void VoteForWinner(int index) | |
{ | |
PickWinner(index); | |
} | |
public bool IsTheLeader() | |
{ | |
return IsLeader(); | |
} | |
public int GetPlayerCount() | |
{ | |
#if !UNITY_EDITOR | |
return PlayerCount(); | |
#else | |
return 1; | |
#endif | |
} | |
public ChoiceTuple GetPlayerChoice(int index) | |
{ | |
return choiceTable[index]; | |
} | |
public void SendNewRound() | |
{ | |
NewRound(); | |
} | |
public int LocalPlayerIndex() | |
{ | |
return GetLocalPlayerIndex(); | |
} | |
public void SetPlayerChoice(int first, int second) | |
{ | |
#if !UNITY_EDITOR | |
var index = GetLocalPlayerIndex(); | |
#else | |
var index = 0; | |
#endif | |
choiceTable[index] = new ChoiceTuple | |
{ | |
first = first, | |
second = second | |
}; | |
// all players have picked | |
if (choiceTable.Count == PlayerCount() - 1) | |
{ | |
GameManager.Singleton.TransitionToNextState(); | |
} | |
} | |
public string GetPlayerName(int index) | |
{ | |
#if UNITY_EDITOR | |
return "Editor Test Name"; | |
#endif | |
return PlayerNameAtIndex(index); | |
} | |
#endregion | |
#region Instance Handlers | |
private void DoGameStart() | |
{ | |
Debug.Log("DoGameStart"); | |
GameManager.Singleton.TransitionToNextState(); | |
} | |
public int promptIndex = 0; | |
private void DoSelectPrompt(int index) | |
{ | |
Debug.Log($"DoSelectPrompt {index}"); | |
promptIndex = index; | |
GameManager.Singleton.TransitionToNextState(); | |
} | |
#endregion | |
public void SendSoundChoices(int firstIndex, int secondIndex) | |
{ | |
SelectSounds(firstIndex, secondIndex); | |
} | |
public bool HasPlayerChoice(int i) | |
{ | |
return choiceTable.ContainsKey(i); | |
} | |
public void ResetGameState() | |
{ | |
this.promptIndex = 0; | |
this.choiceTable.Clear(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEditor; | |
using UnityEditor.Callbacks; | |
using UnityEditor.iOS.Xcode; | |
using System.Diagnostics; | |
using System.IO; | |
using System.Linq; | |
public static class SwiftPostProcess | |
{ | |
[PostProcessBuild] | |
public static void OnPostProcessBuild(BuildTarget buildTarget, string buildPath) | |
{ | |
if (buildTarget != BuildTarget.iOS) return; | |
var projPath = PBXProject.GetPBXProjectPath(buildPath); | |
var proj = new PBXProject(); | |
proj.ReadFromFile(projPath); | |
var targetGuid = proj.GetUnityMainTargetGuid(); | |
var fwTarget = proj.GetUnityFrameworkTargetGuid(); | |
proj.SetBuildProperty(targetGuid, "IPHONEOS_DEPLOYMENT_TARGET", value: "15.0"); | |
proj.SetBuildProperty(fwTarget, "IPHONEOS_DEPLOYMENT_TARGET", value: "15.0"); | |
proj.WriteToFile(projPath); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment