EzAdmin

Docs/Setup guides/Unity

Set up EzAdmin for PlayFab

Add the scripts and the CloudScript, connect PlayFab and check it works.

Unity 2021.3 or newer

What it does

Game bans

/gameban bans through PlayFab, by mention or by PlayFab ID. A Discord ban can reach the game too when you turn it on in /settings.

Role → item

Give a role and the catalog item is granted. Remove the role and EzAdmin takes back only the copy it granted, never one the player bought.

Leaderboards

/leaderboard and /stats read your PlayFab statistics straight into Discord.

Download

First, the PlayFab SDK. EzAdmin's scripts talk to PlayFab through PlayFab's own Unity SDK, so your project needs it before EzAdmin will compile. A game that already signs players in with PlayFab has it. If yours does not, do step 1 of Setup first.
Seeing error CS0246: The type or namespace name 'PlayFab' could not be found? One of two things: the PlayFab SDK is not in the project (import it, above), or Assets/EzAdmin sits inside a folder with its own .asmdef that does not reference PlayFab. Move EzAdmin out of that folder, or add PlayFab to that assembly definition's references. Step by step fix →

Pick one way to install. Both give you the same scripts. Your EzAdmin address, https://ezadmin.online, is already written into them, so there is nothing to fill in.

Easiest

Unity package

Every script for your project in one file.

  1. Download EzAdmin.unitypackage.
  2. In Unity, choose Assets › Import Package › Custom Package.
  3. Pick the file and press Import. The scripts go into Assets/EzAdmin.

Download the package

Updates come by themselves. When you open your project, EzAdmin checks for new scripts and asks: Update now, Later, or Always update automatically. Tools › EzAdmin › Check for Updates checks straight away. Only EzAdmin's own files change; Assets/EzAdmin/MyGame is never touched. Players get updates in your next build.

By hand

Individual files

For projects that keep outside code in their own folders.

  1. Download the zip, or copy each file from Files below.
  2. Put the .cs files and link.xml anywhere under Assets.
  3. Keep link.xml. It stops stripped builds removing code PlayFab needs.

Download the zip

The CloudScript goes into PlayFab, not your project, so it is not in the package. It is in the zip, and in Files below.

Setup

  1. Install the PlayFab SDK in Unity

    Skip this if your game already signs players in with PlayFab.

    1. Open PlayFab's Unity SDK releases and download PlayFabSDK.unitypackage from the newest release.
    2. In Unity: Assets › Import Package › Custom Package, pick the file, press Import. It goes into Assets/PlayFabSDK.
    3. Find PlayFabSharedSettings (search the Project window for it) and type your Title ID into it. The Title ID is on your title's card in Game Manager.
    4. Your game must sign players in to PlayFab (for example PlayFabClientAPI.LoginWithCustomID) before any EzAdmin feature can work: linking, items and mutes all belong to a signed in player.

    Unity now knows the PlayFab namespace, and the error CS0246: 'PlayFab' could not be found goes away.

    Game Manager › your title's card › Title ID

  2. Add EzAdmin to your Discord

    Invite it, then drag the EzAdmin role above the roles it should manage.

    Server Settings › Roles

  3. Make a PlayFab Secret Key just for EzAdmin

    A separate key means you can rotate it without breaking anything else. Run /setup and paste it into the private form. EzAdmin checks the key works before it saves anything.

    One Discord server per game. A PlayFab title can be connected to one Discord server at a time, so two communities can never moderate the same players. If another server already has it, /setup says so; its owner disconnects it there first (dashboard Settings › Games › Disconnect).

    Game Manager › your title › Title settings › Secret Keys

  4. Add the CloudScript

    Open EzAdmin.CloudScript.js from the zip or the files below, paste it at the bottom of your current revision, then save and deploy. It adds to your script and replaces nothing.

    Game Manager › Automation › CloudScript › Revisions

  5. Add the Unity scripts and the link screen

    Import the Unity package. The EzAdmin Setup Wizard opens by itself (or Window › EzAdmin › Setup Wizard). It asks what your game is played on, how players should type, whether you already sign players in to PlayFab, and which features you use, then presses every button for you:

    • adds the EzAdmin object, and the link screen set up for VR or flat
    • turns on the Quest's Requires System Keyboard when you pick the headset keyboard
    • adds a PlayFab sign in if your game has none yet
    • writes Assets/EzAdmin/MyGame/EzAdminHooks.cs: your own script with a method for "just linked", items (Legacy or Economy v2) and mutes, ready for your code. It is never overwritten.

    Prefer to do it by hand? In your first scene choose GameObject › EzAdmin › Link Screen.

    Players link on the EzAdmin website. The Link Discord button opens a page made for that player; they press Sign in with Discord and they are linked, with no code to type. The game notices by itself and the button turns green. On a Quest the page opens in the headset's browser over the game; on PC and phones, in the browser. To show it on a panel inside your game instead, add a web view plugin (such as Vuplex 3D WebView) and set EzAdminWeb.Opener, as EzAdminWeb.cs shows.

    Where the button goes:

    • In VR: a small board. Move the EzAdmin Link Screen object to a wall in your lobby and set Hand Layers to your hand layer; players press the button with a hand or a pointer.
    • Staff Panel: a second button opens the web Staff Panel (who is online, with Kick, Mute and Ban) for players whose Discord holds a moderation permission. Everyone else is told why not.

    The words and colours can be changed in the Inspector, and On Linked, On Status Changed and On Website Opened let you hook your own sounds, doors or a pause menu without writing code. Turn Build UI off to use your own buttons: call OpenLinkPage() and OpenStaffPanel().

    The EzAdmin address is built into the package, so there is nothing to type. Players must be signed in to PlayFab: by your own login code, or by EzAdmin Sign in (EzAdminAuth, which the wizard adds: Meta Quest, Steam, Xbox, PlayStation, phones and PC). The button waits for that by itself.

    Unity › GameObject › EzAdmin › Link Screen

  6. Link your first role

    Copy an item ID from your catalog, then run:

    /role-item add role:@VIP item_id:vip_hat
    /status

    Or pick the role and the item on the dashboard, under Role items. Then read on: where the item goes, and how your game shows it.

    Game Manager › Economy › Catalog

How items reach players

EzAdmin puts items straight into the player's PlayFab inventory, using your title's Secret Key. Nothing is sent to the game client and nothing in your game has to accept it: the next time your game reads the player's inventory, the item is there.

Which inventory: Legacy or Economy v2

PlayFab has two item systems, and a title uses one of them. /setup looks at your title and picks the one that has items. You can change it any time with /playfab economy.

Catalog (Legacy)Economy v2
Where you make itemsEconomy › Catalogs (Legacy) › your catalog versionEconomy › Catalog (the v2 one)
The ID you give EzAdminthe item's Item ID, e.g. vip_hatthe item's ID, the long one shown on the item
Where it landsthe player's inventory, as an item instancethe player's Economy v2 inventory, as a stack
See it in Game ManagerPlayers › the player › InventoryPlayers › the player › Inventory (Economy v2)
Your game reads it withPlayFabClientAPI.GetUserInventoryPlayFabEconomyAPI.GetInventoryItems
Catalog versionyour default catalog, or set one with /playfab economynot used

When a player gets it

  • They must have linked their game account to Discord (/link, or the link panel in your game). Until then EzAdmin cannot know which PlayFab player they are.
  • The moment they link, EzAdmin gives them the items every role they already hold unlocks.
  • When they get a role in Discord, the item follows within seconds. Somebody who buys @VIP on Patreon gets the hat without anyone lifting a finger.
  • When they lose the role, EzAdmin takes back the one copy it gave. A copy they bought or earned in your game is never touched. Turn this off in /settings (Take items back when the role is removed) to let them keep it.
  • By hand: the server owner can give any item from the dashboard, and staff can /propose item for an approver to decide. /role-item sync member:@them rechecks one person.

Show it in your game, no code

Add EzAdminItemUnlock to any object that stays switched on (your player, a holder, a menu). Type the item's id or its catalog name into Item, and drag the hat, sword or button into Show When Owned. It appears the moment the player owns it, within about 30 seconds of a Discord role giving it, even mid game, and hides again if the role is removed. Anything to hide when they own it (a lock icon, a Buy button) goes in Hide When Owned. In code: EzAdminItems.Has("vip_hat").

Purchase and Owned labels

Tick Can Buy on EzAdminItemUnlock and it sells the item for your virtual currency. Any text on the object, or the text objects you drag into Labels, reads Purchase: VIP Hat with the price before they buy, and Owned: VIP Hat after. Change the words under Text labels: {buy}, {owned}, {name}, {price} and {currency} are filled in.

Or read the inventory yourself

Read the inventory after your PlayFab login, and again when the player opens their inventory or shop, so a role that changed while they played shows up.

Catalog (Legacy):

using PlayFab;
using PlayFab.ClientModels;

PlayFabClientAPI.GetUserInventory(new GetUserInventoryRequest(), result =>
{
    foreach (ItemInstance item in result.Inventory)
    {
        if (item.ItemId == "vip_hat")
        {
            vipHatButton.SetActive(true);
        }
    }
}, error => Debug.LogWarning(error.GenerateErrorReport()));

Economy v2:

using PlayFab;
using PlayFab.EconomyModels;

PlayFabEconomyAPI.GetInventoryItems(new GetInventoryItemsRequest { Count = 50 }, result =>
{
    foreach (InventoryItem item in result.Items)
    {
        if (item.Id == VipHatId)
        {
            vipHatButton.SetActive(true);
        }
    }
}, error => Debug.LogWarning(error.GenerateErrorReport()));

Using the item (equipping the hat, spending a coin pack) is your game's own code, exactly as for an item the player bought. Anything that grants something more in return, such as currency, belongs in CloudScript: see the developer reference.

When an item does not arrive

  • "PlayFab refused" in Discord: the item ID is not in the catalog EzAdmin is using. Check the ID letter for letter, and on Legacy check the catalog version with /playfab economy.
  • Economy v2, "has not played this title yet": a v2 inventory only exists once the player has signed in to your game at least once.
  • Nothing happens at all: run /status. It says whether the player is linked, whether role items are switched on in /settings, and whether your plan covers that many roles.
  • It is there in Game Manager but not in game: your game read the inventory before the item was added. Read it again (see above).

Sign in with Discord

Players can link by signing in with Discord instead of typing a code. Discord shows its own screen asking to connect your game, and EzAdmin checks the sign in with Discord and the game session with PlayFab, so neither can be faked.

  1. Make a Discord application for your game

    Players see its name on Discord's sign in screen, so name it after your game.

    Discord Developer Portal › New Application

  2. Import the Discord Social SDK

    Then add EZADMIN_DISCORD_SDK to your scripting define symbols, so EzAdminDiscord compiles.

    Player Settings › Other Settings › Scripting Define Symbols

  3. Tell EzAdmin your application ID

    Run /playfab discord-app with it. The SDK already knows where EzAdmin is.

  4. Add EzAdminDiscord

    Put it next to EzAdminClient. Set the application ID and wire a Sign in with Discord button to EzAdminLinkPanel.LinkWithDiscord.

Linking method on EzAdminLinkPanelWhat players get
AutomaticSign in with Discord, except in a VR headset, where they type the code.
Code onlyThe code from /link. The usual choice for VR only games.
Discord onlySign in with Discord.
Code and DiscordBoth, side by side.

Loading and sign in screen

Open your game scene, then choose Tools › EzAdmin › Create Loading Screen Scene. EzAdmin makes a first scene, puts it at the top of Build Settings and points it at your game. When the game starts it signs the player in, loads their items, then opens your world, with a progress bar and a Try again button if sign in fails.

  • Make it yours: on the EzAdmin Loading Screen object, set your game name, logo, background picture, colours and loading tips.
  • VR: on a headset it floats in front of the player and follows their head gently. On PC and phones it fills the screen.
  • Your own sign in: if your game signs in with its own script, keep that script in this scene and remove EzAdmin Sign in. The screen waits for any PlayFab sign in.
  • Take it out: Tools › EzAdmin › Remove Loading Screen From Build.

Player reports and ban notices

Two optional additions. The dashboard's Game files page has both, with your EzAdmin address filled in.

  1. Your Report button

    Call EzAdminClient.Instance.ReportPlayer(playFabId, displayName, reason, callback). The report arrives in Discord with buttons to ban for a day, a week or for good, or to dismiss it. It is sent as the player signed in on that device, so nobody can report in someone else's name.

  2. Ban notices from your anti tamper

    Set EZADMIN_ADDRESS at the bottom of the EzAdmin CloudScript, then call ezadminNotifyBan(playerId, playerName) straight after your own code bans someone. EzAdmin checks the ban with PlayFab and posts it in Discord with a Lift ban button. It bans nobody itself.

Reports and notices go to the channel set with /reports channel, or your mod log.

Files

Everything EzAdmin adds to your project, printed in full. Read it before you install it: you should never add server code you have not read, from anyone.

PlayFab

EzAdmin.CloudScript.jsPlayFab Game Manager › Automation › CloudScript. Paste at the bottom of your revision.9.6 KB · 333 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

var EZADMIN_CLOUDSCRIPT_VERSION = "1.3.0";

var EZADMIN_LINK_PREFIX = "EzAdminLink_";
var EZADMIN_ATTEMPT_KEY = "EzAdmin_LinkAttempts";
var EZADMIN_CODE_LENGTH = 8;

var EZADMIN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

var EZADMIN_MAX_ATTEMPTS = 8;
var EZADMIN_ATTEMPT_WINDOW_SECONDS = 900;

function ezadminNormaliseCode(raw) {
    if (typeof raw !== "string") {
        return "";
    }
    var code = raw.toUpperCase().replace(/[^A-Z0-9]/g, "");
    if (code.length !== EZADMIN_CODE_LENGTH) {
        return "";
    }
    for (var i = 0; i < code.length; i++) {
        if (EZADMIN_ALPHABET.indexOf(code.charAt(i)) === -1) {
            return "";
        }
    }
    return code;
}

function ezadminNowSeconds() {
    return Math.floor(new Date().getTime() / 1000);
}

function ezadminCheckAttempts(playerId) {
    var now = ezadminNowSeconds();
    var state = { start: now, count: 0 };

    var read = server.GetUserInternalData({
        PlayFabId: playerId,
        Keys: [EZADMIN_ATTEMPT_KEY]
    });

    if (read.Data && read.Data[EZADMIN_ATTEMPT_KEY] && read.Data[EZADMIN_ATTEMPT_KEY].Value) {
        try {
            var parsed = JSON.parse(read.Data[EZADMIN_ATTEMPT_KEY].Value);
            if (parsed && typeof parsed.start === "number" && typeof parsed.count === "number") {
                state = parsed;
            }
        } catch (e) {

            state = { start: now, count: 0 };
        }
    }

    if (now - state.start >= EZADMIN_ATTEMPT_WINDOW_SECONDS) {
        state = { start: now, count: 0 };
    }

    if (state.count >= EZADMIN_MAX_ATTEMPTS) {
        return { allowed: false, remaining: 0 };
    }

    state.count += 1;

    var update = {};
    update[EZADMIN_ATTEMPT_KEY] = JSON.stringify(state);
    server.UpdateUserInternalData({
        PlayFabId: playerId,
        Data: update
    });

    return { allowed: true, remaining: EZADMIN_MAX_ATTEMPTS - state.count };
}

handlers.EzAdminRedeemLinkCode = function (args, context) {

    var code = ezadminNormaliseCode(args && args.code);
    if (!code) {
        return {
            success: false,
            message: "That code is not valid. It is " + EZADMIN_CODE_LENGTH +
                     " characters, letters and numbers only."
        };
    }

    var playerId = currentPlayerId;
    if (!playerId) {
        return { success: false, message: "No player session." };
    }

    var budget = ezadminCheckAttempts(playerId);
    if (!budget.allowed) {
        return {
            success: false,
            message: "Too many link attempts. Wait 15 minutes and try again.",
            remaining: 0
        };
    }

    var key = EZADMIN_LINK_PREFIX + code;

    var existing = server.GetTitleInternalData({ Keys: [key] });
    if (existing.Data && existing.Data[key]) {
        if (existing.Data[key] === playerId) {
            return {
                success: true,
                message: "Already redeemed. Run /link again in Discord to finish.",
                remaining: budget.remaining
            };
        }

        return {
            success: false,
            message: "That code has already been used by another account.",
            remaining: budget.remaining
        };
    }

    server.SetTitleInternalData({
        Key: key,
        Value: playerId
    });

    return {
        success: true,
        message: "Code accepted. Run /link again in Discord to finish linking.",
        remaining: budget.remaining
    };
};

handlers.EzAdminGetLinkStatus = function (args, context) {

    var playerId = currentPlayerId;
    if (!playerId) {
        return { linked: false, discordName: "" };
    }

    var read = server.GetUserReadOnlyData({
        PlayFabId: playerId,
        Keys: ["EzAdmin_Link"]
    });

    if (!read.Data || !read.Data["EzAdmin_Link"] || !read.Data["EzAdmin_Link"].Value) {
        return { linked: false, discordName: "" };
    }

    try {
        var record = JSON.parse(read.Data["EzAdmin_Link"].Value);
        return {
            linked: true,

            discordName: record.discordName || ""
        };
    } catch (e) {
        return { linked: false, discordName: "" };
    }
};

var EZADMIN_ADDRESS = "https://ezadmin.online";

function ezadminNotifyBan(playFabId, playerName) {
    if (typeof playFabId !== "string" || !playFabId ||
        EZADMIN_ADDRESS.indexOf("YOUR-EZADMIN-HOST") !== -1) {
        return false;
    }
    var address = EZADMIN_ADDRESS;
    while (address.charAt(address.length - 1) === "/") {
        address = address.substring(0, address.length - 1);
    }
    try {
        http.request(
            address + "/unity/v1/ban",
            "post",
            JSON.stringify({
                titleId: script.titleId,
                playFabId: playFabId,
                name: String(playerName || "").substring(0, 40)
            }),
            "application/json"
        );
        return true;
    } catch (e) {

        log.error("EzAdmin ban notice not sent: " + e);
        return false;
    }
}

function ezadminReadOnly(playFabId, key) {
    if (typeof playFabId !== "string" || !playFabId) {
        return null;
    }
    try {
        var read = server.GetUserReadOnlyData({ PlayFabId: playFabId, Keys: [key] });
        if (!read.Data || !read.Data[key] || !read.Data[key].Value) {
            return null;
        }
        return JSON.parse(read.Data[key].Value);
    } catch (e) {
        log.error("EzAdmin could not read " + key + ": " + e);
        return null;
    }
}

function ezadminMuteSecondsLeft(playFabId) {
    var mute = ezadminReadOnly(playFabId, "EzAdmin_Mute");
    if (!mute || typeof mute.until !== "number") {
        return 0;
    }
    return Math.max(0, Math.floor(mute.until - Date.now() / 1000));
}

function ezadminIsMuted(playFabId) {
    return ezadminMuteSecondsLeft(playFabId) > 0;
}

handlers.EzAdminGetMyStatus = function (args, context) {
    return {
        muted: ezadminIsMuted(currentPlayerId),
        mutedFor: ezadminMuteSecondsLeft(currentPlayerId)
    };
};

var EZADMIN_APPLIED_KEY = "EzAdmin_Applied";

function ezadminCleanAddress() {
    var address = EZADMIN_ADDRESS;
    while (address.charAt(address.length - 1) === "/") {
        address = address.substring(0, address.length - 1);
    }
    return address;
}

function ezadminPost(path, bodyObj) {
    try {
        var res = http.request(ezadminCleanAddress() + path, "post",
                               JSON.stringify(bodyObj), "application/json");
        return JSON.parse(res);
    } catch (e) {
        log.error("EzAdmin request to " + path + " failed: " + e);
        return null;
    }
}

function ezadminAppliedList(playerId) {
    try {
        var read = server.GetUserInternalData({ PlayFabId: playerId, Keys: [EZADMIN_APPLIED_KEY] });
        if (read.Data && read.Data[EZADMIN_APPLIED_KEY] && read.Data[EZADMIN_APPLIED_KEY].Value) {
            return JSON.parse(read.Data[EZADMIN_APPLIED_KEY].Value) || [];
        }
    } catch (e) {
        log.error("EzAdmin applied read failed: " + e);
    }
    return [];
}

function ezadminSaveApplied(playerId, list) {
    if (list.length > 50) {
        list = list.slice(list.length - 50);
    }
    var update = {};
    update[EZADMIN_APPLIED_KEY] = JSON.stringify(list);
    server.UpdateUserInternalData({ PlayFabId: playerId, Data: update });
}

handlers.EzAdminApply = function (args, context) {
    var playerId = currentPlayerId;
    if (!playerId || EZADMIN_ADDRESS.indexOf("YOUR-EZADMIN-HOST") !== -1) {
        return { ok: false };
    }

    var pulled = ezadminPost("/unity/v1/cs/pull", {
        titleId: script.titleId,
        playFabId: playerId
    });
    if (!pulled || !pulled.actions || !pulled.actions.length) {
        return { ok: true, done: 0 };
    }

    var applied = ezadminAppliedList(playerId);
    var done = [];
    for (var i = 0; i < pulled.actions.length; i++) {
        var action = pulled.actions[i];
        if (!action || !action.nonce) {
            continue;
        }
        if (applied.indexOf(action.nonce) !== -1) {
            done.push(action.nonce);
            continue;
        }
        try {
            if (action.op === "grant" && action.item) {
                var count = Math.max(1, Math.min(action.amount || 1, 100));
                var ids = [];
                for (var k = 0; k < count; k++) {
                    ids.push(action.item);
                }
                var grant = { PlayFabId: playerId, ItemIds: ids };
                if (pulled.catalog) {
                    grant.CatalogVersion = pulled.catalog;
                }
                server.GrantItemsToUser(grant);
            } else if (action.op === "revoke" && action.item) {
                var inv = server.GetUserInventory({ PlayFabId: playerId });
                var items = (inv && inv.Inventory) || [];
                for (var j = 0; j < items.length; j++) {
                    if (items[j].ItemId === action.item && items[j].ItemInstanceId) {
                        server.RevokeInventoryItem({
                            PlayFabId: playerId,
                            ItemInstanceId: items[j].ItemInstanceId
                        });
                        break;
                    }
                }
            }
            applied.push(action.nonce);
            done.push(action.nonce);
        } catch (e) {
            log.error("EzAdmin apply failed for " + action.nonce + ": " + e);
        }
    }

    ezadminSaveApplied(playerId, applied);
    if (done.length) {
        ezadminPost("/unity/v1/cs/done", {
            titleId: script.titleId,
            playFabId: playerId,
            done: done
        });
    }
    return { ok: true, done: done.length };
};

Unity project

EzAdminClient.csAssets/EzAdmin/Runtime. Add it to one object in your first scene.78.2 KB · 2004 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;

namespace EzAdmin.Client
{

    public sealed class EzAdminClient : MonoBehaviour
    {

        public static class CloudFunctions
        {
            public const string RedeemLinkCode = "EzAdminRedeemLinkCode";
            public const string GetLinkStatus = "EzAdminGetLinkStatus";
        }

        public const string Version = "2.13.0";

        public static EzAdminClient Instance { get; private set; }

        [EzAdminGroup("Behaviour")]
        [Tooltip("Keep this object alive across scene loads. Turn off if you place one " +
                 "per scene instead of in a bootstrap scene.")]
        [SerializeField] private bool _persistAcrossScenes = true;

        [Tooltip("Log EzAdmin activity to the console. Editor only either way.")]
        [SerializeField] private bool _verboseLogging;

        [Tooltip("Show /announce messages from Discord on screen (a banner, or a floating card in VR) when nothing " +
                 "else in your game shows them. Turn off if your game shows EzAdminClient.Announcement itself.")]
        [SerializeField] private bool _showAnnouncementBanner = true;

        private const string BakedAddress = "";
        public const string DefaultAddress = "https://ezadmin.online";
        public static string SiteAddress => (string.IsNullOrEmpty(BakedAddress) ? DefaultAddress : BakedAddress).TrimEnd('/');
        private string _ezAdminAddress = SiteAddress;

        [EzAdminGroup("Signing in")]
        [Tooltip("Sign the player in to PlayFab with this device when your game does not do it itself. " +
                 "Turn OFF if your game has its own PlayFab login (Meta, Steam, email...).")]
        [SerializeField] private bool _signInIfNeeded = true;

        [Tooltip("How long to wait for your own login code before signing in for you.")]
        [SerializeField, Range(0f, 30f)] private float _signInAfterSeconds = 2f;

        public enum KickAction { QuitGame, LoadScene, EventOnly }

        [EzAdminGroup("When a moderator kicks this player")]
        [Tooltip("QuitGame closes the game. LoadScene loads Kick Scene (e.g. your main menu). " +
                 "EventOnly does nothing itself: handle EzAdminClient.Kicked in your code.")]
        [SerializeField] private KickAction _onKick = KickAction.QuitGame;
        [SerializeField] private string _kickScene = "";

        [EzAdminGroup("Require a connection to play")]
        [Tooltip("Players must stay connected to EzAdmin to keep playing. If a player blocks EzAdmin (a firewall, " +
                 "or pulling their internet to dodge the anti tamper), they are kicked after the grace time below. " +
                 "Off by default, so a short internet drop never removes an honest player.")]
        [SerializeField] private bool _requireConnection = false;
        [Tooltip("Seconds EzAdmin can be unreachable before the player is kicked. Keep it well above one heartbeat.")]
        [SerializeField, Range(30f, 600f)] private float _connectionGrace = 120f;

        private float _lastGoodBeat;

        public static event Action<string> Kicked;

        public static event Action<bool, int> MuteChanged;

        public static bool IsShadowMuted => ShadowMuteUntil > (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;

        public static long ShadowMuteUntil { get; private set; }

        public static event Action<bool> ShadowMuteChanged;

        private static void SetShadowMute(long until)
        {
            bool was = IsShadowMuted;
            ShadowMuteUntil = until;
            if (was != IsShadowMuted) ShadowMuteChanged?.Invoke(IsShadowMuted);
        }

        public static event Action ItemsChanged;

        public static event Action<string> SignedIn;

        public static event Action<string> Announcement;

        public static string LastAnnouncement { get; private set; } = "";

        public static event Action<bool> FreezeChanged;
        public static bool IsFrozen { get; private set; }

        public static string ServerName { get; private set; } = "";
        public static string CatalogVersion { get; private set; } = "";
        public static Sprite ServerIcon { get; private set; }
        public static event Action ServerBrandChanged;
        private static string _serverIconUrl = "";

        private void ApplyServerBrand(string name, string iconUrl)
        {
            bool changed = false;
            if (!string.IsNullOrEmpty(name) && name != ServerName)
            {
                ServerName = name;
                changed = true;
            }
            iconUrl = iconUrl ?? "";
            if (!string.IsNullOrEmpty(iconUrl) && iconUrl != _serverIconUrl)
            {
                _serverIconUrl = iconUrl;
                StartCoroutine(DownloadServerIcon(iconUrl));
            }
            else if (changed)
            {
                ServerBrandChanged?.Invoke();
            }
        }

        private System.Collections.IEnumerator DownloadServerIcon(string url)
        {
            using (var request = UnityEngine.Networking.UnityWebRequestTexture.GetTexture(url))
            {
                yield return request.SendWebRequest();
                if (request.result != UnityEngine.Networking.UnityWebRequest.Result.Success) yield break;
                var texture = UnityEngine.Networking.DownloadHandlerTexture.GetContent(request);
                if (texture == null) yield break;
                ServerIcon = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height),
                                           new Vector2(0.5f, 0.5f), 100f);
                ServerBrandChanged?.Invoke();
            }
        }

        public static bool IsStaff { get; private set; }
        public static event Action<bool> StaffChanged;

        private static void SetStaff(bool staff)
        {
            if (staff == IsStaff) return;
            IsStaff = staff;
            StaffChanged?.Invoke(staff);
        }

        public static bool CanUseStaffTools { get; private set; }
        public static event Action<bool> StaffToolsChanged;

        private static void SetStaffTools(bool allowed)
        {
            if (allowed == CanUseStaffTools) return;
            CanUseStaffTools = allowed;
            StaffToolsChanged?.Invoke(allowed);
        }

        public void LogStaffTool(string tool, bool? on = null, string targetName = "")
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken) || string.IsNullOrEmpty(tool))
                return;
            string json = "{\"token\":" + Quote(_playToken) + ",\"tool\":" + Quote(tool)
                          + ",\"target\":" + Quote(targetName ?? "")
                          + (on.HasValue ? ",\"on\":" + (on.Value ? "true" : "false") : "") + "}";
            StartCoroutine(PostJson("/unity/v1/stafftool", json, _ => { }));
        }

        private static string Quote(string text)
        {
            var builder = new System.Text.StringBuilder("\"");
            foreach (char c in text)
            {
                if (c == '"' || c == '\\') builder.Append('\\').Append(c);
                else if (c < ' ') builder.Append(' ');
                else builder.Append(c);
            }
            return builder.Append('"').ToString();
        }

        public static event Action<int, string> ShutdownScheduled;

        public static event Action EventChanged;

        public static string EventName { get; private set; } = "";

        public static float Multiplier { get; private set; } = 1f;

        public static long EventEndsAt { get; private set; }

        private long _shutdownAt;

        private string _playToken;

        public static string PlayToken => Instance != null ? Instance._playToken ?? "" : "";

        public static string VerifyAddress => SiteAddress + "/unity/v1/play/verify";

        private string _acNonce = "";
        private string _room = "";
        private long _announceAfter = -1;
        private long _shownAnnounceId = -1;
        private string _shownAnnounceText = "";
        private float _shownAnnounceAt;
        private float _beatSeconds = 30f;
        private string _lastNetworkError;
        private string _signInError;

        public string ConnectionProblem { get; private set; } = "Starting up…";

        public static event Action<string> ConnectionChanged;

        private bool _cloudScriptMissing;

        private void SetProblem(string problem)
        {
            if (problem == null && _cloudScriptMissing) problem = CloudScriptMissing;
            if (problem == ConnectionProblem) return;
            ConnectionProblem = problem;
            if (problem == null)
            {
                Log("Connected to EzAdmin.");
            }
            else
            {
                Debug.LogWarning("EzAdmin: " + problem, this);
            }
            ConnectionChanged?.Invoke(problem);
        }

        private string NetworkProblem()
        {
            string error = _lastNetworkError ?? "";
            if (error.IndexOf("Insecure", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "Unity is blocking the plain http:// address " + _ezAdminAddress + ". Use your https:// address, " +
                       "or for testing set Project Settings › Player › Other Settings › Allow downloads over HTTP " +
                       "to Always allowed." + Fix("unity-http-blocked");
            }
            string where = _ezAdminAddress.TrimEnd('/');
            string local = where.Contains("localhost") || where.Contains("127.0.0.1")
                ? " localhost only works on the PC EzAdmin runs on: a headset or phone needs your online address."
                : "";
            return "Cannot reach EzAdmin at " + where + " (" + (string.IsNullOrEmpty(error) ? "no answer" : error) +
                   "). Is EzAdmin running, and is the address right?" + local + Fix("unity-cannot-reach");
        }

        private string Fix(string problemId)
        {
            string site = (_ezAdminAddress ?? "").Trim().TrimEnd('/');
            return string.IsNullOrEmpty(site) ? "" : " How to fix: " + site + "/troubleshooting.html#" + problemId;
        }
        private bool _refusalLogged;

        private bool _requestInFlight;

        public bool IsBusy => _requestInFlight;

        public bool IsSignedIn => PlayFabClientAPI.IsClientLoggedIn();

        public bool CanLinkWithDiscord => !string.IsNullOrEmpty(_ezAdminAddress);

        public void LinkWithDiscordToken(string discordAccessToken, Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            if (!IsSignedIn)
            {
                onComplete(false, "You are not signed in yet. Try again in a moment.");
                return;
            }

            if (!CanLinkWithDiscord)
            {
                onComplete(false, "Linking with Discord is not set up in this game.");
                return;
            }

            if (string.IsNullOrEmpty(discordAccessToken))
            {
                onComplete(false, "Discord did not sign you in.");
                return;
            }

            if (_requestInFlight)
            {
                onComplete(false, "Still linking - one moment.");
                return;
            }

            _requestInFlight = true;
            Log("Linking with Discord.");
            StartCoroutine(PostDiscordLink(discordAccessToken, onComplete));
        }

        private System.Collections.IEnumerator PostDiscordLink(string discordAccessToken,
                                                               Action<bool, string> onComplete)
        {
            var body = new DiscordLinkRequest
            {
                titleId = PlayFabSettings.staticSettings.TitleId,
                sessionTicket = PlayFabSettings.staticPlayer.ClientSessionTicket,
                discordToken = discordAccessToken
            };

            string url = _ezAdminAddress.TrimEnd('/') + "/unity/v1/discord-link";
            byte[] payload = System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(body));

            using (var request = new UnityEngine.Networking.UnityWebRequest(url, "POST"))
            {
                request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(payload);
                request.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.timeout = 20;

                yield return request.SendWebRequest();
                _requestInFlight = false;

                DiscordLinkResponse response = null;
                try
                {
                    response = JsonUtility.FromJson<DiscordLinkResponse>(request.downloadHandler.text);
                }
                catch (Exception)
                {
                    response = null;
                }

                if (response == null)
                {
                    onComplete(false, "Could not reach EzAdmin. Check your connection and try again.");
                    yield break;
                }

                Log("Discord link result: " + response.ok);
                string message = response.message;
                if (string.IsNullOrEmpty(message))
                {
                    message = response.ok ? "Linked with Discord." : "Linking with Discord did not work.";
                }

                onComplete(response.ok, message);
            }
        }

        private void Awake()
        {
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }

            Instance = this;

            if (_persistAcrossScenes)
            {
                DontDestroyOnLoad(gameObject);
            }
        }

        [EzAdminGroup("Error reports")]
        [Tooltip("Send this game's errors and exceptions to EzAdmin, grouped on the dashboard. Only used when " +
                 "Error reports is switched on for your server.")]
        [SerializeField] private bool _sendErrorReports = true;

        private readonly List<ErrorItem> _errorQueue = new List<ErrorItem>();
        private readonly HashSet<string> _errorsSent = new HashSet<string>();
        private readonly object _errorLock = new object();

        private void OnLogMessage(string condition, string stackTrace, LogType type)
        {
            if (type != LogType.Exception && type != LogType.Error) return;
            if (condition != null && condition.StartsWith("EzAdmin:")) return;
            string first = string.IsNullOrEmpty(stackTrace) ? "" : stackTrace.Split('\n')[0];
            string key = (condition ?? "") + "|" + first;
            lock (_errorLock)
            {
                if (_errorsSent.Count > 500 || !_errorsSent.Add(key) || _errorQueue.Count >= 20) return;
                _errorQueue.Add(new ErrorItem
                {
                    message = (condition ?? "").Length > 300 ? condition.Substring(0, 300) : (condition ?? ""),
                    stack = first.Length > 300 ? first.Substring(0, 300) : first,
                    version = Application.version
                });
            }
        }

        private System.Collections.IEnumerator SendErrors()
        {
            while (true)
            {
                yield return new WaitForSecondsRealtime(60f);
                if (string.IsNullOrEmpty(_playToken)) continue;
                ErrorItem[] batch;
                lock (_errorLock)
                {
                    if (_errorQueue.Count == 0) continue;
                    batch = _errorQueue.ToArray();
                    _errorQueue.Clear();
                }
                var body = new ErrorBatch { token = _playToken, errors = batch };
                yield return PostJson("/unity/v1/errors", JsonUtility.ToJson(body), _ => { });
            }
        }

        public void SetRoom(string roomName)
        {
            _room = string.IsNullOrEmpty(roomName) ? "" : (roomName.Length > 60 ? roomName.Substring(0, 60) : roomName);
        }

        public string Room => _room;

        public void LookupPlayer(string nameOrPlayFabId, Action<bool, LookupResult> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken) || string.IsNullOrEmpty(nameOrPlayFabId))
            {
                onComplete?.Invoke(false, new LookupResult { message = "Not connected to EzAdmin yet." });
                return;
            }
            var body = new LookupRequest { token = _playToken, query = nameOrPlayFabId };
            StartCoroutine(PostJson("/unity/v1/lookup", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<LookupResult>(text) ?? new LookupResult { message = "EzAdmin did not answer." };
                onComplete?.Invoke(reply.ok, reply);
            }));
        }

        public void ReportAchievement(string badgeId, string displayName, Action<bool> onComplete = null)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false);
                return;
            }
            var body = new AchievementRequest { token = _playToken, badge = badgeId ?? "", name = displayName ?? "" };
            StartCoroutine(PostJson("/unity/v1/achievement", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                onComplete?.Invoke(reply != null && reply.ok);
            }));
        }

        private void Start()
        {
            if (_sendErrorReports && CanLinkWithDiscord)
            {
                Application.logMessageReceivedThreaded += OnLogMessage;
                StartCoroutine(SendErrors());
            }
            if (_signInIfNeeded)
            {
                StartCoroutine(SignInIfNeeded());
            }

            if (CanLinkWithDiscord)
            {
                StartCoroutine(PlaytimeLoop());
                StartCoroutine(ItemWatch());
            }
            else
            {
                Debug.LogWarning("EzAdmin: the EzAdmin address on EzAdminClient is empty, so nothing can reach " +
                                 "EzAdmin. Window › EzAdmin › Setup Wizard sets it.", this);
            }
        }

        private System.Collections.IEnumerator SignInIfNeeded()
        {
            yield return new WaitForSecondsRealtime(_signInAfterSeconds);
            if (IsSignedIn)
            {
                yield break;
            }
            if (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId))
            {
                Debug.LogError("EzAdmin: no PlayFab Title ID, so nobody can sign in. Search the Project window for " +
                               "PlayFabSharedSettings and type your Title ID in.", this);
                yield break;
            }
            while (!IsSignedIn)
            {
                bool answered = false;
                PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
                {
                    CustomId = SystemInfo.deviceUniqueIdentifier,
                    CreateAccount = true
                }, result =>
                {
                    answered = true;
                    _signInError = null;
                    Log("Signed in to PlayFab as " + result.PlayFabId);
                    SignedIn?.Invoke(result.PlayFabId);
                }, error =>
                {
                    answered = true;
                    _signInError = SignInAdvice(error);
                    if (_signInError.IndexOf("refused EzAdmin's sign-in", StringComparison.Ordinal) >= 0)
                    {
                        _signInError += Fix("unity-signin-refused");
                    }
                    SetProblem(_signInError);
                    Debug.LogWarning("EzAdmin: PlayFab sign-in failed: " + error.GenerateErrorReport(), this);
                });
                while (!answered) yield return null;
                if (IsSignedIn) yield break;

                yield return new WaitForSecondsRealtime(10f);
            }
        }

        private static string SignInAdvice(PlayFabError error)
        {
            string title = PlayFabSettings.staticSettings.TitleId;
            string said = string.IsNullOrEmpty(error.ErrorMessage) ? error.Error.ToString() : error.ErrorMessage;
            if (said.IndexOf("not authorized", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "PlayFab title " + title + " refused EzAdmin's sign-in (\"" + said + "\"): it only lets your " +
                       "game's own login sign players in. Turn OFF Sign In If Needed on EzAdminClient and put EzAdmin " +
                       "in the scene where your login runs: EzAdmin then uses that player.";
            }
            if (said.IndexOf("banned", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                return "PlayFab says this device's account is banned on title " + title + ".";
            }
            return "PlayFab sign-in failed on title " + title + ": " + said;
        }

        private void HandleBeat(BeatResponse reply)
        {
            if (reply == null) return;
            if (reply.muteUntil != 0 || reply.muteCleared)
            {
                double now = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
                int left = (int)Math.Max(0, reply.muteUntil - now);
                MuteChanged?.Invoke(left > 0, left);
            }
            if (reply.shadowMuteUntil != 0 || reply.shadowMuteCleared)
            {
                SetShadowMute(reply.shadowMuteCleared ? 0 : reply.shadowMuteUntil);
            }
            if (reply.itemsChanged)
            {
                ItemsChanged?.Invoke();
            }
            if (reply.announceId > 0)
            {
                _announceAfter = reply.announceId;
            }
            if (!string.IsNullOrEmpty(reply.announcement))
            {
                bool sameId = reply.announceId > 0 && reply.announceId == _shownAnnounceId;
                bool sameText = reply.announcement == _shownAnnounceText
                                && Time.realtimeSinceStartup - _shownAnnounceAt < 120f;
                if (!sameId && !sameText)
                {
                    _shownAnnounceId = reply.announceId;
                    _shownAnnounceText = reply.announcement;
                    _shownAnnounceAt = Time.realtimeSinceStartup;
                    LastAnnouncement = reply.announcement;
                    Log("Announcement: " + reply.announcement);
                    Announcement?.Invoke(reply.announcement);

                    if (_showAnnouncementBanner && EzAdminLinkScreen.ShowingAnnouncements == 0)
                    {
                        var shown = reply.announceStyle ?? new EzAdminAnnouncement();
                        shown.text = reply.announcement;
                        EzAdminAnnouncer.Show(shown);
                    }
                }
            }
            SetStaff(reply.staff);
            SetStaffTools(reply.staffTools);
            EzAdminGame.Offer(reply.liveopsRev);
            if (reply.freezeChanged)
            {
                IsFrozen = reply.frozen;
                FreezeChanged?.Invoke(reply.frozen);
            }
            string eventName = reply.eventName ?? "";
            float multiplier = reply.eventMultiplier > 0 ? reply.eventMultiplier : 1f;
            if (eventName != EventName || !Mathf.Approximately(multiplier, Multiplier))
            {
                EventName = eventName;
                Multiplier = string.IsNullOrEmpty(eventName) ? 1f : multiplier;
                EventEndsAt = string.IsNullOrEmpty(eventName) ? 0 : reply.eventEndsAt;
                EventChanged?.Invoke();
            }
            if (reply.shutdownAt > 0 && reply.shutdownAt != _shutdownAt)
            {
                _shutdownAt = reply.shutdownAt;
                StartCoroutine(ShutdownCountdown(reply.shutdownAt,
                    string.IsNullOrEmpty(reply.shutdownMessage) ? "The game is restarting." : reply.shutdownMessage));
            }
            if (reply.runCloudScript) RunCloudScriptApply();
            if (!string.IsNullOrEmpty(reply.kick))
            {
                DoKick(reply.kick);
            }
        }

        private bool _applyingCloudScript;

        private void RunCloudScriptApply()
        {
            if (_applyingCloudScript || !PlayFabClientAPI.IsClientLoggedIn()) return;
            _applyingCloudScript = true;
            PlayFabClientAPI.ExecuteCloudScript(new ExecuteCloudScriptRequest
            {
                FunctionName = "EzAdminApply",
                GeneratePlayStreamEvent = false
            }, result =>
            {
                _applyingCloudScript = false;
                ItemsChanged?.Invoke();
            }, error =>
            {
                _applyingCloudScript = false;
            });
        }

        public const float ItemCheckSeconds = 5f;

        private System.Collections.IEnumerator ItemWatch()
        {
            var wait = new WaitForSecondsRealtime(ItemCheckSeconds);
            while (true)
            {
                yield return wait;
                if (string.IsNullOrEmpty(_playToken) || !Application.isFocused) continue;
                bool changed = false;
                yield return PostJson("/unity/v1/play/items-check", JsonUtility.ToJson(new ItemsRequest { token = _playToken }),
                    text =>
                    {
                        var reply = Parse<ItemsCheckResponse>(text);
                        changed = reply != null && reply.ok && reply.changed;
                    });
                if (changed) ItemsChanged?.Invoke();
            }
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsCheckResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public bool changed;
        }

        private bool _codeSent;
        private int _codeBeats;
        private const int CodeResendBeats = 20;
        private bool _attesting;

        private void StartAttestation(string nonce)
        {
            if (_attesting || string.IsNullOrEmpty(nonce) || !EzAdminAttestation.Available) return;
            _attesting = true;
            EzAdminAttestation.Request(nonce, (attestation, error) =>
            {
                if (attestation == null && error == null || string.IsNullOrEmpty(_playToken))
                {
                    _attesting = false;
                    return;
                }
                var body = new AttestRequest { token = _playToken, attestation = attestation ?? "", error = error ?? "" };
                StartCoroutine(PostJson("/unity/v1/play/attest", JsonUtility.ToJson(body), text =>
                {
                    _attesting = false;
                    var reply = Parse<AttestResponse>(text);
                    if (reply == null) return;
                    if (!string.IsNullOrEmpty(reply.kick)) DoKick(reply.kick);
                    else if (!string.IsNullOrEmpty(reply.attestNonce)) StartCoroutine(AttestLater(reply.attestNonce));
                }));
            });
        }

        private System.Collections.IEnumerator AttestLater(string nonce)
        {
            yield return new WaitForSecondsRealtime(20f);
            StartAttestation(nonce);
        }

        private void DoKick(string message)
        {
            Debug.Log("EzAdmin: removed from the game: " + message);
            Kicked?.Invoke(message);
            switch (_onKick)
            {
                case KickAction.QuitGame:
                    StartCoroutine(QuitSoon());
                    break;
                case KickAction.LoadScene:
                    if (!string.IsNullOrEmpty(_kickScene))
                    {
                        UnityEngine.SceneManagement.SceneManager.LoadScene(_kickScene);
                    }
                    break;
            }
        }

        private static double UnixNow()
        {
            return (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
        }

        private System.Collections.IEnumerator ShutdownCountdown(long at, string message)
        {
            int left = (int)Math.Max(0, at - UnixNow());
            ShutdownScheduled?.Invoke(left, message);
            int[] marks = { 300, 60, 30, 10 };
            bool first = true;
            foreach (int mark in marks)
            {
                left = (int)Math.Max(0, at - UnixNow());
                if (left < mark && !first) continue;
                if (left > mark) yield return new WaitForSecondsRealtime(left - mark);
                if (_shutdownAt != at) yield break;
                left = (int)Math.Max(0, at - UnixNow());
                if (left <= 0) break;
                string when = left >= 60 ? (left / 60) + " minute" + (left >= 120 ? "s" : "") : left + " seconds";
                LastAnnouncement = message + " Closing in " + when + ".";
                Announcement?.Invoke(LastAnnouncement);
                first = false;
            }
            left = (int)Math.Max(0, at - UnixNow());
            if (left > 0) yield return new WaitForSecondsRealtime(left);
            if (_shutdownAt == at) DoKick(message);
        }

        private System.Collections.IEnumerator QuitSoon()
        {

            yield return new WaitForSecondsRealtime(3f);
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#else
            Application.Quit();
#endif
        }

        private void OnDestroy()
        {
            Application.logMessageReceivedThreaded -= OnLogMessage;
            if (Instance == this)
            {
                Instance = null;
            }
        }

        private void OnApplicationQuit()
        {
            if (string.IsNullOrEmpty(_playToken))
            {
                return;
            }

            var body = new PlayBeatRequest { token = _playToken, ending = true };
            var request = new UnityEngine.Networking.UnityWebRequest(
                _ezAdminAddress.TrimEnd('/') + "/unity/v1/play/beat", "POST");
            request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(
                System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(body)));
            request.SetRequestHeader("Content-Type", "application/json");
            request.SendWebRequest();
        }

        private System.Collections.IEnumerator PlaytimeLoop()
        {
            float waited = 0f;
            SetProblem("Signing in to PlayFab…");
            while (!IsSignedIn)
            {
                yield return new WaitForSecondsRealtime(1f);
                waited += 1f;
                if (waited >= 15f)
                {
                    SetProblem(_signInError ?? (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId)
                        ? "Not signed in to PlayFab: there is no Title ID. Search the Project window for " +
                          "PlayFabSharedSettings and type yours in." + Fix("unity-no-title-id")
                        : _signInIfNeeded
                            ? "Not signed in to PlayFab yet (title " + PlayFabSettings.staticSettings.TitleId + "). " +
                              "Check the Console for a PlayFab sign-in error." + Fix("unity-own-login-slow")
                            : "Not signed in to PlayFab: your game's own login has not finished. Turn on Sign In If " +
                              "Needed on EzAdminClient if your game has no login." + Fix("unity-own-login-slow")));
                }
            }
            SetProblem("Connecting to EzAdmin…");
            RefreshLinkStatus(null);

            while (true)
            {
                if (string.IsNullOrEmpty(_playToken))
                {

                    float fingerprintWait = 0f;
                    while (EzAdminAntiCheat.Present && !EzAdminAntiCheat.FingerprintReady && fingerprintWait < 10f)
                    {
                        yield return new WaitForSecondsRealtime(0.25f);
                        fingerprintWait += 0.25f;
                    }
                    var start = new PlayStartRequest
                    {
                        titleId = PlayFabSettings.staticSettings.TitleId,
                        sessionTicket = PlayFabSettings.staticPlayer.ClientSessionTicket,
                        version = Version,
                        fingerprint = EzAdminAntiCheat.Fingerprint ?? "",
                        platform = Application.platform.ToString(),
                        appVersion = Application.version,
                        acVersion = EzAdminAntiCheat.Present ? 4 : 0,
                        device = EzAdminAntiCheat.DeviceSignals ?? ""
                    };
                    yield return PostJson("/unity/v1/play/start", JsonUtility.ToJson(start), text =>
                    {
                        var reply = Parse<PlayStartResponse>(text);
                        if (reply == null)
                        {
                            SetProblem(NetworkProblem());
                        }
                        else if (!reply.ok)
                        {
                            SetProblem(string.IsNullOrEmpty(reply.message) ? "EzAdmin refused this game." : reply.message);
                        }
                        if (reply != null && reply.ok && !string.IsNullOrEmpty(reply.token))
                        {
                            _playToken = reply.token;
                            _acNonce = reply.acNonce ?? "";
                            EzAdminAntiCheat.Quiet = reply.acQuiet;
                            _codeSent = false;
                            _codeBeats = 0;
                            _lastGoodBeat = Time.realtimeSinceStartup;
                            if (!string.IsNullOrEmpty(reply.attestNonce)) StartAttestation(reply.attestNonce);
                            SetProblem(null);
                            SetShadowMute(reply.shadowMuteUntil);
                            SetStaff(reply.staff);
                            SetStaffTools(reply.staffTools);
                            EzAdminGame.Offer(reply.liveopsRev);
                            if (!string.IsNullOrEmpty(reply.catalog)) CatalogVersion = reply.catalog;
                            ApplyServerBrand(reply.serverName, reply.serverIcon);
                            if (!string.IsNullOrEmpty(reply.kick))
                            {

                                DoKick(reply.kick);
                            }
                            if (reply.beatSeconds > 0)
                            {
                                _beatSeconds = reply.beatSeconds;
                            }
                        }
                        else if (reply != null && !reply.ok && !string.IsNullOrEmpty(reply.message)
                                 && !_refusalLogged)
                        {

                            _refusalLogged = true;
                            Debug.LogWarning("EzAdmin: " + reply.message);
                        }
                    });

                    if (string.IsNullOrEmpty(_playToken))
                    {

                        yield return new WaitForSecondsRealtime(15f);
                        continue;
                    }
                }
                else
                {
                    if (_codeSent && ++_codeBeats >= CodeResendBeats)
                    {
                        _codeSent = false;
                        _codeBeats = 0;
                        EzAdminIntegrity.ClearCodeReport();
                    }
                    string code = _codeSent ? "" : EzAdminIntegrity.CodeReport ?? "";
                    string answer = EzAdminIntegrity.CodeAnswer ?? "";
                    var beat = new PlayBeatRequest { token = _playToken, ending = false, announceAfter = _announceAfter, room = _room,
                                                    acProof = EzAdminAntiCheat.Proof(_acNonce, code), acScans = EzAdminAntiCheat.Scans,
                                                    acFlags = EzAdminAntiCheat.Flags, codeHashes = code, codeAnswer = answer };
                    yield return PostJson("/unity/v1/play/beat", JsonUtility.ToJson(beat), text =>
                    {

                        var reply = Parse<BeatResponse>(text);
                        if (reply != null && !reply.ok)
                        {
                            _playToken = null;
                        }
                        else
                        {
                            if (reply != null) _lastGoodBeat = Time.realtimeSinceStartup;
                            if (reply != null && code.Length > 0) _codeSent = true;
                            if (reply != null && answer.Length > 0) EzAdminIntegrity.AnswerSent(answer);
                            if (reply != null && !string.IsNullOrEmpty(reply.codeChallenge)) EzAdminIntegrity.Answer(reply.codeChallenge);
                            if (reply != null && !string.IsNullOrEmpty(reply.acNonce)) _acNonce = reply.acNonce;
                            if (reply != null && !string.IsNullOrEmpty(reply.attestNonce)) StartAttestation(reply.attestNonce);
                            HandleBeat(reply);
                        }
                    });

                    if (_requireConnection && !string.IsNullOrEmpty(_playToken) && _lastGoodBeat > 0f
                        && Time.realtimeSinceStartup - _lastGoodBeat > _connectionGrace)
                    {
                        DoKick("Lost connection to EzAdmin. You need to be connected to play here.");
                        _lastGoodBeat = Time.realtimeSinceStartup;
                    }
                }

                yield return new WaitForSecondsRealtime(_beatSeconds);
            }
        }

        public void ReportPlayer(string targetPlayFabId, string targetName, string reason,
                                 Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {

                onComplete?.Invoke(false, "Reporting is not ready yet. Try again in a moment.");
                return;
            }

            var body = new ReportRequest
            {
                token = _playToken,
                target = targetPlayFabId ?? string.Empty,
                targetName = targetName ?? string.Empty,
                reason = reason ?? string.Empty
            };
            StartCoroutine(PostJson("/unity/v1/report", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                if (reply == null)
                {
                    onComplete?.Invoke(false, "Could not reach the server. Try again later.");
                    return;
                }
                onComplete?.Invoke(reply.ok, reply.message ?? string.Empty);
            }));
        }

        public void ViewProfile(string targetPlayFabId, Action<EzAdminProfile> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(EzAdminProfile.Unavailable);
                return;
            }

            var body = new ProfileRequest { token = _playToken, targetPlayFabId = targetPlayFabId ?? string.Empty };
            StartCoroutine(PostJson("/unity/v1/profile", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ProfileResponse>(text);
                if (reply == null || !reply.ok)
                {
                    onComplete?.Invoke(EzAdminProfile.Unavailable);
                    return;
                }
                onComplete?.Invoke(new EzAdminProfile(reply.linked, reply.visible, reply.discordId,
                    reply.name, reply.avatarUrl, reply.profileUrl));
            }));
        }

        public void ViewProfiles(IList<string> playFabIds, Action<Dictionary<string, EzAdminProfile>> onComplete)
        {
            var result = new Dictionary<string, EzAdminProfile>();
            if (playFabIds == null || playFabIds.Count == 0)
            {
                onComplete?.Invoke(result);
                return;
            }
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                foreach (var id in playFabIds) result[id] = EzAdminProfile.Unavailable;
                onComplete?.Invoke(result);
                return;
            }

            var ids = new List<string>();
            foreach (var id in playFabIds)
            {
                if (!string.IsNullOrEmpty(id) && !ids.Contains(id) && ids.Count < 50) ids.Add(id);
            }
            var body = new ProfilesRequest { token = _playToken, playFabIds = ids.ToArray() };
            StartCoroutine(PostJson("/unity/v1/profiles", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ProfilesResponse>(text);
                foreach (var id in playFabIds) result[id] = EzAdminProfile.Unavailable;
                if (reply != null && reply.ok && reply.profiles != null)
                {
                    foreach (var p in reply.profiles)
                    {
                        if (string.IsNullOrEmpty(p.playFabId)) continue;

                        foreach (var asked in playFabIds)
                        {
                            if (string.Equals(asked, p.playFabId, StringComparison.OrdinalIgnoreCase))
                            {
                                result[asked] = new EzAdminProfile(p.linked, p.visible, p.discordId, p.name,
                                                                   p.avatarUrl, p.profileUrl, p.username);
                            }
                        }
                    }
                }
                onComplete?.Invoke(result);
            }));
        }

        public string LinkCode { get; private set; } = "";

        public void OpenLinkPage(Action<bool, string> onComplete = null)
        {
            RequestPage("/unity/v1/link-web", onComplete);
        }

        public void OpenStaffPanel(Action<bool, string> onComplete = null)
        {
            RequestPage("/unity/v1/panel", onComplete);
        }

        private void RequestPage(string path, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress))
            {
                onComplete?.Invoke(false, "EzAdmin's address is not set on EzAdminClient.");
                return;
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                StartCoroutine(RequestPageWhenConnected(path, onComplete));
                return;
            }
            var body = new PageRequest { token = _playToken };
            StartCoroutine(PostJson(path, JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<PageResponse>(text);
                if (reply == null)
                {
                    onComplete?.Invoke(false, "Could not reach EzAdmin. Check your connection.");
                    return;
                }
                if (!reply.ok || string.IsNullOrEmpty(reply.url))
                {
                    onComplete?.Invoke(false, string.IsNullOrEmpty(reply.message) ? "That did not work." : reply.message);
                    return;
                }
                LinkCode = string.IsNullOrEmpty(reply.code) ? "" : reply.code;
                EzAdminWeb.Open(reply.url);
                onComplete?.Invoke(true, "");
            }));
        }

        public void GetOwnedItems(Action<OwnedItem[]> onComplete)
        {
            if (onComplete == null) return;
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onComplete(null);
                return;
            }
            var body = new ItemsRequest { token = _playToken };
            StartCoroutine(PostJson("/unity/v1/items", JsonUtility.ToJson(body), text =>
            {
                var reply = Parse<ItemsResponse>(text);
                if (reply != null && !reply.ok && !string.IsNullOrEmpty(reply.message))
                {
                    Debug.LogWarning("EzAdmin: could not read items: " + reply.message);
                }
                onComplete(reply != null && reply.ok ? (reply.items ?? new OwnedItem[0]) : null);
            }));
        }

        private System.Collections.IEnumerator RequestPageWhenConnected(string path, Action<bool, string> onComplete)
        {
            float until = Time.realtimeSinceStartup + 20f;
            while (string.IsNullOrEmpty(_playToken) && Time.realtimeSinceStartup < until)
            {
                yield return new WaitForSecondsRealtime(0.5f);
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false, ConnectionProblem ?? "Could not connect to EzAdmin.");
                yield break;
            }
            RequestPage(path, onComplete);
        }

        public bool IsConnected => !string.IsNullOrEmpty(_playToken);

        public void ReportPurchase(string itemInstanceOrTransactionId, Action<bool, string> onComplete = null)
        {
            if (string.IsNullOrEmpty(itemInstanceOrTransactionId))
            {
                onComplete?.Invoke(false, "Pass the ItemInstanceId (or Economy v2 TransactionId) PlayFab returned.");
                return;
            }
            PostWithToken("/unity/v1/purchase",
                token => JsonUtility.ToJson(new PurchaseRequest { token = token, reference = itemInstanceOrTransactionId }),
                onComplete);
        }

        public void SendCurrency(string toPlayFabId, int amount, string note, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(toPlayFabId) || amount <= 0)
            {
                onComplete?.Invoke(false, "Choose who to send it to and an amount above 0.");
                return;
            }
            PostWithToken("/unity/v1/pay",
                token => JsonUtility.ToJson(new PayRequest { token = token, to = toPlayFabId, amount = amount, note = note ?? "" }),
                onComplete);
        }

        internal void ReportCheat(string check, string detail)
        {
            PostWithToken("/unity/v1/play/flag",
                token => JsonUtility.ToJson(new FlagRequest { token = token, check = check, detail = detail ?? "" }),
                null);
        }

        internal void ReportPeer(string targetPlayFabId, string check, string detail)
        {
        }

        internal void FetchLiveOps(Action<string> onText)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress) || string.IsNullOrEmpty(_playToken))
            {
                onText?.Invoke("");
                return;
            }
            StartCoroutine(PostJson("/unity/v1/liveops",
                JsonUtility.ToJson(new FlagRequest { token = _playToken }), text => onText?.Invoke(text ?? "")));
        }

        private void PostWithToken(string path, Func<string, string> body, Action<bool, string> onComplete)
        {
            if (string.IsNullOrEmpty(_ezAdminAddress))
            {
                onComplete?.Invoke(false, "EzAdmin's address is not set on EzAdminClient.");
                return;
            }
            StartCoroutine(PostWhenConnected(path, body, onComplete));
        }

        private System.Collections.IEnumerator PostWhenConnected(string path, Func<string, string> body,
                                                                 Action<bool, string> onComplete)
        {
            float waited = 0f;
            while (string.IsNullOrEmpty(_playToken) && waited < 30f)
            {
                yield return new WaitForSecondsRealtime(0.5f);
                waited += 0.5f;
            }
            if (string.IsNullOrEmpty(_playToken))
            {
                onComplete?.Invoke(false, ConnectionProblem ?? "Could not connect to EzAdmin.");
                yield break;
            }
            yield return PostJson(path, body(_playToken), text =>
            {
                var reply = Parse<SimpleResponse>(text);
                if (reply == null)
                    onComplete?.Invoke(false, "Could not reach EzAdmin. Check your connection.");
                else
                    onComplete?.Invoke(reply.ok, string.IsNullOrEmpty(reply.message) ? (reply.ok ? "Done." : "Refused.") : reply.message);
            });
        }

        private System.Collections.IEnumerator PostJson(string path, string json, Action<string> onText)
        {
            string url = _ezAdminAddress.TrimEnd('/') + path;
            using (var request = new UnityEngine.Networking.UnityWebRequest(url, "POST"))
            {
                request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(
                    System.Text.Encoding.UTF8.GetBytes(json));
                request.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.timeout = 20;
                yield return request.SendWebRequest();
                _lastNetworkError = request.result == UnityEngine.Networking.UnityWebRequest.Result.ConnectionError
                    ? request.error : null;
                onText(request.downloadHandler != null ? request.downloadHandler.text : string.Empty);
            }
        }

        [Obsolete("Role data was removed from EzAdmin. This always returns no values; delete the call.")]
        public void GetRoleData(Action<EzAdminValues> onComplete)
        {
            onComplete?.Invoke(EzAdminValues.Empty);
        }

        public const string MuteKey = "EzAdmin_Mute";

        public void GetMuteStatus(Action<bool, int> onComplete)
        {
            if (onComplete == null)
            {
                return;
            }
            if (!IsSignedIn)
            {
                onComplete(false, 0);
                return;
            }
            PlayFabClientAPI.GetUserReadOnlyData(
                new GetUserDataRequest { Keys = new List<string> { MuteKey } },
                result =>
                {
                    UserDataRecord record = null;
                    if (result.Data != null)
                    {
                        result.Data.TryGetValue(MuteKey, out record);
                    }
                    double until = EzAdminValues.Parse(record != null ? record.Value : null).GetNumber("until", 0);
                    double now = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
                    int left = (int)Math.Max(0, Math.Floor(until - now));
                    onComplete(left > 0, left);
                },
                error => onComplete(false, 0));
        }

        private static T Parse<T>(string text) where T : class
        {
            if (string.IsNullOrEmpty(text))
            {
                return null;
            }

            try
            {
                return JsonUtility.FromJson<T>(text);
            }
            catch (Exception)
            {
                return null;
            }
        }

        public void RedeemLinkCode(string code, Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            string cleaned = EzAdminCode.Normalise(code);

            if (!IsSignedIn)
            {
                onComplete(false, "You are not signed in yet. Try again in a moment.");
                return;
            }

            if (string.IsNullOrEmpty(cleaned))
            {
                onComplete(false, "Enter the " + EzAdminCode.Length +
                                  "-character code from Discord.");
                return;
            }

            if (_requestInFlight)
            {
                onComplete(false, "Still checking your last code — one moment.");
                return;
            }

            _requestInFlight = true;
            Log("Redeeming link code.");

            var request = new ExecuteCloudScriptRequest
            {
                FunctionName = CloudFunctions.RedeemLinkCode,
                FunctionParameter = new { code = cleaned },
                GeneratePlayStreamEvent = true
            };

            PlayFabClientAPI.ExecuteCloudScript(
                request,
                result =>
                {
                    _requestInFlight = false;

                    string scriptError = FirstScriptError(result);
                    if (CloudScriptMissingIn(result))
                    {
                        onComplete(false, CloudScriptMissing);
                        return;
                    }
                    if (!string.IsNullOrEmpty(scriptError))
                    {
                        Log("CloudScript error: " + scriptError);
                        onComplete(false, "The server could not check that code. " +
                                          "Tell a server admin that EzAdmin's CloudScript " +
                                          "reported an error.");
                        return;
                    }

                    var response = Decode<RedeemResponse>(result);
                    if (response == null)
                    {
                        onComplete(false, "The server gave an unexpected reply. " +
                                          "Is the EzAdmin CloudScript installed?");
                        return;
                    }

                    if (_cloudScriptMissing) MarkCloudScript(true);
                    Log("Redeem result: " + response.success);
                    onComplete(response.success, ChooseMessage(response));
                },
                error =>
                {
                    _requestInFlight = false;
                    Log("PlayFab error: " + error.GenerateErrorReport());
                    if (CloudScriptMissingIn(error))
                    {
                        onComplete(false, CloudScriptMissing);
                        return;
                    }
                    onComplete(false, "Could not reach the server. Check your connection " +
                                      "and try again.");
                });
        }

        public void RefreshLinkStatus(Action<bool, string> onComplete)
        {
            if (onComplete == null)
            {
                onComplete = delegate { };
            }

            if (!IsSignedIn)
            {
                onComplete(false, string.Empty);
                return;
            }

            var request = new ExecuteCloudScriptRequest
            {
                FunctionName = CloudFunctions.GetLinkStatus,
                FunctionParameter = new { },
                GeneratePlayStreamEvent = false
            };

            PlayFabClientAPI.ExecuteCloudScript(
                request,
                result =>
                {
                    if (CloudScriptMissingIn(result))
                    {
                        onComplete(false, string.Empty);
                        return;
                    }
                    var response = Decode<StatusResponse>(result);
                    if (response == null)
                    {
                        onComplete(false, string.Empty);
                        return;
                    }
                    if (_cloudScriptMissing) MarkCloudScript(true);

                    onComplete(response.linked, response.discordName ?? string.Empty);
                },
                error =>
                {
                    Log("Status check failed: " + error.GenerateErrorReport());
                    CloudScriptMissingIn(error);
                    onComplete(false, string.Empty);
                });
        }

        private static string ChooseMessage(RedeemResponse response)
        {
            if (!string.IsNullOrEmpty(response.message))
            {
                return response.message;
            }

            return response.success
                ? "Code accepted. Run /link again in Discord to finish."
                : "That code was not accepted.";
        }

        private const string CloudScriptMissing =
            "Couldn't reach the EzAdmin backend: the EzAdmin CloudScript is not installed in this game's PlayFab. " +
            "Developer: upload it from the EzAdmin dashboard (Game files) to PlayFab › Automation › Revisions and deploy it.";

        private static bool LooksMissing(string text)
        {
            if (string.IsNullOrEmpty(text)) return false;
            return text.IndexOf("NotFound", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("not found", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("No function named", StringComparison.OrdinalIgnoreCase) >= 0 ||
                   text.IndexOf("is not a function", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        private bool CloudScriptMissingIn(ExecuteCloudScriptResult result)
        {
            bool missing = result != null && result.Error != null &&
                           (LooksMissing(result.Error.Error) || LooksMissing(result.Error.Message));
            if (missing) MarkCloudScript(false);
            return missing;
        }

        private void MarkCloudScript(bool present)
        {
            _cloudScriptMissing = !present;
            if (!present) SetProblem(CloudScriptMissing);
            else if (ConnectionProblem == CloudScriptMissing) SetProblem(string.IsNullOrEmpty(_playToken) ? "Connecting to EzAdmin…" : null);
        }

        private bool CloudScriptMissingIn(PlayFabError error)
        {
            bool missing = error != null && (error.Error == PlayFabErrorCode.CloudScriptNotFound ||
                                             LooksMissing(error.ErrorMessage));
            if (missing) MarkCloudScript(false);
            return missing;
        }

        private static string FirstScriptError(ExecuteCloudScriptResult result)
        {
            if (result == null || result.Error == null)
            {
                return string.Empty;
            }

            return string.IsNullOrEmpty(result.Error.Message)
                ? result.Error.Error
                : result.Error.Message;
        }

        private static T Decode<T>(ExecuteCloudScriptResult result) where T : class
        {
            if (result == null || result.FunctionResult == null)
            {
                return null;
            }

            try
            {
                string json = PlayFab.Json.PlayFabSimpleJson.SerializeObject(result.FunctionResult);
                return PlayFab.Json.PlayFabSimpleJson.DeserializeObject<T>(json);
            }
            catch (Exception)
            {
                return null;
            }
        }

        [System.Diagnostics.Conditional("UNITY_EDITOR")]
        private void Log(string message)
        {
            if (_verboseLogging)
            {
                Debug.Log("[EzAdmin] " + message, this);
            }
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class RedeemResponse
        {
            [UnityEngine.Scripting.Preserve] public bool success;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public int remaining;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class StatusResponse
        {
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public string discordName;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class DiscordLinkRequest
        {
            [UnityEngine.Scripting.Preserve] public string titleId;
            [UnityEngine.Scripting.Preserve] public string sessionTicket;
            [UnityEngine.Scripting.Preserve] public string discordToken;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class DiscordLinkResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string discordName;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayStartRequest
        {
            [UnityEngine.Scripting.Preserve] public string titleId;
            [UnityEngine.Scripting.Preserve] public string sessionTicket;
            [UnityEngine.Scripting.Preserve] public string version;
            [UnityEngine.Scripting.Preserve] public string fingerprint;
            [UnityEngine.Scripting.Preserve] public string platform;
            [UnityEngine.Scripting.Preserve] public string appVersion;
            [UnityEngine.Scripting.Preserve] public int acVersion;
            [UnityEngine.Scripting.Preserve] public string device = "";
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class FlagRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string check;
            [UnityEngine.Scripting.Preserve] public string detail;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PurchaseRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string reference;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PayRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string to;
            [UnityEngine.Scripting.Preserve] public int amount;
            [UnityEngine.Scripting.Preserve] public string note;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        public class LookupResult
        {

            [UnityEngine.Scripting.Preserve] public bool ok;

            [UnityEngine.Scripting.Preserve] public string message;

            [UnityEngine.Scripting.Preserve] public string name;

            [UnityEngine.Scripting.Preserve] public string[] lines;

            [UnityEngine.Scripting.Preserve] public bool online;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class SimpleResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ReportRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string target;
            [UnityEngine.Scripting.Preserve] public string targetName;
            [UnityEngine.Scripting.Preserve] public string reason;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ErrorItem
        {
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string stack;
            [UnityEngine.Scripting.Preserve] public string version;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ErrorBatch
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public ErrorItem[] errors;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AchievementRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string badge;
            [UnityEngine.Scripting.Preserve] public string name;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayStartResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public float beatSeconds;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public bool staff;
            [UnityEngine.Scripting.Preserve] public bool staffTools;
            [UnityEngine.Scripting.Preserve] public long liveopsRev;
            [UnityEngine.Scripting.Preserve] public long shadowMuteUntil;
            [UnityEngine.Scripting.Preserve] public string acNonce;
            [UnityEngine.Scripting.Preserve] public bool acQuiet;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
            [UnityEngine.Scripting.Preserve] public string serverName;
            [UnityEngine.Scripting.Preserve] public string serverIcon;
            [UnityEngine.Scripting.Preserve] public string catalog;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AttestRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string attestation;
            [UnityEngine.Scripting.Preserve] public string error;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class AttestResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PlayBeatRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public bool ending;
            [UnityEngine.Scripting.Preserve] public long announceAfter = -1;
            [UnityEngine.Scripting.Preserve] public string room = "";
            [UnityEngine.Scripting.Preserve] public string acProof = "";
            [UnityEngine.Scripting.Preserve] public int acScans;
            [UnityEngine.Scripting.Preserve] public string acFlags = "";
            [UnityEngine.Scripting.Preserve] public string codeHashes = "";
            [UnityEngine.Scripting.Preserve] public string codeAnswer = "";
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class LookupRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string query;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class BeatResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string acNonce;
            [UnityEngine.Scripting.Preserve] public string attestNonce;
            [UnityEngine.Scripting.Preserve] public string codeChallenge;
            [UnityEngine.Scripting.Preserve] public string kick;
            [UnityEngine.Scripting.Preserve] public long muteUntil;
            [UnityEngine.Scripting.Preserve] public bool muteCleared;
            [UnityEngine.Scripting.Preserve] public long shadowMuteUntil;
            [UnityEngine.Scripting.Preserve] public bool shadowMuteCleared;
            [UnityEngine.Scripting.Preserve] public bool itemsChanged;
            [UnityEngine.Scripting.Preserve] public long announceId;
            [UnityEngine.Scripting.Preserve] public string announcement;
            [UnityEngine.Scripting.Preserve] public EzAdminAnnouncement announceStyle;
            [UnityEngine.Scripting.Preserve] public bool frozen;
            [UnityEngine.Scripting.Preserve] public bool freezeChanged;
            [UnityEngine.Scripting.Preserve] public long shutdownAt;
            [UnityEngine.Scripting.Preserve] public string shutdownMessage;
            [UnityEngine.Scripting.Preserve] public string eventName;
            [UnityEngine.Scripting.Preserve] public float eventMultiplier;
            [UnityEngine.Scripting.Preserve] public long eventEndsAt;
            [UnityEngine.Scripting.Preserve] public bool staff;
            [UnityEngine.Scripting.Preserve] public bool staffTools;
            [UnityEngine.Scripting.Preserve] public long now;
            [UnityEngine.Scripting.Preserve] public long liveopsRev;
            [UnityEngine.Scripting.Preserve] public bool runCloudScript;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        public class OwnedItem
        {

            [UnityEngine.Scripting.Preserve] public string id;

            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public int amount;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ItemsResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public OwnedItem[] items;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfileRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string targetPlayFabId;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PageRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class PageResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public string message;
            [UnityEngine.Scripting.Preserve] public string url;
            [UnityEngine.Scripting.Preserve] public string code;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesRequest
        {
            [UnityEngine.Scripting.Preserve] public string token;
            [UnityEngine.Scripting.Preserve] public string[] playFabIds;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesEntry
        {
            [UnityEngine.Scripting.Preserve] public string playFabId;
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public bool visible;
            [UnityEngine.Scripting.Preserve] public long discordId;
            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public string username;
            [UnityEngine.Scripting.Preserve] public string avatarUrl;
            [UnityEngine.Scripting.Preserve] public string profileUrl;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfilesResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public ProfilesEntry[] profiles;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        private class ProfileResponse
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public bool linked;
            [UnityEngine.Scripting.Preserve] public bool visible;

            [UnityEngine.Scripting.Preserve] public long discordId;
            [UnityEngine.Scripting.Preserve] public string name;
            [UnityEngine.Scripting.Preserve] public string avatarUrl;
            [UnityEngine.Scripting.Preserve] public string profileUrl;
        }
    }

    public sealed class EzAdminValues
    {
        private readonly Dictionary<string, object> _values;

        private EzAdminValues(Dictionary<string, object> values)
        {
            _values = values ?? new Dictionary<string, object>();
        }

        public static readonly EzAdminValues Empty = new EzAdminValues(null);

        public IEnumerable<string> Keys => _values.Keys;

        public bool Has(string key) => key != null && _values.ContainsKey(key);

        public double GetNumber(string key, double fallback = 0)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is double ? (double)value : fallback;
        }

        public int GetInt(string key, int fallback = 0)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is double
                ? (int)Math.Round((double)value) : fallback;
        }

        public bool GetFlag(string key, bool fallback = false)
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is bool ? (bool)value : fallback;
        }

        public string GetText(string key, string fallback = "")
        {
            object value;
            return key != null && _values.TryGetValue(key, out value) && value is string ? (string)value : fallback;
        }

        public static EzAdminValues Parse(string json)
        {
            var values = new Dictionary<string, object>();
            if (string.IsNullOrEmpty(json))
            {
                return new EzAdminValues(values);
            }
            try
            {
                int i = 0;
                SkipSpace(json, ref i);
                if (i >= json.Length || json[i] != '{')
                {
                    return Empty;
                }
                i++;
                while (true)
                {
                    SkipSpace(json, ref i);
                    if (i >= json.Length || json[i] == '}')
                    {
                        break;
                    }
                    string key = ReadString(json, ref i);
                    SkipSpace(json, ref i);
                    if (key == null || i >= json.Length || json[i] != ':')
                    {
                        break;
                    }
                    i++;
                    SkipSpace(json, ref i);
                    object value = ReadValue(json, ref i);
                    if (value != null)
                    {
                        values[key] = value;
                    }
                    SkipSpace(json, ref i);
                    if (i < json.Length && json[i] == ',')
                    {
                        i++;
                    }
                }
            }
            catch (Exception)
            {

            }
            return new EzAdminValues(values);
        }

        private static void SkipSpace(string s, ref int i)
        {
            while (i < s.Length && char.IsWhiteSpace(s[i]))
            {
                i++;
            }
        }

        private static string ReadString(string s, ref int i)
        {
            if (i >= s.Length || s[i] != '"')
            {
                return null;
            }
            i++;
            var text = new StringBuilder();
            while (i < s.Length && s[i] != '"')
            {
                if (s[i] == '\\' && i + 1 < s.Length)
                {
                    i++;
                    char escaped = s[i];
                    if (escaped == 'u' && i + 4 < s.Length)
                    {
                        text.Append((char)Convert.ToInt32(s.Substring(i + 1, 4), 16));
                        i += 4;
                    }
                    else
                    {
                        text.Append(escaped == 'n' ? '\n' : escaped == 't' ? '\t' : escaped);
                    }
                }
                else
                {
                    text.Append(s[i]);
                }
                i++;
            }
            i++;
            return text.ToString();
        }

        private static object ReadValue(string s, ref int i)
        {
            if (s[i] == '"')
            {
                return ReadString(s, ref i);
            }
            if (string.CompareOrdinal(s, i, "true", 0, 4) == 0)
            {
                i += 4;
                return true;
            }
            if (string.CompareOrdinal(s, i, "false", 0, 5) == 0)
            {
                i += 5;
                return false;
            }
            int start = i;
            while (i < s.Length && "+-0123456789.eE".IndexOf(s[i]) >= 0)
            {
                i++;
            }
            double number;
            if (i > start && double.TryParse(s.Substring(start, i - start), NumberStyles.Float,
                    CultureInfo.InvariantCulture, out number))
            {
                return number;
            }

            while (i < s.Length && s[i] != ',' && s[i] != '}')
            {
                i++;
            }
            return null;
        }
    }

    public readonly struct EzAdminProfile
    {

        public bool Unreachable { get; }

        public bool Linked { get; }

        public bool Visible { get; }
        public long DiscordId { get; }
        public string Name { get; }
        public string AvatarUrl { get; }

        public string ProfileUrl { get; }

        public string Username { get; }

        public string DiscordIdText => DiscordId == 0 ? string.Empty : DiscordId.ToString();

        public EzAdminProfile(bool linked, bool visible, long discordId, string name, string avatarUrl, string profileUrl)
            : this(linked, visible, discordId, name, avatarUrl, profileUrl, string.Empty)
        {
        }

        public EzAdminProfile(bool linked, bool visible, long discordId, string name, string avatarUrl,
                              string profileUrl, string username)
        {
            Username = username ?? string.Empty;
            Unreachable = false;
            Linked = linked;
            Visible = visible;
            DiscordId = discordId;
            Name = name ?? string.Empty;
            AvatarUrl = avatarUrl ?? string.Empty;
            ProfileUrl = profileUrl ?? string.Empty;
        }

        private EzAdminProfile(bool unreachable)
        {
            Username = string.Empty;
            Unreachable = unreachable;
            Linked = false;
            Visible = false;
            DiscordId = 0;
            Name = string.Empty;
            AvatarUrl = string.Empty;
            ProfileUrl = string.Empty;
        }

        public static readonly EzAdminProfile Unavailable = new EzAdminProfile(true);
    }

}
EzAdminSetupWizard.csAssets/EzAdmin/Editor. Window › EzAdmin › Setup Wizard: a few questions, then it sets everything up. Editor only; never in your build.38.5 KB · 809 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using EzAdmin.Client;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

namespace EzAdmin.EditorTools
{

    public sealed class EzAdminSetupWizard : EditorWindow
    {
        private enum Platform { VR, Flat, VRAndFlat }
        private enum Corner { BottomLeft, BottomRight, TopLeft, TopRight }
        private enum Items { None, CatalogLegacy, EconomyV2 }

        private const string HooksFolder = "Assets/EzAdmin/MyGame";
        private const string HooksPath = HooksFolder + "/EzAdminHooks.cs";
        private const string PendingHooksKey = "EzAdmin.Wizard.AddHooksAfterCompile";
        private const string ShownKey = "EzAdmin.Wizard.Shown";

        private Platform _platform = Platform.VR;
        private Corner _corner = Corner.BottomLeft;
        private bool _staffButton = true;
        private LayerMask _handLayers = ~0;
        private string _address = EzAdminClient.SiteAddress;
        private bool _hasOwnLogin = true;
        private Items _items = Items.CatalogLegacy;
        private bool _mutes = true;
        private bool _antiCheat = true;
        private bool _discordLink;
        private bool _writeHooks = true;
        private string _loginFoundIn;
        private bool _useMeta = true;
        private bool _secureMeta = true;
        private string _metaPrefix = EzAdminAuth.DefaultMetaPrefix;
        private string _metaPrefixFound;
        private const string MetaDefine = "EZADMIN_META";
        private Vector2 _scroll;
        private string _report = "";
        private int _loading = 1;
        private string _loadName = "";
        private Sprite _loadLogo;
        private Sprite _loadBackground;
        private Color _loadAccent = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        private int _loadNext;

        [MenuItem("Window/EzAdmin/Setup Wizard", false, 0)]
        [MenuItem("Tools/EzAdmin/Setup Wizard", false, 0)]
        public static void Open()
        {
            var window = GetWindow<EzAdminSetupWizard>(true, "EzAdmin Setup", true);
            window.minSize = new Vector2(560, 700);
            window.titleContent = new GUIContent("EzAdmin Setup", EzAdminBrand.Logo(32));
            window.LoadFromScene();
        }

        [InitializeOnLoadMethod]
        private static void OpenOnFirstImport()
        {
            if (EditorPrefs.GetBool(ShownKey + "." + Application.productName, false)) return;
            EditorPrefs.SetBool(ShownKey + "." + Application.productName, true);
            EditorApplication.delayCall += Open;
        }

        private void LoadFromScene()
        {
            var client = FindObjectOfType<EzAdminClient>();
            if (client != null)
            {
                _address = EzAdminClient.SiteAddress;
            }
            _loading = EzAdminLoadingSceneMaker.Exists ? 0 : 1;
            _loginFoundIn = FindOwnLogin();
            _hasOwnLogin = _loginFoundIn != null && FindObjectOfType<EzAdminAuth>() == null;
            _useMeta = MetaSdkFound();
            _metaPrefixFound = FindMetaPrefix(_loginFoundIn);
            var existing = FindObjectOfType<EzAdminAuth>();
            _metaPrefix = existing != null
                ? new SerializedObject(existing).FindProperty("_metaPrefix").stringValue
                : _metaPrefixFound ?? EzAdminAuth.DefaultMetaPrefix;
            _platform = HasPackage("com.unity.xr.management") || HasPackage("com.meta.xr.sdk.core")
                        || HasPackage("com.unity.xr.oculus") || AssetDatabase.IsValidFolder("Assets/Oculus")
                ? Platform.VR : Platform.Flat;
        }

        private static string FindOwnLogin()
        {
            foreach (string guid in AssetDatabase.FindAssets("t:MonoScript", new[] { "Assets" }))
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                if (!path.EndsWith(".cs") || path.StartsWith("Assets/EzAdmin/") || path.Contains("/PlayFabSDK/")
                    || path.Contains("/PlayFabEditorExtensions/"))
                {
                    continue;
                }
                try
                {
                    string text = File.ReadAllText(path);
                    if (text.Contains("PlayFabClientAPI.LoginWith") || text.Contains("PlayFabClientAPI .LoginWith"))
                    {
                        return path;
                    }
                }
                catch (IOException)
                {
                }
            }
            return null;
        }

        private static string FindMetaPrefix(string loginPath)
        {
            if (string.IsNullOrEmpty(loginPath)) return null;
            try
            {
                var match = System.Text.RegularExpressions.Regex.Match(File.ReadAllText(loginPath),
                    "CustomId\\s*=\\s*\"([A-Za-z0-9_\\-]{1,24})\"\\s*\\+");
                return match.Success ? match.Groups[1].Value : null;
            }
            catch (IOException)
            {
                return null;
            }
        }

        public static bool MetaSdkFound()
        {
            foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
            {
                if (assembly.GetType("Oculus.Platform.Users", false) != null) return true;
            }
            return false;
        }

        public static bool MetaSdkOn()
        {
            return HasDefine(BuildTargetGroup.Android, MetaDefine);
        }

        private static bool HasDefine(BuildTargetGroup group, string define)
        {
            string defines = PlayerSettings.GetScriptingDefineSymbolsForGroup(group);
            return Array.IndexOf(defines.Split(';'), define) >= 0;
        }

        private static void SetDefine(BuildTargetGroup group, string define, bool on)
        {
            var list = new List<string>(PlayerSettings.GetScriptingDefineSymbolsForGroup(group)
                .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries));
            bool has = list.Contains(define);
            if (on == has) return;
            if (on) list.Add(define); else list.Remove(define);
            PlayerSettings.SetScriptingDefineSymbolsForGroup(group, string.Join(";", list));
        }

        private static bool HasPackage(string name)
        {
            string manifest = Path.Combine(Application.dataPath, "..", "Packages", "manifest.json");
            return File.Exists(manifest) && File.ReadAllText(manifest).Contains("\"" + name + "\"");
        }

        private static readonly Color Bg = new Color32(0x0D, 0x0F, 0x14, 0xFF);
        private static readonly Color CardBg = new Color32(0x16, 0x19, 0x21, 0xFF);
        private static readonly Color CardLine = new Color32(0x26, 0x2A, 0x34, 0xFF);
        private static readonly Color Text = new Color32(0xF2, 0xF4, 0xF8, 0xFF);
        private static readonly Color Dim = new Color32(0x9A, 0xA1, 0xAE, 0xFF);
        private static readonly Color Bad = new Color32(0xF8, 0x71, 0x71, 0xFF);
        private static readonly Color Warn = new Color32(0xFB, 0xBF, 0x24, 0xFF);

        private GUIStyle _title, _subtitle, _cardTitle, _body, _small, _chip, _segOn, _segOff, _primary,
                         _secondary, _cardStyle, _stepNumber, _row;
        private readonly System.Collections.Generic.Dictionary<Color, Texture2D> _solids =
            new System.Collections.Generic.Dictionary<Color, Texture2D>();

        private Texture2D Solid(Color colour)
        {
            Texture2D t;
            if (_solids.TryGetValue(colour, out t) && t != null) return t;
            t = new Texture2D(1, 1) { hideFlags = HideFlags.HideAndDontSave };
            t.SetPixel(0, 0, colour);
            t.Apply();
            _solids[colour] = t;
            return t;
        }

        private GUIStyle Style(GUIStyle from, int size, FontStyle weight, Color colour, bool wrap = true)
        {
            var style = new GUIStyle(from) { fontSize = size, fontStyle = weight, wordWrap = wrap, richText = true };
            style.normal.textColor = colour;
            return style;
        }

        private void EnsureStyles()
        {
            if (_title != null) return;
            _title = Style(EditorStyles.label, 22, FontStyle.Bold, Text, false);
            _subtitle = Style(EditorStyles.label, 12, FontStyle.Normal, Dim);
            _cardTitle = Style(EditorStyles.label, 14, FontStyle.Bold, Text, false);
            _body = Style(EditorStyles.label, 12, FontStyle.Normal, Text);
            _small = Style(EditorStyles.label, 11, FontStyle.Normal, Dim);
            _chip = Style(EditorStyles.label, 11, FontStyle.Bold, Text, false);
            _chip.alignment = TextAnchor.MiddleCenter;
            _chip.padding = new RectOffset(10, 10, 5, 5);
            _row = Style(EditorStyles.label, 12, FontStyle.Normal, Text);

            _segOff = new GUIStyle(GUI.skin.button) { fontSize = 12, fontStyle = FontStyle.Bold, fixedHeight = 34 };
            _segOff.normal.background = Solid(new Color32(0x20, 0x24, 0x2D, 0xFF));
            _segOff.hover.background = Solid(new Color32(0x2A, 0x2F, 0x3A, 0xFF));
            _segOff.active.background = _segOff.hover.background;
            _segOff.normal.textColor = _segOff.hover.textColor = _segOff.active.textColor = Dim;
            _segOff.border = new RectOffset(0, 0, 0, 0);
            _segOn = new GUIStyle(_segOff);
            _segOn.normal.background = _segOn.hover.background = _segOn.active.background = Solid(EzAdminBrand.GreenDeep);
            _segOn.normal.textColor = _segOn.hover.textColor = _segOn.active.textColor = Color.white;

            _primary = new GUIStyle(_segOn) { fontSize = 15, fixedHeight = 46 };
            _primary.hover.background = Solid(EzAdminBrand.Green);
            _secondary = new GUIStyle(_segOff) { fontSize = 11, fixedHeight = 28 };
            _secondary.normal.textColor = _secondary.hover.textColor = Text;

            _cardStyle = new GUIStyle { padding = new RectOffset(16, 16, 14, 16), margin = new RectOffset(14, 14, 6, 6) };
            _cardStyle.normal.background = Solid(CardBg);
            _stepNumber = Style(EditorStyles.label, 12, FontStyle.Bold, Color.white, false);
            _stepNumber.alignment = TextAnchor.MiddleCenter;
            _stepNumber.normal.background = Solid(EzAdminBrand.GreenDeep);
            _stepNumber.fixedWidth = 22;
            _stepNumber.fixedHeight = 22;
        }

        private void OnGUI()
        {
            EnsureStyles();
            EditorGUI.DrawRect(new Rect(0, 0, position.width, position.height), Bg);
            _scroll = EditorGUILayout.BeginScrollView(_scroll);

            Header();
            StatusStrip();

            Card("1", "Your game", "Where people play it. Players link on the EzAdmin website, with no code to type.", () =>
            {
                _platform = (Platform)Segmented((int)_platform, "VR", "Flat screen", "Both");
                GUILayout.Space(8);
                if (_platform != Platform.Flat)
                {
                    Label("Hand layers", "Which layers count as a hand pressing the VR board's buttons.");
                    _handLayers = LayerMaskField(_handLayers);
                }
                if (_platform != Platform.VR)
                {
                    Label("Button corner", "Where the Link Discord button sits on screen.");
                    _corner = (Corner)Segmented((int)_corner, "Bottom left", "Bottom right", "Top left", "Top right");
                }
                GUILayout.Space(6);
                _staffButton = Switch("Staff Panel button", "Staff open the web Staff Panel from inside the game.", _staffButton);
            });

            Card("2", "Your EzAdmin", "Built in. Your game always talks to EzAdmin at this address.", () =>
            {
                GUILayout.Label(_address, _small);
            });

            Card("3", "Signing players in", _loginFoundIn != null
                    ? "Found your PlayFab login in " + _loginFoundIn + "."
                    : "No PlayFab login found in your scripts.", () =>
            {
                _hasOwnLogin = Segmented(_hasOwnLogin ? 0 : 1, "My game signs players in", "EzAdmin Sign-in") == 0;
                GUILayout.Space(6);
                if (_hasOwnLogin)
                {
                    GUILayout.Label("EzAdmin waits for your login and uses that player.", _small);
                    return;
                }
                GUILayout.Label("EzAdmin Sign-in picks the right login on each platform. Nothing secret is in it, so " +
                                "reading the game's code (dnSpy and the like) gives nobody a way in.", _small);
                GUILayout.Space(6);
                bool meta = MetaSdkFound();
                using (new EditorGUI.DisabledScope(!meta))
                {
                    _useMeta = Switch("Meta Quest: sign in with the Meta account",
                        meta ? "Checks the player owns the game, then signs in with their Meta user id: the same " +
                               "PlayFab account on every headset and every reinstall."
                             : "Import the Meta Platform SDK (Meta XR Platform SDK) to use this.", _useMeta && meta);
                }
                if (_useMeta && meta)
                {
                    GUILayout.Space(4);
                    _secureMeta = Switch("Secure sign-in through EzAdmin",
                        "EzAdmin checks each player with Meta, then signs them in. Nobody can sign in as someone " +
                        "else. Add your Meta app token on the dashboard under Connect games.", _secureMeta);
                    GUILayout.Space(4);
                    _metaPrefix = EditorGUILayout.TextField(new GUIContent("Account prefix",
                        "Put before the Meta user id to make the PlayFab account id."), _metaPrefix);
                    GUILayout.Label(_metaPrefixFound != null
                        ? "Found " + _metaPrefixFound + " in your login, so players keep the accounts they have."
                        : "Players already signed in with Meta? Use the prefix your old login used, so they keep " +
                          "their accounts. A new game can leave it as it is.", _small);
                }
                GUILayout.Space(4);
                GUILayout.Label("Steam, Xbox and PlayStation: one line in your platform code hands EzAdmin the ticket " +
                                "(EzAdminAuth.SteamTicket, XboxToken, PlayStationAuthCode). Phones and PC: a random id " +
                                "kept on the device.", _small);
                if (_loginFoundIn != null)
                {
                    GUILayout.Space(4);
                    GUILayout.Label("<color=#fbbf24>Turn off your old login (" + Path.GetFileName(_loginFoundIn) +
                                    ") so players are not signed in twice.</color>", _small);
                }
            });

            Card("4", "What you will use", "Your starter script gets a method for each one you pick.", () =>
            {
                Label("Role items go into", null);
                _items = (Items)Segmented((int)_items, "No items", "Legacy catalog", "Economy v2");
                GUILayout.Space(4);
                GUILayout.Label(ItemsHelp(), _small);
                GUILayout.Space(8);
                _mutes = Switch("Discord mutes block my chat", "A Discord timeout mutes them in game too.", _mutes);
                _antiCheat = Switch("Anti tamper", "Mod loaders, injected code, memory editing and modified " +
                                                  "copies. Switch it on in Labs on the dashboard too.", _antiCheat);
                _discordLink = Switch("Discord link screen", "Lets players link Discord. Anti tamper works without it.", _discordLink);
                _writeHooks = Switch("Write my starter script", "Assets/EzAdmin/MyGame/EzAdminHooks.cs. Never overwritten.", _writeHooks);
            });

            Card("5", "Loading screen", EzAdminLoadingSceneMaker.Exists
                    ? "You already have one. Pick a design to make it again, or No loading screen to keep it."
                    : "A first scene that signs the player in, loads their items, then opens your game.", () =>
            {
                _loading = Segmented(Mathf.Min(_loading, 1), "No loading screen", "Add the loading screen");
                GUILayout.Space(6);
                if (_loading == 0) return;
                GUILayout.Label("Your logo, name, a progress bar, steps and tips. It is added to its own scene as normal " +
                                "objects you can move and restyle. In VR it floats in front of the player.", _small);
                GUILayout.Space(6);
                if (string.IsNullOrEmpty(_loadName)) _loadName = PlayerSettings.productName;
                var sceneChoices = EzAdminScenes.All(EzAdminLoadingSceneMaker.ScenePath);
                string openNow = UnityEngine.SceneManagement.SceneManager.GetActiveScene().path;
                _loadNext = Mathf.Clamp(_loadNext, 0, sceneChoices.Count);
                _loadNext = EditorGUILayout.Popup("Open after loading", _loadNext,
                    EzAdminScenes.Labels(sceneChoices, "This scene" + (string.IsNullOrEmpty(openNow) ? "" : " (" +
                    Path.GetFileNameWithoutExtension(openNow) + ")")));
                _loadName = EditorGUILayout.TextField("Game name", _loadName);
                _loadLogo = (Sprite)EditorGUILayout.ObjectField("Logo", _loadLogo, typeof(Sprite), false);
                _loadBackground = (Sprite)EditorGUILayout.ObjectField("Background picture", _loadBackground, typeof(Sprite), false);
                _loadAccent = EditorGUILayout.ColorField("Colour", _loadAccent);
                GUILayout.Label("It goes first in Build Settings. Your game scene stays open.", _small);
            });

            GUILayout.Space(10);
            GUILayout.BeginHorizontal();
            GUILayout.Space(14);
            using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_address)))
            {
                if (GUILayout.Button("Set it up", _primary))
                {
                    _report = Apply();
                }
            }
            GUILayout.Space(14);
            GUILayout.EndHorizontal();

            if (!string.IsNullOrEmpty(_report))
            {
                Results();
            }

            GUILayout.Space(8);
            GUILayout.BeginHorizontal();
            GUILayout.Space(14);
            using (new EditorGUI.DisabledScope(string.IsNullOrWhiteSpace(_address)))
            {
                if (GUILayout.Button("Setup guide", _secondary)) Application.OpenURL(SiteLink("/setup-unity.html"));
                if (GUILayout.Button("Developer reference", _secondary)) Application.OpenURL(SiteLink("/developers.html"));
            }
            if (GUILayout.Button("Check for updates", _secondary)) EditorApplication.ExecuteMenuItem("Tools/EzAdmin/Check for Updates");
            if (GUILayout.Button("Protect build", _secondary)) EzAdminBuildGuard.ProtectBuild();
            GUILayout.Space(14);
            GUILayout.EndHorizontal();
            GUILayout.Space(16);
            EditorGUILayout.EndScrollView();
        }

        private static string LoadNextPath(int index)
        {
            var choices = EzAdminScenes.All(EzAdminLoadingSceneMaker.ScenePath);
            return index > 0 && index <= choices.Count ? choices[index - 1].Path : "";
        }

        private string SiteLink(string path)
        {
            return _address.Trim().TrimEnd('/') + path;
        }

        private void Header()
        {
            Rect band = GUILayoutUtility.GetRect(position.width, 92);
            EditorGUI.DrawRect(band, EzAdminBrand.Panel);
            EditorGUI.DrawRect(new Rect(band.x, band.yMax - 3, band.width, 3), EzAdminBrand.Green);
            GUI.DrawTexture(new Rect(band.x + 18, band.y + 16, 58, 58), EzAdminBrand.Logo(128), ScaleMode.ScaleToFit);
            GUI.Label(new Rect(band.x + 90, band.y + 18, band.width - 200, 30), "EzAdmin Setup", _title);
            GUI.Label(new Rect(band.x + 91, band.y + 50, band.width - 110, 34),
                      "Answer four things. EzAdmin sets up your scene and writes your starter script.", _subtitle);
            GUI.Label(new Rect(band.xMax - 84, band.y + 22, 66, 22), "v" + EzAdminClient.Version, _chip);
            GUILayout.Space(10);
        }

        private void StatusStrip()
        {
            string titleId = PlayFabTitleId();
            GUILayout.BeginHorizontal();
            GUILayout.Space(14);
            Pill(titleId != null, "PlayFab SDK");
            Pill(!string.IsNullOrEmpty(titleId), string.IsNullOrEmpty(titleId) ? "No Title ID" : "Title ID set");
            Pill(!string.IsNullOrWhiteSpace(_address), "Address");
            Pill(true, _hasOwnLogin ? "Your login" : "EzAdmin login");
            GUILayout.FlexibleSpace();
            GUILayout.Space(14);
            GUILayout.EndHorizontal();
            if (titleId == null)
            {
                Notice("Import the PlayFab SDK first: github.com/PlayFab/UnitySDK/releases, PlayFabSDK.unitypackage.", Bad,
                       "Open", () => Application.OpenURL("https://github.com/PlayFab/UnitySDK/releases"));
            }
            else if (titleId.Length == 0)
            {
                Notice("Type your PlayFab Title ID into PlayFabSharedSettings.", Warn, "Show it", SelectPlayFabSettings);
            }
            GUILayout.Space(4);
        }

        private void Pill(bool ok, string text)
        {
            var content = new GUIContent((ok ? "<color=#4ade80>●</color>  " : "<color=#f87171>●</color>  ") + text);
            Vector2 size = _chip.CalcSize(content);
            Rect r = GUILayoutUtility.GetRect(size.x, 26, GUILayout.ExpandWidth(false));
            EditorGUI.DrawRect(r, new Color32(0x1C, 0x20, 0x28, 0xFF));
            GUI.Label(r, content, _chip);
            GUILayout.Space(6);
        }

        private void Notice(string text, Color colour, string button, Action onClick)
        {
            GUILayout.Space(6);
            GUILayout.BeginHorizontal(_cardStyle);
            Rect bar = GUILayoutUtility.GetRect(3, 18, GUILayout.Width(3));
            EditorGUI.DrawRect(bar, colour);
            GUILayout.Space(8);
            GUILayout.Label(text, _body);
            if (GUILayout.Button(button, _secondary, GUILayout.Width(90))) onClick();
            GUILayout.EndHorizontal();
        }

        private void Card(string number, string title, string subtitle, Action body)
        {
            Rect area = EditorGUILayout.BeginVertical(_cardStyle);
            if (Event.current.type == EventType.Repaint)
            {
                EditorGUI.DrawRect(new Rect(area.x, area.y, area.width, 1), CardLine);
            }
            GUILayout.BeginHorizontal();
            GUILayout.Label(number, _stepNumber);
            GUILayout.Space(8);
            GUILayout.Label(title, _cardTitle);
            GUILayout.EndHorizontal();
            if (!string.IsNullOrEmpty(subtitle))
            {
                GUILayout.Space(2);
                GUILayout.Label(subtitle, _small);
            }
            GUILayout.Space(10);
            body();
            EditorGUILayout.EndVertical();
        }

        private void Label(string text, string hint)
        {
            GUILayout.Space(4);
            GUILayout.Label("<b>" + text + "</b>", _body);
            if (!string.IsNullOrEmpty(hint)) GUILayout.Label(hint, _small);
        }

        private int Segmented(int value, params string[] options)
        {
            GUILayout.BeginHorizontal();
            for (int i = 0; i < options.Length; i++)
            {
                if (GUILayout.Button(options[i], i == value ? _segOn : _segOff)) value = i;
                if (i < options.Length - 1) GUILayout.Space(4);
            }
            GUILayout.EndHorizontal();
            return value;
        }

        private bool Switch(string label, string hint, bool value)
        {
            GUILayout.BeginHorizontal();
            GUILayout.BeginVertical();
            GUILayout.Label("<b>" + label + "</b>", _body);
            if (!string.IsNullOrEmpty(hint)) GUILayout.Label(hint, _small);
            GUILayout.EndVertical();
            GUILayout.FlexibleSpace();
            if (GUILayout.Button(value ? "On" : "Off", value ? _segOn : _segOff, GUILayout.Width(64))) value = !value;
            GUILayout.EndHorizontal();
            GUILayout.Space(6);
            return value;
        }

        private void Results()
        {
            GUILayout.Space(6);
            EditorGUILayout.BeginVertical(_cardStyle);
            GUILayout.BeginHorizontal();
            GUILayout.Label("√", _stepNumber);
            GUILayout.Space(8);
            GUILayout.Label("Set up", _cardTitle);
            GUILayout.EndHorizontal();
            GUILayout.Space(8);
            bool todo = false;
            foreach (string raw in _report.Split('\n'))
            {
                string line = raw.Trim();
                if (line.StartsWith("Still for you"))
                {
                    todo = true;
                    GUILayout.Space(6);
                    GUILayout.Label("<b>Still for you</b>", _body);
                    continue;
                }
                if (!line.StartsWith("·")) continue;
                string text = line.TrimStart('·', ' ');
                GUILayout.Label((todo ? "<color=#fbbf24>○</color>  " : "<color=#4ade80>√</color>  ") + text, _row);
            }
            EditorGUILayout.EndVertical();
        }

        private string ItemsHelp()
        {
            switch (_items)
            {
                case Items.CatalogLegacy: return "PlayFab Catalog (Legacy): items land in the player's inventory. To " +
                                                 "show one in game, add EzAdminItemUnlock to any object, type the item " +
                                                 "id or name, and drag the hat or sword into Show When Owned.";
                case Items.EconomyV2: return "PlayFab Economy v2: items land in the v2 inventory. To show one in game, " +
                                             "add EzAdminItemUnlock, type the item id or name, and drag the object in.";
                default: return "No items. Linking, mutes and moderation still work.";
            }
        }

        private static LayerMask LayerMaskField(LayerMask mask)
        {
            var names = UnityEditorInternal.InternalEditorUtility.layers;
            int shown = 0;
            for (int i = 0; i < names.Length; i++)
            {
                if ((mask.value & (1 << LayerMask.NameToLayer(names[i]))) != 0) shown |= 1 << i;
            }
            shown = EditorGUILayout.MaskField(shown, names, GUILayout.Height(22));
            int result = 0;
            for (int i = 0; i < names.Length; i++)
            {
                if ((shown & (1 << i)) != 0) result |= 1 << LayerMask.NameToLayer(names[i]);
            }
            return shown == -1 ? (LayerMask)~0 : (LayerMask)result;
        }

        private static string PlayFabTitleId()
        {
            var found = AssetDatabase.FindAssets("t:PlayFabSharedSettings");
            if (found.Length == 0) return null;
            var asset = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(found[0]));
            var property = asset != null ? new SerializedObject(asset).FindProperty("TitleId") : null;
            return property != null ? property.stringValue ?? "" : "";
        }

        private static void SelectPlayFabSettings()
        {
            var found = AssetDatabase.FindAssets("t:PlayFabSharedSettings");
            if (found.Length == 0) return;
            Selection.activeObject = AssetDatabase.LoadMainAssetAtPath(AssetDatabase.GUIDToAssetPath(found[0]));
            EditorGUIUtility.PingObject(Selection.activeObject);
        }

        private string Apply()
        {
            var done = new StringBuilder("Done:\n");
            var todo = new StringBuilder();

            var client = FindObjectOfType<EzAdminClient>();
            if (client == null)
            {
                var go = new GameObject("EzAdmin");
                Undo.RegisterCreatedObjectUndo(go, "EzAdmin setup");
                client = go.AddComponent<EzAdminClient>();
                done.AppendLine("  · added the EzAdmin object");
            }

            var sign = new SerializedObject(client);
            sign.FindProperty("_signInIfNeeded").boolValue = false;
            sign.ApplyModifiedProperties();
            var auth = client.GetComponent<EzAdminAuth>();
            if (_hasOwnLogin)
            {
                if (auth != null) Undo.DestroyObjectImmediate(auth);
                done.AppendLine("  · uses your own PlayFab login");
            }
            else
            {
                if (auth == null) auth = Undo.AddComponent<EzAdminAuth>(client.gameObject);
                bool meta = _useMeta && MetaSdkFound();
                if (meta)
                {
                    var authProps = new SerializedObject(auth);
                    authProps.FindProperty("_secureMeta").boolValue = _secureMeta;
                    authProps.FindProperty("_metaPrefix").stringValue = string.IsNullOrWhiteSpace(_metaPrefix)
                        ? EzAdminAuth.DefaultMetaPrefix : _metaPrefix.Trim();
                    authProps.ApplyModifiedProperties();
                }
                SetDefine(BuildTargetGroup.Android, MetaDefine, meta);
                SetDefine(BuildTargetGroup.Standalone, MetaDefine, meta);
                done.AppendLine("  · added EzAdmin Sign-in" + (meta ? " with Meta Quest accounts" : ""));
                if (meta && _secureMeta)
                    todo.AppendLine("  · paste your Meta app token on the EzAdmin dashboard › Connect games › Meta app");
                if (_loginFoundIn != null)
                    todo.AppendLine("  · turn off your old login in " + _loginFoundIn + " (or delete it)");
            }

            var screen = FindObjectOfType<EzAdminLinkScreen>();
            if (screen == null && _discordLink)
            {
                var go = new GameObject("EzAdmin Link Screen");
                Undo.RegisterCreatedObjectUndo(go, "EzAdmin setup");
                screen = go.AddComponent<EzAdminLinkScreen>();
                if (_platform != Platform.Flat) PlaceInFrontOfView(go.transform);
                done.AppendLine("  · added the link screen" + (_platform != Platform.Flat ? " (move the board to your lobby wall)" : ""));
            }
            if (screen != null)
            {
                var ss = new SerializedObject(screen);
                ss.FindProperty("_mode").enumValueIndex = _platform == Platform.VR ? 2 : _platform == Platform.Flat ? 1 : 0;
                ss.FindProperty("_handLayers").intValue = _handLayers.value;
                ss.FindProperty("_corner").enumValueIndex = (int)_corner;
                ss.FindProperty("_showStaffButton").boolValue = _staffButton;
                ss.ApplyModifiedProperties();
                done.AppendLine("  · Link Discord button set for " + ObjectNames.NicifyVariableName(_platform.ToString()));
            }

            var antiCheat = client.GetComponent<EzAdminAntiCheat>();
            if (_antiCheat && antiCheat == null)
            {
                Undo.AddComponent<EzAdminAntiCheat>(client.gameObject);
                done.AppendLine("  · added anti tamper");
                todo.AppendLine("  · switch on Anti tamper for Unity in Labs on the dashboard, then mark your build official");
            }
            else if (!_antiCheat && antiCheat != null)
            {
                Undo.DestroyObjectImmediate(antiCheat);
                done.AppendLine("  · removed anti tamper");
            }

            if (_writeHooks)
            {
                if (File.Exists(HooksPath))
                {
                    done.AppendLine("  · kept your existing " + HooksPath + " (it is never overwritten)");
                }
                else
                {
                    Directory.CreateDirectory(HooksFolder);
                    File.WriteAllText(HooksPath, HooksScript(), Encoding.UTF8);
                    EditorPrefs.SetBool(PendingHooksKey, true);
                    AssetDatabase.ImportAsset(HooksPath);
                    done.AppendLine("  · wrote " + HooksPath + " (added to the EzAdmin object once Unity compiles it)");
                }
            }

            EditorSceneManager.MarkSceneDirty(client.gameObject.scene);

            if (_loading > 0)
            {
                var gameScene = client.gameObject.scene;
                if (!string.IsNullOrEmpty(gameScene.path)) EditorSceneManager.SaveScene(gameScene);
                string made = EzAdminLoadingSceneMaker.Create(new LoadingSceneOptions
                {
                    VR = _platform != Platform.Flat,
                    SignIn = !_hasOwnLogin,
                    GameName = _loadName,
                    Logo = _loadLogo,
                    Background = _loadBackground,
                    Accent = _loadAccent,
                    NextScene = _loadNext > 0 ? LoadNextPath(_loadNext) : client.gameObject.scene.path,
                    ReturnToGame = true
                }, false);
                if (made.StartsWith("Made "))
                    done.AppendLine("  · made the loading screen and put it first in Build Settings");
                else if (!string.IsNullOrEmpty(made))
                    todo.AppendLine("  · loading screen: " + made);
                if (_hasOwnLogin && made.StartsWith("Made "))
                    todo.AppendLine("  · put your own sign-in script in the EzAdmin Loading scene too, so players sign " +
                                    "in while it shows");
            }

            todo.AppendLine("  · save the scene");
            todo.AppendLine("  · paste EzAdmin.CloudScript.js into PlayFab (Automation › CloudScript) and deploy");
            todo.AppendLine("  · in Discord: /setup with your PlayFab Secret Key, then /config linked-role");
            todo.AppendLine("  · want the website INSIDE the game? Add a web view plugin and set EzAdminWeb.Opener " +
                            "(see EzAdminWeb.cs)");
            return done.ToString().Replace("\n\n", "\n") + "\nStill for you:\n" + todo;
        }

        private static void PlaceInFrontOfView(Transform target)
        {
            var view = SceneView.lastActiveSceneView;
            if (view == null) return;
            var camera = view.camera.transform;
            target.position = camera.position + camera.forward * 2f;
            target.rotation = Quaternion.LookRotation(camera.forward, Vector3.up);
        }

        [UnityEditor.Callbacks.DidReloadScripts]
        private static void AddHooksAfterCompile()
        {
            if (!EditorPrefs.GetBool(PendingHooksKey, false)) return;
            EditorPrefs.DeleteKey(PendingHooksKey);
            Type hooks = null;
            foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
            {
                hooks = assembly.GetType("EzAdminHooks");
                if (hooks != null) break;
            }
            var client = FindObjectOfType<EzAdminClient>();
            if (hooks == null || client == null || client.GetComponent(hooks) != null) return;
            Undo.AddComponent(client.gameObject, hooks);
            EditorSceneManager.MarkSceneDirty(client.gameObject.scene);
        }

        private string HooksScript()
        {
            var s = new StringBuilder();
            s.AppendLine("using EzAdmin.Client;");
            s.AppendLine("using PlayFab;");
            s.AppendLine(_items == Items.EconomyV2 ? "using PlayFab.EconomyModels;" : "using PlayFab.ClientModels;");
            s.AppendLine("using UnityEngine;");
            s.AppendLine();
            s.AppendLine("public class EzAdminHooks : MonoBehaviour");
            s.AppendLine("{");
            s.AppendLine("    private void Start()");
            s.AppendLine("    {");
            s.AppendLine("        EzAdminLink.WhenLinked(OnLinked);");
            s.AppendLine("        EzAdminLink.StatusChanged += message => Debug.Log(\"EzAdmin: \" + message);");
            s.AppendLine("    }");
            s.AppendLine();
            s.AppendLine("    private void OnLinked(string discordName)");
            s.AppendLine("    {");
            s.AppendLine("        Debug.Log(\"Linked to Discord as \" + discordName);");
            if (_items != Items.None) s.AppendLine("        LoadItems();");
            if (_mutes) s.AppendLine("        CheckMute();");
            s.AppendLine("    }");

            if (_items == Items.CatalogLegacy)
            {
                s.AppendLine();
                s.AppendLine("    public void LoadItems()");
                s.AppendLine("    {");
                s.AppendLine("        PlayFabClientAPI.GetUserInventory(new GetUserInventoryRequest(), result =>");
                s.AppendLine("        {");
                s.AppendLine("            foreach (ItemInstance item in result.Inventory)");
                s.AppendLine("            {");
                s.AppendLine("                Debug.Log(\"Has item: \" + item.ItemId);");
                s.AppendLine("            }");
                s.AppendLine("        }, error => Debug.LogWarning(error.GenerateErrorReport()));");
                s.AppendLine("    }");
            }
            else if (_items == Items.EconomyV2)
            {
                s.AppendLine();
                s.AppendLine("    public void LoadItems()");
                s.AppendLine("    {");
                s.AppendLine("        PlayFabEconomyAPI.GetInventoryItems(new GetInventoryItemsRequest { Count = 50 }, result =>");
                s.AppendLine("        {");
                s.AppendLine("            foreach (InventoryItem item in result.Items)");
                s.AppendLine("            {");
                s.AppendLine("                Debug.Log(\"Has item: \" + item.Id + \" x\" + item.Amount);");
                s.AppendLine("            }");
                s.AppendLine("        }, error => Debug.LogWarning(error.GenerateErrorReport()));");
                s.AppendLine("    }");
            }

            if (_mutes)
            {
                s.AppendLine();
                s.AppendLine("    public void CheckMute()");
                s.AppendLine("    {");
                s.AppendLine("        EzAdminClient.Instance.GetMuteStatus((muted, secondsLeft) =>");
                s.AppendLine("        {");
                s.AppendLine("            if (muted) Debug.Log(\"Muted for \" + secondsLeft + \"s\");");
                s.AppendLine("        });");
                s.AppendLine("    }");
            }

            s.AppendLine("}");
            return s.ToString();
        }
    }
}
EzAdminUpdater.csAssets/EzAdmin/Editor. Keeps these scripts up to date from your EzAdmin: no redownloading the package. Editor only; never in your build.12.0 KB · 314 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using EzAdmin.Client;
using UnityEditor;
using UnityEngine;

namespace EzAdmin.EditorTools
{

    [InitializeOnLoad]
    public static class EzAdminUpdater
    {
        private const string Root = "Assets/EzAdmin/";
        private const string Protected = "Assets/EzAdmin/MyGame/";
        private static readonly double CheckEveryHours = 6;

        private static string Key(string name) => "EzAdmin.Updater." + name + "." + Application.productName;

        private const string FilesKeyPath = "Assets/EzAdmin/Editor/EzAdminFilesKey.txt";

        private static HttpClient NewClient(int seconds)
        {
            var http = new HttpClient { Timeout = TimeSpan.FromSeconds(seconds) };
            try
            {
                if (File.Exists(FilesKeyPath))
                {
                    string key = File.ReadAllText(FilesKeyPath).Trim();
                    if (key.StartsWith("ezf_", StringComparison.Ordinal) && key.Length < 120)
                        http.DefaultRequestHeaders.Add("X-EzAdmin-Files-Key", key);
                }
            }
            catch (Exception) { }
            return http;
        }

        static EzAdminUpdater()
        {
            EditorApplication.delayCall += () =>
            {
                double last;
                double.TryParse(EditorPrefs.GetString(Key("LastCheck"), "0"), out last);
                double now = (DateTime.UtcNow - new DateTime(1970, 1, 1)).TotalHours;
                if (now - last < CheckEveryHours) return;
                EditorPrefs.SetString(Key("LastCheck"), now.ToString(System.Globalization.CultureInfo.InvariantCulture));
                _ = Check(false);
            };
        }

        [MenuItem("Tools/EzAdmin/Check for Updates", false, 1)]
        [MenuItem("Window/EzAdmin/Check for Updates", false, 1)]
        private static void CheckNow()
        {
            _ = Check(true);
        }

        [MenuItem("Tools/EzAdmin/Turn Automatic Updates Off", false, 2)]
        private static void AutoOff()
        {
            EditorPrefs.SetBool(Key("Auto"), false);
            Debug.Log("EzAdmin: automatic updates are off. You will be asked before each update.");
        }

        [Serializable]
        private class Manifest
        {
            public bool ok;
            public string version;
            public string[] notes;
            public RemoteFile[] files;
        }

        [Serializable]
        private class RemoteFile
        {
            public string path;
            public string sha256;
            public string meta;
        }

        public static string Address()
        {
            return EzAdminClient.SiteAddress;
        }

        public static void RememberAddress(string address)
        {
            if (!string.IsNullOrWhiteSpace(address)) EditorPrefs.SetString(Key("Address"), address.Trim().TrimEnd('/'));
        }

        private static async Task Check(bool userAsked)
        {
            string address = Address();
            if (string.IsNullOrEmpty(address))
            {
                if (userAsked)
                {
                    EditorUtility.DisplayDialog("EzAdmin", "Set your EzAdmin address first: Window › EzAdmin › Setup Wizard.", "OK");
                }
                return;
            }

            Manifest manifest;
            try
            {
                using (var http = NewClient(20))
                {
                    string json = await http.GetStringAsync(address + "/unity/v1/files/manifest?since=" +
                                                            Uri.EscapeDataString(EzAdminClient.Version));
                    manifest = JsonUtility.FromJson<Manifest>(json);
                }
            }
            catch (Exception e)
            {
                if (userAsked) EditorUtility.DisplayDialog("EzAdmin", "Could not reach EzAdmin at " + address + ".\n\n" + e.Message, "OK");
                return;
            }
            if (manifest == null || !manifest.ok || manifest.files == null)
            {
                if (userAsked) EditorUtility.DisplayDialog("EzAdmin", "EzAdmin did not answer with a file list.", "OK");
                return;
            }

            var changed = new List<RemoteFile>();
            foreach (var file in manifest.files)
            {
                if (!Allowed(file.path)) continue;
                if (!File.Exists(file.path) || Sha256(File.ReadAllBytes(file.path)) != file.sha256)
                {
                    changed.Add(file);
                }
            }
            if (changed.Count == 0)
            {
                if (userAsked) EditorUtility.DisplayDialog("EzAdmin", "EzAdmin is up to date (version " + manifest.version + ").", "OK");
                return;
            }

            if (!EditorPrefs.GetBool(Key("Auto"), false))
            {
                var message = new StringBuilder();
                message.AppendLine("EzAdmin " + manifest.version + " for Unity is ready. " + changed.Count +
                                   " file" + (changed.Count == 1 ? "" : "s") + " will be updated.");
                if (manifest.notes != null && manifest.notes.Length > 0)
                {
                    message.AppendLine();
                    for (int i = 0; i < manifest.notes.Length && i < 6; i++) message.AppendLine("• " + manifest.notes[i]);
                }
                message.AppendLine();
                message.AppendLine("Changes you made inside EzAdmin's own files are replaced. Assets/EzAdmin/MyGame is never touched.");
                int choice = EzAdminDialog.Ask("Update EzAdmin", message.ToString(),
                                               "Update now", "Later", "Always update automatically");
                if (choice == 1) return;
                if (choice == 2) EditorPrefs.SetBool(Key("Auto"), true);
            }

            await Apply(address, changed, manifest.version);
        }

        private static async Task Apply(string address, List<RemoteFile> files, string version)
        {
            var downloaded = new Dictionary<RemoteFile, byte[]>();
            try
            {
                using (var http = NewClient(30))
                {
                    for (int i = 0; i < files.Count; i++)
                    {
                        EditorUtility.DisplayProgressBar("Updating EzAdmin", files[i].path, (float)i / files.Count);
                        byte[] bytes = await http.GetByteArrayAsync(address + "/unity/v1/files/raw?path=" +
                                                                    Uri.EscapeDataString(files[i].path));
                        if (Sha256(bytes) != files[i].sha256)
                        {
                            throw new InvalidDataException(files[i].path + " did not arrive intact.");
                        }
                        downloaded[files[i]] = bytes;
                    }
                }
            }
            catch (Exception e)
            {
                EditorUtility.ClearProgressBar();
                EditorUtility.DisplayDialog("EzAdmin", "The update did not download, so nothing was changed.\n\n" + e.Message, "OK");
                return;
            }

            foreach (var pair in downloaded)
            {
                string path = pair.Key.path;
                Directory.CreateDirectory(Path.GetDirectoryName(path));
                File.WriteAllBytes(path, pair.Value);
                if (!File.Exists(path + ".meta") && !string.IsNullOrEmpty(pair.Key.meta))
                {
                    File.WriteAllText(path + ".meta", pair.Key.meta);
                }
            }
            EditorUtility.ClearProgressBar();
            Debug.Log("EzAdmin: updated to " + version + " (" + downloaded.Count + " file" +
                      (downloaded.Count == 1 ? "" : "s") + ").");
            AssetDatabase.Refresh();
        }

        private static bool Allowed(string path)
        {
            return !string.IsNullOrEmpty(path)
                   && path.StartsWith(Root, StringComparison.Ordinal)
                   && !path.StartsWith(Protected, StringComparison.Ordinal)
                   && !path.Contains("..")
                   && !Path.IsPathRooted(path);
        }

        private static string Sha256(byte[] bytes)
        {
            using (var sha = SHA256.Create())
            {
                var hash = sha.ComputeHash(bytes);
                var text = new StringBuilder(hash.Length * 2);
                foreach (byte b in hash) text.Append(b.ToString("x2"));
                return text.ToString();
            }
        }
    }

    [InitializeOnLoad]
    internal static class EzAdminCleanup
    {
        private static readonly string[] Retired =
        {
            "EzAdminItemGate", "EzAdminItemPicker", "EzAdminSignInObjects", "EzAdminVRGuard",
        };

        static EzAdminCleanup()
        {
            EditorApplication.delayCall += Run;
        }

        private static void Run()
        {
            var removed = new List<string>();
            foreach (string name in Retired)
            {
                foreach (string guid in AssetDatabase.FindAssets(name + " t:MonoScript"))
                {
                    string path = AssetDatabase.GUIDToAssetPath(guid);
                    if (Path.GetFileNameWithoutExtension(path) != name) continue;
                    if (!File.ReadAllText(path).Contains("Copyright (c) 2026 EzAdmin.")) continue;
                    if (AssetDatabase.DeleteAsset(path)) removed.Add(path);
                }
            }
            if (removed.Count > 0)
                Debug.Log("EzAdmin: removed old scripts it no longer uses: " + string.Join(", ", removed));
        }
    }

    internal sealed class EzAdminDialog : EditorWindow
    {
        private string _message;
        private string _ok, _cancel, _alt;
        private int _choice = 1;
        private Vector2 _scroll;

        public static int Ask(string title, string message, string ok, string cancel, string alt)
        {
            var window = CreateInstance<EzAdminDialog>();
            window.titleContent = new GUIContent(title);
            window._message = message;
            window._ok = ok;
            window._cancel = cancel;
            window._alt = alt;
            var size = new Vector2(460, 230);
            window.minSize = window.maxSize = size;
            var main = EditorGUIUtility.GetMainWindowPosition();
            window.position = new Rect(main.center - size / 2, size);
            window.ShowModalUtility();
            return window._choice;
        }

        private void OnGUI()
        {
            EditorGUI.DrawRect(new Rect(0, 0, position.width, position.height), EzAdminBrand.Panel);
            GUI.DrawTexture(new Rect(18, 18, 56, 56), EzAdminBrand.Logo(128), ScaleMode.ScaleToFit);
            var text = new GUIStyle(EditorStyles.wordWrappedLabel) { fontSize = 12 };
            text.normal.textColor = new Color(0.9f, 0.92f, 0.95f);
            GUILayout.BeginArea(new Rect(90, 16, position.width - 108, position.height - 70));
            _scroll = GUILayout.BeginScrollView(_scroll);
            GUILayout.Label(_message, text);
            GUILayout.EndScrollView();
            GUILayout.EndArea();
            GUILayout.BeginArea(new Rect(12, position.height - 44, position.width - 24, 32));
            GUILayout.BeginHorizontal();
            GUILayout.FlexibleSpace();
            if (GUILayout.Button(_ok, GUILayout.Height(26), GUILayout.MinWidth(90))) Pick(0);
            if (!string.IsNullOrEmpty(_alt) && GUILayout.Button(_alt, GUILayout.Height(26))) Pick(2);
            if (GUILayout.Button(_cancel, GUILayout.Height(26), GUILayout.MinWidth(70))) Pick(1);
            GUILayout.EndHorizontal();
            GUILayout.EndArea();
        }

        private void Pick(int choice)
        {
            _choice = choice;
            Close();
        }
    }
}
EzAdminInspectors.csAssets/EzAdmin/Editor. The EzAdmin header on each component in the Inspector.20.8 KB · 498 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using EzAdmin.Client;
using UnityEditor;
using UnityEngine;

namespace EzAdmin.EditorTools
{

    public abstract class EzAdminInspector : Editor
    {
        protected abstract string Title { get; }
        protected abstract string Blurb { get; }

        protected virtual string Status(out Color colour)
        {
            colour = EzAdminBrand.Green;
            return "Ready";
        }

        protected virtual string Hint(out MessageType type)
        {
            type = MessageType.None;
            return null;
        }

        protected virtual void Extras() { }

        protected virtual bool DrawField(SerializedProperty prop) { return false; }

        protected virtual bool ShowGroup(string title) { return true; }

        protected virtual bool OpenByDefault(string title)
        {
            return !title.StartsWith("Events", StringComparison.OrdinalIgnoreCase);
        }

        protected static readonly Color Amber = new Color32(0xFB, 0xBF, 0x24, 0xFF);
        protected static readonly Color Red = new Color32(0xF8, 0x71, 0x71, 0xFF);
        protected static readonly Color Grey = new Color32(0x9C, 0xA3, 0xAF, 0xFF);

        private static GUIStyle _title, _blurb, _pill, _section, _link;
        private static readonly Color Card = new Color32(0x1A, 0x1E, 0x27, 0xFF);
        private static readonly Color CardLight = new Color32(0xEE, 0xF0, 0xF3, 0xFF);
        private static readonly Color Line = new Color32(0x2A, 0x30, 0x3C, 0xFF);

        private List<Group> _groups;

        private sealed class Group
        {
            public string Title;
            public readonly List<string> Fields = new List<string>();
        }

        public override bool RequiresConstantRepaint() => Application.isPlaying;

        public override void OnInspectorGUI()
        {
            Styles();
            serializedObject.Update();
            DrawBrandHeader();

            MessageType type;
            string hint = Hint(out type);
            if (!string.IsNullOrEmpty(hint))
            {
                GUILayout.Space(2);
                EditorGUILayout.HelpBox(hint, type);
            }
            Extras();
            GUILayout.Space(6);

            if (_groups == null) _groups = BuildGroups(target.GetType(), serializedObject);
            foreach (var group in _groups)
                if (ShowGroup(group.Title)) DrawGroup(group);

            serializedObject.ApplyModifiedProperties();
        }

        private void DrawBrandHeader()
        {
            Rect band = GUILayoutUtility.GetRect(EditorGUIUtility.currentViewWidth, 74);
            band.x -= 18;
            band.width += 22;
            band.y -= 4;
            EditorGUI.DrawRect(band, EzAdminBrand.Panel);
            EditorGUI.DrawRect(new Rect(band.x, band.yMax - 2, band.width, 2), EzAdminBrand.Green);

            var logo = new Rect(band.x + 18, band.y + 14, 44, 44);
            GUI.DrawTexture(logo, EzAdminBrand.Logo(128), ScaleMode.ScaleToFit);
            EditorGUIUtility.AddCursorRect(logo, MouseCursor.Link);
            if (Event.current.type == EventType.MouseDown && logo.Contains(Event.current.mousePosition))
            {
                EzAdminMenu.Show(logo);
                Event.current.Use();
            }

            Color colour;
            string status = Status(out colour);
            Vector2 pillSize = _pill.CalcSize(new GUIContent(status));
            var pill = new Rect(band.xMax - pillSize.x - 30, band.y + 15, pillSize.x + 18, 20);
            DrawPill(pill, status, colour);

            float textWidth = pill.x - (band.x + 74) - 8;
            GUI.Label(new Rect(band.x + 74, band.y + 12, textWidth, 22), Title, _title);
            GUI.Label(new Rect(band.x + 74, band.y + 34, band.width - 74 - 18, 34), Blurb, _blurb);

            Rect row = GUILayoutUtility.GetRect(EditorGUIUtility.currentViewWidth, 18);
            float x = row.xMax;
            HeaderLink(ref x, row.y + 2, "Docs", () =>
            {
                string address = EzAdminUpdater.Address();
                if (string.IsNullOrEmpty(address)) EzAdminSetupWizard.Open();
                else Application.OpenURL(address + "/developers.html");
            });
            HeaderLink(ref x, row.y + 2, "Updates", () => EditorApplication.ExecuteMenuItem("Tools/EzAdmin/Check for Updates"));
            HeaderLink(ref x, row.y + 2, "Setup Wizard", EzAdminSetupWizard.Open);
        }

        private static void HeaderLink(ref float right, float y, string label, Action click)
        {
            Vector2 size = _link.CalcSize(new GUIContent(label));
            var rect = new Rect(right - size.x, y, size.x, 16);
            EditorGUIUtility.AddCursorRect(rect, MouseCursor.Link);
            if (GUI.Button(rect, label, _link)) click();
            right = rect.x - 12;
        }

        private static void DrawPill(Rect rect, string text, Color colour)
        {
            var fill = colour;
            fill.a = 0.16f;
            EditorGUI.DrawRect(rect, fill);
            EditorGUI.DrawRect(new Rect(rect.x, rect.y, 3, rect.height), colour);
            var style = new GUIStyle(_pill);
            style.normal.textColor = colour;
            GUI.Label(new Rect(rect.x + 3, rect.y, rect.width - 3, rect.height), text, style);
        }

        private static List<Group> BuildGroups(Type type, SerializedObject so)
        {
            var titles = new Dictionary<string, string>();
            for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
            {
                foreach (var field in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
                {
                    var group = field.GetCustomAttribute<EzAdminGroupAttribute>();
                    if (group != null) titles[field.Name] = group.Title;
                }
            }

            var groups = new List<Group>();
            Group current = new Group { Title = "Settings" };
            var it = so.GetIterator();
            bool enter = true;
            while (it.NextVisible(enter))
            {
                enter = false;
                if (it.name == "m_Script") continue;
                string title;
                if (titles.TryGetValue(it.name, out title))
                {
                    if (current.Fields.Count > 0) groups.Add(current);
                    current = new Group { Title = title };
                }
                current.Fields.Add(it.propertyPath);
            }
            if (current.Fields.Count > 0) groups.Add(current);
            return groups;
        }

        private void DrawGroup(Group group)
        {
            string key = "EzAdmin.Fold." + target.GetType().Name + "." + group.Title;
            bool open = SessionState.GetBool(key, OpenByDefault(group.Title));

            var card = EditorGUILayout.BeginVertical();
            EditorGUI.DrawRect(card, EditorGUIUtility.isProSkin ? Card : CardLight);
            EditorGUI.DrawRect(new Rect(card.x, card.y, 3, card.height), open ? EzAdminBrand.Green : Line);

            Rect head = GUILayoutUtility.GetRect(10, 24);
            var arrow = open ? "▾  " : "▸  ";
            if (GUI.Button(new Rect(head.x + 10, head.y + 3, head.width - 12, 20), arrow + group.Title, _section))
            {
                open = !open;
                SessionState.SetBool(key, open);
            }

            if (open)
            {
                GUILayout.Space(2);
                EditorGUI.indentLevel++;
                float labels = EditorGUIUtility.labelWidth;
                EditorGUIUtility.labelWidth = Mathf.Max(150, EditorGUIUtility.currentViewWidth * 0.42f);
                foreach (var path in group.Fields)
                {
                    var prop = serializedObject.FindProperty(path);
                    if (prop != null && !DrawField(prop)) EditorGUILayout.PropertyField(prop, true);
                }
                EditorGUIUtility.labelWidth = labels;
                EditorGUI.indentLevel--;
                GUILayout.Space(6);
            }
            EditorGUILayout.EndVertical();
            GUILayout.Space(4);
        }

        protected string Text(string field)
        {
            var prop = serializedObject.FindProperty(field);
            return prop != null && prop.propertyType == SerializedPropertyType.String ? prop.stringValue : "";
        }

        protected bool Flag(string field)
        {
            var prop = serializedObject.FindProperty(field);
            return prop != null && prop.propertyType == SerializedPropertyType.Boolean && prop.boolValue;
        }

        protected static string TitleId()
        {
            try { return PlayFab.PlayFabSettings.staticSettings.TitleId ?? ""; }
            catch { return ""; }
        }

        private static void Styles()
        {
            if (_title != null) return;
            _title = new GUIStyle(EditorStyles.boldLabel) { fontSize = 15 };
            _title.normal.textColor = new Color32(0xF5, 0xF7, 0xFA, 0xFF);
            _blurb = new GUIStyle(EditorStyles.wordWrappedMiniLabel) { fontSize = 11 };
            _blurb.normal.textColor = new Color32(0xA3, 0xA9, 0xB7, 0xFF);
            _pill = new GUIStyle(EditorStyles.miniBoldLabel) { alignment = TextAnchor.MiddleCenter, fontSize = 10 };
            _section = new GUIStyle(EditorStyles.boldLabel) { fontSize = 12 };
            _section.normal.textColor = EditorGUIUtility.isProSkin ? new Color32(0xE5, 0xE7, 0xEB, 0xFF) : new Color32(0x1F, 0x29, 0x37, 0xFF);
            _link = new GUIStyle(EditorStyles.miniLabel) { fontSize = 10 };
            _link.normal.textColor = EzAdminBrand.GreenSoft;
            _link.hover.textColor = Color.white;
        }
    }

    [CustomEditor(typeof(EzAdminClient))]
    public sealed class EzAdminClientInspector : EzAdminInspector
    {
        protected override string Title => "EzAdmin";
        protected override string Blurb => "Connects this game to your EzAdmin: linking, items, kicks, mutes and the Staff Panel.";

        protected override string Status(out Color colour)
        {
            if (string.IsNullOrEmpty(EzAdminClient.SiteAddress)) { colour = Amber; return "Not set up"; }
            if (string.IsNullOrEmpty(TitleId())) { colour = Amber; return "No Title ID"; }
            if (!Application.isPlaying) { colour = EzAdminBrand.Green; return "Ready"; }
            var client = (EzAdminClient)target;
            if (client.IsConnected) { colour = EzAdminBrand.Green; return "Connected"; }
            if (client.IsSignedIn) { colour = Amber; return "Connecting"; }
            colour = Grey;
            return "Waiting for sign-in";
        }

        protected override string Hint(out MessageType type)
        {
            type = MessageType.Warning;
            if (string.IsNullOrEmpty(EzAdminClient.SiteAddress))
                return "No EzAdmin address. Download the package again from your EzAdmin.";
            if (string.IsNullOrEmpty(TitleId()))
                return "PlayFab has no Title ID. Search the Project window for PlayFabSharedSettings and type it in.";
            if (Application.isPlaying)
            {
                var client = (EzAdminClient)target;
                if (!client.IsConnected && !string.IsNullOrEmpty(client.ConnectionProblem) && !client.ConnectionProblem.EndsWith("…"))
                {
                    type = MessageType.Error;
                    return client.ConnectionProblem;
                }
            }
            return null;
        }
    }

    [CustomEditor(typeof(EzAdminAuth))]
    public sealed class EzAdminAuthInspector : EzAdminInspector
    {
        protected override string Title => "Sign-in";
        protected override string Blurb => "Signs players in to PlayFab: Meta Quest, Steam, Xbox, PlayStation, or a saved id on phones and PC.";

        protected override string Status(out Color colour)
        {
            if (Application.isPlaying)
            {
                if (PlayFab.PlayFabClientAPI.IsClientLoggedIn()) { colour = EzAdminBrand.Green; return "Signed in: " + EzAdminAuth.SignedInWith; }
                if (!string.IsNullOrEmpty(EzAdminAuth.LastError)) { colour = Red; return "Failed"; }
                colour = Amber;
                return "Signing in";
            }
            colour = EzAdminBrand.Green;
            return EzAdminSetupWizard.MetaSdkOn() ? "Meta Quest ready" : "Ready";
        }

        protected override string Hint(out MessageType type)
        {
            if (Application.isPlaying && !string.IsNullOrEmpty(EzAdminAuth.LastError))
            {
                type = MessageType.Error;
                return EzAdminAuth.LastError;
            }
            type = MessageType.Info;
            if (EzAdminSetupWizard.MetaSdkFound() && !EzAdminSetupWizard.MetaSdkOn())
                return "The Meta Platform SDK is in this project but Meta sign-in is off. Turn it on in the Setup Wizard.";
            return null;
        }
    }

    [CustomEditor(typeof(EzAdminLinkScreen))]
    public sealed class EzAdminLinkScreenInspector : EzAdminInspector
    {
        protected override string Title => "Link Discord button";
        protected override string Blurb => "Opens the EzAdmin website so players link Discord. A button on screen, or a board in VR.";

        protected override string Status(out Color colour)
        {
            if (Application.isPlaying && EzAdminLink.IsLinked) { colour = EzAdminBrand.Green; return "Linked"; }
            if (FindObjectOfType<EzAdminClient>() == null) { colour = Amber; return "No EzAdmin in scene"; }
            colour = EzAdminBrand.Green;
            return "Ready";
        }
    }

    [CustomEditor(typeof(EzAdminAntiCheat))]
    public sealed class EzAdminAntiCheatInspector : EzAdminInspector
    {
        protected override string Title => "Anti tamper";
        protected override string Blurb => "Mod loaders, injected code, debuggers, memory editing, rooted devices " +
                                           "and modified copies, reported to your Discord.";

        protected override string Status(out Color colour)
        {
            if (FindObjectOfType<EzAdminClient>() == null) { colour = Amber; return "No EzAdmin in scene"; }
            colour = EzAdminBrand.Green;
            return Application.isPlaying ? "Watching (not in the Editor)" : "Ready";
        }

        protected override string Hint(out MessageType type)
        {
            type = MessageType.Info;
            return "Nothing is reported from the Editor. Switch on Anti tamper for Unity in Labs on the dashboard, and " +
                   "automatic kicks and bans there too. Keep important numbers in EzSecureInt / EzSecureFloat.";
        }
    }

    [CustomEditor(typeof(EzAdminItemUnlock))]
    public sealed class EzAdminItemUnlockInspector : EzAdminInspector
    {
        protected override string Title => "Item unlock";
        protected override string Blurb => "Shows Purchase: item before they own it and Owned: item after, and turns objects on or off. Type the item id or name.";
    }

    [CustomEditor(typeof(EzAdminProfileView))]
    public sealed class EzAdminProfileViewInspector : EzAdminInspector
    {
        protected override string Title => "Discord profile";
        protected override string Blurb => "Shows a player's Discord name, id and picture, for leaderboards. Call Show(playFabId).";
    }

    [CustomEditor(typeof(EzAdminLinkPanel))]
    public sealed class EzAdminLinkPanelInspector : EzAdminInspector
    {
        protected override string Title => "Link code panel";
        protected override string Blurb => "Typing a link code in your own UI. Most games use the Link Discord button instead.";
    }

    [CustomEditor(typeof(EzAdminTouchButton))]
    public sealed class EzAdminTouchButtonInspector : EzAdminInspector
    {
        protected override string Title => "Touch button";
        protected override string Blurb => "A button players press with a finger or a click, in VR or flat.";
    }

    [CustomEditor(typeof(EzAdminKeyboardInput))]
    public sealed class EzAdminKeyboardInputInspector : EzAdminInspector
    {
        protected override string Title => "Keyboard input";
        protected override string Blurb => "Sends typed keys to a link code panel: the Quest keyboard, a keypad, or your own.";
    }

#if EZADMIN_DISCORD_SDK
    [CustomEditor(typeof(EzAdminDiscord))]
    public sealed class EzAdminDiscordInspector : EzAdminInspector
    {
        protected override string Title => "Discord";
        protected override string Blurb => "Discord sign-in for your own UI.";
    }
#endif

    [CustomEditor(typeof(EzAdminAutoLogin))]
    public sealed class EzAdminAutoLoginInspector : EzAdminInspector
    {
        protected override string Title => "Auto login (old)";
        protected override string Blurb => "A simple device login. Use the Sign-in component (EzAdminAuth) instead: it does every platform.";

        protected override string Status(out Color colour)
        {
            colour = Grey;
            return "Replaced";
        }
    }

    [InitializeOnLoad]
    internal static class EzAdminScriptIcons
    {
        static EzAdminScriptIcons()
        {
            EditorApplication.delayCall += Apply;
        }

        [MenuItem("Tools/EzAdmin/Refresh Script Icons", priority = 200)]
        private static void Apply()
        {
            if (EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling) return;
            var icon = IconAsset();
            if (icon == null) return;

            foreach (var guid in AssetDatabase.FindAssets("t:MonoScript EzAdmin"))
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                if (!path.Replace('\\', '/').Contains("/EzAdmin/Runtime/")) continue;
                var script = AssetDatabase.LoadAssetAtPath<MonoScript>(path);
                var type = script != null ? script.GetClass() : null;
                if (type == null || !typeof(MonoBehaviour).IsAssignableFrom(type)) continue;
                if (EditorGUIUtility.GetIconForObject(script) == icon) continue;
                EditorGUIUtility.SetIconForObject(script, icon);
            }
        }

        private static Texture2D IconAsset()
        {
            var self = AssetDatabase.FindAssets("EzAdminInspectors t:MonoScript");
            if (self.Length == 0) return null;
            string folder = Path.GetDirectoryName(AssetDatabase.GUIDToAssetPath(self[0])).Replace('\\', '/');
            string path = folder + "/EzAdmin Icon.png";

            var icon = AssetDatabase.LoadAssetAtPath<Texture2D>(path);
            if (icon != null) return icon;

            File.WriteAllBytes(path, EzAdminBrand.Logo(128).EncodeToPNG());
            AssetDatabase.ImportAsset(path);
            if (AssetImporter.GetAtPath(path) is TextureImporter importer)
            {
                importer.textureType = TextureImporterType.GUI;
                importer.alphaIsTransparency = true;
                importer.mipmapEnabled = false;
                importer.SaveAndReimport();
            }
            return AssetDatabase.LoadAssetAtPath<Texture2D>(path);
        }
    }

    public static class EzAdminMenu
    {
        private static string Site => EzAdmin.Client.EzAdminClient.SiteAddress;

        public static void Show(Rect at)
        {
            var menu = new GenericMenu();
            menu.AddItem(new GUIContent("Setup Wizard"), false, OpenWizard);
            menu.AddItem(new GUIContent("Dashboard"), false, Dashboard);
            menu.AddSeparator("");
            menu.AddItem(new GUIContent("Setup guide"), false, Guide);
            menu.AddItem(new GUIContent("Troubleshooting"), false, Troubleshooting);
            menu.AddItem(new GUIContent("Support"), false, Support);
            menu.AddSeparator("");
            menu.AddItem(new GUIContent("Check for updates"), false, Updates);
            menu.DropDown(at);
        }

        [MenuItem("EzAdmin/Setup Wizard", false, 0)]
        private static void OpenWizard() => EditorApplication.ExecuteMenuItem("Tools/EzAdmin/Setup Wizard");

        [MenuItem("EzAdmin/Dashboard", false, 20)]
        private static void Dashboard() => Application.OpenURL(Site + "/dashboard");

        [MenuItem("EzAdmin/Setup guide", false, 40)]
        private static void Guide() => Application.OpenURL(Site + "/setup-unity.html");

        [MenuItem("EzAdmin/Troubleshooting", false, 41)]
        private static void Troubleshooting() => Application.OpenURL(Site + "/troubleshooting.html");

        [MenuItem("EzAdmin/Support", false, 42)]
        private static void Support() => Application.OpenURL(Site + "/support.html");

        [MenuItem("EzAdmin/Check for updates", false, 60)]
        private static void Updates() => EditorApplication.ExecuteMenuItem("Tools/EzAdmin/Check for Updates");
    }
}
EzAdminBuildGuard.csAssets/EzAdmin/Editor. Stops a release build on Mono, whose code anyone can dump, and adds Tools › EzAdmin › Protect Build. Editor only; never in your build.2.6 KB · 56 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Client code, licensed under LICENSE-CLIENT.txt.

using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;

namespace EzAdmin.EditorTools
{
    public sealed class EzAdminBuildGuard : IPreprocessBuildWithReport
    {
        public int callbackOrder => -1000;

        public void OnPreprocessBuild(BuildReport report)
        {
            if (EditorUserBuildSettings.development) return;
#if EZADMIN_ALLOW_MONO
            return;
#else
            BuildTargetGroup group = BuildPipeline.GetBuildTargetGroup(report.summary.platform);
            if (group == BuildTargetGroup.WebGL) return;
            if (PlayerSettings.GetScriptingBackend(group) != ScriptingImplementation.Mono2x) return;
            throw new BuildFailedException(
                "EzAdmin: this release build uses Mono, which ships your game's code as plain DLLs anyone can open " +
                "and dump. Use Tools > EzAdmin > Protect Build (switches to IL2CPP with full code stripping), or add " +
                "EZADMIN_ALLOW_MONO to Scripting Define Symbols to build anyway.");
#endif
        }

        [MenuItem("Tools/EzAdmin/Protect Build", false, 20)]
        [MenuItem("Window/EzAdmin/Protect Build", false, 20)]
        public static void ProtectBuild()
        {
            BuildTargetGroup group = EditorUserBuildSettings.selectedBuildTargetGroup;
            if (group == BuildTargetGroup.WebGL)
            {
                EditorUtility.DisplayDialog("EzAdmin", "WebGL is always IL2CPP. Nothing to change.", "OK");
                return;
            }
            PlayerSettings.SetScriptingBackend(group, ScriptingImplementation.IL2CPP);
            PlayerSettings.SetManagedStrippingLevel(group, ManagedStrippingLevel.High);
            PlayerSettings.SetIl2CppCompilerConfiguration(group, Il2CppCompilerConfiguration.Release);
            PlayerSettings.stripEngineCode = true;
            EditorUserBuildSettings.development = false;
            EditorUserBuildSettings.allowDebugging = false;
            EditorUserBuildSettings.connectProfiler = false;
            AssetDatabase.SaveAssets();
            Debug.Log("[EzAdmin] Build protected: IL2CPP, high code stripping, release compiler, no debugging. " +
                      "If stripping removes something your game needs by reflection, list it in a link.xml.");
            EditorUtility.DisplayDialog("EzAdmin",
                "Done. " + group + " now builds with IL2CPP, high code stripping and no script debugging, so the " +
                "game's code ships as native code instead of readable DLLs.", "OK");
        }
    }
}
EzAdminLoginGate.csAssets/EzAdmin/Runtime. Add it to any object, drag in what should stay off until the player is linked with EzAdmin (or is staff), and it switches them on once they are.4.5 KB · 126 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Collections;
using UnityEngine;
using UnityEngine.Events;

namespace EzAdmin.Client
{
    [AddComponentMenu("EzAdmin/EzAdmin Login Gate")]
    public sealed class EzAdminLoginGate : MonoBehaviour
    {
        public enum Requirement
        {
            LinkedToDiscord,
            ConnectedToEzAdmin,
            Staff,
            StaffTools,
        }

        [Tooltip("What the player must be before these switch on.\n" +
                 "Linked To Discord: they linked their Discord account.\n" +
                 "Connected To EzAdmin: signed in and connected, linked or not.\n" +
                 "Staff: their linked Discord holds a moderation permission.\n" +
                 "Staff Tools: they may use in-game staff tools.")]
        [SerializeField] private Requirement _requirement = Requirement.LinkedToDiscord;

        [Tooltip("Switched ON once the requirement is met, OFF until then.")]
        [SerializeField] private GameObject[] _enableWhenReady = new GameObject[0];

        [Tooltip("Switched OFF once the requirement is met, e.g. a \"Link your Discord to play\" sign.")]
        [SerializeField] private GameObject[] _disableWhenReady = new GameObject[0];

        [Tooltip("Scripts or colliders switched on once the requirement is met, e.g. a door's script.")]
        [SerializeField] private Behaviour[] _componentsWhenReady = new Behaviour[0];

        [Tooltip("Switch everything back off if the player stops meeting the requirement (unlinked, lost staff).")]
        [SerializeField] private bool _relockIfLost = true;

        [SerializeField] private UnityEvent _onReady = new UnityEvent();
        [SerializeField] private UnityEvent _onNotReady = new UnityEvent();

        private int _state = -1;

        public bool IsReady => _state == 1;

        public Requirement Needs
        {
            get => _requirement;
            set { _requirement = value; Refresh(); }
        }

        private void Awake()
        {
            Apply(false, true);
        }

        private void OnEnable()
        {
            EzAdminLink.Linked += HandleAny;
            EzAdminLink.StatusChanged += HandleAny;
            EzAdminClient.ConnectionChanged += HandleAny;
            EzAdminClient.SignedIn += HandleAny;
            EzAdminClient.StaffChanged += HandleBool;
            EzAdminClient.StaffToolsChanged += HandleBool;
            StartCoroutine(Watch());
        }

        private void OnDisable()
        {
            EzAdminLink.Linked -= HandleAny;
            EzAdminLink.StatusChanged -= HandleAny;
            EzAdminClient.ConnectionChanged -= HandleAny;
            EzAdminClient.SignedIn -= HandleAny;
            EzAdminClient.StaffChanged -= HandleBool;
            EzAdminClient.StaffToolsChanged -= HandleBool;
        }

        private void HandleAny(string _) => Refresh();

        private void HandleBool(bool _) => Refresh();

        private IEnumerator Watch()
        {
            var wait = new WaitForSecondsRealtime(1f);
            while (true)
            {
                Refresh();
                yield return wait;
            }
        }

        private bool Met()
        {
            var client = EzAdminClient.Instance;
            switch (_requirement)
            {
                case Requirement.LinkedToDiscord: return EzAdminLink.IsLinked;
                case Requirement.ConnectedToEzAdmin: return client != null && client.IsConnected;
                case Requirement.Staff: return EzAdminClient.IsStaff;
                case Requirement.StaffTools: return EzAdminClient.CanUseStaffTools;
                default: return false;
            }
        }

        public void Refresh()
        {
            if (this == null) return;
            bool ready = Met();
            if (!ready && _state == 1 && !_relockIfLost) return;
            int next = ready ? 1 : 0;
            if (next == _state) return;
            Apply(ready, false);
        }

        private void Apply(bool ready, bool silent)
        {
            _state = ready ? 1 : 0;
            foreach (var go in _enableWhenReady) if (go != null) go.SetActive(ready);
            foreach (var go in _disableWhenReady) if (go != null) go.SetActive(!ready);
            foreach (var part in _componentsWhenReady) if (part != null) part.enabled = ready;
            if (silent) return;
            if (ready) _onReady.Invoke(); else _onNotReady.Invoke();
        }
    }
}
EzAdminLoadingScreen.csAssets/EzAdmin/Runtime. A loading and sign in screen for your first scene. It signs the player in, loads their items, then opens your game. Your name, logo, colours and tips; floating in front of the player in VR.67.3 KB · 1523 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using PlayFab;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.XR;

namespace EzAdmin.Client
{
    [DisallowMultipleComponent]
    public sealed class EzAdminLoadingScreen : MonoBehaviour
    {
        public enum ScreenMode { Automatic, VR, Flat }

        [EzAdminGroup("Your game")]
        [Tooltip("Shown big in the middle. Leave empty to use the product name from Player Settings.")]
        [SerializeField] private string _gameName = "";
        [Tooltip("Your game's logo. Leave empty to show the EzAdmin mark.")]
        [SerializeField] private Sprite _gameLogo = null;
        [Tooltip("A line under the name, for example Season 3 or a version.")]
        [SerializeField] private string _subtitle = "";
        [Tooltip("The scene that opens once loading is done.")]
        [SerializeField] private string _nextScene = "";
        [Tooltip("Shown one at a time while loading. Leave empty for none.")]
        [SerializeField, TextArea(1, 3)] private string[] _tips = new string[0];
        [SerializeField, Range(2f, 15f)] private float _secondsPerTip = 5f;

        [EzAdminGroup("Colours")]
        [Tooltip("The progress bar, the steps, the button, the edge of the card and the floor glow in VR.")]
        [SerializeField] private Color _accent = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        [Tooltip("Every word on the screen.")]
        [SerializeField] private Color _text = new Color32(0xF2, 0xF4, 0xF8, 0xFF);
        [Tooltip("The card in the middle.")]
        [SerializeField] private Color _panel = new Color32(0x10, 0x13, 0x1A, 0xF7);
        [Tooltip("Behind the card.")]
        [SerializeField] private Color _background = new Color32(0x06, 0x08, 0x0C, 0xFF);
        [Tooltip("A picture behind everything. Optional.")]
        [SerializeField] private Sprite _backgroundImage = null;
        [Tooltip("Paint the camera background in the background colour while this screen is up.")]
        [SerializeField] private bool _paintCamera = true;

        [EzAdminGroup("What it shows")]
        [Tooltip("The progress bar with Step 2 of 4.")]
        [SerializeField] private bool _showProgress = true;
        [Tooltip("The percentage next to the bar.")]
        [SerializeField] private bool _showPercent = true;
        [Tooltip("The four steps: Connecting, Signing in, Loading items, Loading the world.")]
        [SerializeField] private bool _showSteps = true;
        [Tooltip("The spinner next to the status.")]
        [SerializeField] private bool _showSpinner = true;
        [Tooltip("The logo breathes gently while loading.")]
        [SerializeField] private bool _animateLogo = true;
        [Tooltip("A small Secured by EzAdmin mark under the card.")]
        [SerializeField] private bool _showSecuredBy = true;

        [EzAdminGroup("How it behaves")]
        [Tooltip("Wait until the player is signed in to PlayFab before opening the game.")]
        [SerializeField] private bool _waitForSignIn = true;
        [Tooltip("Wait until the player's items have loaded, so nothing pops in after the world appears.")]
        [SerializeField] private bool _waitForItems = true;
        [Tooltip("Give up waiting for sign-in after this many seconds.")]
        [SerializeField, Range(5f, 90f)] private float _signInTimeout = 30f;
        [Tooltip("When sign-in fails, open the game anyway instead of showing an error.")]
        [SerializeField] private bool _continueIfSignInFails = false;
        [Tooltip("Show a Try again button when sign-in fails.")]
        [SerializeField] private bool _allowRetry = true;
        [Tooltip("In VR, pressing the trigger or A also tries again. The screen says so under the button.")]
        [SerializeField] private bool _retryWithTrigger = true;
        [Tooltip("Try again by itself after this many seconds. 0 waits for the player.")]
        [SerializeField, Range(0f, 60f)] private float _autoRetrySeconds = 0f;
        [Tooltip("Keep the screen up at least this long so it never flashes.")]
        [SerializeField, Range(0f, 8f)] private float _minimumSeconds = 2f;
        [Tooltip("Each step stays on screen at least this long, so players can read it.")]
        [SerializeField, Range(0f, 2f)] private float _stepSeconds = 0.6f;
        [Tooltip("How long the screen takes to fade in and out.")]
        [SerializeField, Range(0.1f, 2f)] private float _fadeSeconds = 0.45f;
        [Tooltip("Optional sound when the game is ready.")]
        [SerializeField] private AudioClip _readySound = null;
        [Tooltip("Optional sound when sign-in fails.")]
        [SerializeField] private AudioClip _errorSound = null;

        [EzAdminGroup("Where it shows")]
        [Tooltip("Automatic: floating in front of the player on a headset, full screen on PC, consoles and phones.")]
        [SerializeField] private ScreenMode _mode = ScreenMode.Automatic;

        [EzAdminGroup("VR")]
        [Tooltip("How far in front of the player the screen floats, in metres.")]
        [SerializeField, Range(1f, 5f)] private float _vrDistance = 2.3f;
        [Tooltip("How wide the screen is, in metres.")]
        [SerializeField, Range(0.8f, 4f)] private float _vrWidth = 2.0f;
        [Tooltip("Bring the screen back in front of the player when they look away.")]
        [SerializeField] private bool _followHead = true;
        [Tooltip("How far the player can look away before the screen comes back, in degrees.")]
        [SerializeField, Range(10f, 60f)] private float _followAngle = 30f;
        [Tooltip("A soft glow on the floor under the player, so they are not standing in a void.")]
        [SerializeField] private bool _vrSpace = true;
        [Tooltip("A short buzz in the controllers when the game is ready.")]
        [SerializeField] private bool _haptics = true;

        [EzAdminGroup("Words")]
        [SerializeField] private string _stepConnect = "Connecting";
        [SerializeField] private string _stepSignIn = "Signing in";
        [SerializeField] private string _stepItems = "Loading items";
        [SerializeField] private string _stepWorld = "Loading the world";
        [SerializeField] private string _welcome = "Welcome back";
        [SerializeField] private string _ready = "Ready";
        [SerializeField] private string _failedTitle = "Could not sign in";
        [SerializeField] private string _retryLabel = "Try again";
        [SerializeField] private string _retryHint = "or press the trigger";
        [SerializeField] private string _tipLabel = "TIP";
        [Tooltip("Optional. A TextMeshPro font asset. Used when the design is built.")]
        [SerializeField] private UnityEngine.Object _fontAsset = null;
        [Tooltip("Optional. Only used when TextMeshPro is not in the project.")]
        [SerializeField] private Font _font = null;

        [EzAdminGroup("Events")]
        [SerializeField] private UnityEvent _onSignedIn = new UnityEvent();
        [SerializeField] private UnityEvent _onReady = new UnityEvent();
        [SerializeField] private StringEvent _onFailed = new StringEvent();

        [Serializable] public class StringEvent : UnityEvent<string> { }

        [Serializable]
        private sealed class Parts
        {
            public Canvas canvas;
            public CanvasGroup group;
            public CanvasGroup content;
            public GameObject backdrop;
            public GameObject backdropArt;
            public GameObject cardArt;
            public GameObject floor;
            public GameObject progress;
            public GameObject steps;
            public GameObject footer;
            public Image bar;
            public RectTransform barFill;
            public Graphic percent;
            public Graphic stepCount;
            public Graphic status;
            public Graphic title;
            public Graphic subtitle;
            public Graphic tip;
            public Graphic tipLabel;
            public Graphic error;
            public Graphic retryLabel;
            public Graphic retryHint;
            public GameObject tipRow;
            public Button retry;
            public RectTransform spinner;
            public RectTransform logoFrame;
            public Image logo;
            public Image backdropImage;
            public Image cardImage;
            public Image[] nodes;
            public Image[] marks;
            public Image[] links;
            public Graphic[] stepTexts;
            public Graphic[] tintGraphics;
            public int[] tintRoles;
            public float[] tintAlphas;
        }

        [SerializeField, HideInInspector] private Parts _parts = new Parts();

        public static Func<string, Sprite, Sprite> SpriteStore;

        private enum Role { Accent, Text, Panel, Background, Lift }

        private sealed class Tint
        {
            public Graphic Graphic;
            public Role Role;
            public float Alpha;
        }

        private sealed class Words
        {
            public Graphic Graphic;
            private PropertyInfo _textProp;
            private PropertyInfo _styleProp;
            private object _bold, _normal;

            public GameObject gameObject => Graphic != null ? Graphic.gameObject : null;

            public static Words Wrap(Graphic g)
            {
                if (g == null) return null;
                var w = new Words { Graphic = g };
                w._textProp = g.GetType().GetProperty("text", BindingFlags.Public | BindingFlags.Instance);
                w._styleProp = g.GetType().GetProperty("fontStyle", BindingFlags.Public | BindingFlags.Instance);
                if (w._styleProp != null && w._styleProp.PropertyType.IsEnum)
                {
                    var type = w._styleProp.PropertyType;
                    w._bold = Enum.Parse(type, "Bold");
                    w._normal = Enum.Parse(type, "Normal");
                }
                return w;
            }

            public string text
            {
                get { return _textProp != null ? _textProp.GetValue(Graphic) as string : ""; }
                set { if (_textProp != null && Graphic != null) _textProp.SetValue(Graphic, value ?? ""); }
            }

            public Color color
            {
                get { return Graphic != null ? Graphic.color : Color.white; }
                set { if (Graphic != null) Graphic.color = value; }
            }

            public bool bold
            {
                set { if (_styleProp != null && Graphic != null) _styleProp.SetValue(Graphic, value ? _bold : _normal); }
            }
        }

        private const float CardW = 1100f;
        private const float CardH = 820f;
        private const float BarW = 780f;

        private readonly List<CanvasGroup> _groups = new List<CanvasGroup>();
        private readonly List<Tint> _tints = new List<Tint>();
        private Canvas _canvas;
        private CanvasGroup _group;
        private CanvasGroup _content;
        private Words _percent;
        private Words _stepCount;
        private Words _status;
        private Words _tip;
        private Words _error;
        private Words[] _stepTexts;
        private int[] _stepState;
        private float[] _popAt;
        private Font _usedFont;
        private float _shown;
        private float _target;
        private string _failure;
        private bool _signedIn;
        private Camera _camera;
        private CameraClearFlags _oldFlags;
        private Color _oldColour;
        private bool _painted;
        private bool _placed;
        private bool _recentring;
        private bool _pressedLast;
        private bool _vrDecided;
        private bool _vrResult;
        private bool _vrNow;
        private bool _built;
        private Color _appliedAccent, _appliedText, _appliedPanel, _appliedBackground;

        private static Type _tmpType;
        private static bool _tmpLooked;
        private static readonly Dictionary<string, Sprite> Sprites = new Dictionary<string, Sprite>();

        public static EzAdminLoadingScreen Current { get; private set; }
        public float Progress => _shown;
        public bool HasDesign => _parts != null && _parts.canvas != null;

        public Color Accent
        {
            get { return _accent; }
            set { _accent = value; ApplyTheme(); }
        }

        private bool IsVR
        {
            get
            {
                if (_mode == ScreenMode.VR) return true;
                if (_mode == ScreenMode.Flat) return false;
                if (_vrDecided) return _vrResult;
                return HeadsetOn();
            }
        }

        private static bool HeadsetOn()
        {
            if (XRSettings.isDeviceActive) return true;
            if (XRSettings.enabled && !string.IsNullOrEmpty(XRSettings.loadedDeviceName)
                && XRSettings.loadedDeviceName != "None") return true;
            var heads = new List<InputDevice>();
            InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.HeadMounted, heads);
            return heads.Count > 0;
        }

        private static bool XrConfigured()
        {
            try
            {
                if (XRSettings.enabled) return true;
            }
            catch (Exception)
            {
            }
            return Type.GetType("UnityEngine.XR.Management.XRGeneralSettings, Unity.XR.Management") != null;
        }

        private void Awake()
        {
            if (!Application.isPlaying) return;
            Current = this;
            _usedFont = _font != null ? _font : LoadFont();
            _camera = Camera.main;
            if (_paintCamera && _camera != null)
            {
                _oldFlags = _camera.clearFlags;
                _oldColour = _camera.backgroundColor;
                _camera.clearFlags = CameraClearFlags.SolidColor;
                _camera.backgroundColor = _background;
                _painted = true;
            }
            if (HasDesign)
            {
                if (_parts.group != null) _parts.group.alpha = 0f;
                if (_parts.floor != null) _parts.floor.SetActive(false);
            }
        }

        private void OnEnable()
        {
            EzAdminAuth.SignedIn += OnSignedIn;
            EzAdminAuth.Failed += OnFailed;
            EzAdminAuth.Banned += OnBanned;
        }

        private void OnDisable()
        {
            EzAdminAuth.SignedIn -= OnSignedIn;
            EzAdminAuth.Failed -= OnFailed;
            EzAdminAuth.Banned -= OnBanned;
            if (Current == this) Current = null;
        }

        private void OnValidate()
        {
            if (!HasDesign) return;
#if UNITY_EDITOR
            if (!Application.isPlaying)
            {
                UnityEditor.EditorApplication.delayCall += () =>
                {
                    if (this == null || !HasDesign) return;
                    Adopt();
                    ApplyMode(_mode == ScreenMode.VR);
                    Refresh();
                };
                return;
            }
#endif
            if (_built) Refresh();
        }

        private void Start()
        {
            if (Application.isPlaying) StartCoroutine(Boot());
        }

        private IEnumerator Boot()
        {
            if (_mode == ScreenMode.Automatic && XrConfigured())
            {
                float until = Time.realtimeSinceStartup + 2f;
                while (!HeadsetOn() && Time.realtimeSinceStartup < until) yield return null;
            }
            _vrResult = HeadsetOn();
            _vrDecided = true;
            if (!HasDesign)
            {
                foreach (var key in new[] { "round", "softbox", "glow", "vfade", "hfade", "arc", "disc", "hoop",
                                            "check", "cross", "vignette", "hoopthin" })
                {
                    Sprite(key);
                    yield return null;
                }
                BuildDesign();
            }
            Adopt();
            ApplyMode(IsVR);
            Refresh();
            if (_parts.group != null) _parts.group.alpha = 0f;
            EnsureEventSystem();
            _built = true;
            yield return null;
            StartCoroutine(Run());
            if (_tip != null && _tips != null && _tips.Length > 0) StartCoroutine(Tips());
        }

        private void OnSignedIn(string playFabId, EzAdminAuth.Method method)
        {
            _signedIn = true;
        }

        private void OnFailed(string reason)
        {
            _failure = string.IsNullOrEmpty(reason) ? "Sign-in did not work." : reason;
        }

        private void OnBanned(DateTime until)
        {
            _failure = until > DateTime.UtcNow.AddYears(50)
                ? "This account is banned."
                : "This account is banned until " + until.ToLocalTime().ToString("d MMM yyyy, HH:mm") + ".";
            _continueIfSignInFails = false;
        }

        private IEnumerator Run()
        {
            float started = Time.unscaledTime;
            yield return FadeAll(1f, _fadeSeconds);

            Step(0, 1);
            float stepAt = Time.unscaledTime;
            _target = Mathf.Max(_target, 0.08f);
            float waitClient = Time.unscaledTime + 3f;
            while (EzAdminClient.Instance == null && Time.unscaledTime < waitClient) yield return null;
            yield return Hold(stepAt);
            Step(0, 2);

            if (_waitForSignIn)
            {
                Step(1, 1);
                stepAt = Time.unscaledTime;
                yield return SignInPhase();
                if (!string.IsNullOrEmpty(_failure) && !_continueIfSignInFails) yield break;
                if (string.IsNullOrEmpty(_failure)) yield return Hold(stepAt);
                bool inNow = _signedIn || PlayFabClientAPI.IsClientLoggedIn();
                Step(1, inNow ? 2 : 3);
                if (inNow)
                {
                    _onSignedIn.Invoke();
                    SetStatus(_welcome);
                    yield return new WaitForSecondsRealtime(0.5f);
                }
            }
            else
            {
                Step(1, 3);
            }
            _target = Mathf.Max(_target, 0.4f);

            if (_waitForItems && PlayFabClientAPI.IsClientLoggedIn())
            {
                Step(2, 1);
                stepAt = Time.unscaledTime;
                EzAdminItems.Start();
                float until = Time.unscaledTime + 10f;
                while (!EzAdminItems.Loaded && Time.unscaledTime < until)
                {
                    _target = Mathf.MoveTowards(_target, 0.52f, Time.unscaledDeltaTime * 0.05f);
                    yield return null;
                }
                yield return Hold(stepAt);
                Step(2, EzAdminItems.Loaded ? 2 : 3);
            }
            else
            {
                Step(2, 3);
            }
            _target = Mathf.Max(_target, 0.55f);

            Step(3, 1);
            AsyncOperation load = StartLoad();
            if (load != null)
            {
                load.allowSceneActivation = false;
                while (load.progress < 0.9f)
                {
                    _target = Mathf.Max(_target, 0.55f + load.progress / 0.9f * 0.42f);
                    yield return null;
                }
            }
            _target = 0.97f;
            while (Time.unscaledTime - started < _minimumSeconds) yield return null;
            _target = 1f;
            while (_shown < 0.995f) yield return null;
            Step(3, 2);
            SetStatus(_ready);
            if (_parts.spinner != null) _parts.spinner.gameObject.SetActive(false);
            Buzz(0.25f, 0.08f);
            Play(_readySound);
            _onReady.Invoke();
            yield return new WaitForSecondsRealtime(0.35f);

            yield return FadeAll(0f, _fadeSeconds);
            if (_parts.canvas != null) _parts.canvas.gameObject.SetActive(false);
            if (_parts.floor != null) _parts.floor.SetActive(false);
            if (load == null)
            {
                Restore();
                yield break;
            }
            load.allowSceneActivation = true;
        }

        private IEnumerator Hold(float since)
        {
            while (Time.unscaledTime - since < _stepSeconds) yield return null;
        }

        private IEnumerator SignInPhase()
        {
            _failure = null;
            if (PlayFabClientAPI.IsClientLoggedIn())
            {
                _signedIn = true;
                yield break;
            }
            float until = Time.unscaledTime + _signInTimeout;
            while (!PlayFabClientAPI.IsClientLoggedIn() && string.IsNullOrEmpty(_failure) && Time.unscaledTime < until)
            {
                _target = Mathf.MoveTowards(_target, 0.34f, Time.unscaledDeltaTime * 0.03f);
                yield return null;
            }
            if (PlayFabClientAPI.IsClientLoggedIn()) yield break;
            if (string.IsNullOrEmpty(_failure))
            {
                _failure = FindObjectOfType<EzAdminAuth>() == null
                    ? "Nothing signed the player in. Add the EzAdmin Sign-in component to this scene."
                    : "Sign-in is taking too long. Check the internet connection.";
            }
            _onFailed.Invoke(_failure);
            if (_continueIfSignInFails) yield break;
            ShowError(_failure);
        }

        private void ShowError(string message)
        {
            Step(1, 4);
            SetStatus(_failedTitle);
            if (_error != null)
            {
                _error.text = message;
                _error.gameObject.SetActive(true);
            }
            bool banned = message.Contains("banned");
            bool retry = _allowRetry && !banned;
            if (_parts.retry != null) _parts.retry.gameObject.SetActive(retry);
            if (_parts.retryHint != null) _parts.retryHint.gameObject.SetActive(retry && _vrNow && _retryWithTrigger);
            if (_parts.tipRow != null) _parts.tipRow.SetActive(false);
            if (_parts.spinner != null) _parts.spinner.gameObject.SetActive(false);
            Buzz(0.4f, 0.12f);
            Play(_errorSound);
            if (!banned && _autoRetrySeconds > 0f) StartCoroutine(AutoRetry());
        }

        private IEnumerator AutoRetry()
        {
            yield return new WaitForSecondsRealtime(_autoRetrySeconds);
            if (!string.IsNullOrEmpty(_failure)) Retry();
        }

        public void Retry()
        {
            if (_error != null) _error.gameObject.SetActive(false);
            if (_parts.retry != null) _parts.retry.gameObject.SetActive(false);
            if (_parts.retryHint != null) _parts.retryHint.gameObject.SetActive(false);
            if (_parts.tipRow != null) _parts.tipRow.SetActive(true);
            if (_parts.spinner != null) _parts.spinner.gameObject.SetActive(_showSpinner);
            _failure = null;
            var auth = FindObjectOfType<EzAdminAuth>();
            if (auth != null) auth.SignIn();
            StopAllCoroutines();
            for (int i = 0; _stepState != null && i < _stepState.Length; i++) Step(i, 0);
            StartCoroutine(Run());
            if (_tip != null && _tips != null && _tips.Length > 0) StartCoroutine(Tips());
        }

        private AsyncOperation StartLoad()
        {
            if (!string.IsNullOrEmpty(_nextScene))
                return Application.CanStreamedLevelBeLoaded(_nextScene) ? SceneManager.LoadSceneAsync(_nextScene) : null;
            int next = SceneManager.GetActiveScene().buildIndex + 1;
            return next < SceneManager.sceneCountInBuildSettings ? SceneManager.LoadSceneAsync(next) : null;
        }

        private IEnumerator Tips()
        {
            var tipGroup = _tip.gameObject.GetComponent<CanvasGroup>();
            if (tipGroup == null) tipGroup = _tip.gameObject.AddComponent<CanvasGroup>();
            int i = UnityEngine.Random.Range(0, _tips.Length);
            while (_tip != null && _tip.Graphic != null)
            {
                _tip.text = _tips[i % _tips.Length];
                yield return Fade(tipGroup, 0f, 1f, 0.35f);
                yield return new WaitForSecondsRealtime(_secondsPerTip);
                yield return Fade(tipGroup, 1f, 0f, 0.35f);
                i++;
            }
        }

        private void Update()
        {
            if (!_built) return;
            float dt = Mathf.Min(Time.unscaledDeltaTime, 1f / 30f);
            if (_accent != _appliedAccent || _text != _appliedText || _panel != _appliedPanel || _background != _appliedBackground)
                ApplyTheme();
            _shown = Mathf.MoveTowards(_shown, _target, dt * (0.08f + Mathf.Abs(_target - _shown) * 3f));
            if (_parts.barFill != null) _parts.barFill.anchorMax = new Vector2(Mathf.Max(0.02f, _shown), 1f);
            if (_percent != null) _percent.text = Mathf.RoundToInt(_shown * 100f) + "%";
            if (_parts.spinner != null && _parts.spinner.gameObject.activeSelf) _parts.spinner.Rotate(0, 0, -320f * dt);
            if (_parts.logoFrame != null)
                _parts.logoFrame.localScale = Vector3.one * (_animateLogo ? 1f + Mathf.Sin(Time.unscaledTime * 1.6f) * 0.015f : 1f);
            AnimateSteps();
            if (_vrNow && _retryWithTrigger && _parts.retry != null && _parts.retry.gameObject.activeInHierarchy
                && TriggerPressed()) Retry();
        }

        private void ApplyTheme()
        {
            foreach (var t in _tints)
            {
                if (t.Graphic == null) continue;
                Color baseColour;
                switch (t.Role)
                {
                    case Role.Accent: baseColour = _accent; break;
                    case Role.Text: baseColour = _text; break;
                    case Role.Panel: baseColour = _panel; break;
                    case Role.Background: baseColour = _background; break;
                    default: baseColour = Color.Lerp(_panel, Color.white, 0.08f); break;
                }
                float alpha = t.Role == Role.Panel || t.Role == Role.Background ? baseColour.a * t.Alpha : t.Alpha;
                t.Graphic.color = new Color(baseColour.r, baseColour.g, baseColour.b, alpha);
            }
            if (_parts.retryLabel != null) _parts.retryLabel.color = OnAccent();
            if (_painted && _camera != null) _camera.backgroundColor = _background;
            PaintSteps();
            _appliedAccent = _accent;
            _appliedText = _text;
            _appliedPanel = _panel;
            _appliedBackground = _background;
        }

        private void Adopt()
        {
            var p = _parts;
            _canvas = p.canvas;
            _group = p.group;
            _content = p.content;
            _groups.Clear();
            if (_group != null) _groups.Add(_group);
            if (p.floor != null)
            {
                var fg = p.floor.GetComponent<CanvasGroup>();
                if (fg != null) _groups.Add(fg);
            }
            _percent = Words.Wrap(p.percent);
            _stepCount = Words.Wrap(p.stepCount);
            _status = Words.Wrap(p.status);
            _tip = Words.Wrap(p.tip);
            _error = Words.Wrap(p.error);
            int n = p.stepTexts != null ? p.stepTexts.Length : 0;
            _stepTexts = new Words[n];
            for (int i = 0; i < n; i++) _stepTexts[i] = Words.Wrap(p.stepTexts[i]);
            if (_stepState == null || _stepState.Length != n)
            {
                _stepState = new int[n];
                _popAt = new float[n];
                for (int i = 0; i < n; i++) _popAt[i] = -10f;
            }
            _tints.Clear();
            int count = p.tintGraphics != null ? p.tintGraphics.Length : 0;
            for (int i = 0; i < count; i++)
                _tints.Add(new Tint { Graphic = p.tintGraphics[i], Role = (Role)p.tintRoles[i], Alpha = p.tintAlphas[i] });
            if (Application.isPlaying && p.retry != null)
            {
                p.retry.onClick.RemoveListener(Retry);
                p.retry.onClick.AddListener(Retry);
                var touch = p.retry.GetComponent<EzAdminTouchButton>();
                if (touch != null)
                {
                    touch.OnPressed.RemoveListener(Retry);
                    touch.OnPressed.AddListener(Retry);
                }
            }
        }

        private void ApplyMode(bool vr)
        {
            _vrNow = vr;
            var p = _parts;
            if (p.canvas == null) return;
            var rt = (RectTransform)p.canvas.transform;
            var scaler = p.canvas.GetComponent<CanvasScaler>();
            if (vr)
            {
                p.canvas.renderMode = RenderMode.WorldSpace;
                p.canvas.worldCamera = _camera;
                rt.sizeDelta = new Vector2(CardW + 240f, CardH + 240f);
                float scale = _vrWidth / CardW;
                rt.localScale = new Vector3(scale, scale, scale);
                if (scaler != null)
                {
                    scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize;
                    scaler.dynamicPixelsPerUnit = 4f;
                    scaler.referencePixelsPerUnit = 100f;
                }
            }
            else
            {
                p.canvas.renderMode = RenderMode.ScreenSpaceOverlay;
                rt.localScale = Vector3.one;
                if (scaler != null)
                {
                    scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
                    scaler.referenceResolution = new Vector2(1920, 1080);
                    scaler.matchWidthOrHeight = 0.5f;
                }
            }
            if (p.backdrop != null) p.backdrop.SetActive(!vr);
            if (p.floor != null) p.floor.SetActive(vr && _vrSpace && Application.isPlaying);
            if (vr && Application.isPlaying) LateUpdate();
        }

        private void Refresh()
        {
            var p = _parts;
            if (p.canvas == null) return;
            SetWords(p.title, string.IsNullOrEmpty(_gameName) ? Application.productName : _gameName);
            SetWords(p.subtitle, _subtitle);
            if (p.subtitle != null) p.subtitle.gameObject.SetActive(!string.IsNullOrEmpty(_subtitle));
            if (p.logo != null)
            {
                if (_gameLogo != null) p.logo.sprite = _gameLogo;
                else if (p.logo.sprite == null && Application.isPlaying) p.logo.sprite = EzAdminBrand.LogoSprite(256);
            }
            if (p.backdropImage != null) p.backdropImage.sprite = _backgroundImage;
            if (p.backdropArt != null) p.backdropArt.SetActive(_backgroundImage != null);
            if (p.cardImage != null) p.cardImage.sprite = _backgroundImage;
            if (p.cardArt != null) p.cardArt.SetActive(_backgroundImage != null && _vrNow);
            string[] words = { _stepConnect, _stepSignIn, _stepItems, _stepWorld };
            for (int i = 0; p.stepTexts != null && i < p.stepTexts.Length && i < words.Length; i++) SetWords(p.stepTexts[i], words[i]);
            SetWords(p.retryLabel, _retryLabel);
            SetWords(p.retryHint, _retryHint);
            SetWords(p.tipLabel, _tipLabel);
            if (p.tipRow != null) p.tipRow.SetActive(_tips != null && _tips.Length > 0);
            if (p.progress != null) p.progress.SetActive(_showProgress);
            if (p.percent != null) p.percent.gameObject.SetActive(_showPercent);
            if (p.steps != null) p.steps.SetActive(_showSteps);
            if (p.spinner != null) p.spinner.gameObject.SetActive(_showSpinner);
            if (p.footer != null) p.footer.SetActive(_showSecuredBy);
            if (!Application.isPlaying) SetWords(p.status, _stepConnect);
            ApplyTheme();
        }

        private static void SetWords(Graphic g, string value)
        {
            var w = Words.Wrap(g);
            if (w != null) w.text = value;
        }

        private T Tinted<T>(T graphic, Role role, float alpha) where T : Graphic
        {
            if (graphic == null) return null;
            _tints.Add(new Tint { Graphic = graphic, Role = role, Alpha = alpha });
            return graphic;
        }

        private GameObject Tinted(GameObject go, Role role, float alpha)
        {
            Tinted(go.GetComponent<Graphic>(), role, alpha);
            return go;
        }

        private void LateUpdate()
        {
            if (!_vrNow || _canvas == null || _canvas.renderMode != RenderMode.WorldSpace) return;
            if (_camera == null) _camera = Camera.main;
            if (_camera == null) return;
            Transform head = _camera.transform;
            Vector3 flat = Vector3.ProjectOnPlane(head.forward, Vector3.up);
            if (flat.sqrMagnitude < 0.0001f) flat = Vector3.ProjectOnPlane(head.up, Vector3.up);
            if (flat.sqrMagnitude < 0.0001f) flat = Vector3.forward;
            flat.Normalize();
            Vector3 wanted = head.position + flat * _vrDistance + Vector3.down * 0.1f;
            Transform t = _canvas.transform;

            if (!_placed)
            {
                t.position = wanted;
                t.rotation = Quaternion.LookRotation(flat, Vector3.up);
                _placed = true;
            }
            else if (_followHead)
            {
                Vector3 toCanvas = Vector3.ProjectOnPlane(t.position - head.position, Vector3.up);
                float angle = Vector3.Angle(toCanvas, flat);
                float distanceOff = Mathf.Abs(toCanvas.magnitude - _vrDistance);
                if (angle > _followAngle || distanceOff > 0.6f) _recentring = true;
                if (_recentring)
                {
                    float k = 1f - Mathf.Exp(-4.5f * Time.unscaledDeltaTime);
                    t.position = Vector3.Lerp(t.position, wanted, k);
                    Vector3 face = Vector3.ProjectOnPlane(t.position - head.position, Vector3.up);
                    if (face.sqrMagnitude > 0.0001f)
                        t.rotation = Quaternion.Slerp(t.rotation, Quaternion.LookRotation(face, Vector3.up), k);
                    if (angle < 2.5f && distanceOff < 0.05f) _recentring = false;
                }
            }

            if (_parts.floor != null && _parts.floor.activeSelf)
            {
                Vector3 f = head.position;
                f.y = FloorHeight(head);
                _parts.floor.transform.position = f;
            }
        }

        private static float FloorHeight(Transform head)
        {
            if (head.parent != null && head.localPosition.y > 0.5f) return head.parent.position.y;
            return head.position.y - 1.6f;
        }

        private void Restore()
        {
            if (!_painted || _camera == null) return;
            _camera.clearFlags = _oldFlags;
            _camera.backgroundColor = _oldColour;
            _painted = false;
        }

        private void SetStatus(string text)
        {
            if (_status != null) _status.text = text;
        }

        private void Step(int index, int state)
        {
            string[] words = { _stepConnect, _stepSignIn, _stepItems, _stepWorld };
            if (state == 1 && index >= 0 && index < words.Length) SetStatus(words[index]);
            if (_stepState == null || index < 0 || index >= _stepState.Length) return;
            if (state == 2 && _stepState[index] != 2) _popAt[index] = Time.unscaledTime;
            _stepState[index] = state;
            int done = 0;
            for (int i = 0; i < _stepState.Length; i++) if (_stepState[i] == 2 || _stepState[i] == 3) done++;
            int current = Mathf.Min(_stepState.Length, done + 1);
            if (_stepCount != null) _stepCount.text = "Step " + current + " of " + _stepState.Length;
            PaintSteps();
        }

        private void PaintSteps()
        {
            var p = _parts;
            if (p.nodes == null || _stepState == null || _stepTexts == null) return;
            var red = new Color32(0xF8, 0x71, 0x71, 0xFF);
            for (int i = 0; i < p.nodes.Length && i < _stepState.Length; i++)
            {
                int s = _stepState[i];
                bool done = s == 2;
                bool active = s == 1;
                bool failed = s == 4;
                if (p.nodes[i] == null) continue;
                if (Application.isPlaying) p.nodes[i].sprite = done || failed ? Sprite("disc") : Sprite("hoop");
                p.nodes[i].color = failed ? (Color)red : done || active ? _accent : Dim(0.22f);
                p.marks[i].gameObject.SetActive(done || active || failed);
                if (Application.isPlaying) p.marks[i].sprite = done ? Sprite("check") : failed ? Sprite("cross") : Sprite("disc");
                p.marks[i].color = done || failed ? OnAccent() : _accent;
                p.marks[i].rectTransform.sizeDelta = done || failed ? new Vector2(24, 24) : new Vector2(13, 13);
                if (_stepTexts[i] != null)
                {
                    _stepTexts[i].color = active ? _text : done ? Dim(0.78f) : Dim(0.4f);
                    _stepTexts[i].bold = active;
                }
                if (p.links != null && i < p.links.Length && p.links[i] != null) p.links[i].color = done ? _accent : Dim(0.14f);
            }
        }

        private Color OnAccent()
        {
            float lum = _accent.r * 0.299f + _accent.g * 0.587f + _accent.b * 0.114f;
            return lum > 0.55f ? new Color(0.03f, 0.05f, 0.04f, 1f) : Color.white;
        }

        private void AnimateSteps()
        {
            var p = _parts;
            if (p.nodes == null || _stepState == null) return;
            float now = Time.unscaledTime;
            for (int i = 0; i < p.nodes.Length && i < _stepState.Length; i++)
            {
                if (p.nodes[i] == null) continue;
                float scale = 1f;
                float since = now - _popAt[i];
                if (since < 0.35f) scale = 1f + Mathf.Sin(since / 0.35f * Mathf.PI) * 0.28f;
                p.nodes[i].rectTransform.localScale = Vector3.one * scale;
                if (_stepState[i] == 1)
                {
                    var c = _accent;
                    c.a = 0.65f + Mathf.Sin(now * 5f) * 0.35f;
                    p.marks[i].color = c;
                }
            }
        }

        private void Play(AudioClip clip)
        {
            if (clip == null) return;
            var at = _camera != null ? _camera.transform.position : Vector3.zero;
            AudioSource.PlayClipAtPoint(clip, at);
        }

        private bool TriggerPressed()
        {
            bool down = false;
            var devices = new List<InputDevice>();
            InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller, devices);
            foreach (var d in devices)
            {
                bool b;
                if ((d.TryGetFeatureValue(CommonUsages.triggerButton, out b) && b)
                    || (d.TryGetFeatureValue(CommonUsages.primaryButton, out b) && b)) down = true;
            }
            bool pressed = down && !_pressedLast;
            _pressedLast = down;
            return pressed;
        }

        private void Buzz(float amplitude, float seconds)
        {
            if (!_haptics || !_vrNow) return;
            var devices = new List<InputDevice>();
            InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller, devices);
            foreach (var d in devices)
            {
                HapticCapabilities caps;
                if (d.TryGetHapticCapabilities(out caps) && caps.supportsImpulse) d.SendHapticImpulse(0, amplitude, seconds);
            }
        }

        private void EnsureEventSystem()
        {
            if (_vrNow || FindObjectOfType<EventSystem>() != null) return;
            var go = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER
            var module = Type.GetType("UnityEngine.InputSystem.UI.InputSystemUIInputModule, Unity.InputSystem");
            if (module != null) go.AddComponent(module);
#else
            go.AddComponent<StandaloneInputModule>();
#endif
        }

        public void RemoveDesign()
        {
            if (_parts == null) return;
            if (_parts.canvas != null) DestroyImmediate(_parts.canvas.gameObject);
            if (_parts.floor != null) DestroyImmediate(_parts.floor);
            _parts = new Parts();
        }

        public void BuildDesign()
        {
            RemoveDesign();
            if (SpriteStore != null) Sprites.Clear();
            _tints.Clear();
            _usedFont = _font != null ? _font : LoadFont();
            var p = _parts;

            var root = NewCanvas("Loading Screen", 30000);
            p.canvas = root.GetComponent<Canvas>();
            p.group = root.GetComponent<CanvasGroup>();
            var rt = (RectTransform)root.transform;
            rt.sizeDelta = new Vector2(CardW + 240f, CardH + 240f);

            var backdrop = new GameObject("Backdrop", typeof(RectTransform));
            backdrop.transform.SetParent(root.transform, false);
            Stretch(backdrop, 0f);
            p.backdrop = backdrop;
            Stretch(Tinted(Img(backdrop.transform, "Base", null, Color.white), Role.Background, 1f), 0f);
            Stretch(Tinted(Img(backdrop.transform, "Top light", "vfade", Color.white), Role.Lift, 0.35f), 0f);
            var art = Img(backdrop.transform, "Picture", null, Color.white);
            Stretch(art, 0f);
            p.backdropImage = art.GetComponent<Image>();
            p.backdropArt = art;
            Stretch(Tinted(Img(art.transform, "Shade", null, Color.white), Role.Background, 0.74f), 0f);
            var glow = Tinted(Img(backdrop.transform, "Glow", "glow", Color.white), Role.Accent, 0.12f);
            Place(glow, new Vector2(0.5f, 0.5f), new Vector2(0, 60), new Vector2(2000, 1450));
            Stretch(Img(backdrop.transform, "Vignette", "vignette", new Color(0, 0, 0, 0.72f)), 0f);

            var shadow = Img(root.transform, "Shadow", "softbox", new Color(0, 0, 0, 0.6f));
            Sliced(shadow, 64f, 64f);
            Place(shadow, new Vector2(0.5f, 0.5f), new Vector2(0, -20), new Vector2(CardW + 130, CardH + 130));

            var edge = Tinted(Img(root.transform, "Edge", "round", Color.white), Role.Accent, 0.28f);
            Sliced(edge, 32f, 40f);
            Place(edge, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(CardW + 4, CardH + 4));

            var card = Tinted(Img(root.transform, "Card", "round", Color.white), Role.Panel, 1f);
            Sliced(card, 32f, 38f);
            Place(card, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(CardW, CardH));
            var cardT = card.transform;
            card.AddComponent<Mask>().showMaskGraphic = true;

            var cardArt = Img(cardT, "Picture", null, Color.white);
            Stretch(cardArt, 0f);
            p.cardImage = cardArt.GetComponent<Image>();
            p.cardArt = cardArt;
            Stretch(Tinted(Img(cardArt.transform, "Shade", null, Color.white), Role.Panel, 0.82f), 0f);
            var wash = Tinted(Img(cardT, "Glow", "glow", Color.white), Role.Accent, 0.14f);
            Place(wash, new Vector2(0.5f, 1f), new Vector2(0, -40), new Vector2(1300, 620));
            var line = Tinted(Img(cardT, "Accent line", "hfade", Color.white), Role.Accent, 1f);
            Place(line, new Vector2(0.5f, 1f), new Vector2(0, -2f), new Vector2(CardW * 0.7f, 4));

            var inner = new GameObject("Content", typeof(RectTransform), typeof(CanvasGroup));
            inner.transform.SetParent(cardT, false);
            Stretch(inner, 0f);
            p.content = inner.GetComponent<CanvasGroup>();
            var c = inner.transform;

            var frame = Img(c, "Logo", "round", Color.white);
            Sliced(frame, 32f, 34f);
            frame.AddComponent<Mask>().showMaskGraphic = false;
            Place(frame, new Vector2(0.5f, 1f), new Vector2(0, -150), new Vector2(156, 156));
            var logo = Img(frame.transform, "Image", null, Color.white);
            p.logo = logo.GetComponent<Image>();
            p.logo.sprite = _gameLogo != null ? _gameLogo : Stored("logo", EzAdminBrand.LogoSprite(256));
            p.logo.preserveAspect = true;
            Stretch(logo, 0f);
            p.logoFrame = (RectTransform)frame.transform;

            var title = Label(c, "Name", "Game", 66, true, Role.Text, 1f, TextAnchor.MiddleCenter);
            Place(title.gameObject, new Vector2(0.5f, 1f), new Vector2(0, -284), new Vector2(CardW - 120, 84));
            Spacing(title, 1.5f);
            p.title = title.Graphic;
            var sub = Label(c, "Subtitle", "", 28, false, Role.Text, 0.55f, TextAnchor.MiddleCenter);
            Place(sub.gameObject, new Vector2(0.5f, 1f), new Vector2(0, -336), new Vector2(CardW - 120, 38));
            p.subtitle = sub.Graphic;

            var row = new GameObject("Status", typeof(RectTransform), typeof(HorizontalLayoutGroup), typeof(ContentSizeFitter));
            row.transform.SetParent(c, false);
            var layout = row.GetComponent<HorizontalLayoutGroup>();
            layout.spacing = 16;
            layout.childAlignment = TextAnchor.MiddleCenter;
            layout.childControlWidth = layout.childControlHeight = true;
            layout.childForceExpandWidth = layout.childForceExpandHeight = false;
            row.GetComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
            Place(row, new Vector2(0.5f, 1f), new Vector2(0, -408), new Vector2(400, 52));
            var spin = Tinted(Img(row.transform, "Spinner", "arc", Color.white), Role.Accent, 1f);
            var spinLayout = spin.AddComponent<LayoutElement>();
            spinLayout.preferredWidth = spinLayout.preferredHeight = 36;
            p.spinner = (RectTransform)spin.transform;
            var status = Label(row.transform, "Text", _stepConnect, 36, true, Role.Text, 1f, TextAnchor.MiddleLeft);
            NoWrap(status);
            p.status = status.Graphic;

            var progress = new GameObject("Progress", typeof(RectTransform));
            progress.transform.SetParent(c, false);
            Stretch(progress, 0f);
            p.progress = progress;
            var track = Tinted(Img(progress.transform, "Track", "round", Color.white), Role.Text, 0.08f);
            Sliced(track, 32f, 9f);
            Place(track, new Vector2(0.5f, 1f), new Vector2(0, -466), new Vector2(BarW, 18));
            var fill = Tinted(Img(track.transform, "Fill", "round", Color.white), Role.Accent, 1f);
            Sliced(fill, 32f, 9f);
            var frt = (RectTransform)fill.transform;
            frt.anchorMin = Vector2.zero;
            frt.anchorMax = new Vector2(0.35f, 1f);
            frt.offsetMin = frt.offsetMax = Vector2.zero;
            p.bar = fill.GetComponent<Image>();
            p.barFill = frt;
            Stretch(Img(fill.transform, "Shine", "vfade", new Color(1, 1, 1, 0.22f)), 0f);
            var count = Label(progress.transform, "Step", "Step 1 of 4", 24, false, Role.Text, 0.5f, TextAnchor.MiddleLeft);
            Place(count.gameObject, new Vector2(0.5f, 1f), new Vector2(-BarW / 2 + 160, -502), new Vector2(320, 32));
            p.stepCount = count.Graphic;
            var percent = Label(progress.transform, "Percent", "35%", 24, true, Role.Text, 0.75f, TextAnchor.MiddleRight);
            Place(percent.gameObject, new Vector2(0.5f, 1f), new Vector2(BarW / 2 - 110, -502), new Vector2(220, 32));
            p.percent = percent.Graphic;

            BuildSteps(c);

            var divider = Tinted(Img(c, "Divider", "hfade", Color.white), Role.Text, 0.1f);
            Place(divider, new Vector2(0.5f, 0f), new Vector2(0, 148), new Vector2(CardW - 160, 2));

            var tipRow = new GameObject("Tip", typeof(RectTransform));
            tipRow.transform.SetParent(c, false);
            Place(tipRow, new Vector2(0.5f, 0f), new Vector2(0, 74), new Vector2(CardW - 120, 90));
            p.tipRow = tipRow;
            var chip = Tinted(Img(tipRow.transform, "Label", "round", Color.white), Role.Accent, 0.16f);
            Sliced(chip, 32f, 15f);
            Place(chip, new Vector2(0.5f, 0.5f), new Vector2(0, 26), new Vector2(84, 32));
            var chipText = Label(chip.transform, "Text", _tipLabel, 19, true, Role.Accent, 1f, TextAnchor.MiddleCenter);
            Stretch(chipText.gameObject, 0f);
            Spacing(chipText, 3f);
            p.tipLabel = chipText.Graphic;
            var tip = Label(tipRow.transform, "Tip text", "Your loading tips show here.", 27, false, Role.Text, 0.72f,
                            TextAnchor.MiddleCenter);
            Place(tip.gameObject, new Vector2(0.5f, 0.5f), new Vector2(0, -18), new Vector2(CardW - 160, 60));
            p.tip = tip.Graphic;

            var error = Label(c, "Error", "", 28, false, Role.Text, 1f, TextAnchor.MiddleCenter);
            _tints.RemoveAt(_tints.Count - 1);
            error.color = new Color32(0xFF, 0x8F, 0x91, 0xFF);
            Place(error.gameObject, new Vector2(0.5f, 0f), new Vector2(0, 180), new Vector2(CardW - 160, 64));
            error.gameObject.SetActive(false);
            p.error = error.Graphic;

            var retry = Tinted(Img(c, "Try again", "round", Color.white), Role.Accent, 1f);
            Sliced(retry, 32f, 24f);
            retry.GetComponent<Image>().raycastTarget = true;
            Place(retry, new Vector2(0.5f, 0f), new Vector2(0, 92), new Vector2(300, 76));
            var button = retry.AddComponent<Button>();
            var colours = button.colors;
            colours.highlightedColor = new Color(1.12f, 1.12f, 1.12f, 1f);
            colours.pressedColor = new Color(0.85f, 0.85f, 0.85f, 1f);
            button.colors = colours;
            var box = retry.AddComponent<BoxCollider>();
            box.size = new Vector3(300, 76, 60);
            box.isTrigger = true;
            retry.AddComponent<EzAdminTouchButton>();
            var retryText = Label(retry.transform, "Text", _retryLabel, 30, true, Role.Text, 1f, TextAnchor.MiddleCenter);
            _tints.RemoveAt(_tints.Count - 1);
            Stretch(retryText.gameObject, 0f);
            p.retry = button;
            p.retryLabel = retryText.Graphic;
            retry.SetActive(false);
            var hint = Label(c, "Trigger hint", _retryHint, 22, false, Role.Text, 0.45f, TextAnchor.MiddleCenter);
            Place(hint.gameObject, new Vector2(0.5f, 0f), new Vector2(0, 34), new Vector2(520, 30));
            hint.gameObject.SetActive(false);
            p.retryHint = hint.Graphic;

            var foot = new GameObject("Secured by EzAdmin", typeof(RectTransform), typeof(HorizontalLayoutGroup),
                                      typeof(ContentSizeFitter));
            foot.transform.SetParent(root.transform, false);
            var fl = foot.GetComponent<HorizontalLayoutGroup>();
            fl.spacing = 10;
            fl.childAlignment = TextAnchor.MiddleCenter;
            fl.childControlWidth = fl.childControlHeight = true;
            fl.childForceExpandWidth = fl.childForceExpandHeight = false;
            foot.GetComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
            Place(foot, new Vector2(0.5f, 0.5f), new Vector2(0, -CardH / 2 - 56), new Vector2(260, 32));
            var mark = Img(foot.transform, "Mark", null, new Color(1, 1, 1, 0.8f));
            mark.GetComponent<Image>().sprite = Stored("mark", EzAdminBrand.LogoSprite(64));
            var ml = mark.AddComponent<LayoutElement>();
            ml.preferredWidth = ml.preferredHeight = 26;
            NoWrap(Label(foot.transform, "Text", "Secured by EzAdmin", 22, false, Role.Text, 0.45f, TextAnchor.MiddleLeft));
            p.footer = foot;

            BuildFloor();

            p.tintGraphics = new Graphic[_tints.Count];
            p.tintRoles = new int[_tints.Count];
            p.tintAlphas = new float[_tints.Count];
            for (int i = 0; i < _tints.Count; i++)
            {
                p.tintGraphics[i] = _tints[i].Graphic;
                p.tintRoles[i] = (int)_tints[i].Role;
                p.tintAlphas[i] = _tints[i].Alpha;
            }
            Adopt();
            ApplyMode(_mode == ScreenMode.VR);
            Refresh();
        }

        private void BuildSteps(Transform c)
        {
            var p = _parts;
            var holder = new GameObject("Steps", typeof(RectTransform));
            holder.transform.SetParent(c, false);
            Stretch(holder, 0f);
            p.steps = holder;
            var h = holder.transform;
            string[] steps = { _stepConnect, _stepSignIn, _stepItems, _stepWorld };
            int n = steps.Length;
            p.nodes = new Image[n];
            p.marks = new Image[n];
            p.stepTexts = new Graphic[n];
            p.links = new Image[n - 1];
            float gap = BarW / (n - 1);
            const float y = -572f;
            for (int i = 0; i < n - 1; i++)
            {
                var link = Img(h, "Line", null, Dim(0.14f));
                Place(link, new Vector2(0.5f, 1f), new Vector2(-BarW / 2 + gap * (i + 0.5f), y), new Vector2(gap - 56, 4));
                p.links[i] = link.GetComponent<Image>();
            }
            for (int i = 0; i < n; i++)
            {
                float x = -BarW / 2 + gap * i;
                var node = Img(h, "Step " + (i + 1), "hoop", Dim(0.22f));
                Place(node, new Vector2(0.5f, 1f), new Vector2(x, y), new Vector2(42, 42));
                p.nodes[i] = node.GetComponent<Image>();
                var mark = Img(node.transform, "Mark", "disc", _accent);
                Place(mark, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(13, 13));
                p.marks[i] = mark.GetComponent<Image>();
                mark.SetActive(false);
                var label = Label(h, "Step " + (i + 1) + " name", steps[i], 25, false, Role.Text, 0.4f, TextAnchor.UpperCenter);
                _tints.RemoveAt(_tints.Count - 1);
                Place(label.gameObject, new Vector2(0.5f, 1f), new Vector2(x, y - 54), new Vector2(gap + 30, 60));
                p.stepTexts[i] = label.Graphic;
            }
        }

        private void BuildFloor()
        {
            var floor = NewCanvas("Loading Screen Floor", 29990);
            var canvas = floor.GetComponent<Canvas>();
            canvas.renderMode = RenderMode.WorldSpace;
            var frt = (RectTransform)floor.transform;
            frt.sizeDelta = new Vector2(600, 600);
            frt.localScale = Vector3.one * 0.01f;
            frt.rotation = Quaternion.Euler(90f, 0f, 0f);
            var pool = Tinted(Img(floor.transform, "Glow", "glow", Color.white), Role.Accent, 0.16f);
            Stretch(pool, 0f);
            var ring = Tinted(Img(floor.transform, "Ring", "hoopthin", Color.white), Role.Accent, 0.35f);
            Place(ring, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(120, 120));
            floor.SetActive(false);
            _parts.floor = floor;
        }

        private GameObject NewCanvas(string name, int order)
        {
            var root = new GameObject(name, typeof(RectTransform), typeof(Canvas), typeof(CanvasGroup), typeof(CanvasScaler),
                                      typeof(GraphicRaycaster));
            root.GetComponent<Canvas>().sortingOrder = order;
            return root;
        }

        private Color Dim(float alpha)
        {
            return new Color(_text.r, _text.g, _text.b, alpha);
        }

        private IEnumerator FadeAll(float to, float seconds)
        {
            var from = new float[_groups.Count];
            for (int i = 0; i < _groups.Count; i++) from[i] = _groups[i] != null ? _groups[i].alpha : to;
            float t = 0f;
            while (t < seconds)
            {
                t += Time.unscaledDeltaTime;
                float k = Mathf.SmoothStep(0f, 1f, t / seconds);
                for (int i = 0; i < _groups.Count; i++)
                    if (_groups[i] != null) _groups[i].alpha = Mathf.Lerp(from[i], to, k);
                if (_content != null && to > 0f)
                    ((RectTransform)_content.transform).anchoredPosition = new Vector2(0, Mathf.Lerp(-18f, 0f, k));
                yield return null;
            }
            foreach (var g in _groups)
                if (g != null) g.alpha = to;
        }

        private static IEnumerator Fade(CanvasGroup group, float from, float to, float seconds)
        {
            if (group == null) yield break;
            float t = 0f;
            while (t < seconds)
            {
                t += Time.unscaledDeltaTime;
                if (group == null) yield break;
                group.alpha = Mathf.Lerp(from, to, Mathf.SmoothStep(0f, 1f, t / seconds));
                yield return null;
            }
            if (group != null) group.alpha = to;
        }

        private GameObject Img(Transform parent, string name, string sprite, Color colour)
        {
            var go = new GameObject(name, typeof(RectTransform), typeof(Image));
            go.transform.SetParent(parent, false);
            var image = go.GetComponent<Image>();
            if (sprite != null) image.sprite = Sprite(sprite);
            image.color = colour;
            image.raycastTarget = false;
            return go;
        }

        private static void Sliced(GameObject go, float textureRadius, float radius)
        {
            var image = go.GetComponent<Image>();
            image.type = Image.Type.Sliced;
            image.pixelsPerUnitMultiplier = textureRadius / Mathf.Max(1f, radius);
        }

        private static Type TmpType()
        {
            if (_tmpLooked) return _tmpType;
            _tmpLooked = true;
            _tmpType = Type.GetType("TMPro.TextMeshProUGUI, Unity.TextMeshPro");
            if (_tmpType == null) return null;
            var settings = Type.GetType("TMPro.TMP_Settings, Unity.TextMeshPro");
            var font = settings?.GetProperty("defaultFontAsset", BindingFlags.Public | BindingFlags.Static);
            try
            {
                if (font == null || font.GetValue(null) == null) _tmpType = null;
            }
            catch (Exception)
            {
                _tmpType = null;
            }
            return _tmpType;
        }

        private static void Set(object target, string property, object value)
        {
            try
            {
                var p = target.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
                if (p == null || !p.CanWrite) return;
                if (p.PropertyType.IsEnum && value is string) value = Enum.Parse(p.PropertyType, (string)value);
                p.SetValue(target, value);
            }
            catch (Exception)
            {
            }
        }

        private Words Label(Transform parent, string name, string value, int size, bool bold, Role role, float alpha,
                            TextAnchor align)
        {
            var tmp = TmpType();
            Graphic graphic;
            if (tmp != null)
            {
                var go = new GameObject(name, typeof(RectTransform));
                go.transform.SetParent(parent, false);
                graphic = (Graphic)go.AddComponent(tmp);
                if (_fontAsset != null && _fontAsset.GetType().Name == "TMP_FontAsset") Set(graphic, "font", _fontAsset);
                Set(graphic, "fontSize", (float)size);
                Set(graphic, "alignment", align == TextAnchor.MiddleLeft ? "Left" : align == TextAnchor.MiddleRight ? "Right"
                                          : align == TextAnchor.UpperCenter ? "Top" : "Center");
                Set(graphic, "enableWordWrapping", true);
                Set(graphic, "overflowMode", "Overflow");
            }
            else
            {
                var go = new GameObject(name, typeof(RectTransform), typeof(Text));
                go.transform.SetParent(parent, false);
                var text = go.GetComponent<Text>();
                text.font = _usedFont;
                text.fontSize = size;
                text.alignment = align;
                text.horizontalOverflow = HorizontalWrapMode.Wrap;
                text.verticalOverflow = VerticalWrapMode.Overflow;
                graphic = text;
            }
            graphic.raycastTarget = false;
            var words = Words.Wrap(graphic);
            words.text = value;
            words.bold = bold;
            Tinted(graphic, role, alpha);
            graphic.color = role == Role.Accent ? new Color(_accent.r, _accent.g, _accent.b, alpha) : Dim(alpha);
            return words;
        }

        private static void NoWrap(Words words)
        {
            var legacy = words.Graphic as Text;
            if (legacy != null) legacy.horizontalOverflow = HorizontalWrapMode.Overflow;
            else Set(words.Graphic, "enableWordWrapping", false);
        }

        private static void Spacing(Words words, float amount)
        {
            if (!(words.Graphic is Text)) Set(words.Graphic, "characterSpacing", amount);
        }

        private static void Place(GameObject go, Vector2 anchor, Vector2 position, Vector2 size)
        {
            var rt = (RectTransform)go.transform;
            rt.anchorMin = rt.anchorMax = anchor;
            rt.pivot = new Vector2(0.5f, 0.5f);
            rt.anchoredPosition = position;
            rt.sizeDelta = size;
        }

        private static void Stretch(GameObject go, float inset)
        {
            var rt = (RectTransform)go.transform;
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMin = new Vector2(inset, inset);
            rt.offsetMax = new Vector2(-inset, -inset);
        }

        private static Sprite Stored(string key, Sprite sprite)
        {
            return SpriteStore != null ? SpriteStore(key, sprite) : sprite;
        }

        private static Sprite Sprite(string key)
        {
            Sprite cached;
            if (Sprites.TryGetValue(key, out cached) && cached != null) return cached;
            Sprite made;
            switch (key)
            {
                case "round": made = Make(128, 32, (x, y) => RoundRect(x, y, 128, 32f)); break;
                case "softbox": made = Make(128, 64, (x, y) => SoftBox(x, y, 128)); break;
                case "disc": made = Make(64, 0, (x, y) => Disc(x, y, 64, 31f)); break;
                case "hoop": made = Make(64, 0, (x, y) => Ring(x, y, 64, 30f, 4.5f)); break;
                case "hoopthin": made = Make(128, 0, (x, y) => Ring(x, y, 128, 62f, 2f)); break;
                case "arc": made = Make(96, 0, (x, y) => Arc(x, y, 96)); break;
                case "check": made = Make(64, 0, (x, y) => Check(x, y, 64)); break;
                case "cross": made = Make(64, 0, (x, y) => Cross(x, y, 64)); break;
                case "glow": made = Make(128, 0, (x, y) => Glow(x, y, 128)); break;
                case "vignette": made = Make(128, 0, (x, y) => Vignette(x, y, 128)); break;
                case "hfade": made = Make(256, 0, (x, y) => Mathf.Sin(Mathf.Clamp01(x / 256f) * Mathf.PI)); break;
                case "vfade": made = Make(64, 0, (x, y) => Mathf.Clamp01(y / 64f)); break;
                default: made = Make(4, 0, (x, y) => 1f); break;
            }
            made.name = "EzAdmin " + key;
            made = Stored(key, made);
            Sprites[key] = made;
            return made;
        }

        private static Sprite Make(int size, int border, Func<float, float, float> alpha)
        {
            var texture = new Texture2D(size, size, TextureFormat.RGBA32, true)
            {
                hideFlags = HideFlags.HideAndDontSave,
                wrapMode = TextureWrapMode.Clamp,
                filterMode = FilterMode.Trilinear,
                anisoLevel = 4
            };
            var pixels = new Color32[size * size];
            for (int y = 0; y < size; y++)
                for (int x = 0; x < size; x++)
                    pixels[y * size + x] = new Color32(255, 255, 255, (byte)(Mathf.Clamp01(alpha(x + 0.5f, y + 0.5f)) * 255f));
            texture.SetPixels32(pixels);
            texture.Apply(true, SpriteStore == null);
            return UnityEngine.Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f, 0,
                                             SpriteMeshType.FullRect, new Vector4(border, border, border, border));
        }

        private static float RoundRect(float x, float y, int size, float r)
        {
            float cx = Mathf.Clamp(x, r, size - r), cy = Mathf.Clamp(y, r, size - r);
            float d = Mathf.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
            return Mathf.Clamp01(r - d + 0.5f);
        }

        private static float SoftBox(float x, float y, int size)
        {
            const float inset = 44f, r = 20f, blur = 38f;
            float cx = Mathf.Clamp(x, inset + r, size - inset - r), cy = Mathf.Clamp(y, inset + r, size - inset - r);
            float d = Mathf.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) - r;
            float t = Mathf.Clamp01(1f - (d + 6f) / blur);
            return t * t * (3f - 2f * t);
        }

        private static float Disc(float x, float y, int size, float r)
        {
            float d = Mathf.Sqrt((x - size / 2f) * (x - size / 2f) + (y - size / 2f) * (y - size / 2f));
            return Mathf.Clamp01(r - d + 0.5f);
        }

        private static float Ring(float x, float y, int size, float r, float width)
        {
            float d = Mathf.Sqrt((x - size / 2f) * (x - size / 2f) + (y - size / 2f) * (y - size / 2f));
            return Mathf.Clamp01(r - d + 0.5f) * Mathf.Clamp01(d - (r - width) + 0.5f);
        }

        private static float Arc(float x, float y, int size)
        {
            float dx = x - size / 2f, dy = y - size / 2f;
            float ring = Ring(x, y, size, size / 2f - 3f, 9f);
            float angle = (Mathf.Atan2(dy, dx) / (Mathf.PI * 2f) + 1f) % 1f;
            return ring * Mathf.SmoothStep(0.05f, 1f, angle);
        }

        private static float Segment(float x, float y, Vector2 a, Vector2 b, float width)
        {
            var p = new Vector2(x, y);
            var ab = b - a;
            float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / ab.sqrMagnitude);
            float d = Vector2.Distance(p, a + ab * t);
            return Mathf.Clamp01(width / 2f - d + 0.5f);
        }

        private static float Check(float x, float y, int size)
        {
            float s = size / 64f;
            return Mathf.Max(Segment(x, y, new Vector2(14, 33) * s, new Vector2(27, 20) * s, 8f * s),
                             Segment(x, y, new Vector2(27, 20) * s, new Vector2(50, 45) * s, 8f * s));
        }

        private static float Cross(float x, float y, int size)
        {
            float s = size / 64f;
            return Mathf.Max(Segment(x, y, new Vector2(18, 18) * s, new Vector2(46, 46) * s, 8f * s),
                             Segment(x, y, new Vector2(18, 46) * s, new Vector2(46, 18) * s, 8f * s));
        }

        private static float Glow(float x, float y, int size)
        {
            float d = Mathf.Sqrt((x - size / 2f) * (x - size / 2f) + (y - size / 2f) * (y - size / 2f)) / (size / 2f);
            float t = Mathf.Clamp01(1f - d);
            return t * t;
        }

        private static float Vignette(float x, float y, int size)
        {
            float dx = (x / size - 0.5f) * 2f, dy = (y / size - 0.5f) * 2f;
            float d = Mathf.Sqrt(dx * dx + dy * dy) / 1.414f;
            return Mathf.SmoothStep(0.45f, 1.05f, d);
        }

        private static Font LoadFont()
        {
            string[] parts = Application.unityVersion.Split('.');
            int major = 0, minor = 0;
            if (parts.Length > 1)
            {
                int.TryParse(parts[0], out major);
                int.TryParse(parts[1], out minor);
            }
            bool legacyRuntime = major > 2022 || (major == 2022 && minor >= 2);
            return Resources.GetBuiltinResource<Font>(legacyRuntime ? "LegacyRuntime.ttf" : "Arial.ttf");
        }
    }
}
EzAdminLoadingSceneMaker.csAssets/EzAdmin/Editor. Tools › EzAdmin › Create Loading Screen Scene makes the scene and puts it first in Build Settings.8.6 KB · 185 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Collections.Generic;
using System.IO;
using EzAdmin.Client;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

namespace EzAdmin.EditorTools
{
    public sealed class LoadingSceneOptions
    {
        public bool VR = true;
        public bool SignIn = true;
        public string GameName = "";
        public Sprite Logo;
        public Sprite Background;
        public Color Accent = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        public bool ReturnToGame = true;
        public string NextScene = "";
    }

    public static class EzAdminLoadingSceneMaker
    {
        public const string Folder = "Assets/EzAdmin Scenes";
        public const string ScenePath = Folder + "/EzAdmin Loading.unity";

        [MenuItem("Tools/EzAdmin/Create Loading Screen Scene", false, 20)]
        public static void CreateFromMenu()
        {
            string result = Create(new LoadingSceneOptions { GameName = PlayerSettings.productName }, true);
            if (!string.IsNullOrEmpty(result)) EditorUtility.DisplayDialog("EzAdmin", result, "OK");
        }

        public static bool Exists => File.Exists(ScenePath);

        public static string Create(LoadingSceneOptions options, bool ask)
        {
            if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return "";

            var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
            string firstGame = "";
            foreach (var s in scenes)
            {
                if (s.enabled && s.path != ScenePath)
                {
                    firstGame = s.path;
                    break;
                }
            }
            if (!string.IsNullOrEmpty(options.NextScene) && File.Exists(options.NextScene)) firstGame = options.NextScene;
            var open = SceneManager.GetActiveScene();
            if (string.IsNullOrEmpty(firstGame) && !string.IsNullOrEmpty(open.path) && open.path != ScenePath)
                firstGame = open.path;
            if (string.IsNullOrEmpty(firstGame))
                return "Save your game scene first, or add it to File > Build Settings, so the loading screen knows " +
                       "what to open after it.";
            string comeBack = !string.IsNullOrEmpty(open.path) && open.path != ScenePath ? open.path : firstGame;

            if (ask && Exists && !EditorUtility.DisplayDialog("EzAdmin",
                    "There is already an EzAdmin loading scene. Make a fresh one?", "Make a fresh one", "Cancel"))
                return "";

            if (!AssetDatabase.IsValidFolder(Folder)) AssetDatabase.CreateFolder("Assets", "EzAdmin Scenes");

            var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);

            var cameraObject = new GameObject("Main Camera", typeof(Camera), typeof(AudioListener));
            cameraObject.tag = "MainCamera";
            cameraObject.transform.position = new Vector3(0f, 1.6f, 0f);
            var camera = cameraObject.GetComponent<Camera>();
            camera.clearFlags = CameraClearFlags.SolidColor;
            camera.backgroundColor = new Color32(0x0B, 0x0C, 0x10, 0xFF);
            camera.nearClipPlane = 0.05f;
            if (options.VR) TryAddTrackedPose(cameraObject);

            var ez = new GameObject("EzAdmin", typeof(EzAdminClient));
            if (options.SignIn) ez.AddComponent<EzAdminAuth>();

            var loader = new GameObject("EzAdmin Loading Screen", typeof(EzAdminLoadingScreen));
            var so = new SerializedObject(loader.GetComponent<EzAdminLoadingScreen>());
            so.FindProperty("_nextScene").stringValue = Path.GetFileNameWithoutExtension(firstGame);
            so.FindProperty("_gameName").stringValue = string.IsNullOrEmpty(options.GameName)
                ? PlayerSettings.productName : options.GameName;
            so.FindProperty("_gameLogo").objectReferenceValue = options.Logo;
            so.FindProperty("_backgroundImage").objectReferenceValue = options.Background;
            so.FindProperty("_accent").colorValue = options.Accent;
            so.FindProperty("_mode").enumValueIndex = options.VR ? 0 : 2;
            so.ApplyModifiedPropertiesWithoutUndo();
            BuildDesign(loader.GetComponent<EzAdminLoadingScreen>());

            EditorSceneManager.SaveScene(scene, ScenePath);

            scenes.RemoveAll(s => s.path == ScenePath);
            if (!scenes.Exists(s => s.path == firstGame)) scenes.Add(new EditorBuildSettingsScene(firstGame, true));
            scenes.Insert(0, new EditorBuildSettingsScene(ScenePath, true));
            EditorBuildSettings.scenes = scenes.ToArray();

            if (options.ReturnToGame && !string.IsNullOrEmpty(comeBack)) EditorSceneManager.OpenScene(comeBack);
            else Selection.activeGameObject = loader;

            return "Made " + ScenePath + " and put it first in Build Settings. It signs the player in, loads their items, " +
                   "then opens " + Path.GetFileNameWithoutExtension(firstGame) + ".\n\nThe screen is built into that scene as " +
                   "normal objects under 'Loading Screen', so you can move or restyle any of it. The name, logo, colours " +
                   "and words are set on the EzAdmin Loading Screen object.";
        }

        public const string ArtFolder = Folder + "/Loading Screen Art";

        public static void BuildDesign(EzAdminLoadingScreen screen)
        {
            if (screen == null) return;
            if (!AssetDatabase.IsValidFolder(Folder)) AssetDatabase.CreateFolder("Assets", "EzAdmin Scenes");
            if (!AssetDatabase.IsValidFolder(ArtFolder)) AssetDatabase.CreateFolder(Folder, "Loading Screen Art");
            EzAdminLoadingScreen.SpriteStore = SaveSprite;
            try
            {
                Undo.RegisterFullObjectHierarchyUndo(screen.gameObject, "Build loading screen");
                screen.BuildDesign();
                EditorUtility.SetDirty(screen);
                EditorSceneManager.MarkSceneDirty(screen.gameObject.scene);
            }
            finally
            {
                EzAdminLoadingScreen.SpriteStore = null;
            }
        }

        private static Sprite SaveSprite(string key, Sprite made)
        {
            string path = ArtFolder + "/" + key + ".png";
            var existing = AssetDatabase.LoadAssetAtPath<Sprite>(path);
            if (existing != null) return existing;
            byte[] png;
            try
            {
                png = made.texture.EncodeToPNG();
            }
            catch (System.Exception)
            {
                return made;
            }
            File.WriteAllBytes(path, png);
            AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport);
            var importer = AssetImporter.GetAtPath(path) as TextureImporter;
            if (importer != null)
            {
                importer.textureType = TextureImporterType.Sprite;
                importer.spriteImportMode = SpriteImportMode.Single;
                importer.spritePixelsPerUnit = 100f;
                importer.spriteBorder = made.border;
                importer.mipmapEnabled = true;
                importer.alphaIsTransparency = true;
                importer.wrapMode = TextureWrapMode.Clamp;
                importer.filterMode = FilterMode.Trilinear;
                importer.textureCompression = TextureImporterCompression.Uncompressed;
                importer.SaveAndReimport();
            }
            var sprite = AssetDatabase.LoadAssetAtPath<Sprite>(path);
            return sprite != null ? sprite : made;
        }

        private static void TryAddTrackedPose(GameObject cameraObject)
        {
            var type = System.Type.GetType("UnityEngine.SpatialTracking.TrackedPoseDriver, UnityEngine.SpatialTracking")
                       ?? System.Type.GetType("UnityEngine.InputSystem.XR.TrackedPoseDriver, Unity.InputSystem");
            if (type != null) cameraObject.AddComponent(type);
        }

        [MenuItem("Tools/EzAdmin/Remove Loading Screen From Build", false, 21)]
        public static void Remove()
        {
            var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
            int removed = scenes.RemoveAll(s => s.path == ScenePath);
            EditorBuildSettings.scenes = scenes.ToArray();
            EditorUtility.DisplayDialog("EzAdmin", removed > 0
                ? "The loading screen is out of Build Settings. Your game starts on its own first scene again."
                : "The loading screen was not in Build Settings.", "OK");
        }
    }
}
EzAdminLoadingScreenInspector.csAssets/EzAdmin/Editor. The loading screen's Inspector, with a list of your scenes.7.5 KB · 175 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Collections.Generic;
using System.IO;
using EzAdmin.Client;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

namespace EzAdmin.EditorTools
{
    public static class EzAdminScenes
    {
        public sealed class Choice
        {
            public string Name;
            public string Path;
            public bool InBuild;
        }

        public static List<Choice> All(string skipPath)
        {
            var list = new List<Choice>();
            var seen = new HashSet<string>();
            foreach (var s in EditorBuildSettings.scenes)
            {
                if (string.IsNullOrEmpty(s.path) || s.path == skipPath || !File.Exists(s.path)) continue;
                list.Add(new Choice { Name = System.IO.Path.GetFileNameWithoutExtension(s.path), Path = s.path, InBuild = s.enabled });
                seen.Add(s.path);
            }
            foreach (string guid in AssetDatabase.FindAssets("t:Scene", new[] { "Assets" }))
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                if (seen.Contains(path) || path == skipPath || path.StartsWith("Assets/EzAdmin")) continue;
                list.Add(new Choice { Name = System.IO.Path.GetFileNameWithoutExtension(path), Path = path, InBuild = false });
            }
            return list;
        }

        public static string[] Labels(List<Choice> choices, string first)
        {
            var labels = new string[choices.Count + 1];
            labels[0] = first;
            for (int i = 0; i < choices.Count; i++)
                labels[i + 1] = choices[i].InBuild ? choices[i].Name : "Other scenes/" + choices[i].Name + "  (adds it to the build)";
            return labels;
        }

        public static void AddToBuild(string path)
        {
            var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
            int at = scenes.FindIndex(s => s.path == path);
            if (at >= 0)
            {
                if (!scenes[at].enabled) scenes[at] = new EditorBuildSettingsScene(path, true);
            }
            else
            {
                scenes.Add(new EditorBuildSettingsScene(path, true));
            }
            EditorBuildSettings.scenes = scenes.ToArray();
        }
    }

    [CustomEditor(typeof(EzAdminLoadingScreen))]
    public sealed class EzAdminLoadingScreenInspector : EzAdminInspector
    {
        protected override string Title => "Loading screen";
        protected override string Blurb => "Signs the player in, loads their items, then opens your game. Change the name, logo and colours below.";

        protected override string Status(out Color colour)
        {
            var scenes = EditorBuildSettings.scenes;
            string here = ((EzAdminLoadingScreen)target).gameObject.scene.path;
            if (scenes.Length == 0 || scenes[0].path != here)
            {
                colour = Amber;
                return "Not first in Build Settings";
            }
            colour = EzAdminBrand.Green;
            return Application.isPlaying ? "Showing" : "Plays first";
        }

        protected override bool OpenByDefault(string title)
        {
            return title == "Your game" || title == "Colours" || title == "Where it shows";
        }

        protected override bool ShowGroup(string title)
        {
            if (title != "VR") return true;
            var mode = serializedObject.FindProperty("_mode");
            return mode == null || mode.enumValueIndex != (int)EzAdminLoadingScreen.ScreenMode.Flat;
        }

        protected override bool DrawField(SerializedProperty prop)
        {
            if (prop.name == "_mode")
            {
                string[] names = { "Automatic (VR on a headset, full screen elsewhere)", "VR only", "Flat screen only" };
                prop.enumValueIndex = EditorGUILayout.Popup(new GUIContent("Show as", prop.tooltip), prop.enumValueIndex, names);
                return true;
            }
            return DrawScene(prop);
        }

        protected override void Extras()
        {
            var screen = (EzAdminLoadingScreen)target;
            if (!Application.isPlaying)
            {
                GUILayout.Space(2);
                if (!screen.HasDesign)
                {
                    EditorGUILayout.HelpBox("The screen is not in this scene yet. Build it once and every part shows up " +
                                            "under 'Loading Screen' in the Hierarchy, ready to move and restyle.", MessageType.Info);
                    if (GUILayout.Button("Build the loading screen into this scene", GUILayout.Height(26)))
                        EzAdminLoadingSceneMaker.BuildDesign(screen);
                }
                else
                {
                    GUILayout.BeginHorizontal();
                    if (GUILayout.Button("Select the screen objects", GUILayout.Height(22)))
                        Selection.activeGameObject = GameObject.Find("Loading Screen");
                    if (GUILayout.Button("Rebuild the screen", GUILayout.Height(22))
                        && EditorUtility.DisplayDialog("EzAdmin", "Build the screen again with the newest EzAdmin design? " +
                                                       "Changes you made to the screen objects by hand are replaced. Your " +
                                                       "settings below are kept.", "Rebuild", "Cancel"))
                        EzAdminLoadingSceneMaker.BuildDesign(screen);
                    GUILayout.EndHorizontal();
                }
            }
            string here = screen.gameObject.scene.path;
            var scenes = EditorBuildSettings.scenes;
            if (string.IsNullOrEmpty(here) || (scenes.Length > 0 && scenes[0].path == here)) return;
            GUILayout.Space(2);
            if (GUILayout.Button("Make this the first scene in Build Settings", GUILayout.Height(24)))
            {
                var list = new List<EditorBuildSettingsScene>(scenes);
                list.RemoveAll(s => s.path == here);
                list.Insert(0, new EditorBuildSettingsScene(here, true));
                EditorBuildSettings.scenes = list.ToArray();
            }
        }

        private bool DrawScene(SerializedProperty prop)
        {
            if (prop.name != "_nextScene") return false;
            string here = ((EzAdminLoadingScreen)target).gameObject.scene.path;
            var choices = EzAdminScenes.All(here);
            var labels = EzAdminScenes.Labels(choices, "The next scene in Build Settings");
            int current = 0;
            for (int i = 0; i < choices.Count; i++)
                if (choices[i].Name == prop.stringValue) current = i + 1;
            int picked = EditorGUILayout.Popup(new GUIContent("Open after loading", prop.tooltip), current, labels);
            if (picked != current)
            {
                if (picked == 0)
                {
                    prop.stringValue = "";
                }
                else
                {
                    var choice = choices[picked - 1];
                    prop.stringValue = choice.Name;
                    if (!choice.InBuild) EzAdminScenes.AddToBuild(choice.Path);
                }
            }
            if (!string.IsNullOrEmpty(prop.stringValue) && current == 0)
                EditorGUILayout.HelpBox("The scene '" + prop.stringValue + "' was not found. Pick one from the list.", MessageType.Warning);
            return true;
        }
    }
}
EzAdminLinkScreen.csAssets/EzAdmin/Runtime. The ready made link screen: a button on screen, or a keypad board in VR. Add it with GameObject › EzAdmin › Link Screen.33.6 KB · 784 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    [ExecuteAlways]
    public sealed class EzAdminLinkScreen : MonoBehaviour
    {
        public enum ScreenMode { Automatic, Flat, WorldSpaceVR }
        public enum Corner { BottomLeft, BottomRight, TopLeft, TopRight }

        [Serializable] public class StringEvent : UnityEvent<string> { }

        [EzAdminGroup("Where it shows")]
        [Tooltip("Automatic: a board in the world when a VR headset is active, a button on screen otherwise.")]
        [SerializeField] private ScreenMode _mode = ScreenMode.Automatic;
        [Tooltip("Off: build no UI. Call OpenLinkPage / OpenStaffPanel from your own buttons.")]
        [SerializeField] private bool _buildUI = true;
        [SerializeField] private Corner _corner = Corner.BottomLeft;
        [Tooltip("Hide the button once this account is linked.")]
        [SerializeField] private bool _hideWhenLinked = true;
        [Tooltip("Show a Staff Panel button to staff: players whose linked Discord account holds a moderation " +
                 "permission. Everyone else never sees it.")]
        [SerializeField] private bool _showStaffButton = true;

        [EzAdminGroup("VR board")]
        [Tooltip("Width of the board in metres.")]
        [SerializeField, Range(0.3f, 2f)] private float _boardWidthMetres = 0.7f;
        [Tooltip("Layers that count as a hand pressing the buttons.")]
        [SerializeField] private LayerMask _handLayers = ~0;

        [EzAdminGroup("Words")]
        [SerializeField] private string _title = "Link your Discord";
        [SerializeField] private string _subtitle = "Your Discord roles count in the game: perks, items and staff tools.";
        [SerializeField] private string _buttonLabel = "Link Discord";

        [EzAdminGroup("Look")]
        [SerializeField] private Color _accent = new Color32(0x58, 0x65, 0xF2, 0xFF);
        [SerializeField] private Color _good = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        [SerializeField] private Color _panel = new Color32(0x12, 0x15, 0x1C, 0xF5);
        [Tooltip("Leave empty for Unity's built-in font.")]
        [SerializeField] private Font _font;

        [EzAdminGroup("Announcements")]
        [Tooltip("Show /announce messages from Discord: a banner at the top of the screen, or a line on the VR board. " +
                 "Turn off if your game shows EzAdminClient.Announcement itself.")]
        [SerializeField] private bool _showAnnouncements = true;
        [Tooltip("How long the banner stays up.")]
        [SerializeField, Range(3f, 30f)] private float _announcementSeconds = 8f;

        [EzAdminGroup("Events - hook your own code here")]
        [SerializeField] private StringEvent _onLinked = new StringEvent();
        [SerializeField] private StringEvent _onStatusChanged = new StringEvent();
        [Tooltip("The website opened (link page or Staff Panel). Pause your game here if you like.")]
        [SerializeField] private StringEvent _onWebsiteOpened = new StringEvent();
        [Tooltip("Turned on when the player is linked, off when they are not.")]
        [SerializeField] private GameObject[] _enableWhenLinked = new GameObject[0];
        [Tooltip("Turned off when the player is linked, on when they are not.")]
        [SerializeField] private GameObject[] _disableWhenLinked = new GameObject[0];

        private int _linkedShown = -1;
        private float _nextLinkCheck;

        private void LateUpdate()
        {
            if (!Application.isPlaying) return;
            if (Time.unscaledTime < _nextLinkCheck) return;
            _nextLinkCheck = Time.unscaledTime + 0.5f;
            int now = EzAdminLink.IsLinked ? 1 : 0;
            if (now == _linkedShown) return;
            _linkedShown = now;
            foreach (var go in _enableWhenLinked)
                if (go != null && go != gameObject && go.activeSelf != (now == 1)) go.SetActive(now == 1);
            foreach (var go in _disableWhenLinked)
                if (go != null && go != gameObject && go.activeSelf != (now == 0)) go.SetActive(now == 0);
        }

        private const float WatchSeconds = 15 * 60;

        private GameObject _root;
        private Image _brandLogo;
        private Image _mainButton;
        private Text _mainLabel;
        private Text _status;
        private Image _dot;
        private GameObject _card;
        private GameObject _staffButton;
        private Coroutine _watching;
        private static Sprite _rounded;
        private const float SpriteRadius = 22f;

        public UnityEvent<string> OnLinked => _onLinked;
        public UnityEvent<string> OnStatusChanged => _onStatusChanged;
        public UnityEvent<string> OnWebsiteOpened => _onWebsiteOpened;
        public bool IsLinked => EzAdminLink.IsLinked;

        public bool IsWorldSpace
        {
            get
            {
                if (_mode == ScreenMode.WorldSpaceVR) return true;
                if (_mode == ScreenMode.Flat) return false;
                return UnityEngine.XR.XRSettings.isDeviceActive;
            }
        }

        internal static int ShowingAnnouncements;

        private void OnEnable()
        {
            if (!Application.isPlaying)
            {
#if UNITY_EDITOR
                UnityEditor.EditorApplication.delayCall += () => { if (this != null && !Application.isPlaying) BuildPreview(); };
#endif
                return;
            }
            if (_showAnnouncements) ShowingAnnouncements++;
            EzAdminLink.Linked += HandleLinked;
            EzAdminClient.ConnectionChanged += HandleConnection;
            EzAdminClient.Announcement += HandleAnnouncement;
            EzAdminClient.FreezeChanged += HandleFreeze;
            EzAdminClient.StaffChanged += HandleStaff;
            EzAdminClient.ServerBrandChanged += ApplyBrandLogo;
        }

        private void OnDisable()
        {
            if (!Application.isPlaying)
            {
                ClearPreview();
                return;
            }
            if (_showAnnouncements) ShowingAnnouncements = Mathf.Max(0, ShowingAnnouncements - 1);
            EzAdminLink.Linked -= HandleLinked;
            EzAdminClient.ConnectionChanged -= HandleConnection;
            EzAdminClient.Announcement -= HandleAnnouncement;
            EzAdminClient.FreezeChanged -= HandleFreeze;
            EzAdminClient.StaffChanged -= HandleStaff;
            EzAdminClient.ServerBrandChanged -= ApplyBrandLogo;
        }

        private void ApplyBrandLogo()
        {
            if (_brandLogo == null) return;
            var server = EzAdminClient.ServerIcon;
            _brandLogo.sprite = server != null ? server : EzAdminBrand.LogoSprite(96);
            _brandLogo.preserveAspect = true;
        }

        private void HandleFreeze(bool frozen)
        {
            HandleAnnouncement(frozen ? "A moderator has frozen you while they take a look. Please wait."
                                      : "You can move again.");
        }

        private GameObject _banner;
        private Text _bannerText;
        private Coroutine _bannerHide;

        private void HandleAnnouncement(string message)
        {
            if (!_showAnnouncements || this == null || string.IsNullOrEmpty(message)) return;
            if (IsWorldSpace)
            {

                SetStatus("Announcement: " + message);
                EzAdminAnnouncer.Show(message, _announcementSeconds);
                return;
            }
            if (_banner == null) BuildBanner();
            _bannerText.text = message;
            _banner.SetActive(true);
            if (_bannerHide != null) StopCoroutine(_bannerHide);
            _bannerHide = StartCoroutine(HideBannerSoon());
        }

        private IEnumerator HideBannerSoon()
        {
            yield return new WaitForSecondsRealtime(_announcementSeconds);
            if (_banner != null) _banner.SetActive(false);
        }

        private void BuildBanner()
        {
            if (_font == null) _font = LoadFont();
            _banner = new GameObject("EzAdmin Announcement", typeof(RectTransform));
            _banner.transform.SetParent(transform, false);
            var canvas = _banner.AddComponent<Canvas>();
            canvas.renderMode = RenderMode.ScreenSpaceOverlay;
            canvas.sortingOrder = 600;
            var scaler = _banner.AddComponent<CanvasScaler>();
            scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
            scaler.referenceResolution = new Vector2(1600, 900);
            scaler.matchWidthOrHeight = 0.5f;

            var card = MakeRounded(_banner.transform, _panel);
            card.name = "Card";
            var rt = (RectTransform)card.transform;
            rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 1f);
            rt.anchoredPosition = new Vector2(0, -24);
            rt.sizeDelta = new Vector2(720, 0);
            var column = card.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset(20, 20, 14, 16);
            column.spacing = 6;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = true;
            column.childForceExpandHeight = false;
            card.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
            var shadow = card.AddComponent<Shadow>();
            shadow.effectColor = new Color(0, 0, 0, 0.45f);
            shadow.effectDistance = new Vector2(0, -4);

            var head = new GameObject("Header", typeof(RectTransform));
            head.transform.SetParent(card.transform, false);
            var row = head.AddComponent<HorizontalLayoutGroup>();
            row.spacing = 8;
            row.childAlignment = TextAnchor.MiddleLeft;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = row.childForceExpandHeight = false;
            var logo = new GameObject("EzAdmin logo", typeof(RectTransform), typeof(Image));
            logo.transform.SetParent(head.transform, false);
            logo.GetComponent<Image>().sprite = EzAdminBrand.LogoSprite(64);
            logo.GetComponent<Image>().raycastTarget = false;
            var size = logo.AddComponent<LayoutElement>();
            size.preferredWidth = size.minWidth = 22;
            size.preferredHeight = size.minHeight = 22;
            MakeText(head.transform, "Announcement", 15, FontStyle.Bold, _good, TextAnchor.MiddleLeft);

            _bannerText = MakeText(card.transform, "", 20, FontStyle.Normal, Color.white, TextAnchor.UpperLeft);
            _banner.SetActive(false);
        }

        private void HandleConnection(string problem)
        {
            SetLight(problem == null ? _good : problem.EndsWith("…") ? new Color32(0xFB, 0xBF, 0x24, 0xFF)
                                                                      : new Color32(0xF8, 0x71, 0x71, 0xFF));
            if (problem == null)
            {
                if (_status != null && _status.gameObject.activeSelf && !EzAdminLink.IsLinked) SetStatus("");
                return;
            }
            if (!problem.EndsWith("…")) SetStatus(problem);
        }

        private void BuildPreview()
        {
            ClearPreview();
            if (!_buildUI || !isActiveAndEnabled) return;
            if (_font == null) _font = LoadFont();
            if (_mode == ScreenMode.Flat) BuildFlat(); else BuildBoard();
            if (_root == null) return;
            foreach (var t in _root.GetComponentsInChildren<Transform>(true))
                t.gameObject.hideFlags = HideFlags.DontSave | HideFlags.NotEditable | HideFlags.HideInHierarchy;
        }

        private void ClearPreview()
        {
            if (_root == null || Application.isPlaying) return;
            DestroyImmediate(_root);
            _root = null;
        }

#if UNITY_EDITOR
        private void OnValidate()
        {
            if (Application.isPlaying) return;
            UnityEditor.EditorApplication.delayCall += () => { if (this != null && !Application.isPlaying) BuildPreview(); };
        }
#endif

        private void Start()
        {
            if (!Application.isPlaying) return;
            for (int i = transform.childCount - 1; i >= 0; i--)
            {
                var child = transform.GetChild(i).gameObject;
                if ((child.hideFlags & HideFlags.DontSave) != 0) Destroy(child);
            }
            _root = null;
            if (_font == null) _font = LoadFont();
            if (_buildUI)
            {
                if (IsWorldSpace) BuildBoard(); else { EnsureEventSystem(); BuildFlat(); }
            }
            if (EzAdminLink.IsLinked && _hideWhenLinked && _buildUI)
            {
                _linkedAndHidden = true;
                ShowCardForStaff();
            }
            StartCoroutine(CheckWhenConnected());
        }

        public void OpenLinkPage()
        {
            if (EzAdminLink.IsLinked)
            {
                SetStatus("You're already linked" + (string.IsNullOrEmpty(EzAdminLink.DiscordName) ? "." : " as " + EzAdminLink.DiscordName + "."));
                return;
            }
            var client = EzAdminClient.Instance;
            if (client == null)
            {
                SetStatus("EzAdmin is not set up in this scene.");
                Debug.LogError("EzAdminLinkScreen: no EzAdminClient in the scene. GameObject › EzAdmin › Link Screen adds one.", this);
                return;
            }
            SetMain("Opening…", _accent);
            client.OpenLinkPage((opened, message) =>
            {
                if (this == null) return;
                if (!opened)
                {
                    SetMain(_buttonLabel, _accent);
                    SetStatus(message);
                    return;
                }
                _onWebsiteOpened.Invoke("link");
                SetMain("Waiting for Discord…", _accent);
                string code = client.LinkCode;
                SetStatus(string.IsNullOrEmpty(code)
                    ? "Finish in your browser, then come back. This updates by itself."
                    : "Finish in your browser, or type this in Discord:\n<size=30><b>/link " + code + "</b></size>\n" +
                      "This updates by itself.");
                if (_watching != null) StopCoroutine(_watching);
                _watching = StartCoroutine(WatchForLink());
            });
        }

        public void OpenStaffPanel()
        {
            var client = EzAdminClient.Instance;
            if (client == null) return;
            SetStatus("Opening the Staff Panel…");
            client.OpenStaffPanel((opened, message) =>
            {
                if (this == null) return;
                if (opened) _onWebsiteOpened.Invoke("panel");
                SetStatus(opened ? "Staff Panel opened in your browser." : message);
            });
        }

        private IEnumerator CheckWhenConnected()
        {
            while (EzAdminClient.Instance != null && !EzAdminClient.Instance.IsConnected)
            {
                yield return new WaitForSecondsRealtime(1f);
            }
            EzAdminLink.Check();
        }

        private IEnumerator WatchForLink()
        {
            float until = Time.realtimeSinceStartup + WatchSeconds;
            while (!EzAdminLink.IsLinked && Time.realtimeSinceStartup < until)
            {
                yield return new WaitForSecondsRealtime(3f);
                EzAdminLink.Check();
            }
            if (!EzAdminLink.IsLinked)
            {
                SetMain(_buttonLabel, _accent);
                SetStatus("That link ran out. Press " + _buttonLabel + " to try again.");
            }
            _watching = null;
        }

        private void OnApplicationFocus(bool focused)
        {

            if (focused && _watching != null) EzAdminLink.Check();
        }

        private void HandleLinked(string discordName)
        {
            SetMain(string.IsNullOrEmpty(discordName) ? "Linked" : "Linked as " + discordName, _good);
            SetLight(_good);
            SetStatus("Your Discord is linked. Roles you have there now count here.");
            _onLinked.Invoke(discordName);
            if (_hideWhenLinked && _buildUI && _root != null)
            {
                StartCoroutine(HideSoon());
            }
        }

        private bool _linkedAndHidden;

        private IEnumerator HideSoon()
        {
            yield return new WaitForSecondsRealtime(4f);
            _linkedAndHidden = true;
            ShowCardForStaff();
        }

        private void HandleStaff(bool staff)
        {
            if (this == null) return;
            if (_staffButton != null) _staffButton.SetActive(_showStaffButton && staff);
            if (_linkedAndHidden) ShowCardForStaff();
        }

        private void ShowCardForStaff()
        {
            bool staff = _showStaffButton && EzAdminClient.IsStaff;
            if (_card == null && _root == null) return;

            if (_mainButton != null) _mainButton.gameObject.SetActive(false);
            if (_status != null) _status.gameObject.SetActive(false);
            if (_card != null) _card.SetActive(staff);
            else if (_root != null) _root.SetActive(staff);
        }

        private void SetMain(string label, Color colour)
        {
            if (_mainLabel != null) _mainLabel.text = label;
            if (_mainButton != null) _mainButton.color = colour;
        }

        private void SetStatus(string message)
        {
            if (_status != null)
            {
                _status.gameObject.SetActive(!string.IsNullOrEmpty(message));
                _status.text = message;
            }
            _onStatusChanged.Invoke(message);
        }

        private void BuildFlat()
        {
            _root = new GameObject("EzAdmin Link Button", typeof(RectTransform));
            _root.transform.SetParent(transform, false);
            var canvas = _root.AddComponent<Canvas>();
            canvas.renderMode = RenderMode.ScreenSpaceOverlay;
            canvas.sortingOrder = 500;
            var scaler = _root.AddComponent<CanvasScaler>();
            scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
            scaler.referenceResolution = new Vector2(1600, 900);
            scaler.matchWidthOrHeight = 0.5f;
            _root.AddComponent<GraphicRaycaster>();

            Vector2 anchor = _corner == Corner.BottomLeft ? new Vector2(0, 0) : _corner == Corner.BottomRight ? new Vector2(1, 0)
                           : _corner == Corner.TopLeft ? new Vector2(0, 1) : new Vector2(1, 1);
            Vector2 inward = new Vector2(anchor.x == 0 ? 1 : -1, anchor.y == 0 ? 1 : -1);

            _card = MakeRounded(_root.transform, _panel, 20);
            _card.name = "EzAdmin card";
            var c = (RectTransform)_card.transform;
            c.anchorMin = c.anchorMax = c.pivot = anchor;
            c.anchoredPosition = new Vector2(24 * inward.x, 24 * inward.y);
            c.sizeDelta = new Vector2(380, 0);
            var column = _card.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset(18, 18, 16, 18);
            column.spacing = 12;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = true;
            column.childForceExpandHeight = false;
            var fit = _card.AddComponent<ContentSizeFitter>();
            fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
            var shadow = _card.AddComponent<Shadow>();
            shadow.effectColor = new Color(0, 0, 0, 0.45f);
            shadow.effectDistance = new Vector2(0, -4);

            var header = new GameObject("Header", typeof(RectTransform));
            header.transform.SetParent(_card.transform, false);
            var row = header.AddComponent<HorizontalLayoutGroup>();
            row.spacing = 12;
            row.childAlignment = TextAnchor.MiddleLeft;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = row.childForceExpandHeight = false;
            Sized(header, -1, 40);

            var logo = new GameObject("EzAdmin logo", typeof(RectTransform), typeof(Image));
            logo.transform.SetParent(header.transform, false);
            _brandLogo = logo.GetComponent<Image>();
            _brandLogo.sprite = EzAdminBrand.LogoSprite(96);
            _brandLogo.raycastTarget = false;
            Sized(logo, 40, 40);
            ApplyBrandLogo();

            var name = MakeText(header.transform, "EzAdmin", 22, FontStyle.Bold, Color.white, TextAnchor.MiddleLeft);
            name.horizontalOverflow = HorizontalWrapMode.Overflow;
            Sized(name.gameObject, -1, 40, flexible: true);

            var dot = new GameObject("Status light", typeof(RectTransform), typeof(Image));
            dot.transform.SetParent(header.transform, false);
            _dot = dot.GetComponent<Image>();
            Round(_dot, 6);
            _dot.color = new Color32(0xFB, 0xBF, 0x24, 0xFF);
            _dot.raycastTarget = false;
            Sized(dot, 12, 12);

            _status = MakeText(_card.transform, "", 15, FontStyle.Normal, new Color(1, 1, 1, 0.78f), TextAnchor.UpperLeft);
            _status.lineSpacing = 1.1f;
            _status.gameObject.SetActive(false);

            var main = MakeButton(_card.transform, _buttonLabel, _accent, 22, OpenLinkPage, false);
            Sized(main, -1, 58);
            _mainButton = main.GetComponent<Image>();
            _mainLabel = main.GetComponentInChildren<Text>();

            if (_showStaffButton)
            {
                _staffButton = MakeButton(_card.transform, "Staff Panel", new Color(1, 1, 1, 0.08f), 17, OpenStaffPanel, false);
                Sized(_staffButton, -1, 42);
                _staffButton.SetActive(EzAdminClient.IsStaff);
            }

            if (anchor.y == 0)
            {
                header.transform.SetAsFirstSibling();
            }
        }

        private static void Sized(GameObject go, float width, float height, bool flexible = false)
        {
            var element = go.GetComponent<LayoutElement>() ?? go.AddComponent<LayoutElement>();
            if (width >= 0) { element.preferredWidth = width; element.minWidth = width; }
            if (height >= 0) { element.preferredHeight = height; element.minHeight = height; }
            if (flexible) element.flexibleWidth = 1;

            var box = go.GetComponent<BoxCollider>();
            if (box != null && width >= 0 && height >= 0) box.size = new Vector3(width, height, 40f);
        }

        private void SetLight(Color colour)
        {
            if (_dot != null) _dot.color = colour;
        }

        private static void EnsureEventSystem()
        {
            if (EventSystem.current != null || FindObjectOfType<EventSystem>() != null) return;
            var go = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER
            var module = Type.GetType("UnityEngine.InputSystem.UI.InputSystemUIInputModule, Unity.InputSystem");
            if (module != null) go.AddComponent(module);
#else
            go.AddComponent<StandaloneInputModule>();
#endif
        }

        private void BuildBoard()
        {
            const float width = 700f, inset = 48f;
            _root = new GameObject("EzAdmin Link Board", typeof(RectTransform));
            _root.transform.SetParent(transform, false);
            var canvas = _root.AddComponent<Canvas>();
            canvas.renderMode = RenderMode.WorldSpace;
            canvas.worldCamera = Camera.main;
            _root.AddComponent<CanvasScaler>().dynamicPixelsPerUnit = 4f;
            _root.AddComponent<GraphicRaycaster>();
            var rt = (RectTransform)_root.transform;
            rt.sizeDelta = new Vector2(width, 420f);
            rt.localScale = Vector3.one * (_boardWidthMetres / width);
            var body = _root.AddComponent<Rigidbody>();
            body.isKinematic = true;
            body.useGravity = false;

            var panel = MakeRounded(_root.transform, _panel, 28);
            panel.name = "Board";
            var p = (RectTransform)panel.transform;
            p.anchorMin = p.anchorMax = p.pivot = new Vector2(0.5f, 0.5f);
            p.sizeDelta = new Vector2(width, 0);
            var column = panel.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset((int)inset, (int)inset, 46, 40);
            column.spacing = 22;
            column.childAlignment = TextAnchor.UpperCenter;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = column.childForceExpandHeight = false;
            panel.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;

            var stripe = MakeRounded(panel.transform, _accent, 3);
            stripe.name = "Accent";
            stripe.AddComponent<LayoutElement>().ignoreLayout = true;
            var s = (RectTransform)stripe.transform;
            s.anchorMin = new Vector2(0, 1);
            s.anchorMax = new Vector2(1, 1);
            s.pivot = new Vector2(0.5f, 1);
            s.offsetMin = new Vector2(inset, -24);
            s.offsetMax = new Vector2(-inset, -18);

            var header = new GameObject("Header", typeof(RectTransform));
            header.transform.SetParent(panel.transform, false);
            var row = header.AddComponent<HorizontalLayoutGroup>();
            row.spacing = 18;
            row.childAlignment = TextAnchor.MiddleCenter;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = row.childForceExpandHeight = false;
            Sized(header, -1, -1, flexible: true);

            var logo = new GameObject("EzAdmin logo", typeof(RectTransform), typeof(Image));
            logo.transform.SetParent(header.transform, false);
            _brandLogo = logo.GetComponent<Image>();
            _brandLogo.sprite = EzAdminBrand.LogoSprite(128);
            _brandLogo.raycastTarget = false;
            Sized(logo, 64, 64);
            ApplyBrandLogo();
            MakeText(header.transform, _title, 44, FontStyle.Bold, Color.white, TextAnchor.MiddleLeft);

            var sub = MakeText(panel.transform, _subtitle, 24, FontStyle.Normal, new Color(1, 1, 1, 0.7f), TextAnchor.UpperCenter);
            sub.lineSpacing = 1.1f;
            Sized(sub.gameObject, -1, -1, flexible: true);

            var main = MakeButton(panel.transform, _buttonLabel, _accent, 34, OpenLinkPage, true);
            Sized(main, 520, 96);
            _mainButton = main.GetComponent<Image>();
            _mainLabel = main.GetComponentInChildren<Text>();

            _status = MakeText(panel.transform, "", 22, FontStyle.Normal, new Color(1, 1, 1, 0.85f), TextAnchor.UpperCenter);
            _status.lineSpacing = 1.1f;
            Sized(_status.gameObject, -1, -1, flexible: true);
            _status.gameObject.SetActive(false);

            if (_showStaffButton)
            {
                _staffButton = MakeButton(panel.transform, "Staff Panel", new Color(1, 1, 1, 0.1f), 22, OpenStaffPanel, true);
                Sized(_staffButton, 240, 56);
                _staffButton.SetActive(EzAdminClient.IsStaff);
            }
        }

        private GameObject MakeButton(Transform parent, string label, Color colour, int fontSize, Action onPress, bool touch)
        {
            var go = MakeRounded(parent, colour);
            go.name = label;
            var button = go.AddComponent<Button>();
            var colours = button.colors;
            colours.normalColor = Color.white;
            colours.highlightedColor = new Color(0.9f, 0.9f, 0.95f, 1f);
            colours.pressedColor = new Color(0.75f, 0.75f, 0.8f, 1f);
            colours.fadeDuration = 0.08f;
            button.colors = colours;
            var text = MakeText(go.transform, label, fontSize, FontStyle.Bold, Color.white, TextAnchor.MiddleCenter);

            text.resizeTextForBestFit = true;
            text.resizeTextMinSize = Mathf.Max(10, fontSize / 2);
            text.resizeTextMaxSize = fontSize;
            text.verticalOverflow = VerticalWrapMode.Truncate;
            Fill(text.transform, 14);
            if (touch)
            {
                var box = go.AddComponent<BoxCollider>();
                box.isTrigger = true;
                var press = go.AddComponent<EzAdminTouchButton>();
                press.TouchLayers = _handLayers;
                press.OnPressed.AddListener(() => onPress());
                button.onClick.AddListener(press.Press);
            }
            else
            {
                button.onClick.AddListener(() => onPress());
            }
            return go;
        }

        private static void AddLogo(Transform button, float size, float inset)
        {
            var logo = new GameObject("EzAdmin logo", typeof(RectTransform), typeof(Image));
            logo.transform.SetParent(button, false);
            var image = logo.GetComponent<Image>();
            image.sprite = EzAdminBrand.LogoSprite(96);
            image.raycastTarget = false;
            var rt = (RectTransform)logo.transform;
            rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0, 0.5f);
            rt.anchoredPosition = new Vector2(inset, 0);
            rt.sizeDelta = new Vector2(size, size);
            var label = button.GetComponentInChildren<Text>();
            if (label != null)
            {
                var lt = (RectTransform)label.transform;
                lt.offsetMin = new Vector2(inset + size + 6, lt.offsetMin.y);
            }
        }

        private GameObject MakeRounded(Transform parent, Color colour, float radius = 18f)
        {
            var go = new GameObject("Shape", typeof(RectTransform), typeof(Image));
            go.transform.SetParent(parent, false);
            var image = go.GetComponent<Image>();
            Round(image, radius);
            image.color = colour;
            return go;
        }

        private static void Round(Image image, float radius)
        {
            image.sprite = RoundedSprite();
            image.type = Image.Type.Sliced;
            image.pixelsPerUnitMultiplier = SpriteRadius / Mathf.Max(1f, radius);
        }

        private Text MakeText(Transform parent, string value, int size, FontStyle style, Color colour, TextAnchor align)
        {
            var go = new GameObject("Text", typeof(RectTransform), typeof(Text));
            go.transform.SetParent(parent, false);
            var text = go.GetComponent<Text>();
            text.font = _font;
            text.text = value;
            text.fontSize = size;
            text.fontStyle = style;
            text.color = colour;
            text.alignment = align;
            text.horizontalOverflow = HorizontalWrapMode.Wrap;
            text.verticalOverflow = VerticalWrapMode.Overflow;
            text.raycastTarget = false;
            return text;
        }

        private static void Fill(Transform target, float padding)
        {
            var rt = (RectTransform)target;
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMin = new Vector2(padding, 0);
            rt.offsetMax = new Vector2(-padding, 0);
        }

        private static Sprite RoundedSprite()
        {
            if (_rounded != null) return _rounded;
            const int size = 64, radius = (int)SpriteRadius;
            var texture = new Texture2D(size, size, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.Clamp };
            var pixels = new Color32[size * size];
            for (int y = 0; y < size; y++)
            {
                for (int x = 0; x < size; x++)
                {
                    float cx = Mathf.Clamp(x + 0.5f, radius, size - radius);
                    float cy = Mathf.Clamp(y + 0.5f, radius, size - radius);
                    float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
                    byte a = (byte)(Mathf.Clamp01(radius - d + 0.5f) * 255);
                    pixels[y * size + x] = new Color32(255, 255, 255, a);
                }
            }
            texture.SetPixels32(pixels);
            texture.Apply();
            _rounded = Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f, 0,
                                     SpriteMeshType.FullRect, new Vector4(radius, radius, radius, radius));
            return _rounded;
        }

        private static Font LoadFont()
        {
            string[] parts = Application.unityVersion.Split('.');
            int major = 0, minor = 0;
            if (parts.Length > 1)
            {
                int.TryParse(parts[0], out major);
                int.TryParse(parts[1], out minor);
            }
            bool legacyRuntime = major > 2022 || (major == 2022 && minor >= 2);
            return Resources.GetBuiltinResource<Font>(legacyRuntime ? "LegacyRuntime.ttf" : "Arial.ttf");
        }

#if UNITY_EDITOR
        [UnityEditor.MenuItem("GameObject/EzAdmin/Link Screen", false, 10)]
        private static void CreateFromMenu()
        {
            if (FindObjectOfType<EzAdminClient>() == null)
            {
                var clientObject = new GameObject("EzAdmin", typeof(EzAdminClient));
                UnityEditor.Undo.RegisterCreatedObjectUndo(clientObject, "Add EzAdmin");
            }
            var go = new GameObject("EzAdmin Link Screen", typeof(EzAdminLinkScreen));
            if (UnityEditor.SceneView.lastActiveSceneView != null)
            {
                go.transform.position = UnityEditor.SceneView.lastActiveSceneView.pivot;
            }
            UnityEditor.Undo.RegisterCreatedObjectUndo(go, "Add EzAdmin Link Screen");
            UnityEditor.Selection.activeGameObject = go;
        }
#endif
    }
}
EzAdminAnnouncer.csAssets/EzAdmin/Runtime. Shows /announce messages on screen, or as a card in front of the player in VR, when your game shows nothing itself. Nothing to set up.37.9 KB · 873 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Networking;
using UnityEngine.UI;
using UnityEngine.XR;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif

namespace EzAdmin.Client
{
    [Serializable]
    public sealed class EzAdminAnnouncement
    {
        public string title = "Announcement";
        public string text = "";
        public string colour = "#2ECC71";
        public string placement = "banner";
        public string avatar = "";
        public bool sound;
        public float seconds = 8f;
    }

    public enum EzAnnouncePlacement { FromDashboard, Top, TopRight, Center, Bottom }
    public enum EzGamepadClose { East, North, West, South, Start, Select }
    public enum EzXrClose { SecondaryButton, PrimaryButton, Trigger, Grip, Menu }
    public enum EzInputKind { MouseKeyboard, Gamepad, Touch, XR }

    [Serializable]
    public sealed class EzAnnouncementEvent : UnityEvent<string, string> { }

    [DisallowMultipleComponent]
    [AddComponentMenu("EzAdmin/EzAdmin Announcements")]
    public sealed class EzAdminAnnouncer : MonoBehaviour
    {
        [Header("Closing")]
        [Tooltip("Let players close an announcement before its time is up.")]
        [SerializeField] private bool _playersCanClose = true;
        [Tooltip("Key that closes it on PC.")]
        [SerializeField] private KeyCode _closeKey = KeyCode.Escape;
        [Tooltip("Controller button that closes it. East is B on Xbox and Circle on PlayStation.")]
        [SerializeField] private EzGamepadClose _gamepadButton = EzGamepadClose.East;
        [Tooltip("VR controller button that closes it. Secondary is B or Y on Quest.")]
        [SerializeField] private EzXrClose _xrButton = EzXrClose.SecondaryButton;
        [Tooltip("In VR, pointing a controller at the panel and pulling the trigger also closes it.")]
        [SerializeField] private bool _pointAndTrigger = true;

        [Header("Look")]
        [Tooltip("Where it shows on a flat screen. From dashboard uses the choice made in Game Control.")]
        [SerializeField] private EzAnnouncePlacement _placement = EzAnnouncePlacement.FromDashboard;
        [Tooltip("Use the colour picked on the dashboard. Off uses the accent colour below.")]
        [SerializeField] private bool _useDashboardColour = true;
        [SerializeField] private Color _accent = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        [SerializeField] private Color _panel = new Color32(0x11, 0x13, 0x17, 0xF5);
        [SerializeField] private Color _textColour = new Color32(0xED, 0xEF, 0xF2, 0xFF);
        [Tooltip("Leave empty for Unity's built in font.")]
        [SerializeField] private Font _font;
        [SerializeField, Range(0f, 40f)] private float _cornerRadius = 18f;
        [SerializeField, Range(360f, 900f)] private float _width = 620f;
        [Tooltip("Show the picture set on the dashboard beside the heading.")]
        [SerializeField] private bool _showPicture = true;
        [Tooltip("Played when an announcement appears, if sound is switched on in Game Control. Leave empty for EzAdmin's soft two note chime.")]
        [SerializeField] private AudioClip _sound;
        [Tooltip("The longest an announcement stays up, even if the dashboard asks for longer.")]
        [SerializeField, Range(4f, 300f)] private float _longestOnScreen = 30f;

        [Header("VR")]
        [SerializeField, Range(0.8f, 3f)] private float _vrDistance = 1.6f;
        [Tooltip("Height compared with the player's eyes. A little below is easiest to read.")]
        [SerializeField, Range(-0.6f, 0.4f)] private float _vrHeight = -0.08f;
        [Tooltip("How far the player can turn away before the panel drifts back in front of them.")]
        [SerializeField, Range(10f, 90f)] private float _vrFollowAngle = 35f;
        [SerializeField, Range(0.0006f, 0.003f)] private float _vrScale = 0.0012f;

        [Header("Events")]
        public EzAnnouncementEvent onShown = new EzAnnouncementEvent();
        public UnityEvent onClosed = new UnityEvent();

        public static event Action<EzAdminAnnouncement> Shown;
        public static event Action Closed;
        public static bool Hold { get; set; }
        public static EzInputKind LastInput { get; private set; } = EzInputKind.MouseKeyboard;

        private static EzAdminAnnouncer _instance;

        private Canvas _canvas;
        private CanvasGroup _group;
        private RectTransform _card;
        private RectTransform _closeButton;
        private Image _bar;
        private Image _progress;
        private Image _avatar;
        private Text _title;
        private Text _body;
        private GameObject _hint;
        private Image _hintIcon;
        private Text _hintKey;
        private Text _hintWords;
        private GameObject _chip;
        private Text _chipText;
        private bool _vr;
        private bool _showing;
        private float _shownAt;
        private float _length;
        private EzAdminAnnouncement _current;
        private EzAdminAnnouncement _waiting;
        private readonly Dictionary<string, Sprite> _sprites = new Dictionary<string, Sprite>();
        private readonly Dictionary<string, bool> _xrWasDown = new Dictionary<string, bool>();

        private static AudioClip _chime;

        private static AudioClip Chime()
        {
            if (_chime != null) return _chime;
            const int rate = 44100;
            int length = (int)(rate * 0.7f);
            var data = new float[length];
            for (int i = 0; i < length; i++)
            {
                float t = i / (float)rate;
                float first = Mathf.Sin(2f * Mathf.PI * 880f * t) * Mathf.Exp(-t * 9f);
                float late = t - 0.12f;
                float second = late > 0f ? Mathf.Sin(2f * Mathf.PI * 1318.5f * late) * Mathf.Exp(-late * 6f) : 0f;
                float attack = Mathf.Clamp01(t / 0.004f) * Mathf.Clamp01((0.7f - t) / 0.05f);
                data[i] = (first * 0.45f + second * 0.4f) * attack * 0.5f;
            }
            _chime = AudioClip.Create("EzAdminChime", length, 1, rate, false);
            _chime.SetData(data, 0);
            return _chime;
        }

        public static void Show(string message, float seconds = 8f)
        {
            if (string.IsNullOrEmpty(message)) return;
            Show(new EzAdminAnnouncement { text = message, seconds = seconds });
        }

        public static void Show(EzAdminAnnouncement announcement)
        {
            if (announcement == null || string.IsNullOrEmpty(announcement.text)) return;
            Instance().Present(announcement);
        }

        public static void Close()
        {
            if (_instance != null) _instance.Dismiss();
        }

        private static EzAdminAnnouncer Instance()
        {
            if (_instance != null) return _instance;
            _instance = FindObjectOfType<EzAdminAnnouncer>();
            if (_instance != null) return _instance;
            var go = new GameObject("EzAdmin Announcements");
            DontDestroyOnLoad(go);
            _instance = go.AddComponent<EzAdminAnnouncer>();
            return _instance;
        }

        private void Awake()
        {
            if (_instance != null && _instance != this)
            {
                Destroy(this);
                return;
            }
            _instance = this;
            if (Application.isMobilePlatform) LastInput = EzInputKind.Touch;
            else if (Application.isConsolePlatform) LastInput = EzInputKind.Gamepad;
        }

        private void OnDestroy()
        {
            if (_instance == this) _instance = null;
        }

        private void Present(EzAdminAnnouncement a)
        {
            if (Hold)
            {
                _waiting = a;
                EnsureBuilt();
                ShowChip(true);
                return;
            }
            _waiting = null;
            EnsureBuilt();
            ShowChip(false);
            _card.gameObject.SetActive(true);
            _current = a;
            Color accent = _accent;
            if (_useDashboardColour && !string.IsNullOrEmpty(a.colour))
            {
                Color parsed;
                if (ColorUtility.TryParseHtmlString(a.colour, out parsed)) accent = parsed;
            }
            _bar.color = accent;
            _progress.color = new Color(accent.r, accent.g, accent.b, 0.85f);
            _title.color = accent;
            _title.text = string.IsNullOrEmpty(a.title) ? "Announcement" : a.title;
            _body.text = a.text;
            _avatar.transform.parent.gameObject.SetActive(false);
            if (_showPicture && !string.IsNullOrEmpty(a.avatar) && a.avatar.StartsWith("https://"))
                StartCoroutine(LoadPicture(a.avatar));
            _length = Mathf.Clamp(a.seconds <= 0f ? 8f : a.seconds, 4f, _longestOnScreen);
            _shownAt = Time.unscaledTime;
            _showing = true;
            _canvas.gameObject.SetActive(true);
            Place(true);
            RefreshHint();
            StopCoroutine("FadeIn");
            StartCoroutine("FadeIn");
            if (a.sound)
            {
                var cam = Camera.main;
                AudioSource.PlayClipAtPoint(_sound != null ? _sound : Chime(), cam != null ? cam.transform.position : Vector3.zero, 0.6f);
            }
            onShown.Invoke(_title.text, a.text);
            if (Shown != null) Shown(a);
        }

        private void Dismiss()
        {
            if (!_showing) return;
            _showing = false;
            StopCoroutine("FadeIn");
            StartCoroutine(FadeOut());
            onClosed.Invoke();
            if (Closed != null) Closed();
        }

        private IEnumerator FadeIn()
        {
            float t = 0f;
            Vector2 home = _card.anchoredPosition;
            Vector2 from = home + new Vector2(0f, _vr ? -18f : 14f);
            while (t < 1f)
            {
                t += Time.unscaledDeltaTime / 0.28f;
                float e = 1f - Mathf.Pow(1f - Mathf.Clamp01(t), 3f);
                _group.alpha = e;
                _card.anchoredPosition = Vector2.LerpUnclamped(from, home, e);
                yield return null;
            }
            _group.alpha = 1f;
            _card.anchoredPosition = home;
        }

        private IEnumerator FadeOut()
        {
            float t = 0f;
            while (t < 1f && _group != null)
            {
                t += Time.unscaledDeltaTime / 0.2f;
                _group.alpha = 1f - Mathf.Clamp01(t);
                yield return null;
            }
            if (!_showing && _canvas != null) _canvas.gameObject.SetActive(false);
        }

        private void Update()
        {
            TrackInput();
            if (_waiting != null && !Hold)
            {
                var next = _waiting;
                Present(next);
                return;
            }
            if (_chip != null && _chip.activeSelf && ClosePressed())
            {
                bool keep = Hold;
                Hold = false;
                if (_waiting != null) Present(_waiting);
                Hold = keep;
                return;
            }
            if (!_showing) return;
            float left = 1f - (Time.unscaledTime - _shownAt) / _length;
            _progress.fillAmount = Mathf.Clamp01(left);
            if (left <= 0f)
            {
                Dismiss();
                return;
            }
            if (_playersCanClose && (ClosePressed() || ClickedClose())) Dismiss();
            RefreshHint();
        }

        private void LateUpdate()
        {
            if (_vr && (_showing || (_chip != null && _chip.activeSelf))) Place(false);
        }

        private void TrackInput()
        {
            if (XRSettings.isDeviceActive)
            {
                LastInput = EzInputKind.XR;
                return;
            }
#if ENABLE_INPUT_SYSTEM
            var pad = Gamepad.current;
            if (pad != null && (pad.buttonSouth.wasPressedThisFrame || pad.buttonEast.wasPressedThisFrame
                                || pad.buttonNorth.wasPressedThisFrame || pad.buttonWest.wasPressedThisFrame
                                || pad.startButton.wasPressedThisFrame || pad.leftStick.ReadValue().sqrMagnitude > 0.25f))
                LastInput = EzInputKind.Gamepad;
            if (Touchscreen.current != null && Touchscreen.current.primaryTouch.press.wasPressedThisFrame)
                LastInput = EzInputKind.Touch;
            if ((Keyboard.current != null && Keyboard.current.anyKey.wasPressedThisFrame)
                || (Mouse.current != null && (Mouse.current.leftButton.wasPressedThisFrame
                                              || Mouse.current.delta.ReadValue().sqrMagnitude > 4f)))
                LastInput = EzInputKind.MouseKeyboard;
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
            if (Input.touchCount > 0)
            {
                LastInput = EzInputKind.Touch;
                return;
            }
            for (int i = 0; i < 20; i++)
            {
                if (Input.GetKeyDown(KeyCode.JoystickButton0 + i))
                {
                    LastInput = EzInputKind.Gamepad;
                    return;
                }
            }
            if (Input.anyKeyDown || Input.GetMouseButtonDown(0)
                || Mathf.Abs(Input.GetAxisRaw("Mouse X")) + Mathf.Abs(Input.GetAxisRaw("Mouse Y")) > 0.5f)
                LastInput = EzInputKind.MouseKeyboard;
#endif
        }

        private bool ClosePressed()
        {
            if (XRSettings.isDeviceActive) return XrPressed(XrUsage(_xrButton)) || (_pointAndTrigger && PointedAndTriggered());
#if ENABLE_INPUT_SYSTEM
            if (Keyboard.current != null)
            {
                Key key;
                if (Enum.TryParse(_closeKey == KeyCode.Return ? "Enter" : _closeKey.ToString(), out key)
                    && Keyboard.current[key].wasPressedThisFrame) return true;
            }
            var pad = Gamepad.current;
            if (pad != null && PadButton(pad).wasPressedThisFrame) return true;
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
            if (Input.GetKeyDown(_closeKey)) return true;
            if (Input.GetKeyDown(LegacyPadButton(_gamepadButton))) return true;
#endif
            return false;
        }

#if ENABLE_INPUT_SYSTEM
        private UnityEngine.InputSystem.Controls.ButtonControl PadButton(Gamepad pad)
        {
            switch (_gamepadButton)
            {
                case EzGamepadClose.North: return pad.buttonNorth;
                case EzGamepadClose.West: return pad.buttonWest;
                case EzGamepadClose.South: return pad.buttonSouth;
                case EzGamepadClose.Start: return pad.startButton;
                case EzGamepadClose.Select: return pad.selectButton;
                default: return pad.buttonEast;
            }
        }
#endif

        private static KeyCode LegacyPadButton(EzGamepadClose button)
        {
            switch (button)
            {
                case EzGamepadClose.South: return KeyCode.JoystickButton0;
                case EzGamepadClose.West: return KeyCode.JoystickButton2;
                case EzGamepadClose.North: return KeyCode.JoystickButton3;
                case EzGamepadClose.Select: return KeyCode.JoystickButton6;
                case EzGamepadClose.Start: return KeyCode.JoystickButton7;
                default: return KeyCode.JoystickButton1;
            }
        }

        private static InputFeatureUsage<bool> XrUsage(EzXrClose button)
        {
            switch (button)
            {
                case EzXrClose.PrimaryButton: return UnityEngine.XR.CommonUsages.primaryButton;
                case EzXrClose.Trigger: return UnityEngine.XR.CommonUsages.triggerButton;
                case EzXrClose.Grip: return UnityEngine.XR.CommonUsages.gripButton;
                case EzXrClose.Menu: return UnityEngine.XR.CommonUsages.menuButton;
                default: return UnityEngine.XR.CommonUsages.secondaryButton;
            }
        }

        private bool XrPressed(InputFeatureUsage<bool> usage)
        {
            bool pressed = false;
            var devices = new List<UnityEngine.XR.InputDevice>();
            InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller, devices);
            foreach (var d in devices)
            {
                bool down;
                if (!d.TryGetFeatureValue(usage, out down)) continue;
                string key = usage.name + d.name + d.characteristics;
                bool was;
                _xrWasDown.TryGetValue(key, out was);
                if (down && !was) pressed = true;
                _xrWasDown[key] = down;
            }
            return pressed;
        }

        private bool PointedAndTriggered()
        {
            if (_canvas == null || !_vr) return false;
            var cam = Camera.main;
            Transform space = cam != null && cam.transform.parent != null ? cam.transform.parent : null;
            var devices = new List<UnityEngine.XR.InputDevice>();
            InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller, devices);
            foreach (var d in devices)
            {
                bool down;
                if (!d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.triggerButton, out down)) continue;
                string key = "point" + d.name + d.characteristics;
                bool was;
                _xrWasDown.TryGetValue(key, out was);
                _xrWasDown[key] = down;
                if (!down || was) continue;
                Vector3 pos;
                Quaternion rot;
                if (!d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.devicePosition, out pos)) continue;
                if (!d.TryGetFeatureValue(UnityEngine.XR.CommonUsages.deviceRotation, out rot)) continue;
                Vector3 origin = space != null ? space.TransformPoint(pos) : pos;
                Vector3 dir = space != null ? space.TransformDirection(rot * Vector3.forward) : rot * Vector3.forward;
                var plane = new Plane(-_canvas.transform.forward, _canvas.transform.position);
                float hit;
                if (!plane.Raycast(new Ray(origin, dir), out hit)) continue;
                Vector3 local = _card.InverseTransformPoint(origin + dir * hit);
                if (_card.rect.Contains(new Vector2(local.x, local.y))) return true;
            }
            return false;
        }

        private bool ClickedClose()
        {
            if (_vr || _closeButton == null) return false;
            Vector2 at;
            bool clicked = false;
            at = Vector2.zero;
#if ENABLE_INPUT_SYSTEM
            if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
            {
                at = Mouse.current.position.ReadValue();
                clicked = true;
            }
            else if (Touchscreen.current != null && Touchscreen.current.primaryTouch.press.wasPressedThisFrame)
            {
                at = Touchscreen.current.primaryTouch.position.ReadValue();
                clicked = true;
            }
#endif
#if ENABLE_LEGACY_INPUT_MANAGER
            if (!clicked && Input.GetMouseButtonDown(0))
            {
                at = Input.mousePosition;
                clicked = true;
            }
            if (!clicked && Input.touchCount > 0 && Input.GetTouch(0).phase == UnityEngine.TouchPhase.Began)
            {
                at = Input.GetTouch(0).position;
                clicked = true;
            }
#endif
            if (!clicked) return false;
            var padded = _closeButton;
            return RectTransformUtility.RectangleContainsScreenPoint(padded, at, null)
                   || (LastInput == EzInputKind.Touch && RectTransformUtility.RectangleContainsScreenPoint(_card, at, null)
                       && Vector2.Distance(at, RectTransformUtility.WorldToScreenPoint(null, padded.position)) < 90f);
        }

        private void RefreshHint()
        {
            if (_hint == null) return;
            bool show = _playersCanClose && LastInput != EzInputKind.Touch;
            _hint.SetActive(show);
            _closeButton.gameObject.SetActive(_playersCanClose && !_vr);
            if (!show) return;
            string key, words = "Close";
            bool round;
            switch (LastInput)
            {
                case EzInputKind.Gamepad:
                    key = PadLabel(_gamepadButton);
                    round = _gamepadButton != EzGamepadClose.Start && _gamepadButton != EzGamepadClose.Select;
                    break;
                case EzInputKind.XR:
                    key = XrLabel(_xrButton);
                    round = _xrButton == EzXrClose.PrimaryButton || _xrButton == EzXrClose.SecondaryButton;
                    if (_pointAndTrigger && _xrButton != EzXrClose.Trigger) words = "Close, or point and pull the trigger";
                    break;
                default:
                    key = KeyLabel(_closeKey);
                    round = false;
                    break;
            }
            _hintKey.text = key;
            _hintWords.text = words;
            _hintIcon.sprite = Sprite(round ? "disc" : "round");
            var size = ((RectTransform)_hintIcon.transform).sizeDelta;
            float w = round ? 28f : Mathf.Max(34f, key.Length * 10f + 18f);
            ((RectTransform)_hintIcon.transform).sizeDelta = new Vector2(w, size.y);
            var le = _hintIcon.GetComponent<LayoutElement>();
            if (le != null) le.preferredWidth = w;
        }

        private static string PadLabel(EzGamepadClose b)
        {
            switch (b)
            {
                case EzGamepadClose.North: return "Y";
                case EzGamepadClose.West: return "X";
                case EzGamepadClose.South: return "A";
                case EzGamepadClose.Start: return "Start";
                case EzGamepadClose.Select: return "Select";
                default: return "B";
            }
        }

        private static string XrLabel(EzXrClose b)
        {
            switch (b)
            {
                case EzXrClose.PrimaryButton: return "A";
                case EzXrClose.Trigger: return "Trigger";
                case EzXrClose.Grip: return "Grip";
                case EzXrClose.Menu: return "Menu";
                default: return "B";
            }
        }

        private static string KeyLabel(KeyCode k)
        {
            switch (k)
            {
                case KeyCode.Escape: return "Esc";
                case KeyCode.Return: return "Enter";
                case KeyCode.Backspace: return "Backspace";
                case KeyCode.Space: return "Space";
                default: return k.ToString();
            }
        }

        private void ShowChip(bool on)
        {
            if (_chip == null) return;
            _chip.SetActive(on);
            if (!on) return;
            if (!_showing) _canvas.gameObject.SetActive(true);
            _group.alpha = 1f;
            _card.gameObject.SetActive(false);
            string key = LastInput == EzInputKind.XR ? XrLabel(_xrButton) : LastInput == EzInputKind.Gamepad
                ? PadLabel(_gamepadButton) : KeyLabel(_closeKey);
            _chipText.text = LastInput == EzInputKind.Touch ? "New announcement" : "New announcement  ·  " + key + " to read";
        }

        private IEnumerator LoadPicture(string url)
        {
            using (var request = UnityWebRequestTexture.GetTexture(url))
            {
                request.timeout = 8;
                yield return request.SendWebRequest();
                if (request.result != UnityWebRequest.Result.Success || _avatar == null) yield break;
                var texture = DownloadHandlerTexture.GetContent(request);
                if (texture == null) yield break;
                _avatar.sprite = UnityEngine.Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height),
                                                           new Vector2(0.5f, 0.5f));
                _avatar.transform.parent.gameObject.SetActive(true);
            }
        }

        private void Place(bool snap)
        {
            if (!_vr)
            {
                EzAnnouncePlacement where = _placement;
                if (where == EzAnnouncePlacement.FromDashboard && _current != null)
                    where = _current.placement == "toast" ? EzAnnouncePlacement.TopRight
                          : _current.placement == "centre" ? EzAnnouncePlacement.Center : EzAnnouncePlacement.Top;
                if (where == EzAnnouncePlacement.FromDashboard) where = EzAnnouncePlacement.Top;
                Vector2 anchor;
                Vector2 offset;
                switch (where)
                {
                    case EzAnnouncePlacement.TopRight: anchor = new Vector2(1f, 1f); offset = new Vector2(-28f, -28f); break;
                    case EzAnnouncePlacement.Center: anchor = new Vector2(0.5f, 0.5f); offset = Vector2.zero; break;
                    case EzAnnouncePlacement.Bottom: anchor = new Vector2(0.5f, 0f); offset = new Vector2(0f, 36f); break;
                    default: anchor = new Vector2(0.5f, 1f); offset = new Vector2(0f, -28f); break;
                }
                _card.anchorMin = _card.anchorMax = _card.pivot = anchor;
                if (snap) _card.anchoredPosition = offset;
                var chipRt = (RectTransform)_chip.transform;
                chipRt.anchorMin = chipRt.anchorMax = chipRt.pivot = new Vector2(0.5f, 1f);
                chipRt.anchoredPosition = new Vector2(0f, -20f);
                return;
            }
            var cam = Camera.main;
            if (cam == null) return;
            Vector3 flat = cam.transform.forward;
            flat.y = 0f;
            if (flat.sqrMagnitude < 0.001f) flat = cam.transform.up;
            flat.Normalize();
            Vector3 target = cam.transform.position + flat * _vrDistance + Vector3.up * _vrHeight;
            Transform t = _canvas.transform;
            Vector3 toPanel = t.position - cam.transform.position;
            toPanel.y = 0f;
            bool away = Vector3.Angle(flat, toPanel) > _vrFollowAngle;
            if (away) _drifting = true;
            if (snap) t.position = target;
            else if (_drifting)
            {
                t.position = Vector3.Lerp(t.position, target, 1f - Mathf.Exp(-Time.unscaledDeltaTime * 4f));
                if ((t.position - target).sqrMagnitude < 0.0004f) _drifting = false;
            }
            t.rotation = Quaternion.LookRotation(t.position - cam.transform.position, Vector3.up);
        }

        private bool _drifting;

        private void EnsureBuilt()
        {
            bool vr = XRSettings.isDeviceActive;
            if (_canvas != null && vr == _vr) return;
            if (_canvas != null) Destroy(_canvas.gameObject);
            _vr = vr;
            Font font = _font != null ? _font : LoadFont();

            var root = new GameObject("EzAdmin Announcement", typeof(RectTransform));
            root.transform.SetParent(transform, false);
            _canvas = root.AddComponent<Canvas>();
            _group = root.AddComponent<CanvasGroup>();
            _group.blocksRaycasts = false;
            _group.interactable = false;
            if (vr)
            {
                _canvas.renderMode = RenderMode.WorldSpace;
                ((RectTransform)root.transform).sizeDelta = new Vector2(_width + 80f, 420f);
                root.transform.localScale = Vector3.one * _vrScale;
                root.AddComponent<CanvasScaler>().dynamicPixelsPerUnit = 4f;
            }
            else
            {
                _canvas.renderMode = RenderMode.ScreenSpaceOverlay;
                _canvas.sortingOrder = 650;
                var scaler = root.AddComponent<CanvasScaler>();
                scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
                scaler.referenceResolution = new Vector2(1600, 900);
                scaler.matchWidthOrHeight = 0.5f;
            }

            float scale = vr ? 1.7f : 1f;
            var card = Box(root.transform, "Card", _panel, "round");
            _card = (RectTransform)card.transform;
            _card.sizeDelta = new Vector2(_width * (vr ? 1.15f : 1f), 0f);
            var fitter = card.AddComponent<ContentSizeFitter>();
            fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
            var column = card.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset((int)(26 * scale), (int)(22 * scale), (int)(18 * scale), (int)(20 * scale));
            column.spacing = 8f * scale;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = true;
            column.childForceExpandHeight = false;

            _bar = Box(card.transform, "Accent", Color.white, "round").GetComponent<Image>();
            var barRt = (RectTransform)_bar.transform;
            _bar.gameObject.AddComponent<LayoutElement>().ignoreLayout = true;
            barRt.anchorMin = new Vector2(0f, 0f);
            barRt.anchorMax = new Vector2(0f, 1f);
            barRt.pivot = new Vector2(0f, 0.5f);
            barRt.sizeDelta = new Vector2(6f * scale, -28f * scale);
            barRt.anchoredPosition = new Vector2(10f * scale, 0f);

            var head = new GameObject("Head", typeof(RectTransform));
            head.transform.SetParent(card.transform, false);
            var row = head.AddComponent<HorizontalLayoutGroup>();
            row.spacing = 12f * scale;
            row.childAlignment = TextAnchor.MiddleLeft;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = false;
            row.childForceExpandHeight = false;

            var frame = Box(head.transform, "Picture", Color.white, "disc");
            frame.AddComponent<Mask>().showMaskGraphic = false;
            var frameLe = frame.AddComponent<LayoutElement>();
            frameLe.preferredWidth = frameLe.preferredHeight = 34f * scale;
            var pic = new GameObject("Image", typeof(RectTransform), typeof(Image));
            pic.transform.SetParent(frame.transform, false);
            Stretch((RectTransform)pic.transform);
            _avatar = pic.GetComponent<Image>();
            _avatar.preserveAspect = true;
            frame.SetActive(false);

            _title = MakeText(head.transform, font, "Announcement", Mathf.RoundToInt(17 * scale), FontStyle.Bold, _accent);
            _title.gameObject.AddComponent<LayoutElement>().flexibleWidth = 1f;

            var close = Box(head.transform, "Close", new Color(1f, 1f, 1f, 0.08f), "disc");
            _closeButton = (RectTransform)close.transform;
            var closeLe = close.AddComponent<LayoutElement>();
            closeLe.preferredWidth = closeLe.preferredHeight = (Application.isMobilePlatform ? 44f : 30f) * scale;
            var cross = Box(close.transform, "X", new Color(_textColour.r, _textColour.g, _textColour.b, 0.85f), "cross");
            var crossRt = (RectTransform)cross.transform;
            crossRt.anchorMin = crossRt.anchorMax = new Vector2(0.5f, 0.5f);
            crossRt.sizeDelta = Vector2.one * 14f * scale;

            _body = MakeText(card.transform, font, "", Mathf.RoundToInt(20 * scale), FontStyle.Normal, _textColour);
            _body.lineSpacing = 1.15f;

            var foot = new GameObject("Foot", typeof(RectTransform));
            foot.transform.SetParent(card.transform, false);
            var footRow = foot.AddComponent<HorizontalLayoutGroup>();
            footRow.spacing = 10f * scale;
            footRow.childAlignment = TextAnchor.MiddleLeft;
            footRow.childControlWidth = footRow.childControlHeight = true;
            footRow.childForceExpandWidth = false;
            footRow.childForceExpandHeight = false;
            _hint = foot;

            var icon = Box(foot.transform, "Key", new Color(1f, 1f, 1f, 0.12f), "round");
            _hintIcon = icon.GetComponent<Image>();
            var iconLe = icon.AddComponent<LayoutElement>();
            iconLe.preferredHeight = 26f * scale;
            iconLe.preferredWidth = 40f * scale;
            _hintKey = MakeText(icon.transform, font, "Esc", Mathf.RoundToInt(12 * scale), FontStyle.Bold, _textColour);
            _hintKey.alignment = TextAnchor.MiddleCenter;
            Stretch((RectTransform)_hintKey.transform);
            _hintWords = MakeText(foot.transform, font, "Close", Mathf.RoundToInt(13 * scale), FontStyle.Normal,
                                  new Color(_textColour.r, _textColour.g, _textColour.b, 0.6f));

            var track = Box(card.transform, "Time", new Color(1f, 1f, 1f, 0.07f), "round");
            track.AddComponent<LayoutElement>().preferredHeight = 4f * scale;
            var fill = Box(track.transform, "Left", Color.white, "round");
            Stretch((RectTransform)fill.transform);
            _progress = fill.GetComponent<Image>();
            _progress.type = Image.Type.Filled;
            _progress.fillMethod = Image.FillMethod.Horizontal;

            var chip = Box(root.transform, "New", _panel, "round");
            var chipRt = (RectTransform)chip.transform;
            if (vr)
            {
                chipRt.anchorMin = chipRt.anchorMax = chipRt.pivot = new Vector2(0.5f, 0f);
                chipRt.anchoredPosition = new Vector2(0f, 10f);
            }
            var chipFit = chip.AddComponent<ContentSizeFitter>();
            chipFit.horizontalFit = chipFit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
            var chipRow = chip.AddComponent<HorizontalLayoutGroup>();
            chipRow.padding = new RectOffset((int)(16 * scale), (int)(16 * scale), (int)(9 * scale), (int)(9 * scale));
            chipRow.childControlWidth = chipRow.childControlHeight = true;
            _chipText = MakeText(chip.transform, font, "New announcement", Mathf.RoundToInt(14 * scale), FontStyle.Bold, _textColour);
            _chip = chip;
            chip.SetActive(false);

            root.SetActive(false);
        }

        private GameObject Box(Transform parent, string name, Color colour, string sprite)
        {
            var go = new GameObject(name, typeof(RectTransform), typeof(Image));
            go.transform.SetParent(parent, false);
            var image = go.GetComponent<Image>();
            image.sprite = Sprite(sprite);
            image.type = sprite == "round" ? Image.Type.Sliced : Image.Type.Simple;
            image.color = colour;
            image.raycastTarget = false;
            return go;
        }

        private static void Stretch(RectTransform rt)
        {
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMin = rt.offsetMax = Vector2.zero;
        }

        private Sprite Sprite(string key)
        {
            Sprite cached;
            if (_sprites.TryGetValue(key, out cached) && cached != null) return cached;
            int radius = Mathf.Max(2, Mathf.RoundToInt(_cornerRadius));
            Sprite made;
            switch (key)
            {
                case "disc": made = Make(64, 0, (x, y) => Mathf.Clamp01(31f - Vector2.Distance(new Vector2(x, y), new Vector2(32f, 32f)) + 0.5f)); break;
                case "cross": made = Make(64, 0, Cross); break;
                default:
                    int size = radius * 2 + 8;
                    made = Make(size, radius + 2, (x, y) => Round(x, y, size, radius));
                    break;
            }
            _sprites[key] = made;
            return made;
        }

        private static float Round(float x, float y, int size, float r)
        {
            float cx = Mathf.Clamp(x, r, size - r);
            float cy = Mathf.Clamp(y, r, size - r);
            return Mathf.Clamp01(r - Vector2.Distance(new Vector2(x, y), new Vector2(cx, cy)) + 0.5f);
        }

        private static float Cross(float x, float y)
        {
            Vector2 p = new Vector2(x - 32f, y - 32f);
            if (Mathf.Abs(p.x) > 24f || Mathf.Abs(p.y) > 24f) return 0f;
            float a = Mathf.Abs(p.x - p.y) / 1.4142f;
            float b = Mathf.Abs(p.x + p.y) / 1.4142f;
            return Mathf.Clamp01(4.5f - Mathf.Min(a, b));
        }

        private static Sprite Make(int size, int border, Func<float, float, float> alpha)
        {
            var texture = new Texture2D(size, size, TextureFormat.RGBA32, false)
            {
                hideFlags = HideFlags.HideAndDontSave,
                wrapMode = TextureWrapMode.Clamp,
                filterMode = FilterMode.Bilinear
            };
            var pixels = new Color32[size * size];
            for (int y = 0; y < size; y++)
                for (int x = 0; x < size; x++)
                    pixels[y * size + x] = new Color32(255, 255, 255, (byte)(Mathf.Clamp01(alpha(x + 0.5f, y + 0.5f)) * 255f));
            texture.SetPixels32(pixels);
            texture.Apply(false, true);
            return UnityEngine.Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f, 0,
                                             SpriteMeshType.FullRect, new Vector4(border, border, border, border));
        }

        private static Text MakeText(Transform parent, Font font, string value, int size, FontStyle style, Color colour)
        {
            var go = new GameObject("Text", typeof(RectTransform));
            go.transform.SetParent(parent, false);
            var text = go.AddComponent<Text>();
            text.font = font;
            text.text = value;
            text.fontSize = size;
            text.fontStyle = style;
            text.color = colour;
            text.alignment = TextAnchor.MiddleLeft;
            text.horizontalOverflow = HorizontalWrapMode.Wrap;
            text.verticalOverflow = VerticalWrapMode.Overflow;
            text.raycastTarget = false;
            return text;
        }

        private static Font LoadFont()
        {
            string[] parts = Application.unityVersion.Split('.');
            int major = 0, minor = 0;
            if (parts.Length > 1)
            {
                int.TryParse(parts[0], out major);
                int.TryParse(parts[1], out minor);
            }
            bool legacyRuntime = major > 2022 || (major == 2022 && minor >= 2);
            return Resources.GetBuiltinResource<Font>(legacyRuntime ? "LegacyRuntime.ttf" : "Arial.ttf");
        }
    }
}
EzAdminAuth.csAssets/EzAdmin/Runtime. EzAdmin Sign in: Meta Quest, Steam, Xbox, PlayStation, phones and PC. No secrets inside. The wizard adds it.23.1 KB · 586 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;
#if EZADMIN_META
using Oculus.Platform;
using Oculus.Platform.Models;
#endif

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminAuth : MonoBehaviour
    {
        public enum Method { None, Meta, Steam, Xbox, PlayStation, Device }

        [EzAdminGroup("Meta Quest")]
        [Tooltip("Quit if the player does not own the app on the Meta store (piracy check). Builds on a headset only.")]
#pragma warning disable 0414
        [SerializeField] private bool _requireEntitlement = true;
#pragma warning restore 0414

        public const string DefaultMetaPrefix = "META_";

        [Tooltip("Put before the Meta user id to make the PlayFab account id. If players already signed in with " +
                 "Meta before, use the prefix your old login used so they keep their accounts.")]
        [SerializeField] private string _metaPrefix = DefaultMetaPrefix;
        [Tooltip("EzAdmin checks the player with Meta, then signs them in to PlayFab from its server. Nobody can " +
                 "sign in as another player. Needs your Meta app token on the EzAdmin dashboard (Connect games).")]
        [SerializeField] private bool _secureMeta = true;
        [Tooltip("Insecure, off on purpose. If EzAdmin is not set up for secure sign-in yet, sign in the old way " +
                 "instead of failing. The old way trusts the headset's word for who the player is, so a changed copy " +
                 "can sign in as someone else and get past a ban. Leave this off and set up the Meta app token on the " +
                 "EzAdmin dashboard (Connect games) so EzAdmin checks the player with Meta from its own server.")]
        [SerializeField] private bool _allowOldMetaLogin = false;

        [EzAdminGroup("Everything else")]
        [Tooltip("Where the device's random id is kept between launches. Use your old key to keep old accounts.")]
        [SerializeField] private string _savedIdKey = "EzAdmin.PlayerId";
        [Tooltip("Prefix for device accounts made by this script.")]
        [SerializeField] private string _devicePrefix = "DEVICE_";

        [EzAdminGroup("Behaviour")]
        [Tooltip("Start signing in as soon as the game starts.")]
        [SerializeField] private bool _signInOnStart = true;
        [Tooltip("Quit the game if the account is banned.")]
        [SerializeField] private bool _quitIfBanned = true;

        [EzAdminGroup("After sign-in")]
        [Tooltip("Turned on once the player is signed in.")]
        [SerializeField] private GameObject[] _objectsToEnable = new GameObject[0];
        [Tooltip("Turned off once the player is signed in. A loading screen, a Press to start sign.")]
        [SerializeField] private GameObject[] _objectsToDisable = new GameObject[0];

        [EzAdminGroup("Items (object name = PlayFab item id)")]
        [Tooltip("Each object is turned on only if the player owns the item with the same id as the object's name.")]
        [SerializeField] private GameObject[] _enableIfOwned = new GameObject[0];
        [Tooltip("Each object is turned off if the player owns the item with the same id as the object's name.")]
        [SerializeField] private GameObject[] _disableIfOwned = new GameObject[0];

        [EzAdminGroup("Staff")]
        [Tooltip("Only on for staff. Staff come from your Discord roles through EzAdmin, so nobody can fake it in game.")]
        [SerializeField] private GameObject[] _staffObjects = new GameObject[0];

        [EzAdminGroup("Ban and errors")]
        [Tooltip("Turned on when sign-in fails, the player is banned or kicked.")]
        [SerializeField] private GameObject[] _errorObjectsEnable = new GameObject[0];
        [Tooltip("Turned off at the same time.")]
        [SerializeField] private GameObject[] _errorObjectsDisable = new GameObject[0];
        [Tooltip("Text (UI Text or TextMeshPro) that shows the reason.")]
        [SerializeField] private Component[] _errorTexts = new Component[0];

        [EzAdminGroup("Keep between scenes")]
        [SerializeField] private GameObject[] _dontDestroyOnLoad = new GameObject[0];

        [EzAdminGroup("AFK")]
        [Tooltip("Seconds without moving before the player is removed. 0 turns it off.")]
        [SerializeField] private float _afkSeconds = 0f;

        public static Func<string> SteamTicket;

        public static Func<string> XboxToken;

        public static Func<string> PlayStationAuthCode;

        public static string PlayStationRedirectUri = "orbis://games";

        public static event Action<string, Method> SignedIn;

        public static event Action<string> Failed;

        public static event Action<DateTime> Banned;

        public static Method SignedInWith { get; private set; }

        public static string LastError { get; private set; }

        public static string MetaUserId { get; private set; } = "";
        public static string MetaProof { get; private set; } = "";

        private static bool _busy;

        private void Awake()
        {
            foreach (var go in _dontDestroyOnLoad)
                if (go != null && go.transform.parent == null) DontDestroyOnLoad(go);
            Toggle(_objectsToEnable, false);
            Toggle(_enableIfOwned, false);
            Toggle(_staffObjects, EzAdminClient.IsStaff);
            Toggle(_errorObjectsEnable, false);
        }

        private void OnEnable()
        {
            EzAdminClient.StaffChanged += OnStaff;
            EzAdminClient.Kicked += ShowError;
            EzAdminItems.Changed += ApplyItems;
        }

        private void OnDisable()
        {
            EzAdminClient.StaffChanged -= OnStaff;
            EzAdminClient.Kicked -= ShowError;
            EzAdminItems.Changed -= ApplyItems;
        }

        private void Start()
        {
            if (_signInOnStart) SignIn();
        }

        private UnityEngine.Vector3 _lastSpot;
        private float _stillSince;
        private bool _afkDone;

        private void Update()
        {
            if (_afkSeconds <= 0f || _afkDone || !PlayFabClientAPI.IsClientLoggedIn()) return;
            var cam = UnityEngine.Camera.main;
            if (cam == null) return;
            UnityEngine.Vector3 spot = cam.transform.position + cam.transform.forward * 0.5f;
            if ((spot - _lastSpot).sqrMagnitude > 0.0004f)
            {
                _lastSpot = spot;
                _stillSince = Time.unscaledTime;
                return;
            }
            if (Time.unscaledTime - _stillSince < _afkSeconds) return;
            _afkDone = true;
            ShowError("Removed for being idle.");
            QuitGame();
        }

        private void AfterSignIn()
        {
            Toggle(_errorObjectsEnable, false);
            Toggle(_errorObjectsDisable, true);
            Write(_errorTexts, "");
            Toggle(_objectsToEnable, true);
            Toggle(_objectsToDisable, false);
            _stillSince = Time.unscaledTime;
            EzAdminItems.Start();
            if (EzAdminItems.Loaded) ApplyItems();
        }

        private void ApplyItems()
        {
            if (this == null || !EzAdminItems.Loaded) return;
            foreach (var go in _enableIfOwned)
                if (go != null && go != gameObject) go.SetActive(EzAdminItems.Has(go.name));
            foreach (var go in _disableIfOwned)
                if (go != null && go != gameObject) go.SetActive(!EzAdminItems.Has(go.name));
        }

        private void OnStaff(bool staff)
        {
            Toggle(_staffObjects, staff);
        }

        private void ShowError(string message)
        {
            Toggle(_errorObjectsEnable, true);
            Toggle(_errorObjectsDisable, false);
            Write(_errorTexts, message ?? "");
        }

        private void Toggle(GameObject[] objects, bool active)
        {
            if (objects == null) return;
            foreach (var go in objects)
                if (go != null && go != gameObject && go.activeSelf != active) go.SetActive(active);
        }

        private static void Write(Component[] targets, string text)
        {
            if (targets == null) return;
            foreach (var target in targets)
            {
                if (target == null) continue;
                var property = target.GetType().GetProperty("text");
                if (property != null && property.CanWrite && property.PropertyType == typeof(string)) property.SetValue(target, text);
            }
        }

        public void SignIn()
        {
            if (_busy || PlayFabClientAPI.IsClientLoggedIn()) return;
            if (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId))
            {
                Fail("No PlayFab Title ID. Search the Project window for PlayFabSharedSettings and type it in.");
                return;
            }
            _busy = true;
            StartCoroutine(Run());
        }

        private IEnumerator Run()
        {
            Method method = Choose();
            float[] backoff = { 2f, 5f, 10f, 20f, 30f };
            for (int attempt = 0; ; attempt++)
            {
                bool done = false, retry = false;
                yield return StartCoroutine(Attempt(method, (ok, canRetry) => { done = ok; retry = canRetry; }));
                if (done || !retry) break;
                yield return new WaitForSecondsRealtime(backoff[Math.Min(attempt, backoff.Length - 1)]);
            }
            _busy = false;
        }

        private Method Choose()
        {
            if (XboxToken != null) return Method.Xbox;
            if (PlayStationAuthCode != null) return Method.PlayStation;
            if (SteamTicket != null) return Method.Steam;
#if EZADMIN_META && UNITY_ANDROID && !UNITY_EDITOR
            return Method.Meta;
#else
            return Method.Device;
#endif
        }

        private IEnumerator Attempt(Method method, Action<bool, bool> finished)
        {
            switch (method)
            {
                case Method.Meta:
                    yield return StartCoroutine(MetaIdentity());
                    if (string.IsNullOrEmpty(MetaUserId))
                    {
                        finished(false, LastError == null);
                        yield break;
                    }
                    if (_secureMeta && !string.IsNullOrEmpty(MetaProof))
                    {
                        string code = "";
                        yield return StartCoroutine(SecureMeta(finished, c => code = c));
                        if (code == null) yield break;
                        if (code != "meta_not_set" || !_allowOldMetaLogin)
                        {
                            finished(false, code == "retry");
                            yield break;
                        }
                        Debug.LogWarning("EzAdmin: secure Quest sign-in is not set up for this game yet, so the old " +
                                         "sign-in was used. Add the Meta app token on the EzAdmin dashboard (Connect games).", this);
                    }
                    yield return Login(method, cb => PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
                    {
                        CustomId = _metaPrefix + MetaUserId,
                        CreateAccount = true,
                        InfoRequestParameters = Info()
                    }, cb, e => OnError(e, finished)), finished);
                    break;

                case Method.Steam:
                    yield return Login(method, cb => PlayFabClientAPI.LoginWithSteam(new LoginWithSteamRequest
                    {
                        SteamTicket = SteamTicket(),
                        CreateAccount = true,
                        InfoRequestParameters = Info()
                    }, cb, e => OnError(e, finished)), finished);
                    break;

                case Method.Xbox:
                    yield return Login(method, cb => PlayFabClientAPI.LoginWithXbox(new LoginWithXboxRequest
                    {
                        XboxToken = XboxToken(),
                        CreateAccount = true,
                        InfoRequestParameters = Info()
                    }, cb, e => OnError(e, finished)), finished);
                    break;

                case Method.PlayStation:
                    yield return Login(method, cb => PlayFabClientAPI.LoginWithPSN(new LoginWithPSNRequest
                    {
                        AuthCode = PlayStationAuthCode(),
                        RedirectUri = PlayStationRedirectUri,
                        CreateAccount = true,
                        InfoRequestParameters = Info()
                    }, cb, e => OnError(e, finished)), finished);
                    break;

                default:
                    yield return Login(Method.Device, cb => PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
                    {
                        CustomId = DeviceId(),
                        CreateAccount = true,
                        InfoRequestParameters = Info()
                    }, cb, e => OnError(e, finished)), finished);
                    break;
            }
        }

        private IEnumerator Login(Method method, Action<Action<LoginResult>> call, Action<bool, bool> finished)
        {
            _answered = false;
            call(result => OnLoggedIn(result, method, finished));

            float until = Time.realtimeSinceStartup + 30f;
            while (!_answered && Time.realtimeSinceStartup < until) yield return null;
            if (!_answered)
            {
                _answered = true;
                finished(false, true);
            }
        }

        private bool _answered;

        public static bool SignedInSecurely { get; private set; }

        [Serializable]
        private class SecureRequest
        {
            public string titleId;
            public string userId;
            public string proof;
            public string legacyPrefix;
        }

        [Serializable]
        private class SecureReply
        {
            public bool ok;
            public string code;
            public string message;
            public string playFabId;
            public string sessionTicket;
            public string entityToken;
            public string entityId;
            public string entityType;
            public bool newlyCreated;
            public bool migrated;
            public string bannedUntil;
        }

        private IEnumerator SecureMeta(Action<bool, bool> finished, Action<string> report)
        {
            var body = new SecureRequest
            {
                titleId = PlayFabSettings.staticSettings.TitleId,
                userId = MetaUserId,
                proof = MetaProof,
                legacyPrefix = _metaPrefix ?? ""
            };
            string url = EzAdminClient.SiteAddress + "/unity/v1/auth/meta";
            string text = "";
            using (var request = new UnityEngine.Networking.UnityWebRequest(url, "POST"))
            {
                request.uploadHandler = new UnityEngine.Networking.UploadHandlerRaw(
                    System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(body)));
                request.downloadHandler = new UnityEngine.Networking.DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.timeout = 25;
                yield return request.SendWebRequest();
                text = request.downloadHandler != null ? request.downloadHandler.text : "";
            }
            SecureReply reply = null;
            try { reply = string.IsNullOrEmpty(text) ? null : JsonUtility.FromJson<SecureReply>(text); }
            catch (Exception) { reply = null; }
            if (reply == null)
            {
                LastError = null;
                Debug.LogWarning("EzAdmin: could not reach EzAdmin to sign in. Retrying.", this);
                report("retry");
                yield break;
            }
            if (!reply.ok)
            {
                if (reply.code == "meta_not_set")
                {
                    report("meta_not_set");
                    yield break;
                }
                if (reply.code == "banned")
                {
                    Banned?.Invoke(DateTime.MaxValue);
                    Fail("This account is banned.");
                    if (_quitIfBanned) QuitGame();
                    finished(false, false);
                    report(null);
                    yield break;
                }
                bool retry = reply.code == "meta_unreachable" || reply.code == "playfab_unreachable" || reply.code == "slow_down";
                Fail("Sign-in failed: " + reply.message);
                if (retry) LastError = null;
                report(retry ? "retry" : "failed");
                yield break;
            }

            PlayFabSettings.staticPlayer.ClientSessionTicket = reply.sessionTicket;
            PlayFabSettings.staticPlayer.PlayFabId = reply.playFabId;
            PlayFabSettings.staticPlayer.EntityToken = reply.entityToken;
            PlayFabSettings.staticPlayer.EntityId = reply.entityId;
            PlayFabSettings.staticPlayer.EntityType = reply.entityType;
            SignedInSecurely = true;
            LastError = null;
            SignedInWith = Method.Meta;
            if (Debug.isDebugBuild)
                Debug.Log("EzAdmin: signed in securely through EzAdmin" + (reply.migrated ? " (old account moved over)" : "") + ".");

            DateTime until;
            if (!string.IsNullOrEmpty(reply.bannedUntil) && DateTime.TryParse(reply.bannedUntil, out until) && until > DateTime.UtcNow)
            {
                Banned?.Invoke(until);
                Fail("This account is banned until " + until.ToString("u") + ".");
                if (_quitIfBanned) QuitGame();
                finished(false, false);
                report(null);
                yield break;
            }
            SignedIn?.Invoke(reply.playFabId, Method.Meta);
            AfterSignIn();
            finished(true, false);
            report(null);
        }

        private static GetPlayerCombinedInfoRequestParams Info()
        {
            return new GetPlayerCombinedInfoRequestParams { GetPlayerProfile = true };
        }

        private void OnLoggedIn(LoginResult result, Method method, Action<bool, bool> finished)
        {
            if (_answered) return;
            _answered = true;
            LastError = null;
            SignedInWith = method;
            if (Debug.isDebugBuild) Debug.Log("EzAdmin: signed in to PlayFab with " + method + " as " + result.PlayFabId);

            var bannedUntil = result.InfoResultPayload?.PlayerProfile?.BannedUntil;
            if (bannedUntil != null)
            {
                Banned?.Invoke(bannedUntil.Value);
                Fail("This account is banned until " + bannedUntil.Value.ToString("u") + ".");
                if (_quitIfBanned) QuitGame();
                finished(false, false);
                return;
            }
            SignedIn?.Invoke(result.PlayFabId, method);
            AfterSignIn();
            finished(true, false);
        }

        private void OnError(PlayFabError error, Action<bool, bool> finished)
        {
            if (_answered) return;
            _answered = true;
            string said = string.IsNullOrEmpty(error.ErrorMessage) ? error.Error.ToString() : error.ErrorMessage;
            if (error.Error == PlayFabErrorCode.AccountBanned)
            {
                Banned?.Invoke(DateTime.MaxValue);
                Fail("This account is banned.");
                if (_quitIfBanned) QuitGame();
                finished(false, false);
                return;
            }
            if (said.IndexOf("not authorized", StringComparison.OrdinalIgnoreCase) >= 0)
            {
                Fail("PlayFab title " + PlayFabSettings.staticSettings.TitleId + " blocks game sign-ins (\"" + said + "\"). " +
                     "Its API rules deny client calls: in PlayFab Game Manager remove the rule that disables Client " +
                     "API access. No sign-in can work until then.");
                finished(false, false);
                return;
            }

            bool retry = error.Error == PlayFabErrorCode.ServiceUnavailable || error.Error == PlayFabErrorCode.ConnectionError
                         || error.HttpCode == 0 || error.HttpCode >= 500;
            Fail("PlayFab sign-in failed: " + said);
            if (retry) LastError = null;
            finished(false, retry);
        }

        private void Fail(string message)
        {
            ShowError(message);
            LastError = message;
            Debug.LogWarning("EzAdmin: " + message, this);
            Failed?.Invoke(message);
        }

        private string DeviceId()
        {

            string id = PlayerPrefs.GetString(_savedIdKey, "");
            if (string.IsNullOrEmpty(id))
            {
                id = _devicePrefix + Guid.NewGuid().ToString("N");
                PlayerPrefs.SetString(_savedIdKey, id);
                PlayerPrefs.Save();
            }
            return id;
        }

        private static void QuitGame()
        {
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#else
            UnityEngine.Application.Quit();
#endif
        }

#if EZADMIN_META
        private IEnumerator MetaIdentity()
        {
            MetaUserId = "";
            MetaProof = "";
            bool step = false, ok = false;

            Core.AsyncInitialize().OnComplete(msg => { ok = !msg.IsError; step = true; });
            while (!step) yield return null;
            if (!ok)
            {
                Fail("The Meta platform did not start. Check the App ID in Oculus › Platform › Edit Settings.");
                yield break;
            }

            if (_requireEntitlement)
            {
                step = false;
                Entitlements.IsUserEntitledToApplication().OnComplete(msg => { ok = !msg.IsError; step = true; });
                while (!step) yield return null;
                if (!ok)
                {
                    Fail("This copy of the game is not owned on the Meta store.");
                    QuitGame();
                    yield break;
                }
            }

            step = false;
            Users.GetLoggedInUser().OnComplete((Message<User> msg) =>
            {
                if (!msg.IsError && msg.Data != null) MetaUserId = msg.Data.ID.ToString();
                step = true;
            });
            while (!step) yield return null;
            if (string.IsNullOrEmpty(MetaUserId))
            {
                Fail("Could not read the Meta account. Is the headset signed in?");
                yield break;
            }

            step = false;
            Users.GetUserProof().OnComplete((Message<UserProof> msg) =>
            {
                if (!msg.IsError && msg.Data != null) MetaProof = msg.Data.Value;
                step = true;
            });
            while (!step) yield return null;
        }
#else
        private IEnumerator MetaIdentity()
        {
            Fail("Meta sign-in needs the Meta Platform SDK. Run the Setup Wizard to switch it on.");
            yield break;
        }
#endif
    }
}
EzAdminAutoLogin.csAssets/EzAdmin/Runtime. A PlayFab sign in per device, for a game that has no login yet. The wizard adds it when you need it.1.6 KB · 48 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminAutoLogin : MonoBehaviour
    {
        [Tooltip("Make a PlayFab account for this device the first time it is seen.")]
        [SerializeField] private bool _createAccount = true;

        [Tooltip("Print what happens to the Console.")]
        [SerializeField] private bool _log = true;

        public static bool IsSignedIn => PlayFabClientAPI.IsClientLoggedIn();

        private void Start()
        {
            if (PlayFabClientAPI.IsClientLoggedIn())
            {
                return;
            }
            if (string.IsNullOrEmpty(PlayFabSettings.staticSettings.TitleId))
            {
                Debug.LogError("EzAdminAutoLogin: no PlayFab Title ID. Put yours in PlayFabSharedSettings " +
                               "(search the Project window for it).", this);
                return;
            }

            PlayFabClientAPI.LoginWithCustomID(new LoginWithCustomIDRequest
            {
                CustomId = SystemInfo.deviceUniqueIdentifier,
                CreateAccount = _createAccount
            }, result =>
            {
                if (_log) Debug.Log("EzAdmin: signed in to PlayFab as " + result.PlayFabId);
            }, error =>
            {
                Debug.LogWarning("EzAdmin: PlayFab sign-in failed: " + error.GenerateErrorReport(), this);
            });
        }
    }
}
EzAdminWeb.csAssets/EzAdmin/Runtime. Where EzAdmin's web pages open: the browser, or your in game web view.0.8 KB · 31 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using UnityEngine;

namespace EzAdmin.Client
{

    public static class EzAdminWeb
    {

        public static Action<string> Opener = Application.OpenURL;

        public static event Action<string> Opened;

        public static void Open(string url)
        {
            if (string.IsNullOrEmpty(url)) return;
            (Opener ?? Application.OpenURL)(url);
            Opened?.Invoke(url);
        }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        private static void ResetOnPlay()
        {
            Opener = Application.OpenURL;
            Opened = null;
        }
    }
}
EzAdminBrand.csAssets/EzAdmin/Runtime. The EzAdmin logo and colours, drawn in code.5.3 KB · 131 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using UnityEngine;

namespace EzAdmin.Client
{

    [AttributeUsage(AttributeTargets.Field)]
    public sealed class EzAdminGroupAttribute : Attribute
    {
        public readonly string Title;
        public EzAdminGroupAttribute(string title) { Title = title; }
    }

    public static class EzAdminBrand
    {
        public static readonly Color Green = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        public static readonly Color GreenSoft = new Color32(0x4A, 0xDE, 0x80, 0xFF);
        public static readonly Color GreenDeep = new Color32(0x12, 0xA1, 0x50, 0xFF);
        public static readonly Color Ink = new Color32(0x14, 0x14, 0x14, 0xFF);
        public static readonly Color Panel = new Color32(0x12, 0x15, 0x1C, 0xFF);
        public static readonly Color Discord = new Color32(0x58, 0x65, 0xF2, 0xFF);

        private static readonly Vector2[] E = Poly(236, 282, 572, 282, 572, 386, 352, 386, 352, 484, 636, 484,
                                                   636, 588, 352, 588, 352, 774, 576, 774, 576, 878, 236, 878);
        private static readonly Vector2[] AOuter = Poly(742, 282, 814, 282, 1068, 778, 944, 778, 886, 660,
                                                        670, 660, 612, 778, 488, 778);
        private static readonly Vector2[] AHole = Poly(778, 424, 704, 576, 852, 576);
        private const float Radius = 232f;

        private static readonly Dictionary<int, Texture2D> Textures = new Dictionary<int, Texture2D>();
        private static readonly Dictionary<int, Sprite> Sprites = new Dictionary<int, Sprite>();

        public static Texture2D Logo(int size = 128)
        {
            size = Mathf.Clamp(size, 16, 512);
            Texture2D cached;
            if (Textures.TryGetValue(size, out cached) && cached != null) return cached;

            var texture = new Texture2D(size, size, TextureFormat.RGBA32, false)
            {
                name = "EzAdmin logo",
                wrapMode = TextureWrapMode.Clamp,
                filterMode = FilterMode.Bilinear,
                hideFlags = HideFlags.HideAndDontSave
            };
            var pixels = new Color[size * size];
            const int samples = 4;
            float scale = 1024f / size;
            for (int py = 0; py < size; py++)
            {
                for (int px = 0; px < size; px++)
                {
                    float r = 0, g = 0, b = 0, a = 0;
                    for (int sy = 0; sy < samples; sy++)
                    {
                        for (int sx = 0; sx < samples; sx++)
                        {
                            float x = (px + (sx + 0.5f) / samples) * scale;

                            float y = (size - py - (sy + 0.5f) / samples) * scale;
                            Color c = Sample(x, y);
                            r += c.r * c.a; g += c.g * c.a; b += c.b * c.a; a += c.a;
                        }
                    }
                    float n = samples * samples;
                    pixels[py * size + px] = a > 0 ? new Color(r / a, g / a, b / a, a / n) : Color.clear;
                }
            }
            texture.SetPixels(pixels);
            texture.Apply();
            Textures[size] = texture;
            return texture;
        }

        public static Sprite LogoSprite(int size = 64)
        {
            Sprite cached;
            if (Sprites.TryGetValue(size, out cached) && cached != null) return cached;
            var texture = Logo(size);
            var sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), 100f);
            sprite.name = "EzAdmin logo";
            Sprites[size] = sprite;
            return sprite;
        }

        private static Color Sample(float x, float y)
        {
            if (!InTile(x, y)) return Color.clear;
            if (Inside(AOuter, x, y) && !Inside(AHole, x, y))
            {
                float t = Mathf.InverseLerp(282f, 778f, y);
                return Color.Lerp(GreenSoft, GreenDeep, t);
            }
            if (Inside(E, x, y)) return Color.white;
            return Ink;
        }

        private static bool InTile(float x, float y)
        {
            float cx = Mathf.Clamp(x, Radius, 1024f - Radius);
            float cy = Mathf.Clamp(y, Radius, 1024f - Radius);
            float dx = x - cx, dy = y - cy;
            return x >= 0 && y >= 0 && x <= 1024f && y <= 1024f && dx * dx + dy * dy <= Radius * Radius;
        }

        private static bool Inside(Vector2[] poly, float x, float y)
        {
            bool inside = false;
            for (int i = 0, j = poly.Length - 1; i < poly.Length; j = i++)
            {
                if ((poly[i].y > y) != (poly[j].y > y) &&
                    x < (poly[j].x - poly[i].x) * (y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x)
                {
                    inside = !inside;
                }
            }
            return inside;
        }

        private static Vector2[] Poly(params float[] xy)
        {
            var points = new Vector2[xy.Length / 2];
            for (int i = 0; i < points.Length; i++) points[i] = new Vector2(xy[i * 2], xy[i * 2 + 1]);
            return points;
        }
    }
}
EzAdminItems.csAssets/EzAdmin/Runtime. What the player owns, kept up to date: EzAdminItems.Has("vip_hat").3.1 KB · 96 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace EzAdmin.Client
{

    public static class EzAdminItems
    {
        private static readonly Dictionary<string, EzAdminClient.OwnedItem> ById =
            new Dictionary<string, EzAdminClient.OwnedItem>(StringComparer.OrdinalIgnoreCase);
        private static readonly Dictionary<string, EzAdminClient.OwnedItem> ByName =
            new Dictionary<string, EzAdminClient.OwnedItem>(StringComparer.OrdinalIgnoreCase);
        private static bool _started;
        private static bool _loading;

        public static bool Loaded { get; private set; }

        public static event Action Changed;

        public static IEnumerable<EzAdminClient.OwnedItem> All => ById.Values;

        public static bool Has(string idOrName)
        {
            if (string.IsNullOrEmpty(idOrName)) return false;
            return ById.ContainsKey(idOrName) || ByName.ContainsKey(idOrName);
        }

        public static int Count(string idOrName)
        {
            EzAdminClient.OwnedItem item;
            if (ById.TryGetValue(idOrName ?? "", out item) || ByName.TryGetValue(idOrName ?? "", out item))
            {
                return Math.Max(1, item.amount);
            }
            return 0;
        }

        public static void Start()
        {
            if (_started) return;
            var client = EzAdminClient.Instance;
            if (client == null) return;
            _started = true;
            EzAdminClient.ItemsChanged += Refresh;
            EzAdminLink.Linked += _ => Refresh();
            client.StartCoroutine(FirstLoad());
        }

        public static void Refresh()
        {
            var client = EzAdminClient.Instance;
            if (client == null || _loading) return;
            _loading = true;
            client.GetOwnedItems(items =>
            {
                _loading = false;
                if (items == null) return;
                ById.Clear();
                ByName.Clear();
                foreach (var item in items)
                {
                    if (string.IsNullOrEmpty(item.id)) continue;
                    ById[item.id] = item;
                    if (!string.IsNullOrEmpty(item.name)) ByName[item.name] = item;
                }
                Loaded = true;
                Changed?.Invoke();
            });
        }

        private static IEnumerator FirstLoad()
        {
            while (EzAdminClient.Instance != null && !EzAdminClient.Instance.IsConnected)
            {
                yield return new WaitForSecondsRealtime(1f);
            }
            Refresh();
        }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        private static void ResetOnPlay()
        {
            ById.Clear();
            ByName.Clear();
            _started = false;
            _loading = false;
            Loaded = false;
            Changed = null;
        }
    }
}
EzAdminItemUnlock.csAssets/EzAdmin/Runtime. Switches a hat, sword or button on when the player owns the item. No code.18.6 KB · 482 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Collections.Generic;
using System.Reflection;
using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;

namespace EzAdmin.Client
{

    public sealed class EzAdminItemUnlock : MonoBehaviour, IPointerClickHandler
    {
        [EzAdminGroup("Item")]
        [Tooltip("Catalog Item Id or display name. Case does not matter.")]
        [SerializeField] private string _item = "";

        [Tooltip("A nice name to show in text labels. Leave empty to use the item id above.")]
        [SerializeField] private string _displayName = "";

        [EzAdminGroup("Buying")]
        [Tooltip("Let the player buy this item. The price comes from the item in your PlayFab catalog.")]
        [SerializeField] private bool _canBuy = false;

        [Tooltip("Buy when a hand or object touches this. This object needs a Collider set to Is Trigger.")]
        [SerializeField] private bool _buyOnTouch = true;

        [Tooltip("Only something with this tag can buy it by touch, for example your hand tag. Empty means anything.")]
        [SerializeField] private string _buyTag = "";

        [Tooltip("Buy when the player clicks this with a mouse or controller. This object needs a Collider.")]
        [SerializeField] private bool _buyOnClick = true;

        [Tooltip("Seconds before a touch can buy again, so a resting hand does not buy over and over.")]
        [SerializeField, Range(0.2f, 5f)] private float _rearmSeconds = 1f;

        [Tooltip("Which virtual currency to spend when the item has more than one price. Empty uses the first one.")]
        [SerializeField] private string _currency = "";

        [EzAdminGroup("Turn objects on or off")]
        [Tooltip("Turned ON when they own the item, OFF when they do not.")]
        [SerializeField] private GameObject[] _showWhenOwned = new GameObject[0];

        [Tooltip("Turned OFF when they own the item, e.g. a lock icon or a Buy button.")]
        [SerializeField] private GameObject[] _hideWhenOwned = new GameObject[0];

        [Tooltip("Hide the owned things until the items have loaded, so nobody sees a flash of a hat they lack.")]
        [SerializeField] private bool _hideUntilLoaded = true;

        [EzAdminGroup("Text labels")]
        [Tooltip("Any text objects to fill in: TextMeshPro UI, a 3D TextMeshPro, or a plain UI Text. " +
                 "The right one is found for you.")]
        [SerializeField] private GameObject[] _labels = new GameObject[0];

        [Tooltip("No labels dragged in above? Fill the first text found on this object or its children.")]
        [SerializeField] private bool _findLabel = true;

        [Tooltip("What the labels say when the player owns it. {owned} {buy} {name} {status} {price} {currency} are filled in.")]
        [SerializeField] private string _ownedText = "{owned}: {name}";

        [Tooltip("What the labels say when the player does not own it. {buy} {name} {status} {price} {currency} are filled in.")]
        [SerializeField] private string _notOwnedText = "{buy}: {name}\n{price} {currency}";

        [Tooltip("What the labels say while the items are still loading.")]
        [SerializeField] private string _loadingText = "{name}";

        [Tooltip("The word {buy} becomes: the call to action before buying, for example Purchase or Buy.")]
        [SerializeField] private string _buyWord = "Purchase";

        [Tooltip("The word {owned} becomes once bought, for example Owned or Equipped.")]
        [SerializeField] private string _ownedLabel = "Owned";

        [Tooltip("The word {status} becomes once bought.")]
        [SerializeField] private string _ownedWord = "Owned";

        [Tooltip("The word {status} becomes before buying.")]
        [SerializeField] private string _notOwnedWord = "Locked";

        [EzAdminGroup("Events - hook your own code here")]
        [SerializeField] private UnityEvent _onOwned = new UnityEvent();
        [SerializeField] private UnityEvent _onNotOwned = new UnityEvent();
        [Tooltip("Bought just now. Wire a sound, a particle, a haptic pulse.")]
        [SerializeField] private UnityEvent _onPurchased = new UnityEvent();
        [Tooltip("A purchase did not go through. The reason is passed.")]
        [SerializeField] private StringEvent _onFailed = new StringEvent();

        [System.Serializable] public class StringEvent : UnityEvent<string> { }

        private int _last = -1;
        private bool _busy;
        private float _nextBuy;
        private static readonly Dictionary<System.Type, PropertyInfo> _textProps =
            new Dictionary<System.Type, PropertyInfo>();

        public UnityEvent OnPurchased => _onPurchased;
        public UnityEvent<string> OnFailed => _onFailed;

        public string Item
        {
            get { return _item; }
            set { _item = value; _last = -1; Apply(); }
        }

        public string DisplayName
        {
            get { return string.IsNullOrEmpty(_displayName) ? _item : _displayName; }
            set { _displayName = value; _last = -1; Apply(); }
        }

        public bool Owned => EzAdminItems.Has(_item);

        private void OnValidate()
        {
            Upgrade();
        }

        private void Upgrade()
        {
            if (_ownedText == "{name}\n{status}" || _ownedText == "{name}" || string.IsNullOrEmpty(_ownedText))
                _ownedText = "{owned}: {name}";
            if (_notOwnedText == "{name}" || string.IsNullOrEmpty(_notOwnedText))
                _notOwnedText = "{buy}: {name}\n{price} {currency}";
            if (_ownedWord == "Purchased") _ownedWord = "Owned";
            if (string.IsNullOrEmpty(_ownedLabel)) _ownedLabel = "Owned";
            if (string.IsNullOrEmpty(_buyWord)) _buyWord = "Purchase";
        }

        private GameObject[] Labels()
        {
            if (_labels != null && _labels.Length > 0) return _labels;
            if (!_findLabel) return _labels;
            if (_foundLabel == null) _foundLabel = FindText();
            return _foundLabel == null ? _labels : new[] { _foundLabel };
        }

        private GameObject _foundLabel;

        private GameObject FindText()
        {
            foreach (var t in GetComponentsInChildren<Component>(true))
            {
                if (t == null || t is Transform) continue;
                if (t is UnityEngine.UI.Text || TextProperty(t.GetType()) != null) return t.gameObject;
            }
            return null;
        }

        private void OnEnable()
        {
            Upgrade();
            EzAdminItems.Changed += Apply;
            if (_hideUntilLoaded && !EzAdminItems.Loaded)
            {
                Set(_showWhenOwned, false);
            }
            EzAdminItems.Start();
            if (_canBuy)
            {
                EnsureTouch();
                EnsurePrices();
                if (_buyOnClick) EnsureClicking();
            }
            Apply();
        }

        private void EnsureTouch()
        {
            if (!_buyOnTouch) return;
            var col = GetComponent<Collider>();
            if (col == null)
            {
                Debug.LogWarning("EzAdminItemUnlock on '" + name + "' has Buy On Touch set but no Collider. "
                                 + "Add one and tick Is Trigger.", this);
                return;
            }
            if (!col.isTrigger)
                Debug.LogWarning("EzAdminItemUnlock on '" + name + "': tick Is Trigger on the Collider so a hand "
                                 + "can touch through it.", this);
            if (GetComponent<Rigidbody>() == null)
            {
                var body = gameObject.AddComponent<Rigidbody>();
                body.isKinematic = true;
                body.useGravity = false;
            }
        }

        private void OnDisable()
        {
            EzAdminItems.Changed -= Apply;
        }

        private void Apply()
        {
            if (this == null) return;
            if (_canBuy) EnsurePrices();
            bool loaded = EzAdminItems.Loaded;
            bool owned = loaded && EzAdminItems.Has(_item);
            if (loaded)
            {
                Set(_showWhenOwned, owned);
                Set(_hideWhenOwned, !owned);
            }
            WriteLabels(owned, loaded);
            if (!loaded) return;
            int now = owned ? 1 : 0;
            if (now == _last) return;
            _last = now;
            if (owned) _onOwned.Invoke(); else _onNotOwned.Invoke();
        }

        public void Buy()
        {
            if (_busy || !_canBuy) return;
            if (EzAdminItems.Loaded && EzAdminItems.Has(_item)) return;
            if (!PlayFabClientAPI.IsClientLoggedIn())
            {
                Fail("Not signed in yet. Try again in a moment.");
                return;
            }
            string itemId, currency;
            int price;
            if (!Resolve(out itemId, out currency, out price))
            {
                EnsurePrices();
                Fail("Still loading the price. Try again in a moment.");
                return;
            }
            _busy = true;
            PlayFabClientAPI.PurchaseItem(new PurchaseItemRequest
            {
                CatalogVersion = string.IsNullOrEmpty(Version) ? null : Version,
                ItemId = itemId,
                VirtualCurrency = currency,
                Price = price
            }, result =>
            {
                if (this == null) return;
                _busy = false;
                var client = EzAdminClient.Instance;
                if (client != null && result.Items != null && result.Items.Count > 0
                    && !string.IsNullOrEmpty(result.Items[0].ItemInstanceId))
                    client.ReportPurchase(result.Items[0].ItemInstanceId);
                _onPurchased.Invoke();
                EzAdminItems.Refresh();
            }, error =>
            {
                if (this == null) return;
                _busy = false;
                Fail(Friendly(error));
            });
        }

        private void Fail(string message)
        {
            _onFailed.Invoke(message);
        }

        private static string Friendly(PlayFabError error)
        {
            if (error == null) return "That did not work.";
            switch (error.Error)
            {
                case PlayFabErrorCode.InsufficientFunds: return "Not enough currency for that.";
                case PlayFabErrorCode.ItemNotFound:
                case PlayFabErrorCode.CatalogItemIdInvalid: return "That item is not in your PlayFab catalog.";
                case PlayFabErrorCode.NotAuthenticated: return "Not signed in yet. Try again in a moment.";
                default: return string.IsNullOrEmpty(error.ErrorMessage) ? "That did not work." : error.ErrorMessage;
            }
        }

        private void OnTriggerEnter(Collider other)
        {
            if (!_canBuy || !_buyOnTouch || other == null) return;
            if (!string.IsNullOrEmpty(_buyTag) && !other.CompareTag(_buyTag)) return;
            TryBuy();
        }

        public void OnPointerClick(PointerEventData eventData)
        {
            if (_canBuy && _buyOnClick) TryBuy();
        }

        private void OnMouseUpAsButton()
        {
            if (_canBuy && _buyOnClick) TryBuy();
        }

        private void TryBuy()
        {
            if (Time.unscaledTime < _nextBuy) return;
            _nextBuy = Time.unscaledTime + _rearmSeconds;
            Buy();
        }

        private void EnsureClicking()
        {
            var graphic = GetComponent<UnityEngine.UI.Graphic>();
            if (graphic == null)
            {
                var cam = Camera.main;
                if (cam != null && cam.GetComponent<PhysicsRaycaster>() == null
                    && cam.GetComponent<Physics2DRaycaster>() == null)
                    cam.gameObject.AddComponent<PhysicsRaycaster>();
            }
            if (EventSystem.current != null || FindObjectOfType<EventSystem>() != null) return;
            var go = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER
            var module = System.Type.GetType("UnityEngine.InputSystem.UI.InputSystemUIInputModule, Unity.InputSystem");
            if (module != null) go.AddComponent(module);
#else
            go.AddComponent<StandaloneInputModule>();
#endif
        }

        private void WriteLabels(bool owned, bool loaded)
        {
            var labels = Labels();
            if (labels == null || labels.Length == 0) return;
            string template = !loaded ? _loadingText : owned ? _ownedText : _notOwnedText;
            string text = Fill(template, owned).Trim();
            foreach (var go in labels)
                if (go != null) SetText(go, text);
        }

        private string Fill(string template, bool owned)
        {
            if (template == null) return "";
            string currency = _currency;
            string priceText = "";
            string resolvedId, foundCurrency;
            int price;
            if (Resolve(out resolvedId, out foundCurrency, out price))
            {
                currency = foundCurrency;
                priceText = price.ToString();
            }
            if (string.IsNullOrEmpty(priceText))
                template = template.Replace("{price} {currency}", "").Replace("{price}", "");
            return template
                .Replace("{owned}", _ownedLabel ?? "")
                .Replace("{buy}", _buyWord ?? "")
                .Replace("{name}", DisplayName)
                .Replace("{item}", _item)
                .Replace("{status}", owned ? _ownedWord : _notOwnedWord)
                .Replace("{price}", priceText)
                .Replace("{currency}", currency ?? "");
        }

        private sealed class Entry
        {
            public string itemId;
            public Dictionary<string, int> prices;
        }

        private static readonly Dictionary<string, Dictionary<string, Entry>> _catalog =
            new Dictionary<string, Dictionary<string, Entry>>();
        private static readonly Dictionary<string, List<System.Action>> _waiting =
            new Dictionary<string, List<System.Action>>();

        private static string Version => EzAdminClient.CatalogVersion ?? "";
        private string Key => Version;

        private void EnsurePrices()
        {
            string key = Key;
            if (_catalog.ContainsKey(key)) return;
            if (!PlayFabClientAPI.IsClientLoggedIn()) return;
            List<System.Action> list;
            if (_waiting.TryGetValue(key, out list))
            {
                list.Add(ApplyIfAlive);
                return;
            }
            _waiting[key] = new List<System.Action> { ApplyIfAlive };
            PlayFabClientAPI.GetCatalogItems(new GetCatalogItemsRequest
            {
                CatalogVersion = string.IsNullOrEmpty(Version) ? null : Version
            }, result =>
            {
                var map = new Dictionary<string, Entry>(System.StringComparer.OrdinalIgnoreCase);
                if (result.Catalog != null)
                    foreach (var ci in result.Catalog)
                    {
                        if (string.IsNullOrEmpty(ci.ItemId)) continue;
                        var prices = new Dictionary<string, int>();
                        if (ci.VirtualCurrencyPrices != null)
                            foreach (var p in ci.VirtualCurrencyPrices) prices[p.Key] = (int)p.Value;
                        var entry = new Entry { itemId = ci.ItemId, prices = prices };
                        map[ci.ItemId] = entry;
                        if (!string.IsNullOrEmpty(ci.DisplayName)) map[ci.DisplayName] = entry;
                    }
                _catalog[key] = map;
                Flush(key);
            }, error =>
            {
                _catalog[key] = new Dictionary<string, Entry>();
                Flush(key);
            });
        }

        private static void Flush(string key)
        {
            List<System.Action> list;
            if (!_waiting.TryGetValue(key, out list)) return;
            _waiting.Remove(key);
            foreach (var cb in list) cb();
        }

        private void ApplyIfAlive()
        {
            if (this != null) Apply();
        }

        private bool Resolve(out string itemId, out string currency, out int price)
        {
            itemId = _item;
            currency = _currency;
            price = 0;
            Dictionary<string, Entry> map;
            if (!_catalog.TryGetValue(Key, out map)) return false;
            Entry entry;
            if (string.IsNullOrEmpty(_item) || !map.TryGetValue(_item, out entry)) return false;
            itemId = entry.itemId;
            if (entry.prices == null || entry.prices.Count == 0) return false;
            int found;
            if (!string.IsNullOrEmpty(_currency) && entry.prices.TryGetValue(_currency, out found))
            {
                price = found;
                return true;
            }
            foreach (var pair in entry.prices)
            {
                if (pair.Key == "RM") continue;
                currency = pair.Key;
                price = pair.Value;
                return true;
            }
            return false;
        }

        private static void SetText(GameObject go, string value)
        {
            var legacy = go.GetComponent<UnityEngine.UI.Text>();
            if (legacy != null)
            {
                if (legacy.text != value) legacy.text = value;
                return;
            }
            foreach (var component in go.GetComponents<Component>())
            {
                if (component == null || component is Transform) continue;
                var prop = TextProperty(component.GetType());
                if (prop == null) continue;
                if ((prop.GetValue(component) as string) != value) prop.SetValue(component, value);
                return;
            }
        }

        private static PropertyInfo TextProperty(System.Type type)
        {
            PropertyInfo cached;
            if (_textProps.TryGetValue(type, out cached)) return cached;
            PropertyInfo found = null;
            var prop = type.GetProperty("text", BindingFlags.Public | BindingFlags.Instance,
                                        null, typeof(string), System.Type.EmptyTypes, null);
            if (prop != null && prop.CanRead && prop.CanWrite) found = prop;
            _textProps[type] = found;
            return found;
        }

        private void Set(GameObject[] objects, bool active)
        {
            foreach (var go in objects)
            {
                if (go != null && go != gameObject && go.activeSelf != active) go.SetActive(active);
            }
        }
    }
}
EzAdminStore.csAssets/EzAdmin/Runtime. The in game shop: sells PlayFab catalog items by their Item Id for your virtual currency, on PC, consoles, phones and VR. Add it with GameObject › EzAdmin › Shop.31.8 KB · 740 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using System.Collections.Generic;
using PlayFab;
using PlayFab.ClientModels;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminStore : MonoBehaviour
    {
        public enum ScreenMode { Automatic, Flat, WorldSpaceVR }
        public enum Corner { BottomRight, BottomLeft, TopRight, TopLeft }

        [Serializable]
        public sealed class StoreItem
        {
            [Tooltip("The PlayFab catalog Item Id. This is what the player buys and what EzAdmin grants.")]
            public string itemId = "";
            [Tooltip("Shown to the player. Leave empty to use the Item Id.")]
            public string title = "";
            [TextArea(0, 3)]
            [Tooltip("A short line under the name. Optional.")]
            public string description = "";
            [Tooltip("Optional picture for this item.")]
            public Sprite icon;
            [Tooltip("How much it costs in the virtual currency below.")]
            public int price = 10;
            [Tooltip("PlayFab virtual currency code, for example GC. Matches the catalog price.")]
            public string currency = "GC";
            [Tooltip("Hide this item once the player owns it. Good for one-time unlocks.")]
            public bool hideWhenOwned = false;
        }

        [Serializable] public class StringEvent : UnityEvent<string> { }

        [EzAdminGroup("What is for sale")]
        [Tooltip("Each row is one PlayFab catalog item, bought by its Item Id.")]
        [SerializeField] private StoreItem[] _items = new StoreItem[0];

        [EzAdminGroup("Where it shows")]
        [Tooltip("Automatic: a board in the world on a VR headset, a panel on screen otherwise.")]
        [SerializeField] private ScreenMode _mode = ScreenMode.Automatic;
        [Tooltip("Off: build no UI. Call Open(), Close() or Buy(itemId) from your own buttons.")]
        [SerializeField] private bool _buildUI = true;
        [Tooltip("Start with the shop open.")]
        [SerializeField] private bool _openOnStart = false;
        [SerializeField] private Corner _corner = Corner.BottomRight;

        [EzAdminGroup("VR board")]
        [Tooltip("Width of the board in metres.")]
        [SerializeField, Range(0.4f, 2.5f)] private float _boardWidthMetres = 0.9f;
        [Tooltip("Layers that count as a hand pressing the buttons.")]
        [SerializeField] private LayerMask _handLayers = ~0;

        [EzAdminGroup("Words")]
        [SerializeField] private string _title = "Shop";
        [Tooltip("How the balance reads. {0} is the amount, {1} is the currency code.")]
        [SerializeField] private string _balanceFormat = "{0} {1}";
        [SerializeField] private string _buyLabel = "Purchase";
        [SerializeField] private string _ownedLabel = "Owned";
        [Tooltip("The item name line before buying. {name}, {price} and {currency} are filled in.")]
        [SerializeField] private string _notOwnedName = "Purchase: {name}";
        [Tooltip("The item name line once they own it. {name} is filled in.")]
        [SerializeField] private string _ownedName = "Owned: {name}";
        [Tooltip("The button when they cannot afford it. {price} and {currency} are filled in.")]
        [SerializeField] private string _cannotAffordLabel = "Need {price}";
        [SerializeField] private string _openLabel = "Shop";

        [EzAdminGroup("Look")]
        [SerializeField] private Color _accent = new Color32(0x22, 0xC5, 0x5E, 0xFF);
        [SerializeField] private Color _panel = new Color32(0x12, 0x15, 0x1C, 0xF7);
        [Tooltip("Leave empty for Unity's built-in font.")]
        [SerializeField] private Font _font;

        [EzAdminGroup("Events - hook your own code here")]
        [Tooltip("An item was bought. The Item Id is passed.")]
        [SerializeField] private StringEvent _onPurchased = new StringEvent();
        [Tooltip("A purchase did not go through. The reason is passed.")]
        [SerializeField] private StringEvent _onFailed = new StringEvent();

        public UnityEvent<string> OnPurchased => _onPurchased;
        public UnityEvent<string> OnFailed => _onFailed;

        private GameObject _root;
        private GameObject _window;
        private GameObject _listHolder;
        private Text _balanceText;
        private Text _status;
        private readonly Dictionary<string, int> _balances = new Dictionary<string, int>();
        private readonly HashSet<string> _owned = new HashSet<string>();
        private readonly Dictionary<string, Row> _rows = new Dictionary<string, Row>();
        private bool _busy;
        private bool _open;
        private float _nextRefresh;

        private sealed class Row
        {
            public GameObject go;
            public GameObject buy;
            public Text buyLabel;
            public Image buyImage;
            public Text price;
            public Text name;
        }

        public bool IsWorldSpace
        {
            get
            {
                if (_mode == ScreenMode.WorldSpaceVR) return true;
                if (_mode == ScreenMode.Flat) return false;
                return UnityEngine.XR.XRSettings.isDeviceActive;
            }
        }

        private void OnEnable()
        {
            EzAdminClient.ItemsChanged += Refresh;
        }

        private void OnDisable()
        {
            EzAdminClient.ItemsChanged -= Refresh;
        }

        private void Start()
        {
            if (_font == null) _font = LoadFont();
            if (_buildUI)
            {
                if (!IsWorldSpace) EnsureEventSystem();
                Build();
                SetOpen(_openOnStart);
            }
            Refresh();
        }

        private void Update()
        {
            if (_open && Time.unscaledTime >= _nextRefresh)
            {
                _nextRefresh = Time.unscaledTime + 10f;
                Refresh();
            }
        }

        public void Open() { SetOpen(true); Refresh(); }
        public void Close() { SetOpen(false); }
        public void Toggle() { SetOpen(!_open); if (_open) Refresh(); }

        private void SetOpen(bool open)
        {
            _open = open;
            if (_window != null) _window.SetActive(open);
            if (open) _nextRefresh = Time.unscaledTime + 10f;
        }

        public void Refresh()
        {
            if (!PlayFabClientAPI.IsClientLoggedIn()) return;
            PlayFabClientAPI.GetUserInventory(new GetUserInventoryRequest(), result =>
            {
                if (this == null) return;
                _balances.Clear();
                if (result.VirtualCurrency != null)
                    foreach (var pair in result.VirtualCurrency) _balances[pair.Key] = pair.Value;
                _owned.Clear();
                if (result.Inventory != null)
                    foreach (var item in result.Inventory)
                        if (!string.IsNullOrEmpty(item.ItemId)) _owned.Add(item.ItemId);
                PaintAll();
            }, error =>
            {
                if (this != null) SetStatus(Friendly(error));
            });
        }

        public void Buy(string itemId)
        {
            StoreItem item = Find(itemId);
            if (item == null)
            {
                Fail("That item is not in this shop.");
                return;
            }
            Purchase(item);
        }

        private StoreItem Find(string itemId)
        {
            foreach (var item in _items)
                if (item != null && item.itemId == itemId) return item;
            return null;
        }

        private void Purchase(StoreItem item)
        {
            if (_busy) return;
            if (string.IsNullOrEmpty(item.itemId))
            {
                Fail("This item has no Item Id set.");
                return;
            }
            if (!PlayFabClientAPI.IsClientLoggedIn())
            {
                Fail("Not signed in yet. Try again in a moment.");
                return;
            }
            if (Balance(item.currency) < item.price)
            {
                Fail("Not enough " + item.currency + ".");
                return;
            }
            _busy = true;
            SetStatus("Buying " + Name(item) + "…");
            MarkRowBusy(item.itemId, true);
            PlayFabClientAPI.PurchaseItem(new PurchaseItemRequest
            {
                CatalogVersion = string.IsNullOrEmpty(EzAdminClient.CatalogVersion) ? null : EzAdminClient.CatalogVersion,
                ItemId = item.itemId,
                VirtualCurrency = item.currency,
                Price = item.price
            }, result =>
            {
                if (this == null) return;
                _busy = false;
                _owned.Add(item.itemId);
                if (_balances.ContainsKey(item.currency))
                    _balances[item.currency] = Mathf.Max(0, _balances[item.currency] - item.price);
                string instance = "";
                if (result.Items != null && result.Items.Count > 0) instance = result.Items[0].ItemInstanceId ?? "";
                var client = EzAdminClient.Instance;
                if (client != null && !string.IsNullOrEmpty(instance)) client.ReportPurchase(instance);
                SetStatus(Name(item) + " is yours.");
                _onPurchased.Invoke(item.itemId);
                PaintAll();
                Invoke(nameof(Refresh), 1.5f);
            }, error =>
            {
                if (this == null) return;
                _busy = false;
                MarkRowBusy(item.itemId, false);
                Fail(Friendly(error));
            });
        }

        private int Balance(string currency)
        {
            int value;
            return _balances.TryGetValue(currency ?? "", out value) ? value : 0;
        }

        private void Fail(string message)
        {
            SetStatus(message);
            _onFailed.Invoke(message);
        }

        private static string Friendly(PlayFabError error)
        {
            if (error == null) return "That did not work.";
            switch (error.Error)
            {
                case PlayFabErrorCode.InsufficientFunds: return "Not enough currency for that.";
                case PlayFabErrorCode.ItemNotFound:
                case PlayFabErrorCode.CatalogItemIdInvalid: return "That item is not in your PlayFab catalog.";
                case PlayFabErrorCode.NotAuthenticated: return "Not signed in yet. Try again in a moment.";
                default: return string.IsNullOrEmpty(error.ErrorMessage) ? "That did not work." : error.ErrorMessage;
            }
        }

        private string Name(StoreItem item)
        {
            return string.IsNullOrEmpty(item.title) ? item.itemId : item.title;
        }

        private void Build()
        {
            if (IsWorldSpace) BuildBoard(); else BuildFlat();
            PaintAll();
        }

        private void BuildFlat()
        {
            _root = new GameObject("EzAdmin Shop", typeof(RectTransform));
            _root.transform.SetParent(transform, false);
            var canvas = _root.AddComponent<Canvas>();
            canvas.renderMode = RenderMode.ScreenSpaceOverlay;
            canvas.sortingOrder = 520;
            var scaler = _root.AddComponent<CanvasScaler>();
            scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
            scaler.referenceResolution = new Vector2(1600, 900);
            scaler.matchWidthOrHeight = 0.5f;
            _root.AddComponent<GraphicRaycaster>();

            Vector2 anchor = _corner == Corner.BottomLeft ? new Vector2(0, 0)
                           : _corner == Corner.BottomRight ? new Vector2(1, 0)
                           : _corner == Corner.TopLeft ? new Vector2(0, 1) : new Vector2(1, 1);
            Vector2 inward = new Vector2(anchor.x == 0 ? 1 : -1, anchor.y == 0 ? 1 : -1);

            var opener = MakeButton(_root.transform, _openLabel, _accent, 22, Toggle, false);
            var ort = (RectTransform)opener.transform;
            ort.anchorMin = ort.anchorMax = ort.pivot = anchor;
            ort.anchoredPosition = new Vector2(24 * inward.x, 24 * inward.y);
            ort.sizeDelta = new Vector2(180, 56);

            _window = MakeRounded(_root.transform, _panel, 22);
            _window.name = "Window";
            var w = (RectTransform)_window.transform;
            w.anchorMin = w.anchorMax = w.pivot = anchor;
            w.anchoredPosition = new Vector2(24 * inward.x, (24 + 68) * inward.y);
            w.sizeDelta = new Vector2(420, 540);
            var shadow = _window.AddComponent<Shadow>();
            shadow.effectColor = new Color(0, 0, 0, 0.5f);
            shadow.effectDistance = new Vector2(0, -5);

            var column = _window.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset(18, 18, 16, 16);
            column.spacing = 12;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = true;
            column.childForceExpandHeight = false;

            BuildHeader(_window.transform, 40, 24, true);

            var scroll = new GameObject("Scroll", typeof(RectTransform), typeof(Image), typeof(ScrollRect), typeof(Mask));
            scroll.transform.SetParent(_window.transform, false);
            scroll.GetComponent<Image>().color = new Color(0, 0, 0, 0.001f);
            var mask = scroll.GetComponent<Mask>();
            mask.showMaskGraphic = false;
            var rect = scroll.GetComponent<ScrollRect>();
            rect.horizontal = false;
            rect.movementType = ScrollRect.MovementType.Clamped;
            rect.scrollSensitivity = 24;
            var scrollSize = scroll.AddComponent<LayoutElement>();
            scrollSize.flexibleHeight = 1;

            _listHolder = new GameObject("Items", typeof(RectTransform));
            _listHolder.transform.SetParent(scroll.transform, false);
            var holderRect = (RectTransform)_listHolder.transform;
            holderRect.anchorMin = new Vector2(0, 1);
            holderRect.anchorMax = new Vector2(1, 1);
            holderRect.pivot = new Vector2(0.5f, 1);
            var holderColumn = _listHolder.AddComponent<VerticalLayoutGroup>();
            holderColumn.spacing = 10;
            holderColumn.childControlWidth = holderColumn.childControlHeight = true;
            holderColumn.childForceExpandWidth = true;
            holderColumn.childForceExpandHeight = false;
            _listHolder.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
            rect.content = holderRect;
            rect.viewport = (RectTransform)scroll.transform;

            _status = MakeText(_window.transform, "", 15, FontStyle.Normal, new Color(1, 1, 1, 0.8f), TextAnchor.UpperLeft);
            _status.gameObject.SetActive(false);

            BuildRows(96);
        }

        private void BuildBoard()
        {
            const float width = 760f;
            _root = new GameObject("EzAdmin Shop Board", typeof(RectTransform));
            _root.transform.SetParent(transform, false);
            var canvas = _root.AddComponent<Canvas>();
            canvas.renderMode = RenderMode.WorldSpace;
            canvas.worldCamera = Camera.main;
            _root.AddComponent<CanvasScaler>().dynamicPixelsPerUnit = 4f;
            _root.AddComponent<GraphicRaycaster>();
            var rt = (RectTransform)_root.transform;
            rt.sizeDelta = new Vector2(width, 900f);
            rt.localScale = Vector3.one * (_boardWidthMetres / width);
            var body = _root.AddComponent<Rigidbody>();
            body.isKinematic = true;
            body.useGravity = false;

            _window = MakeRounded(_root.transform, _panel, 28);
            _window.name = "Board";
            var w = (RectTransform)_window.transform;
            w.anchorMin = w.anchorMax = w.pivot = new Vector2(0.5f, 0.5f);
            w.sizeDelta = new Vector2(width, 0);
            var column = _window.AddComponent<VerticalLayoutGroup>();
            column.padding = new RectOffset(40, 40, 36, 36);
            column.spacing = 20;
            column.childControlWidth = column.childControlHeight = true;
            column.childForceExpandWidth = true;
            column.childForceExpandHeight = false;
            _window.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;

            BuildHeader(_window.transform, 64, 40, false);

            _listHolder = new GameObject("Items", typeof(RectTransform));
            _listHolder.transform.SetParent(_window.transform, false);
            var holderColumn = _listHolder.AddComponent<VerticalLayoutGroup>();
            holderColumn.spacing = 16;
            holderColumn.childControlWidth = holderColumn.childControlHeight = true;
            holderColumn.childForceExpandWidth = true;
            holderColumn.childForceExpandHeight = false;

            _status = MakeText(_window.transform, "", 24, FontStyle.Normal, new Color(1, 1, 1, 0.85f), TextAnchor.UpperCenter);
            _status.gameObject.SetActive(false);

            BuildRows(160);
        }

        private void BuildHeader(Transform parent, float logoSize, int titleSize, bool flat)
        {
            var header = new GameObject("Header", typeof(RectTransform));
            header.transform.SetParent(parent, false);
            var row = header.AddComponent<HorizontalLayoutGroup>();
            row.spacing = 12;
            row.childAlignment = TextAnchor.MiddleLeft;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = row.childForceExpandHeight = false;
            Sized(header, -1, logoSize);

            var logo = new GameObject("EzAdmin logo", typeof(RectTransform), typeof(Image));
            logo.transform.SetParent(header.transform, false);
            logo.GetComponent<Image>().sprite = EzAdminBrand.LogoSprite(96);
            logo.GetComponent<Image>().raycastTarget = false;
            Sized(logo, logoSize, logoSize);

            var name = MakeText(header.transform, _title, titleSize, FontStyle.Bold, Color.white, TextAnchor.MiddleLeft);
            name.horizontalOverflow = HorizontalWrapMode.Overflow;
            Sized(name.gameObject, -1, logoSize, flexible: true);

            var pill = MakeRounded(header.transform, new Color(1, 1, 1, 0.08f), 14);
            pill.name = "Balance";
            var pad = pill.AddComponent<HorizontalLayoutGroup>();
            pad.padding = new RectOffset(14, 14, 6, 6);
            pad.childAlignment = TextAnchor.MiddleCenter;
            pad.childControlWidth = pad.childControlHeight = true;
            _balanceText = MakeText(pill.transform, "", flat ? 16 : 26, FontStyle.Bold, _accent, TextAnchor.MiddleRight);
            _balanceText.horizontalOverflow = HorizontalWrapMode.Overflow;
            pill.AddComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;

            if (flat)
            {
                var close = MakeButton(header.transform, "X", new Color(1, 1, 1, 0.08f), 18, Close, false);
                Sized(close, 36, 36);
            }
        }

        private void BuildRows(float rowHeight)
        {
            _rows.Clear();
            foreach (var item in _items)
            {
                if (item == null || string.IsNullOrEmpty(item.itemId)) continue;
                _rows[item.itemId] = BuildRow(item, rowHeight);
            }
        }

        private Row BuildRow(StoreItem item, float rowHeight)
        {
            bool flat = !IsWorldSpace;
            var card = MakeRounded(_listHolder.transform, new Color(1, 1, 1, 0.05f), 16);
            card.name = item.itemId;
            Sized(card, -1, rowHeight);
            var row = card.AddComponent<HorizontalLayoutGroup>();
            row.padding = new RectOffset(flat ? 12 : 22, flat ? 12 : 22, flat ? 10 : 18, flat ? 10 : 18);
            row.spacing = flat ? 12 : 20;
            row.childAlignment = TextAnchor.MiddleLeft;
            row.childControlWidth = row.childControlHeight = true;
            row.childForceExpandWidth = row.childForceExpandHeight = false;

            if (item.icon != null)
            {
                var icon = new GameObject("Icon", typeof(RectTransform), typeof(Image));
                icon.transform.SetParent(card.transform, false);
                var image = icon.GetComponent<Image>();
                image.sprite = item.icon;
                image.preserveAspect = true;
                image.raycastTarget = false;
                float s = flat ? 56 : 110;
                Sized(icon, s, s);
            }

            var textCol = new GameObject("Text", typeof(RectTransform));
            textCol.transform.SetParent(card.transform, false);
            var col = textCol.AddComponent<VerticalLayoutGroup>();
            col.spacing = 2;
            col.childControlWidth = col.childControlHeight = true;
            col.childForceExpandWidth = true;
            col.childForceExpandHeight = false;
            col.childAlignment = TextAnchor.MiddleLeft;
            Sized(textCol, -1, -1, flexible: true);

            var nameText = MakeText(textCol.transform, Name(item), flat ? 19 : 32, FontStyle.Bold, Color.white, TextAnchor.LowerLeft);
            if (!string.IsNullOrEmpty(item.description))
            {
                var desc = MakeText(textCol.transform, item.description, flat ? 14 : 24, FontStyle.Normal,
                                    new Color(1, 1, 1, 0.65f), TextAnchor.UpperLeft);
                desc.horizontalOverflow = HorizontalWrapMode.Wrap;
            }

            var result = new Row { go = card, name = nameText };
            result.buy = MakeButton(card.transform, _buyLabel, _accent, flat ? 17 : 28,
                                    () => Purchase(item), IsWorldSpace);
            Sized(result.buy, flat ? 118 : 220, flat ? rowHeight - 24 : 100);
            result.buyImage = result.buy.GetComponent<Image>();
            result.buyLabel = result.buy.GetComponentInChildren<Text>();
            result.price = MakeText(result.buy.transform, "", flat ? 12 : 20, FontStyle.Normal,
                                    new Color(1, 1, 1, 0.85f), TextAnchor.LowerCenter);
            var prt = (RectTransform)result.price.transform;
            prt.anchorMin = new Vector2(0, 0);
            prt.anchorMax = new Vector2(1, 0.4f);
            prt.offsetMin = prt.offsetMax = Vector2.zero;
            return result;
        }

        private void PaintAll()
        {
            if (_balanceText != null)
            {
                string shown = "";
                foreach (var pair in _balances)
                {
                    if (shown.Length > 0) shown += "   ";
                    shown += string.Format(_balanceFormat, pair.Value, pair.Key);
                }
                _balanceText.text = shown;
            }
            foreach (var item in _items)
            {
                if (item == null || string.IsNullOrEmpty(item.itemId)) continue;
                Row row;
                if (!_rows.TryGetValue(item.itemId, out row) || row.go == null) continue;
                bool owned = _owned.Contains(item.itemId);
                bool hide = owned && item.hideWhenOwned;
                row.go.SetActive(!hide);
                if (hide) continue;
                bool canAfford = Balance(item.currency) >= item.price;
                if (row.name != null)
                    row.name.text = Words(owned ? _ownedName : _notOwnedName, item);
                if (owned)
                {
                    row.buyLabel.text = _ownedLabel;
                    row.buyImage.color = new Color(1, 1, 1, 0.12f);
                    SetRowButton(row, false);
                    if (row.price != null) row.price.text = "";
                }
                else
                {
                    row.buyLabel.text = canAfford ? _buyLabel : Words(_cannotAffordLabel, item);
                    row.buyImage.color = canAfford ? _accent : new Color(1, 1, 1, 0.12f);
                    SetRowButton(row, canAfford);
                    if (row.price != null) row.price.text = string.Format(_balanceFormat, item.price, item.currency);
                }
            }
        }

        private string Words(string format, StoreItem item)
        {
            if (string.IsNullOrEmpty(format)) return Name(item);
            return format.Replace("{name}", Name(item))
                         .Replace("{price}", item.price.ToString())
                         .Replace("{currency}", item.currency ?? "");
        }

        private void MarkRowBusy(string itemId, bool busy)
        {
            Row row;
            if (_rows.TryGetValue(itemId, out row) && row.go != null && row.buyLabel != null)
                if (busy) row.buyLabel.text = "…";
        }

        private static void SetRowButton(Row row, bool enabled)
        {
            var button = row.buy.GetComponent<Button>();
            if (button != null) button.interactable = enabled;
            var touch = row.buy.GetComponent<EzAdminTouchButton>();
            if (touch != null) touch.IsInteractable = enabled;
        }

        private void SetStatus(string message)
        {
            if (_status == null) return;
            _status.gameObject.SetActive(!string.IsNullOrEmpty(message));
            _status.text = message;
        }

        private GameObject MakeButton(Transform parent, string label, Color colour, int fontSize, Action onPress, bool touch)
        {
            var go = MakeRounded(parent, colour, 14);
            go.name = label;
            var button = go.AddComponent<Button>();
            var colours = button.colors;
            colours.normalColor = Color.white;
            colours.highlightedColor = new Color(0.9f, 0.95f, 0.92f, 1f);
            colours.pressedColor = new Color(0.75f, 0.8f, 0.77f, 1f);
            colours.disabledColor = new Color(0.6f, 0.6f, 0.6f, 1f);
            colours.fadeDuration = 0.08f;
            button.colors = colours;
            var text = MakeText(go.transform, label, fontSize, FontStyle.Bold, Color.white, TextAnchor.MiddleCenter);
            text.resizeTextForBestFit = true;
            text.resizeTextMinSize = Mathf.Max(10, fontSize / 2);
            text.resizeTextMaxSize = fontSize;
            Fill(text.transform, 10);
            if (touch)
            {
                var box = go.AddComponent<BoxCollider>();
                box.isTrigger = true;
                var press = go.AddComponent<EzAdminTouchButton>();
                press.TouchLayers = _handLayers;
                press.OnPressed.AddListener(() => onPress());
                button.onClick.AddListener(press.Press);
            }
            else
            {
                button.onClick.AddListener(() => onPress());
            }
            return go;
        }

        private static void Sized(GameObject go, float width, float height, bool flexible = false)
        {
            var element = go.GetComponent<LayoutElement>() ?? go.AddComponent<LayoutElement>();
            if (width >= 0) { element.preferredWidth = width; element.minWidth = width; }
            if (height >= 0) { element.preferredHeight = height; element.minHeight = height; }
            if (flexible) element.flexibleWidth = 1;
            var box = go.GetComponent<BoxCollider>();
            if (box != null && width >= 0 && height >= 0) box.size = new Vector3(width, height, 40f);
        }

        private GameObject MakeRounded(Transform parent, Color colour, float radius = 18f)
        {
            var go = new GameObject("Shape", typeof(RectTransform), typeof(Image));
            go.transform.SetParent(parent, false);
            var image = go.GetComponent<Image>();
            image.sprite = RoundedSprite();
            image.type = Image.Type.Sliced;
            image.pixelsPerUnitMultiplier = 22f / Mathf.Max(1f, radius);
            image.color = colour;
            return go;
        }

        private Text MakeText(Transform parent, string value, int size, FontStyle style, Color colour, TextAnchor align)
        {
            var go = new GameObject("Text", typeof(RectTransform), typeof(Text));
            go.transform.SetParent(parent, false);
            var text = go.GetComponent<Text>();
            text.font = _font;
            text.text = value;
            text.fontSize = size;
            text.fontStyle = style;
            text.color = colour;
            text.alignment = align;
            text.horizontalOverflow = HorizontalWrapMode.Wrap;
            text.verticalOverflow = VerticalWrapMode.Overflow;
            text.raycastTarget = false;
            return text;
        }

        private static void Fill(Transform target, float padding)
        {
            var rt = (RectTransform)target;
            rt.anchorMin = Vector2.zero;
            rt.anchorMax = Vector2.one;
            rt.offsetMin = new Vector2(padding, 0);
            rt.offsetMax = new Vector2(-padding, 0);
        }

        private static void EnsureEventSystem()
        {
            if (EventSystem.current != null || FindObjectOfType<EventSystem>() != null) return;
            var go = new GameObject("EventSystem", typeof(EventSystem));
#if ENABLE_INPUT_SYSTEM && !ENABLE_LEGACY_INPUT_MANAGER
            var module = Type.GetType("UnityEngine.InputSystem.UI.InputSystemUIInputModule, Unity.InputSystem");
            if (module != null) go.AddComponent(module);
#else
            go.AddComponent<StandaloneInputModule>();
#endif
        }

        private static Sprite _rounded;

        private static Sprite RoundedSprite()
        {
            if (_rounded != null) return _rounded;
            const int size = 64, radius = 22;
            var texture = new Texture2D(size, size, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.Clamp };
            var pixels = new Color32[size * size];
            for (int y = 0; y < size; y++)
            {
                for (int x = 0; x < size; x++)
                {
                    float cx = Mathf.Clamp(x + 0.5f, radius, size - radius);
                    float cy = Mathf.Clamp(y + 0.5f, radius, size - radius);
                    float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
                    byte a = (byte)(Mathf.Clamp01(radius - d + 0.5f) * 255);
                    pixels[y * size + x] = new Color32(255, 255, 255, a);
                }
            }
            texture.SetPixels32(pixels);
            texture.Apply();
            _rounded = Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f, 0,
                                     SpriteMeshType.FullRect, new Vector4(radius, radius, radius, radius));
            return _rounded;
        }

        private static Font LoadFont()
        {
            string[] parts = Application.unityVersion.Split('.');
            int major = 0, minor = 0;
            if (parts.Length > 1)
            {
                int.TryParse(parts[0], out major);
                int.TryParse(parts[1], out minor);
            }
            bool legacyRuntime = major > 2022 || (major == 2022 && minor >= 2);
            return Resources.GetBuiltinResource<Font>(legacyRuntime ? "LegacyRuntime.ttf" : "Arial.ttf");
        }

#if UNITY_EDITOR
        [UnityEditor.MenuItem("GameObject/EzAdmin/Shop", false, 11)]
        private static void CreateFromMenu()
        {
            if (FindObjectOfType<EzAdminClient>() == null)
            {
                var clientObject = new GameObject("EzAdmin", typeof(EzAdminClient));
                UnityEditor.Undo.RegisterCreatedObjectUndo(clientObject, "Add EzAdmin");
            }
            var go = new GameObject("EzAdmin Shop", typeof(EzAdminStore));
            if (UnityEditor.SceneView.lastActiveSceneView != null)
                go.transform.position = UnityEditor.SceneView.lastActiveSceneView.pivot;
            UnityEditor.Undo.RegisterCreatedObjectUndo(go, "Add EzAdmin Shop");
            UnityEditor.Selection.activeGameObject = go;
        }
#endif
    }
}
EzAdminProfiles.csAssets/EzAdmin/Runtime. Discord names, ids and pictures for a whole leaderboard at once, cached.4.9 KB · 148 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

namespace EzAdmin.Client
{

    public static class EzAdminProfiles
    {

        public static float CacheSeconds = 300f;

        private struct Cached
        {
            public EzAdminProfile Profile;
            public float At;
        }

        private static readonly Dictionary<string, Cached> Profiles = new Dictionary<string, Cached>();
        private static readonly Dictionary<string, Texture2D> Avatars = new Dictionary<string, Texture2D>();
        private static readonly Dictionary<string, List<Action<Texture2D>>> Loading =
            new Dictionary<string, List<Action<Texture2D>>>();

        public static void Get(string playFabId, Action<EzAdminProfile> onComplete)
        {
            Get(new[] { playFabId }, all => onComplete?.Invoke(all[playFabId]));
        }

        public static void Get(IList<string> playFabIds, Action<Dictionary<string, EzAdminProfile>> onComplete)
        {
            var result = new Dictionary<string, EzAdminProfile>();
            var missing = new List<string>();
            float now = Time.realtimeSinceStartup;
            foreach (var id in playFabIds)
            {
                if (string.IsNullOrEmpty(id) || result.ContainsKey(id)) continue;
                Cached hit;
                if (Profiles.TryGetValue(id, out hit) && now - hit.At < CacheSeconds)
                {
                    result[id] = hit.Profile;
                }
                else
                {
                    missing.Add(id);
                }
            }
            var client = EzAdminClient.Instance;
            if (missing.Count == 0 || client == null)
            {
                foreach (var id in missing) result[id] = EzAdminProfile.Unavailable;
                onComplete?.Invoke(result);
                return;
            }
            client.ViewProfiles(missing, fetched =>
            {
                float at = Time.realtimeSinceStartup;
                foreach (var pair in fetched)
                {
                    result[pair.Key] = pair.Value;
                    if (!pair.Value.Unreachable)
                    {
                        Profiles[pair.Key] = new Cached { Profile = pair.Value, At = at };
                    }
                }
                onComplete?.Invoke(result);
            });
        }

        public static void LoadAvatar(string avatarUrl, Action<Texture2D> onLoaded, int size = 128)
        {
            if (string.IsNullOrEmpty(avatarUrl) || onLoaded == null)
            {
                onLoaded?.Invoke(null);
                return;
            }
            string url = WithSize(avatarUrl, size);
            Texture2D texture;
            if (Avatars.TryGetValue(url, out texture) && texture != null)
            {
                onLoaded(texture);
                return;
            }
            List<Action<Texture2D>> waiting;
            if (Loading.TryGetValue(url, out waiting))
            {
                waiting.Add(onLoaded);
                return;
            }
            var client = EzAdminClient.Instance;
            if (client == null)
            {
                onLoaded(null);
                return;
            }
            Loading[url] = new List<Action<Texture2D>> { onLoaded };
            client.StartCoroutine(Download(url));
        }

        public static void ClearCache()
        {
            Profiles.Clear();
        }

        private static IEnumerator Download(string url)
        {
            Texture2D texture = null;
            using (var request = UnityWebRequestTexture.GetTexture(url))
            {
                request.timeout = 20;
                yield return request.SendWebRequest();
                if (request.result == UnityWebRequest.Result.Success)
                {
                    texture = DownloadHandlerTexture.GetContent(request);
                    texture.name = "Discord avatar";
                    Avatars[url] = texture;
                }
            }
            List<Action<Texture2D>> waiting;
            if (Loading.TryGetValue(url, out waiting))
            {
                Loading.Remove(url);
                foreach (var callback in waiting) callback?.Invoke(texture);
            }
        }

        private static string WithSize(string url, int size)
        {

            int clamped = Mathf.ClosestPowerOfTwo(Mathf.Clamp(size, 16, 1024));
            int query = url.IndexOf('?');
            string bare = query >= 0 ? url.Substring(0, query) : url;
            return bare + "?size=" + clamped;
        }

        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
        private static void ResetOnPlay()
        {
            Profiles.Clear();
            Avatars.Clear();
            Loading.Clear();
        }
    }
}
EzAdminProfileView.csAssets/EzAdmin/Runtime. Put it on a leaderboard row: Show(playFabId) fills in the Discord name, id and picture.3.3 KB · 92 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminProfileView : MonoBehaviour
    {
        [System.Serializable] public class StringEvent : UnityEvent<string> { }
        [System.Serializable] public class TextureEvent : UnityEvent<Texture> { }

        [Tooltip("Optional: show this player straight away.")]
        [SerializeField] private string _playFabId = "";

        [EzAdminGroup("Where it goes (all optional)")]
        [SerializeField] private RawImage _avatar;
        [SerializeField] private Text _name;
        [SerializeField] private Text _discordId;
        [Tooltip("Shown only when this player's Discord is visible, e.g. a small Discord logo.")]
        [SerializeField] private GameObject _discordBadge;

        [EzAdminGroup("Look")]
        [SerializeField, Range(16, 512)] private int _avatarSize = 64;
        [Tooltip("Shown instead of the Discord id when it is private.")]
        [SerializeField] private string _privateIdText = "";

        [EzAdminGroup("Events (for TextMeshPro or your own UI)")]
        [SerializeField] private StringEvent _onName = new StringEvent();
        [SerializeField] private StringEvent _onDiscordId = new StringEvent();
        [SerializeField] private TextureEvent _onAvatar = new TextureEvent();

        private string _showing = "";

        public EzAdminProfile Profile { get; private set; } = EzAdminProfile.Unavailable;

        private void Start()
        {
            if (!string.IsNullOrEmpty(_playFabId)) Show(_playFabId);
        }

        public void Show(string playFabId, string gameName = "")
        {
            _showing = playFabId ?? "";
            if (!string.IsNullOrEmpty(gameName)) SetName(gameName);
            if (_discordBadge != null) _discordBadge.SetActive(false);
            if (string.IsNullOrEmpty(_showing)) return;

            string asked = _showing;
            EzAdminProfiles.Get(asked, profile =>
            {
                if (this == null || asked != _showing) return;
                Profile = profile;
                if (!profile.Visible)
                {
                    SetId(_privateIdText);
                    return;
                }
                SetName(profile.Name);
                SetId(profile.DiscordIdText);
                if (_discordBadge != null) _discordBadge.SetActive(true);
                EzAdminProfiles.LoadAvatar(profile.AvatarUrl, texture =>
                {
                    if (this == null || asked != _showing || texture == null) return;
                    if (_avatar != null) _avatar.texture = texture;
                    _onAvatar.Invoke(texture);
                }, _avatarSize);
            });
        }

        public void OpenDiscordProfile()
        {
            if (Profile.Visible && !string.IsNullOrEmpty(Profile.ProfileUrl)) EzAdminWeb.Open(Profile.ProfileUrl);
        }

        private void SetName(string value)
        {
            if (_name != null) _name.text = value;
            _onName.Invoke(value);
        }

        private void SetId(string value)
        {
            if (_discordId != null) _discordId.text = value;
            _onDiscordId.Invoke(value);
        }
    }
}
EzAdminLinkPanel.csAssets/EzAdmin/Runtime. The link logic with no UI, for building your own screen.10.4 KB · 371 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using UnityEngine;
using UnityEngine.Events;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminLinkPanel : MonoBehaviour
    {
        [System.Serializable]
        public class StringEvent : UnityEvent<string> { }

        [System.Serializable]
        public class BoolEvent : UnityEvent<bool> { }

        [EzAdminGroup("Display — wire these to your text components")]
        [Tooltip("The code as typed so far, padded with placeholders. e.g. 'AB3 _ _ _'")]
        [SerializeField] private StringEvent _onCodeChanged = new StringEvent();

        [Tooltip("Status line shown under the code box.")]
        [SerializeField] private StringEvent _onStatusChanged = new StringEvent();

        [EzAdminGroup("State")]
        [Tooltip("True when the code is complete and Submit will actually do something. " +
                 "Wire to your submit button's IsInteractable.")]
        [SerializeField] private BoolEvent _onCanSubmitChanged = new BoolEvent();

        [Tooltip("True while a request is in flight. Wire to a spinner, or to the " +
                 "keypad's interactable state.")]
        [SerializeField] private BoolEvent _onBusyChanged = new BoolEvent();

        [EzAdminGroup("Outcome")]
        [SerializeField] private UnityEvent _onLinkSucceeded = new UnityEvent();
        [SerializeField] private UnityEvent _onLinkFailed = new UnityEvent();

        [EzAdminGroup("Look")]
        [Tooltip("Character shown for each digit not yet typed.")]
        [SerializeField] private string _placeholder = "_";

        [Tooltip("Put a space between characters so the code is readable at VR text " +
                 "sizes. The dash between the two groups is always shown.")]
        [SerializeField] private bool _spaceOutCode = true;

        [EzAdminGroup("Behaviour")]
        [Tooltip("Submit automatically as soon as the last character is entered, " +
                 "so the player never hunts for a Submit button.")]
        [SerializeField] private bool _autoSubmitWhenComplete = true;

        [Tooltip("Check whether this account is already linked when the panel opens.")]
        [SerializeField] private bool _checkStatusOnEnable = true;

        public enum LinkMethod
        {

            Automatic,

            CodeOnly,

            DiscordOnly,

            CodeAndDiscord
        }

        [EzAdminGroup("Linking method")]
        [Tooltip("Automatic: players in a VR headset type the code, everyone else can also sign in " +
                 "with Discord. Signing in with Discord needs EzAdminDiscord and the Discord Social SDK.")]
        [SerializeField] private LinkMethod _linkMethod = LinkMethod.Automatic;

        [Tooltip("True when a Sign in with Discord button should be shown. Wire to that button's GameObject.SetActive.")]
        [SerializeField] private BoolEvent _onDiscordOptionChanged = new BoolEvent();

        [Tooltip("True when the code box and keypad should be shown. Wire to their GameObject.SetActive.")]
        [SerializeField] private BoolEvent _onCodeOptionChanged = new BoolEvent();

        private readonly System.Text.StringBuilder _buffer = new System.Text.StringBuilder(8);
        private bool _isBusy;
        private bool _lastCanSubmit;

        public string CurrentCode
        {
            get { return _buffer.ToString(); }
        }

        public bool CanSubmit
        {
            get { return !_isBusy && EzAdminCode.IsComplete(_buffer.ToString()); }
        }

        public bool DiscordAvailable
        {
            get
            {
                if (_linkMethod == LinkMethod.CodeOnly || !DiscordReady())
                {
                    return false;
                }

                return _linkMethod != LinkMethod.Automatic || !UnityEngine.XR.XRSettings.isDeviceActive;
            }
        }

        public bool CodeAvailable
        {
            get { return _linkMethod != LinkMethod.DiscordOnly || !DiscordAvailable; }
        }

        private void OnEnable()
        {
            Clear();
            PushOptions();

            if (_checkStatusOnEnable)
            {
                CheckExistingLink();
            }
        }

        private void Start()
        {

            PushOptions();
        }

        public void LinkWithDiscord()
        {
            if (_isBusy)
            {
                return;
            }

            if (!DiscordAvailable)
            {
                SetStatus("Linking with Discord is not available here. Use the code from /link.");
                return;
            }

#if EZADMIN_DISCORD_SDK
            SetBusy(true);
            SetStatus("Opening Discord...");
            EzAdminDiscord.Instance.LinkWithDiscord((succeeded, message) =>
            {
                if (this == null || !isActiveAndEnabled)
                {
                    return;
                }

                SetBusy(false);
                SetStatus(message);

                if (succeeded)
                {
                    _onLinkSucceeded.Invoke();
                }
                else
                {
                    _onLinkFailed.Invoke();
                }
            });
#endif
        }

        private static bool DiscordReady()
        {
#if EZADMIN_DISCORD_SDK
            var client = EzAdminClient.Instance;
            return EzAdminDiscord.Instance != null && EzAdminDiscord.Instance.IsReady
                   && client != null && client.CanLinkWithDiscord;
#else
            return false;
#endif
        }

        private void PushOptions()
        {
            _onDiscordOptionChanged.Invoke(DiscordAvailable);
            _onCodeOptionChanged.Invoke(CodeAvailable);
        }

        public void AppendCharacter(string character)
        {
            if (_isBusy || string.IsNullOrEmpty(character))
            {
                return;
            }

            char c = char.ToUpperInvariant(character[0]);

            if (!EzAdminCode.IsAllowed(c))
            {
                return;
            }

            if (_buffer.Length >= EzAdminCode.Length)
            {
                return;
            }

            _buffer.Append(c);
            PushCode();

            if (_autoSubmitWhenComplete && _buffer.Length == EzAdminCode.Length)
            {
                Submit();
            }
        }

        public void Backspace()
        {
            if (_isBusy || _buffer.Length == 0)
            {
                return;
            }

            _buffer.Length = _buffer.Length - 1;
            PushCode();
        }

        public void Clear()
        {
            _buffer.Length = 0;
            SetStatus(CodeAvailable
                ? "Run /link in Discord to get your code."
                : "Sign in with Discord to link your account.");
            PushCode();
        }

        public void SetCode(string code)
        {
            if (_isBusy)
            {
                return;
            }

            string cleaned = EzAdminCode.Normalise(code);

            _buffer.Length = 0;
            if (!string.IsNullOrEmpty(cleaned))
            {
                _buffer.Append(cleaned);
            }

            PushCode();
        }

        public void Submit()
        {
            if (_isBusy)
            {
                return;
            }

            string code = _buffer.ToString();

            if (!EzAdminCode.IsComplete(code))
            {
                SetStatus("Enter all " + EzAdminCode.Length + " characters.");
                return;
            }

            var client = EzAdminClient.Instance;
            if (client == null)
            {
                SetStatus("EzAdmin is not set up in this scene.");
                Debug.LogError("EzAdminLinkPanel: no EzAdminClient in the scene. Add the " +
                               "EzAdminClient component to a bootstrap object.", this);
                return;
            }

            SetBusy(true);
            SetStatus("Checking your code...");

            EzAdminLink.Redeem(code, (succeeded, message) =>
            {

                if (this == null || !isActiveAndEnabled)
                {
                    return;
                }

                SetBusy(false);
                SetStatus(message);

                if (succeeded)
                {
                    _onLinkSucceeded.Invoke();
                }
                else
                {
                    _onLinkFailed.Invoke();

                }
            });
        }

        public void CheckExistingLink()
        {
            var client = EzAdminClient.Instance;
            if (client == null)
            {
                return;
            }

            EzAdminLink.Check((isLinked, discordName) =>
            {
                if (this == null || !isActiveAndEnabled)
                {
                    return;
                }

                if (!isLinked)
                {
                    return;
                }

                SetStatus(string.IsNullOrEmpty(discordName)
                    ? "This account is already linked to Discord."
                    : "Already linked to " + discordName + ".");
            });
        }

        private void PushCode()
        {
            _onCodeChanged.Invoke(BuildDisplayCode());

            bool canSubmit = CanSubmit;
            if (canSubmit != _lastCanSubmit)
            {
                _lastCanSubmit = canSubmit;
                _onCanSubmitChanged.Invoke(canSubmit);
            }
        }

        private string BuildDisplayCode()
        {
            string typed = _buffer.ToString();
            var display = new System.Text.StringBuilder(EzAdminCode.Length * 2);

            for (int i = 0; i < EzAdminCode.Length; i++)
            {
                if (i > 0 && i % EzAdminCode.GroupSize == 0)
                {

                    display.Append(_spaceOutCode ? " - " : "-");
                }
                else if (i > 0 && _spaceOutCode)
                {
                    display.Append(' ');
                }

                display.Append(i < typed.Length ? typed[i].ToString() : _placeholder);
            }

            return display.ToString();
        }

        private void SetBusy(bool busy)
        {
            _isBusy = busy;
            _onBusyChanged.Invoke(busy);
            PushCode();
        }

        private void SetStatus(string message)
        {
            _onStatusChanged.Invoke(message ?? string.Empty);
        }
    }
}
EzAdminKeyboardInput.csAssets/EzAdmin/Runtime. Typing on PC, the system keyboard on console, Quest and mobile.6.1 KB · 232 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using UnityEngine;
using UnityEngine.EventSystems;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    [RequireComponent(typeof(EzAdminLinkPanel))]
    public sealed class EzAdminKeyboardInput : MonoBehaviour
    {
        [Tooltip("Accept typing from a physical keyboard (PC, or a keyboard plugged into anything).")]
        [SerializeField] private bool _physicalKeyboard = true;

        [Tooltip("Allow Ctrl+V / Cmd+V to paste a code copied from Discord. Desktop only.")]
        [SerializeField] private bool _allowPaste = true;

        [Tooltip("Submit automatically when the system keyboard is confirmed.")]
        [SerializeField] private bool _submitFromSystemKeyboard = true;

        private EzAdminLinkPanel _panel;
        private TouchScreenKeyboard _systemKeyboard;

        public bool SystemKeyboardAvailable
        {
            get { return TouchScreenKeyboard.isSupported; }
        }

        private void Awake()
        {
            _panel = GetComponent<EzAdminLinkPanel>();
        }

#if ENABLE_INPUT_SYSTEM
        private void OnEnable()
        {
            if (Keyboard.current != null)
            {
                Keyboard.current.onTextInput += OnTextInput;
            }
        }

        private void OnDisable()
        {
            if (Keyboard.current != null)
            {
                Keyboard.current.onTextInput -= OnTextInput;
            }
            CloseSystemKeyboard();
        }

        private void OnTextInput(char c)
        {
            if (_physicalKeyboard && !ATextFieldHasFocus())
            {
                HandleCharacter(c);
            }
        }
#else
        private void OnDisable()
        {
            CloseSystemKeyboard();
        }
#endif

        public void OpenSystemKeyboard()
        {
            if (!TouchScreenKeyboard.isSupported || _systemKeyboard != null)
            {
                return;
            }

            _systemKeyboard = TouchScreenKeyboard.Open(
                _panel.CurrentCode,
                TouchScreenKeyboardType.ASCIICapable,
                false,
                false,
                false,
                false,
                "EzAdmin code",
                EzAdminCode.Length);
        }

        private void CloseSystemKeyboard()
        {
            if (_systemKeyboard != null)
            {
                _systemKeyboard.active = false;
                _systemKeyboard = null;
            }
        }

        private void Update()
        {
            PollSystemKeyboard();

            if (!_physicalKeyboard || ATextFieldHasFocus())
            {
                return;
            }

#if ENABLE_INPUT_SYSTEM
            Keyboard keyboard = Keyboard.current;
            if (keyboard == null)
            {
                return;
            }

            if (keyboard.backspaceKey.wasPressedThisFrame)
            {
                _panel.Backspace();
            }

            if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame)
            {
                _panel.Submit();
            }

            if (_allowPaste && keyboard.vKey.wasPressedThisFrame
                && (keyboard.ctrlKey.isPressed || keyboard.leftCommandKey.isPressed || keyboard.rightCommandKey.isPressed))
            {
                Paste();
            }
#elif ENABLE_LEGACY_INPUT_MANAGER

            string typed = Input.inputString;
            for (int i = 0; i < typed.Length; i++)
            {
                HandleCharacter(typed[i]);
            }

            bool modifier = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)
                            || Input.GetKey(KeyCode.LeftCommand) || Input.GetKey(KeyCode.RightCommand);
            if (_allowPaste && modifier && Input.GetKeyDown(KeyCode.V))
            {
                Paste();
            }
#endif
        }

        private void HandleCharacter(char c)
        {
            if (c == '\b')
            {
                _panel.Backspace();
                return;
            }

            if (c == '\n' || c == '\r')
            {
#if !ENABLE_INPUT_SYSTEM
                _panel.Submit();
#endif
                return;
            }

            if (char.IsControl(c))
            {
                return;
            }

            _panel.AppendCharacter(c.ToString());
        }

        private void Paste()
        {
            if (!_allowPaste)
            {
                return;
            }

            string clipboard = GUIUtility.systemCopyBuffer;
            if (!string.IsNullOrEmpty(clipboard))
            {
                _panel.SetCode(clipboard);
            }
        }

        private void PollSystemKeyboard()
        {
            if (_systemKeyboard == null)
            {
                return;
            }

            switch (_systemKeyboard.status)
            {
                case TouchScreenKeyboard.Status.Done:
                    _panel.SetCode(_systemKeyboard.text);
                    _systemKeyboard = null;
                    if (_submitFromSystemKeyboard)
                    {
                        _panel.Submit();
                    }
                    break;

                case TouchScreenKeyboard.Status.Canceled:
                case TouchScreenKeyboard.Status.LostFocus:
                    _systemKeyboard = null;
                    break;
            }
        }

        private static bool ATextFieldHasFocus()
        {
            EventSystem events = EventSystem.current;
            if (events == null)
            {
                return false;
            }

            GameObject selected = events.currentSelectedGameObject;
            if (selected == null)
            {
                return false;
            }

            UnityEngine.UI.InputField legacyField = selected.GetComponent<UnityEngine.UI.InputField>();
            if (legacyField != null && legacyField.isFocused)
            {
                return true;
            }

            return selected.GetComponent("TMP_InputField") != null;
        }
    }
}
EzAdminTouchButton.csAssets/EzAdmin/Runtime. One button for a mouse, a controller or a VR hand.5.6 KB · 177 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using UnityEngine;
using UnityEngine.Events;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminTouchButton : MonoBehaviour
    {
        [EzAdminGroup("What this button does")]
        [Tooltip("Raised on click, on pointer, and on physical touch alike.")]
        [SerializeField] private UnityEvent _onPressed = new UnityEvent();

        [EzAdminGroup("Physical touch (VR)")]
        [Tooltip("Let a hand or finger collider press this by moving into it.")]
        [SerializeField] private bool _enablePhysicalTouch = true;

        [Tooltip("Which layers count as a hand. Leave as Everything to accept any " +
                 "trigger, but a dedicated hand layer is strongly recommended — " +
                 "otherwise a dropped object rolling into the panel presses it.")]
        [SerializeField] private LayerMask _touchLayers = ~0;

        [Tooltip("Seconds before this button can be pressed again. Stops a hand " +
                 "resting against the collider from firing on every physics step.")]
        [SerializeField, Range(0.05f, 2f)] private float _rearmSeconds = 0.35f;

        [Tooltip("Ignore touches for this long after the button appears, and anything already touching it then. " +
                 "Stops a player spawning into the board from pressing it.")]
        [SerializeField, Range(0f, 10f)] private float _armDelay = 2f;

        [Tooltip("Only something this small (in metres) counts as a hand. A body, a wall or the floor is bigger " +
                 "and never presses it.")]
        [SerializeField, Range(0.05f, 1f)] private float _maxHandSize = 0.3f;

        [EzAdminGroup("Feedback (optional)")]
        [Tooltip("Raised at the same moment as the press — wire a click sound, a " +
                 "haptic pulse, or a button-depress animation here.")]
        [SerializeField] private UnityEvent _onFeedback = new UnityEvent();

        [Tooltip("Raised when a hand enters and leaves, for a highlight effect. " +
                 "Never fires for pointer clicks, only physical touch.")]
        [SerializeField] private UnityEvent _onTouchEnter = new UnityEvent();
        [SerializeField] private UnityEvent _onTouchExit = new UnityEvent();

        private float _nextAllowedTime;
        private bool _isInteractable = true;
        private float _armedAt;
        private readonly System.Collections.Generic.HashSet<Collider> _ignored =
            new System.Collections.Generic.HashSet<Collider>();

        private void OnEnable()
        {
            _armedAt = Time.unscaledTime + _armDelay;
            _ignored.Clear();
        }

        private bool LooksLikeHand(Collider other)
        {
            var size = other.bounds.size;
            return Mathf.Max(size.x, Mathf.Max(size.y, size.z)) <= _maxHandSize;
        }

        public bool IsInteractable
        {
            get { return _isInteractable; }
            set { _isInteractable = value; }
        }

        public LayerMask TouchLayers
        {
            get { return _touchLayers; }
            set { _touchLayers = value; }
        }

        public UnityEvent OnPressed
        {
            get { return _onPressed; }
        }

        public void Press()
        {
            if (!_isInteractable)
            {
                return;
            }

            if (Time.unscaledTime < _nextAllowedTime)
            {
                return;
            }

            _nextAllowedTime = Time.unscaledTime + _rearmSeconds;

            _onFeedback.Invoke();
            _onPressed.Invoke();
        }

        private void OnTriggerEnter(Collider other)
        {
            if (!_enablePhysicalTouch || !_isInteractable)
            {
                return;
            }

            if (!IsInTouchLayers(other.gameObject.layer))
            {
                return;
            }

            if (Time.unscaledTime < _armedAt)
            {
                _ignored.Add(other);
                return;
            }

            if (_ignored.Contains(other) || !LooksLikeHand(other))
            {
                return;
            }

            _onTouchEnter.Invoke();
            Press();
        }

        private void OnTriggerExit(Collider other)
        {
            if (!_enablePhysicalTouch)
            {
                return;
            }

            if (!IsInTouchLayers(other.gameObject.layer))
            {
                return;
            }

            if (_ignored.Remove(other))
            {
                return;
            }

            _onTouchExit.Invoke();
        }

        private bool IsInTouchLayers(int layer)
        {
            return (_touchLayers.value & (1 << layer)) != 0;
        }

#if UNITY_EDITOR
        private void OnValidate()
        {
            if (!_enablePhysicalTouch || gameObject.hideFlags != HideFlags.None || !gameObject.scene.IsValid())
            {
                return;
            }

            var ownCollider = GetComponent<Collider>();
            if (ownCollider == null)
            {
                Debug.LogWarning(
                    "EzAdminTouchButton on '" + name + "' has Physical Touch enabled but " +
                    "no Collider. Add one and tick Is Trigger.", this);
            }
            else if (!ownCollider.isTrigger)
            {
                Debug.LogWarning(
                    "EzAdminTouchButton on '" + name + "': the Collider should be a " +
                    "trigger, or a hand will bump into it instead of pressing it.", this);
            }
        }
#endif
    }
}
EzAdminCode.csAssets/EzAdmin/Runtime. Checks a link code before it is sent.2.4 KB · 97 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Text;

namespace EzAdmin.Client
{

    public static class EzAdminCode
    {

        public const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

        public const int Length = 8;

        public const int GroupSize = 4;

        public static string Normalise(string raw)
        {
            if (string.IsNullOrEmpty(raw))
            {
                return string.Empty;
            }

            var builder = new StringBuilder(Length);
            for (int i = 0; i < raw.Length; i++)
            {
                char c = char.ToUpperInvariant(raw[i]);

                if (c == 'O')
                {
                    continue;
                }

                if (c == ' ' || c == '-' || c == '_')
                {
                    continue;
                }

                if (Alphabet.IndexOf(c) >= 0)
                {
                    builder.Append(c);
                }
                else
                {
                    return string.Empty;
                }

                if (builder.Length > Length)
                {
                    return string.Empty;
                }
            }

            return builder.Length == Length ? builder.ToString() : string.Empty;
        }

        public static string CleanPartial(string raw)
        {
            if (string.IsNullOrEmpty(raw))
            {
                return string.Empty;
            }
            var builder = new StringBuilder(Length);
            for (int i = 0; i < raw.Length && builder.Length < Length; i++)
            {
                char c = char.ToUpperInvariant(raw[i]);
                if (Alphabet.IndexOf(c) >= 0)
                {
                    builder.Append(c);
                }
            }
            return builder.ToString();
        }

        public static bool IsComplete(string raw)
        {
            return Normalise(raw).Length == Length;
        }

        public static bool IsAllowed(char c)
        {
            return Alphabet.IndexOf(char.ToUpperInvariant(c)) >= 0;
        }

        public static string Format(string raw)
        {
            string code = Normalise(raw);
            if (code.Length != Length)
            {
                return code;
            }

            return code.Substring(0, GroupSize) + "-" + code.Substring(GroupSize);
        }
    }
}
EzAdminDiscord.csAssets/EzAdmin/Runtime. Sign in with Discord. Needs the Discord Social SDK.4.8 KB · 158 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

#if EZADMIN_DISCORD_SDK
using System;
using Discord.Sdk;
using UnityEngine;
using UnityEngine.Events;

namespace EzAdmin.Client
{

    [DisallowMultipleComponent]
    public sealed class EzAdminDiscord : MonoBehaviour
    {
        [Serializable]
        public class StringEvent : UnityEvent<string> { }

        public static EzAdminDiscord Instance { get; private set; }

        [EzAdminGroup("Your Discord application")]
        [Tooltip("Discord Developer Portal > your game's app > General Information > Application ID. " +
                 "The same number the server owner gave /playfab discord-app.")]
        [SerializeField] private string _applicationId = "";

        [EzAdminGroup("Events")]
        [Tooltip("A message for the player while linking, and when it finishes.")]
        [SerializeField] private StringEvent _onStatusChanged = new StringEvent();

        [SerializeField] private UnityEvent _onLinked = new UnityEvent();
        [SerializeField] private UnityEvent _onLinkFailed = new UnityEvent();

        private Client _client;
        private ulong _appId;
        private string _codeVerifier;
        private bool _linking;

        public bool IsReady => _client != null && _appId != 0;

        public bool IsLinking => _linking;

        private void Awake()
        {
            if (Instance != null && Instance != this)
            {
                Destroy(this);
                return;
            }

            Instance = this;

            if (!ulong.TryParse((_applicationId ?? string.Empty).Trim(), out _appId))
            {
                Debug.LogError("EzAdminDiscord: set Application ID to your game's Discord application ID.", this);
                return;
            }

            _client = new Client();
            _client.AddLogCallback(OnLog, LoggingSeverity.Error);
            _client.SetApplicationId(_appId);
        }

        private void OnDestroy()
        {
            if (Instance == this)
            {
                Instance = null;
            }
        }

        public void LinkWithDiscord()
        {
            LinkWithDiscord(null);
        }

        public void LinkWithDiscord(Action<bool, string> onComplete)
        {
            if (_linking)
            {
                return;
            }

            var ezadmin = EzAdminClient.Instance;
            if (!IsReady || ezadmin == null || !ezadmin.CanLinkWithDiscord)
            {
                Finish(false, "Linking with Discord is not set up in this game.", onComplete);
                return;
            }

            _linking = true;
            SetStatus("Opening Discord...");

            var verifier = _client.CreateAuthorizationCodeVerifier();
            _codeVerifier = verifier.Verifier();

            var args = new AuthorizationArgs();
            args.SetClientId(_appId);

            args.SetScopes(Client.GetDefaultPresenceScopes() + " identify");
            args.SetCodeChallenge(verifier.Challenge());

            _client.Authorize(args, (result, code, redirectUri) =>
            {
                if (!result.Successful())
                {
                    Finish(false, "Discord sign-in was cancelled.", onComplete);
                    return;
                }

                SetStatus("Signing in...");
                _client.GetToken(_appId, code, _codeVerifier, redirectUri,
                    (tokenResult, accessToken, refreshToken, tokenType, expiresIn, scope) =>
                    {
                        if (!tokenResult.Successful() || string.IsNullOrEmpty(accessToken))
                        {
                            Finish(false, "Discord did not sign you in. Try again.", onComplete);
                            return;
                        }

                        SetStatus("Linking your account...");
                        ezadmin.LinkWithDiscordToken(accessToken,
                            (succeeded, message) => Finish(succeeded, message, onComplete));
                    });
            });
        }

        private void Finish(bool succeeded, string message, Action<bool, string> onComplete)
        {
            _linking = false;
            SetStatus(message);

            if (succeeded)
            {
                _onLinked.Invoke();
            }
            else
            {
                _onLinkFailed.Invoke();
            }

            if (onComplete != null)
            {
                onComplete(succeeded, message);
            }
        }

        private void SetStatus(string message)
        {
            _onStatusChanged.Invoke(message ?? string.Empty);
        }

        private void OnLog(string message, LoggingSeverity severity)
        {
            Debug.Log("EzAdminDiscord: " + message, this);
        }
    }
}
#endif
EzAdminAntiCheat.csAssets/EzAdmin/Runtime. Anti tamper. The full file only downloads from your dashboard on a paid plan, so this copy keeps your settings but checks nothing.5.6 KB · 132 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;

namespace EzAdmin.Client
{

#pragma warning disable 0067, 0414
    [DisallowMultipleComponent]
    public sealed class EzAdminAntiCheat : MonoBehaviour
    {
        public enum Response { ReportOnly, CloseGame, EventOnly }

        [EzAdminGroup("Testing")]
        [Tooltip("Lets Report and ReportCheck send to EzAdmin while you play in the Unity editor, so you can see a " +
                 "report arrive on the dashboard. The automatic checks stay off in the editor either way.")]
        [SerializeField] private bool _sendTestReportsFromEditor = true;

        public static bool SendTestReportsFromEditor = false;

        [EzAdminGroup("Checks")]
        [SerializeField] private bool _modLoaders = true;
        [SerializeField] private bool _injectedCode = true;
        [SerializeField] private bool _debuggers = true;
        [SerializeField] private bool _memoryEditing = true;
        [SerializeField] private bool _rootedDevices = true;
        [SerializeField] private bool _hookingTools = true;
        [Tooltip("Keep scanning for DLLs and libraries injected into the game, code mapped in without a file, " +
                 "and patches written into the game's own code.")]
        [SerializeField] private bool _deepScan = true;
        [Tooltip("Windows: report cheat and debugging tools that are open while the game runs, such as Cheat Engine.")]
        [SerializeField] private bool _cheatTools = true;

        [EzAdminGroup("Assemblies and files")]
        [Tooltip("Assembly names that mean a mod loader or cheat tool is in the game. Remove one your " +
                 "own game uses (some games ship Harmony).")]
        [SerializeField] private List<string> _blockedAssemblies = new List<string>
        {
            "MelonLoader", "BepInEx", "UnityExplorer", "UniverseLib", "Il2CppInterop", "UnhollowerBaseLib",
            "UnhollowerRuntimeLib", "Il2CppAssemblyUnhollower", "Doorstop", "SharpMonoInjector",
            "MonoMod.RuntimeDetour", "0Harmony",
        };

        [EzAdminGroup("Android")]
        [Tooltip("The SHA-256 fingerprint of the certificate your released APK is signed with. With Google " +
                 "Play App Signing, copy \"App signing key certificate\" SHA-256 from Play Console. An APK " +
                 "signed with anything else is a modified copy. Colons and spaces are fine. Empty: not checked.")]
        [SerializeField] private string _expectedCertificate = "";
        [Tooltip("Report a copy that was not installed by one of the stores below (a sideloaded APK). " +
                 "Leave off while you test builds you install yourself.")]
        [SerializeField] private bool _requireStoreInstall = false;
        [SerializeField] private List<string> _allowedInstallers = new List<string>
        {
            "com.android.vending", "com.amazon.venezia", "com.sec.android.app.samsungapps",
            "com.huawei.appmarket", "com.oculus.horizon", "com.oculus.ocms",
        };
        [Tooltip("Report the game running in an Android emulator. Off by default: plenty of honest players use one.")]
        [SerializeField] private bool _emulators = false;

        [EzAdminGroup("When something is found")]
        [Tooltip("Close the game after reporting an injection tool: a mod loader, injected code, a hooking " +
                 "tool, a debugger or a modified copy. Rooted devices and speed are only reported.")]
        [SerializeField] private bool _closeOnInjection = true;
        [Tooltip("ReportOnly: tell your Discord and nothing else. CloseGame: also close the game. " +
                 "EventOnly: tell your Discord and fire On Detected for your own code.")]
        [SerializeField] private Response _response = Response.ReportOnly;
        [SerializeField] private DetectedEvent _onDetected = new DetectedEvent();

        [Serializable] public class DetectedEvent : UnityEvent<string, string> { }

        public static event Action<string, string> Detected;

        public static string Fingerprint { get; private set; } = "";
        public static bool FingerprintReady { get; private set; }
        public static bool Present { get; private set; }
        public static bool Quiet { get; internal set; }

        public static string DeviceSignals { get; private set; } = "";
        public static int Scans { get; private set; }
        public static string Flags => "";

        public static void SetTimeScale(float scale)
        {
            Time.timeScale = scale;
        }

        public static void Report(string detail)
        {
            Locked();
        }

        public static void ReportCheck(string check, string detail)
        {
            Locked();
        }

        internal static void MemoryTampered(string what)
        {
        }

        internal static string Proof(string nonce)
        {
            return "";
        }

        internal static string Proof(string nonce, string code)
        {
            return "";
        }

        private static bool _told;

        private static void Locked()
        {
            if (_told) return;
            _told = true;
            Debug.Log("EzAdmin: anti tamper comes with a paid plan. Download the package from your dashboard " +
                      "(Game files) after upgrading and this file is replaced with the full anti tamper.");
        }

        private void Awake()
        {
            FingerprintReady = true;
            Locked();
        }
    }
#pragma warning restore 0067, 0414
}
EzAdminIntegrity.csAssets/EzAdmin/Runtime. The anti tamper scanner. The full file only downloads from your dashboard on a paid plan, so this copy is empty.1.0 KB · 43 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.Collections.Generic;

namespace EzAdmin.Client
{
    internal static class EzAdminIntegrity
    {
        internal struct Finding
        {
            public string Check;
            public string Detail;
            public Finding(string check, string detail) { Check = check; Detail = detail; }
        }

        internal static volatile string CodeReport = "";
        internal static volatile string CodeAnswer = "";

        internal static void ClearCodeReport()
        {
            CodeReport = "";
        }

        internal static void AnswerSent(string answer)
        {
            if (CodeAnswer == answer) CodeAnswer = "";
        }

        internal static void Answer(string challenge)
        {
        }

        internal static void Prepare()
        {
        }

        internal static List<Finding> Scan(bool injection, bool code, bool tools)
        {
            return new List<Finding>();
        }
    }
}
EzAdminAttestation.csAssets/EzAdmin/Runtime. Asks Meta to vouch for the Quest copy and headset. Needs the Meta Platform SDK in your game.1.9 KB · 60 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using UnityEngine;

namespace EzAdmin.Client
{
    internal static class EzAdminAttestation
    {
        internal static bool Available
        {
#if EZADMIN_META_PLATFORM && UNITY_ANDROID && !UNITY_EDITOR
            get { return true; }
#else
            get { return false; }
#endif
        }

        internal static void Request(string nonce, Action<string, string> done)
        {
#if EZADMIN_META_PLATFORM && UNITY_ANDROID && !UNITY_EDITOR
            try
            {
                if (Application.internetReachability == NetworkReachability.NotReachable)
                {
                    done(null, "no internet");
                    return;
                }
                if (!Oculus.Platform.Core.IsInitialized())
                    Oculus.Platform.Core.AsyncInitialize();
                var request = Oculus.Platform.DeviceApplicationIntegrity.GetIntegrityToken(nonce);
                if (request == null)
                {
                    done(null, "the Meta Platform SDK is not set up");
                    return;
                }
                request.OnComplete(message =>
                {
                    if (message == null || message.IsError)
                    {
                        string why = message != null && message.GetError() != null ? message.GetError().Message : "";
                        done(null, string.IsNullOrEmpty(why) ? "Meta did not answer" : why);
                    }
                    else
                    {
                        done(message.Data, null);
                    }
                });
            }
            catch (Exception e)
            {
                done(null, e.GetType().Name);
            }
#else
            done(null, null);
#endif
        }
    }
}
EzAdminMetaDefine.csAssets/EzAdmin/Editor. Switches the Meta check on for Android builds when the Meta Platform SDK is in your project.2.6 KB · 64 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Compilation;
using UnityEngine;

namespace EzAdmin.EditorTools
{
    [InitializeOnLoad]
    internal static class EzAdminMetaDefine
    {
        private const string Define = "EZADMIN_META_PLATFORM";

        static EzAdminMetaDefine()
        {
            EditorApplication.delayCall += Sync;
        }

        [MenuItem("Tools/EzAdmin/Check Meta Platform SDK")]
        private static void SyncFromMenu()
        {
            Sync();
            Debug.Log(Has() ? "EzAdmin: Meta hardware checks are on for Android builds."
                            : "EzAdmin: the Meta Platform SDK was not found, so Meta hardware checks are off.");
        }

        private static bool Has()
        {
            string current = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.Android) ?? "";
            return current.Split(';').Any(d => d.Trim() == Define);
        }

        private static void Sync()
        {
            if (EditorApplication.isCompiling || EditorApplication.isUpdating) return;
            bool reachable = MetaReachable();
            string current = PlayerSettings.GetScriptingDefineSymbolsForGroup(BuildTargetGroup.Android) ?? "";
            var defines = current.Split(';').Select(d => d.Trim()).Where(d => d.Length > 0).ToList();
            bool has = defines.Contains(Define);
            if (reachable == has) return;
            if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android && !reachable) return;
            if (reachable) defines.Add(Define);
            else defines.Remove(Define);
            PlayerSettings.SetScriptingDefineSymbolsForGroup(BuildTargetGroup.Android, string.Join(";", defines));
            Debug.Log(reachable
                ? "EzAdmin: found the Meta Platform SDK. Meta hardware checks are now built into Android builds."
                : "EzAdmin: the Meta Platform SDK is gone, so Meta hardware checks were taken out of Android builds.");
        }

        private static bool MetaReachable()
        {
            foreach (var assembly in CompilationPipeline.GetAssemblies(AssembliesType.Player))
            {
                if (!assembly.sourceFiles.Any(f => f.EndsWith("EzAdminAttestation.cs"))) continue;
                return assembly.assemblyReferences.Any(r => r.name == "Oculus.Platform") ||
                       assembly.compiledAssemblyReferences.Any(r => Path.GetFileNameWithoutExtension(r) == "Oculus.Platform");
            }
            return false;
        }
    }
}
EzAdminToolbar.csAssets/EzAdmin/Editor. The EzAdmin logo button in Unity's top toolbar.3.5 KB · 93 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Reflection;
using EzAdmin.Client;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;

namespace EzAdmin.EditorTools
{
    [InitializeOnLoad]
    internal static class EzAdminToolbar
    {
        private static bool _added;
        private static int _tries;

        static EzAdminToolbar()
        {
            EditorApplication.update -= TryAdd;
            EditorApplication.update += TryAdd;
        }

        private static void TryAdd()
        {
            if (_added) return;
            if (_tries++ > 600) { Stop(); return; }
            try
            {
                Type toolbarType = typeof(Editor).Assembly.GetType("UnityEditor.Toolbar");
                if (toolbarType == null) { Stop(); return; }
                var toolbars = Resources.FindObjectsOfTypeAll(toolbarType);
                if (toolbars.Length == 0) return;
                var root = RootOf(toolbarType, toolbars[0]);
                if (root == null) return;
                if (root.Q("EzAdminToolbarButton") != null) { Stop(); return; }

                var zone = root.Q("ToolbarZoneLeftAlign") ?? root.Q("ToolbarZoneRightAlign");
                if (zone == null) return;

                var host = new VisualElement { name = "EzAdminToolbarButton" };
                host.style.flexGrow = 0;
                host.style.flexShrink = 0;
                host.style.width = 34;
                host.style.height = Length.Percent(100);
                host.style.justifyContent = Justify.Center;
                host.style.alignItems = Align.Center;

                var button = new IMGUIContainer(Draw);
                button.style.flexGrow = 1;
                button.style.alignSelf = Align.Stretch;
                host.Add(button);

                if (zone.name == "ToolbarZoneRightAlign") zone.Insert(0, host);
                else zone.Add(host);
                Stop();
            }
            catch (Exception)
            {
                Stop();
            }
        }

        private static VisualElement RootOf(Type toolbarType, UnityEngine.Object toolbar)
        {
            var field = toolbarType.GetField("m_Root", BindingFlags.NonPublic | BindingFlags.Instance)
                        ?? toolbarType.GetField("m_Root", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy);
            if (field != null && field.GetValue(toolbar) is VisualElement fromField) return fromField;

            var prop = toolbarType.GetProperty("rootVisualElement", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
            if (prop != null && prop.GetValue(toolbar) is VisualElement fromProp) return fromProp;

            return null;
        }

        private static void Stop()
        {
            _added = true;
            EditorApplication.update -= TryAdd;
        }

        private static void Draw()
        {
            var rect = GUILayoutUtility.GetRect(30, 24, GUILayout.ExpandHeight(true));
            if (GUI.Button(rect, new GUIContent("", "EzAdmin"), GUIStyle.none))
                EzAdminMenu.Show(rect);
            float s = Mathf.Min(18f, rect.height - 2f);
            var icon = new Rect(rect.x + (rect.width - s) / 2f, rect.y + (rect.height - s) / 2f, s, s);
            if (EzAdminBrand.Logo(64) != null) GUI.DrawTexture(icon, EzAdminBrand.Logo(64), ScaleMode.ScaleToFit);
        }
    }
}
EzSecureValues.csAssets/EzAdmin/Runtime. EzSecureInt, EzSecureFloat and EzSecureLong: numbers memory editors cannot find or change.5.9 KB · 165 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using UnityEngine;

namespace EzAdmin.Client
{

    [Serializable]
    public struct EzSecureInt : IEquatable<EzSecureInt>, IComparable<EzSecureInt>
    {
        [SerializeField] private int _hidden;
        [SerializeField] private int _key;
        [SerializeField] private int _check;
        private int _last;

        public EzSecureInt(int value)
        {
            _key = EzSecureKeys.Next();
            _hidden = value ^ _key;
            _check = EzSecureKeys.Check(value, _key);
            _last = value;
        }

        public int Value
        {
            get
            {
                if (_key == 0 && _hidden == 0 && _check == 0) return 0;
                int value = _hidden ^ _key;
                if (EzSecureKeys.Check(value, _key) != _check)
                {
                    EzAdminAntiCheat.MemoryTampered("whole number");
                    this = new EzSecureInt(_last);
                    return _last;
                }
                _last = value;
                return value;
            }
        }

        public static implicit operator EzSecureInt(int value) => new EzSecureInt(value);
        public static implicit operator int(EzSecureInt secure) => secure.Value;
        public static EzSecureInt operator ++(EzSecureInt a) => new EzSecureInt(a.Value + 1);
        public static EzSecureInt operator --(EzSecureInt a) => new EzSecureInt(a.Value - 1);
        public bool Equals(EzSecureInt other) => Value == other.Value;
        public override bool Equals(object obj) => obj is EzSecureInt other && Equals(other);
        public override int GetHashCode() => Value.GetHashCode();
        public int CompareTo(EzSecureInt other) => Value.CompareTo(other.Value);
        public override string ToString() => Value.ToString();
    }

    [Serializable]
    public struct EzSecureFloat : IEquatable<EzSecureFloat>, IComparable<EzSecureFloat>
    {
        [SerializeField] private int _hidden;
        [SerializeField] private int _key;
        [SerializeField] private int _check;
        private float _last;

        public EzSecureFloat(float value)
        {
            int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
            _key = EzSecureKeys.Next();
            _hidden = bits ^ _key;
            _check = EzSecureKeys.Check(bits, _key);
            _last = value;
        }

        public float Value
        {
            get
            {
                if (_key == 0 && _hidden == 0 && _check == 0) return 0f;
                int bits = _hidden ^ _key;
                if (EzSecureKeys.Check(bits, _key) != _check)
                {
                    EzAdminAntiCheat.MemoryTampered("number");
                    this = new EzSecureFloat(_last);
                    return _last;
                }
                float value = BitConverter.ToSingle(BitConverter.GetBytes(bits), 0);
                _last = value;
                return value;
            }
        }

        public static implicit operator EzSecureFloat(float value) => new EzSecureFloat(value);
        public static implicit operator float(EzSecureFloat secure) => secure.Value;
        public bool Equals(EzSecureFloat other) => Value.Equals(other.Value);
        public override bool Equals(object obj) => obj is EzSecureFloat other && Equals(other);
        public override int GetHashCode() => Value.GetHashCode();
        public int CompareTo(EzSecureFloat other) => Value.CompareTo(other.Value);
        public override string ToString() => Value.ToString();
    }

    [Serializable]
    public struct EzSecureLong : IEquatable<EzSecureLong>, IComparable<EzSecureLong>
    {
        [SerializeField] private long _hidden;
        [SerializeField] private long _key;
        [SerializeField] private int _check;
        private long _last;

        public EzSecureLong(long value)
        {
            _key = ((long)EzSecureKeys.Next() << 32) | (uint)EzSecureKeys.Next();
            _hidden = value ^ _key;
            _check = EzSecureKeys.Check((int)(value ^ (value >> 32)), (int)_key);
            _last = value;
        }

        public long Value
        {
            get
            {
                if (_key == 0 && _hidden == 0 && _check == 0) return 0;
                long value = _hidden ^ _key;
                if (EzSecureKeys.Check((int)(value ^ (value >> 32)), (int)_key) != _check)
                {
                    EzAdminAntiCheat.MemoryTampered("whole number");
                    this = new EzSecureLong(_last);
                    return _last;
                }
                _last = value;
                return value;
            }
        }

        public static implicit operator EzSecureLong(long value) => new EzSecureLong(value);
        public static implicit operator long(EzSecureLong secure) => secure.Value;
        public bool Equals(EzSecureLong other) => Value == other.Value;
        public override bool Equals(object obj) => obj is EzSecureLong other && Equals(other);
        public override int GetHashCode() => Value.GetHashCode();
        public int CompareTo(EzSecureLong other) => Value.CompareTo(other.Value);
        public override string ToString() => Value.ToString();
    }

    internal static class EzSecureKeys
    {
        private static readonly System.Random Random = new System.Random(Guid.NewGuid().GetHashCode());

        private const int Salt = 0x1F3D5B79;

        internal static int Next()
        {
            lock (Random)
            {
                int key = Random.Next(int.MinValue, int.MaxValue);
                return key == 0 ? 0x5A5A5A5 : key;
            }
        }

        internal static int Check(int value, int key)
        {
            unchecked
            {
                int h = value * 0x2C1B3C6D + Salt;
                h ^= key * 0x297A2D39;
                return (h ^ (h >> 15)) * 0x6A09E667;
            }
        }
    }
}
EzAdminGame.csAssets/EzAdmin/Runtime. Game settings and news you change on the dashboard's Game Control tab, read with EzAdminGame.GetFloat and friends.6.5 KB · 197 lines
// Copyright (c) 2026 EzAdmin. All rights reserved.
// Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt.

using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;

namespace EzAdmin.Client
{

    public static class EzAdminGame
    {
        private const string CacheKey = "ezadmin.liveops";

        private static readonly Dictionary<string, Entry> Settings = new Dictionary<string, Entry>();
        private static readonly List<EzAdminNewsPost> Posts = new List<EzAdminNewsPost>();
        private static bool _loaded;
        private static bool _fetching;

        public static event Action Changed;

        public static bool Ready { get; private set; }

        public static long Revision { get; private set; } = -1;

        public static IReadOnlyList<EzAdminNewsPost> News
        {
            get { LoadCache(); return Posts; }
        }

        public static bool Has(string key)
        {
            LoadCache();
            return key != null && Settings.ContainsKey(key);
        }

        public static string GetString(string key, string fallback = "")
        {
            LoadCache();
            if (key == null || !Settings.TryGetValue(key, out var entry)) return fallback;
            switch (entry.type)
            {
                case "text": return entry.text ?? fallback;
                case "number": return entry.number.ToString(CultureInfo.InvariantCulture);
                default: return entry.on ? "on" : "off";
            }
        }

        public static float GetFloat(string key, float fallback = 0f)
        {
            LoadCache();
            return key != null && Settings.TryGetValue(key, out var entry) && entry.type == "number" ? (float)entry.number : fallback;
        }

        public static int GetInt(string key, int fallback = 0)
        {
            LoadCache();
            if (key == null || !Settings.TryGetValue(key, out var entry) || entry.type != "number") return fallback;
            double value = Math.Round(entry.number);
            return value > int.MaxValue ? int.MaxValue : value < int.MinValue ? int.MinValue : (int)value;
        }

        public static bool GetBool(string key, bool fallback = false)
        {
            LoadCache();
            return key != null && Settings.TryGetValue(key, out var entry) && entry.type == "switch" ? entry.on : fallback;
        }

        public static void Refresh()
        {
            Fetch();
        }

        internal static void Offer(long revision)
        {
            LoadCache();
            if (revision == Revision && Ready) return;
            if (revision <= 0)
            {

                if (Revision != 0) Apply(new Reply { ok = true, rev = 0, config = new Entry[0], news = new Post[0] }, true);
                return;
            }
            Fetch();
        }

        private static void Fetch()
        {
            var client = EzAdminClient.Instance;
            if (client == null || _fetching) return;
            _fetching = true;
            client.FetchLiveOps(text =>
            {
                _fetching = false;
                Reply reply = null;
                try { reply = string.IsNullOrEmpty(text) ? null : JsonUtility.FromJson<Reply>(text); }
                catch (ArgumentException) { reply = null; }
                if (reply != null && reply.ok) Apply(reply, true);
            });
        }

        private static void Apply(Reply reply, bool save)
        {
            Settings.Clear();
            foreach (var entry in reply.config ?? new Entry[0])
            {
                if (entry != null && !string.IsNullOrEmpty(entry.key)) Settings[entry.key] = entry;
            }
            Posts.Clear();
            foreach (var post in reply.news ?? new Post[0])
            {
                if (post != null) Posts.Add(new EzAdminNewsPost(post.id, post.title, post.body, post.posted));
            }
            Revision = reply.rev;
            Ready = true;
            if (save)
            {
                try
                {
                    PlayerPrefs.SetString(CacheKey, JsonUtility.ToJson(reply));
                    PlayerPrefs.Save();
                }
                catch (Exception) {  }
            }
            try { Changed?.Invoke(); }
            catch (Exception exc) { Debug.LogException(exc); }
        }

        private static void LoadCache()
        {
            if (_loaded) return;
            _loaded = true;
            try
            {
                string text = PlayerPrefs.GetString(CacheKey, "");
                if (string.IsNullOrEmpty(text)) return;
                var reply = JsonUtility.FromJson<Reply>(text);
                if (reply != null && reply.ok)
                {
                    Apply(reply, false);
                    Ready = true;
                    Revision = -1;
                }
            }
            catch (Exception) {  }
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        internal class Entry
        {
            [UnityEngine.Scripting.Preserve] public string key;
            [UnityEngine.Scripting.Preserve] public string type;
            [UnityEngine.Scripting.Preserve] public string text;
            [UnityEngine.Scripting.Preserve] public double number;
            [UnityEngine.Scripting.Preserve] public bool on;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        internal class Post
        {
            [UnityEngine.Scripting.Preserve] public string id;
            [UnityEngine.Scripting.Preserve] public string title;
            [UnityEngine.Scripting.Preserve] public string body;
            [UnityEngine.Scripting.Preserve] public long posted;
        }

        [Serializable]
        [UnityEngine.Scripting.Preserve]
        internal class Reply
        {
            [UnityEngine.Scripting.Preserve] public bool ok;
            [UnityEngine.Scripting.Preserve] public long rev;
            [UnityEngine.Scripting.Preserve] public Entry[] config;
            [UnityEngine.Scripting.Preserve] public Post[] news;
        }
    }

    public sealed class EzAdminNewsPost
    {
        public string Id { get; }
        public string Title { get; }
        public string Body { get; }

        public DateTime Posted { get; }

        internal EzAdminNewsPost(string id, string title, string body, long posted)
        {
            Id = id ?? "";
            Title = title ?? "";
            Body = body ?? "";
            Posted = DateTimeOffset.FromUnixTimeSeconds(Math.Max(0, posted)).UtcDateTime;
        }
    }
}
link.xmlAssets/EzAdmin. Stops stripped builds removing code PlayFab needs.0.9 KB · 19 lines
<!-- Copyright (c) 2026 EzAdmin. All rights reserved. Licensed under the EzAdmin Client License. See LICENSE-CLIENT.txt. -->
<!--
  EzAdmin - keep the reply types PlayFab fills in by reflection.

  Xbox, PlayStation, Quest, iOS and WebGL builds all use IL2CPP, which strips code
  that looks unused. PlayFab sets these fields by NAME, so to the linker they look
  unused - and at "Medium" or "High" stripping they vanish. The build still
  succeeds; every link attempt then silently reports failure on those platforms
  only. This file stops that.

  Keep this file anywhere under Assets/. If you move the EzAdmin scripts into
  their own assembly definition, change "Assembly-CSharp" below to that name.
-->
<linker>
  <assembly fullname="Assembly-CSharp">
    <type fullname="EzAdmin.Client.EzAdminClient/RedeemResponse" preserve="all" />
    <type fullname="EzAdmin.Client.EzAdminClient/StatusResponse" preserve="all" />
  </assembly>
</linker>

The scripts only use PlayFab's client API, so no secret key ever ships inside your game.

Platforms

  • Meta Quest

Players can type their link code with a keyboard or a controller, use the system keyboard on Quest, or press the buttons with a VR hand.

Commands for Unity

CommandWhat it doesWho can use it
/setupConnect your PlayFab title. The Secret Key goes in a private form and is checked before it is stored.
Manage PlayFab Elevated
config.playfab · Server owner, or anyone they grant it to
/playfab statusShow the connection and the key's fingerprint, never the key itself.
Manage PlayFab Elevated
config.playfab · Server owner, or anyone they grant it to
/playfab disconnectDelete this server's PlayFab key.
Manage PlayFab Elevated
config.playfab · Server owner, or anyone they grant it to
/playfab economyChoose Catalog (Legacy) or Economy v2, and the catalog version, for items.
Manage PlayFab Elevated
config.playfab · Server owner, or anyone they grant it to
/playfab discord-appLet players link by signing in with Discord in the game, instead of typing a code.
Manage PlayFab Elevated
config.playfab · Server owner, or anyone they grant it to
/gamebanBan a player from the game, by mention or by PlayFab ID.
Game Ban
game.ban · Or Discord's Ban Members permission
/gameunbanLift a game ban.
Game Unban Elevated
game.unban · Server owner, or anyone they grant it to
/gamelookupLook up a player's account and ban history.
Player Lookup
game.lookup · Or Discord's Timeout Members permission
/whoisShow a member's linked game account and the items their roles give.
Player Lookup
game.lookup · Or Discord's Timeout Members permission
/leaderboardShow your game's top players for one statistic.
Everyone
/statsShow a player's in game statistics.
Everyone
Looking up someone else needs Player Lookup.
/whoplaysShow how many members have linked their game account.
Everyone
/linkLink your game account. Type the code your game shows (/link K7P-4QX), or run it with no code to get one to type in the game.
Everyone
/unlinkUnlink your game account. Items your roles gave are taken back. Also on the website: Account > Linked game accounts.
Everyone
/forcelinkLink a member's account by hand.
Manage Links Elevated
link.manage · Server owner, or anyone they grant it to
/profileChoose whether other players in the game can see this is your Discord account.
Everyone

Moderation, role → item and plan commands. See every command.

Before you ship

  • Obfuscators. Exclude EzAdminClient.CloudFunctions and its response classes. CloudScript function names are looked up by name while the game runs.
  • Stripped builds. Keep link.xml in the folder. Without it, Quest builds can lose code PlayFab needs.
  • Playtime. The game reports playtime for the dashboard. It comes from the player's device, so treat Unity playtime as a guide rather than a record.