Game/Miner.cs
namespace Monolith;
/// <summary>
/// Turns player input and drone timers into blast requests. Nothing here mutates the voxel
/// world directly: every removal goes to the host and comes back as a broadcast, so all
/// clients see identical holes.
/// </summary>
public sealed class Miner : Component
{
[Property] public CameraComponent Camera { get; set; }
/// <summary>Where the player is currently aiming, for the HUD crosshair and drone targeting.</summary>
public VoxelHit AimHit { get; private set; }
public bool HasAim => AimHit.Hit;
private float fireAccumulator;
private float droneAccumulator;
// ---------------------------------------------------------------- demolition
// The live charge and the reload cooldown are TWO INDEPENDENT TRACKS, not one state
// machine. Right click fires a charge and simultaneously starts the cooldown. From then on:
//
// - the charge bores in and waits, and left click detonates it whenever you choose;
// - the cooldown runs its own course, and a right click inside its window catches the
// reload and returns the charge instantly.
//
// Those happen in any order. Missing the reload never costs you the live charge: it still
// detonates on your next left click, you just wait the full cooldown for the next one.
// --- track A: the live charge ---
private bool chargeLive;
private bool drillStopped;
private DemoCharge chargeMarker;
private GameTimeSince timeSinceCarve;
private Vector3 drillOrigin;
private Vector3 drillDirection;
/// <summary>World units travelled from the muzzle. Covers the flight and the bore alike.</summary>
private float drillTravelled;
/// <summary>Travel at which it first entered solid rock. Negative means still in the air.</summary>
private float entryTravelled = -1f;
/// <summary>Voxels bored since entry. Zero for the whole flight.</summary>
private float drillDepth;
// --- track B: the reload ---
private bool chargeAvailable = true;
private GameTimeSince timeSinceFired = 99f;
/// <summary>True while the short randomised reload minigame is running.</summary>
private bool reloadAttemptOpen;
/// <summary>Where the target sits on this attempt's bar, 0 to 1. Rerolled every shot.</summary>
private float reloadWindowStart;
/// <summary>Set for a moment after a successful active reload, for HUD feedback.</summary>
public GameTimeSince TimeSinceActiveReload { get; private set; } = 99f;
public bool ActiveReloadJustHit => TimeSinceActiveReload < 1.0f;
/// <summary>Set for a moment after a failed attempt, for HUD feedback.</summary>
public GameTimeSince TimeSinceActiveReloadMiss { get; private set; } = 99f;
public bool ActiveReloadJustMissed => TimeSinceActiveReloadMiss < 1.0f;
/// <summary>A charge is bored into the shape and waiting for a left click.</summary>
public bool ChargeLive => chargeLive;
/// <summary>You may fire a new charge right now.</summary>
public bool ChargeReady => chargeAvailable && !chargeLive;
/// <summary>True while the 2 second reload minigame is on screen.</summary>
public bool ReloadAttemptOpen => reloadAttemptOpen;
/// <summary>Playhead across the reload minigame bar, 0 to 1.</summary>
public float ReloadAttemptProgress
=> Math.Clamp( timeSinceFired / Tuning.ActiveReloadDuration, 0f, 1f );
/// <summary>Left edge of this attempt's target, 0 to 1 along the bar.</summary>
public float ReloadWindowStart => reloadWindowStart;
public float ReloadWindowWidth => Tuning.ActiveReloadWindowWidth;
/// <summary>Progress of the long fallback cooldown, once the attempt has been missed.</summary>
public float ReloadProgress => chargeAvailable
? 1f
: Math.Clamp( timeSinceFired / CooldownLength, 0f, 1f );
/// <summary>0 to 1, how close the bore is to the ideal burial depth.</summary>
public float DrillQuality
=> Math.Clamp( drillDepth / EffectiveIdealDepth, 0f, 1f );
/// <summary>
/// Burial depth that counts as fully buried, for THIS shape.
///
/// **This is why the charge felt useless early.** `DemolitionIdealDepth` is a flat 26 voxels,
/// and stage 1 is eight voxels across. You cannot bury a charge 26 deep in an 8 deep shape,
/// so `DrillQuality` could never leave zero, the depth bonus never applied, and the opening
/// stages only ever saw the smallest possible blast. The bonus was unreachable by
/// construction rather than by play.
///
/// Scaling it to the shape means "fully buried" means the same THING at every size: about
/// forty percent of the way through the smallest axis, which is as deep as anything can
/// meaningfully go before it exits the far side.
/// </summary>
private static float EffectiveIdealDepth
{
get
{
var world = MonolithManager.Instance.IsValid()
? MonolithManager.Instance.World
: null;
if ( world == null )
return Tuning.DemolitionIdealDepth;
int shortest = Math.Min( world.Size.x, Math.Min( world.Size.y, world.Size.z ) );
return MathF.Max( 2f,
MathF.Min( Tuning.DemolitionIdealDepth, shortest * Tuning.DemolitionDepthFraction ) );
}
}
/// <summary>Cooldown length, shortened by the Cadence node in the Core Tree.</summary>
private float CooldownLength => PlayerProgress.Local.IsValid()
? PlayerProgress.Local.DemolitionCooldown
: Tuning.DemolitionCooldown;
public float ChargeCooldownLeft
=> chargeAvailable ? 0f : MathF.Max( 0f, CooldownLength - timeSinceFired );
/// <summary>True while a right click right now would catch the target.</summary>
public bool InActiveReloadWindow
{
get
{
if ( !reloadAttemptOpen ) return false;
float p = ReloadAttemptProgress;
return p >= reloadWindowStart && p <= reloadWindowStart + Tuning.ActiveReloadWindowWidth;
}
}
/// <summary>World position of the live charge, for the HUD marker.</summary>
public Vector3 LiveChargePosition => ChargePosition;
/// <summary>
/// Hard cap on shots resolved in one frame, so a frame hitch cannot burst the network.
///
/// Down from 4 to 2. This is a cap on SHOTS, but each shot is `ProjectileCount` bolts, so at
/// 21 projectiles a volley the real per-frame spike was 84 objects. Two keeps the spike
/// readable while still allowing 120 shots a second at 60fps, far more than any fire rate
/// the economy produces.
/// </summary>
private const int MaxShotsPerFrame = 2;
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
var manager = MonolithManager.Instance;
if ( !manager.IsValid() || manager.World == null || !manager.SnapshotReady )
return;
var progress = PlayerProgress.Local;
if ( !progress.IsValid() )
return;
UpdateAim( manager );
if ( Hud.CursorVisible )
{
// The upgrade panel is open, so the mouse belongs to the UI.
fireAccumulator = 0f;
}
else
{
// A left click that sets off a charge is spent on that, not on a mining shot.
bool detonated = UpdateDemolition( manager, progress );
UpdatePlayerFire( manager, progress, detonated );
}
UpdateDrones( manager, progress );
}
public void OnMonolithReset()
{
fireAccumulator = 0f;
droneAccumulator = 0f;
AimHit = default;
// The old shape is gone, so any charge boring into it is meaningless.
chargeLive = false;
drillStopped = false;
chargeAvailable = true;
reloadAttemptOpen = false;
chargeMarker?.GameObject?.Destroy();
chargeMarker = null;
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.Data.MonolithsCleared++;
progress.CubesThisMonolith = 0;
progress.Save();
}
if ( progress.IsValid() && MonolithManager.Instance.IsValid() )
progress.ReportStage( MonolithManager.Instance.Tier );
}
// ---------------------------------------------------------------- aiming
/// <summary>
/// Where the crosshair points in world space, whether or not there is rock there.
///
/// This is separate from <see cref="AimHit"/> on purpose. Firing used to be gated on
/// AimHit.Hit, so aiming at open sky produced NO SHOT AT ALL: a Spotter hanging above the
/// shape, an interceptor drifting in over open ground and a shield node were all literally
/// unshootable, which reads as "my bullets do nothing to them". A shot must always leave
/// the muzzle; whether it finds anything is the projectile's business, not the trigger's.
/// </summary>
public Vector3 AimPoint { get; private set; }
private void UpdateAim( MonolithManager manager )
{
var camera = Camera ?? Scene.Camera;
if ( !camera.IsValid() )
{
AimHit = default;
return;
}
var ray = camera.ScreenNormalToRay( new Vector2( 0.5f, 0.5f ) );
if ( manager.World.TraceRay( ray.Position, ray.Forward, Tuning.MineRayLength, out var hit ) )
{
AimHit = hit;
AimPoint = manager.World.VoxelToWorld( hit.Voxel.x, hit.Voxel.y, hit.Voxel.z );
return;
}
AimHit = default;
AimPoint = ray.Position + ray.Forward * Tuning.MineRayLength;
}
// ---------------------------------------------------------------- firing
/// <summary>
/// Right click plants a charge where you are aiming, left click sets it off. Deliberately
/// two deliberate inputs rather than a passive proc: it is the one ability you aim with
/// intent, and its cooldown never changes so the rhythm stays readable at any level.
/// </summary>
/// <summary>
/// Right click bores a charge into the rock; left click sets it off wherever it has got to.
/// Detonating on the surface wastes most of the blast on empty air, so the skill is holding
/// your nerve while it sinks. Missing the timing entirely wastes the charge.
///
/// The cooldown also carries an active reload: right clicking in the last moment of it
/// catches the charge early, and clicking too soon costs you.
/// </summary>
/// <summary>
/// Returns true if this frame's left click was consumed detonating a charge, so the normal
/// mining shot is suppressed for that one click.
/// </summary>
private bool UpdateDemolition( MonolithManager manager, PlayerProgress progress )
{
// An Unarmed run removes the ability outright, bores and active reloads included.
if ( progress.DemolitionDisabled )
return false;
UpdateReload();
UpdateLiveCharge( manager );
return TryDetonate( manager, progress );
}
/// <summary>Track B. Independent of whether a charge is currently live.</summary>
private void UpdateReload()
{
// The attempt only lasts a couple of seconds. Let it lapse if it is not taken.
if ( reloadAttemptOpen && timeSinceFired > Tuning.ActiveReloadDuration )
{
reloadAttemptOpen = false;
TimeSinceActiveReloadMiss = 0;
}
if ( !chargeAvailable && !reloadAttemptOpen && ChargeCooldownLeft <= 0f )
chargeAvailable = true;
if ( !Input.Pressed( "Attack2" ) )
return;
if ( reloadAttemptOpen )
{
// This right click is an attempt at the target, not a request to fire.
if ( InActiveReloadWindow )
{
chargeAvailable = true;
TimeSinceActiveReload = 0;
// The skill shot feeds the chain harder than anything else, which is what
// makes catching it worth the attention it costs.
var progress = PlayerProgress.Local;
if ( progress.IsValid() )
{
progress.Data.PerfectReloads++;
progress.AddResonance( Tuning.ResonanceReloadBonus );
}
}
else
{
TimeSinceActiveReloadMiss = 0;
}
// Hit or miss, the attempt is spent. Missing simply leaves you on the long
// cooldown, which is already counting from the moment you fired.
reloadAttemptOpen = false;
return;
}
if ( chargeAvailable )
TryFireCharge();
}
private void TryFireCharge()
{
// One charge at a time. Catching the reload while one is still live banks the readiness,
// so you can fire again the instant you detonate.
//
// It no longer requires the crosshair to be on ROCK. That gate meant a right click at
// open sky did nothing at all, not even a sound, which is the identical bug the left
// click had before `AimPoint` replaced `AimHit.Hit`. A charge is an area weapon; where
// it detonates is the player's decision, not a precondition for firing it.
if ( chargeLive )
return;
var manager = MonolithManager.Instance;
var camera = Camera ?? Scene.Camera;
if ( !manager.IsValid() || !camera.IsValid() )
return;
// The charge starts at the MUZZLE, not at the surface. You watch it cross the gap and
// then sink in, and you time the detonation off that whole path: the flight is part of
// the decision rather than something skipped over.
drillOrigin = MuzzlePosition;
drillDirection = camera.WorldRotation.Forward;
drillTravelled = 0f;
drillDepth = 0f;
entryTravelled = -1f;
drillStopped = false;
chargeLive = true;
chargeAvailable = false;
timeSinceFired = 0;
timeSinceCarve = 0;
// Open a fresh reload attempt with a newly randomised target, so it can never be
// played from muscle memory.
reloadAttemptOpen = true;
reloadWindowStart = Game.Random.Float( Tuning.ActiveReloadMinStart, Tuning.ActiveReloadMaxStart );
chargeMarker = DemoCharge.Spawn( Scene, drillOrigin );
}
/// <summary>Track A. Bores in, then waits indefinitely for the detonation click.</summary>
private void UpdateLiveCharge( MonolithManager manager )
{
if ( !chargeLive || drillStopped )
return;
// One continuous path: it flies at projectile speed until it meets the shape, then
// slows to boring speed once it is inside.
bool inside = entryTravelled >= 0f;
float speed = inside
? Tuning.DemolitionDrillSpeed * Tuning.VoxelSize
: Tuning.ChargeProjectileSpeed;
drillTravelled += speed * Time.Delta;
var position = ChargePosition;
if ( !inside )
{
// Has it reached the rock yet?
var voxel = manager.World.WorldToVoxel( position );
if ( manager.World.IsSolid( voxel.x, voxel.y, voxel.z ) )
{
entryTravelled = drillTravelled;
inside = true;
}
else if ( position.z <= FloorHeight )
{
// IT HIT THE FLOOR. This is the rocket jump, and it fires ITSELF rather than
// waiting for a left click. Requiring the timed detonation would have made the
// technique a two-button combo executed while airborne and already committed,
// which is not a movement tool, it is a stunt. Aim down, click, go.
RocketJump( manager, position.WithZ( FloorHeight ) );
return;
}
else if ( drillTravelled > Tuning.ChargeMaxFlight )
{
// Reached the end of its flight without meeting rock. It STOPS and waits rather
// than fizzling: a charge that vanished after a near miss punished the player
// twice, once for the miss and again with the full cooldown for nothing. It is
// an area weapon, so it can still be set off where it hangs.
drillTravelled = Tuning.ChargeMaxFlight;
drillStopped = true;
position = ChargePosition;
}
}
if ( inside )
{
drillDepth = (drillTravelled - entryTravelled) / Tuning.VoxelSize;
// Stop boring at the ideal depth and hold there. The charge is never wasted by
// waiting: it simply stops sinking and sits until you set it off.
if ( drillDepth >= EffectiveIdealDepth )
{
drillDepth = EffectiveIdealDepth;
drillTravelled = entryTravelled + drillDepth * Tuning.VoxelSize;
drillStopped = true;
position = ChargePosition;
}
}
if ( chargeMarker.IsValid() )
chargeMarker.WorldPosition = position;
// Bore a pilot hole once inside, so the descent is visible. Throttled: it goes over
// the network. Nothing is carved during the flight, obviously.
if ( inside && timeSinceCarve >= Tuning.DemolitionCarveInterval )
{
timeSinceCarve = 0;
var voxel = manager.World.WorldToVoxel( position );
if ( manager.World.InBounds( voxel.x, voxel.y, voxel.z ) )
manager.RequestBlast( voxel, Tuning.DemolitionBoreRadius, false );
}
}
/// <summary>
/// Z of the arena floor. Read from the movement component rather than stored, because the
/// floor moves with the stage: every shape sits at its own height.
/// </summary>
private float FloorHeight
{
get
{
var movement = Components.Get<PlayerMovement>();
return movement.IsValid() ? movement.FloorZ : float.MinValue;
}
}
/// <summary>
/// The rocket jump. A charge that meets the floor detonates on contact and throws you.
///
/// **What it is for.** Every other way to move is free: hops, the double jump dash, sprinting.
/// They cost nothing but timing, so there was never a decision to make about movement. This
/// one costs a CHARGE, and a charge is the single biggest mining tool in the game. Spending
/// it on distance instead of on rock is the trade, and it is the first time going faster is
/// paid for out of the same budget as clearing faster.
///
/// **It still mines.** The blast is not reduced. Fire at the floor beside the shape and you
/// get the launch AND the excavation, which turns a movement tool into a positioning problem
/// worth being good at, rather than a tax you pay to travel.
/// </summary>
private void RocketJump( MonolithManager manager, Vector3 centre )
{
var progress = PlayerProgress.Local;
var movement = Components.Get<PlayerMovement>();
if ( manager != null && progress.IsValid() )
{
// REDUCED, not removed. A floor hit that mined as hard as a buried one would make the
// timed detonation pointless: you would always fire at the ground, take the launch as
// a free extra, and never bore anything again. Scaling the CUBES rather than the
// radius keeps the reduction honest, since radius is the cube root of volume and
// cutting it directly would take far more than it looks like it does.
double cubes = Upgrades.DemolitionCubes(
progress.DemolitionLevel, manager.TotalCubes, manager.InMonolith )
* Tuning.RocketJumpBlastFraction;
float radius = Upgrades.RadiusForCubes( cubes );
manager.RequestBlast( manager.World.WorldToVoxel( centre ), radius, true );
BlastEffect.Spawn( Scene, centre, radius * Tuning.VoxelSize, true );
DetonateHazards( centre, radius * Tuning.VoxelSize );
// Still pays into the chain. It is a real detonation, just a cheaper one.
progress.AddResonance();
}
if ( movement.IsValid() )
{
var away = movement.WorldPosition - centre;
float distance = MathF.Max( 1f, away.Length );
// Falls off with distance, so a shot at your own feet launches you hardest. That is
// what makes it aimable: you choose your power by choosing where to put the charge,
// and the strongest launch is also the one that needs the most nerve.
float falloff = Math.Clamp(
1f - (distance / Tuning.RocketJumpRadius), 0f, 1f );
if ( falloff > 0f )
{
// Biased upward regardless of geometry. Straight from the blast to the player is
// almost vertical when you shoot your own feet and almost flat when you shoot
// ahead of you, and the flat case would slide you along the ground instead of
// launching. The bias makes every rocket jump a jump.
var direction = (away.Normal + Vector3.Up * Tuning.RocketJumpUpBias).Normal;
movement.AddImpulse( direction * Tuning.RocketJumpForce * falloff );
Audio.Play( Audio.Explosion, centre, 1f, Audio.Vary( 0.95f ) );
}
}
EndCharge();
}
private Vector3 ChargePosition
=> drillOrigin + drillDirection * drillTravelled;
private void EndCharge()
{
chargeLive = false;
drillStopped = false;
chargeMarker?.GameObject?.Destroy();
chargeMarker = null;
}
private bool TryDetonate( MonolithManager manager, PlayerProgress progress )
{
if ( !chargeLive || !Input.Pressed( "Attack1" ) )
return false;
var voxel = manager.World.WorldToVoxel( ChargePosition );
// Sized against the STAGE, not as a fixed sphere, so one charge means the same thing at
// stage 1 and stage 90. `InitialCount` rather than what remains: the charge should not
// get weaker as you clear the level, which would make it useless exactly when you are
// hunting the last of it.
double cubes = Upgrades.DemolitionCubes(
progress.DemolitionLevel, manager.TotalCubes, manager.InMonolith );
// The depth bonus stays, but as a bonus on top of a full blast rather than the
// difference between a blast and nothing. Burying it is rewarded twice over: the quoted
// fraction assumes solid packing, so a surface detonation already loses half its sphere
// to open air before this multiplier applies at all.
float radius = Upgrades.RadiusForCubes( cubes )
* (1f + Tuning.DemolitionDepthBonus * DrillQuality);
// Detonates wherever it is, in rock or in open air, and the sphere reaches whatever it
// reaches. Requiring the CENTRE to be a valid voxel meant a charge sitting just off the
// surface did nothing, even with most of its radius overlapping the shape. The blast is
// clamped to the world by HostHandleBlast anyway, so an out-of-bounds centre is safe.
manager.RequestBlast( voxel, radius, true );
BlastEffect.Spawn( Scene, ChargePosition, radius * Tuning.VoxelSize, true );
Audio.Play( Audio.Explosion, ChargePosition, 1f, Audio.Vary( 0.75f ) );
DetonateHazards( ChargePosition, radius * Tuning.VoxelSize );
progress.AddResonance();
// Only a fully buried detonation counts toward the Mark, so it rewards the nerve to
// let it sink rather than simply firing often.
if ( DrillQuality >= 0.999f )
progress.Data.DeepDetonations++;
EndCharge();
return true;
}
/// <summary>
/// A detonating charge kills anything caught in it.
///
/// Rock and hazards were entirely separate systems: the blast removed voxels and left every
/// machine standing in the middle of it untouched, which reads as the explosion not being
/// real. It also left the charge as the one tool with no answer to the things that exist to
/// interrupt you, so a Spotter locking on could not be solved by the biggest weapon you had.
///
/// Damage is deliberately overwhelming rather than scaled. This is a ten second cooldown
/// aimed by hand: anything standing in it should die, and a charge that merely wounded a
/// Sentinel would be a worse version of clicking on it.
/// </summary>
private void DetonateHazards( Vector3 centre, float worldRadius )
{
const int lethal = 999;
// Snapshots throughout: TakeHit destroys, and destruction mutates the list being walked.
foreach ( var spotter in Spotter.All.ToList() )
{
if ( spotter.IsValid() && spotter.WorldPosition.Distance( centre ) <= worldRadius + spotter.Radius )
spotter.TakeHit( lethal );
}
foreach ( var sentinel in Sentinel.All.ToList() )
{
if ( sentinel.IsValid() && sentinel.WorldPosition.Distance( centre ) <= worldRadius + sentinel.Radius )
sentinel.TakeHit( lethal );
}
foreach ( var crawler in Crawler.All.ToList() )
{
if ( crawler.IsValid() && crawler.WorldPosition.Distance( centre ) <= worldRadius + crawler.Radius )
crawler.TakeHit( lethal );
}
foreach ( var anchor in Anchor.All.ToList() )
{
if ( anchor.IsValid() && anchor.WorldPosition.Distance( centre ) <= worldRadius + anchor.Radius )
anchor.TakeHit( lethal );
}
foreach ( var leech in Leech.All.ToList() )
{
if ( leech.IsValid() && leech.WorldPosition.Distance( centre ) <= worldRadius )
leech.TakeHit( lethal );
}
foreach ( var interceptor in Interceptor.All.ToList() )
{
if ( interceptor.IsValid() && interceptor.WorldPosition.Distance( centre ) <= worldRadius )
interceptor.TakeHit( lethal );
}
// Incoming orbs are swept out of the air too, so a charge clears the space around you
// rather than clearing the rock and leaving the shot that was already on its way.
foreach ( var orb in SentinelOrb.All.ToList() )
{
if ( orb.IsValid() && orb.WorldPosition.Distance( centre ) <= worldRadius )
orb.Shatter();
}
}
private void UpdatePlayerFire( MonolithManager manager, PlayerProgress progress, bool detonated )
{
if ( detonated )
{
fireAccumulator = 0f;
return;
}
// NOTHING BLOCKS FIRING. Not being staggered, not anything.
//
// A stagger used to stop you shooting for 0.7s and zero the banked accumulator with it.
// On paper that was "costs time, never progress". In practice, once Sentinels started
// actually working, up to four of them landing an orb every few seconds meant a large
// fraction of every fight was spent unable to fire, with no explanation on screen. The
// only possible reading was that the gun had broken.
//
// Taking away AGENCY is worse than taking away either time or progress, and it is the
// one cost this game should never charge. Being hit still costs plenty: your momentum is
// scrubbed, which breaks a hop chain, and your Resonance drops, which is real money. Both
// are things you can see happening. Neither makes the mouse button stop working.
if ( !Input.Down( "Attack1" ) )
{
// Keep at most one banked shot so tapping stays responsive without letting
// a long hold build up a burst.
fireAccumulator = MathF.Min( fireAccumulator, 1f );
return;
}
fireAccumulator += Time.Delta * progress.DrillSpeed;
int shots = 0;
while ( fireAccumulator >= 1f && shots < MaxShotsPerFrame )
{
fireAccumulator -= 1f;
shots++;
// Always fires. See AimPoint: gating this on hitting rock made everything that flies
// above the shape unshootable.
Fire( manager, progress, AimPoint, 1f, false );
}
// CARRY the leftover instead of discarding it.
//
// This used to zero the accumulator whenever the per-frame limit was hit, which made
// firing lumpy in exactly the way reported as "a few shots, then a large shotgun blast":
// you build up a backlog, release MaxShotsPerFrame at once, throw the rest away, and
// build up again. With multi-shot at 21 bolts a volley, one of those frames is 40+
// projectiles appearing simultaneously.
//
// Clamped rather than unbounded, so a frame hitch cannot bank a huge burst, but a
// sustainable rate now flows evenly across frames.
fireAccumulator = MathF.Min( fireAccumulator, 1.5f );
}
private void UpdateDrones( MonolithManager manager, PlayerProgress progress )
{
// A Handmade run turns drones off entirely: every cube has to be one you shot.
if ( progress.DronesDisabled )
return;
// A shielded stage jams them too, so idling through it is not an option.
if ( manager.ShieldActive )
return;
int drones = progress.DroneCount;
if ( drones <= 0 ) return;
droneAccumulator += Time.Delta * drones * progress.DroneFireRate;
int shots = 0;
while ( droneAccumulator >= 1f && shots < MaxShotsPerFrame )
{
droneAccumulator -= 1f;
shots++;
if ( TryFindDroneTarget( manager, out var target ) )
{
Fire( manager, progress,
manager.World.VoxelToWorld( target.x, target.y, target.z ),
Tuning.DroneRadiusScale, true );
}
}
if ( shots >= MaxShotsPerFrame )
droneAccumulator = 0f;
}
/// <summary>
/// Muzzle position: the tip of the gun in Terry's hand.
///
/// This used to be derived from the CAMERA, which was fine in first person (bolts appeared
/// from just off-screen) and obviously wrong the moment the camera moved behind the body:
/// every shot spawned in the bottom right corner of the screen, attached to nothing.
///
/// The camera version survives only as a fallback for the frames before the avatar has
/// built its gun.
/// </summary>
private Vector3 MuzzlePosition
{
get
{
var avatar = Components.Get<PlayerAvatar>();
if ( avatar.IsValid() && avatar.HasMuzzle )
return avatar.MuzzlePosition;
var camera = Camera ?? Scene.Camera;
if ( !camera.IsValid() )
return WorldPosition;
var rot = camera.WorldRotation;
return camera.WorldPosition + rot.Forward * 30f + rot.Right * 14f + rot.Down * 12f;
}
}
/// <summary>
/// Which drone head fires next. Round robin so the ring visibly takes it in turns rather
/// than one head doing all the work while the rest sit there.
/// </summary>
private int nextDrone;
private MiningDrone NextDrone()
{
if ( MiningDrone.All.Count == 0 )
return null;
nextDrone = (nextDrone + 1) % MiningDrone.All.Count;
return MiningDrone.All[nextDrone];
}
/// <summary>
/// Sends one volley at a world point. <paramref name="fromDrone"/> matters: a drone shot is
/// not allowed to break a shield node, because a shield that clears its own jam is not a
/// shield.
/// </summary>
private void Fire( MonolithManager manager, PlayerProgress progress, Vector3 target,
float radiusScale, bool fromDrone )
{
// A volatile cube only goes off if you hit it squarely. Catching one in the blast of a
// neighbouring shot does nothing, so they stay something you aim at rather than
// something that happens to you.
//
// A drone shot leaves the nose of one of the drones overhead, not your gun. Buying a
// drone should look like buying a drone.
var muzzle = MuzzlePosition;
if ( fromDrone )
{
var drone = NextDrone();
if ( drone.IsValid() )
{
muzzle = drone.MuzzlePosition;
drone.OnFired();
}
}
var baseDirection = (target - muzzle).Normal;
// One projectile per prestige, CAPPED at what can still be read as separate bolts.
//
// Past the cap the reward is converted rather than lost: the bolts we do not draw are
// paid back as blast radius, scaled by the cube root of the ratio so the volume removed
// is unchanged. Twenty-one simultaneous bolts is not twenty-one times the feedback, it
// is a wall nobody can parse, and it was reported as a shotgun blast twice.
int owned = Math.Max( 1, progress.ProjectileCount );
int count = Upgrades.VisibleProjectiles( owned );
float radius = progress.BlastRadius * radiusScale
* Upgrades.MultiShotRadiusScale( owned );
for ( int i = 0; i < count; i++ )
{
// The first shot goes exactly where you aimed; the rest fan out so they land on
// different cubes, which is where the throughput actually comes from.
var direction = i == 0
? baseDirection
: Spread( baseDirection, Tuning.MultiShotSpreadDegrees );
// The first bolt of every volley is ESSENTIAL: it ignores the population cap, so a
// trigger pull always produces something. Only the multi-shot extras thin out under
// load, which degrades throughput rather than responsiveness.
Projectile.Fire( Scene, muzzle, direction, radius, false, false, fromDrone,
essential: i == 0 );
}
}
private static Vector3 Spread( Vector3 direction, float degrees )
{
float radians = degrees * MathF.PI / 180f;
// Random tilt in a cone around the aim direction.
var random = Vector3.Random.Normal;
var perpendicular = Vector3.Cross( direction, random ).Normal;
if ( perpendicular.Length < 0.01f )
perpendicular = Vector3.Cross( direction, Vector3.Up ).Normal;
float angle = Game.Random.Float( -radians, radians );
return (direction + perpendicular * MathF.Tan( angle )).Normal;
}
/// <summary>
/// Drones pick their own target by firing a ray at the shape from a random direction and
/// taking the first surface hit.
///
/// **That alone stops working exactly when you need it most.** A random ray through a mostly
/// empty 98^3 volume essentially never finds the last few cubes, so drones silently went
/// idle for the entire tail of every stage: the part where hunting for one cube 2,000 units
/// away is least fun and most in need of help. That was a bug, not a design.
///
/// The fallback is the manager's residue list, which is already maintained for the HUD
/// beacon. Random rays stay the fast path while the shape is full; the list takes over once
/// it is not.
/// </summary>
private bool TryFindDroneTarget( MonolithManager manager, out Vector3Int target )
{
target = default;
var world = manager.World;
var bounds = world.WorldBounds;
float outerRadius = bounds.Size.Length;
for ( int attempt = 0; attempt < 8; attempt++ )
{
var from = bounds.Center + Vector3.Random.Normal * outerRadius;
var to = new Vector3(
Game.Random.Float( bounds.Mins.x, bounds.Maxs.x ),
Game.Random.Float( bounds.Mins.y, bounds.Maxs.y ),
Game.Random.Float( bounds.Mins.z, bounds.Maxs.z ) );
if ( world.TraceRay( from, (to - from).Normal, outerRadius * 2.5f, out var hit ) )
{
target = hit.Voxel;
return true;
}
}
return manager.TryPickResidueVoxel( out target );
}
}