Fatality/KnifeDrop.cs
using System;
using System.Collections.Generic;
using SquishyTerry.Scoring;

namespace SquishyTerry;

/// <summary>
/// Fatality, part one: when the combo reaches <see cref="TriggerCombo"/>, a knife on a string drops in
/// from the top of the screen and swings until it settles, dangling in front of Terry. When the combo
/// ends it's hauled back up out of view.
/// <para>
/// The knife can be grabbed (left click) and dragged. Yank it hard enough away from where the string
/// hangs from and the string snaps off at the top: the knife is yours, string still attached, and stays
/// in your hand even after the combo ends. Let go of a snapped knife and it falls away and is removed
/// <see cref="LooseLifetime"/> seconds later. If the combo ends before the string snaps, the knife is
/// pulled out of your hand and hauled back up.
/// </para>
/// <para>
/// Once the string has snapped, drag the tip onto Terry and it sticks in. Keep pushing (in the direction the knife points) to drive
/// it deeper, up to the guard; pull back to draw it out. Let go and it stays stuck, riding along with
/// the skin as the face squishes, until you grab it and pull it out.
/// </para>
/// <para>
/// The string and knife are simulated the same way as the face: position-based dynamics. Each step
/// predicts every point from its velocity and gravity, then repeatedly enforces distance constraints
/// (rope links, and the knife as a rigid two-point bar hanging off the end), and derives the new
/// velocities from how far the points moved. It runs in fixed sub-steps, and at slow-motion speed.
/// </para>
/// Everything is placed relative to the camera, so the string always hangs from just above the top
/// edge of the view.
/// </summary>
public sealed class KnifeDrop : Component
{
	/// <summary>Combo that drops the knife.</summary>
	[Property] public int TriggerCombo { get; set; } = ScoreKeeper.FatalityCombo;

	/// <summary>Editor testing: drop the knife now (untick to haul it back up).</summary>
	[Property] public bool Preview { get; set; }

	/// <summary>How far in front of the camera the string hangs (world units; the face is ~11 away).</summary>
	[Property] public float Depth { get; set; } = 9f;

	/// <summary>Sideways offset of the anchor from the centre of the view (world units, + = right).</summary>
	[Property] public float Sideways { get; set; } = 6f;

	/// <summary>Length of the string (world units).</summary>
	[Property] public float RopeLength { get; set; } = 9f;

	/// <summary>Gravity (world units / s²).</summary>
	[Property] public float Gravity { get; set; } = 70f;

	/// <summary>How far past the string's reach the knife has to be pulled to snap it (world units).</summary>
	[Property] public float BreakStretch { get; set; } = 2.5f;

	/// <summary>How fast that pull has to be going to count as a yank (world units / s).</summary>
	[Property] public float BreakSpeed { get; set; } = 20f;

	/// <summary>Pulled this far past the reach, it snaps however slowly (world units).</summary>
	[Property] public float BreakStretchAlways { get; set; } = 6f;

	/// <summary>Seconds a snapped knife lingers after being let go before it's removed.</summary>
	[Property] public float LooseLifetime { get; set; } = 5f;

	/// <summary>The player is holding the knife.</summary>
	public bool Held => _held;

	const int Links = 18;             // rope segments
	const float KnifeLength = 5f;     // pommel to tip
	const float StepTime = 1f / 120f; // fixed physics sub-step
	const int Iterations = 12;        // constraint passes per step
	const float Damping = 0.995f;
	const float RetractSpeed = 40f;   // units / s the anchor is hauled up when the combo ends
	const float RopeThickness = 0.14f;
	const float BladeStart = 1.8f;    // guard end of the blade (from the pommel)
	const float MaxDepth = KnifeLength - BladeStart; // all the way in, up to the guard
	const float StabTilt = 0.6f;      // how steeply a stab turns into the skin (vs. the way it was held)
	const float SpurtEvery = 0.6f;
	const float MotionScale = 150f;   // face speed (simulation units / frame) that counts as squishing hard, inverted
	const float FinishHold = 2.5f;    // seconds of the fatality (X eyes, slow motion) before everything resets    // pushing this much deeper throws a fresh spurt of blood
	const float StabBlendTime = 0.08f;
	const float GrabRadius = 0.8f;     // how close to the knife a click has to be
	const float GrabStiffness = 0.35f; // per constraint pass: the hand wins, but the string still tugs back

	// Points 0..Links are the rope (0 = anchor); Links is also the knife's pommel; Links + 1 is its tip.
	Vector3[] _pos, _prev;
	float[] _invMass;
	float _linkLength;
	float _stepAcc;

	bool _active;      // simulating and visible
	bool _retracting;
	bool _loose;       // the string snapped off at the top
	float _looseTimer; // seconds since a snapped knife was let go

	bool _held;
	float _grabAlong;  // 0 = pommel, 1 = tip
	float _grabDepth;  // distance along the camera's forward of the grabbed point
	Vector3 _grabTarget;
	float _prevExcess;

	// Stuck in the face.
	SquishyFace _face;
	bool _stuck;
	bool _mustLeave;        // pulled out: the tip has to leave the face before it can stick again
	int _stuckTri;
	Vector3 _stuckBary;     // entry point on the skin
	Vector3 _stuckDirHead;  // knife direction, in the head's frame (turns with the head)
	Vector3 _pushAxis;      // on-screen direction that pushes it deeper
	float _stuckDepth;      // how far the tip is in
	float _pushBase;
	float _stuckBlend;
	Vector3 _fromPommel, _fromTip, _stuckPommel, _stuckTip;

	// The skin is farther from the camera than the plane the knife hangs in, so a stuck knife is scaled
	// up by the depth ratio to keep the size it had on screen (instead of looking tiny).
	float _stuckScale = 1f;
	float _visualScale = 1f;

	Blood _blood;
	int _wound = -1;
	float _lastSpurtDepth;
	float _bloodied;   // 0..1 how much of the blade has been in
	ModelRenderer _blade;

	StageHook _hook;
	bool _finishing;
	float _finishTimer;
	float _lift;       // how far the anchor has been hauled up
	int _prevCombo;
	ScoreKeeper _keeper;

	GameObject _root, _knife;
	readonly List<GameObject> _ropeSegments = new();

	// --- Lifecycle ---

	protected override void OnEnabled()
	{
		BuildVisuals();
		_root.Enabled = false;
	}

	protected override void OnDisabled()
	{
		if ( _keeper is not null ) _keeper.SquishLanded -= OnSquishLanded;
		_keeper = null;
		_root?.Destroy();
		_root = null;
		_ropeSegments.Clear();
	}

	protected override void OnUpdate()
	{
		if ( _keeper is null )
		{
			_keeper = Scene.GetAllComponents<ScoreKeeper>().FirstOrDefault();
			if ( _keeper is not null ) _keeper.SquishLanded += OnSquishLanded;
		}
		_face ??= Components.Get<SquishyFace>();
		_blood ??= Components.Get<Blood>();
		_hook ??= Components.Get<StageHook>();
		var camera = Scene.Camera;
		if ( camera is null ) return;

		// Drop when the combo reaches the trigger (or the preview box is ticked).
		int combo = _keeper?.Combo ?? 0;
		bool trigger = combo >= TriggerCombo && _prevCombo < TriggerCombo;
		if ( trigger && (!_active || _loose && !_held) || Preview && !_active ) Drop( camera );
		_prevCombo = combo;

		UpdateBleeding();
		if ( !_active ) return;

		// The fatality plays out (the stage hook drags him off, if there is one), then everything resets.
		if ( _finishing && _hook is null && (_finishTimer += RealTime.Delta) >= FinishHold )
		{
			EndFatality();
			if ( _face is not null ) _face.InputLocked = false;
			return;
		}

		// The combo ended with the string still attached: it's pulled out of your hand and hauled up.
		if ( !_loose && !_retracting && !Preview && combo == 0 )
		{
			if ( _stuck ) Unstick( camera );
			_retracting = true;
			_held = false;
		}

		if ( _held )
		{
			if ( Input.Down( "Attack1" ) ) _grabTarget = MouseTarget( camera );
			else _held = false;
		}

		if ( _stuck ) UpdateStuck( camera );
		else if ( _held && _loose ) TryStick( camera ); // only once the string has snapped

		if ( _retracting )
		{
			_lift += RetractSpeed * RealTime.Delta;
			// Gone once the knife is well above the top of the view.
			if ( _lift > RopeLength + KnifeLength + 12f ) { Hide(); return; }
		}

		// A snapped knife that's been let go falls away and is removed a few seconds later.
		if ( _loose && !_held && !_stuck )
		{
			_looseTimer += RealTime.Delta;
			if ( _looseTimer >= LooseLifetime ) { Hide(); return; }
		}
		else _looseTimer = 0f;

		// Fixed sub-steps, at slow-motion speed.
		_stepAcc += Time.Delta * SlowMo.Scale;
		int steps = 0;
		while ( _stepAcc >= StepTime && steps < 8 )
		{
			Step( camera );
			_stepAcc -= StepTime;
			steps++;
		}
		if ( steps == 8 ) _stepAcc = 0f;

		if ( _held && !_loose && !_stuck ) CheckSnap( camera );

		UpdateVisuals( camera );
	}

	/// <summary>Squishing him while he bleeds makes every wound spurt.</summary>
	void OnSquishLanded()
	{
		if ( _blood is not null && _blood.BleedingWounds > 0 ) _blood.PumpAll( 5 );
	}

	/// <summary>How hard the face is moving (0 still .. 1 being squished hard): bleeding is pumped by it.</summary>
	float FaceMotion()
	{
		if ( _face?.Sim is null ) return 0f;
		float motion = Math.Clamp( _face.Sim.ComputeAverageSpeed() * MotionScale, 0f, 1f );
		return _face.IsDragging ? MathF.Max( motion, 0.5f ) : motion;
	}

	/// <summary>Bleeding this frame: pumps the drips and feeds the blood score.</summary>
	void UpdateBleeding()
	{
		if ( _blood is null ) return;
		float motion = FaceMotion();
		_blood.Pump = 1f + BloodTally.MotionBoost * motion;
		if ( _keeper is not null && _keeper.Bleeding && !_finishing )
			_keeper.BloodTick( RealTime.Delta, _blood.BleedingWounds, _stuck ? _stuckDepth / MaxDepth : 0f, motion );
	}

	void Hide()
	{
		_keeper?.EndBloodRun(); // the knife went away without a fatality: the bleeding scores as it stands
		_active = false;
		_retracting = false;
		_loose = false;
		_held = false;
		_stuck = false;
		_finishing = false;
		_root.Enabled = false;
	}

	// --- Stabbing ---

	/// <summary>While held: if the tip is over Terry, it sticks into the skin under it.</summary>
	void TryStick( CameraComponent camera )
	{
		if ( _face is null ) return;

		var pommel = _pos[Links];
		var tip = _pos[Links + 1];
		var eye = camera.WorldPosition;
		if ( !_face.TraceSurface( new Ray( eye, (tip - eye).Normal ), out int tri, out var bary ) )
		{
			_mustLeave = false;
			return;
		}
		if ( _mustLeave ) return;

		// The way the knife points on screen, turned into the skin.
		var forward = camera.WorldRotation.Forward;
		var along = (tip - pommel).Normal;
		var axis = along - forward * Vector3.Dot( along, forward );
		axis = axis.Length > 0.1f ? axis.Normal : camera.WorldRotation.Down;
		var dir = (axis - _face.SurfaceNormal( tri, bary ) * StabTilt).Normal;

		var eyeForward = camera.WorldRotation.Forward;
		float heldDepth = MathF.Max( Vector3.Dot( tip - eye, eyeForward ), 0.1f );
		float skinDepth = Vector3.Dot( _face.SurfacePoint( tri, bary ) - eye, eyeForward );
		_stuckScale = Math.Clamp( skinDepth / heldDepth, 0.5f, 3f );

		_stuck = true;
		_stuckTri = tri;
		_stuckBary = bary;
		_stuckDirHead = _face.DirectionToHead( dir );
		_pushAxis = axis;
		_stuckDepth = 0.2f;
		_pushBase = _stuckDepth - Vector3.Dot( _grabTarget, _pushAxis );
		_stuckBlend = 0f;
		_fromPommel = pommel;
		_fromTip = tip;
		_invMass[Links] = 0f;
		_invMass[Links + 1] = 0f;

		_lastSpurtDepth = _stuckDepth;
		_keeper?.BloodStab();
		if ( _blood is not null )
		{
			_wound = _blood.OpenWound( tri, bary );
			_blood.Spurt( _wound, -dir, 14 );
		}
		UpdateStuck( camera );
	}

	/// <summary>Places a stuck knife on its entry point; pushing and pulling (while held) moves it in and out.</summary>
	void UpdateStuck( CameraComponent camera )
	{
		var dir = _face.DirectionFromHead( _stuckDirHead ).Normal;
		if ( _held )
		{
			float push = Vector3.Dot( _grabTarget, _pushAxis );
			_stuckDepth = MathF.Min( _pushBase + push, MaxDepth );
			if ( _stuckDepth < 0f ) { Unstick( camera ); return; }
			_pushBase = _stuckDepth - push; // pulling back responds at once, even after pushing past the guard

			// Driving it deeper makes it bleed harder.
			if ( _stuckDepth - _lastSpurtDepth > SpurtEvery )
			{
				_lastSpurtDepth = _stuckDepth;
				_blood?.Spurt( _wound, -dir, 6, 0.8f );
			}
			_lastSpurtDepth = MathF.Min( _lastSpurtDepth, _stuckDepth );
			_bloodied = MathF.Max( _bloodied, _stuckDepth / MaxDepth );

			// All the way in, up to the guard: fatality.
			if ( _stuckDepth >= MaxDepth - 0.05f && !_finishing ) Fatality( dir );
		}

		var entry = _face.SurfacePoint( _stuckTri, _stuckBary );
		_stuckTip = entry + dir * (_stuckDepth * _stuckScale);
		_stuckPommel = _stuckTip - dir * (KnifeLength * _stuckScale);

		// Ease in from where it was held, so it doesn't pop.
		_stuckBlend = MathF.Min( _stuckBlend + RealTime.Delta / StabBlendTime, 1f );
		if ( _stuckBlend < 1f )
		{
			_stuckTip = Vector3.Lerp( _fromTip, _stuckTip, _stuckBlend );
			_stuckPommel = Vector3.Lerp( _fromPommel, _stuckPommel, _stuckBlend );
		}
		_visualScale = 1f + (_stuckScale - 1f) * _stuckBlend;
		PinKnife();
	}

	/// <summary>Lights out: X eyes, a last gush, slow motion, and it counts on the leaderboard.</summary>
	void Fatality( Vector3 dir )
	{
		_finishing = true;
		_finishTimer = 0f;
		_held = false;
		if ( _face is not null )
		{
			_face.XEyes = true;
			_face.InputLocked = true; // he's done: no more stretching or squishing
		}
		_blood?.Spurt( _wound, -dir, 24, 1.3f );
		SlowMo.Trigger();
		_keeper?.RecordFatality();
		_hook?.Begin();
	}

	/// <summary>Clears the fatality away: knife, blood and X eyes (a fresh Terry is coming).</summary>
	public void EndFatality()
	{
		Hide();
		_blood?.Clear();
		if ( _face is not null ) _face.XEyes = false;
	}

	void PinKnife()
	{
		_pos[Links] = _prev[Links] = _stuckPommel;
		_pos[Links + 1] = _prev[Links + 1] = _stuckTip;
	}

	void Unstick( CameraComponent camera )
	{
		// Back into the hand, in the plane the knife is held in, at its normal size, pointing the way it
		// was stuck, and at rest: jumping there in one step would read as a huge velocity and fling it.
		var eye = camera.WorldPosition;
		var forward = camera.WorldRotation.Forward;
		var stuckDir = (_stuckTip - _stuckPommel).Normal;
		var dir = stuckDir - forward * Vector3.Dot( stuckDir, forward );
		dir = dir.Length > 0.1f ? dir.Normal : camera.WorldRotation.Down;

		var hand = _grabTarget;
		if ( !_held )
		{
			var grabbed = Vector3.Lerp( _stuckPommel, _stuckTip, _grabAlong );
			hand = eye + (grabbed - eye) * (Depth / MathF.Max( Vector3.Dot( grabbed - eye, forward ), 0.1f ));
		}
		var pommel = hand - dir * (_grabAlong * KnifeLength);
		var shift = pommel - _pos[Links];
		for ( int i = 0; i < Links; i++ )
		{
			_pos[i] += shift; // the string comes along rather than being yanked
			_prev[i] = _pos[i];
		}
		_pos[Links] = _prev[Links] = pommel;
		_pos[Links + 1] = _prev[Links + 1] = pommel + dir * KnifeLength;

		_stuck = false;
		_mustLeave = true;
		_visualScale = 1f;
		_invMass[Links] = 0.25f;
		_invMass[Links + 1] = 0.25f;
		_prevExcess = float.MaxValue; // no yank reading from the jump out

		_blood?.CloseWound( _wound, -stuckDir );
		if ( _face is not null ) _face.XEyes = false;
		_wound = -1;
	}

	// --- Grabbing ---

	/// <summary>
	/// Called on a click: grabs the knife if the ray passes close enough to it.
	/// Returns true when it did, so the click doesn't also grab the face.
	/// </summary>
	public bool TryGrab( Ray ray )
	{
		if ( !_active || _retracting || _finishing || Scene.Camera is not { } camera ) return false;

		var pommel = _pos[Links];
		var along = _pos[Links + 1] - pommel;
		float lengthSq = along.LengthSquared;
		if ( lengthSq < 1e-6f ) return false;

		// Closest points between the click ray and the knife.
		var w = ray.Position - pommel;
		float b = Vector3.Dot( ray.Forward, along );
		float d = Vector3.Dot( ray.Forward, w );
		float e = Vector3.Dot( along, w );
		float denom = lengthSq - b * b; // ray.Forward is unit length
		float visible = _stuck ? (KnifeLength - _stuckDepth) / KnifeLength : 1f; // a stuck blade is hidden in the face
		float s = denom > 1e-6f ? Math.Clamp( (e - b * d) / denom, 0f, visible ) : 0f;
		var onKnife = pommel + along * s;
		float t = MathF.Max( Vector3.Dot( onKnife - ray.Position, ray.Forward ), 0f );
		if ( (ray.Position + ray.Forward * t - onKnife).Length > GrabRadius ) return false;

		_held = true;
		_grabAlong = s;
		_grabDepth = _stuck ? Depth : Vector3.Dot( onKnife - camera.WorldPosition, camera.WorldRotation.Forward );
		_grabTarget = _stuck ? MouseTarget( camera ) : onKnife;
		_prevExcess = Excess( camera );
		if ( _stuck ) _pushBase = _stuckDepth - Vector3.Dot( _grabTarget, _pushAxis );
		return true;
	}

	/// <summary>The point under the mouse at the depth the knife was grabbed at.</summary>
	Vector3 MouseTarget( CameraComponent camera )
	{
		var ray = camera.ScreenPixelToRay( Mouse.Position );
		float facing = MathF.Max( Vector3.Dot( ray.Forward, camera.WorldRotation.Forward ), 0.1f );
		return ray.Position + ray.Forward * (_grabDepth / facing);
	}

	/// <summary>How far the hand is past what the string can reach (0 when within reach).</summary>
	float Excess( CameraComponent camera )
	{
		float reach = RopeLength + _grabAlong * KnifeLength;
		return MathF.Max( (_grabTarget - Anchor( camera )).Length - reach, 0f );
	}

	/// <summary>A hard yank (or a very long pull) snaps the string off at the top.</summary>
	void CheckSnap( CameraComponent camera )
	{
		float excess = Excess( camera );
		float speed = (excess - _prevExcess) / MathF.Max( RealTime.Delta, 1e-4f );
		_prevExcess = excess;
		if ( excess > BreakStretchAlways || excess > BreakStretch && speed > BreakSpeed ) Snap();
	}

	void Snap()
	{
		_loose = true;
		_invMass[0] = 1f; // the top end is free now, trailing behind the knife
		_prev[0] = _pos[0];
	}

	// --- Simulation ---

	Vector3 Anchor( CameraComponent camera )
	{
		float halfHeight = Depth * MathF.Tan( camera.FieldOfView.DegreeToRadian() * 0.5f );
		var rot = camera.WorldRotation;
		return camera.WorldPosition + rot.Forward * Depth + rot.Up * (halfHeight + 1.5f + _lift) + rot.Right * Sideways;
	}

	void Drop( CameraComponent camera )
	{
		_keeper?.EndBloodRun();
		int n = Links + 2;
		_pos = new Vector3[n];
		_prev = new Vector3[n];
		_invMass = new float[n];
		_linkLength = RopeLength / Links;
		_lift = 0f;
		_retracting = false;
		_loose = false;
		_held = false;
		_looseTimer = 0f;
		_stuck = false;
		_mustLeave = false;
		_visualScale = 1f;
		_bloodied = 0f;
		_wound = -1;
		if ( _face is not null ) _face.XEyes = false;
		_stepAcc = 0f;

		// Everything starts bunched at the anchor (just above the view) and falls in; a small sideways
		// nudge on the knife gives it a natural swing as the string snaps taut.
		var anchor = Anchor( camera );
		var right = camera.WorldRotation.Right;
		for ( int i = 0; i <= Links; i++ )
		{
			_pos[i] = anchor + right * (i * 0.02f);
			_prev[i] = _pos[i];
			_invMass[i] = 1f;
		}
		_invMass[0] = 0f; // pinned to the anchor
		_invMass[Links] = 0.25f; // pommel: the knife is heavy
		_pos[Links + 1] = _pos[Links] + Vector3.Down * KnifeLength;
		_prev[Links + 1] = _pos[Links + 1] - right * 0.15f;
		_invMass[Links + 1] = 0.25f;

		_active = true;
		_root.Enabled = true;
	}

	void Step( CameraComponent camera )
	{
		int n = _pos.Length;
		if ( !_loose )
		{
			_pos[0] = Anchor( camera );
			_prev[0] = _pos[0];
		}

		// Predict: velocity (from the last move) + gravity.
		var gravity = Vector3.Down * Gravity * StepTime * StepTime;
		for ( int i = _loose ? 0 : 1; i < n; i++ )
		{
			var velocity = (_pos[i] - _prev[i]) * Damping;
			_prev[i] = _pos[i];
			_pos[i] += velocity + gravity;
		}

		if ( _stuck ) PinKnife();

		// Enforce the constraints: rope links (they can stretch no further than their length, like a
		// string) and the knife as a rigid bar.
		for ( int it = 0; it < Iterations; it++ )
		{
			for ( int i = 0; i < Links; i++ ) SolveDistance( i, i + 1, _linkLength, ropeOnly: true );
			SolveDistance( Links, Links + 1, KnifeLength, ropeOnly: false );
			if ( _held ) SolveGrab();
		}
	}

	/// <summary>Pulls the grabbed point of the knife (between pommel and tip) towards the mouse.</summary>
	void SolveGrab()
	{
		int a = Links, b = Links + 1;
		float ca = 1f - _grabAlong, cb = _grabAlong;
		var point = _pos[a] * ca + _pos[b] * cb;
		float w = _invMass[a] * ca * ca + _invMass[b] * cb * cb;
		if ( w <= 1e-6f ) return;
		var correction = (_grabTarget - point) * (GrabStiffness / w);
		_pos[a] += correction * (_invMass[a] * ca);
		_pos[b] += correction * (_invMass[b] * cb);
	}

	void SolveDistance( int a, int b, float rest, bool ropeOnly )
	{
		var delta = _pos[b] - _pos[a];
		float length = delta.Length;
		if ( length < 1e-6f ) return;
		if ( ropeOnly && length <= rest ) return; // a string only pulls, it doesn't push

		float wa = _invMass[a], wb = _invMass[b];
		float w = wa + wb;
		if ( w <= 0f ) return;
		var correction = delta * ((length - rest) / (length * w));
		_pos[a] += correction * wa;
		_pos[b] -= correction * wb;
	}

	// --- Visuals ---

	void BuildVisuals()
	{
		_root = new GameObject( GameObject, true, "Fatality Knife" );
		_root.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_root.NetworkMode = NetworkMode.Never;

		var material = Material.Load( "materials/default.vmat" );
		var unitBox = Model.Builder.AddMesh( BoxMesh( material, Vector3.Zero, Vector3.One ) ).Create();

		for ( int i = 0; i < Links; i++ )
		{
			var seg = new GameObject( _root, true, $"Rope {i}" );
			var r = seg.Components.Create<ModelRenderer>();
			r.Model = unitBox;
			r.Tint = new Color( 0.78f, 0.7f, 0.52f );
			r.RenderType = ModelRenderer.ShadowRenderType.Off;
			_ropeSegments.Add( seg );
		}

		// The knife, built along +X from the pommel (0) to the tip (KnifeLength), blade facing +Z.
		_knife = new GameObject( _root, true, "Knife" );
		AddPart( _knife, "Handle", Model.Builder.AddMesh( BoxMesh( material, new Vector3( 0.8f, 0f, 0f ), new Vector3( 1.6f, 0.42f, 0.34f ) ) ).Create(), new Color( 0.32f, 0.18f, 0.09f ) );
		AddPart( _knife, "Guard", Model.Builder.AddMesh( BoxMesh( material, new Vector3( 1.7f, 0f, 0f ), new Vector3( 0.2f, 1.05f, 0.4f ) ) ).Create(), new Color( 0.18f, 0.18f, 0.2f ) );
		_blade = AddPart( _knife, "Blade", Model.Builder.AddMesh( BladeMesh( material ) ).Create(), SteelColor );
	}

	static readonly Color SteelColor = new( 0.86f, 0.88f, 0.92f );
	static readonly Color BloodySteelColor = new( 0.45f, 0.06f, 0.07f );

	static ModelRenderer AddPart( GameObject parent, string name, Model model, Color tint )
	{
		var go = new GameObject( parent, true, name );
		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;
		r.Tint = tint;
		return r;
	}

	void UpdateVisuals( CameraComponent camera )
	{
		var toCamera = -camera.WorldRotation.Forward;

		for ( int i = 0; i < Links; i++ )
		{
			var a = _pos[i];
			var b = _pos[i + 1];
			var dir = b - a;
			float len = dir.Length;
			var seg = _ropeSegments[i];
			seg.WorldPosition = (a + b) * 0.5f;
			seg.WorldRotation = len > 1e-5f ? Rotation.LookAt( dir / len, toCamera ) : Rotation.Identity;
			seg.WorldScale = new Vector3( MathF.Max( len, 0.01f ), RopeThickness, RopeThickness );
		}

		// Knife: from the pommel towards the tip, flat side to the camera.
		var pommel = _pos[Links];
		var along = _pos[Links + 1] - pommel;
		_knife.WorldPosition = pommel;
		_knife.WorldRotation = Rotation.LookAt( along.Normal, toCamera );
		_knife.WorldScale = _visualScale;
		if ( _blade is not null ) _blade.Tint = Color.Lerp( SteelColor, BloodySteelColor, _bloodied * 0.8f );
	}

	// --- Meshes ---

	/// <summary>An axis-aligned box of the given size centred on <paramref name="center"/>.</summary>
	static Mesh BoxMesh( Material material, Vector3 center, Vector3 size )
	{
		var verts = new List<Vertex>();
		var indices = new List<int>();
		var h = size * 0.5f;
		Vector3[] normals = { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down };
		foreach ( var nrm in normals )
		{
			// Two axes spanning the face.
			var u = MathF.Abs( nrm.z ) > 0.5f ? Vector3.Forward : Vector3.Up;
			var v = Vector3.Cross( nrm, u );
			int start = verts.Count;
			foreach ( var (su, sv) in new[] { (-1f, -1f), (1f, -1f), (1f, 1f), (-1f, 1f) } )
			{
				var p = nrm + u * su + v * sv;
				verts.Add( new Vertex( center + p * h, nrm, u, Vector4.Zero ) );
			}
			indices.AddRange( new[] { start, start + 1, start + 2, start, start + 2, start + 3 } );
		}
		return BuildMesh( material, verts, indices, center, size );
	}

	/// <summary>A flat blade from the guard to the tip: straight spine on top, curved edge below.</summary>
	static Mesh BladeMesh( Material material )
	{
		(float x, float y)[] outline =
		{
			(1.8f, 0.42f), (4.4f, 0.42f), (5f, 0.3f), (4.6f, 0.02f), (3.8f, -0.3f), (2.8f, -0.44f), (1.8f, -0.46f),
		};
		const float half = 0.045f;
		var verts = new List<Vertex>();
		var indices = new List<int>();

		// Front and back faces (the outline is convex, so a fan works).
		foreach ( float side in new[] { 1f, -1f } )
		{
			int start = verts.Count;
			var nrm = new Vector3( 0, 0, side );
			foreach ( var (x, y) in outline ) verts.Add( new Vertex( new Vector3( x, y, half * side ), nrm, Vector3.Forward, Vector4.Zero ) );
			for ( int i = 1; i + 1 < outline.Length; i++ )
			{
				if ( side > 0 ) indices.AddRange( new[] { start, start + i, start + i + 1 } );
				else indices.AddRange( new[] { start, start + i + 1, start + i } );
			}
		}

		// Thin edges around the outline.
		for ( int i = 0; i < outline.Length; i++ )
		{
			var (x0, y0) = outline[i];
			var (x1, y1) = outline[(i + 1) % outline.Length];
			var edge = new Vector3( x1 - x0, y1 - y0, 0 );
			var nrm = new Vector3( edge.y, -edge.x, 0 ).Normal;
			int start = verts.Count;
			verts.Add( new Vertex( new Vector3( x0, y0, half ), nrm, Vector3.Forward, Vector4.Zero ) );
			verts.Add( new Vertex( new Vector3( x1, y1, half ), nrm, Vector3.Forward, Vector4.Zero ) );
			verts.Add( new Vertex( new Vector3( x1, y1, -half ), nrm, Vector3.Forward, Vector4.Zero ) );
			verts.Add( new Vertex( new Vector3( x0, y0, -half ), nrm, Vector3.Forward, Vector4.Zero ) );
			indices.AddRange( new[] { start, start + 2, start + 1, start, start + 3, start + 2 } );
		}

		return BuildMesh( material, verts, indices, new Vector3( 3.4f, 0, 0 ), new Vector3( 3.4f, 1f, 0.2f ) );
	}

	static Mesh BuildMesh( Material material, List<Vertex> verts, List<int> indices, Vector3 center, Vector3 size )
	{
		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( verts.Count, Vertex.Layout, verts );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = BBox.FromPositionAndSize( center, size + Vector3.One * 0.1f );
		return mesh;
	}
}