using System.Collections; using System.Collections.Generic; using UnityEngine; /// /// MatchBoard — a complete, minimal match-3 core loop for Unity 6. /// /// HOW TO USE: /// 1. Create a new 3D (URP or Built-in) project in Unity 6.3 LTS. /// 2. In the Hierarchy: Create Empty -> rename it "Board". /// 3. Drag this script onto "Board" (or Add Component -> Match Board). /// 4. Make sure there is a "Main Camera" in the scene (there is by default). /// 5. Press Play. Click one tile, then an adjacent tile, to swap. /// /// This intentionally has NO backend, NO pooling, NO special candies yet. /// It is the honest foundation. Build fun on top of this, not infrastructure /// underneath it. The grid is pure 2D logic; the cubes are just the renderer. /// public class MatchBoard : MonoBehaviour { [Header("Board size")] public int width = 8; public int height = 8; public int colorCount = 6; // 3..6 [Header("Layout / feel")] public float spacing = 1.1f; // world distance between tile centers public float fallSpeed = 14f; // how fast tiles slide into their slot // ---- state ---- int[,] grid; // color id per cell (0..colorCount-1), -1 = empty Transform[,] tiles; // the visible cube for each cell Vector3[,] target; // where each cube should slide to Color[] palette; Camera cam; bool busy; // locks input while the board resolves Vector2Int? selected; void Start() { cam = Camera.main; BuildPalette(); grid = new int[width, height]; tiles = new Transform[width, height]; target = new Vector3[width, height]; BuildBoard(); PlaceCamera(); } void Update() { // Every frame, slide each cube toward its logical slot. Decoupling the // animation from the logic like this keeps the cascade code simple. for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) if (tiles[x, y] != null) tiles[x, y].position = Vector3.MoveTowards( tiles[x, y].position, target[x, y], fallSpeed * Time.deltaTime); if (!busy && Input.GetMouseButtonDown(0)) HandleClick(); } // ---------------------------------------------------------------- build void BuildPalette() { palette = new Color[] { new Color(0.90f, 0.22f, 0.27f), // red new Color(0.98f, 0.75f, 0.05f), // yellow new Color(0.49f, 0.23f, 0.93f), // purple new Color(0.96f, 0.49f, 0.00f), // orange new Color(0.26f, 0.63f, 0.28f), // green new Color(0.12f, 0.53f, 0.90f), // blue }; colorCount = Mathf.Clamp(colorCount, 3, palette.Length); } void BuildBoard() { for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { grid[x, y] = PickNonMatching(x, y); SpawnTile(x, y, grid[x, y]); } } // Avoid creating a 3-in-a-row as we fill left->right, bottom->top. int PickNonMatching(int x, int y) { var banned = new HashSet(); if (x >= 2 && grid[x - 1, y] == grid[x - 2, y]) banned.Add(grid[x - 1, y]); if (y >= 2 && grid[x, y - 1] == grid[x, y - 2]) banned.Add(grid[x, y - 1]); int c; do { c = Random.Range(0, colorCount); } while (banned.Contains(c)); return c; } void SpawnTile(int x, int y, int color) { var go = GameObject.CreatePrimitive(PrimitiveType.Cube); // includes a BoxCollider for raycasts go.name = $"Tile_{x}_{y}"; go.transform.SetParent(transform); go.transform.localScale = Vector3.one * 0.92f; go.GetComponent().material.color = palette[color]; Vector3 slot = WorldPos(x, y); go.transform.position = slot + Vector3.up * (height * spacing); // start above and fall in target[x, y] = slot; tiles[x, y] = go.transform; } Vector3 WorldPos(int x, int y) => new Vector3(x * spacing, y * spacing, 0f); void PlaceCamera() { if (cam == null) return; float cx = (width - 1) * spacing / 2f; float cy = (height - 1) * spacing / 2f; cam.orthographic = true; // flat 2D-style view of 3D cubes cam.orthographicSize = (height * spacing) / 2f + 0.6f; cam.transform.position = new Vector3(cx, cy, -10f); cam.transform.rotation = Quaternion.identity; // For a true 3D look later: cam.orthographic = false; then tilt the camera and add a light. } // ---------------------------------------------------------------- input void HandleClick() { Ray ray = cam.ScreenPointToRay(Input.mousePosition); if (!Physics.Raycast(ray, out RaycastHit hit)) return; for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) if (tiles[x, y] != null && tiles[x, y] == hit.transform) { OnTileClicked(new Vector2Int(x, y)); return; } } void OnTileClicked(Vector2Int cell) { if (selected == null) { selected = cell; Highlight(cell, true); return; } Vector2Int a = selected.Value; Highlight(a, false); selected = null; if (a == cell) return; // tapped same tile -> deselect if (IsAdjacent(a, cell)) StartCoroutine(TrySwap(a, cell)); else { selected = cell; Highlight(cell, true); } // tapped far tile -> reselect } bool IsAdjacent(Vector2Int a, Vector2Int b) => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y) == 1; void Highlight(Vector2Int c, bool on) { if (tiles[c.x, c.y] != null) tiles[c.x, c.y].localScale = Vector3.one * (on ? 1.08f : 0.92f); } // ---------------------------------------------------------------- core loop IEnumerator TrySwap(Vector2Int a, Vector2Int b) { busy = true; SwapCells(a, b); yield return new WaitForSeconds(0.18f); if (FindMatches().Count == 0) { SwapCells(a, b); // illegal move -> swap back yield return new WaitForSeconds(0.18f); } else { yield return ResolveCascades(); } busy = false; } void SwapCells(Vector2Int a, Vector2Int b) { (grid[a.x, a.y], grid[b.x, b.y]) = (grid[b.x, b.y], grid[a.x, a.y]); (tiles[a.x, a.y], tiles[b.x, b.y]) = (tiles[b.x, b.y], tiles[a.x, a.y]); target[a.x, a.y] = WorldPos(a.x, a.y); target[b.x, b.y] = WorldPos(b.x, b.y); } // Returns every cell that is part of a run of 3+ (horizontal or vertical). HashSet FindMatches() { var matched = new HashSet(); for (int y = 0; y < height; y++) // horizontal runs { int run = 1; for (int x = 1; x <= width; x++) { bool same = x < width && grid[x, y] != -1 && grid[x, y] == grid[x - 1, y]; if (same) run++; else { if (run >= 3) for (int k = 1; k <= run; k++) matched.Add(new Vector2Int(x - k, y)); run = 1; } } } for (int x = 0; x < width; x++) // vertical runs { int run = 1; for (int y = 1; y <= height; y++) { bool same = y < height && grid[x, y] != -1 && grid[x, y] == grid[x, y - 1]; if (same) run++; else { if (run >= 3) for (int k = 1; k <= run; k++) matched.Add(new Vector2Int(x, y - k)); run = 1; } } } return matched; } IEnumerator ResolveCascades() { while (true) { var matches = FindMatches(); if (matches.Count == 0) yield break; // <-- hook a combo multiplier / score here later foreach (var c in matches) { if (tiles[c.x, c.y] != null) Destroy(tiles[c.x, c.y].gameObject); tiles[c.x, c.y] = null; grid[c.x, c.y] = -1; } yield return new WaitForSeconds(0.12f); ApplyGravity(); Refill(); yield return new WaitForSeconds(0.20f); } } void ApplyGravity() { for (int x = 0; x < width; x++) { int write = 0; // lowest empty slot in this column for (int y = 0; y < height; y++) { if (grid[x, y] != -1) { if (write != y) { grid[x, write] = grid[x, y]; tiles[x, write] = tiles[x, y]; target[x, write] = WorldPos(x, write); grid[x, y] = -1; tiles[x, y] = null; } write++; } } } } void Refill() { for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) if (grid[x, y] == -1) { grid[x, y] = Random.Range(0, colorCount); SpawnTile(x, y, grid[x, y]); } } }