LineGrow.razor
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@inherits PanelComponent
<root>
<div class="game-screen">
<div class="board @(DeathFlash ? "death-flash" : "board-normal")"
style="width: @(BoardW)px; height: @(BoardH)px;">
@* ── Obstacles ── *@
@foreach ( var obs in Obstacles )
{
var ox = obs.X * CellPx + 2;
var oy = obs.Y * CellPx + 2;
var os = CellPx - 4;
<div class="obstacle" style="left: @(ox)px; top: @(oy)px; width: @(os)px; height: @(os)px;"></div>
}
@* ── Line segments between consecutive cell centres ── *@
@foreach ( var seg in GetLineSegments() )
{
<div class="line-seg"
style="left: @(seg.Left)px; top: @(seg.Top)px; width: @(seg.Width)px; height: @(seg.Height)px; opacity: @(seg.Alpha.ToString("F2"));">
</div>
}
@* ── Head orb ── *@
@if ( Snake.Count > 0 )
{
var hx = Snake[0].X * CellPx + HalfCell - 8;
var hy = Snake[0].Y * CellPx + HalfCell - 8;
<div class="head-orb" style="left: @(hx)px; top: @(hy)px;"></div>
}
@* ── Particles ── *@
@foreach ( var p in Particles )
{
<div class="particle"
style="left: @(p.X)px; top: @(p.Y)px; opacity: @(p.Alpha.ToString("F2"));">
</div>
}
@* ── HUD ── *@
<div class="hud-pill">
<div class="hud-item">
<span class="hud-label">LENGTH</span>
<span class="hud-value">@Snake.Count</span>
</div>
<div class="hud-sep"></div>
<div class="hud-item">
<span class="hud-label">BEST</span>
<span class="hud-value">@HighScore</span>
</div>
</div>
@* ── Overlays ── *@
@if ( Phase != GamePhase.Playing || IsPaused )
{
<div class="overlay">
@if ( IsPaused )
{
<p class="overlay-title">Paused</p>
<p class="overlay-hint">Esc to resume</p>
<button class="quit-btn" onclick=@QuitToMenu>Quit</button>
}
else if ( Phase == GamePhase.Idle )
{
<p class="overlay-title">Line Grow</p>
<p class="overlay-hint">WASD to start</p>
}
else if ( Phase == GamePhase.Dead )
{
<p class="overlay-title">Dead</p>
<p class="overlay-score">@Snake.Count</p>
<p class="overlay-hint">Press any direction to retry</p>
}
</div>
}
</div>
</div>
</root>
@code {
public enum Direction { Up, Down, Left, Right }
public enum GamePhase { Idle, Playing, Dead }
const int Cols = 30;
const int Rows = 22;
const int CellPx = 30;
const int HalfCell = CellPx / 2;
const int LineHalf = 5;
int BoardW => Cols * CellPx;
int BoardH => Rows * CellPx;
// How many obstacles to scatter. Scales with grid size but stays manageable.
const int ObstacleCount = 18;
// Safe zone around the snake's starting position — no obstacles placed here.
// Starting snake is at (5,11),(6,11),(7,11) heading right, so clear a 5-cell radius.
const int SafeX = 7;
const int SafeY = 11;
const int SafeRadius = 5;
GamePhase Phase = GamePhase.Idle;
bool IsPaused;
bool DeathFlash;
int HighScore;
List<(int X, int Y)> Snake = new();
List<(int X, int Y)> Obstacles = new List<(int X, int Y)>();
Direction Dir = Direction.Right;
Direction PendingDir = Direction.Right;
float TickAccum;
const float TickInterval = 0.12f;
// ── Particles ──────────────────────────────────────────────
class Particle
{
public float X, Y;
public float Vx, Vy;
public float Alpha;
public float Life;
public float MaxLife;
}
List<Particle> Particles = new();
// ── Segment data ────────────────────────────────────────────
struct LineSeg { public float Left, Top, Width, Height, Alpha; }
IEnumerable<LineSeg> GetLineSegments()
{
int count = Snake.Count;
for ( int i = 0; i < count - 1; i++ )
{
var a = Snake[i];
var b = Snake[i + 1];
float ax = a.X * CellPx + HalfCell;
float ay = a.Y * CellPx + HalfCell;
float bx = b.X * CellPx + HalfCell;
float by = b.Y * CellPx + HalfCell;
float alpha = 1f - (float)i / count * 0.6f;
if ( ax == bx )
{
float top = Math.Min( ay, by );
float bot = Math.Max( ay, by );
yield return new LineSeg { Left = ax - LineHalf, Top = top, Width = LineHalf * 2, Height = bot - top, Alpha = alpha };
}
else
{
float left = Math.Min( ax, bx );
float right = Math.Max( ax, bx );
yield return new LineSeg { Left = left, Top = ay - LineHalf, Width = right - left, Height = LineHalf * 2, Alpha = alpha };
}
}
}
// ── Obstacle generation ─────────────────────────────────────
List<(int X, int Y)> GenerateObstacles( Random rng )
{
var result = new List<(int X, int Y)>();
var occupied = new HashSet<(int X, int Y)>();
// Mark starting snake cells as occupied
occupied.Add( (X: 5, Y: 11) );
occupied.Add( (X: 6, Y: 11) );
occupied.Add( (X: 7, Y: 11) );
int attempts = 0;
while ( result.Count < ObstacleCount && attempts < 2000 )
{
attempts++;
int x = rng.Next( 0, Cols );
int y = rng.Next( 0, Rows );
// Keep a clear zone around the spawn
int dx = x - SafeX;
int dy = y - SafeY;
if ( dx * dx + dy * dy <= SafeRadius * SafeRadius ) continue;
if ( occupied.Contains( (X: x, Y: y) ) ) continue;
// Don't cluster — require at least 1 cell gap from existing obstacles
bool tooClose = false;
foreach ( var o in result )
{
int odx = o.X - x;
int ody = o.Y - y;
if ( odx * odx + ody * ody < 4 ) { tooClose = true; break; }
}
if ( tooClose ) continue;
result.Add( (X: x, Y: y) );
occupied.Add( (X: x, Y: y) );
}
return result;
}
// ── Lifecycle ───────────────────────────────────────────────
protected override void OnStart()
{
base.OnStart();
Panel.Style.PointerEvents = PointerEvents.All;
ResetGame();
}
protected override void OnUpdate()
{
HandleInput();
if ( Phase == GamePhase.Playing && !IsPaused )
{
TickAccum += Time.Delta;
if ( TickAccum >= TickInterval )
{
TickAccum -= TickInterval;
Tick();
}
}
bool anyParticle = false;
for ( int i = Particles.Count - 1; i >= 0; i-- )
{
var p = Particles[i];
p.Life -= Time.Delta;
if ( p.Life <= 0f ) { Particles.RemoveAt( i ); continue; }
p.X += p.Vx * Time.Delta;
p.Y += p.Vy * Time.Delta;
p.Alpha = p.Life / p.MaxLife;
anyParticle = true;
}
if ( anyParticle ) StateHasChanged();
}
// ── Input ───────────────────────────────────────────────────
void HandleInput()
{
if ( LineGrowInput.Escape )
{
if ( Phase == GamePhase.Playing )
{
IsPaused = !IsPaused;
StateHasChanged();
}
return;
}
if ( IsPaused ) return;
if ( Phase != GamePhase.Playing )
{
if ( LineGrowInput.AnyDir ) StartGame();
return;
}
if ( LineGrowInput.Up ) TrySetDir( Direction.Up );
if ( LineGrowInput.Down ) TrySetDir( Direction.Down );
if ( LineGrowInput.Left ) TrySetDir( Direction.Left );
if ( LineGrowInput.Right ) TrySetDir( Direction.Right );
}
void TrySetDir( Direction d )
{
var opp = Dir switch
{
Direction.Up => Direction.Down,
Direction.Down => Direction.Up,
Direction.Left => Direction.Right,
Direction.Right => Direction.Left,
_ => Dir
};
if ( d != opp ) PendingDir = d;
}
// ── Game tick ───────────────────────────────────────────────
void Tick()
{
Dir = PendingDir;
var head = Snake[0];
var next = Dir switch
{
Direction.Up => (X: head.X, Y: head.Y - 1),
Direction.Down => (X: head.X, Y: head.Y + 1),
Direction.Left => (X: head.X - 1, Y: head.Y),
Direction.Right => (X: head.X + 1, Y: head.Y),
_ => head
};
// Wall collision
if ( next.X < 0 || next.X >= Cols || next.Y < 0 || next.Y >= Rows ) { Die(); return; }
// Self collision
for ( int i = 1; i < Snake.Count; i++ )
if ( Snake[i].X == next.X && Snake[i].Y == next.Y ) { Die(); return; }
// Obstacle collision
foreach ( var obs in Obstacles )
if ( obs.X == next.X && obs.Y == next.Y ) { Die(); return; }
Snake.Insert( 0, next );
if ( Snake.Count > HighScore ) HighScore = Snake.Count;
SpawnParticles( next.X * CellPx + HalfCell, next.Y * CellPx + HalfCell, 4, 30f, 0.4f );
StateHasChanged();
}
// ── Particles ───────────────────────────────────────────────
void SpawnParticles( float cx, float cy, int count, float speed, float life )
{
var rng = new Random();
for ( int i = 0; i < count; i++ )
{
float angle = (float)(rng.NextDouble() * Math.PI * 2);
float spd = (float)(rng.NextDouble() * speed + speed * 0.3f);
float lt = (float)(rng.NextDouble() * life * 0.5f + life * 0.5f);
Particles.Add( new Particle
{
X = cx - 3f, Y = cy - 3f,
Vx = (float)Math.Cos( angle ) * spd,
Vy = (float)Math.Sin( angle ) * spd,
Alpha = 1f, Life = lt, MaxLife = lt
} );
}
if ( Particles.Count > 150 )
Particles.RemoveRange( 0, Particles.Count - 150 );
}
void Die()
{
Phase = GamePhase.Dead;
DeathFlash = true;
if ( Snake.Count > 0 )
{
var h = Snake[0];
SpawnParticles( h.X * CellPx + HalfCell, h.Y * CellPx + HalfCell, 24, 80f, 0.7f );
}
StateHasChanged();
}
// ── Game state ───────────────────────────────────────────────
void ResetGame()
{
Snake = new List<(int X, int Y)> { (X: 7, Y: 11), (X: 6, Y: 11), (X: 5, Y: 11) };
Dir = Direction.Right; PendingDir = Direction.Right;
DeathFlash = false; IsPaused = false;
Particles.Clear();
Obstacles.Clear();
Phase = GamePhase.Idle;
StateHasChanged();
}
void StartGame()
{
var rng = new Random();
Snake = new List<(int X, int Y)> { (X: 7, Y: 11), (X: 6, Y: 11), (X: 5, Y: 11) };
Dir = Direction.Right; PendingDir = Direction.Right;
DeathFlash = false; IsPaused = false;
Particles.Clear();
Obstacles = GenerateObstacles( rng );
Phase = GamePhase.Playing;
StateHasChanged();
}
void QuitToMenu() { Game.Overlay.ShowPauseMenu(); }
}