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.
@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>