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;
}
}