Voxel/Stages.cs
namespace Monolith;
public enum ShapeKind
{
/// <summary>Solid box. Size decides whether it reads as a cube, a slab or a spire.</summary>
Box,
/// <summary>Solid ellipsoid inscribed in the size box.</summary>
Sphere,
/// <summary>Torus lying flat in XY. All edges, calves constantly.</summary>
Ring,
/// <summary>Box with a regular grid of shafts bored through all three axes.</summary>
Lattice,
/// <summary>Several overlapping blobs drifting together.</summary>
Cluster,
}
public sealed class StageDef
{
public string Name;
public ShapeKind Shape;
public Vector3Int Size;
/// <summary>Flavour line shown when the stage condenses.</summary>
public string Tagline;
}
/// <summary>
/// The ladder of shapes. Clearing one condenses the next.
///
/// Sizes are the REAL tuning, derived from a simulated greedy upgrade buyer: each archetype is
/// around 600 cubes at stage 1, growing by <see cref="Tuning.StageSizeGrowth"/> per stage so
/// that stage 100 lands just inside <see cref="Tuning.StageMaxAxis"/> and a min-maxing player
/// takes roughly 18 minutes for the full ladder.
/// </summary>
public static class Stages
{
public static readonly StageDef[] All =
{
new()
{
Name = "The Seed",
Shape = ShapeKind.Box,
Size = new Vector3Int( 7, 7, 6 ),
Tagline = "A small thing. Break it.",
},
new()
{
Name = "The Slab",
Shape = ShapeKind.Box,
Size = new Vector3Int( 12, 12, 2 ),
Tagline = "Wide and thin. Cut underneath it.",
},
new()
{
Name = "The Spire",
Shape = ShapeKind.Box,
Size = new Vector3Int( 5, 5, 12 ),
Tagline = "Take the base and the rest follows.",
},
new()
{
Name = "The Sphere",
Shape = ShapeKind.Sphere,
Size = new Vector3Int( 9, 9, 9 ),
Tagline = "No flat faces to hide behind.",
},
new()
{
Name = "The Ring",
Shape = ShapeKind.Ring,
Size = new Vector3Int( 14, 14, 5 ),
Tagline = "All edge, no core.",
},
new()
{
Name = "The Lattice",
Shape = ShapeKind.Lattice,
Size = new Vector3Int( 11, 11, 11 ),
Tagline = "Thin members. Everything wants to fall.",
},
new()
{
Name = "The Monolith",
Shape = ShapeKind.Box,
Size = new Vector3Int( 7, 7, 6 ),
Tagline = "The real one.",
},
new()
{
Name = "The Cluster",
Shape = ShapeKind.Cluster,
Size = new Vector3Int( 14, 14, 9 ),
Tagline = "It never was one piece.",
},
};
/// <summary>
/// The eight authored shapes are archetypes. The ladder cycles through them forever,
/// growing each time round, so stage 100 is the same shape vocabulary at a far larger
/// scale rather than 92 shapes nobody wrote. See <see cref="Tuning.PrestigeStageRequirement"/>
/// for where Collapse becomes available.
/// </summary>
public static StageDef Get( int index )
{
if ( All.Length == 0 )
return null;
if ( index < 0 ) index = 0;
return All[index % All.Length];
}
/// <summary>How many full cycles of the archetype list we are into.</summary>
public static int CycleOf( int stageIndex ) => Math.Max( 0, stageIndex ) / All.Length;
/// <summary>
/// Grows smoothly **per stage**, not per cycle. A per-cycle step made every eighth stage a
/// wall and the seven between it trivial; a gentle per-stage curve keeps each one feeling
/// slightly meatier than the last. See <see cref="Tuning.StageSizeGrowth"/> for the pacing
/// arithmetic behind the constant.
/// </summary>
public static Vector3Int SizeFor( int stageIndex )
{
var def = Get( stageIndex );
if ( def == null )
return new Vector3Int( 20, 20, 20 );
if ( stageIndex <= 0 )
return def.Size;
float scale = MathF.Pow( Tuning.StageSizeGrowth, stageIndex );
int max = Tuning.StageMaxAxis;
return new Vector3Int(
Math.Clamp( (int)(def.Size.x * scale), 8, max ),
Math.Clamp( (int)(def.Size.y * scale), 8, max ),
Math.Clamp( (int)(def.Size.z * scale), 8, max ) );
}
public static string NameFor( int stageIndex )
{
var def = Get( stageIndex );
if ( def == null ) return "Monolith";
int cycle = CycleOf( stageIndex );
if ( cycle <= 0 )
return def.Name;
// Greek suffix per cycle reads better than "+3" and suggests escalation.
string[] tiers = { "", " II", " III", " IV", " V", " VI", " VII", " VIII", " IX", " X" };
return cycle < tiers.Length ? def.Name + tiers[cycle] : $"{def.Name} +{cycle}";
}
// ---------------------------------------------------------------- volatile cubes
/// <summary>
/// Density of volatile blocks, driven by the local player's Instability upgrade. Set by
/// <see cref="PlayerProgress"/>; kept as a static so the mesher and the hit test cannot
/// disagree about which blocks are volatile.
///
/// **Multiplayer caveat:** this is a per-player value applied to a shared world, so on the
/// shared Monolith two players with different Instability see different volatile blocks.
/// Fine for co-op against a rock, and it is written down here rather than discovered later.
/// Making it authoritative would mean seeding it from the host per world.
/// </summary>
public static float VolatileChance { get; set; } = Tuning.VolatileChanceBase;
/// <summary>
/// Volatile blocks render bright red and detonate hard, but only on a **direct hit**. They
/// are a pure hash of position, so nothing is stored per voxel and nothing is sent over the
/// wire: changing the density simply changes how many of the same ordering qualify.
/// </summary>
public static bool IsVolatile( int x, int y, int z )
{
uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(z * 83492791);
// Avalanche the low bits, otherwise a plain modulo on a lattice of coordinates picks
// out regular planes rather than a scatter.
h ^= h >> 13;
h *= 0x5bd1e995;
h ^= h >> 15;
return h % 10000u < (uint)(VolatileChance * 10000f);
}
// ---------------------------------------------------------------- shape fill
/// <summary>True if this voxel is solid for the given shape. Pure function of position,
/// so host and clients generate byte-identical worlds with no data transfer.</summary>
public static bool IsSolid( ShapeKind shape, Vector3Int size, int x, int y, int z )
{
switch ( shape )
{
case ShapeKind.Sphere:
return InEllipsoid( size, x, y, z, 1f );
case ShapeKind.Ring:
return InRing( size, x, y, z );
case ShapeKind.Lattice:
return InLattice( x, y, z );
case ShapeKind.Cluster:
return InCluster( size, x, y, z );
default:
return true;
}
}
private static bool InEllipsoid( Vector3Int size, int x, int y, int z, float scale )
{
float rx = size.x * 0.5f * scale;
float ry = size.y * 0.5f * scale;
float rz = size.z * 0.5f * scale;
float dx = (x - size.x * 0.5f + 0.5f) / rx;
float dy = (y - size.y * 0.5f + 0.5f) / ry;
float dz = (z - size.z * 0.5f + 0.5f) / rz;
return dx * dx + dy * dy + dz * dz <= 1f;
}
private static bool InRing( Vector3Int size, int x, int y, int z )
{
float cx = size.x * 0.5f - 0.5f;
float cy = size.y * 0.5f - 0.5f;
float cz = size.z * 0.5f - 0.5f;
// Major radius sits between the centre and the outer edge; minor radius fills the rest.
float major = MathF.Min( size.x, size.y ) * 0.32f;
float minor = MathF.Min( MathF.Min( size.x, size.y ) * 0.16f, size.z * 0.5f );
float dx = x - cx;
float dy = y - cy;
float dz = z - cz;
float planar = MathF.Sqrt( dx * dx + dy * dy ) - major;
return planar * planar + dz * dz <= minor * minor;
}
private static bool InLattice( int x, int y, int z )
{
// Bore square shafts through all three axes on a regular pitch, leaving a frame.
const int pitch = 12;
const int hole = 7;
bool holeX = (y % pitch) < hole && (z % pitch) < hole;
bool holeY = (x % pitch) < hole && (z % pitch) < hole;
bool holeZ = (x % pitch) < hole && (y % pitch) < hole;
return !(holeX || holeY || holeZ);
}
/// <summary>Blob centres as fractions of the size box. Fixed, not random, so every client
/// generates the same cluster with nothing sent over the wire.</summary>
private static readonly Vector3[] ClusterCentres =
{
new( 0.30f, 0.32f, 0.50f ),
new( 0.70f, 0.38f, 0.44f ),
new( 0.50f, 0.68f, 0.56f ),
new( 0.24f, 0.70f, 0.40f ),
new( 0.78f, 0.72f, 0.62f ),
};
private static bool InCluster( Vector3Int size, int x, int y, int z )
{
float radius = MathF.Min( size.x, size.y ) * 0.19f;
foreach ( var c in ClusterCentres )
{
float dx = x - c.x * size.x;
float dy = y - c.y * size.y;
float dz = z - c.z * size.z;
if ( dx * dx + dy * dy + dz * dz <= radius * radius )
return true;
}
return false;
}
}