Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,8 @@ private void CalculateTeleportPosition(in Entity playerEntity, ref PlayerTelepor
// Aim at the parcel center: its base corner lies on the parcel boundary, where settling
// tips the avatar into the neighbouring parcel. The exact landing XZ is refined later by
// TeleportCharacterSystem, which probes the parcel for its actual walkable floor.
const float HALF_PARCEL_SIZE = ParcelMathHelper.PARCEL_SIZE / 2f;
Vector3 targetWorldPosition = ParcelMathHelper.GetPositionByParcelPosition(parcel)
+ new Vector3(HALF_PARCEL_SIZE, 0f, HALF_PARCEL_SIZE);
+ new Vector3(ParcelMathHelper.HALF_PARCEL_SIZE, 0f, ParcelMathHelper.HALF_PARCEL_SIZE);

// Keep the landing inside the scene's parcels; if it falls outside, ValidateTeleportPosition
// clamps it to the requested parcel's base position.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Arch.Core;
using DCL.Character.CharacterMotion.Systems;
using DCL.CharacterMotion.Components;
using DCL.Ipfs;
using ECS.SceneLifeCycle;
using ECS.SceneLifeCycle.Realm;
using ECS.TestSuite;
using NSubstitute;
using NUnit.Framework;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using Utility;

namespace DCL.Character.CharacterMotion.Tests
{
public class TeleportPositionCalculationSystemShould : UnitySystemTestBase<TeleportPositionCalculationSystem>
{
private const int PARCEL = ParcelMathHelper.PARCEL_SIZE;
private const float EPSILON = 0.001f;

[SetUp]
public void Setup()
{
system = new TeleportPositionCalculationSystem(world, Substitute.For<ILandscape>());
}

/// <summary>
/// A land-on-parcel intent aims at the requested parcel's centre rather than the scene's spawn
/// point, so an event at a parcel that holds no spawn point of its own (e.g. the Theatre at 0,5
/// inside Genesis Plaza) lands on that parcel. Whether such an intent is raised at all is decided
/// upstream by <see cref="TeleportUtils.TryPickSpawnPointNameInParcel" />.
/// </summary>
[Test]
public void AimAtParcelCentreWhenIntentLandsOnParcel()
{
var baseParcel = new Vector2Int(0, 0);
var eventParcel = new Vector2Int(0, 5);

var parcels = new List<Vector2Int>();

for (int y = baseParcel.y; y <= eventParcel.y; y++)
parcels.Add(new Vector2Int(0, y));

SceneEntityDefinition sceneDef = BuildSceneDef(
baseParcel,
parcels,
MakeSpawnPoint(x: 2f, z: 2f));

Entity entity = world.Create(new PlayerTeleportIntent(sceneDef, eventParcel, Vector3.zero, CancellationToken.None, landOnParcel: true));

system!.Update(0);

Vector3 position = world.Get<PlayerTeleportIntent>(entity).Position;

Assert.That(position.x, Is.EqualTo((eventParcel.x * PARCEL) + ParcelMathHelper.HALF_PARCEL_SIZE).Within(EPSILON));
Assert.That(position.z, Is.EqualTo((eventParcel.y * PARCEL) + ParcelMathHelper.HALF_PARCEL_SIZE).Within(EPSILON));
}

private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadOnlyList<Vector2Int> parcels, params SceneMetadata.SpawnPoint[] spawnPoints)
{
var sceneSection = new SceneMetadataScene
{
DecodedBase = baseParcel,
DecodedParcels = parcels,
};

var metadata = new SceneMetadata
{
scene = sceneSection,
spawnPoints = new List<SceneMetadata.SpawnPoint>(spawnPoints),
};

return new SceneEntityDefinition("test-scene", metadata);
}

private static SceneMetadata.SpawnPoint MakeSpawnPoint(float x, float z) =>
new ()
{
name = "TestSpawn",
@default = true,
position = new SceneMetadata.SpawnPoint.Position
{
x = new SceneMetadata.SpawnPoint.Coordinate { SingleValue = x },
y = new SceneMetadata.SpawnPoint.Coordinate { SingleValue = 0f },
z = new SceneMetadata.SpawnPoint.Coordinate { SingleValue = z },
},
};
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

146 changes: 110 additions & 36 deletions Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/TeleportUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,10 @@ public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWit

if (sceneDef != null && spawnPoints is { Count: > 0 })
{
SceneMetadata.SpawnPoint spawnPoint;
Vector3 anchorWorldPosition;
LocalBounds bounds;

if (TryPickNamedSpawnPoint(spawnPoints, spawnPointName, out spawnPoint))
if (TryPickNamedSpawnPoint(spawnPoints, spawnPointName, out SceneMetadata.SpawnPoint spawnPoint))
{
// Named spawn point positions are scene-local: anchor them at the scene base parcel,
// not at the teleport target parcel
Expand All @@ -112,6 +111,107 @@ public static (Vector3 targetWorldPosition, Vector3? cameraTarget) PickTargetWit
return (targetWorldPosition, cameraTarget);
}

/// <summary>
/// Names the spawn point the creator placed in <paramref name="parcel" />, so a teleport aimed at that
/// parcel can address it through <see cref="PickTargetWithOffset" /> instead of guessing a spot itself.
/// Several spawn points reaching into the parcel are narrowed down by the ordinary rules of
/// <see cref="PickSpawnPoint" />. A nameless spawn point is not addressable and counts as absent.
/// </summary>
public static bool TryPickSpawnPointNameInParcel(SceneEntityDefinition sceneDef, Vector2Int parcel, out string spawnPointName)
{
spawnPointName = string.Empty;

List<SceneMetadata.SpawnPoint>? spawnPoints = sceneDef.metadata.spawnPoints;

if (spawnPoints is not { Count: > 0 })
return false;

Vector2Int baseParcel = sceneDef.metadata.scene.DecodedBase;
LocalBounds bounds = CalculateLocalBounds(sceneDef.metadata.scene.DecodedParcels, baseParcel);

// The parcel expressed in the same scene-local space as the spawn point coordinates
Vector2 parcelMin = new Vector2((parcel.x - baseParcel.x) * ParcelMathHelper.PARCEL_SIZE,
(parcel.y - baseParcel.y) * ParcelMathHelper.PARCEL_SIZE);

List<SceneMetadata.SpawnPoint> inParcel = ListPool<SceneMetadata.SpawnPoint>.Get();

foreach (SceneMetadata.SpawnPoint spawnPoint in spawnPoints)
if (CoversParcel(spawnPoint, in bounds, parcelMin))
inParcel.Add(spawnPoint);

if (inParcel.Count > 0)
{
Vector3 baseWorldPosition = ParcelMathHelper.GetPositionByParcelPosition(baseParcel).WithErrorCompensation();
Vector3 parcelWorldPosition = ParcelMathHelper.GetPositionByParcelPosition(parcel).WithErrorCompensation();
spawnPointName = PickSpawnPoint(inParcel, parcelWorldPosition, baseWorldPosition, in bounds).name;
}

ListPool<SceneMetadata.SpawnPoint>.Release(inParcel);

return !string.IsNullOrEmpty(spawnPointName);
}

/// <summary>
/// True when the span <paramref name="spawnPoint" /> can resolve to reaches into the parcel whose
Comment thread
popuz marked this conversation as resolved.
Outdated
/// scene-local minimum corner is <paramref name="parcelMin" />. Spawn point coordinates are
/// scene-local, so clamp them to the scene bounds exactly as <see cref="PickTargetWithOffset" /> does.
/// Borders belong to both neighbours: a spawn point sitting on a parcel edge counts as inside it.
/// </summary>
private static bool CoversParcel(SceneMetadata.SpawnPoint spawnPoint, in LocalBounds bounds, Vector2 parcelMin)
{
// An unset coordinate resolves to the parcel centre, as GetSpawnPositionOffset does
if (!TryGetClampedRange(spawnPoint.position.x, bounds.MinX, bounds.MaxX, out float minX, out float maxX))
minX = maxX = ParcelMathHelper.HALF_PARCEL_SIZE;

if (!TryGetClampedRange(spawnPoint.position.z, bounds.MinZ, bounds.MaxZ, out float minZ, out float maxZ))
minZ = maxZ = ParcelMathHelper.HALF_PARCEL_SIZE;

return maxX >= parcelMin.x && minX <= parcelMin.x + ParcelMathHelper.PARCEL_SIZE
&& maxZ >= parcelMin.y && minZ <= parcelMin.y + ParcelMathHelper.PARCEL_SIZE;
}

/// <summary>
/// The span a spawn point coordinate can resolve to, clamped to the axis bounds. False when the
/// coordinate is absent from the scene metadata, leaving the fallback to the caller — the spawn
/// position substitutes the parcel centre horizontally but the ground vertically.
/// </summary>
private static bool TryGetClampedRange(SceneMetadata.SpawnPoint.Coordinate coordinate, float axisMin, float axisMax, out float min, out float max)
{
if (coordinate.SingleValue != null)
{
min = max = Mathf.Clamp(coordinate.SingleValue.Value, axisMin, axisMax);
return true;
}

float[]? range = coordinate.MultiValue;

if (range == null)
{
min = max = 0f;
return false;
}

switch (range.Length)
{
case 0:
min = max = 0f;
return true;
case 1:
min = max = Mathf.Clamp(range[0], axisMin, axisMax);
return true;
default:
min = range[0];
max = range[1];

if (min > max)
(min, max) = (max, min);

min = Mathf.Clamp(min, axisMin, axisMax);
max = Mathf.Clamp(max, axisMin, axisMax);
return true;
}
}

private static bool TryPickNamedSpawnPoint(IReadOnlyList<SceneMetadata.SpawnPoint> spawnPoints, string? spawnPointName, out SceneMetadata.SpawnPoint spawnPoint)
{
spawnPoint = default(SceneMetadata.SpawnPoint);
Expand Down Expand Up @@ -180,48 +280,22 @@ private static SceneMetadata.SpawnPoint PickSpawnPoint(IReadOnlyList<SceneMetada

private static Vector3 GetSpawnPositionOffset(SceneMetadata.SpawnPoint spawnPoint, in LocalBounds bounds)
{
static float GetRandomPointClamped(float[] coordArray, float axisMin, float axisMax)
{
switch (coordArray.Length)
{
case 1:
return Mathf.Clamp(coordArray[0], axisMin, axisMax);
case >= 2:
{
float min = coordArray[0];
float max = coordArray[1];

if (min > max)
(min, max) = (max, min);

min = Mathf.Clamp(min, axisMin, axisMax);
max = Mathf.Clamp(max, axisMin, axisMax);

if (Mathf.Approximately(min, max))
return max;

return (float)((RANDOM.NextDouble() * (max - min)) + min);
}
default:
return 0;
}
}

// Scatter the players over the whole span the creator declared instead of stacking them on one point
static float? GetSpawnComponentClamped(SceneMetadata.SpawnPoint.Coordinate coordinate, float axisMin, float axisMax)
{
if (coordinate.SingleValue != null)
return Mathf.Clamp(coordinate.SingleValue.Value, axisMin, axisMax);
if (!TryGetClampedRange(coordinate, axisMin, axisMax, out float min, out float max))
return null;

if (coordinate.MultiValue != null)
return GetRandomPointClamped(coordinate.MultiValue, axisMin, axisMax);
if (Mathf.Approximately(min, max))
return max;

return null;
return (float)((RANDOM.NextDouble() * (max - min)) + min);
}

return new Vector3(
Comment thread
popuz marked this conversation as resolved.
Outdated
GetSpawnComponentClamped(spawnPoint.position.x, bounds.MinX, bounds.MaxX) ?? ParcelMathHelper.PARCEL_SIZE / 2f,
GetSpawnComponentClamped(spawnPoint.position.x, bounds.MinX, bounds.MaxX) ?? ParcelMathHelper.HALF_PARCEL_SIZE,
GetSpawnComponentClamped(spawnPoint.position.y, 0f, float.PositiveInfinity) ?? 0,
GetSpawnComponentClamped(spawnPoint.position.z, bounds.MinZ, bounds.MaxZ) ?? ParcelMathHelper.PARCEL_SIZE / 2f);
GetSpawnComponentClamped(spawnPoint.position.z, bounds.MinZ, bounds.MaxZ) ?? ParcelMathHelper.HALF_PARCEL_SIZE);
}

private static LocalBounds CalculateLocalBounds(IReadOnlyList<Vector2Int> sceneParcels, Vector2Int referenceParcel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,93 @@ public void ScatterWithinNamedRangeRegardlessOfTargetParcel()
Assert.Greater(distinctLandings.Count, 1, "Players must scatter within the range, not stack on one point");
}

/// <summary>
/// The "BBQ Sauce Recipe" event at -148,141: the event parcel is the very parcel holding the
/// scene's spawn point, so a teleport aimed at it must land on that spawn point instead of the
/// parcel centre, where the scene's centrepiece asset stands.
/// </summary>
[Test]
public void PickTheSpawnPointStandingInTheRequestedParcel()
{
var baseParcel = new Vector2Int(-148, 141);

SceneEntityDefinition sceneDef = BuildSceneDef(
baseParcel,
new[] { new Vector2Int(-148, 142), new Vector2Int(-147, 142), baseParcel, new Vector2Int(-147, 141) },
MakeSpawnPoint(
xRange: new[] { 0f, 3f },
yRange: new[] { 0f, 0f },
zRange: new[] { 0f, 3f },
cameraTarget: new Vector3(8f, 1f, 8f),
isDefault: true,
name: "SpawnArea1"));

Assert.That(TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, baseParcel, out string spawnPointName), Is.True);
Assert.That(spawnPointName, Is.EqualTo("SpawnArea1"));
}

/// <summary>
/// The original land-on-parcel motivation: an event at a parcel that holds no spawn point of its
/// own (e.g. the Theatre at 0,5 inside Genesis Plaza) must keep landing on that parcel.
/// </summary>
[Test]
public void PickNoSpawnPointForParcelThatHoldsNone()
{
var baseParcel = new Vector2Int(0, 0);
var farParcel = new Vector2Int(0, 5);

var parcels = new List<Vector2Int>();

for (int y = baseParcel.y; y <= farParcel.y; y++)
parcels.Add(new Vector2Int(0, y));

SceneEntityDefinition sceneDef = BuildSceneDef(
baseParcel,
parcels,
MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "main"));

Assert.That(TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, farParcel, out _), Is.False);
Assert.That(TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, baseParcel, out _), Is.True);
}

/// <summary>
/// A spawn point designated for the requested parcel wins over a default standing elsewhere: the
/// request names a parcel, and honouring the default instead would drop the player even further
/// from the spot he asked for.
/// </summary>
[Test]
public void PreferTheSpawnPointInTheParcelOverACloserDefault()
{
var baseParcel = new Vector2Int(0, 0);
var farParcel = new Vector2Int(0, 3);

SceneEntityDefinition sceneDef = BuildSceneDef(
baseParcel,
new[] { baseParcel, new Vector2Int(0, 1), new Vector2Int(0, 2), farParcel },
MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: "entrance"),
MakeSpawnPoint(xSingle: 8f, ySingle: 0f, zSingle: 50f, name: "stage"));

Assert.That(TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, farParcel, out string spawnPointName), Is.True);
Assert.That(spawnPointName, Is.EqualTo("stage"));
}

/// <summary>
/// A nameless spawn point cannot be addressed through <see cref="TeleportUtils.PickTargetWithOffset" />,
/// so it must not suppress the land-on-parcel fallback.
/// </summary>
[Test]
public void PickNoSpawnPointWhenTheOneInTheParcelIsNameless()
{
var baseParcel = new Vector2Int(0, 0);

SceneEntityDefinition sceneDef = BuildSceneDef(
baseParcel,
new[] { baseParcel },
MakeSpawnPoint(xSingle: 2f, ySingle: 0f, zSingle: 2f, isDefault: true, name: ""));

Assert.That(TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, baseParcel, out _), Is.False);
}

private static SceneEntityDefinition BuildSceneDef(Vector2Int baseParcel, IReadOnlyList<Vector2Int> parcels, params SceneMetadata.SpawnPoint[] spawnPoints)
{
var sceneSection = new SceneMetadataScene
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Utility
public static class ParcelMathHelper
{
public const int PARCEL_SIZE = 16;
public const float HALF_PARCEL_SIZE = PARCEL_SIZE / 2f;
public const float SQR_PARCEL_SIZE = PARCEL_SIZE * PARCEL_SIZE;
private const float BOUNDS_OFFSET_EPSILON = 0.3f;

Expand Down
7 changes: 7 additions & 0 deletions Explorer/Assets/DCL/RealmNavigation/TeleportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ public void StartTeleportToSpawnPoint(SceneEntityDefinition sceneDataSceneEntity

if (sceneDef != null && !TeleportUtils.IsRoad(sceneDef.metadata.OriginalJson.AsSpan()))
{
// Honor the spawn point the creator placed in the requested parcel instead of aiming at its centre
if (landOnParcel && TeleportUtils.TryPickSpawnPointNameInParcel(sceneDef, parcel, out string parcelSpawnPointName))
{
landOnParcel = false;
spawnPointName ??= parcelSpawnPointName;
}

// When landing on the exact parcel, keep the requested parcel; otherwise snap to the
// scene base so the spawn point is used.
if (!landOnParcel)
Expand Down
Loading