M1: add NGO + Multiplayer Services packages and prototype scripts (compiling)
Netcode for GameObjects 2.13.0 + Unity Multiplayer Services 2.2.4 (unified Relay/Lobby SDK; supersedes deprecated standalone relay/lobby). Four M1 scripts, verified against live editor reflection and compiling clean: - Net/NetworkBootstrap.cs Relay host/join-by-code harness (IMGUI) - Net/PlayerDriver.cs replicated host-auth capsule (input->ServerRpc->host) - Bot/TreadPart.cs v0.1 tread feel: snap-to-cruise/lock-pivot/lean - Bot/SeatManager.cs left/right tread seat claim + leaver-safety Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04333223f4
commit
d723447e31
15 changed files with 1006 additions and 4 deletions
184
Assets/Scripts/Bot/SeatManager.cs
Normal file
184
Assets/Scripts/Bot/SeatManager.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jankenbots.Prototype
|
||||
{
|
||||
/// <summary>
|
||||
/// SeatManager — decides WHICH player drives WHICH tread of the shared bot.
|
||||
///
|
||||
/// M1 coordination feel: two humans each pilot half of one janky body, so the
|
||||
/// whole point is that a seat is a scarce, claimable slot. This lives on the
|
||||
/// bot chassis GameObject (alongside the chassis Rigidbody / NetworkObject) and
|
||||
/// is the single source of truth for seat ownership.
|
||||
///
|
||||
/// Model:
|
||||
/// - Two seats, Left and Right.
|
||||
/// - Each seat stores the ulong clientId of its pilot, or <see cref="NoPilot"/>
|
||||
/// (== NEUTRAL / free) when nobody holds it.
|
||||
/// - First player to ask claims Left, the next claims Right, everyone after
|
||||
/// that gets nothing (a spectator for M1). Treads with no pilot stay NEUTRAL
|
||||
/// and simply apply no force — the bot just doesn't move on that side.
|
||||
///
|
||||
/// Authority: server-only writes. Seat state is host-authoritative just like the
|
||||
/// physics, so clients never guess — they read replicated NetworkVariables and
|
||||
/// send a ServerRpc to *ask* for a seat.
|
||||
///
|
||||
/// TreadPart hooks into this via <see cref="OwnsSeat"/>: a TreadPart is tagged
|
||||
/// Left or Right, and on the host it only honors input from the client that
|
||||
/// currently owns that seat.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class SeatManager : NetworkBehaviour
|
||||
{
|
||||
public enum Seat { Left, Right }
|
||||
|
||||
// Sentinel for "this seat is empty / NEUTRAL". Real clientIds are small
|
||||
// ulongs starting at 0, so MaxValue is a safe "nobody" marker. We can't use
|
||||
// -1 because clientId is unsigned.
|
||||
public const ulong NoPilot = ulong.MaxValue;
|
||||
|
||||
// Replicated seat ownership. Server writes, everyone reads. Clients subscribe
|
||||
// to OnValueChanged if they want to react (e.g. update the seat-claim UI);
|
||||
// for M1 we just poll them in OnGUI, which is plenty.
|
||||
public readonly NetworkVariable<ulong> LeftPilot =
|
||||
new NetworkVariable<ulong>(NoPilot,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server);
|
||||
|
||||
public readonly NetworkVariable<ulong> RightPilot =
|
||||
new NetworkVariable<ulong>(NoPilot,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Leaver-safety wiring
|
||||
// ---------------------------------------------------------------------
|
||||
// If a pilot rage-quits (or their WiFi dies), their seat MUST free up so a
|
||||
// remaining/new player can grab it — otherwise the bot is permanently
|
||||
// half-dead. We hook client-disconnect on the server and clear any seat that
|
||||
// pointed at the departing client.
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// Only the server owns seat state, so only the server needs the hook.
|
||||
if (IsServer)
|
||||
{
|
||||
NetworkManager.Singleton.OnClientDisconnectCallback += HandleClientDisconnect;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (IsServer && NetworkManager.Singleton != null)
|
||||
{
|
||||
NetworkManager.Singleton.OnClientDisconnectCallback -= HandleClientDisconnect;
|
||||
}
|
||||
}
|
||||
|
||||
// Leaver-safety: free whichever seat the departing pilot held.
|
||||
void HandleClientDisconnect(ulong clientId)
|
||||
{
|
||||
if (LeftPilot.Value == clientId) LeftPilot.Value = NoPilot;
|
||||
if (RightPilot.Value == clientId) RightPilot.Value = NoPilot;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Claiming a seat
|
||||
// ---------------------------------------------------------------------
|
||||
// A client presses "join the bot"; we don't let it pick a side — first come
|
||||
// gets Left, second gets Right. This keeps M1 dead simple and mirrors the
|
||||
// "first two players claim left/right tread" scope.
|
||||
|
||||
/// <summary>
|
||||
/// Client asks the host for a seat. RequireOwnership is false because the
|
||||
/// asking client does NOT own the bot (the host does) — any client may ask.
|
||||
/// The sender's clientId comes from RpcParams, so a client can't lie about
|
||||
/// who it is.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, RequireOwnership = false)]
|
||||
public void ClaimSeatRpc(RpcParams rpcParams = default)
|
||||
{
|
||||
ulong requester = rpcParams.Receive.SenderClientId;
|
||||
|
||||
// Already seated? Do nothing (idempotent — safe to spam the button).
|
||||
if (LeftPilot.Value == requester || RightPilot.Value == requester)
|
||||
return;
|
||||
|
||||
// Fill Left first, then Right. Anyone after that is a spectator for M1.
|
||||
if (LeftPilot.Value == NoPilot)
|
||||
LeftPilot.Value = requester;
|
||||
else if (RightPilot.Value == NoPilot)
|
||||
RightPilot.Value = requester;
|
||||
// else: bot is full — silently ignore.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Voluntarily give up your seat (frees it as NEUTRAL). Handy for testing and
|
||||
/// for letting a player hop from Left to Right. Server-authoritative.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, RequireOwnership = false)]
|
||||
public void ReleaseSeatRpc(RpcParams rpcParams = default)
|
||||
{
|
||||
ulong requester = rpcParams.Receive.SenderClientId;
|
||||
if (LeftPilot.Value == requester) LeftPilot.Value = NoPilot;
|
||||
if (RightPilot.Value == requester) RightPilot.Value = NoPilot;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Queries used by TreadPart (host-side input gating)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>Does <paramref name="clientId"/> currently pilot <paramref name="seat"/>?
|
||||
/// TreadPart calls this on the host to decide whether to honor an input RPC.</summary>
|
||||
public bool OwnsSeat(Seat seat, ulong clientId)
|
||||
{
|
||||
return PilotOf(seat) == clientId && clientId != NoPilot;
|
||||
}
|
||||
|
||||
/// <summary>ClientId piloting a seat, or <see cref="NoPilot"/> if free.</summary>
|
||||
public ulong PilotOf(Seat seat)
|
||||
{
|
||||
return seat == Seat.Left ? LeftPilot.Value : RightPilot.Value;
|
||||
}
|
||||
|
||||
/// <summary>Which seat (if any) the local player holds — for local UI only.</summary>
|
||||
Seat? LocalSeat()
|
||||
{
|
||||
if (NetworkManager.Singleton == null) return null;
|
||||
ulong me = NetworkManager.Singleton.LocalClientId;
|
||||
if (LeftPilot.Value == me) return Seat.Left;
|
||||
if (RightPilot.Value == me) return Seat.Right;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Throwaway debug UI — good enough for playtesting with friends.
|
||||
// ---------------------------------------------------------------------
|
||||
void OnGUI()
|
||||
{
|
||||
if (!IsSpawned) return;
|
||||
|
||||
Seat? mine = LocalSeat();
|
||||
string held = mine.HasValue ? mine.Value.ToString() : "none (spectator)";
|
||||
|
||||
const int w = 260, h = 78;
|
||||
GUI.Box(new Rect(10, 10, w, h), "JANKENBOTS · seat");
|
||||
GUI.Label(new Rect(20, 32, w - 20, 20), $"You hold: {held}");
|
||||
GUI.Label(new Rect(20, 52, w - 20, 20),
|
||||
$"Left: {SeatLabel(LeftPilot.Value)} Right: {SeatLabel(RightPilot.Value)}");
|
||||
|
||||
// Client-side ask/release buttons (they just fire the ServerRpc).
|
||||
if (!mine.HasValue)
|
||||
{
|
||||
if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Claim"))
|
||||
ClaimSeatRpc();
|
||||
}
|
||||
else if (GUI.Button(new Rect(w - 70, 30, 55, 20), "Leave"))
|
||||
{
|
||||
ReleaseSeatRpc();
|
||||
}
|
||||
}
|
||||
|
||||
static string SeatLabel(ulong pilot) => pilot == NoPilot ? "free" : $"#{pilot}";
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Bot/SeatManager.cs.meta
Normal file
2
Assets/Scripts/Bot/SeatManager.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3dd8122938f14804f9e4ef7e494fb2f6
|
||||
250
Assets/Scripts/Bot/TreadPart.cs
Normal file
250
Assets/Scripts/Bot/TreadPart.cs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jankenbots.Prototype
|
||||
{
|
||||
/// <summary>
|
||||
/// JANKENBOTS M1 — ONE tread of the shared janky bot.
|
||||
///
|
||||
/// This is the HEART of the control-feel test. Everything here exists to answer
|
||||
/// one question: is it FUN for two friends to each drive one tread of the same
|
||||
/// clumsy body and try to make it go where they want together?
|
||||
///
|
||||
/// ARCHITECTURE (host-authoritative, no prediction — see the M1 cheat-sheet):
|
||||
/// * There is ONE simulated Rigidbody: the CHASSIS. Both treads are just
|
||||
/// force-emitters that push on that shared body. No tread has its own
|
||||
/// Rigidbody. Differential drive "emerges" because the two treads apply
|
||||
/// their forces at different WORLD POSITIONS (left vs right of centre).
|
||||
/// * The owning pilot's client only READS input and ships {throttle, pivotHeld,
|
||||
/// lean} to the host via an RPC. It applies NOTHING locally.
|
||||
/// * The host (IsServer) caches the latest input per tread and applies ALL
|
||||
/// forces in FixedUpdate. NGO's NetworkRigidbody/NetworkTransform on the
|
||||
/// chassis replicates the resulting motion back to everyone.
|
||||
///
|
||||
/// FEEL MODEL v0.1 — three deliberately "janky" verbs, each a literal force:
|
||||
/// (a) SNAP-TO-CRUISE THROTTLE — a tread does not have an analog gas pedal.
|
||||
/// Push the stick fully forward and it commits to a fixed CRUISE force
|
||||
/// (chunky, momentum-y, a bit out of your hands). Partial stick is
|
||||
/// proportional so you CAN feather it, but the intent is "slam it to
|
||||
/// cruise and live with the consequences" — that shared over-commitment
|
||||
/// is where the comedy of coordination comes from.
|
||||
/// (b) LOCK-PIVOT — hold a button and THIS tread plants itself as an anchor.
|
||||
/// Its drive contribution is cancelled and we actively fight the chassis'
|
||||
/// motion AT the tread's position, so the whole bot swings/rotates about
|
||||
/// this tread like a pinned foot. Two pilots learn "you plant, I drive"
|
||||
/// to turn on the spot.
|
||||
/// (c) PASSIVE LEAN — this tall bot WANTS to tip over. Nudging the stick
|
||||
/// sideways dumps a ballast torque along the drive axis to counter-roll,
|
||||
/// a constant low-key balancing chore shared between pilots.
|
||||
///
|
||||
/// Tuning fields are public so we can dial the feel live in the inspector while
|
||||
/// friends are playing. Numbers here are only sane starting points.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class TreadPart : NetworkBehaviour
|
||||
{
|
||||
// Which side of the bot this tread is. Purely descriptive for M1 (the actual
|
||||
// left/right behaviour comes from where the tread SITS on the chassis, not
|
||||
// from this enum) — but it's handy for seat-assignment logs and inspector
|
||||
// sanity, and lets us bias per-side tuning later if we want asymmetry.
|
||||
public enum Side { Left, Right }
|
||||
|
||||
[Header("Identity")]
|
||||
[Tooltip("Which tread this is. Descriptive — real behaviour comes from world position on the chassis.")]
|
||||
public Side side = Side.Left;
|
||||
|
||||
[Header("Shared body")]
|
||||
[Tooltip("The ONE simulated chassis Rigidbody every tread pushes on. Leave empty to auto-find on a parent.")]
|
||||
public Rigidbody chassis;
|
||||
|
||||
// ---- (a) SNAP-TO-CRUISE THROTTLE tuning ----------------------------------
|
||||
[Header("(a) Throttle — snap-to-cruise")]
|
||||
[Tooltip("Force (Newtons) applied at full-forward stick. This is the 'cruise' the tread snaps to. Bigger = the bot lurches harder and is twice as hard to coordinate.")]
|
||||
public float cruiseForce = 1200f;
|
||||
|
||||
[Tooltip("Below this stick magnitude we treat throttle as zero — kills drift/noise so a resting stick doesn't creep the bot.")]
|
||||
[Range(0f, 0.5f)]
|
||||
public float throttleDeadzone = 0.08f;
|
||||
|
||||
// ---- (b) LOCK-PIVOT tuning -----------------------------------------------
|
||||
[Header("(b) Lock-pivot — plant this tread as an anchor")]
|
||||
[Tooltip("How hard the planted tread resists the chassis sliding at its position. Higher = a crisper, more locked pivot; too high = the whole bot snaps rigidly and feels un-janky.")]
|
||||
public float pivotAnchorStrength = 2500f;
|
||||
|
||||
[Tooltip("Extra angular damping (torque opposing spin) while planting. Keeps the pivot from becoming a wild spin — the planted foot should feel 'stuck', not greasy.")]
|
||||
public float pivotAngularResistance = 400f;
|
||||
|
||||
// ---- (c) PASSIVE LEAN tuning ---------------------------------------------
|
||||
[Header("(c) Passive lean — anti-tip ballast")]
|
||||
[Tooltip("Ballast torque (N·m) at full sideways stick, applied along the drive (forward) axis to counter-roll the tall bot. Tune vs how tippy the chassis is.")]
|
||||
public float leanTorque = 800f;
|
||||
|
||||
[Tooltip("Below this sideways magnitude, no lean torque — resting stick = no ballast.")]
|
||||
[Range(0f, 0.5f)]
|
||||
public float leanDeadzone = 0.08f;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// HOST-SIDE cached input. These are written ONLY by the RPC (which only runs
|
||||
// on the server) and read ONLY in FixedUpdate (also gated to server). We never
|
||||
// touch them on a non-owning client, so no sync primitive is needed — the
|
||||
// authoritative simulation is entirely host-local.
|
||||
// --------------------------------------------------------------------------
|
||||
float _throttle; // 0..1, already deadzoned/clamped by the sender
|
||||
bool _pivotHeld; // is the pilot planting this tread right now?
|
||||
float _lean; // -1..1 sideways ballast request
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// Convenience: if nobody wired the chassis in the inspector, grab the
|
||||
// Rigidbody off a parent. All treads should end up pointing at the SAME
|
||||
// chassis Rigidbody — that shared reference is what makes it one bot.
|
||||
if (chassis == null)
|
||||
chassis = GetComponentInParent<Rigidbody>();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CLIENT: read local input, ship it to the host. Nothing is applied locally.
|
||||
// Runs every frame on the owning pilot only.
|
||||
// ==========================================================================
|
||||
void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
float throttle = ReadThrottle(); // 0..1
|
||||
bool pivot = ReadPivotHeld();
|
||||
float lean = ReadLean(); // -1..1
|
||||
|
||||
// One tiny packet per frame to the host. The host simulates; we watch the
|
||||
// replicated chassis move. That round-trip "lag between my stick and the
|
||||
// bot lurching" is itself part of the janky feel we're testing.
|
||||
SubmitTreadInputRpc(throttle, pivot, lean);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owning client → host. Named *Rpc + [Rpc(SendTo.Server)] per NGO 2.x.
|
||||
/// RequireOwnership stays true (default): only the pilot who owns this tread
|
||||
/// may drive it. The host just caches; forces are applied in FixedUpdate.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
void SubmitTreadInputRpc(float throttle, bool pivotHeld, float lean, RpcParams _ = default)
|
||||
{
|
||||
_throttle = Mathf.Clamp01(throttle);
|
||||
_pivotHeld = pivotHeld;
|
||||
_lean = Mathf.Clamp(lean, -1f, 1f);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HOST ONLY: turn the cached input into literal forces on the SHARED chassis.
|
||||
// This is the entire physics of the bot. Two TreadParts running this in the
|
||||
// same FixedUpdate, pushing at their two different world positions, ARE the
|
||||
// differential drive.
|
||||
// ==========================================================================
|
||||
void FixedUpdate()
|
||||
{
|
||||
if (!IsServer) return; // authority guard — clients never simulate
|
||||
if (chassis == null) return;
|
||||
|
||||
Vector3 treadPos = transform.position; // where THIS tread pushes from
|
||||
|
||||
if (_pivotHeld)
|
||||
{
|
||||
// ---- (b) LOCK-PIVOT --------------------------------------------
|
||||
// The pilot has planted this tread. We do NOT drive with it; instead
|
||||
// we make the chassis behave as if it's pinned at this tread's
|
||||
// position, so the OTHER tread's thrust swings the whole bot around
|
||||
// this point like a pivoting foot.
|
||||
//
|
||||
// 1) Cancel the sideways/linear slip AT the tread position by pushing
|
||||
// back against the local velocity there. GetPointVelocity gives the
|
||||
// chassis' velocity at this world point (includes rotation), so
|
||||
// opposing it plants the point in space.
|
||||
Vector3 pointVel = chassis.GetPointVelocity(treadPos);
|
||||
chassis.AddForceAtPosition(-pointVel * pivotAnchorStrength, treadPos, ForceMode.Force);
|
||||
|
||||
// 2) Bleed off raw spin so the pivot feels 'stuck', not greasy. This
|
||||
// is a soft angular brake, NOT a hard lock — we still want jank.
|
||||
chassis.AddTorque(-chassis.angularVelocity * pivotAngularResistance, ForceMode.Force);
|
||||
|
||||
// NOTE: no throttle drive while planting — a planted tread is an
|
||||
// anchor, not a motor. (Lean is also skipped: you're busy pivoting.)
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- (a) SNAP-TO-CRUISE THROTTLE -----------------------------------
|
||||
// throttle is 0..1. Full stick == full cruiseForce (the "snap to cruise"
|
||||
// commitment); partial stick scales it down so feathering is possible but
|
||||
// not the point. Push along the tread's own forward so a mis-aligned /
|
||||
// knocked-askew tread pushes the bot in a wonky direction — jank on
|
||||
// purpose. Applied AT the tread's world position → left+right offset =
|
||||
// differential drive (asymmetric throttle turns the bot).
|
||||
if (_throttle > throttleDeadzone)
|
||||
{
|
||||
Vector3 drive = transform.forward * (_throttle * cruiseForce);
|
||||
chassis.AddForceAtPosition(drive, treadPos, ForceMode.Force);
|
||||
}
|
||||
|
||||
// ---- (c) PASSIVE LEAN — anti-tip ballast ---------------------------
|
||||
// Sideways stick shovels ballast torque along the drive (forward) axis to
|
||||
// counter-roll the top-heavy bot. It's a constant balancing chore the
|
||||
// pilots share; it does NOT steer (that's the throttle differential).
|
||||
if (Mathf.Abs(_lean) > leanDeadzone)
|
||||
{
|
||||
chassis.AddTorque(transform.forward * (_lean * leanTorque), ForceMode.Force);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// INPUT READERS — placeholder wiring for M1. Swap for real Input System
|
||||
// actions once seats are assigned; kept trivial so the physics is testable
|
||||
// immediately. Only ever called on the owning client (inside Update's guard).
|
||||
// ==========================================================================
|
||||
|
||||
/// <summary>0..1 throttle. Vertical axis, forward only (no reverse in v0.1).</summary>
|
||||
float ReadThrottle()
|
||||
{
|
||||
// Forward-only: negative stick = 0 throttle (reverse is a later feel test).
|
||||
float v = Mathf.Max(0f, Input.GetAxisRaw("Vertical"));
|
||||
return v < throttleDeadzone ? 0f : v;
|
||||
}
|
||||
|
||||
/// <summary>Is the plant-pivot button held?</summary>
|
||||
bool ReadPivotHeld()
|
||||
{
|
||||
// Placeholder: left shift = plant. Real build: per-seat gamepad button.
|
||||
return Input.GetKey(KeyCode.LeftShift);
|
||||
}
|
||||
|
||||
/// <summary>-1..1 sideways ballast request.</summary>
|
||||
float ReadLean()
|
||||
{
|
||||
float h = Input.GetAxisRaw("Horizontal");
|
||||
return Mathf.Abs(h) < leanDeadzone ? 0f : h;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Seat lifecycle. The bot spawns owned by the server; the seat manager grants
|
||||
// a tread to a pilot via NetworkObject.ChangeOwnership(clientId) (server-only,
|
||||
// see cheat-sheet §8). These hooks just log so we can see claims land while
|
||||
// testing with friends, and clear stale input if a pilot leaves.
|
||||
// ==========================================================================
|
||||
public override void OnGainedOwnership()
|
||||
{
|
||||
base.OnGainedOwnership();
|
||||
if (IsOwner)
|
||||
Debug.Log($"[TreadPart] {side} tread claimed by local pilot (client {OwnerClientId}).");
|
||||
}
|
||||
|
||||
public override void OnLostOwnership()
|
||||
{
|
||||
base.OnLostOwnership();
|
||||
// On the host, wipe cached input so an un-piloted tread goes limp instead
|
||||
// of coasting on the last pilot's stick.
|
||||
if (IsServer)
|
||||
{
|
||||
_throttle = 0f;
|
||||
_pivotHeld = false;
|
||||
_lean = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Bot/TreadPart.cs.meta
Normal file
2
Assets/Scripts/Bot/TreadPart.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: a07e6377a09865c4e8c2b59a637e7dd1
|
||||
Loading…
Add table
Add a link
Reference in a new issue