From e4ac24f9898462df9db57fda1d15631d495bf6ba Mon Sep 17 00:00:00 2001 From: John Lamb Date: Sat, 7 Feb 2026 17:05:44 -0600 Subject: [PATCH] feat(grid): Replace real-time CTF with turn-based grid system Replace continuous free-form movement with discrete 20x50 grid-based gameplay featuring asymmetric movement mechanics: - Blue/Red teams with 3 units each - Zone-based movement: orthogonal (4 dir) in offense, diagonal (8 dir) in defense - Alternating turns with click-to-select, click-to-move input - Fog of war: 3-cell Chebyshev vision radius per unit - Defense speed nerf: skip every 4th move in own zone - AI opponent that chases flag carriers and advances toward enemy flag - Collision resolution: defender wins in their zone, lower ID wins in neutral Implements all 3 phases from the plan: - Phase 1: Playable grid with hot-seat two-player - Phase 2: Fog of war + defense speed nerf - Phase 3: AI opponent Deleted obsolete files: Flag.cs, Unit.cs, RouteDrawer.cs, SimpleAI.cs, Visibility.cs Co-Authored-By: Claude --- .../Assets/Scripts/CameraController.cs | 51 +- Backyard CTF/Assets/Scripts/Flag.cs | 97 -- Backyard CTF/Assets/Scripts/Flag.cs.meta | 2 - Backyard CTF/Assets/Scripts/Game.cs | 493 +--------- Backyard CTF/Assets/Scripts/Grid/GridAI.cs | 143 +++ Backyard CTF/Assets/Scripts/Grid/GridBoard.cs | 905 ++++++++++++++++++ Backyard CTF/Assets/Scripts/Grid/GridUnit.cs | 37 + Backyard CTF/Assets/Scripts/RouteDrawer.cs | 147 --- .../Assets/Scripts/RouteDrawer.cs.meta | 2 - Backyard CTF/Assets/Scripts/SimpleAI.cs | 70 -- Backyard CTF/Assets/Scripts/SimpleAI.cs.meta | 2 - Backyard CTF/Assets/Scripts/Unit.cs | 232 ----- Backyard CTF/Assets/Scripts/Unit.cs.meta | 2 - Backyard CTF/Assets/Scripts/Visibility.cs | 59 -- .../Assets/Scripts/Visibility.cs.meta | 2 - ...26-02-04-asymmetric-grid-ctf-brainstorm.md | 103 ++ ...-04-feat-asymmetric-grid-ctf-board-plan.md | 403 ++++++++ 17 files changed, 1658 insertions(+), 1092 deletions(-) delete mode 100644 Backyard CTF/Assets/Scripts/Flag.cs delete mode 100644 Backyard CTF/Assets/Scripts/Flag.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/Grid/GridAI.cs create mode 100644 Backyard CTF/Assets/Scripts/Grid/GridBoard.cs create mode 100644 Backyard CTF/Assets/Scripts/Grid/GridUnit.cs delete mode 100644 Backyard CTF/Assets/Scripts/RouteDrawer.cs delete mode 100644 Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta delete mode 100644 Backyard CTF/Assets/Scripts/SimpleAI.cs delete mode 100644 Backyard CTF/Assets/Scripts/SimpleAI.cs.meta delete mode 100644 Backyard CTF/Assets/Scripts/Unit.cs delete mode 100644 Backyard CTF/Assets/Scripts/Unit.cs.meta delete mode 100644 Backyard CTF/Assets/Scripts/Visibility.cs delete mode 100644 Backyard CTF/Assets/Scripts/Visibility.cs.meta create mode 100644 docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md create mode 100644 docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md diff --git a/Backyard CTF/Assets/Scripts/CameraController.cs b/Backyard CTF/Assets/Scripts/CameraController.cs index dd05d3b..fdc9eae 100644 --- a/Backyard CTF/Assets/Scripts/CameraController.cs +++ b/Backyard CTF/Assets/Scripts/CameraController.cs @@ -5,22 +5,23 @@ public class CameraController : MonoBehaviour { // Zoom settings public float minZoom = 5f; - public float maxZoom = 25f; + public float maxZoom = 35f; public float zoomSpeed = 2f; public float pinchZoomSpeed = 0.1f; // Pan settings public float panSpeed = 1f; + // Map bounds (grid-based) + float mapWidth = ZoneBoundaries.BoardWidth; + float mapHeight = ZoneBoundaries.BoardHeight; + Camera cam; Vector2 lastPanPosition; bool isPanning; float lastPinchDistance; bool isPinching; - // Track if we started on a unit (don't pan if drawing route) - bool startedOnUnit; - void Start() { cam = Camera.main; @@ -45,7 +46,7 @@ public class CameraController : MonoBehaviour Zoom(-scroll * zoomSpeed * Time.deltaTime * 10f); } - // Right-click pan (left-click is for route drawing) + // Right-click pan (left-click is for unit selection) if (mouse.rightButton.wasPressedThisFrame) { lastPanPosition = mouse.position.ReadValue(); @@ -115,32 +116,8 @@ public class CameraController : MonoBehaviour var primaryTouch = touch.primaryTouch; Vector2 touchPos = primaryTouch.position.ReadValue(); - // Check if touch started on a unit - if (primaryTouch.press.wasPressedThisFrame) - { - Vector2 worldPos = cam.ScreenToWorldPoint(touchPos); - var hit = Physics2D.OverlapPoint(worldPos); - startedOnUnit = hit != null && hit.GetComponent() != null; - - if (!startedOnUnit) - { - lastPanPosition = touchPos; - isPanning = true; - } - } - else if (primaryTouch.press.wasReleasedThisFrame) - { - isPanning = false; - startedOnUnit = false; - } - - // Single finger pan (only if not drawing route) - if (isPanning && !startedOnUnit && primaryTouch.press.isPressed) - { - Vector2 delta = touchPos - lastPanPosition; - Pan(-delta); - lastPanPosition = touchPos; - } + // Two-finger gestures only for pan on touch - single finger is for unit selection + // Don't initiate pan on single touch } else { @@ -174,10 +151,14 @@ public class CameraController : MonoBehaviour float halfHeight = cam.orthographicSize; float halfWidth = halfHeight * cam.aspect; - float minX = -Game.MapWidth / 2f + halfWidth; - float maxX = Game.MapWidth / 2f - halfWidth; - float minY = -Game.MapHeight / 2f + halfHeight; - float maxY = Game.MapHeight / 2f - halfHeight; + float minX = -mapWidth / 2f + halfWidth; + float maxX = mapWidth / 2f - halfWidth; + float minY = -mapHeight / 2f + halfHeight; + float maxY = mapHeight / 2f - halfHeight; + + // Handle case where zoom is wider than map + if (minX > maxX) minX = maxX = 0; + if (minY > maxY) minY = maxY = 0; Vector3 pos = cam.transform.position; pos.x = Mathf.Clamp(pos.x, minX, maxX); diff --git a/Backyard CTF/Assets/Scripts/Flag.cs b/Backyard CTF/Assets/Scripts/Flag.cs deleted file mode 100644 index fd1090e..0000000 --- a/Backyard CTF/Assets/Scripts/Flag.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Collections; -using UnityEngine; - -public class Flag : MonoBehaviour -{ - public Unit.Team team; - public Transform homePosition; - public Unit carriedBy; - - float dropTimer; - bool isDropped; - Coroutine returnCoroutine; - - void Update() - { - if (carriedBy != null) - { - // Follow carrier - transform.position = carriedBy.transform.position + new Vector3(0.3f, 0.3f, 0); - } - } - - public void Pickup(Unit unit) - { - if (carriedBy != null) return; - - // Cancel return timer if picking up dropped flag - if (returnCoroutine != null) - { - StopCoroutine(returnCoroutine); - returnCoroutine = null; - } - - carriedBy = unit; - unit.hasFlag = true; - isDropped = false; - - Debug.Log($"{unit.team} picked up {team} flag!"); - } - - public void Drop() - { - if (carriedBy == null) return; - - carriedBy.hasFlag = false; - carriedBy = null; - isDropped = true; - - Debug.Log($"{team} flag dropped!"); - - // Start return timer - returnCoroutine = StartCoroutine(ReturnAfterDelay()); - } - - IEnumerator ReturnAfterDelay() - { - yield return new WaitForSeconds(Game.FlagReturnDelay); - - if (isDropped && carriedBy == null) - { - ReturnHome(); - } - } - - public void ReturnHome() - { - if (returnCoroutine != null) - { - StopCoroutine(returnCoroutine); - returnCoroutine = null; - } - - if (carriedBy != null) - { - carriedBy.hasFlag = false; - carriedBy = null; - } - - transform.position = homePosition.position; - isDropped = false; - - Debug.Log($"{team} flag returned home!"); - } - - void OnTriggerEnter2D(Collider2D other) - { - // If flag is dropped and a friendly unit touches it, return it home immediately - if (isDropped && carriedBy == null) - { - var unit = other.GetComponent(); - if (unit != null && unit.team == team && !unit.isTaggedOut) - { - ReturnHome(); - } - } - } -} diff --git a/Backyard CTF/Assets/Scripts/Flag.cs.meta b/Backyard CTF/Assets/Scripts/Flag.cs.meta deleted file mode 100644 index 2400d90..0000000 --- a/Backyard CTF/Assets/Scripts/Flag.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 95e5070ee05d6462bbee9fa1ca4917d9 \ No newline at end of file diff --git a/Backyard CTF/Assets/Scripts/Game.cs b/Backyard CTF/Assets/Scripts/Game.cs index 540448f..b935e45 100644 --- a/Backyard CTF/Assets/Scripts/Game.cs +++ b/Backyard CTF/Assets/Scripts/Game.cs @@ -1,5 +1,3 @@ -using System.Collections; -using System.Collections.Generic; using UnityEngine; using TMPro; @@ -16,63 +14,21 @@ public class Game : MonoBehaviour } // =========================================== - // CONFIGURATION - All tunable constants here + // CONFIGURATION // =========================================== - // Gameplay - public const int UnitsPerTeam = 3; - public const float UnitSpeed = 5f; - public const float UnitSize = 1f; - public const float UnitColliderRadius = 0.5f; - public const float MinGapSize = 2f; // Minimum gap between obstacles + // Camera settings for grid view + public const float CameraZoom = 28f; // Fits 20x50 grid + public const float CameraMinZoom = 15f; + public const float CameraMaxZoom = 35f; - // Vision & Detection - public const float VisionRadius = 6f; - public const float TagRadius = 0.75f; - public const float FlagPickupRadius = 0.75f; - - // Timing - public const float RespawnDelay = 5f; - public const float FlagReturnDelay = 5f; - - // Scoring - public const int WinScore = 3; - - // Map - Large neighborhood (19:9 landscape, requires zoom) - public const float MapWidth = 80f; - public const float MapHeight = 40f; - - // Camera - public const float CameraMinZoom = 8f; - public const float CameraMaxZoom = 22f; - public const float CameraStartZoom = 15f; - - // Bases - public const float BaseSize = 6f; - public const float BaseInset = 6f; - - // Neighborhood layout - public const float StreetWidth = 4f; - public const float HouseWidth = 5f; - public const float HouseDepth = 4f; - public const float YardDepth = 3f; - - // References set during setup + // References public static Game Instance { get; private set; } - public List playerUnits = new(); - public List enemyUnits = new(); - public Transform playerBase; - public Transform enemyBase; - public Flag playerFlag; - public Flag enemyFlag; - - int playerScore; - int enemyScore; - bool gameOver; - + GridBoard gridBoard; TextMeshProUGUI scoreText; TextMeshProUGUI gameOverText; + TextMeshProUGUI turnText; void Awake() { @@ -81,17 +37,9 @@ public class Game : MonoBehaviour void Start() { - // Seed random for consistent neighborhood layout - Random.InitState(42); - SetupCamera(); - CreateGround(); - CreateObstacles(); - CreateBases(); - CreateFlags(); - SpawnUnits(); CreateUI(); - SetupSystems(); + CreateGridBoard(); } void SetupCamera() @@ -100,8 +48,9 @@ public class Game : MonoBehaviour if (cam != null) { cam.orthographic = true; - cam.orthographicSize = CameraStartZoom; + cam.orthographicSize = CameraZoom; cam.transform.position = new Vector3(0, 0, -10); + cam.backgroundColor = new Color(0.1f, 0.1f, 0.15f); // Add camera controller for zoom/pan var controller = cam.gameObject.AddComponent(); @@ -110,270 +59,6 @@ public class Game : MonoBehaviour } } - void CreateGround() - { - // Grass background - var ground = CreateSprite("Ground", new Color(0.2f, 0.35f, 0.15f), MapWidth, MapHeight); - ground.transform.position = Vector3.zero; - var sr = ground.GetComponent(); - sr.sortingOrder = -10; - - // Create streets (darker gray paths) - Color streetColor = new Color(0.3f, 0.3f, 0.32f); - - // Main horizontal street (center) - var mainStreet = CreateSprite("MainStreet", streetColor, MapWidth - 20f, StreetWidth); - mainStreet.transform.position = new Vector3(0, 0, 0); - mainStreet.GetComponent().sortingOrder = -9; - - // Upper horizontal street - var upperStreet = CreateSprite("UpperStreet", streetColor, MapWidth - 30f, StreetWidth); - upperStreet.transform.position = new Vector3(0, 10f, 0); - upperStreet.GetComponent().sortingOrder = -9; - - // Lower horizontal street - var lowerStreet = CreateSprite("LowerStreet", streetColor, MapWidth - 30f, StreetWidth); - lowerStreet.transform.position = new Vector3(0, -10f, 0); - lowerStreet.GetComponent().sortingOrder = -9; - - // Vertical cross streets - float[] crossStreetX = { -20f, -8f, 8f, 20f }; - int streetIndex = 0; - foreach (float x in crossStreetX) - { - var crossStreet = CreateSprite($"CrossStreet_{streetIndex++}", streetColor, StreetWidth, MapHeight - 10f); - crossStreet.transform.position = new Vector3(x, 0, 0); - crossStreet.GetComponent().sortingOrder = -9; - } - } - - void CreateObstacles() - { - // Create realistic neighborhood layout for 80x40 map - // Pattern: horizontal streets with house rows, vertical cross-streets - // All gaps minimum 2.5 units for unit passage - - var obstacles = new List<(Vector2 pos, Vector2 size, Color color)>(); - - // Street grid creates the main routes - // Houses line the streets with backyards creating sneaky routes - - // === ROW 1 (Top) - y around 12-16 === - CreateHouseRow(obstacles, y: 14f, xStart: -32f, xEnd: 32f, houseWidth: 5f, gapWidth: 3f, yardSide: -1); - - // === ROW 2 (Upper-mid) - y around 4-8 === - CreateHouseRow(obstacles, y: 6f, xStart: -28f, xEnd: 28f, houseWidth: 6f, gapWidth: 3.5f, yardSide: 1); - - // === ROW 3 (Center) - y around -2 to 2 === - // Staggered houses creating interesting chokepoints - CreateHouseRow(obstacles, y: -1f, xStart: -25f, xEnd: 25f, houseWidth: 5f, gapWidth: 4f, yardSide: -1); - - // === ROW 4 (Lower-mid) - y around -8 to -4 === - CreateHouseRow(obstacles, y: -8f, xStart: -28f, xEnd: 28f, houseWidth: 6f, gapWidth: 3.5f, yardSide: 1); - - // === ROW 5 (Bottom) - y around -12 to -16 === - CreateHouseRow(obstacles, y: -14f, xStart: -32f, xEnd: 32f, houseWidth: 5f, gapWidth: 3f, yardSide: 1); - - // === Add some fences/hedges in backyards for extra cover === - // These create the sneaky backyard routes - AddBackyardObstacles(obstacles); - - // Create all obstacles - int houseIndex = 0; - foreach (var (pos, size, color) in obstacles) - { - var house = CreateSprite($"House_{houseIndex++}", color, size.x, size.y); - house.transform.position = new Vector3(pos.x, pos.y, 0); - - var collider = house.AddComponent(); - collider.size = Vector2.one; - - var sr = house.GetComponent(); - sr.sortingOrder = -5; - } - } - - void CreateHouseRow(List<(Vector2, Vector2, Color)> obstacles, float y, float xStart, float xEnd, - float houseWidth, float gapWidth, int yardSide) - { - Color houseColor = new Color(0.4f, 0.35f, 0.3f); // Brown-ish houses - float houseDepth = 4f; - float x = xStart; - - while (x < xEnd - houseWidth) - { - // Vary house sizes slightly for visual interest - float w = houseWidth + Random.Range(-0.5f, 0.5f); - float h = houseDepth + Random.Range(-0.3f, 0.3f); - - // Skip houses near bases (leave clear zones) - if (Mathf.Abs(x) > 12f || Mathf.Abs(y) < 10f) - { - obstacles.Add((new Vector2(x + w / 2f, y), new Vector2(w, h), houseColor)); - } - - x += w + gapWidth; - } - } - - void AddBackyardObstacles(List<(Vector2, Vector2, Color)> obstacles) - { - Color fenceColor = new Color(0.5f, 0.4f, 0.3f); // Fence brown - Color hedgeColor = new Color(0.15f, 0.35f, 0.15f); // Hedge green - - // Fences between yards (vertical) - create backyard maze - float[] fenceXPositions = { -22f, -14f, -6f, 6f, 14f, 22f }; - foreach (float fx in fenceXPositions) - { - // Upper backyard fences - if (Random.value > 0.3f) - obstacles.Add((new Vector2(fx, 10f), new Vector2(0.5f, 3f), fenceColor)); - // Lower backyard fences - if (Random.value > 0.3f) - obstacles.Add((new Vector2(fx, -10f), new Vector2(0.5f, 3f), fenceColor)); - } - - // Hedges along some property lines (horizontal) - float[] hedgeYPositions = { 10f, 2.5f, -4f, -11f }; - foreach (float hy in hedgeYPositions) - { - float hx = Random.Range(-20f, 20f); - if (Mathf.Abs(hx) > 8f) // Not too close to center - { - obstacles.Add((new Vector2(hx, hy), new Vector2(4f, 1f), hedgeColor)); - } - } - - // Some sheds/garages in backyards - Color shedColor = new Color(0.45f, 0.4f, 0.35f); - Vector2[] shedPositions = { - new(-18f, 10f), new(-10f, -10f), new(8f, 10f), new(16f, -10f), - new(-26f, 2f), new(26f, -3f) - }; - foreach (var pos in shedPositions) - { - if (Random.value > 0.4f) - obstacles.Add((pos, new Vector2(2.5f, 2.5f), shedColor)); - } - } - - void CreateBases() - { - // Player base - left side - var playerBaseGO = CreateSprite("PlayerBase", new Color(0.2f, 0.3f, 0.8f, 0.5f), BaseSize, BaseSize); - playerBaseGO.transform.position = new Vector3(-MapWidth / 2f + BaseInset, 0, 0); - playerBase = playerBaseGO.transform; - var sr1 = playerBaseGO.GetComponent(); - sr1.sortingOrder = -8; - - var playerBaseTrigger = playerBaseGO.AddComponent(); - playerBaseTrigger.isTrigger = true; - playerBaseTrigger.size = Vector2.one; - - var playerBaseZone = playerBaseGO.AddComponent(); - playerBaseZone.team = Unit.Team.Player; - - // Enemy base - right side - var enemyBaseGO = CreateSprite("EnemyBase", new Color(0.8f, 0.2f, 0.2f, 0.5f), BaseSize, BaseSize); - enemyBaseGO.transform.position = new Vector3(MapWidth / 2f - BaseInset, 0, 0); - enemyBase = enemyBaseGO.transform; - var sr2 = enemyBaseGO.GetComponent(); - sr2.sortingOrder = -8; - - var enemyBaseTrigger = enemyBaseGO.AddComponent(); - enemyBaseTrigger.isTrigger = true; - enemyBaseTrigger.size = Vector2.one; - - var enemyBaseZone = enemyBaseGO.AddComponent(); - enemyBaseZone.team = Unit.Team.Enemy; - } - - void CreateFlags() - { - // Player flag at player base - var playerFlagGO = CreateSprite("PlayerFlag", new Color(0.3f, 0.5f, 1f), 1f, 1.5f); - playerFlagGO.transform.position = playerBase.position; - playerFlag = playerFlagGO.AddComponent(); - playerFlag.team = Unit.Team.Player; - playerFlag.homePosition = playerBase; - - var flagCollider1 = playerFlagGO.AddComponent(); - flagCollider1.isTrigger = true; - flagCollider1.radius = FlagPickupRadius; - - var rb1 = playerFlagGO.AddComponent(); - rb1.bodyType = RigidbodyType2D.Kinematic; - - var sr1 = playerFlagGO.GetComponent(); - sr1.sortingOrder = 5; - - // Enemy flag at enemy base - var enemyFlagGO = CreateSprite("EnemyFlag", new Color(1f, 0.3f, 0.3f), 1f, 1.5f); - enemyFlagGO.transform.position = enemyBase.position; - enemyFlag = enemyFlagGO.AddComponent(); - enemyFlag.team = Unit.Team.Enemy; - enemyFlag.homePosition = enemyBase; - - var flagCollider2 = enemyFlagGO.AddComponent(); - flagCollider2.isTrigger = true; - flagCollider2.radius = FlagPickupRadius; - - var rb2 = enemyFlagGO.AddComponent(); - rb2.bodyType = RigidbodyType2D.Kinematic; - - var sr2 = enemyFlagGO.GetComponent(); - sr2.sortingOrder = 5; - } - - void SpawnUnits() - { - // Spawn player units near player base - for (int i = 0; i < UnitsPerTeam; i++) - { - var offset = GetUnitSpawnOffset(i, UnitsPerTeam, isPlayer: true); - var unit = CreateUnit($"PlayerUnit_{i}", Unit.Team.Player, playerBase.position + offset); - playerUnits.Add(unit); - } - - // Spawn enemy units near enemy base - for (int i = 0; i < UnitsPerTeam; i++) - { - var offset = GetUnitSpawnOffset(i, UnitsPerTeam, isPlayer: false); - var unit = CreateUnit($"EnemyUnit_{i}", Unit.Team.Enemy, enemyBase.position + offset); - enemyUnits.Add(unit); - } - } - - Vector3 GetUnitSpawnOffset(int index, int total, bool isPlayer) - { - float spacing = 1.8f; - float yOffset = (index - (total - 1) / 2f) * spacing; - float xOffset = isPlayer ? 2f : -2f; // Offset toward center of map - return new Vector3(xOffset, yOffset, 0); - } - - Unit CreateUnit(string name, Unit.Team team, Vector3 position) - { - Color color = team == Unit.Team.Player ? new Color(0.3f, 0.5f, 1f) : new Color(1f, 0.3f, 0.3f); - var unitGO = CreateSprite(name, color, UnitSize, UnitSize); - unitGO.transform.position = position; - - var unit = unitGO.AddComponent(); - unit.team = team; - - var collider = unitGO.AddComponent(); - collider.isTrigger = true; - collider.radius = UnitColliderRadius; - - var rb = unitGO.AddComponent(); - rb.bodyType = RigidbodyType2D.Kinematic; - - var sr = unitGO.GetComponent(); - sr.sortingOrder = 10; - - return unit; - } - void CreateUI() { // Create Canvas @@ -383,7 +68,7 @@ public class Game : MonoBehaviour canvasGO.AddComponent(); canvasGO.AddComponent(); - // Score text + // Score text (top center) var scoreGO = new GameObject("ScoreText"); scoreGO.transform.SetParent(canvasGO.transform, false); scoreText = scoreGO.AddComponent(); @@ -399,6 +84,38 @@ public class Game : MonoBehaviour scoreRect.anchoredPosition = new Vector2(0, -20); scoreRect.sizeDelta = new Vector2(200, 60); + // Turn indicator (below score) + var turnGO = new GameObject("TurnText"); + turnGO.transform.SetParent(canvasGO.transform, false); + turnText = turnGO.AddComponent(); + turnText.text = "BLUE'S TURN"; + turnText.fontSize = 32; + turnText.alignment = TextAlignmentOptions.Center; + turnText.color = Color.blue; + + var turnRect = turnGO.GetComponent(); + turnRect.anchorMin = new Vector2(0.5f, 1f); + turnRect.anchorMax = new Vector2(0.5f, 1f); + turnRect.pivot = new Vector2(0.5f, 1f); + turnRect.anchoredPosition = new Vector2(0, -80); + turnRect.sizeDelta = new Vector2(300, 50); + + // Instructions (bottom) + var instructionsGO = new GameObject("InstructionsText"); + instructionsGO.transform.SetParent(canvasGO.transform, false); + var instructionsText = instructionsGO.AddComponent(); + instructionsText.text = "Click unit to select, then click destination to move"; + instructionsText.fontSize = 20; + instructionsText.alignment = TextAlignmentOptions.Center; + instructionsText.color = new Color(0.7f, 0.7f, 0.7f); + + var instrRect = instructionsGO.GetComponent(); + instrRect.anchorMin = new Vector2(0.5f, 0f); + instrRect.anchorMax = new Vector2(0.5f, 0f); + instrRect.pivot = new Vector2(0.5f, 0f); + instrRect.anchoredPosition = new Vector2(0, 20); + instrRect.sizeDelta = new Vector2(500, 40); + // Game over text (hidden initially) var gameOverGO = new GameObject("GameOverText"); gameOverGO.transform.SetParent(canvasGO.transform, false); @@ -417,122 +134,14 @@ public class Game : MonoBehaviour gameOverRect.sizeDelta = new Vector2(400, 100); } - void SetupSystems() + void CreateGridBoard() { - // Add RouteDrawer - gameObject.AddComponent(); + var boardGO = new GameObject("GridBoard"); + gridBoard = boardGO.AddComponent(); - // Add Visibility system - var visibility = gameObject.AddComponent(); - visibility.playerUnits = playerUnits.ToArray(); - visibility.enemyUnits = enemyUnits.ToArray(); - visibility.enemyFlag = enemyFlag; - - // Add SimpleAI - var ai = gameObject.AddComponent(); - ai.aiUnits = enemyUnits.ToArray(); - ai.playerFlag = playerFlag; - ai.aiBase = enemyBase; - } - - public void Score(Unit.Team team) - { - if (gameOver) return; - - if (team == Unit.Team.Player) - playerScore++; - else - enemyScore++; - - UpdateScoreUI(); - Debug.Log($"Score: {playerScore} - {enemyScore}"); - - if (playerScore >= WinScore) - EndGame(true); - else if (enemyScore >= WinScore) - EndGame(false); - else - ResetRound(); - } - - void UpdateScoreUI() - { - if (scoreText != null) - scoreText.text = $"{playerScore} - {enemyScore}"; - } - - void ResetRound() - { - // Return flags to home - playerFlag.ReturnHome(); - enemyFlag.ReturnHome(); - - // Respawn all units at bases - for (int i = 0; i < playerUnits.Count; i++) - { - var offset = GetUnitSpawnOffset(i, playerUnits.Count, isPlayer: true); - playerUnits[i].ForceRespawn(playerBase.position + offset); - } - - for (int i = 0; i < enemyUnits.Count; i++) - { - var offset = GetUnitSpawnOffset(i, enemyUnits.Count, isPlayer: false); - enemyUnits[i].ForceRespawn(enemyBase.position + offset); - } - } - - void EndGame(bool playerWon) - { - gameOver = true; - - if (gameOverText != null) - { - gameOverText.text = playerWon ? "YOU WIN!" : "YOU LOSE!"; - gameOverText.color = playerWon ? Color.green : Color.red; - gameOverText.gameObject.SetActive(true); - } - - Debug.Log(playerWon ? "Player wins!" : "Enemy wins!"); - } - - public Transform GetBase(Unit.Team team) - { - return team == Unit.Team.Player ? playerBase : enemyBase; - } - - public Flag GetFlag(Unit.Team team) - { - return team == Unit.Team.Player ? playerFlag : enemyFlag; - } - - // Helper to create a colored sprite - GameObject CreateSprite(string name, Color color, float width, float height) - { - var go = new GameObject(name); - var sr = go.AddComponent(); - sr.sprite = CreateRectSprite(); - sr.color = color; - go.transform.localScale = new Vector3(width, height, 1); - return go; - } - - // Create a simple white 1x1 sprite - static Sprite cachedSprite; - static Sprite CreateRectSprite() - { - if (cachedSprite != null) return cachedSprite; - - var tex = new Texture2D(1, 1); - tex.SetPixel(0, 0, Color.white); - tex.Apply(); - - cachedSprite = Sprite.Create(tex, new Rect(0, 0, 1, 1), new Vector2(0.5f, 0.5f), 1); - return cachedSprite; + // Pass UI references + gridBoard.scoreText = scoreText; + gridBoard.gameOverText = gameOverText; + gridBoard.turnText = turnText; } } - -// Simple component to identify base zones -public class BaseZone : MonoBehaviour -{ - public Unit.Team team; -} diff --git a/Backyard CTF/Assets/Scripts/Grid/GridAI.cs b/Backyard CTF/Assets/Scripts/Grid/GridAI.cs new file mode 100644 index 0000000..81e402d --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Grid/GridAI.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +public class GridAI : MonoBehaviour +{ + GridBoard board; + Team aiTeam; + float thinkDelay = 0.5f; + float thinkTimer = 0f; + bool hasMoved = false; + + public void Initialize(GridBoard board, Team team) + { + this.board = board; + this.aiTeam = team; + } + + void Update() + { + if (board == null || board.IsGameOver()) return; + if (board.GetCurrentTeam() != aiTeam) + { + hasMoved = false; + thinkTimer = 0f; + return; + } + + if (hasMoved) return; + + // Small delay to make AI moves visible + thinkTimer += Time.deltaTime; + if (thinkTimer < thinkDelay) return; + + MakeMove(); + hasMoved = true; + } + + void MakeMove() + { + var units = board.GetUnits(aiTeam); + var visibleCells = board.GetVisibleCells(aiTeam); + + // Find a unit that can move + GridUnit bestUnit = null; + Vector2Int bestMove = default; + float bestScore = float.MinValue; + + foreach (var unit in units) + { + if (unit.IsTaggedOut) continue; + + // Check speed nerf + if (board.IsDefending(unit) && unit.ConsecutiveDefenseMoves % 4 == 3) + { + continue; // This unit must skip + } + + var validMoves = board.GetValidMoves(unit); + if (validMoves.Count == 0) continue; + + foreach (var move in validMoves) + { + float score = EvaluateMove(unit, move, visibleCells); + if (score > bestScore) + { + bestScore = score; + bestUnit = unit; + bestMove = move; + } + } + } + + if (bestUnit != null) + { + board.ExecuteAIMove(bestUnit, bestMove); + } + else + { + // No valid moves, skip turn + board.AISkipTurn(); + } + } + + float EvaluateMove(GridUnit unit, Vector2Int move, HashSet visibleCells) + { + float score = 0f; + + var enemyFlagPos = board.GetEnemyFlagPosition(aiTeam); + var ourFlagCarrier = board.GetFlagCarrier(aiTeam == Team.Blue ? Team.Red : Team.Blue); + var theirFlagCarrier = board.GetFlagCarrier(aiTeam); + + // Are we carrying the enemy flag? + bool carryingFlag = theirFlagCarrier == unit; + + if (carryingFlag) + { + // Priority: Return to base with flag + // Move toward our defense zone + int targetY = aiTeam == Team.Blue ? 0 : ZoneBoundaries.BoardHeight - 1; + float distToBase = Mathf.Abs(move.y - targetY); + score += 1000f - distToBase * 10f; + } + else if (ourFlagCarrier != null && visibleCells.Contains(ourFlagCarrier.GridPosition)) + { + // Our flag is being carried - chase the carrier! + float distToCarrier = ChebyshevDistance(move, ourFlagCarrier.GridPosition); + score += 500f - distToCarrier * 15f; + } + else + { + // Go for the enemy flag + float distToFlag = ChebyshevDistance(move, enemyFlagPos); + score += 100f - distToFlag * 5f; + } + + // Small bonus for advancing toward enemy + if (aiTeam == Team.Blue) + { + score += move.y * 0.5f; // Blue advances up + } + else + { + score += (ZoneBoundaries.BoardHeight - move.y) * 0.5f; // Red advances down + } + + // Avoid staying in defense too long (speed penalty) + if (board.GetZoneOwner(move.y) == (aiTeam == Team.Blue ? ZoneOwner.Blue : ZoneOwner.Red)) + { + score -= 5f; + } + + // Small randomness to prevent predictability + score += Random.Range(0f, 2f); + + return score; + } + + int ChebyshevDistance(Vector2Int a, Vector2Int b) + { + return Mathf.Max(Mathf.Abs(a.x - b.x), Mathf.Abs(a.y - b.y)); + } +} diff --git a/Backyard CTF/Assets/Scripts/Grid/GridBoard.cs b/Backyard CTF/Assets/Scripts/Grid/GridBoard.cs new file mode 100644 index 0000000..938a12f --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Grid/GridBoard.cs @@ -0,0 +1,905 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +public enum ZoneOwner { Blue, Neutral, Red } + +public static class ZoneBoundaries +{ + public const int TeamBlueDefenseEnd = 20; // Y < 20 is Blue defense + public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamBlueDefenseEnd) + public const int BoardWidth = 20; + public const int BoardHeight = 50; +} + +public class GridBoard : MonoBehaviour +{ + // Configuration + const float CellSize = 1f; + const float CellPadding = 0.05f; + const int UnitsPerTeam = 3; + const int WinScore = 3; + const int RespawnDelay = 2; // Turns until respawn + const int VisionRadius = 3; // Chebyshev distance for fog of war + + // Colors + static readonly Color BlueZoneColor = new Color(0.2f, 0.3f, 0.6f, 0.4f); + static readonly Color NeutralZoneColor = new Color(0.4f, 0.4f, 0.4f, 0.4f); + static readonly Color RedZoneColor = new Color(0.6f, 0.2f, 0.2f, 0.4f); + static readonly Color BlueUnitColor = new Color(0.3f, 0.5f, 1f); + static readonly Color RedUnitColor = new Color(1f, 0.3f, 0.3f); + static readonly Color ValidMoveColor = new Color(0.3f, 1f, 0.3f, 0.5f); + static readonly Color SelectedColor = new Color(1f, 1f, 0.3f, 0.8f); + static readonly Color FlagBlueColor = new Color(0.3f, 0.5f, 1f); + static readonly Color FlagRedColor = new Color(1f, 0.3f, 0.3f); + + // Board state + GridUnit[,] cellOccupants; + Dictionary unitPositions = new(); + List blueUnits = new(); + List redUnits = new(); + + // Flags + Vector2Int blueFlagPosition; + Vector2Int redFlagPosition; + Vector2Int blueFlagHome; + Vector2Int redFlagHome; + GridUnit blueFlagCarrier; + GridUnit redFlagCarrier; + GameObject blueFlagGO; + GameObject redFlagGO; + + // Turn state + Team currentTeam = Team.Blue; + int turnNumber = 0; + + // Scores + int blueScore = 0; + int redScore = 0; + bool gameOver = false; + + // UI state + GridUnit selectedUnit; + List validMoves = new(); + List validMoveHighlights = new(); + GameObject selectionHighlight; + + // Visual objects + GameObject[,] cellVisuals; + + // Visibility + HashSet visibleToBlue = new(); + HashSet visibleToRed = new(); + + // AI + GridAI ai; + + // UI references (set by Game.cs) + public TMPro.TextMeshProUGUI scoreText; + public TMPro.TextMeshProUGUI gameOverText; + public TMPro.TextMeshProUGUI turnText; + + int nextUnitId = 0; + + void Start() + { + InitializeBoard(); + CreateBoardVisuals(); + SpawnUnits(); + CreateFlags(); + RecalculateVisibility(); + UpdateUI(); + + // Initialize AI for red team + ai = gameObject.AddComponent(); + ai.Initialize(this, Team.Red); + } + + void InitializeBoard() + { + cellOccupants = new GridUnit[ZoneBoundaries.BoardWidth, ZoneBoundaries.BoardHeight]; + cellVisuals = new GameObject[ZoneBoundaries.BoardWidth, ZoneBoundaries.BoardHeight]; + } + + void CreateBoardVisuals() + { + // Create parent for organization + var boardParent = new GameObject("Board"); + boardParent.transform.SetParent(transform); + + for (int x = 0; x < ZoneBoundaries.BoardWidth; x++) + { + for (int y = 0; y < ZoneBoundaries.BoardHeight; y++) + { + Color zoneColor = GetZoneColor(y); + var cell = CreateSprite($"Cell_{x}_{y}", zoneColor, CellSize - CellPadding, CellSize - CellPadding); + cell.transform.SetParent(boardParent.transform); + cell.transform.position = GridToWorld(new Vector2Int(x, y)); + cell.GetComponent().sortingOrder = -10; + cellVisuals[x, y] = cell; + } + } + } + + Color GetZoneColor(int y) + { + var zone = GetZoneOwner(y); + return zone switch + { + ZoneOwner.Blue => BlueZoneColor, + ZoneOwner.Neutral => NeutralZoneColor, + ZoneOwner.Red => RedZoneColor, + _ => NeutralZoneColor + }; + } + + void SpawnUnits() + { + // Blue units spawn at bottom of blue zone + for (int i = 0; i < UnitsPerTeam; i++) + { + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3); + int y = 2 + (i / 3); + SpawnUnit(Team.Blue, new Vector2Int(x, y)); + } + + // Red units spawn at top of red zone + for (int i = 0; i < UnitsPerTeam; i++) + { + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3); + int y = ZoneBoundaries.BoardHeight - 3 - (i / 3); + SpawnUnit(Team.Red, new Vector2Int(x, y)); + } + } + + GridUnit SpawnUnit(Team team, Vector2Int position) + { + Color color = team == Team.Blue ? BlueUnitColor : RedUnitColor; + var go = CreateSprite($"{team}Unit_{nextUnitId}", color, CellSize * 0.8f, CellSize * 0.8f); + go.GetComponent().sortingOrder = 10; + + var unit = new GridUnit(nextUnitId++, team, position, go); + unit.SetWorldPosition(GridToWorld(position)); + + cellOccupants[position.x, position.y] = unit; + unitPositions[unit] = position; + + if (team == Team.Blue) + blueUnits.Add(unit); + else + redUnits.Add(unit); + + return unit; + } + + void CreateFlags() + { + // Blue flag at center bottom of blue zone + blueFlagHome = new Vector2Int(ZoneBoundaries.BoardWidth / 2, 1); + blueFlagPosition = blueFlagHome; + blueFlagGO = CreateSprite("BlueFlag", FlagBlueColor, CellSize * 0.5f, CellSize * 0.8f); + blueFlagGO.GetComponent().sortingOrder = 5; + blueFlagGO.transform.position = GridToWorld(blueFlagPosition); + + // Red flag at center top of red zone + redFlagHome = new Vector2Int(ZoneBoundaries.BoardWidth / 2, ZoneBoundaries.BoardHeight - 2); + redFlagPosition = redFlagHome; + redFlagGO = CreateSprite("RedFlag", FlagRedColor, CellSize * 0.5f, CellSize * 0.8f); + redFlagGO.GetComponent().sortingOrder = 5; + redFlagGO.transform.position = GridToWorld(redFlagPosition); + } + + void Update() + { + if (gameOver) return; + + // AI handles red team + if (currentTeam == Team.Red) + { + return; // AI takes control in GridAI.Update() + } + + HandleInput(); + } + + void HandleInput() + { + var mouse = Mouse.current; + if (mouse == null) return; + + if (mouse.leftButton.wasPressedThisFrame) + { + Vector2 worldPos = Camera.main.ScreenToWorldPoint(mouse.position.ReadValue()); + Vector2Int gridPos = WorldToGrid(worldPos); + + if (!IsInBounds(gridPos)) return; + + // If we have a unit selected and clicked on a valid move, execute it + if (selectedUnit != null && validMoves.Contains(gridPos)) + { + ExecutePlayerMove(selectedUnit, gridPos); + return; + } + + // Check if we clicked on a friendly unit to select it + var unitAtCell = cellOccupants[gridPos.x, gridPos.y]; + if (unitAtCell != null && unitAtCell.Team == currentTeam && !unitAtCell.IsTaggedOut) + { + // Check if unit can move this turn (defense speed nerf) + if (IsDefending(unitAtCell) && unitAtCell.ConsecutiveDefenseMoves % 4 == 3) + { + // This unit must skip their move + Debug.Log($"Unit {unitAtCell.UnitId} must skip move (defense speed nerf)"); + SelectUnit(null); + return; + } + + SelectUnit(unitAtCell); + } + else + { + // Clicked on empty cell or enemy - deselect + SelectUnit(null); + } + } + + // Right click to deselect + if (mouse.rightButton.wasPressedThisFrame) + { + SelectUnit(null); + } + } + + void SelectUnit(GridUnit unit) + { + selectedUnit = unit; + ClearValidMoveHighlights(); + + if (unit == null) + { + if (selectionHighlight != null) + selectionHighlight.SetActive(false); + validMoves.Clear(); + return; + } + + // Show selection highlight + if (selectionHighlight == null) + { + selectionHighlight = CreateSprite("SelectionHighlight", SelectedColor, CellSize * 0.95f, CellSize * 0.95f); + selectionHighlight.GetComponent().sortingOrder = 1; + } + selectionHighlight.SetActive(true); + selectionHighlight.transform.position = GridToWorld(unit.GridPosition); + + // Calculate and show valid moves + validMoves = GetValidMoves(unit); + foreach (var move in validMoves) + { + var highlight = CreateSprite("ValidMove", ValidMoveColor, CellSize * 0.9f, CellSize * 0.9f); + highlight.GetComponent().sortingOrder = 0; + highlight.transform.position = GridToWorld(move); + validMoveHighlights.Add(highlight); + } + } + + void ClearValidMoveHighlights() + { + foreach (var highlight in validMoveHighlights) + { + Destroy(highlight); + } + validMoveHighlights.Clear(); + } + + public List GetValidMoves(GridUnit unit) + { + var moves = new List(); + if (unit.IsTaggedOut) return moves; + + var pos = unit.GridPosition; + bool isDefending = IsDefending(unit); + + // Determine valid directions based on zone + Vector2Int[] directions; + if (isDefending) + { + // Diagonal movement (8 directions) in own defense zone + directions = new Vector2Int[] + { + new(-1, -1), new(0, -1), new(1, -1), + new(-1, 0), new(1, 0), + new(-1, 1), new(0, 1), new(1, 1) + }; + } + else + { + // Orthogonal movement (4 directions) in offense/neutral zones + directions = new Vector2Int[] + { + new(0, -1), new(-1, 0), new(1, 0), new(0, 1) + }; + } + + foreach (var dir in directions) + { + var newPos = pos + dir; + if (IsInBounds(newPos)) + { + // Can move to empty cells or cells with enemies (will trigger collision) + var occupant = cellOccupants[newPos.x, newPos.y]; + if (occupant == null || occupant.Team != unit.Team) + { + moves.Add(newPos); + } + } + } + + return moves; + } + + void ExecutePlayerMove(GridUnit unit, Vector2Int destination) + { + ExecuteMove(unit, destination); + EndTurn(); + } + + void ExecuteMove(GridUnit unit, Vector2Int destination) + { + var startPos = unit.GridPosition; + bool wasDefending = IsDefending(unit); + + // Move unit + MoveUnit(unit, destination); + + // Update defense move counter + bool nowDefending = IsDefending(unit); + if (nowDefending) + { + unit.ConsecutiveDefenseMoves++; + } + else + { + unit.ConsecutiveDefenseMoves = 0; + } + + // Check collision + CheckCollision(destination); + + // Check flag pickup + CheckFlagPickup(unit); + + // Check scoring + CheckScoring(unit); + + // Update visual + unit.SetWorldPosition(GridToWorld(destination)); + UpdateFlagVisuals(); + } + + public void MoveUnit(GridUnit unit, Vector2Int to) + { + var from = unitPositions[unit]; + if (cellOccupants[from.x, from.y] == unit) + { + cellOccupants[from.x, from.y] = null; + } + cellOccupants[to.x, to.y] = unit; + unitPositions[unit] = to; + unit.GridPosition = to; + } + + void CheckCollision(Vector2Int cell) + { + // Find all units at this cell + var unitsHere = new List(); + foreach (var unit in blueUnits) + { + if (!unit.IsTaggedOut && unit.GridPosition == cell) + unitsHere.Add(unit); + } + foreach (var unit in redUnits) + { + if (!unit.IsTaggedOut && unit.GridPosition == cell) + unitsHere.Add(unit); + } + + if (unitsHere.Count < 2) return; + + // Check for enemy collisions + for (int i = 0; i < unitsHere.Count; i++) + { + for (int j = i + 1; j < unitsHere.Count; j++) + { + var a = unitsHere[i]; + var b = unitsHere[j]; + if (a.Team != b.Team) + { + ResolveCollision(a, b, cell); + } + } + } + } + + void ResolveCollision(GridUnit unitA, GridUnit unitB, Vector2Int cell) + { + if (unitA.IsTaggedOut || unitB.IsTaggedOut) return; + + var zoneOwner = GetZoneOwner(cell.y); + + // Defender in their zone always wins + if (unitA.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) + TagOut(unitB); + else if (unitB.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) + TagOut(unitA); + else if (unitA.Team == Team.Red && zoneOwner == ZoneOwner.Red) + TagOut(unitB); + else if (unitB.Team == Team.Red && zoneOwner == ZoneOwner.Red) + TagOut(unitA); + else + { + // Neutral zone: lower UnitId wins (deterministic) + if (unitA.UnitId < unitB.UnitId) + TagOut(unitB); + else + TagOut(unitA); + } + } + + void TagOut(GridUnit unit) + { + if (unit.IsTaggedOut) return; + + unit.IsTaggedOut = true; + unit.RespawnTurnsRemaining = RespawnDelay; + unit.ConsecutiveDefenseMoves = 0; + + // Drop flag if carrying + if (blueFlagCarrier == unit) + { + blueFlagCarrier = null; + // Flag stays at current position + Debug.Log("Blue flag dropped!"); + } + if (redFlagCarrier == unit) + { + redFlagCarrier = null; + Debug.Log("Red flag dropped!"); + } + + // Remove from cell + var pos = unit.GridPosition; + if (cellOccupants[pos.x, pos.y] == unit) + { + cellOccupants[pos.x, pos.y] = null; + } + + // Fade out visual + if (unit.SpriteRenderer != null) + { + var color = unit.SpriteRenderer.color; + color.a = 0.3f; + unit.SpriteRenderer.color = color; + } + + Debug.Log($"{unit.Team} unit {unit.UnitId} tagged out!"); + } + + void CheckFlagPickup(GridUnit unit) + { + if (unit.IsTaggedOut) return; + + var pos = unit.GridPosition; + + // Blue unit can pick up red flag + if (unit.Team == Team.Blue && pos == redFlagPosition && redFlagCarrier == null) + { + redFlagCarrier = unit; + Debug.Log($"Blue unit picked up red flag!"); + } + // Red unit can pick up blue flag + else if (unit.Team == Team.Red && pos == blueFlagPosition && blueFlagCarrier == null) + { + blueFlagCarrier = unit; + Debug.Log($"Red unit picked up blue flag!"); + } + + // Return own flag if it's dropped and friendly unit touches it + if (unit.Team == Team.Blue && pos == blueFlagPosition && blueFlagCarrier == null && blueFlagPosition != blueFlagHome) + { + blueFlagPosition = blueFlagHome; + Debug.Log("Blue flag returned home!"); + } + if (unit.Team == Team.Red && pos == redFlagPosition && redFlagCarrier == null && redFlagPosition != redFlagHome) + { + redFlagPosition = redFlagHome; + Debug.Log("Red flag returned home!"); + } + } + + void CheckScoring(GridUnit unit) + { + if (unit.IsTaggedOut) return; + + var pos = unit.GridPosition; + var zone = GetZoneOwner(pos.y); + + // Blue scores by bringing red flag to blue zone + if (unit.Team == Team.Blue && redFlagCarrier == unit && zone == ZoneOwner.Blue) + { + Score(Team.Blue); + } + // Red scores by bringing blue flag to red zone + else if (unit.Team == Team.Red && blueFlagCarrier == unit && zone == ZoneOwner.Red) + { + Score(Team.Red); + } + } + + void Score(Team team) + { + if (team == Team.Blue) + { + blueScore++; + Debug.Log($"Blue scores! {blueScore} - {redScore}"); + } + else + { + redScore++; + Debug.Log($"Red scores! {blueScore} - {redScore}"); + } + + UpdateUI(); + + if (blueScore >= WinScore) + { + EndGame(Team.Blue); + } + else if (redScore >= WinScore) + { + EndGame(Team.Red); + } + else + { + ResetRound(); + } + } + + void ResetRound() + { + // Return flags + blueFlagPosition = blueFlagHome; + redFlagPosition = redFlagHome; + blueFlagCarrier = null; + redFlagCarrier = null; + + // Respawn all units + for (int i = 0; i < blueUnits.Count; i++) + { + var unit = blueUnits[i]; + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3); + int y = 2 + (i / 3); + RespawnUnit(unit, new Vector2Int(x, y)); + } + + for (int i = 0; i < redUnits.Count; i++) + { + var unit = redUnits[i]; + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3); + int y = ZoneBoundaries.BoardHeight - 3 - (i / 3); + RespawnUnit(unit, new Vector2Int(x, y)); + } + + UpdateFlagVisuals(); + RecalculateVisibility(); + } + + void RespawnUnit(GridUnit unit, Vector2Int position) + { + unit.IsTaggedOut = false; + unit.RespawnTurnsRemaining = 0; + unit.ConsecutiveDefenseMoves = 0; + + // Clear old position + var oldPos = unit.GridPosition; + if (cellOccupants[oldPos.x, oldPos.y] == unit) + { + cellOccupants[oldPos.x, oldPos.y] = null; + } + + // Find nearest empty cell if position is occupied + if (cellOccupants[position.x, position.y] != null) + { + position = FindNearestEmptyCell(position); + } + + unit.GridPosition = position; + unitPositions[unit] = position; + cellOccupants[position.x, position.y] = unit; + unit.SetWorldPosition(GridToWorld(position)); + + // Restore visual + if (unit.SpriteRenderer != null) + { + var color = unit.SpriteRenderer.color; + color.a = 1f; + unit.SpriteRenderer.color = color; + } + } + + Vector2Int FindNearestEmptyCell(Vector2Int center) + { + for (int radius = 1; radius < 10; radius++) + { + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + var pos = center + new Vector2Int(dx, dy); + if (IsInBounds(pos) && cellOccupants[pos.x, pos.y] == null) + { + return pos; + } + } + } + } + return center; + } + + void EndTurn() + { + SelectUnit(null); + turnNumber++; + + // Process respawn timers + ProcessRespawns(); + + // Recalculate visibility + RecalculateVisibility(); + + // Switch teams + currentTeam = currentTeam == Team.Blue ? Team.Red : Team.Blue; + + UpdateUI(); + + Debug.Log($"Turn {turnNumber}: {currentTeam}'s turn"); + } + + void ProcessRespawns() + { + foreach (var unit in blueUnits) + { + if (unit.IsTaggedOut) + { + unit.RespawnTurnsRemaining--; + if (unit.RespawnTurnsRemaining <= 0) + { + int idx = blueUnits.IndexOf(unit); + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (idx % 3); + int y = 2 + (idx / 3); + RespawnUnit(unit, new Vector2Int(x, y)); + Debug.Log($"Blue unit {unit.UnitId} respawned!"); + } + } + } + + foreach (var unit in redUnits) + { + if (unit.IsTaggedOut) + { + unit.RespawnTurnsRemaining--; + if (unit.RespawnTurnsRemaining <= 0) + { + int idx = redUnits.IndexOf(unit); + int x = ZoneBoundaries.BoardWidth / 2 - 1 + (idx % 3); + int y = ZoneBoundaries.BoardHeight - 3 - (idx / 3); + RespawnUnit(unit, new Vector2Int(x, y)); + Debug.Log($"Red unit {unit.UnitId} respawned!"); + } + } + } + } + + void EndGame(Team winner) + { + gameOver = true; + + if (gameOverText != null) + { + gameOverText.text = winner == Team.Blue ? "BLUE WINS!" : "RED WINS!"; + gameOverText.color = winner == Team.Blue ? Color.blue : Color.red; + gameOverText.gameObject.SetActive(true); + } + + Debug.Log($"{winner} wins!"); + } + + void UpdateUI() + { + if (scoreText != null) + { + scoreText.text = $"{blueScore} - {redScore}"; + } + + if (turnText != null) + { + string turnIndicator = currentTeam == Team.Blue ? "BLUE'S TURN" : "RED'S TURN"; + turnText.text = turnIndicator; + turnText.color = currentTeam == Team.Blue ? Color.blue : Color.red; + } + } + + void UpdateFlagVisuals() + { + // Blue flag follows carrier or stays at position + if (blueFlagCarrier != null && !blueFlagCarrier.IsTaggedOut) + { + blueFlagPosition = blueFlagCarrier.GridPosition; + blueFlagGO.transform.position = GridToWorld(blueFlagPosition) + new Vector2(0.2f, 0.2f); + } + else + { + blueFlagGO.transform.position = GridToWorld(blueFlagPosition); + } + + // Red flag follows carrier or stays at position + if (redFlagCarrier != null && !redFlagCarrier.IsTaggedOut) + { + redFlagPosition = redFlagCarrier.GridPosition; + redFlagGO.transform.position = GridToWorld(redFlagPosition) + new Vector2(0.2f, 0.2f); + } + else + { + redFlagGO.transform.position = GridToWorld(redFlagPosition); + } + + // Apply fog of war visibility to flags + bool blueFlagVisible = visibleToBlue.Contains(blueFlagPosition) || blueFlagCarrier != null; + bool redFlagVisible = visibleToBlue.Contains(redFlagPosition) || redFlagCarrier != null; + + // For now, in single-player, Blue is the human player, so we apply Blue's visibility + redFlagGO.GetComponent().enabled = redFlagVisible; + // Blue flag is always visible to blue player + blueFlagGO.GetComponent().enabled = true; + } + + // Fog of War + void RecalculateVisibility() + { + visibleToBlue.Clear(); + visibleToRed.Clear(); + + foreach (var unit in blueUnits) + { + if (unit.IsTaggedOut) continue; + AddVisibleCells(unit.GridPosition, visibleToBlue); + } + + foreach (var unit in redUnits) + { + if (unit.IsTaggedOut) continue; + AddVisibleCells(unit.GridPosition, visibleToRed); + } + + // Update enemy visibility based on Blue's vision (human player) + foreach (var enemy in redUnits) + { + bool visible = visibleToBlue.Contains(enemy.GridPosition) || enemy.IsTaggedOut; + if (enemy.SpriteRenderer != null) + { + enemy.SpriteRenderer.enabled = visible; + } + } + + UpdateFlagVisuals(); + } + + void AddVisibleCells(Vector2Int center, HashSet visibleSet) + { + for (int dx = -VisionRadius; dx <= VisionRadius; dx++) + { + for (int dy = -VisionRadius; dy <= VisionRadius; dy++) + { + var cell = center + new Vector2Int(dx, dy); + if (IsInBounds(cell)) + { + visibleSet.Add(cell); + } + } + } + } + + // AI interface + public void ExecuteAIMove(GridUnit unit, Vector2Int destination) + { + if (gameOver) return; + if (unit.Team != currentTeam) return; + + // Check speed nerf + if (IsDefending(unit) && unit.ConsecutiveDefenseMoves % 4 == 3) + { + Debug.Log($"AI unit {unit.UnitId} skips move (defense speed nerf)"); + EndTurn(); + return; + } + + ExecuteMove(unit, destination); + EndTurn(); + } + + public void AISkipTurn() + { + if (gameOver) return; + EndTurn(); + } + + public Team GetCurrentTeam() => currentTeam; + public bool IsGameOver() => gameOver; + public List GetUnits(Team team) => team == Team.Blue ? blueUnits : redUnits; + public HashSet GetVisibleCells(Team team) => team == Team.Blue ? visibleToBlue : visibleToRed; + public Vector2Int GetEnemyFlagPosition(Team team) => team == Team.Blue ? redFlagPosition : blueFlagPosition; + public GridUnit GetFlagCarrier(Team flagTeam) => flagTeam == Team.Blue ? blueFlagCarrier : redFlagCarrier; + + // Utility functions + public ZoneOwner GetZoneOwner(int y) => y switch + { + < ZoneBoundaries.TeamBlueDefenseEnd => ZoneOwner.Blue, + < ZoneBoundaries.NeutralEnd => ZoneOwner.Neutral, + _ => ZoneOwner.Red + }; + + public bool IsDefending(GridUnit unit) + { + var zone = GetZoneOwner(unit.GridPosition.y); + return (unit.Team == Team.Blue && zone == ZoneOwner.Blue) || + (unit.Team == Team.Red && zone == ZoneOwner.Red); + } + + public bool IsInBounds(Vector2Int pos) => + pos.x >= 0 && pos.x < ZoneBoundaries.BoardWidth && + pos.y >= 0 && pos.y < ZoneBoundaries.BoardHeight; + + public Vector2 GridToWorld(Vector2Int gridPos) + { + // Center the board + float offsetX = -ZoneBoundaries.BoardWidth * CellSize / 2f + CellSize / 2f; + float offsetY = -ZoneBoundaries.BoardHeight * CellSize / 2f + CellSize / 2f; + return new Vector2( + gridPos.x * CellSize + offsetX, + gridPos.y * CellSize + offsetY + ); + } + + public Vector2Int WorldToGrid(Vector2 worldPos) + { + float offsetX = -ZoneBoundaries.BoardWidth * CellSize / 2f; + float offsetY = -ZoneBoundaries.BoardHeight * CellSize / 2f; + int x = Mathf.FloorToInt((worldPos.x - offsetX) / CellSize); + int y = Mathf.FloorToInt((worldPos.y - offsetY) / CellSize); + return new Vector2Int(x, y); + } + + // Helper to create a colored sprite + GameObject CreateSprite(string name, Color color, float width, float height) + { + var go = new GameObject(name); + var sr = go.AddComponent(); + sr.sprite = GetRectSprite(); + sr.color = color; + go.transform.localScale = new Vector3(width, height, 1); + return go; + } + + static Sprite cachedSprite; + static Sprite GetRectSprite() + { + if (cachedSprite != null) return cachedSprite; + + var tex = new Texture2D(1, 1); + tex.SetPixel(0, 0, Color.white); + tex.Apply(); + + cachedSprite = Sprite.Create(tex, new Rect(0, 0, 1, 1), new Vector2(0.5f, 0.5f), 1); + return cachedSprite; + } +} diff --git a/Backyard CTF/Assets/Scripts/Grid/GridUnit.cs b/Backyard CTF/Assets/Scripts/Grid/GridUnit.cs new file mode 100644 index 0000000..ca57930 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Grid/GridUnit.cs @@ -0,0 +1,37 @@ +using UnityEngine; + +public enum Team { Blue, Red } + +public class GridUnit +{ + public int UnitId; + public Team Team; + public Vector2Int GridPosition; + public int ConsecutiveDefenseMoves; + public bool IsTaggedOut; + public int RespawnTurnsRemaining; + + // Visual representation + public GameObject GameObject; + public SpriteRenderer SpriteRenderer; + + public GridUnit(int unitId, Team team, Vector2Int position, GameObject go) + { + UnitId = unitId; + Team = team; + GridPosition = position; + ConsecutiveDefenseMoves = 0; + IsTaggedOut = false; + RespawnTurnsRemaining = 0; + GameObject = go; + SpriteRenderer = go.GetComponent(); + } + + public void SetWorldPosition(Vector2 worldPos) + { + if (GameObject != null) + { + GameObject.transform.position = new Vector3(worldPos.x, worldPos.y, 0); + } + } +} diff --git a/Backyard CTF/Assets/Scripts/RouteDrawer.cs b/Backyard CTF/Assets/Scripts/RouteDrawer.cs deleted file mode 100644 index ed0d9c8..0000000 --- a/Backyard CTF/Assets/Scripts/RouteDrawer.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using UnityEngine.InputSystem; - -public class RouteDrawer : MonoBehaviour -{ - Unit selectedUnit; - List currentRoute = new(); - LineRenderer lineRenderer; - bool isDrawing; - - const float MinPointDistance = 0.5f; - - void Start() - { - CreateLineRenderer(); - } - - void CreateLineRenderer() - { - var lineGO = new GameObject("RouteLine"); - lineRenderer = lineGO.AddComponent(); - lineRenderer.startWidth = 0.15f; - lineRenderer.endWidth = 0.15f; - lineRenderer.material = new Material(Shader.Find("Sprites/Default")); - lineRenderer.startColor = new Color(1f, 1f, 1f, 0.5f); - lineRenderer.endColor = new Color(1f, 1f, 1f, 0.5f); - lineRenderer.sortingOrder = 20; - lineRenderer.positionCount = 0; - } - - void Update() - { - var mouse = Mouse.current; - var touch = Touchscreen.current; - - // Handle mouse input - if (mouse != null) - { - HandlePointerInput( - mouse.leftButton.wasPressedThisFrame, - mouse.leftButton.wasReleasedThisFrame, - mouse.leftButton.isPressed, - mouse.position.ReadValue() - ); - } - // Handle touch input - else if (touch != null && touch.primaryTouch.press.isPressed) - { - var primaryTouch = touch.primaryTouch; - HandlePointerInput( - primaryTouch.press.wasPressedThisFrame, - primaryTouch.press.wasReleasedThisFrame, - primaryTouch.press.isPressed, - primaryTouch.position.ReadValue() - ); - } - } - - void HandlePointerInput(bool pressed, bool released, bool held, Vector2 screenPos) - { - Vector2 worldPos = Camera.main.ScreenToWorldPoint(screenPos); - - if (pressed) - { - OnPointerDown(worldPos); - } - else if (released) - { - OnPointerUp(); - } - else if (held && isDrawing) - { - OnPointerDrag(worldPos); - } - } - - void OnPointerDown(Vector2 worldPos) - { - // Check if we clicked on a player unit - var hit = Physics2D.OverlapPoint(worldPos); - if (hit != null) - { - var unit = hit.GetComponent(); - if (unit != null && unit.team == Unit.Team.Player && !unit.isTaggedOut) - { - selectedUnit = unit; - isDrawing = true; - currentRoute.Clear(); - currentRoute.Add(worldPos); - UpdateLineRenderer(); - } - } - } - - void OnPointerDrag(Vector2 worldPos) - { - if (!isDrawing || selectedUnit == null) return; - - // Only add point if far enough from last point - if (currentRoute.Count > 0) - { - float dist = Vector2.Distance(currentRoute[currentRoute.Count - 1], worldPos); - if (dist >= MinPointDistance) - { - currentRoute.Add(worldPos); - UpdateLineRenderer(); - } - } - } - - void OnPointerUp() - { - if (isDrawing && selectedUnit != null && currentRoute.Count > 0) - { - // Remove the first point (unit's current position) and apply route - if (currentRoute.Count > 1) - { - currentRoute.RemoveAt(0); - } - selectedUnit.SetRoute(currentRoute); - } - - // Clear drawing state - isDrawing = false; - selectedUnit = null; - currentRoute.Clear(); - ClearLineRenderer(); - } - - void UpdateLineRenderer() - { - if (lineRenderer == null) return; - - lineRenderer.positionCount = currentRoute.Count; - for (int i = 0; i < currentRoute.Count; i++) - { - lineRenderer.SetPosition(i, new Vector3(currentRoute[i].x, currentRoute[i].y, 0)); - } - } - - void ClearLineRenderer() - { - if (lineRenderer == null) return; - lineRenderer.positionCount = 0; - } -} diff --git a/Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta b/Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta deleted file mode 100644 index 34215f7..0000000 --- a/Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8c4467d56dec84c76a24b128afc73a3f \ No newline at end of file diff --git a/Backyard CTF/Assets/Scripts/SimpleAI.cs b/Backyard CTF/Assets/Scripts/SimpleAI.cs deleted file mode 100644 index ec8b232..0000000 --- a/Backyard CTF/Assets/Scripts/SimpleAI.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -public class SimpleAI : MonoBehaviour -{ - public Unit[] aiUnits; - public Flag playerFlag; - public Transform aiBase; - - float decisionTimer; - const float DecisionInterval = 0.5f; - const float RandomOffset = 1.5f; - - void Update() - { - decisionTimer -= Time.deltaTime; - if (decisionTimer <= 0) - { - MakeDecisions(); - decisionTimer = DecisionInterval; - } - } - - void MakeDecisions() - { - if (playerFlag == null || aiBase == null) return; - - for (int i = 0; i < aiUnits.Length; i++) - { - var unit = aiUnits[i]; - if (unit == null || unit.isTaggedOut) continue; - - Vector2 target; - - if (unit.hasFlag) - { - // Has flag - return to base - target = aiBase.position; - } - else if (playerFlag.carriedBy != null && playerFlag.carriedBy.team == Unit.Team.Enemy) - { - // Friendly unit has flag - escort or find something else to do - // For now, just patrol near own base - target = (Vector2)aiBase.position + Random.insideUnitCircle * 5f; - } - else if (playerFlag.carriedBy != null) - { - // Player has our flag - chase the carrier - target = playerFlag.carriedBy.transform.position; - } - else - { - // Flag is at home or dropped - go for it - target = playerFlag.transform.position; - } - - // Add slight randomness to prevent all units clumping - Vector2 randomOffset = Random.insideUnitCircle * RandomOffset; - - // Offset based on unit index for some spread - float angle = (i / (float)aiUnits.Length) * Mathf.PI * 2f; - Vector2 spreadOffset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 1f; - - Vector2 finalTarget = target + randomOffset * 0.5f + spreadOffset; - - // Simple route: straight line to target - unit.SetRoute(new List { finalTarget }); - } - } -} diff --git a/Backyard CTF/Assets/Scripts/SimpleAI.cs.meta b/Backyard CTF/Assets/Scripts/SimpleAI.cs.meta deleted file mode 100644 index 806d13a..0000000 --- a/Backyard CTF/Assets/Scripts/SimpleAI.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: faa2f6c6b185d4351829086d17055949 \ No newline at end of file diff --git a/Backyard CTF/Assets/Scripts/Unit.cs b/Backyard CTF/Assets/Scripts/Unit.cs deleted file mode 100644 index 5f22ab2..0000000 --- a/Backyard CTF/Assets/Scripts/Unit.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using UnityEngine; - -public class Unit : MonoBehaviour -{ - public enum Team { Player, Enemy } - - public Team team; - public bool isTaggedOut; - public bool hasFlag; - - List route = new(); - int routeIndex; - bool isMoving; - - SpriteRenderer spriteRenderer; - CircleCollider2D circleCollider; - - void Awake() - { - spriteRenderer = GetComponent(); - circleCollider = GetComponent(); - } - - void Update() - { - if (isTaggedOut || !isMoving || route.Count == 0) return; - - FollowRoute(); - } - - void FollowRoute() - { - if (routeIndex >= route.Count) - { - isMoving = false; - return; - } - - Vector2 target = route[routeIndex]; - Vector2 current = transform.position; - Vector2 direction = (target - current).normalized; - float distance = Vector2.Distance(current, target); - - // Check for obstacles ahead - if (IsObstacleAhead(direction)) - { - isMoving = false; - return; - } - - float moveDistance = Game.UnitSpeed * Time.deltaTime; - - if (moveDistance >= distance) - { - // Reached waypoint - transform.position = new Vector3(target.x, target.y, 0); - routeIndex++; - - if (routeIndex >= route.Count) - { - isMoving = false; - } - } - else - { - // Move toward waypoint - Vector2 newPos = current + direction * moveDistance; - transform.position = new Vector3(newPos.x, newPos.y, 0); - } - } - - bool IsObstacleAhead(Vector2 direction) - { - // Raycast to check for obstacles (non-trigger colliders) - var hits = Physics2D.RaycastAll(transform.position, direction, 0.6f); - foreach (var hit in hits) - { - if (hit.collider != null && hit.collider.gameObject != gameObject && !hit.collider.isTrigger) - { - return true; - } - } - return false; - } - - public void SetRoute(List waypoints) - { - if (isTaggedOut) return; - - route = new List(waypoints); - routeIndex = 0; - isMoving = route.Count > 0; - } - - public void ClearRoute() - { - route.Clear(); - routeIndex = 0; - isMoving = false; - } - - void OnTriggerEnter2D(Collider2D other) - { - if (isTaggedOut) return; - - // Check for enemy unit collision (tagging) - var otherUnit = other.GetComponent(); - if (otherUnit != null && otherUnit.team != team && !otherUnit.isTaggedOut) - { - HandleTagCollision(otherUnit); - return; - } - - // Check for flag pickup - var flag = other.GetComponent(); - if (flag != null && flag.team != team && flag.carriedBy == null) - { - flag.Pickup(this); - return; - } - - // Check for scoring (reaching own base with enemy flag) - var baseZone = other.GetComponent(); - if (baseZone != null && baseZone.team == team && hasFlag) - { - // Get the flag we're carrying - var carriedFlag = team == Team.Player ? Game.Instance.enemyFlag : Game.Instance.playerFlag; - if (carriedFlag != null && carriedFlag.carriedBy == this) - { - carriedFlag.Drop(); - Game.Instance.Score(team); - } - } - } - - void HandleTagCollision(Unit other) - { - // Only process collision once - lower instance ID handles it - if (GetInstanceID() > other.GetInstanceID()) return; - - // Determine who gets tagged: farther from their own base loses - // The instigator (closer to their base) is NOT captured - Transform myBase = Game.Instance.GetBase(team); - Transform theirBase = Game.Instance.GetBase(other.team); - - float myDistance = Vector2.Distance(transform.position, myBase.position); - float theirDistance = Vector2.Distance(other.transform.position, theirBase.position); - - // Only the one farther from their base gets tagged - if (myDistance > theirDistance) - { - TagOut(); - } - else - { - // They are farther or equal - they get tagged - other.TagOut(); - } - } - - public void TagOut() - { - if (isTaggedOut) return; - - isTaggedOut = true; - isMoving = false; - route.Clear(); - - // Drop flag if carrying - if (hasFlag) - { - var flag = team == Team.Player ? Game.Instance.enemyFlag : Game.Instance.playerFlag; - if (flag != null && flag.carriedBy == this) - { - flag.Drop(); - } - } - - // Visual feedback - fade out - if (spriteRenderer != null) - { - var color = spriteRenderer.color; - color.a = 0.3f; - spriteRenderer.color = color; - } - - StartCoroutine(RespawnCoroutine()); - } - - IEnumerator RespawnCoroutine() - { - yield return new WaitForSeconds(Game.RespawnDelay); - - // Respawn at base (landscape - horizontal offset toward center) - Transform baseTransform = Game.Instance.GetBase(team); - float xOffset = team == Team.Player ? 2f : -2f; - Vector3 offset = new Vector3(xOffset, Random.Range(-2f, 2f), 0); - transform.position = baseTransform.position + offset; - - isTaggedOut = false; - - // Restore visual - if (spriteRenderer != null) - { - var color = spriteRenderer.color; - color.a = 1f; - spriteRenderer.color = color; - } - } - - public void ForceRespawn(Vector3 position) - { - StopAllCoroutines(); - transform.position = position; - isTaggedOut = false; - isMoving = false; - route.Clear(); - hasFlag = false; - - if (spriteRenderer != null) - { - var color = spriteRenderer.color; - color.a = 1f; - spriteRenderer.color = color; - } - } - - public bool IsMoving => isMoving; - public List CurrentRoute => route; -} diff --git a/Backyard CTF/Assets/Scripts/Unit.cs.meta b/Backyard CTF/Assets/Scripts/Unit.cs.meta deleted file mode 100644 index 28c948e..0000000 --- a/Backyard CTF/Assets/Scripts/Unit.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6ea1fe45ed12847fdbf61f14ae451c47 \ No newline at end of file diff --git a/Backyard CTF/Assets/Scripts/Visibility.cs b/Backyard CTF/Assets/Scripts/Visibility.cs deleted file mode 100644 index 0be0fa1..0000000 --- a/Backyard CTF/Assets/Scripts/Visibility.cs +++ /dev/null @@ -1,59 +0,0 @@ -using UnityEngine; - -public class Visibility : MonoBehaviour -{ - public Unit[] playerUnits; - public Unit[] enemyUnits; - public Flag enemyFlag; - - void Update() - { - // Update visibility of enemy units - foreach (var enemy in enemyUnits) - { - if (enemy == null) continue; - - bool visible = IsVisibleToAnyPlayerUnit(enemy.transform.position); - var sr = enemy.GetComponent(); - if (sr != null) - { - sr.enabled = visible || enemy.isTaggedOut; // Show tagged out units (they're faded anyway) - } - } - - // Update visibility of enemy flag (only when not carried) - if (enemyFlag != null && enemyFlag.carriedBy == null) - { - bool flagVisible = IsVisibleToAnyPlayerUnit(enemyFlag.transform.position); - var flagSr = enemyFlag.GetComponent(); - if (flagSr != null) - { - flagSr.enabled = flagVisible; - } - } - else if (enemyFlag != null && enemyFlag.carriedBy != null) - { - // Flag is carried - always show it (it's attached to a visible unit) - var flagSr = enemyFlag.GetComponent(); - if (flagSr != null) - { - flagSr.enabled = true; - } - } - } - - bool IsVisibleToAnyPlayerUnit(Vector2 position) - { - foreach (var unit in playerUnits) - { - if (unit == null || unit.isTaggedOut) continue; - - float distance = Vector2.Distance(unit.transform.position, position); - if (distance < Game.VisionRadius) - { - return true; - } - } - return false; - } -} diff --git a/Backyard CTF/Assets/Scripts/Visibility.cs.meta b/Backyard CTF/Assets/Scripts/Visibility.cs.meta deleted file mode 100644 index 3028324..0000000 --- a/Backyard CTF/Assets/Scripts/Visibility.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 02e237c0633a948708044095c3dc90c5 \ No newline at end of file diff --git a/docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md b/docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md new file mode 100644 index 0000000..c1565d2 --- /dev/null +++ b/docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md @@ -0,0 +1,103 @@ +# Asymmetric Grid CTF Board + +**Date:** 2026-02-04 +**Status:** Ready for planning + +## What We're Building + +A turn-based capture-the-flag game on a 20x50 grid with asymmetric movement mechanics. Each team has 3 pieces that move differently depending on which zone they're in: + +- **Offensive zone (enemy territory):** Orthogonal movement only (Manhattan distance) +- **Defensive zone (home territory):** Diagonal movement only (Chebyshev distance) +- **Neutral zone (center):** Both teams move orthogonally (on offense) + +The board is mirrored: Team A's defensive zone is Team B's offensive zone, and vice versa. + +### Core Mechanics + +1. **Simultaneous turns:** Both teams plan moves secretly, then execute at the same time +2. **Fog of war:** Each piece sees 3 cells in any direction; the rest is hidden +3. **Collision resolution:** Defender wins ties (piece in their defensive zone captures invader) +4. **Defense speed nerf:** Defensive movement is 75% speed (skip every 4th move in defense zone) +5. **Victory condition:** Capture enemy flag from their base and return it to your base + +## Why This Approach + +### Pure Grid Replacement over Hybrid + +The PDF's game theory analysis is fundamentally about discrete move counts (Manhattan vs Chebyshev distance). Free-form movement with grid constraints would: +- Complicate collision detection +- Make fog of war harder to compute +- Obscure the strategic depth the grid creates + +A clean grid system directly implements the analyzed mechanics. + +### Simultaneous Turns over Turn-Based + +Simultaneous planning creates the "mixed-strategy game" described in the PDF. If turns were sequential, the reactive player always has perfect information. Simultaneous moves mean: +- Offense can commit to a direction without the defense knowing +- Both teams must predict opponent behavior +- Creates bluffing and misdirection opportunities + +### Visible Grid + +Players need to understand their movement options. The orthogonal green squares and diagonal red squares from the PDF communicate which directions are legal at a glance. + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Board size | 20x50 | Wide enough for 3 pieces per side with meaningful positioning | +| Zone proportions | 20x20 defense, 10x10 neutral, 20x20 defense | Small neutral = more defensive play per user request | +| Movement per turn | 1 cell | Matches PDF analysis; multi-cell would change game theory | +| Vision radius | 3 cells | Creates meaningful information asymmetry without total blindness | +| Defense speed | 75% (3 moves per 4 turns) | PDF analysis shows this creates balanced mixed-strategy game | +| Collision rule | Defender wins | Rewards positioning in your territory | +| Flag location | Back of defensive zone | Classic CTF setup | + +## Open Questions + +1. **How to visualize fog of war?** Options: darken hidden cells, hide them entirely, show "last known" positions +2. **What happens to flag carrier if tagged?** Drop flag? Flag returns to base? +3. **Respawn mechanics?** Where do tagged pieces respawn? How long until they can act? +4. **Turn timer?** Unlimited planning time or forced time limit? +5. **AI opponent?** Should we build AI for single-player, or multiplayer-only initially? + +## Grid Visual Reference + +The PDF shows a pattern where: +- Green squares form an orthogonal grid (offense paths) +- Red diagonal lines overlay, creating larger diamond-shaped cells (defense paths) +- The two grids intersect, meaning some cells are reachable by both movement types + +For implementation, we need to define: +- Cell size in world units +- How to render the dual-grid overlay +- Visual distinction between zones (Team A defense, neutral, Team B defense) + +## Technical Considerations + +### Current Architecture Impact + +The existing `Game.cs`, `Unit.cs`, and `RouteDrawer.cs` will need significant changes: +- Replace `RouteDrawer` path drawing with click-to-select, click-to-move +- Replace continuous movement in `Unit.cs` with discrete grid steps +- Add turn manager for simultaneous move resolution +- Add fog of war system (current `Visibility.cs` is radius-based, needs grid conversion) + +### New Components Needed + +1. **GridBoard** - Manages 20x50 cell array, zone definitions, visual rendering +2. **TurnManager** - Handles move planning phase, simultaneous execution, turn counting +3. **GridMovement** - Validates moves based on zone type, handles defense speed nerf +4. **FogOfWar** - Computes visible cells per team, hides/reveals pieces +5. **CollisionResolver** - Determines outcomes when pieces occupy same cell + +## Next Steps + +Run `/workflows:plan` to create implementation plan addressing: +1. Grid data structure and rendering +2. Turn system and move planning UI +3. Zone-based movement validation +4. Fog of war implementation +5. Collision and capture mechanics diff --git a/docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md b/docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md new file mode 100644 index 0000000..3601a5a --- /dev/null +++ b/docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md @@ -0,0 +1,403 @@ +--- +title: "feat: Asymmetric Grid CTF Board" +type: feat +date: 2026-02-04 +brainstorm: docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md +--- + +# feat: Asymmetric Grid CTF Board + +## Overview + +Replace the current real-time continuous movement CTF game with a turn-based grid system featuring asymmetric movement mechanics. Each team's 3 pieces move differently based on which zone they occupy: orthogonal (Manhattan) in offensive zones, diagonal (Chebyshev) in defensive zones. + +**Key changes from current implementation:** +- Continuous free-form movement → Discrete grid-based movement +- Real-time gameplay → Alternating turn-based +- Uniform movement → Zone-dependent movement rules + +## Problem Statement + +The current implementation is a real-time CTF game where: +- Units move continuously along drawn paths +- All units have the same movement capabilities +- The defense has no inherent advantage + +The game design analysis (see `docs/CAPTURE THE FLAG.pdf`) shows that asymmetric movement creates interesting strategic depth: +- Defense moving diagonally covers more ground (Chebyshev distance) +- Offense moving orthogonally is predictable (Manhattan distance) +- With 75% defense speed nerf, this becomes a balanced mixed-strategy game + +## Proposed Solution + +Build a 20x50 grid-based board with three zones and alternating turn-based gameplay. + +### Board Layout + +``` +┌────────────────────────────────────────────────────────┐ +│ TEAM B DEFENSE │ Y: 30-49 +│ (Team A Offense) │ (20 rows) +│ [Diagonal Movement] │ +│ 🚩 Flag B │ +├────────────────────────────────────────────────────────┤ +│ NEUTRAL ZONE │ Y: 20-29 +│ (Both teams on offense) │ (10 rows) +│ [Orthogonal Movement] │ +├────────────────────────────────────────────────────────┤ +│ TEAM A DEFENSE │ Y: 0-19 +│ (Team B Offense) │ (20 rows) +│ [Diagonal Movement] │ +│ 🚩 Flag A │ +└────────────────────────────────────────────────────────┘ + X: 0 ←────────── 20 cells ──────────→ 19 +``` + +### Movement Rules + +| Zone (for piece) | Movement Type | Speed | +|------------------|---------------|-------| +| Own Defense | Diagonal (8 directions) | 75% (skip every 4th turn) | +| Enemy Defense (Offense) | Orthogonal (4 directions: N/S/E/W) | 100% | +| Neutral | Orthogonal (4 directions) | 100% | + +### Turn Flow (Alternating) + +``` +┌─────────────────┐ +│ PLAYER TURN │ Click unit → click destination → unit moves +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ RESOLVE │ Check collision, flag pickup, scoring +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ AI TURN │ AI selects and executes move +└────────┬────────┘ + ▼ +┌─────────────────┐ +│ RESOLVE │ Check collision, flag pickup, scoring +└────────┬────────┘ + ▼ + Next turn +``` + +**Note:** Start with alternating turns for simplicity. Simultaneous turns can be added later if alternating feels too simple. + +## Technical Approach + +### Architecture (3 Classes) + +``` +Assets/Scripts/ +├── Grid/ +│ ├── GridBoard.cs # Board state, zones, rendering, input, turn logic +│ ├── GridUnit.cs # Unit data struct +│ └── GridAI.cs # AI decision-making (Phase 3) +├── Game.cs # Bootstrap, update for grid +├── Flag.cs # Adapt for grid coordinates +└── CameraController.cs # Keep as-is +``` + +### Zone Boundaries (Constants) + +```csharp +public static class ZoneBoundaries +{ + public const int TeamADefenseEnd = 20; // Y < 20 is Team A defense + public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamADefenseEnd) + public const int BoardWidth = 20; + public const int BoardHeight = 50; +} +``` + +### Data Structures + +```csharp +public enum Team { Blue, Red } // Canonical names used everywhere + +public enum ZoneOwner { Blue, Neutral, Red } + +// GridUnit.cs - minimal data struct +public class GridUnit +{ + public int UnitId; // Stable identifier for determinism + public Team Team; + public Vector2Int GridPosition; + public int ConsecutiveDefenseMoves; // Reset when leaving defense zone + public bool IsTaggedOut; + public int RespawnTurnsRemaining; // 0 when active + // Note: HasFlag NOT stored here - query Flag.CarriedBy instead +} + +// GridBoard.cs - all game logic in one place +public class GridBoard : MonoBehaviour +{ + // Board is a 2D array of unit references (null = empty) + GridUnit[,] cellOccupants = new GridUnit[BoardWidth, BoardHeight]; + + // Canonical position storage - single source of truth + Dictionary unitPositions = new(); + + public ZoneOwner GetZoneOwner(int y) => y switch + { + < ZoneBoundaries.TeamADefenseEnd => ZoneOwner.Blue, + < ZoneBoundaries.NeutralEnd => ZoneOwner.Neutral, + _ => ZoneOwner.Red + }; + + public bool IsDefending(GridUnit unit) => + (unit.Team == Team.Blue && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Blue) || + (unit.Team == Team.Red && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Red); + + // Atomic move that keeps both data structures in sync + public void MoveUnit(GridUnit unit, Vector2Int to) + { + var from = unitPositions[unit]; + cellOccupants[from.x, from.y] = null; + cellOccupants[to.x, to.y] = unit; + unitPositions[unit] = to; + unit.GridPosition = to; + } +} +``` + +### Collision Resolution (Deterministic) + +Collisions are resolved deterministically by unit ID (lower ID wins ties): + +```csharp +void ResolveCollision(GridUnit unitA, GridUnit unitB, Vector2Int cell) +{ + // Same team = no collision (can share cell temporarily) + if (unitA.Team == unitB.Team) return; + + var zoneOwner = GetZoneOwner(cell.y); + + // Defender in their zone always wins + if (unitA.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) + TagOut(unitB); + else if (unitB.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) + TagOut(unitA); + else if (unitA.Team == Team.Red && zoneOwner == ZoneOwner.Red) + TagOut(unitB); + else if (unitB.Team == Team.Red && zoneOwner == ZoneOwner.Red) + TagOut(unitA); + else + { + // Neutral zone: lower UnitId wins (deterministic) + if (unitA.UnitId < unitB.UnitId) + TagOut(unitB); + else + TagOut(unitA); + } +} +``` + +### Turn Execution Order + +Each turn resolves in this exact order: + +1. **Speed nerf check** - If unit is defending and `ConsecutiveDefenseMoves % 4 == 3`, skip move +2. **Execute move** - Update position +3. **Collision check** - Tag losing unit if two enemies on same cell +4. **Flag pickup** - Unit on enemy flag cell picks it up +5. **Flag drop** - Tagged unit drops flag at current position +6. **Score check** - Flag carrier in own base scores +7. **Respawn tick** - Decrement `RespawnTurnsRemaining`, respawn if 0 +8. **Update visibility** - Toggle enemy sprite visibility (Phase 2) + +## Implementation Phases + +### Phase 1: Playable Grid + +**Goal:** Two humans can play hot-seat CTF on a grid. + +**Tasks:** +- [x] Create `GridBoard.cs`: + - Render 20x50 grid using existing `CreateSprite()` helper + - Color zones (blue tint for Blue defense, gray for neutral, red tint for Red defense) + - Handle mouse input (click unit, click destination) + - Implement `GetValidMoves()` with zone-aware movement + - Implement `MoveUnit()` with atomic position update + - Implement collision resolution +- [x] Create `GridUnit.cs` as minimal data struct +- [x] Adapt `Flag.cs` for grid coordinates: + - Flag pickup on entering cell + - Flag drop on tag (stays at cell) + - Flag return when friendly touches it + - Score when carrier reaches own base +- [x] Modify `Game.cs` to instantiate `GridBoard` instead of current map +- [x] Alternating turns: `bool isPlayerTurn`, swap after each move +- [x] Win at 3 points, use existing `gameOverText` + +**Files to create:** +- `Assets/Scripts/Grid/GridBoard.cs` +- `Assets/Scripts/Grid/GridUnit.cs` + +**Files to modify:** +- `Assets/Scripts/Game.cs` +- `Assets/Scripts/Flag.cs` + +**Verification:** +``` +Run game → See colored grid with 6 pieces and 2 flags +Click unit → Valid moves highlight (4 or 8 based on zone) +Click valid cell → Unit moves, turn swaps +Move to enemy flag → Pick up flag +Return to base with flag → Score point +Move onto enemy in your zone → Enemy respawns +First to 3 → "You Win" / "You Lose" +``` + +### Phase 2: Asymmetric Mechanics + +**Goal:** Add the mechanics that create strategic depth. + +**Tasks:** +- [x] Add fog of war: + - Each unit sees 3 cells (Chebyshev distance) + - Toggle enemy sprite `enabled` based on visibility + - Recalculate after each move +- [x] Add defense speed nerf: + - Track `ConsecutiveDefenseMoves` on GridUnit + - Skip every 4th move when defending + - Reset counter when leaving defense zone + - Visual indicator (dim sprite) when next move will be skipped + +**Fog visibility (method in GridBoard):** +```csharp +HashSet visibleToBlue = new(); + +void RecalculateVisibility() +{ + visibleToBlue.Clear(); + foreach (var unit in blueUnits) + { + if (unit.IsTaggedOut) continue; + for (int dx = -3; dx <= 3; dx++) + for (int dy = -3; dy <= 3; dy++) + { + var cell = unit.GridPosition + new Vector2Int(dx, dy); + if (IsInBounds(cell)) visibleToBlue.Add(cell); + } + } + + foreach (var enemy in redUnits) + enemy.Sprite.enabled = visibleToBlue.Contains(enemy.GridPosition); +} +``` + +**Verification:** +``` +Start game → Red units partially hidden +Move Blue unit → Fog reveals new cells +Defender moves 3 times → 4th move skipped (unit dims beforehand) +Defender leaves zone → Counter resets +``` + +### Phase 3: AI Opponent + +**Goal:** Single-player mode. + +**Tasks:** +- [x] Create `GridAI.cs`: + - AI respects fog (only sees what its units see) + - Simple strategy: chase visible flag carrier, else advance toward flag + - AI takes turn after player +- [x] Delete `Assets/Scripts/SimpleAI.cs` + +**AI decision (single difficulty):** +```csharp +Vector2Int? DecideMove(GridUnit unit) +{ + var validMoves = board.GetValidMoves(unit); + if (validMoves.Count == 0) return null; + + // Priority 1: Chase visible flag carrier + var carrier = GetVisibleEnemyFlagCarrier(); + if (carrier != null) + return validMoves.OrderBy(m => Distance(m, carrier.GridPosition)).First(); + + // Priority 2: Advance toward enemy flag + var flagPos = enemyFlag.GridPosition; + return validMoves.OrderBy(m => Distance(m, flagPos)).First(); +} +``` + +**Files to create:** +- `Assets/Scripts/Grid/GridAI.cs` + +**Files to delete:** +- `Assets/Scripts/SimpleAI.cs` + +**Verification:** +``` +Start game → AI takes turns after player +AI chases if it sees flag carrier +AI advances toward flag otherwise +AI doesn't react to units outside fog +Full game completes without errors +``` + +## Deferred (Not MVP) + +These features are explicitly deferred until the core loop is validated: + +- Simultaneous turns (adds: planned moves storage, submit button, ghost indicators, conflict resolution) +- Turn timer +- Sound effects +- Camera auto-zoom +- Zone labels +- Win/lose screen (use `Debug.Log` or existing `gameOverText`) +- Multiple AI difficulties +- Respawn delay (use instant respawn) +- Flag auto-return timer (flags stay where dropped) + +## Edge Cases to Handle + +| Edge Case | Resolution | +|-----------|------------| +| Three+ units on same cell | Process collisions pairwise by UnitId order | +| Zone crossing during move | Speed nerf based on starting position | +| Flag dropped in neutral | Can be "returned" by either team touching it | +| Both carriers tagged same turn | Both flags drop (alternating turns makes this impossible) | +| Tagged while about to score | Tag resolves before score check | +| Skip turn with flag | Carrier stays in place, keeps flag | +| Respawn location occupied | Respawn at nearest empty cell in base | + +## Acceptance Criteria + +### Functional (Phase 1 - MVP) +- [x] 20x50 grid renders with three colored zones +- [x] 6 pieces (3 per team) at starting positions +- [x] Orthogonal movement in offense zones, diagonal in defense +- [x] Alternating turns +- [x] Collision: defender wins in their zone +- [x] Flags: pickup, drop on tag, return on touch, score on base +- [x] First to 3 wins + +### Functional (Phase 2) +- [x] 3-cell vision radius per unit +- [x] Enemy units hidden outside fog +- [x] Defenders skip every 4th move + +### Functional (Phase 3) +- [x] AI plays Red team +- [x] AI respects fog +- [x] Full game completes + +## Success Metrics + +1. **Playable:** Full game loop from start to victory works +2. **Fun check:** Does asymmetric movement feel strategically interesting? + +Balance testing and engagement metrics deferred until core loop is validated. + +## References + +- Brainstorm: `docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md` +- Game design PDF: `docs/CAPTURE THE FLAG.pdf` +- Current movement: `Assets/Scripts/Unit.cs:42` +- Bootstrap pattern: `Assets/Scripts/Game.cs:15`