UI/Lockpicker.razor

A UI Razor component implementing a lockpicking minigame. It displays instructions, renders a small 3D scene with a padlock, pick and wrench, tracks mouse input to set the pick angle and tension, animates the lock cylinder, and signals success to the player.

Native Interop
@using System
@using System.Threading.Tasks
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits Panel

<root class="@(open ? "open" : "")">
	<div class="title">
		<span>Move your mouse to aim the pick, then hold [Mouse1] to turn the lock.</span>
		<span class="sub">The closer you are, the further it turns - turn it all the way to open. Press [Mouse2] to exit.</span>
	</div>
</root>

@code {
	private const float SolveTolerance = 5f;  // how close to a full 90° turn counts as open

	private bool open;
	private bool solved;
	private bool down;        // holding tension (Mouse1)
	private float correctAngle;
	private float pickAngle;
	private float rotation;   // cylinder turn, 0..90

	// 3D lock render
	private ScenePanel scenePanel;
	private SceneWorld world;
	private SceneModel padlock;
	private SceneObject wrench;
	private SceneObject pick;

	private Transform RestTransform => new( Vector3.Down * 3.5f, Rotation.From( 60, 0, 0 ) );

	public Lockpicker()
	{
		LockpickerBus.OnOpen += OnOpen;
	}

	public override void OnDeleted()
	{
		base.OnDeleted();
		LockpickerBus.OnOpen -= OnOpen;
		world?.Delete();
	}

	private void OnOpen()
	{
		open = true;
		solved = false;
		down = false;
		rotation = 0f;
		pickAngle = 0f;
		correctAngle = LockpickerBus.CorrectAngle;

		// Recreate the padlock so no animation state (turned cylinder / popped shackle) from the previous
		// lock carries over - toggling the anim params back doesn't reliably rewind a played "unlocked".
		if ( world is not null )
		{
			padlock?.Delete();
			padlock = new SceneModel( world, "models/items/lock/lock.vmdl", RestTransform );
		}
	}

	private void Close()
	{
		open = false;
		LockpickerBus.IsOpen = false;
	}

	// Mouse buttons must come through the UI button event - while the cursor is over this
	// pointer-events:all panel the game Input system never sees the clicks.
	public override void OnButtonEvent( ButtonEvent e )
	{
		base.OnButtonEvent( e );

		if ( !open )
			return;

		if ( e.Button == "mouseleft" )
			down = e.Pressed;
		else if ( e.Button == "mouseright" && e.Pressed )
			Close();
	}

	protected override void OnAfterTreeRender( bool firstTime )
	{
		base.OnAfterTreeRender( firstTime );
		if ( !firstTime )
			return;

		// Build the little 3D scene that holds the lock, wrench and pick.
		world = new SceneWorld { AmbientLightColor = Color.White * 0.35f };
		_ = new ScenePointLight( world, Vector3.Up * 15f + Vector3.Backward * 5f, 200f, Color.White );
		_ = new ScenePointLight( world, Vector3.Up * 25f + Vector3.Forward * 10f, 200f, Color.White );
		_ = new ScenePointLight( world, Vector3.Backward * 5f + Vector3.Down * 35f, 200f, Color.White * 0.5f );

		padlock = new SceneModel( world, "models/items/lock/lock.vmdl", RestTransform );
		wrench = new SceneObject( world, "models/items/wrench/wrench.vmdl", Transform.Zero );
		pick = new SceneObject( world, "models/items/pick/pick.vmdl", Transform.Zero );

		scenePanel = AddChild<ScenePanel>( "scene" );
		scenePanel.World = world;
		scenePanel.Camera.Position = Vector3.Backward * 40f;
		scenePanel.Camera.Rotation = Rotation.Identity;
		scenePanel.Camera.FieldOfView = 60f;
		scenePanel.Camera.ZNear = 5f;
		scenePanel.Camera.ZFar = 1000f;
		scenePanel.Camera.BackgroundColor = Color.Transparent;
	}

	private static float AngleDifference( float a, float b )
	{
		var d = MathF.Abs( (a - b) % 360f );
		return d > 180f ? 360f - d : d;
	}

	public override void Tick()
	{
		if ( !open )
			return;

		// Stay in sync if something else closed the minigame (e.g. the player-side Escape fail-safe).
		if ( !LockpickerBus.IsOpen )
		{
			open = false;
			return;
		}

		// `down` is driven by OnButtonEvent (UI mouse buttons), not the game Input system.

		// The pick follows the mouse only while NOT applying tension; holding Mouse1 freezes your guess so
		// you can read how far the lock turns and adjust. (Legacy Skyrim-style binding.)
		if ( !down )
		{
			var center = Screen.Size * 0.5f;
			var delta = Mouse.Position - center;
			pickAngle = MathF.Atan2( delta.y, delta.x ).RadianToDegree();
		}

		// While held, the cylinder binds partway based on how far the pick is from the sweet spot - a full
		// 90° turn only when you're dead on. This continuous "how far it turns" IS the feedback.
		var target = down
			? Math.Clamp( 90f - 90f * AngleDifference( pickAngle, correctAngle ) / 360f, 0f, 90f )
			: 0f;
		rotation = MathX.Lerp( rotation, target, Time.Delta * 10f );

		if ( !solved && down && rotation.AlmostEqual( 90f, SolveTolerance ) )
		{
			solved = true;
			_ = Solve();
		}

		UpdateScene();
	}

	private void UpdateScene()
	{
		if ( padlock is null )
			return;

		var hole = padlock.GetAttachment( "hole" ) ?? Transform.Zero;

		// Wrench sits in the keyhole providing tension.
		wrench.Transform = new Transform(
			hole.Position + padlock.Rotation.Up * 1.2f + padlock.Rotation.Forward * 0.4f,
			hole.Rotation.RotateAroundAxis( Vector3.Up, 40f ).RotateAroundAxis( Vector3.Forward, -90f ) );

		// Pick rotates with the mouse around the keyhole.
		pick.Transform = new Transform(
			hole.Position + padlock.Rotation.Up * 1.2f + padlock.Rotation.Forward * 0.1f,
			hole.Rotation
				.RotateAroundAxis( Vector3.Up, 90f )
				.RotateAroundAxis( Vector3.Forward, 45f )
				.RotateAroundAxis( Vector3.Up + Vector3.Left, pickAngle ) );

		padlock.Transform = Transform.Lerp( padlock.Transform, RestTransform, 2f * Time.Delta, true );
		padlock.SetAnimParameter( "rotate", rotation );
		padlock.Update( Time.Delta );
	}

	private async Task Solve()
	{
		Sound.Play( "sounds/lockpicking/lock_success.sound" );
		padlock?.SetAnimParameter( "unlocked", true );
		Player.Local?.FinishLockpick();

		await Task.DelayRealtimeSeconds( 0.5f );
		Close();
	}

	protected override int BuildHash() => HashCode.Combine( open );
}

<style>
	Lockpicker {
		position: absolute;
		top: 0; left: 0;
		width: 100%;
		height: 100%;
		justify-content: center;
		align-items: center;
		pointer-events: none;
		opacity: 0;
		background-color: rgba(0,0,0,0.4);
		backdrop-filter: blur(4px);

		&.open {
			opacity: 1;
			pointer-events: all;
		}

		.title {
			position: absolute;
			top: 180px;
			flex-direction: column;
			align-items: center;
			color: white;
			font-size: 30px;
			text-shadow: 3px 3px 0px black;
			z-index: 2;

			.sub { font-size: 22px; color: rgba(200,200,200,1); margin-top: 8px; }
		}

		.scene {
			position: absolute;
			width: 100%;
			height: 100%;
			pointer-events: none;
		}
	}
</style>