Add maze generation visualization with 2-phase animation

Enhance maze generation animation system with visual feedback during algorithm execution and a two-phase rendering approach:

- Phase 1: Preview mode displays simple blocks while algorithms run, with real-time feedback for path checking (valid/invalid/evaluating states)
- Phase 2: Sweep 3D modular pieces into place with pop-in animations

Updates:
- New MazeCellHighlight states (EvaluatingValid, EvaluatingInvalid) for algorithm feedback
- Modified all maze algorithms (DFS, Kruskal's, Prim's) to emit visual feedback when checking adjacent cells
- New animation components: HighlightLinger (self-destruct highlights) and PopInAnimation (juicy pop-in effect)
- Refactored MazeReworkSpawner to support preview prefabs and track spawned object state
- New prefabs: Stair room and MazeVisualize variants
- Added stepDelay and isPreviewMode controls to MazeAnimator for flexible pacing
- Reduced default maze size and adjusted material colors for testing
This commit is contained in:
2026-07-04 05:01:00 +07:00
parent a3359cf7e1
commit bdbb76a42a
26 changed files with 1345 additions and 92 deletions

View File

@@ -29,8 +29,28 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
{
int nx = current.x + dx[i];
int nz = current.y + dz[i];
if (nx > 0 && nx < width - 1 && nz > 0 && nz < depth - 1 && !visited[nx, nz])
candidates.Add(i);
int wx = current.x + dx[i] / 2;
int wz = current.y + dz[i] / 2;
if (nx > 0 && nx < width - 1 && nz > 0 && nz < depth - 1)
{
if (!visited[nx, nz])
{
candidates.Add(i);
}
else
{
// Checked a visited cell
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
}
}
else
{
// Checked out of bounds
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
}
}
if (candidates.Count > 0)
@@ -46,6 +66,10 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
visited[wx, wz] = true;
visited[nx2, nz2] = true;
// Show valid pick
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingValid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
// Move head off current cell
onCellChanged?.Invoke(current.x, current.y, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);

View File

@@ -38,25 +38,44 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
int wx = cx + dx[order[i]] / 2;
int wz = cz + dz[order[i]] / 2;
if (nx > 0 && nx < width - 1 && nz > 0 && nz < depth - 1 && !visited[nx, nz])
if (nx > 0 && nx < width - 1 && nz > 0 && nz < depth - 1)
{
grid[wx, wz] = MazeReworkCellType.Corridor;
grid[nx, nz] = MazeReworkCellType.Corridor;
visited[wx, wz] = true;
visited[nx, nz] = true;
if (!visited[nx, nz])
{
// Valid path found! Show valid check
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingValid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
// Move head off current cell
onCellChanged?.Invoke(cx, cz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
// Show intermediate wall breaking with head
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.SearchHead);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
grid[wx, wz] = MazeReworkCellType.Corridor;
grid[nx, nz] = MazeReworkCellType.Corridor;
visited[wx, wz] = true;
visited[nx, nz] = true;
CarveFrom(nx, nz, grid, visited, rng, width, depth, onCellChanged);
// Move head off current cell
onCellChanged?.Invoke(cx, cz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
// Show intermediate wall breaking with head
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.SearchHead);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
// Clear head from the child cell and restore it here
onCellChanged?.Invoke(nx, nz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
onCellChanged?.Invoke(cx, cz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.SearchHead);
CarveFrom(nx, nz, grid, visited, rng, width, depth, onCellChanged);
// Clear head from the child cell and restore it here
onCellChanged?.Invoke(nx, nz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.None);
onCellChanged?.Invoke(cx, cz, MazeReworkCellType.Corridor, Animation.MazeCellHighlight.SearchHead);
}
else
{
// Invalid path (already visited)
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
}
}
else
{
// Invalid path (out of bounds)
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, MazeReworkCellType.Wall, Animation.MazeCellHighlight.None);
}
}
}

View File

@@ -49,9 +49,13 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
// Show evaluating highlight
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.Evaluating);
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.None);
if (uf.Find(cellId[ax, az]) != uf.Find(cellId[bx, bz]))
{
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.EvaluatingValid);
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.None);
grid[wx, wz] = MazeReworkCellType.Corridor;
grid[ax, az] = MazeReworkCellType.Corridor;
grid[bx, bz] = MazeReworkCellType.Corridor;
@@ -65,7 +69,8 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
}
else
{
// Clear evaluate highlight
// Invalid
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.None);
}
}

View File

@@ -31,11 +31,17 @@ namespace Baba_yaga.GameSetup.MazeRework.Algorithms
if (visited[nx, nz])
{
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.EvaluatingInvalid);
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.None);
// Clear the frontier highlight if this cell was already visited by another path
onCellChanged?.Invoke(nx, nz, grid[nx, nz], Animation.MazeCellHighlight.None);
continue;
}
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.EvaluatingValid);
onCellChanged?.Invoke(wx, wz, grid[wx, wz], Animation.MazeCellHighlight.None);
grid[wx, wz] = MazeReworkCellType.Corridor;
grid[nx, nz] = MazeReworkCellType.Corridor;
visited[wx, wz] = true;

View File

@@ -0,0 +1,34 @@
using System.Collections;
using UnityEngine;
namespace Baba_yaga.GameSetup.MazeRework.Animation
{
public class HighlightLinger : MonoBehaviour
{
public float lingerTime = 0.4f;
private void Start()
{
StartCoroutine(LingerAndDie());
}
private IEnumerator LingerAndDie()
{
yield return new WaitForSeconds(lingerTime);
// Optional: fade out logic could go here
float fadeOutTime = 0.1f;
float time = 0f;
Vector3 startScale = transform.localScale;
while (time < fadeOutTime)
{
time += Time.deltaTime;
transform.localScale = Vector3.Lerp(startScale, Vector3.zero, time / fadeOutTime);
yield return null;
}
Destroy(gameObject);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e88f5f0389dbd043937d08377e3168a

View File

@@ -7,9 +7,12 @@ namespace Baba_yaga.GameSetup.MazeRework.Animation
public class MazeAnimator : MonoBehaviour
{
[Header("Animation Settings")]
[Tooltip("How many cell changes to process in a single frame. Higher = faster generation.")]
[Tooltip("How many cell changes to process in a single frame (if stepDelay is 0).")]
[Min(1)]
public int cellsPerFrame = 5;
public int cellsPerFrame = 1; // Default to 1 so you can see it clearly!
[Tooltip("Delay in seconds between every single cell change. Set to > 0 to actually see the checking flashes!")]
public float stepDelay = 0.05f;
[Tooltip("Extra delay (in seconds) between major generation phases (like switching from Rooms to Carving).")]
public float delayBetweenPhases = 0.5f;
@@ -63,6 +66,7 @@ namespace Baba_yaga.GameSetup.MazeRework.Animation
}
spawner.Clear();
spawner.isPreviewMode = true; // Phase 1: Simple preview blocks
MazeAnimationPhase currentPhase = MazeAnimationPhase.RoomPlacement;
int processedThisFrame = 0;
@@ -80,13 +84,29 @@ namespace Baba_yaga.GameSetup.MazeRework.Animation
}
workingGrid[change.X, change.Z] = change.Type;
workingHighlights[change.X, change.Z] = change.Highlight;
if (change.Highlight == MazeCellHighlight.EvaluatingValid ||
change.Highlight == MazeCellHighlight.EvaluatingInvalid ||
change.Highlight == MazeCellHighlight.Evaluating)
{
// Linger flashes independently so they don't get erased if cellsPerFrame > 1
spawner.FlashHighlight(change.Highlight, change.X, change.Z, yOffset, container);
}
else
{
workingHighlights[change.X, change.Z] = change.Highlight;
}
// Refresh the cell and its neighbors in the spawner
spawner.RefreshCell(workingGrid, workingHighlights, change.X, change.Z, yOffset, container);
processedThisFrame++;
if (processedThisFrame >= cellsPerFrame)
if (stepDelay > 0f)
{
yield return new WaitForSeconds(stepDelay);
}
else if (processedThisFrame >= cellsPerFrame)
{
yield return null; // Wait for next frame
processedThisFrame = 0;
@@ -96,21 +116,52 @@ namespace Baba_yaga.GameSetup.MazeRework.Animation
// Wait a moment at the end before finalizing
if (delayBetweenPhases > 0f)
{
yield return new WaitForSeconds(delayBetweenPhases);
yield return new WaitForSeconds(delayBetweenPhases * 2f); // Extra pause to admire the 2D layout!
}
// One final sync with finalGrid just to be absolutely safe
// --- Phase 2: Render 3D Map ---
spawner.isPreviewMode = false;
// Sweep across the grid and spawn the heavy 3D modular pieces
for (int z = 0; z < depth; z++)
{
for (int x = 0; x < width; x++)
{
bool gridChanged = workingGrid[x, z] != finalGrid[x, z];
bool highlightChanged = workingHighlights[x, z] != MazeCellHighlight.None;
if (gridChanged || highlightChanged)
workingGrid[x, z] = finalGrid[x, z];
workingHighlights[x, z] = MazeCellHighlight.None;
// Only yield when we actually spawn a piece to create a cool sweep wave!
if (finalGrid[x, z] != MazeReworkCellType.Wall)
{
workingGrid[x, z] = finalGrid[x, z];
workingHighlights[x, z] = MazeCellHighlight.None;
// 1. Flash an Evaluating highlight to visualize "choosing" this cell
spawner.FlashHighlight(MazeCellHighlight.EvaluatingValid, x, z, yOffset, container);
if (stepDelay > 0f)
{
yield return new WaitForSeconds(stepDelay * 0.5f); // Short pause for the check
}
// 2. Spawn the actual piece
spawner.RefreshCell(workingGrid, workingHighlights, x, z, yOffset, container);
// Wait a tiny fraction of a second to create the sweeping wave effect
if (stepDelay > 0f)
{
yield return new WaitForSeconds(stepDelay * 0.5f); // faster than the algorithm checks
}
else
{
processedThisFrame++;
if (processedThisFrame >= cellsPerFrame * 2) // Twice as fast as algorithm phase
{
yield return null;
processedThisFrame = 0;
}
}
}
else
{
// Even if it's a wall, refresh it to clear the preview walls (if any)
spawner.RefreshCell(workingGrid, workingHighlights, x, z, yOffset, container);
}
}

View File

@@ -5,6 +5,8 @@ namespace Baba_yaga.GameSetup.MazeRework.Animation
None,
SearchHead, // Current cell being evaluated (DFS, Prim's)
Frontier, // Known candidate cells (Prim's)
Evaluating // Walls currently being considered (Kruskal's)
Evaluating, // Walls currently being considered (Kruskal's)
EvaluatingValid,
EvaluatingInvalid
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections;
using UnityEngine;
namespace Baba_yaga.GameSetup.MazeRework.Animation
{
public class PopInAnimation : MonoBehaviour
{
public float duration = 0.25f;
public float delay = 0f;
private void Start()
{
StartCoroutine(AnimateScale());
}
private IEnumerator AnimateScale()
{
Vector3 targetScale = transform.localScale;
Quaternion targetRot = transform.localRotation;
transform.localScale = Vector3.zero;
// Start rotation off by 90 degrees on Y axis so it visibly "spins" into the correct orientation
transform.localRotation = targetRot * Quaternion.Euler(0, -90f, 0);
if (delay > 0f)
yield return new WaitForSeconds(delay);
float time = 0f;
while (time < duration)
{
time += Time.deltaTime;
float t = time / duration;
// easeOutBack formula for a juicy overshoot effect
float c1 = 1.70158f;
float c3 = c1 + 1f;
float easeT = 1f + c3 * Mathf.Pow(t - 1f, 3f) + c1 * Mathf.Pow(t - 1f, 2f);
transform.localScale = targetScale * Mathf.Max(0f, easeT);
// Spin into place using easeOut
float easeOutQuad = 1 - (1 - t) * (1 - t);
transform.localRotation = Quaternion.SlerpUnclamped(targetRot * Quaternion.Euler(0, -90f, 0), targetRot, easeT);
yield return null;
}
transform.localScale = targetScale;
transform.localRotation = targetRot;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43114e744edaec247a2ece63347b73cd

View File

@@ -28,6 +28,16 @@ namespace Baba_yaga.GameSetup.MazeRework
[Tooltip("Prefab for the End cell (player exit point). Auto-rotates based on neighbors.")]
public GameObject endPrefab;
[Header("Preview Mode Settings (Phase 1)")]
[Tooltip("If true, the spawner will only spawn simple preview blocks instead of full 3D modular pieces.")]
public bool isPreviewMode = false;
[Tooltip("Simple prefab to represent carved paths during the algorithm phase (e.g. a flat plane or basic cube).")]
public GameObject previewPathPrefab;
[Tooltip("Simple prefab to represent walls during the algorithm phase (optional, can be left null).")]
public GameObject previewWallPrefab;
[Header("Highlight Prefabs (Animation Only)")]
public GameObject searchHeadPrefab;
public GameObject frontierPrefab;
@@ -53,7 +63,14 @@ namespace Baba_yaga.GameSetup.MazeRework
[Tooltip("Physical distance between each grid cell center.")]
public float spacing = 3.0f;
private readonly Dictionary<Vector2Int, GameObject> _spawnedObjects = new Dictionary<Vector2Int, GameObject>();
private struct SpawnedCellData
{
public GameObject Instance;
public GameObject Prefab;
public float Rotation;
}
private readonly Dictionary<Vector2Int, SpawnedCellData> _spawnedGridCells = new Dictionary<Vector2Int, SpawnedCellData>();
private readonly Dictionary<Vector2Int, GameObject> _spawnedHighlights = new Dictionary<Vector2Int, GameObject>();
private void Start()
@@ -80,12 +97,12 @@ namespace Baba_yaga.GameSetup.MazeRework
/// </summary>
public void Clear()
{
foreach (var obj in _spawnedObjects.Values)
foreach (var data in _spawnedGridCells.Values)
{
if (obj == null) continue;
if (Application.isPlaying) Destroy(obj); else DestroyImmediate(obj);
if (data.Instance == null) continue;
if (Application.isPlaying) Destroy(data.Instance); else DestroyImmediate(data.Instance);
}
_spawnedObjects.Clear();
_spawnedGridCells.Clear();
foreach (var obj in _spawnedHighlights.Values)
{
@@ -131,6 +148,23 @@ namespace Baba_yaga.GameSetup.MazeRework
RefreshSingleCell(grid, highlights, x, z - 1, width, depth, yOffset, container);
}
public void FlashHighlight(MazeCellHighlight hType, int x, int z, float yOffset, Transform container)
{
GameObject hPrefab = null;
if (hType == MazeCellHighlight.EvaluatingValid) hPrefab = searchHeadPrefab;
else if (hType == MazeCellHighlight.EvaluatingInvalid) hPrefab = evaluatingPrefab;
else if (hType == MazeCellHighlight.Evaluating) hPrefab = evaluatingPrefab;
if (hPrefab != null)
{
Vector3 localPos = new Vector3(x * spacing, yOffset, z * spacing);
GameObject spawnedH = Instantiate(hPrefab, container != null ? container : transform);
spawnedH.transform.localPosition = localPos;
spawnedH.name = $"Flash_{hType}_{x}_{z}";
spawnedH.AddComponent<Animation.HighlightLinger>(); // Self-destructs after lingering
}
}
private void RefreshSingleCell(MazeReworkCellType[,] grid, MazeCellHighlight[,] highlights, int x, int z, int width, int depth, float yOffset, Transform container)
{
if (x < 0 || x >= width || z < 0 || z >= depth) return;
@@ -153,6 +187,8 @@ namespace Baba_yaga.GameSetup.MazeRework
if (hType == MazeCellHighlight.SearchHead) hPrefab = searchHeadPrefab;
else if (hType == MazeCellHighlight.Frontier) hPrefab = frontierPrefab;
else if (hType == MazeCellHighlight.Evaluating) hPrefab = evaluatingPrefab;
else if (hType == MazeCellHighlight.EvaluatingValid) hPrefab = searchHeadPrefab; // Use same prefab or we can add new ones, but for now fallback to Head/Evaluating
else if (hType == MazeCellHighlight.EvaluatingInvalid) hPrefab = evaluatingPrefab;
if (hPrefab != null)
{
@@ -160,58 +196,86 @@ namespace Baba_yaga.GameSetup.MazeRework
GameObject spawnedH = Instantiate(hPrefab, container != null ? container : transform);
spawnedH.transform.localPosition = localPos;
spawnedH.name = $"Highlight_{hType}_{x}_{z}";
// Highlights should pop instantly without animation since they are short-lived cursors
_spawnedHighlights[pos] = spawnedH;
}
}
// 2. Process Grid Cells
if (type == MazeReworkCellType.Wall)
GameObject targetPrefab = null;
float targetRot = 0f;
string targetName = "";
if (isPreviewMode)
{
if (_spawnedObjects.TryGetValue(pos, out GameObject existing))
if (type == MazeReworkCellType.Wall)
{
if (Application.isPlaying) Destroy(existing);
else DestroyImmediate(existing);
_spawnedObjects.Remove(pos);
targetPrefab = previewWallPrefab;
targetName = "PreviewWall";
}
else
{
targetPrefab = previewPathPrefab;
targetName = "PreviewPath";
}
}
else
{
if (type != MazeReworkCellType.Wall)
{
if (type == MazeReworkCellType.Start && beginningPrefab != null)
{
(_, float baseRot) = GetModularPrefabAndRotation(grid, x, z, width, depth);
targetPrefab = beginningPrefab;
targetRot = baseRot + beginningRotationOffset;
targetName = "Beginning";
}
else if (type == MazeReworkCellType.End && endPrefab != null)
{
(_, float baseRot) = GetModularPrefabAndRotation(grid, x, z, width, depth);
targetPrefab = endPrefab;
targetRot = baseRot + endRotationOffset;
targetName = "End";
}
else
{
(targetPrefab, targetRot) = GetModularPrefabAndRotation(grid, x, z, width, depth);
targetName = $"{type}";
}
}
}
if (targetPrefab == null)
{
if (_spawnedGridCells.TryGetValue(pos, out var existing))
{
if (Application.isPlaying) Destroy(existing.Instance);
else DestroyImmediate(existing.Instance);
_spawnedGridCells.Remove(pos);
}
return;
}
if (type == MazeReworkCellType.Start && beginningPrefab != null)
// If it already exists and is the identical prefab/rotation, DO NOT recreate it!
// This prevents PopInAnimation from constantly restarting when neighbors are evaluated.
if (_spawnedGridCells.TryGetValue(pos, out var existingData))
{
(_, float baseRot) = GetModularPrefabAndRotation(grid, x, z, width, depth);
SpawnPrefab(beginningPrefab, x, yOffset, z, baseRot + beginningRotationOffset, container, "Beginning");
return;
if (existingData.Prefab == targetPrefab && Mathf.Approximately(existingData.Rotation, targetRot))
{
return; // No change needed!
}
if (Application.isPlaying) Destroy(existingData.Instance);
else DestroyImmediate(existingData.Instance);
}
if (type == MazeReworkCellType.End && endPrefab != null)
{
(_, float baseRot) = GetModularPrefabAndRotation(grid, x, z, width, depth);
SpawnPrefab(endPrefab, x, yOffset, z, baseRot + endRotationOffset, container, "End");
return;
}
(GameObject modularPrefab, float yRotation) = GetModularPrefabAndRotation(grid, x, z, width, depth);
if (modularPrefab != null)
{
SpawnPrefab(modularPrefab, x, yOffset, z, yRotation, container, $"{type}");
}
}
private void SpawnPrefab(GameObject prefab, int x, float y, int z, float yRotation, Transform container, string namePrefix)
{
Vector3 localPosition = new Vector3(x * spacing, y, z * spacing);
GameObject spawnedObj = Instantiate(prefab, container != null ? container : transform);
Vector3 localPosition = new Vector3(x * spacing, yOffset, z * spacing);
GameObject spawnedObj = Instantiate(targetPrefab, container != null ? container : transform);
spawnedObj.transform.localPosition = localPosition;
spawnedObj.transform.localRotation = Quaternion.Euler(0f, yRotation, 0f);
spawnedObj.name = $"{namePrefix}_{x}_{z}";
spawnedObj.transform.localRotation = Quaternion.Euler(0f, targetRot, 0f);
spawnedObj.name = $"{targetName}_{x}_{z}";
spawnedObj.AddComponent<Animation.PopInAnimation>();
var pos = new Vector2Int(x, z);
if (_spawnedObjects.TryGetValue(pos, out GameObject existing))
{
if (Application.isPlaying) Destroy(existing);
else DestroyImmediate(existing);
}
_spawnedObjects[pos] = spawnedObj;
_spawnedGridCells[pos] = new SpawnedCellData { Instance = spawnedObj, Prefab = targetPrefab, Rotation = targetRot };
}
private (GameObject, float) GetModularPrefabAndRotation(MazeReworkCellType[,] grid, int x, int z, int width, int depth)