A single-file falling-sand simulation component for s&box. It implements a cellular automata with ~30 materials, painting tools, scenarios, save/load (RLE to a data file), dynamic texture rendering with glow, audio hooks, HUD setup, and progression/milestone tracking.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sandbox.UI;
namespace Science;
public enum Element
{
Empty,
Wall,
Sand,
Water,
Oil,
Fire,
Smoke,
Steam,
Gas,
Cloud,
TNT,
C4,
Bomb,
Nitro,
Spark,
Acid,
Plant,
Seed,
Vine,
Fuse,
Metal,
Stone,
Glass,
Ice,
Salt,
Mud,
Lava,
Mercury,
Virus,
Clone,
Eraser
}
public enum ElementCategory
{
Powders,
Liquids,
Gases,
Energy,
Life,
Explosives,
Solids,
Tools
}
/// <summary>
/// Science - a falling-sand sandbox. A cellular-automata simulation of ~30
/// reactive materials, rendered to a single dynamic texture (with baked glow)
/// that is presented through the 2D HUD.
/// </summary>
public sealed class ScienceGame : Component
{
[Property] public int GridWidth { get; set; } = 160;
[Property] public int GridHeight { get; set; } = 100;
// --- Public state read by the HUD -------------------------------------
public Element CurrentTool { get; private set; } = Element.Sand;
public int BrushRadius { get; private set; } = 4;
public bool Paused { get; private set; }
public int Particles { get; private set; }
public int Reactions { get; private set; }
public int Discoveries => _discovered.Count;
public float SpeedScale { get; private set; } = 1f;
public string LastReaction { get; private set; } = "Ready";
public string CurrentToolName => ToolName( CurrentTool );
public Texture GridTexture { get; private set; }
public IReadOnlyList<string> RecentReactions => _recentReactions;
public IReadOnlyList<string> Milestones => _milestones;
public bool ShowGrid { get; set; } = true;
public string ActiveExperimentName { get; private set; } = "Open Lab";
public string ObjectiveText { get; private set; } = "Create reactions and unlock milestones.";
public string ObjectiveProgress => _objectiveComplete
? "Complete"
: $"{Math.Clamp( Reactions - _objectiveStartReactions, 0, _objectiveTarget )} / {_objectiveTarget} reactions";
public float ObjectivePercent => _objectiveTarget <= 0
? 1f
: Math.Clamp( (Reactions - _objectiveStartReactions) / (float)_objectiveTarget, 0f, 1f );
public bool ObjectiveComplete => _objectiveComplete;
public int CompletedExperiments => _completedExperiments;
public int LabScore => Discoveries * 150 + Reactions * 10 + CompletedExperiments * 500 + Particles / 25;
public string LabRank => LabScore switch
{
>= 7000 => "Director",
>= 4500 => "Senior Scientist",
>= 2500 => "Research Lead",
>= 1200 => "Technician",
_ => "Intern"
};
const float SimulationRate = 30f;
const int MaxBrushRadius = 14;
const int MinBrushRadius = 1;
// Supersample factor: each grid cell becomes SS x SS texture pixels so glow
// has room to bleed and blocks get a subtle bevel.
const int SS = 4;
Element[,] _grid;
int[,] _life;
int[,] _updated;
readonly Random _random = new();
int _tickId;
float _stepAccumulator;
bool _renderDirty = true;
bool _stepRequested;
// Render buffers (allocated once).
int _texW, _texH;
Color32[] _pixels;
Color32[] _background;
float[] _glowR, _glowG, _glowB;
float[] _glowKernel;
int _glowRadius;
// Discovery / lab-notebook tracking.
readonly HashSet<string> _discovered = new();
readonly List<string> _recentReactions = new();
readonly List<string> _milestones = new();
readonly HashSet<string> _milestoneKeys = new();
int _objectiveStartReactions;
int _objectiveTarget = 6;
bool _objectiveComplete;
int _completedExperiments;
// Painting input is fed from the HUD canvas rectangle (screen pixels).
Rect _canvasRect;
bool _pointerInCanvas;
// Brush hover preview (drawn into the texture so it reads on the canvas).
int _hoverX, _hoverY;
bool _hasHover;
// Procedural audio.
ScienceAudio _audio;
public bool Muted => _audio?.Muted ?? false;
// Attract mode: the menu runs a live, self-seeding simulation as its backdrop.
public bool AttractMode { get; private set; }
float _attractAccumulator;
int _attractCycle;
const string SaveFile = "science_sandbox.txt";
static readonly Element[] ToolOrder =
{
Element.Sand, Element.Water, Element.Oil, Element.Fire, Element.Smoke,
Element.Steam, Element.Gas, Element.Cloud, Element.Spark, Element.Acid,
Element.Plant, Element.Seed, Element.Vine, Element.Salt, Element.Mud,
Element.Ice, Element.Lava, Element.Mercury, Element.TNT, Element.C4,
Element.Bomb, Element.Nitro, Element.Fuse, Element.Metal, Element.Stone,
Element.Glass, Element.Virus, Element.Clone, Element.Wall, Element.Eraser
};
public IReadOnlyList<Element> ToolPalette => ToolOrder;
// --- Colours ----------------------------------------------------------
static readonly Color EmptyColor = new( 0.05f, 0.06f, 0.08f );
static readonly Color WallColor = new( 0.34f, 0.37f, 0.42f );
static readonly Color SandColor = new( 0.92f, 0.74f, 0.34f );
static readonly Color WaterColor = new( 0.16f, 0.46f, 0.92f, 0.92f );
static readonly Color OilColor = new( 0.16f, 0.13f, 0.08f );
static readonly Color FireColor = new( 1.00f, 0.40f, 0.06f );
static readonly Color SmokeColor = new( 0.40f, 0.43f, 0.46f, 0.70f );
static readonly Color SteamColor = new( 0.78f, 0.88f, 0.95f, 0.72f );
static readonly Color GasColor = new( 0.58f, 0.92f, 0.42f, 0.58f );
static readonly Color CloudColor = new( 0.70f, 0.78f, 0.86f, 0.64f );
static readonly Color TntColor = new( 0.86f, 0.14f, 0.12f );
static readonly Color C4Color = new( 0.94f, 0.52f, 0.22f );
static readonly Color BombColor = new( 0.12f, 0.13f, 0.15f );
static readonly Color NitroColor = new( 0.92f, 0.16f, 0.72f, 0.92f );
static readonly Color SparkColor = new( 1.00f, 0.95f, 0.45f );
static readonly Color AcidColor = new( 0.45f, 1.00f, 0.22f );
static readonly Color PlantColor = new( 0.20f, 0.72f, 0.26f );
static readonly Color SeedColor = new( 0.52f, 0.32f, 0.10f );
static readonly Color VineColor = new( 0.10f, 0.50f, 0.16f );
static readonly Color FuseColor = new( 0.76f, 0.52f, 0.26f );
static readonly Color MetalColor = new( 0.62f, 0.67f, 0.72f );
static readonly Color StoneColor = new( 0.38f, 0.38f, 0.40f );
static readonly Color GlassColor = new( 0.66f, 0.90f, 1.00f, 0.62f );
static readonly Color IceColor = new( 0.56f, 0.82f, 1.00f, 0.88f );
static readonly Color SaltColor = new( 0.93f, 0.92f, 0.84f );
static readonly Color MudColor = new( 0.36f, 0.24f, 0.12f );
static readonly Color LavaColor = new( 1.00f, 0.42f, 0.06f );
static readonly Color MercuryColor = new( 0.80f, 0.84f, 0.90f );
static readonly Color VirusColor = new( 0.82f, 0.12f, 1.00f );
static readonly Color CloneColor = new( 0.24f, 1.00f, 0.80f );
// =====================================================================
// Lifecycle
// =====================================================================
protected override void OnStart()
{
base.OnStart();
_grid = new Element[GridWidth, GridHeight];
_life = new int[GridWidth, GridHeight];
_updated = new int[GridWidth, GridHeight];
BuildRenderResources();
EnsureCameraBackground();
DisableSkybox();
EnsureAudio();
EnsureHud();
ClearWorld();
SetAttractMode( true );
LastReaction = "Ready";
#pragma warning disable CS0612
try { Mouse.Visible = true; } catch { }
#pragma warning restore CS0612
Log.Info( "Science ready." );
}
protected override void OnUpdate()
{
HandleInput();
if ( AttractMode )
UpdateAttract();
if ( !Paused )
{
_stepAccumulator += Time.Delta * SpeedScale;
float stepTime = 1f / SimulationRate;
int maxSteps = SpeedScale > 2f ? 4 : 2;
int steps = 0;
while ( _stepAccumulator >= stepTime && steps < maxSteps )
{
_stepAccumulator -= stepTime;
SimulateStep();
steps++;
}
}
else if ( _stepRequested )
{
SimulateStep();
}
_stepRequested = false;
if ( _renderDirty )
{
RenderToTexture();
_renderDirty = false;
}
if ( !AttractMode )
UpdateProgression();
}
// =====================================================================
// Public commands (called from the HUD)
// =====================================================================
public void SelectTool( Element tool )
{
CurrentTool = tool;
LastReaction = ToolName( tool );
_audio?.Select();
}
// Lightweight hooks the HUD calls so its buttons feel responsive.
public void PlayClick() => _audio?.Click();
public void ToggleMute()
{
if ( _audio == null ) return;
_audio.Muted = !_audio.Muted;
LastReaction = _audio.Muted ? "Muted" : "Sound on";
if ( !_audio.Muted ) _audio.Click();
}
public void IncreaseBrush() => BrushRadius = Math.Min( MaxBrushRadius, BrushRadius + 1 );
public void DecreaseBrush() => BrushRadius = Math.Max( MinBrushRadius, BrushRadius - 1 );
public void TogglePause()
{
Paused = !Paused;
LastReaction = Paused ? "Paused" : "Running";
}
public void StepOnce()
{
if ( !Paused ) Paused = true;
_stepRequested = true;
LastReaction = "Stepped";
}
public void CycleSpeed()
{
SpeedScale = SpeedScale switch
{
<= 0.5f => 1f,
<= 1f => 2f,
<= 2f => 4f,
_ => 0.5f
};
LastReaction = $"Speed {SpeedScale:0.#}x";
}
public void ToggleGrid()
{
ShowGrid = !ShowGrid;
BuildBackground();
_renderDirty = true;
}
public void SetCanvasRect( Rect rect, bool pointerInside )
{
_canvasRect = rect;
_pointerInCanvas = pointerInside;
}
public void ClearWorld()
{
Array.Clear( _grid, 0, _grid.Length );
Array.Clear( _life, 0, _life.Length );
BuildBoundaryCells();
Particles = CountParticles();
LastReaction = "Cleared";
_renderDirty = true;
}
// =====================================================================
// Attract mode (live menu backdrop)
// =====================================================================
public void SetAttractMode( bool on )
{
if ( AttractMode == on ) return;
AttractMode = on;
ClearWorld();
Paused = false;
_attractAccumulator = 0f;
LastReaction = on ? "Idle lab" : "Ready";
}
// Continuously drips a rotating palette of materials from the top so the
// menu is a constantly-evolving, self-reacting scene rather than a still.
void UpdateAttract()
{
_attractAccumulator += Time.Delta;
if ( _attractAccumulator < 0.10f ) return;
_attractAccumulator -= 0.10f;
// Let the heap breathe: stop feeding once it's comfortably full.
if ( Particles > GridWidth * GridHeight * 0.34f ) return;
_attractCycle++;
int top = GridHeight - 3;
SpawnBlob( 5 + _random.Next( GridWidth - 10 ), top, AttractMaterial(), 3 );
// A second emitter weighted toward glow keeps the scene lit and reactive.
if ( _attractCycle % 3 == 0 )
{
Element hot = _random.Next( 2 ) == 0 ? Element.Lava : Element.Fire;
SpawnBlob( 5 + _random.Next( GridWidth - 10 ), top, hot, 2 );
}
}
Element AttractMaterial()
{
int r = _random.Next( 100 );
if ( r < 24 ) return Element.Sand;
if ( r < 44 ) return Element.Water;
if ( r < 58 ) return Element.Oil;
if ( r < 68 ) return Element.Salt;
if ( r < 78 ) return Element.Plant;
if ( r < 86 ) return Element.Acid;
if ( r < 93 ) return Element.Lava;
return Element.Spark;
}
void SpawnBlob( int cx, int cy, Element element, int radius )
{
int radiusSquared = radius * radius;
for ( int y = cy - radius; y <= cy + radius; y++ )
{
for ( int x = cx - radius; x <= cx + radius; x++ )
{
if ( !InBounds( x, y ) || IsBoundaryCell( x, y ) ) continue;
int dx = x - cx;
int dy = y - cy;
if ( dx * dx + dy * dy > radiusSquared ) continue;
if ( _random.NextDouble() > 0.68 ) continue;
if ( _grid[x, y] == Element.Empty )
SetCell( x, y, element );
}
}
_renderDirty = true;
}
// =====================================================================
// Scenario presets
// =====================================================================
public void LoadScenario( int index )
{
switch ( index )
{
case 0:
ScenarioMixedLab();
StartExperiment( "Mixed Lab", "Use the starter samples to trigger six reactions.", 6 );
break;
case 1:
ScenarioVolcano();
StartExperiment( "Volcano", "Turn heat, water, and stone into five chain reactions.", 5 );
break;
case 2:
ScenarioGarden();
StartExperiment( "Garden", "Grow, burn, sprout, or flood four live reactions.", 4 );
break;
case 3:
ScenarioDemolition();
StartExperiment( "Demolition", "Wire the structure and cause four blast events.", 4 );
break;
default:
ScenarioMixedLab();
StartExperiment( "Mixed Lab", "Use the starter samples to trigger six reactions.", 6 );
break;
}
}
void ScenarioMixedLab()
{
ClearWorld();
for ( int x = 16; x < 52; x++ ) SetCell( x, GridHeight - 14 + _random.Next( 0, 4 ), Element.Sand );
for ( int x = 64; x < 100; x++ ) SetCell( x, GridHeight - 16 + _random.Next( 0, 4 ), Element.Water );
for ( int x = 104; x < 132; x++ ) SetCell( x, GridHeight - 20 + _random.Next( 0, 3 ), Element.Oil );
for ( int x = 54; x < 70; x++ ) { SetCell( x, 14, Element.TNT ); SetCell( x, 15, Element.TNT ); }
for ( int x = 12; x < 28; x++ ) SetCell( x, 12 + _random.Next( 0, 4 ), Element.Plant );
for ( int x = 130; x < 146; x++ ) SetCell( x, 48 + _random.Next( 0, 4 ), Element.Acid );
for ( int x = 26; x < 46; x++ ) SetCell( x, 56 + _random.Next( 0, 3 ), Element.Salt );
for ( int x = 78; x < 94; x++ ) SetCell( x, 66 + _random.Next( 0, 2 ), Element.Lava );
for ( int x = 108; x < 122; x++ ) SetCell( x, 38 + _random.Next( 0, 3 ), Element.Mercury );
for ( int x = 36; x < 50; x++ ) SetCell( x, 30, Element.Metal );
SetCell( 72, 24, Element.Fuse ); SetCell( 73, 24, Element.Fuse ); SetCell( 74, 24, Element.C4 );
SetCell( 118, 74, Element.Cloud ); SetCell( 119, 74, Element.Cloud );
SetCell( 44, 80, Element.Clone ); SetCell( 45, 80, Element.Sand );
Particles = CountParticles();
LastReaction = "Mixed Lab";
_renderDirty = true;
}
void ScenarioVolcano()
{
ClearWorld();
int cx = GridWidth / 2;
for ( int x = 0; x < GridWidth; x++ )
{
int slope = Math.Abs( x - cx );
int top = Math.Max( 4, 30 - slope / 2 );
for ( int y = 1; y < top; y++ )
SetCell( x, y, Element.Stone );
}
for ( int x = cx - 3; x <= cx + 3; x++ )
for ( int y = 1; y < 26; y++ )
SetCell( x, y, Element.Lava );
for ( int x = 8; x < 40; x++ ) SetCell( x, GridHeight - 12 + _random.Next( 0, 3 ), Element.Water );
for ( int x = 60; x < 90; x++ ) SetCell( x, 44 + _random.Next( 0, 3 ), Element.Plant );
Particles = CountParticles();
LastReaction = "Volcano";
_renderDirty = true;
}
void ScenarioGarden()
{
ClearWorld();
for ( int x = 2; x < GridWidth - 2; x++ )
for ( int y = 1; y < 8; y++ )
SetCell( x, y, Element.Mud );
for ( int x = 10; x < GridWidth - 10; x += 7 )
SetCell( x, 8, Element.Seed );
for ( int x = 20; x < GridWidth - 20; x += 14 )
for ( int y = 9; y < 13; y++ )
SetCell( x, y, Element.Water );
for ( int x = 30; x < GridWidth - 30; x += 22 )
SetCell( x, GridHeight - 6, Element.Cloud );
Particles = CountParticles();
LastReaction = "Garden";
_renderDirty = true;
}
void ScenarioDemolition()
{
ClearWorld();
for ( int x = 30; x < GridWidth - 30; x++ )
for ( int y = 1; y < 40; y++ )
{
bool brick = (x / 6 + y / 4) % 2 == 0;
SetCell( x, y, brick ? Element.Stone : Element.Metal );
}
for ( int x = 40; x < GridWidth - 40; x += 12 )
for ( int y = 6; y < 34; y += 10 )
{
SetCell( x, y, Element.C4 );
SetCell( x, y - 1, Element.Fuse );
}
for ( int x = 34; x < GridWidth - 34; x++ ) SetCell( x, 41, Element.Fuse );
Particles = CountParticles();
LastReaction = "Demolition";
_renderDirty = true;
}
// =====================================================================
// Save / Load
// =====================================================================
public void SaveState()
{
try
{
var sb = new StringBuilder();
sb.Append( GridWidth ).Append( 'x' ).Append( GridHeight ).Append( ':' );
// Run-length encode the grid (column-major matches iteration order).
Element run = _grid[0, 0];
int count = 0;
for ( int y = 0; y < GridHeight; y++ )
{
for ( int x = 0; x < GridWidth; x++ )
{
var e = _grid[x, y];
if ( e == run ) { count++; continue; }
sb.Append( (int)run ).Append( '*' ).Append( count ).Append( ';' );
run = e; count = 1;
}
}
sb.Append( (int)run ).Append( '*' ).Append( count ).Append( ';' );
FileSystem.Data.WriteAllText( SaveFile, sb.ToString() );
LastReaction = "Saved";
}
catch ( Exception e )
{
Log.Warning( $"Science save failed: {e.Message}" );
LastReaction = "Save failed";
}
}
public void LoadState()
{
try
{
if ( !FileSystem.Data.FileExists( SaveFile ) )
{
LastReaction = "No save";
return;
}
var text = FileSystem.Data.ReadAllText( SaveFile );
int colon = text.IndexOf( ':' );
if ( colon < 0 ) { LastReaction = "Bad save"; return; }
ClearWorld();
var body = text[(colon + 1)..];
int index = 0;
foreach ( var token in body.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
{
int star = token.IndexOf( '*' );
if ( star < 0 ) continue;
int value = int.Parse( token[..star] );
int length = int.Parse( token[(star + 1)..] );
for ( int i = 0; i < length; i++ )
{
int x = index % GridWidth;
int y = index / GridWidth;
index++;
if ( y >= GridHeight ) break;
SetCell( x, y, (Element)value );
}
}
Particles = CountParticles();
LastReaction = "Loaded";
_renderDirty = true;
}
catch ( Exception e )
{
Log.Warning( $"Science load failed: {e.Message}" );
LastReaction = "Load failed";
}
}
// =====================================================================
// Input / painting
// =====================================================================
void HandleInput()
{
if ( Input.Pressed( "slot1" ) ) SelectTool( Element.Sand );
if ( Input.Pressed( "slot2" ) ) SelectTool( Element.Water );
if ( Input.Pressed( "slot3" ) ) SelectTool( Element.Fire );
if ( Input.Pressed( "slot4" ) ) SelectTool( Element.Stone );
if ( Input.Pressed( "slot5" ) ) SelectTool( Element.Lava );
if ( Input.Pressed( "slot6" ) ) SelectTool( Element.Acid );
if ( Input.Pressed( "slot7" ) ) SelectTool( Element.Plant );
if ( Input.Pressed( "slot8" ) ) SelectTool( Element.TNT );
if ( Input.Pressed( "slot9" ) ) SelectTool( Element.Eraser );
if ( Input.Pressed( "reload" ) ) TogglePause();
if ( Input.Pressed( "use" ) ) LoadScenario( 0 );
bool hadHover = _hasHover;
int prevX = _hoverX, prevY = _hoverY;
if ( !_pointerInCanvas )
{
_hasHover = false;
if ( hadHover ) _renderDirty = true;
return;
}
if ( TryGetMouseCell( out var x, out var y ) )
{
_hoverX = x;
_hoverY = y;
_hasHover = true;
if ( !hadHover || prevX != x || prevY != y ) _renderDirty = true;
}
else
{
_hasHover = false;
if ( hadHover ) _renderDirty = true;
}
if ( Input.Down( "attack1" ) && TryGetMouseCell( out x, out y ) )
PaintBrush( x, y, CurrentTool );
if ( Input.Down( "attack2" ) && TryGetMouseCell( out x, out y ) )
PaintBrush( x, y, Element.Eraser );
}
bool TryGetMouseCell( out int cellX, out int cellY )
{
cellX = 0;
cellY = 0;
if ( _canvasRect.Width < 1f || _canvasRect.Height < 1f ) return false;
var m = Mouse.Position;
float u = (m.x - _canvasRect.Left) / _canvasRect.Width;
float v = (m.y - _canvasRect.Top) / _canvasRect.Height;
if ( u < 0f || u > 1f || v < 0f || v > 1f ) return false;
cellX = (int)(u * GridWidth);
// Texture row 0 is the top of the image, which is grid y = GridHeight-1.
cellY = GridHeight - 1 - (int)(v * GridHeight);
return InBounds( cellX, cellY );
}
void PaintBrush( int centerX, int centerY, Element tool )
{
int radiusSquared = BrushRadius * BrushRadius;
bool dense = tool is Element.Wall or Element.Metal or Element.Stone or Element.Glass or Element.Eraser;
for ( int y = centerY - BrushRadius; y <= centerY + BrushRadius; y++ )
{
for ( int x = centerX - BrushRadius; x <= centerX + BrushRadius; x++ )
{
if ( !InBounds( x, y ) ) continue;
if ( IsBoundaryCell( x, y ) ) continue;
int deltaX = x - centerX;
int deltaY = y - centerY;
if ( deltaX * deltaX + deltaY * deltaY > radiusSquared ) continue;
if ( !dense && _random.NextDouble() > 0.78 ) continue;
SetCell( x, y, tool == Element.Eraser ? Element.Empty : tool );
}
}
Particles = CountParticles();
_renderDirty = true;
if ( tool == Element.Eraser ) _audio?.Erase(); else _audio?.Paint();
}
// =====================================================================
// Simulation (cellular automata - preserved behaviour)
// =====================================================================
void SimulateStep()
{
_tickId++;
int direction = _random.Next( 2 ) == 0 ? 1 : -1;
for ( int y = 0; y < GridHeight; y++ )
{
for ( int column = 0; column < GridWidth; column++ )
{
int x = direction > 0 ? column : GridWidth - 1 - column;
if ( _updated[x, y] == _tickId ) continue;
switch ( _grid[x, y] )
{
case Element.Sand: SimulateSand( x, y ); break;
case Element.Salt: SimulateSalt( x, y ); break;
case Element.Stone: SimulateStone( x, y ); break;
case Element.Mud: SimulateMud( x, y ); break;
case Element.Water: SimulateWater( x, y ); break;
case Element.Oil: SimulateOil( x, y ); break;
case Element.Nitro: SimulateNitro( x, y ); break;
case Element.Lava: SimulateLava( x, y ); break;
case Element.Mercury: SimulateMercury( x, y ); break;
case Element.Fire: SimulateFire( x, y ); break;
case Element.Smoke: SimulateSmoke( x, y ); break;
case Element.Steam: SimulateSteam( x, y ); break;
case Element.Gas: SimulateGas( x, y ); break;
case Element.Cloud: SimulateCloud( x, y ); break;
case Element.TNT: SimulateExplosive( x, y, 12, "TNT armed" ); break;
case Element.C4: SimulateExplosive( x, y, 16, "C4 armed" ); break;
case Element.Bomb: SimulateExplosive( x, y, 20, "Bomb armed" ); break;
case Element.Spark: SimulateSpark( x, y ); break;
case Element.Acid: SimulateAcid( x, y ); break;
case Element.Plant: SimulatePlant( x, y ); break;
case Element.Seed: SimulateSeed( x, y ); break;
case Element.Vine: SimulateVine( x, y ); break;
case Element.Fuse: SimulateFuse( x, y ); break;
case Element.Ice: SimulateIce( x, y ); break;
case Element.Virus: SimulateVirus( x, y ); break;
case Element.Clone: SimulateClone( x, y ); break;
}
}
}
_renderDirty = true;
}
void SimulateSand( int x, int y )
{
if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
TryMoveInto( x, y, x - direction, y - 1, true );
}
void SimulateSalt( int x, int y )
{
if ( TryFindNeighbor( x, y, Element.Water, out _, out _ ) )
{
SetCell( x, y, Element.Empty );
RecordReaction( "Salt dissolve" );
return;
}
SimulateSand( x, y );
}
void SimulateStone( int x, int y )
{
if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
TryMoveInto( x, y, x - direction, y - 1, true );
}
void SimulateMud( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Stone, "Mud baked" );
return;
}
if ( _random.NextDouble() > 0.46 )
{
MarkUpdated( x, y );
return;
}
if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
if ( _random.NextDouble() < 0.28 && TryMoveInto( x, y, x + direction, y, false ) ) return;
MarkUpdated( x, y );
}
void SimulateWater( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Steam, "Steam" );
return;
}
if ( TryFindNeighbor( x, y, Element.Sand, out var sandX, out var sandY ) && _random.NextDouble() < 0.05 )
{
SetCell( sandX, sandY, Element.Mud );
SetCell( x, y, Element.Empty );
RecordReaction( "Mud" );
return;
}
if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
TryMoveInto( x, y, x - direction, y, false );
}
void SimulateOil( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Fire, "Oil burn" );
return;
}
if ( InBounds( x, y + 1 ) && _grid[x, y + 1] == Element.Water )
{
SwapCells( x, y, x, y + 1 );
return;
}
if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
TryMoveInto( x, y, x - direction, y, false );
}
void SimulateNitro( int x, int y )
{
if ( TouchingHot( x, y ) )
{
Explode( x, y, 9 );
return;
}
if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
TryMoveInto( x, y, x - direction, y, false );
}
void SimulateLava( int x, int y )
{
_life[x, y]--;
if ( TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) || TryFindNeighbor( x, y, Element.Ice, out waterX, out waterY ) )
{
SetCell( waterX, waterY, Element.Steam );
if ( _random.NextDouble() < 0.46 ) SetCell( x, y, Element.Stone );
RecordReaction( "Quench" );
return;
}
HeatNeighbors( x, y );
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Stone );
MarkUpdated( x, y );
return;
}
if ( _random.NextDouble() < 0.28 )
{
MarkUpdated( x, y );
return;
}
if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, true ) ) return;
if ( _random.NextDouble() < 0.34 && TryMoveInto( x, y, x + direction, y, true ) ) return;
MarkUpdated( x, y );
}
void SimulateMercury( int x, int y )
{
if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, true ) ) return;
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
TryMoveInto( x, y, x - direction, y, false );
}
void SimulateAcid( int x, int y )
{
if ( ReactAcid( x, y ) ) return;
if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
TryMoveInto( x, y, x - direction, y, false );
}
void SimulateFire( int x, int y )
{
_life[x, y]--;
SpreadFire( x, y );
if ( _life[x, y] <= 0 )
{
SetCell( x, y, _random.NextDouble() < 0.55 ? Element.Smoke : Element.Empty );
return;
}
if ( _random.NextDouble() < 0.32 && TryMoveInto( x, y, x + _random.Next( -1, 2 ), y + 1, false ) ) return;
MarkUpdated( x, y );
}
void SimulateSmoke( int x, int y )
{
_life[x, y]--;
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Empty );
return;
}
if ( TryRise( x, y ) ) return;
MarkUpdated( x, y );
}
void SimulateSteam( int x, int y )
{
_life[x, y]--;
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Water );
MarkUpdated( x, y );
return;
}
if ( TryRise( x, y ) ) return;
MarkUpdated( x, y );
}
void SimulateGas( int x, int y )
{
_life[x, y]--;
if ( TouchingHot( x, y ) )
{
Explode( x, y, 6 );
return;
}
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Smoke );
return;
}
if ( TryRise( x, y ) ) return;
int direction = RandomDirection();
if ( _random.NextDouble() < 0.58 && TryMoveInto( x, y, x + direction, y, false ) ) return;
MarkUpdated( x, y );
}
void SimulateCloud( int x, int y )
{
_life[x, y]--;
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Steam, "Warm cloud" );
return;
}
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Empty );
return;
}
if ( InBounds( x, y - 1 ) && _grid[x, y - 1] == Element.Empty && _random.NextDouble() < 0.018 )
{
SetCell( x, y - 1, Element.Water );
RecordReaction( "Rain" );
}
if ( _random.NextDouble() < 0.46 && TryRise( x, y ) ) return;
int direction = RandomDirection();
if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
MarkUpdated( x, y );
}
void SimulateExplosive( int x, int y, int radius, string armedReaction )
{
if ( _life[x, y] > 0 )
{
_life[x, y]--;
if ( _life[x, y] <= 0 )
Explode( x, y, radius );
else
MarkUpdated( x, y );
return;
}
if ( TouchingHot( x, y ) )
{
_life[x, y] = 6;
MarkUpdated( x, y );
RecordReaction( armedReaction );
}
}
void SimulateSpark( int x, int y )
{
_life[x, y]--;
IgniteNeighbors( x, y );
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Fire );
return;
}
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( -1, 2 );
if ( TryMoveInto( x, y, nx, ny, false ) ) return;
MarkUpdated( x, y );
}
void SimulatePlant( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Fire, "Plant burn" );
return;
}
if ( _random.NextDouble() > 0.06 || !TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) ) return;
for ( int attempt = 0; attempt < 5; attempt++ )
{
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( 0, 2 );
if ( InBounds( nx, ny ) && _grid[nx, ny] == Element.Empty )
{
SetCell( nx, ny, Element.Plant );
SetCell( waterX, waterY, Element.Empty );
RecordReaction( "Growth" );
return;
}
}
}
void SimulateSeed( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Fire, "Seed burn" );
return;
}
if ( TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) )
{
SetCell( x, y, _random.NextDouble() < 0.24 ? Element.Vine : Element.Plant );
if ( _random.NextDouble() < 0.56 ) SetCell( waterX, waterY, Element.Empty );
RecordReaction( "Sprout" );
return;
}
SimulateSand( x, y );
}
void SimulateVine( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Fire, "Vine burn" );
return;
}
if ( _random.NextDouble() > 0.08 || !TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) ) return;
for ( int attempt = 0; attempt < 6; attempt++ )
{
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( 0, 2 );
if ( InBounds( nx, ny ) && _grid[nx, ny] == Element.Empty )
{
SetCell( nx, ny, Element.Vine );
SetCell( waterX, waterY, Element.Empty );
RecordReaction( "Vine" );
return;
}
}
}
void SimulateFuse( int x, int y )
{
if ( _life[x, y] > 0 )
{
_life[x, y]--;
IgniteFuseNeighbors( x, y );
if ( _life[x, y] <= 0 )
SetCell( x, y, Element.Fire );
else
MarkUpdated( x, y );
return;
}
if ( TouchingHot( x, y ) )
{
_life[x, y] = 5;
MarkUpdated( x, y );
RecordReaction( "Fuse" );
}
}
void SimulateIce( int x, int y )
{
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Water, "Melt" );
return;
}
if ( Touching( x, y, Element.Salt ) && _random.NextDouble() < 0.08 )
ReactCell( x, y, Element.Water, "Salt melt" );
}
void SimulateVirus( int x, int y )
{
_life[x, y]--;
if ( TouchingHot( x, y ) )
{
ReactCell( x, y, Element.Smoke, "Virus burn" );
return;
}
if ( _life[x, y] <= 0 )
{
SetCell( x, y, Element.Empty );
return;
}
if ( _random.NextDouble() < 0.22 )
{
for ( int attempt = 0; attempt < 4; attempt++ )
{
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( -1, 2 );
if ( nx == x && ny == y || !InBounds( nx, ny ) ) continue;
var target = _grid[nx, ny];
if ( target is Element.Empty or Element.Wall or Element.Metal or Element.Glass or Element.Virus ) continue;
SetCell( nx, ny, Element.Virus );
RecordReaction( "Virus" );
break;
}
}
MarkUpdated( x, y );
}
void SimulateClone( int x, int y )
{
if ( !TryFindCloneSource( x, y, out var source ) ) return;
for ( int attempt = 0; attempt < 6; attempt++ )
{
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( -1, 2 );
if ( nx == x && ny == y || !InBounds( nx, ny ) || _grid[nx, ny] != Element.Empty ) continue;
SetCell( nx, ny, source );
RecordReaction( "Clone" );
return;
}
}
bool ReactAcid( int x, int y )
{
for ( int attempt = 0; attempt < 4; attempt++ )
{
int nx = x + _random.Next( -1, 2 );
int ny = y + _random.Next( -1, 2 );
if ( nx == x && ny == y ) continue;
if ( !InBounds( nx, ny ) ) continue;
var target = _grid[nx, ny];
if ( target is Element.Empty or Element.Wall or Element.Acid or Element.Smoke or Element.Steam or Element.Gas or Element.Cloud or Element.Fire ) continue;
if ( target is Element.TNT or Element.C4 or Element.Bomb or Element.Nitro )
{
Explode( nx, ny, 7 );
SetCell( x, y, Element.Smoke );
RecordReaction( "Acid burst" );
return true;
}
SetCell( nx, ny, _random.NextDouble() < 0.35 ? Element.Smoke : Element.Empty );
if ( _random.NextDouble() < 0.12 ) SetCell( x, y, Element.Steam );
RecordReaction( "Dissolve" );
return true;
}
return false;
}
void HeatNeighbors( int x, int y )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( !InBounds( nx, ny ) ) continue;
switch ( _grid[nx, ny] )
{
case Element.Water:
case Element.Ice:
SetCell( nx, ny, Element.Steam );
RecordReaction( "Steam" );
break;
case Element.Oil:
case Element.Gas:
case Element.Plant:
case Element.Seed:
case Element.Vine:
case Element.Fuse:
SetCell( nx, ny, Element.Fire );
RecordReaction( "Ignition" );
break;
case Element.Sand:
if ( _random.NextDouble() < 0.10 )
{
SetCell( nx, ny, Element.Glass );
RecordReaction( "Glass" );
}
break;
case Element.Nitro:
Explode( nx, ny, 9 );
break;
case Element.TNT:
case Element.C4:
case Element.Bomb:
if ( _life[nx, ny] <= 0 )
{
_life[nx, ny] = 5;
RecordReaction( "Armed" );
}
break;
}
}
}
}
void IgniteFuseNeighbors( int x, int y )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( !InBounds( nx, ny ) ) continue;
switch ( _grid[nx, ny] )
{
case Element.Fuse when _life[nx, ny] <= 0:
_life[nx, ny] = 5;
RecordReaction( "Fuse" );
break;
case Element.TNT:
case Element.C4:
case Element.Bomb:
if ( _life[nx, ny] <= 0 )
{
_life[nx, ny] = 5;
RecordReaction( "Armed" );
}
break;
case Element.Nitro:
Explode( nx, ny, 9 );
break;
}
}
}
}
void SpreadFire( int x, int y )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( !InBounds( nx, ny ) ) continue;
switch ( _grid[nx, ny] )
{
case Element.Water:
case Element.Ice:
SetCell( nx, ny, Element.Steam );
SetCell( x, y, Element.Smoke );
RecordReaction( "Steam" );
return;
case Element.Gas:
Explode( nx, ny, 6 );
return;
case Element.Nitro:
Explode( nx, ny, 9 );
return;
case Element.Oil:
case Element.Plant:
case Element.Seed:
case Element.Vine:
case Element.Fuse:
if ( _random.NextDouble() < 0.32 )
{
SetCell( nx, ny, Element.Fire );
RecordReaction( "Ignition" );
}
break;
case Element.TNT:
case Element.C4:
case Element.Bomb:
if ( _life[nx, ny] <= 0 )
{
_life[nx, ny] = 5;
RecordReaction( "Armed" );
}
break;
}
}
}
}
void IgniteNeighbors( int x, int y )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( !InBounds( nx, ny ) ) continue;
if ( _grid[nx, ny] is Element.Oil or Element.Gas or Element.Plant or Element.Seed or Element.Vine or Element.Fuse )
{
SetCell( nx, ny, Element.Fire );
RecordReaction( "Ignition" );
}
else if ( _grid[nx, ny] == Element.Nitro )
{
Explode( nx, ny, 9 );
}
else if ( (_grid[nx, ny] is Element.TNT or Element.C4 or Element.Bomb) && _life[nx, ny] <= 0 )
{
_life[nx, ny] = 4;
RecordReaction( "Armed" );
}
}
}
}
void Explode( int centerX, int centerY, int radius )
{
int radiusSquared = radius * radius;
for ( int y = centerY - radius; y <= centerY + radius; y++ )
{
for ( int x = centerX - radius; x <= centerX + radius; x++ )
{
if ( !InBounds( x, y ) ) continue;
int dx = x - centerX;
int dy = y - centerY;
int distanceSquared = dx * dx + dy * dy;
if ( distanceSquared > radiusSquared ) continue;
if ( _grid[x, y] == Element.Wall ) continue;
if ( _grid[x, y] == Element.Metal && _random.NextDouble() > 0.22 ) continue;
double roll = _random.NextDouble();
if ( roll < 0.18 ) SetCell( x, y, Element.Spark );
else if ( roll < 0.58 ) SetCell( x, y, Element.Fire );
else if ( roll < 0.82 ) SetCell( x, y, Element.Smoke );
else SetCell( x, y, Element.Empty );
MarkUpdated( x, y );
}
}
Particles = CountParticles();
_audio?.Explosion( radius );
RecordReaction( "Boom" );
}
bool TryMoveInto( int x, int y, int nx, int ny, bool displaceLiquid )
{
if ( !InBounds( nx, ny ) ) return false;
var target = _grid[nx, ny];
if ( target == Element.Empty || IsGas( target ) || displaceLiquid && IsLiquid( target ) )
{
SwapCells( x, y, nx, ny );
return true;
}
return false;
}
bool TryRise( int x, int y )
{
int direction = RandomDirection();
if ( TryMoveInto( x, y, x, y + 1, false ) ) return true;
if ( TryMoveInto( x, y, x + direction, y + 1, false ) ) return true;
if ( TryMoveInto( x, y, x - direction, y + 1, false ) ) return true;
if ( _random.NextDouble() < 0.35 && TryMoveInto( x, y, x + direction, y, false ) ) return true;
return false;
}
void SwapCells( int x, int y, int nx, int ny )
{
var element = _grid[x, y];
int life = _life[x, y];
_grid[x, y] = _grid[nx, ny];
_life[x, y] = _life[nx, ny];
_grid[nx, ny] = element;
_life[nx, ny] = life;
MarkUpdated( x, y );
MarkUpdated( nx, ny );
}
void ReactCell( int x, int y, Element element, string reaction )
{
SetCell( x, y, element );
MarkUpdated( x, y );
RecordReaction( reaction );
}
void SetCell( int x, int y, Element element, int life = -1 )
{
if ( !InBounds( x, y ) ) return;
if ( element == Element.Eraser ) element = Element.Empty;
var previous = _grid[x, y];
_grid[x, y] = element;
_life[x, y] = element == Element.Empty ? 0 : life >= 0 ? life : DefaultLife( element );
if ( previous == Element.Empty && element != Element.Empty ) Particles++;
if ( previous != Element.Empty && element == Element.Empty ) Particles--;
}
// =====================================================================
// Rendering - single dynamic texture with baked glow
// =====================================================================
void BuildRenderResources()
{
_texW = GridWidth * SS;
_texH = GridHeight * SS;
_pixels = new Color32[_texW * _texH];
_background = new Color32[_texW * _texH];
_glowR = new float[_texW * _texH];
_glowG = new float[_texW * _texH];
_glowB = new float[_texW * _texH];
BuildGlowKernel();
BuildBackground();
GridTexture = Texture.Create( _texW, _texH, ImageFormat.RGBA8888 )
.WithName( "science_grid" )
.WithDynamicUsage()
.WithData( _background )
.Finish();
}
void BuildGlowKernel()
{
_glowRadius = SS * 2;
int size = _glowRadius * 2 + 1;
_glowKernel = new float[size * size];
float sigma = _glowRadius * 0.62f;
float twoSigmaSq = 2f * sigma * sigma;
for ( int j = 0; j < size; j++ )
{
for ( int i = 0; i < size; i++ )
{
int dx = i - _glowRadius;
int dy = j - _glowRadius;
float d2 = dx * dx + dy * dy;
_glowKernel[j * size + i] = MathF.Exp( -d2 / twoSigmaSq );
}
}
}
void BuildBackground()
{
float cx = _texW * 0.5f;
float cy = _texH * 0.5f;
float maxDist = MathF.Sqrt( cx * cx + cy * cy );
var baseCol = EmptyColor;
var gridCol = new Color( 0.085f, 0.10f, 0.13f );
for ( int py = 0; py < _texH; py++ )
{
for ( int px = 0; px < _texW; px++ )
{
float dx = px - cx;
float dy = py - cy;
float dist = MathF.Sqrt( dx * dx + dy * dy ) / maxDist;
float vignette = 1f - dist * dist * 0.55f;
var col = baseCol * vignette;
if ( ShowGrid )
{
// Faint lab grid every cell boundary.
bool line = (px % SS == 0) || (py % SS == 0);
if ( line ) col = Color.Lerp( col, gridCol, 0.5f );
}
_background[py * _texW + px] = ToColor32( col );
}
}
}
void RenderToTexture()
{
if ( _pixels == null || GridTexture == null || !GridTexture.IsValid ) return;
Array.Copy( _background, _pixels, _pixels.Length );
Array.Clear( _glowR, 0, _glowR.Length );
Array.Clear( _glowG, 0, _glowG.Length );
Array.Clear( _glowB, 0, _glowB.Length );
for ( int y = 0; y < GridHeight; y++ )
{
int row0 = (GridHeight - 1 - y) * SS;
for ( int x = 0; x < GridWidth; x++ )
{
var element = _grid[x, y];
if ( element == Element.Empty ) continue;
int col0 = x * SS;
var color = ElementColor( element, _life[x, y] );
DrawCellBlock( col0, row0, color, element );
float glow = EmissiveStrength( element, _life[x, y] );
if ( glow > 0f )
SplatGlow( col0 + SS / 2, row0 + SS / 2, color, glow );
}
}
CompositeGlow();
DrawBrushPreview();
GridTexture.Update( _pixels, 0, 0, _texW, _texH );
}
// Outline the brush footprint at the hovered cell so painting has a clear
// aim point. A bright ring marks the edge; a faint wash marks the fill area.
void DrawBrushPreview()
{
if ( !_hasHover ) return;
Color tint = CurrentTool == Element.Eraser
? new Color( 1f, 0.42f, 0.42f )
: ElementColor( CurrentTool, 0 );
// Keep the ring readable even for dark materials.
tint = Color.Lerp( tint, new Color( 1f, 1f, 1f ), 0.35f );
float r = BrushRadius;
int span = BrushRadius + 1;
for ( int dy = -span; dy <= span; dy++ )
{
for ( int dx = -span; dx <= span; dx++ )
{
int gx = _hoverX + dx;
int gy = _hoverY + dy;
if ( !InBounds( gx, gy ) ) continue;
float dist = MathF.Sqrt( dx * dx + dy * dy );
float ringDelta = MathF.Abs( dist - r );
if ( ringDelta <= 0.7f )
BlendCellBlock( gx, gy, tint, 0.8f );
else if ( dist < r )
BlendCellBlock( gx, gy, tint, 0.09f );
}
}
}
void BlendCellBlock( int gx, int gy, Color color, float alpha )
{
int col0 = gx * SS;
int row0 = (GridHeight - 1 - gy) * SS;
for ( int ry = 0; ry < SS; ry++ )
{
int py = row0 + ry;
if ( py < 0 || py >= _texH ) continue;
int rowIdx = py * _texW + col0;
for ( int rx = 0; rx < SS; rx++ )
{
int px = col0 + rx;
if ( px < 0 || px >= _texW ) continue;
var bg = FromColor32( _pixels[rowIdx + rx] );
_pixels[rowIdx + rx] = ToColor32( Color.Lerp( bg, color, alpha ) );
}
}
}
void DrawCellBlock( int col0, int row0, Color color, Element element )
{
bool translucent = IsGas( element ) || element is Element.Glass or Element.Water or Element.Oil or Element.Acid or Element.Mercury;
float alpha = color.a;
for ( int ry = 0; ry < SS; ry++ )
{
int py = row0 + ry;
if ( py < 0 || py >= _texH ) continue;
// Subtle bevel: top rows brighter, bottom rows darker for powder depth.
float shade = 1f + (1f - ry / (float)(SS - 1)) * 0.16f - 0.12f * (ry / (float)(SS - 1));
int rowIdx = py * _texW + col0;
for ( int rx = 0; rx < SS; rx++ )
{
int px = col0 + rx;
if ( px < 0 || px >= _texW ) continue;
var shaded = new Color( color.r * shade, color.g * shade, color.b * shade );
if ( translucent && alpha < 0.999f )
{
// Blend over existing background for see-through materials.
var bg = FromColor32( _pixels[rowIdx + rx] );
shaded = Color.Lerp( bg, shaded, alpha );
}
_pixels[rowIdx + rx] = ToColor32( shaded );
}
}
}
void SplatGlow( int centerX, int centerY, Color color, float strength )
{
int size = _glowRadius * 2 + 1;
for ( int j = 0; j < size; j++ )
{
int py = centerY - _glowRadius + j;
if ( py < 0 || py >= _texH ) continue;
int rowIdx = py * _texW;
for ( int i = 0; i < size; i++ )
{
int px = centerX - _glowRadius + i;
if ( px < 0 || px >= _texW ) continue;
float k = _glowKernel[j * size + i] * strength;
int idx = rowIdx + px;
_glowR[idx] += color.r * k;
_glowG[idx] += color.g * k;
_glowB[idx] += color.b * k;
}
}
}
void CompositeGlow()
{
for ( int i = 0; i < _pixels.Length; i++ )
{
float gr = _glowR[i];
if ( gr <= 0.0001f && _glowG[i] <= 0.0001f && _glowB[i] <= 0.0001f ) continue;
var c = _pixels[i];
int r = c.r + (int)(MathF.Min( gr, 1.7f ) * 255f);
int g = c.g + (int)(MathF.Min( _glowG[i], 1.7f ) * 255f);
int b = c.b + (int)(MathF.Min( _glowB[i], 1.7f ) * 255f);
_pixels[i] = new Color32( (byte)Math.Min( r, 255 ), (byte)Math.Min( g, 255 ), (byte)Math.Min( b, 255 ), 255 );
}
}
float EmissiveStrength( Element element, int life )
{
return element switch
{
Element.Fire => 1.1f,
Element.Spark => 1.5f,
Element.Lava => 0.95f,
Element.Nitro => 0.30f,
Element.Clone => 0.42f,
Element.Virus => 0.36f,
Element.Acid => 0.30f,
_ => 0f
};
}
Color ElementColor( Element element, int life )
{
return element switch
{
Element.Wall => WallColor,
Element.Sand => Jitter( SandColor, 0.06f ),
Element.Water => WaterColor,
Element.Oil => OilColor,
Element.Fire => life % 3 == 0 ? SparkColor : FireColor,
Element.Smoke => new Color( SmokeColor.r, SmokeColor.g, SmokeColor.b, Math.Clamp( life / 120f, 0.22f, 0.75f ) ),
Element.Steam => new Color( SteamColor.r, SteamColor.g, SteamColor.b, Math.Clamp( life / 150f, 0.20f, 0.72f ) ),
Element.Gas => new Color( GasColor.r, GasColor.g, GasColor.b, Math.Clamp( life / 220f, 0.24f, 0.62f ) ),
Element.Cloud => new Color( CloudColor.r, CloudColor.g, CloudColor.b, Math.Clamp( life / 360f, 0.22f, 0.68f ) ),
Element.TNT => life > 0 && life % 2 == 0 ? SparkColor : TntColor,
Element.C4 => life > 0 && life % 2 == 0 ? SparkColor : C4Color,
Element.Bomb => life > 0 && life % 2 == 0 ? TntColor : BombColor,
Element.Nitro => NitroColor,
Element.Spark => SparkColor,
Element.Acid => AcidColor,
Element.Plant => PlantColor,
Element.Seed => SeedColor,
Element.Vine => VineColor,
Element.Fuse => life > 0 && life % 2 == 0 ? SparkColor : FuseColor,
Element.Metal => MetalColor,
Element.Stone => Jitter( StoneColor, 0.04f ),
Element.Glass => GlassColor,
Element.Ice => IceColor,
Element.Salt => Jitter( SaltColor, 0.03f ),
Element.Mud => MudColor,
Element.Lava => life % 4 == 0 ? SparkColor : LavaColor,
Element.Mercury => MercuryColor,
Element.Virus => life % 5 == 0 ? AcidColor : VirusColor,
Element.Clone => life % 2 == 0 ? CloneColor : SparkColor,
_ => EmptyColor
};
}
Color Jitter( Color color, float amount )
{
float offset = ((float)_random.NextDouble() - 0.5f) * amount;
return new Color(
Math.Clamp( color.r + offset, 0f, 1f ),
Math.Clamp( color.g + offset, 0f, 1f ),
Math.Clamp( color.b + offset, 0f, 1f ),
color.a
);
}
static Color32 ToColor32( Color c ) => new(
(byte)(Math.Clamp( c.r, 0f, 1f ) * 255f),
(byte)(Math.Clamp( c.g, 0f, 1f ) * 255f),
(byte)(Math.Clamp( c.b, 0f, 1f ) * 255f),
(byte)(Math.Clamp( c.a, 0f, 1f ) * 255f) );
static Color FromColor32( Color32 c ) => new( c.r / 255f, c.g / 255f, c.b / 255f, c.a / 255f );
// =====================================================================
// Scene helpers
// =====================================================================
void BuildBoundaryCells()
{
for ( int x = 0; x < GridWidth; x++ )
SetCell( x, 0, Element.Wall );
for ( int y = 0; y < GridHeight; y++ )
{
SetCell( 0, y, Element.Wall );
SetCell( GridWidth - 1, y, Element.Wall );
}
}
void EnsureCameraBackground()
{
var camera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault( c => c.IsMainCamera )
?? Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
if ( camera == null ) return;
camera.BackgroundColor = new Color( 0.02f, 0.025f, 0.035f );
camera.ClearFlags = ClearFlags.Color | ClearFlags.Depth | ClearFlags.Stencil;
}
void DisableSkybox()
{
foreach ( var sky in Scene.GetAllComponents<SkyBox2D>() )
sky.Enabled = false;
foreach ( var probe in Scene.GetAllComponents<EnvmapProbe>() )
probe.Enabled = false;
}
void EnsureAudio()
{
_audio = GameObject.Components.Get<ScienceAudio>() ?? GameObject.Components.Create<ScienceAudio>();
}
void EnsureHud()
{
var existingHud = Scene.GetAllComponents<ScienceHud>().FirstOrDefault();
if ( existingHud != null )
{
existingHud.Game = this;
ConfigureHudScreenPanel( existingHud.GameObject );
return;
}
var hudObject = Scene.CreateObject();
hudObject.Name = "Science HUD";
ConfigureHudScreenPanel( hudObject );
var hud = hudObject.Components.Create<ScienceHud>();
hud.Game = this;
}
void ConfigureHudScreenPanel( GameObject hudObject )
{
var screenPanel = hudObject.Components.Get<ScreenPanel>() ?? hudObject.Components.Create<ScreenPanel>();
screenPanel.AutoScreenScale = false;
}
// =====================================================================
// Small helpers
// =====================================================================
bool TryFindCloneSource( int x, int y, out Element source )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( !InBounds( nx, ny ) ) continue;
var element = _grid[nx, ny];
if ( CanClone( element ) )
{
source = element;
return true;
}
}
}
source = Element.Empty;
return false;
}
bool TryFindNeighbor( int x, int y, Element element, out int foundX, out int foundY )
{
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
if ( dx == 0 && dy == 0 ) continue;
int nx = x + dx;
int ny = y + dy;
if ( InBounds( nx, ny ) && _grid[nx, ny] == element )
{
foundX = nx;
foundY = ny;
return true;
}
}
}
foundX = 0;
foundY = 0;
return false;
}
void RecordReaction( string reaction )
{
// The menu backdrop reacts constantly; don't let it bank score, fill the
// notebook, or play chimes before the player has even entered the lab.
if ( AttractMode )
{
LastReaction = reaction;
return;
}
LastReaction = reaction;
Reactions++;
if ( _discovered.Add( reaction ) )
{
_recentReactions.Insert( 0, reaction );
if ( _recentReactions.Count > 8 ) _recentReactions.RemoveAt( _recentReactions.Count - 1 );
_audio?.Discovery();
}
else
{
PlayReactionSound( reaction );
}
}
// Maps ongoing reactions to ambient crackle / hiss. Play() rate-limits each
// channel, so a roaring fire becomes a steady texture rather than noise.
void PlayReactionSound( string reaction )
{
switch ( reaction )
{
case "Ignition":
case "Oil burn":
case "Plant burn":
case "Seed burn":
case "Vine burn":
case "Fuse":
case "Virus burn":
_audio?.Ignite();
break;
case "Steam":
case "Quench":
case "Melt":
case "Salt melt":
case "Warm cloud":
_audio?.Quench();
break;
}
}
void StartExperiment( string name, string objective, int reactionTarget )
{
ActiveExperimentName = name;
ObjectiveText = objective;
_objectiveStartReactions = Reactions;
_objectiveTarget = Math.Max( 1, reactionTarget );
_objectiveComplete = false;
LastReaction = name;
AddMilestone( $"Loaded {name}", $"experiment:{name}" );
}
void UpdateProgression()
{
if ( !_objectiveComplete && Reactions - _objectiveStartReactions >= _objectiveTarget )
{
_objectiveComplete = true;
_completedExperiments++;
LastReaction = $"{ActiveExperimentName} complete";
AddMilestone( $"{ActiveExperimentName} complete", $"complete:{ActiveExperimentName}:{_completedExperiments}", playSound: false );
_audio?.Complete();
}
if ( Discoveries >= 3 ) AddMilestone( "First findings", "discoveries:3" );
if ( Discoveries >= 8 ) AddMilestone( "Reaction catalog", "discoveries:8" );
if ( Discoveries >= 16 ) AddMilestone( "Advanced chemistry", "discoveries:16" );
if ( Discoveries >= 24 ) AddMilestone( "Full notebook", "discoveries:24" );
if ( Reactions >= 25 ) AddMilestone( "Chain reactor", "reactions:25" );
if ( Reactions >= 75 ) AddMilestone( "Unstable lab", "reactions:75" );
if ( Particles >= 2500 ) AddMilestone( "Dense world", "particles:2500" );
}
void AddMilestone( string label, string key, bool playSound = true )
{
if ( !_milestoneKeys.Add( key ) ) return;
_milestones.Insert( 0, label );
if ( _milestones.Count > 6 ) _milestones.RemoveAt( _milestones.Count - 1 );
if ( playSound ) _audio?.Milestone();
}
void MarkUpdated( int x, int y )
{
if ( InBounds( x, y ) ) _updated[x, y] = _tickId;
}
bool TouchingHot( int x, int y ) => Touching( x, y, Element.Fire ) || Touching( x, y, Element.Spark ) || Touching( x, y, Element.Lava );
bool Touching( int x, int y, Element element ) => TryFindNeighbor( x, y, element, out _, out _ );
bool InBounds( int x, int y ) => x >= 0 && x < GridWidth && y >= 0 && y < GridHeight;
bool IsBoundaryCell( int x, int y ) => x == 0 || y == 0 || x == GridWidth - 1;
int RandomDirection() => _random.Next( 2 ) == 0 ? -1 : 1;
static bool IsGas( Element element ) => element is Element.Smoke or Element.Steam or Element.Gas or Element.Cloud;
static bool IsLiquid( Element element ) => element is Element.Water or Element.Oil or Element.Acid or Element.Nitro or Element.Lava or Element.Mercury or Element.Mud;
static bool CanClone( Element element ) => element is not Element.Empty and not Element.Wall and not Element.Clone and not Element.Eraser;
int CountParticles()
{
int count = 0;
for ( int y = 0; y < GridHeight; y++ )
for ( int x = 0; x < GridWidth; x++ )
if ( _grid[x, y] != Element.Empty ) count++;
return count;
}
int DefaultLife( Element element )
{
return element switch
{
Element.Fire => _random.Next( 18, 42 ),
Element.Smoke => _random.Next( 90, 145 ),
Element.Steam => _random.Next( 100, 170 ),
Element.Gas => _random.Next( 150, 230 ),
Element.Cloud => _random.Next( 260, 420 ),
Element.Spark => _random.Next( 9, 18 ),
Element.Lava => _random.Next( 260, 460 ),
Element.Virus => _random.Next( 160, 260 ),
_ => 0
};
}
// =====================================================================
// Metadata used by the HUD (names, categories, descriptions)
// =====================================================================
public static ElementCategory CategoryOf( Element element )
{
return element switch
{
Element.Sand or Element.Salt or Element.Mud or Element.Seed => ElementCategory.Powders,
Element.Water or Element.Oil or Element.Acid or Element.Mercury or Element.Lava => ElementCategory.Liquids,
Element.Smoke or Element.Steam or Element.Gas or Element.Cloud => ElementCategory.Gases,
Element.Fire or Element.Spark => ElementCategory.Energy,
Element.Plant or Element.Vine or Element.Virus or Element.Clone => ElementCategory.Life,
Element.TNT or Element.C4 or Element.Bomb or Element.Nitro or Element.Fuse => ElementCategory.Explosives,
Element.Metal or Element.Stone or Element.Glass or Element.Ice or Element.Wall => ElementCategory.Solids,
_ => ElementCategory.Tools
};
}
public static string CategoryName( ElementCategory category ) => category switch
{
ElementCategory.Powders => "Powders",
ElementCategory.Liquids => "Liquids",
ElementCategory.Gases => "Gases",
ElementCategory.Energy => "Energy",
ElementCategory.Life => "Life",
ElementCategory.Explosives => "Explosives",
ElementCategory.Solids => "Solids",
_ => "Tools"
};
public static string Describe( Element element ) => element switch
{
Element.Sand => "Granular powder. Piles into slopes; bakes to glass near heat.",
Element.Water => "Flows and levels out. Boils to steam, makes mud with sand.",
Element.Oil => "Floats on water and ignites on contact with heat.",
Element.Fire => "Spreads to fuels, boils water, arms explosives, fades to smoke.",
Element.Smoke => "Rises and dissipates after fire burns out.",
Element.Steam => "Hot vapour from boiled water; condenses back to water.",
Element.Gas => "Flammable vapour that explodes when it touches heat.",
Element.Cloud => "Drifts upward and occasionally rains water below.",
Element.Spark => "Skittering energy that ignites fuels then becomes fire.",
Element.Acid => "Dissolves most materials; bursts explosives.",
Element.Plant => "Grows toward water; burns readily.",
Element.Seed => "Falls like sand, sprouts into plant or vine near water.",
Element.Vine => "Climbing growth that creeps toward water.",
Element.Salt => "Powder that dissolves in water and melts ice.",
Element.Mud => "Heavy sludge that bakes into stone when heated.",
Element.Ice => "Frozen solid; melts to water near heat or salt.",
Element.Lava => "Molten rock. Cools to stone, quenches to steam in water.",
Element.Mercury => "Dense liquid metal that pools and flows.",
Element.TNT => "Classic explosive. Arms from heat or fuse, then detonates.",
Element.C4 => "High-yield charge with a larger blast radius.",
Element.Bomb => "Massive payload for the biggest explosions.",
Element.Nitro => "Volatile liquid that detonates the instant it touches heat.",
Element.Fuse => "Burns along its length to trigger distant charges.",
Element.Metal => "Sturdy barrier that mostly resists blasts.",
Element.Stone => "Solid rock that tumbles and piles.",
Element.Glass => "Transparent solid formed when sand is superheated.",
Element.Virus => "Infects and converts nearby matter; killed by fire.",
Element.Clone => "Copies whatever material touches it into empty space.",
Element.Wall => "Indestructible boundary that nothing can pass.",
Element.Eraser => "Removes material from the canvas.",
_ => ""
};
public static string ToolName( Element element ) => element switch
{
Element.Empty => "Empty",
Element.Wall => "Wall",
Element.Sand => "Sand",
Element.Water => "Water",
Element.Oil => "Oil",
Element.Fire => "Fire",
Element.Smoke => "Smoke",
Element.Steam => "Steam",
Element.Gas => "Gas",
Element.Cloud => "Cloud",
Element.TNT => "TNT",
Element.C4 => "C4",
Element.Bomb => "Bomb",
Element.Nitro => "Nitro",
Element.Spark => "Spark",
Element.Acid => "Acid",
Element.Plant => "Plant",
Element.Seed => "Seed",
Element.Vine => "Vine",
Element.Fuse => "Fuse",
Element.Metal => "Metal",
Element.Stone => "Stone",
Element.Glass => "Glass",
Element.Ice => "Ice",
Element.Salt => "Salt",
Element.Mud => "Mud",
Element.Lava => "Lava",
Element.Mercury => "Mercury",
Element.Virus => "Virus",
Element.Clone => "Clone",
Element.Eraser => "Erase",
_ => element.ToString()
};
}