using AcDream.Core.World;
namespace AcDream.App.Input;
///
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
///
public sealed class LocalPlayerProjectionController
{
private readonly Func _resolveEntity;
private readonly Func _liveCenterX;
private readonly Func _liveCenterY;
private readonly Action _syncShadow;
private readonly Action _rebucket;
private readonly Func _isCurrentVisibleProjection;
private readonly Action _suspendShadow;
public LocalPlayerProjectionController(
Func resolveEntity,
Func liveCenterX,
Func liveCenterY,
Action syncShadow,
Action rebucket,
Func isCurrentVisibleProjection,
Action suspendShadow)
{
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
_isCurrentVisibleProjection = isCurrentVisibleProjection
?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
_suspendShadow = suspendShadow
?? throw new ArgumentNullException(nameof(suspendShadow));
}
public void Project(
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
WorldEntity? entity = _resolveEntity();
if (entity is null)
return;
entity.SetPosition(movement.RenderPosition);
entity.ParentCellId = movement.CellId;
// Retail's root is a complete Frame. Project the authoritative body
// quaternion directly so pitch/roll from a server correction or DAT
// root frame cannot be flattened by the presentation layer.
entity.Rotation = controller.BodyOrientation;
uint currentLandblock;
if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u)
{
// Indoor positions belong to the cell's landblock. Dungeon frame
// origins can be negative, so render XYZ is not a landblock key.
currentLandblock = (movement.CellId & 0xFFFF0000u) | 0xFFFFu;
}
else
{
System.Numerics.Vector3 position = controller.Position;
int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f);
int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f);
currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF);
}
// The teleport owner alone projects the destination while the local
// controller deliberately retains its frozen source cell.
if (controller.State == PlayerState.PortalSpace)
return;
// SetPositionInternal commits the complete root before changing cell
// membership, then replaces collision rows only while the object still
// owns a loaded cell. Rebucket is a synchronous callback boundary: it
// can move this incarnation to pending or replace the GUID entirely.
_rebucket(entity.ServerGuid, currentLandblock);
if (hidden
|| movement.CellId == 0
|| !_isCurrentVisibleProjection(entity))
{
_suspendShadow(entity);
return;
}
_syncShadow(entity, movement.CellId);
}
}