// PoseTrigger.cs // Two players pose together, here a handshake. While two or more players are inside this trigger area, each of them // gets an action button (F on desktop). Walk up to another player in the area and press it: they get a dialog asking // whether they want to shake hands. If they say yes while you are both still in the area, the two of you are placed // facing each other, lined up for the pose, and both play the emote at the same time. // // • You can ask several people at once, but each of them only once at a time (no spamming one player). // • Each player does one pose at a time. If more people say yes, those poses wait in line and play one after the // other as soon as both players in each are free. // • Leaving the area quietly cancels every request to or from you (and a pose you are in). // • The emote is not the handshake only: pick any two-person emote. Both players can play the same one (a handshake // facing each other) or each side its own (EmoteReceiver: a hug and a hug back, a high five and a catch, a bow and a // curtsy...). Distance and Side Offset line the two avatars up the way the emote was animated. // // Set up: // 1. Make a box (or any shape) covering the area, give it a Collider with "Is Trigger" on, and put this script on it. // (Or put the script anywhere and drag the area into TriggerArea.) A see-through material shows people where it is. // 2. Pick the emote in EmoteRequester (upload it in the Avatar Editor first). Leave EmoteReceiver empty for the same one. // 3. Play it with a second player and adjust Distance / Side Offset until the hands meet. // // Lining up an emote: Distance and SideOffset come from where the emote puts the hand at the moment of the pose, // measured from where the character stood when it started (emotes that step in move the body too): // Distance = forward reach of the requester's emote + forward reach of the receiver's emote (+ a palm each) // SideOffset = (sideways reach of one + sideways reach of the other) / 2 (to the right = plus) // "Reach" here is the wrist (the hand bone). For hands that hold each other add about a palm each to Distance (the palm // centre is ~5 cm past the wrist), and move each player ~1.75 cm right (SideOffset minus that): the two right palms face // each other, so the hands sit side by side instead of in one another. // Measure it on the platform's avatars, not on the character the emote was made with: the same animation reaches less // far with shorter arms. The defaults are for Mixamo's "Shaking Hands" (Shaking_Hands_1) on both sides, played on the // standard avatars (Female1 / Male1): it steps in and the right wrist ends 66 cm in front of where the player stood, // 1.5 cm left of their middle; with a palm each that is Distance 1.32 + 0.10 = 1.42 and SideOffset -0.015 - 0.0175 // = -0.03. (On Mixamo's own, taller character the same clip reaches 69 cm, 9 cm to the right.) using System.Collections; using System.Collections.Generic; using IHXR.Simulation; public class PoseTrigger : ScriptBase { [Header("Where")] [Tooltip("The trigger area (a Collider with Is Trigger on). Empty = the object this script is on.")] public SceneObject TriggerArea; [Header("The pose")] [Tooltip("What it is called in the button and the dialog, e.g. \"Shake hands\" / \"shake hands with you\".")] public string ButtonLabel = "Shake hands"; public string RequestText = "would like to shake hands with you."; [Tooltip("The emote the player who asked plays.")] public Emote EmoteRequester; [Tooltip("The emote the player who said yes plays. Empty = the same as EmoteRequester.")] public Emote EmoteReceiver; [Tooltip("How long the pose lasts, in seconds (the emote's length). Then both are free again.")] public float PoseSeconds = 4.4f; [Header("Lining up")] [Tooltip("Metres between where the two players stand when the emote starts (their feet). Emotes that step in close the gap themselves. 1.42 = Mixamo Shaking Hands on both sides, on the standard avatars.")] public float Distance = 1.42f; [Tooltip("Metres each player is moved sideways, to their own left (minus = right). Use the sideways reach of the emote's hand, as played on the avatars (-0.03 = Mixamo Shaking Hands on the standard avatars).")] public float SideOffset = -0.03f; [Tooltip("Degrees each player turns away from facing the other straight on (plus = clockwise seen from above).")] public float TurnOffset = 0.0f; [Header("Asking")] [Tooltip("How close (metres) another player must be to ask them. The one in front of you is picked first.")] public float AskRange = 2.5f; [Tooltip("The desktop key for the button (phones and VR get the button itself / the right stick click).")] public string Key = "F"; const string ButtonId = "pose-trigger-ask"; readonly HashSet inside = new HashSet(); // Asked but not answered yet: "from|to". readonly HashSet pending = new HashSet(); // Said yes, waiting for both players to be free: in the order they were accepted. readonly List queue = new List(); // Players posing right now. readonly HashSet busy = new HashSet(); public override void Start() { var area = TriggerArea != null && !TriggerArea.IsNull ? TriggerArea : null; if (area != null) RegisterTrigger(area, OnEnter, OnExit); else RegisterTrigger(OnEnter, OnExit); ListenForActionButton(ButtonId, OnButton); if (EmoteRequester == null || EmoteRequester.IsNull) LogWarning("[PoseTrigger] Pick an emote in EmoteRequester."); } // ---------------------------------------------------------------- who is in the area void OnEnter(TriggerInfo info) { if (!info.IsPlayer || string.IsNullOrEmpty(info.Username)) return; inside.Add(info.Username); UpdateButtons(); } void OnExit(TriggerInfo info) { if (!info.IsPlayer || string.IsNullOrEmpty(info.Username)) return; Leave(info.Username); } public override void OnUserPart(UserInfo user) => Leave(user.Username); void Leave(string username) { if (!inside.Remove(username)) return; HideActionButton(username, ButtonId); // Everything to or from them is quietly dropped (and a pose they are in ends). pending.RemoveWhere(key => key.StartsWith(username + "|") || key.EndsWith("|" + username)); queue.RemoveAll(pair => pair[0] == username || pair[1] == username); UpdateButtons(); } // The button is there only while someone else is in the area to ask. void UpdateButtons() { bool show = inside.Count >= 2; foreach (var username in inside) if (show) ShowActionButton(username, ButtonId, ButtonLabel, Key); else HideActionButton(username, ButtonId); } // ---------------------------------------------------------------- asking void OnButton(ActionButtonInfo info) { if (info.State != ActionButtonState.Down) return; string from = info.Username; if (!inside.Contains(from)) return; string to = PickPartner(from); if (to == null) { ShowToast(from, null, ButtonLabel, "Walk up to someone in the area first."); return; } string key = from + "|" + to; if (pending.Contains(key) || IsQueued(from, to)) { ShowToast(from, null, ButtonLabel, "Still waiting for " + to + "."); return; } pending.Add(key); ShowToast(from, null, ButtonLabel, "Asked " + to + "."); ShowDialog(ButtonLabel, from + " " + RequestText, ModalDialogIcon.Info, ModalDialogButtons.YesNo, result => { // Only when the request still stands: both still here, nobody cancelled it. if (!pending.Remove(key)) return; if (result != ModalDialogResult.Yes) return; if (!inside.Contains(from) || !inside.Contains(to)) return; queue.Add(new[] { from, to }); RunQueue(); }, to); } // The player you face within AskRange (the nearest one if nobody is in front of you). string PickPartner(string from) { var me = GetUser(from); if (me == null) return null; string best = null; float bestScore = float.MaxValue; foreach (var other in inside) { if (other == from) continue; var them = GetUser(other); if (them == null) continue; var gap = them.Position - me.Position; gap.y = 0; float distance = gap.magnitude; if (distance > AskRange) continue; float facing = distance > 0.01f ? Vector3.Dot(me.Forward, gap / distance) : 1f; float score = distance * (facing > 0.3f ? 1f : 3f); // in front of you counts as three times closer if (score < bestScore) { bestScore = score; best = other; } } return best; } bool IsQueued(string a, string b) { foreach (var pair in queue) if ((pair[0] == a && pair[1] == b) || (pair[0] == b && pair[1] == a)) return true; return false; } // ---------------------------------------------------------------- posing, one at a time per player void RunQueue() { for (int i = 0; i < queue.Count; i++) { var pair = queue[i]; if (busy.Contains(pair[0]) || busy.Contains(pair[1])) continue; queue.RemoveAt(i); i--; if (!inside.Contains(pair[0]) || !inside.Contains(pair[1])) continue; StartCoroutine(Pose(pair[0], pair[1])); } } IEnumerator Pose(string a, string b) { var ua = GetUser(a); var ub = GetUser(b); if (ua == null || ub == null) yield break; busy.Add(a); busy.Add(b); // Facing each other across the middle point of where they stand, Distance apart. var middle = (ua.Position + ub.Position) * 0.5f; var across = ub.Position - ua.Position; across.y = 0; if (across.sqrMagnitude < 0.0001f) across = ua.Forward; across = across.normalized; // Each player's own left (y up): A faces +across, B faces -across. var leftOfA = new Vector3(-across.z, 0, across.x); var spotA = middle - across * (Distance * 0.5f) + leftOfA * SideOffset; var spotB = middle + across * (Distance * 0.5f) - leftOfA * SideOffset; spotA.y = ua.Position.y; spotB.y = ub.Position.y; float yawA = FacingYaw(across) + TurnOffset, yawB = FacingYaw(-across) + TurnOffset; TeleportPlayer(a, spotA, new Vector3(0, yawA, 0), true); TeleportPlayer(b, spotB, new Vector3(0, yawB, 0), true); yield return new WaitForSeconds(0.2f); // both in place before the emote starts var emoteA = EmoteRequester; var emoteB = EmoteReceiver != null && !EmoteReceiver.IsNull ? EmoteReceiver : EmoteRequester; if (emoteA != null && !emoteA.IsNull) { PlayPlayerEmote(a, emoteA); PlayPlayerEmote(b, emoteB); } // Until the emote is over, or one of them leaves the area. float until = Time.time + PoseSeconds; while (Time.time < until && inside.Contains(a) && inside.Contains(b)) yield return new WaitForSeconds(0.1f); StopPlayerEmote(a); StopPlayerEmote(b); busy.Remove(a); busy.Remove(b); RunQueue(); // their next pose in line, if any } // TeleportPlayer's facing: degrees, 0 = toward -Z, 90 = toward +X. static float FacingYaw(Vector3 direction) => (float)(System.Math.Atan2(direction.x, -direction.z) * 180.0 / System.Math.PI); }