diff --git a/Backyard CTF/Assets/Scripts/Flag.cs b/Backyard CTF/Assets/Scripts/Flag.cs new file mode 100644 index 0000000..fd1090e --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Flag.cs @@ -0,0 +1,97 @@ +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/Game.cs b/Backyard CTF/Assets/Scripts/Game.cs new file mode 100644 index 0000000..dc11de8 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Game.cs @@ -0,0 +1,388 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using TMPro; + +public class Game : MonoBehaviour +{ + // Auto-bootstrap when game starts - no Editor setup needed + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Bootstrap() + { + if (Instance != null) return; + + var gameGO = new GameObject("Game"); + gameGO.AddComponent(); + } + + // Constants - tune later + public const float UnitSpeed = 5f; + public const float VisionRadius = 4f; + public const float TagRadius = 0.75f; + public const float RespawnDelay = 3f; + public const float FlagReturnDelay = 5f; + public const int WinScore = 3; + + // Map dimensions + const float MapWidth = 40f; + const float MapHeight = 30f; + + // References set during setup + 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; + + TextMeshProUGUI scoreText; + TextMeshProUGUI gameOverText; + + void Awake() + { + Instance = this; + } + + void Start() + { + SetupCamera(); + CreateGround(); + CreateObstacles(); + CreateBases(); + CreateFlags(); + SpawnUnits(); + CreateUI(); + SetupSystems(); + } + + void SetupCamera() + { + var cam = Camera.main; + if (cam != null) + { + cam.orthographic = true; + cam.orthographicSize = MapHeight / 2f + 2f; + cam.transform.position = new Vector3(0, 0, -10); + } + } + + void CreateGround() + { + var ground = CreateSprite("Ground", new Color(0.2f, 0.4f, 0.2f), MapWidth, MapHeight); + ground.transform.position = Vector3.zero; + var sr = ground.GetComponent(); + sr.sortingOrder = -10; + } + + void CreateObstacles() + { + // Houses as obstacles - asymmetric backyard layout + Vector2[] housePositions = { + new(-12f, 8f), + new(-8f, -5f), + new(0f, 3f), + new(5f, -8f), + new(10f, 6f), + new(14f, -3f), + }; + + Vector2[] houseSizes = { + new(4f, 3f), + new(3f, 4f), + new(5f, 2.5f), + new(3f, 3f), + new(4f, 4f), + new(3.5f, 3f), + }; + + for (int i = 0; i < housePositions.Length; i++) + { + var house = CreateSprite($"House_{i}", new Color(0.4f, 0.4f, 0.4f), houseSizes[i].x, houseSizes[i].y); + house.transform.position = new Vector3(housePositions[i].x, housePositions[i].y, 0); + + var collider = house.AddComponent(); + // Size is (1,1) in local space - scale handles actual size + collider.size = Vector2.one; + + var sr = house.GetComponent(); + sr.sortingOrder = -5; + } + } + + void CreateBases() + { + // Player base - bottom left + var playerBaseGO = CreateSprite("PlayerBase", new Color(0.2f, 0.3f, 0.8f, 0.5f), 6f, 6f); + playerBaseGO.transform.position = new Vector3(-MapWidth / 2f + 5f, -MapHeight / 2f + 5f, 0); + playerBase = playerBaseGO.transform; + var sr1 = playerBaseGO.GetComponent(); + sr1.sortingOrder = -8; + + var playerBaseTrigger = playerBaseGO.AddComponent(); + playerBaseTrigger.isTrigger = true; + playerBaseTrigger.size = Vector2.one; // Local space - scale handles actual size + + var playerBaseZone = playerBaseGO.AddComponent(); + playerBaseZone.team = Unit.Team.Player; + + // Enemy base - top right + var enemyBaseGO = CreateSprite("EnemyBase", new Color(0.8f, 0.2f, 0.2f, 0.5f), 6f, 6f); + enemyBaseGO.transform.position = new Vector3(MapWidth / 2f - 5f, MapHeight / 2f - 5f, 0); + enemyBase = enemyBaseGO.transform; + var sr2 = enemyBaseGO.GetComponent(); + sr2.sortingOrder = -8; + + var enemyBaseTrigger = enemyBaseGO.AddComponent(); + enemyBaseTrigger.isTrigger = true; + enemyBaseTrigger.size = Vector2.one; // Local space - scale handles actual size + + 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 = 0.75f; + + 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 = 0.75f; + + var rb2 = enemyFlagGO.AddComponent(); + rb2.bodyType = RigidbodyType2D.Kinematic; + + var sr2 = enemyFlagGO.GetComponent(); + sr2.sortingOrder = 5; + } + + void SpawnUnits() + { + // Spawn 5 player units near player base + for (int i = 0; i < 5; i++) + { + var offset = new Vector3((i - 2) * 1.5f, -2f, 0); + var unit = CreateUnit($"PlayerUnit_{i}", Unit.Team.Player, playerBase.position + offset); + playerUnits.Add(unit); + } + + // Spawn 5 enemy units near enemy base + for (int i = 0; i < 5; i++) + { + var offset = new Vector3((i - 2) * 1.5f, 2f, 0); + var unit = CreateUnit($"EnemyUnit_{i}", Unit.Team.Enemy, enemyBase.position + offset); + enemyUnits.Add(unit); + } + } + + 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, 1f, 1f); + unitGO.transform.position = position; + + var unit = unitGO.AddComponent(); + unit.team = team; + + var collider = unitGO.AddComponent(); + collider.isTrigger = true; + collider.radius = 0.5f; + + var rb = unitGO.AddComponent(); + rb.bodyType = RigidbodyType2D.Kinematic; + + var sr = unitGO.GetComponent(); + sr.sortingOrder = 10; + + return unit; + } + + void CreateUI() + { + // Create Canvas + var canvasGO = new GameObject("Canvas"); + var canvas = canvasGO.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvasGO.AddComponent(); + canvasGO.AddComponent(); + + // Score text + var scoreGO = new GameObject("ScoreText"); + scoreGO.transform.SetParent(canvasGO.transform, false); + scoreText = scoreGO.AddComponent(); + scoreText.text = "0 - 0"; + scoreText.fontSize = 48; + scoreText.alignment = TextAlignmentOptions.Center; + scoreText.color = Color.white; + + var scoreRect = scoreGO.GetComponent(); + scoreRect.anchorMin = new Vector2(0.5f, 1f); + scoreRect.anchorMax = new Vector2(0.5f, 1f); + scoreRect.pivot = new Vector2(0.5f, 1f); + scoreRect.anchoredPosition = new Vector2(0, -20); + scoreRect.sizeDelta = new Vector2(200, 60); + + // Game over text (hidden initially) + var gameOverGO = new GameObject("GameOverText"); + gameOverGO.transform.SetParent(canvasGO.transform, false); + gameOverText = gameOverGO.AddComponent(); + gameOverText.text = ""; + gameOverText.fontSize = 72; + gameOverText.alignment = TextAlignmentOptions.Center; + gameOverText.color = Color.white; + gameOverText.gameObject.SetActive(false); + + var gameOverRect = gameOverGO.GetComponent(); + gameOverRect.anchorMin = new Vector2(0.5f, 0.5f); + gameOverRect.anchorMax = new Vector2(0.5f, 0.5f); + gameOverRect.pivot = new Vector2(0.5f, 0.5f); + gameOverRect.anchoredPosition = Vector2.zero; + gameOverRect.sizeDelta = new Vector2(400, 100); + } + + void SetupSystems() + { + // Add RouteDrawer + gameObject.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 = new Vector3((i - 2) * 1.5f, -2f, 0); + playerUnits[i].ForceRespawn(playerBase.position + offset); + } + + for (int i = 0; i < enemyUnits.Count; i++) + { + var offset = new Vector3((i - 2) * 1.5f, 2f, 0); + 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; + } +} + +// Simple component to identify base zones +public class BaseZone : MonoBehaviour +{ + public Unit.Team team; +} diff --git a/Backyard CTF/Assets/Scripts/RouteDrawer.cs b/Backyard CTF/Assets/Scripts/RouteDrawer.cs new file mode 100644 index 0000000..ed0d9c8 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/RouteDrawer.cs @@ -0,0 +1,147 @@ +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/SimpleAI.cs b/Backyard CTF/Assets/Scripts/SimpleAI.cs new file mode 100644 index 0000000..ec8b232 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/SimpleAI.cs @@ -0,0 +1,70 @@ +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/Unit.cs b/Backyard CTF/Assets/Scripts/Unit.cs new file mode 100644 index 0000000..f42cc9d --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Unit.cs @@ -0,0 +1,231 @@ +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) + { + // Determine who gets tagged: farther from their own base loses + 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); + + if (myDistance > theirDistance) + { + TagOut(); + } + else if (theirDistance > myDistance) + { + other.TagOut(); + } + else + { + // Equal distance - both get tagged (more chaos!) + TagOut(); + 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 + Transform baseTransform = Game.Instance.GetBase(team); + Vector3 offset = new Vector3(Random.Range(-2f, 2f), team == Team.Player ? -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/Visibility.cs b/Backyard CTF/Assets/Scripts/Visibility.cs new file mode 100644 index 0000000..0be0fa1 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Visibility.cs @@ -0,0 +1,59 @@ +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/docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md b/docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md new file mode 100644 index 0000000..76e8ff3 --- /dev/null +++ b/docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md @@ -0,0 +1,67 @@ +--- +date: 2026-02-01 +topic: teaser-prototype +--- + +# Teaser Prototype: Playable Game Core + +## What We're Building + +A minimal playable teaser that captures the "practiced chaos" of Neighborhood Quarterback - the Rocket League-style feeling where chaos has patterns you can learn to exploit. + +**Scope:** +- 1v1 vs AI opponent +- 5 identical units per side +- Draw-routes for commanding units +- Tag-out respawn (captured units respawn at base after delay) +- Simple fog of war (see near your units only) +- Backyard-style asymmetric map with houses/fences +- First to 3 points (points for flag grab AND returning flag to base) + +## Why This Approach + +We chose **Minimal Playable Core** over polished slice or systems-first approaches because: + +1. **Validate the feel first** - The soul doc's "practiced chaos" needs playtesting to verify +2. **Fast iteration** - Rough edges are fine if we can quickly change what matters +3. **Avoid over-engineering** - Don't build robust systems for unvalidated design + +## Key Decisions + +- **AI opponent over multiplayer**: Simpler to build, can playtest alone, control pacing +- **Draw routes over click-to-move**: More tactical, matches the "quarterback" command fantasy +- **Tag-out over jail escort**: Simpler first pass; jail escort adds complexity we can add later +- **Fog of war included**: Core to the mind game, worth the complexity +- **5 units (not 3)**: Matches soul doc, enables interesting squad tactics +- **All identical units**: No classes yet; focus on positioning/routes before differentiation +- **Asymmetric map**: Thematic "backyard" feel even if harder to balance +- **First to 3 with grab+return points**: Creates multiple scoring opportunities per round + +## What Success Looks Like + +When playing, you should see: +- Moments where you read the AI's pattern and exploit it +- Chaotic scrambles when plans collide +- "Almost had it" flag runs that feel learnable +- Fog reveal moments that create tension + +## Out of Scope (For Now) + +- Class differentiation (Sneak/Patrol/Speed) +- Jail escort mechanics +- Motion lights +- Pre-phase setup (placing flag/jail) +- Multiplayer networking +- Polish/juice/animations + +## Open Questions for Planning + +1. **Map layout**: What's the minimum topology for interesting play? Lanes, chokepoints, shortcuts? +2. **AI behavior**: How smart does AI need to be to create "practiced chaos"? +3. **Route-drawing UX**: Click-drag? Waypoints? How to visualize planned route? +4. **Fog implementation**: Tile-based? Raycast? Mesh-based reveal? +5. **Scoring flow**: What happens after a point? Reset positions? Continuous play? + +## Next Steps + +Run `/workflows:plan` to break this down into implementation tasks. diff --git a/docs/plans/2026-02-01-feat-teaser-prototype-playable-core-plan.md b/docs/plans/2026-02-01-feat-teaser-prototype-playable-core-plan.md new file mode 100644 index 0000000..b036838 --- /dev/null +++ b/docs/plans/2026-02-01-feat-teaser-prototype-playable-core-plan.md @@ -0,0 +1,346 @@ +--- +title: "feat: Teaser Prototype Playable Core" +type: feat +date: 2026-02-01 +revised: 2026-02-01 +--- + +# Teaser Prototype: Playable Core (Simplified) + +## Overview + +Build a minimal playable 1v1 capture-the-flag teaser that captures the "practiced chaos" of Neighborhood Quarterback - where chaos has patterns you can learn to exploit, like Rocket League. + +**Target experience:** Fast rounds with flag grabs, chases, tag-outs, and scrambles. Players should feel "I almost had it" and "I can learn this." + +## Guiding Principle + +**Build the skateboard, not the car chassis.** Get something playable in days, not weeks. Polish comes after validating the core loop is fun. + +## Proposed Solution + +6 scripts, 3 phases, ~15 tasks: + +``` +Assets/Scripts/ +├── Game.cs # Score, reset, win, spawn +├── Unit.cs # Movement, state, respawn, flag carrying +├── RouteDrawer.cs # Click-drag to draw routes +├── Flag.cs # Pickup, drop, return +├── Visibility.cs # Simple sprite show/hide (no shaders) +└── SimpleAI.cs # Chase flag or flag carrier +``` + +No feature folders for MVP. No managers. Refactor when needed. + +## Technical Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Pathfinding | None | Draw route, unit follows, stops at obstacles | +| Fog of War | Sprite SetActive | Hide enemies outside vision radius. No shaders. | +| State | Bools/enums on scripts | No state machine frameworks | +| AI | One behavior | Chase player flag (or flag carrier) | +| Events | Direct method calls | No event bus for 6 scripts | + +## Constants (Hardcoded, Tune Later) + +```csharp +// In Game.cs - move to ScriptableObject if needed +const float UnitSpeed = 5f; +const float VisionRadius = 4f; +const float TagRadius = 0.75f; +const float RespawnDelay = 3f; +const float FlagReturnDelay = 5f; +const int WinScore = 3; +``` + +--- + +## Phase 1: Movement & Map (3-4 days) + +**Goal:** Draw routes, units follow them, obstacles block. + +### Tasks + +- [x] **1.1** Create placeholder art in Main.unity: + - Green plane (ground, ~40x30 units) + - Gray rectangles (4-6 houses as obstacles with BoxCollider2D) + - Colored circles (units - blue team, red team) + - Two base zones (opposite corners) + +- [x] **1.2** Create `Unit.cs`: + ```csharp + public class Unit : MonoBehaviour + { + public enum Team { Player, Enemy } + public Team team; + public bool isTaggedOut; + public bool hasFlag; + + List route; + int routeIndex; + + public void SetRoute(List waypoints) { ... } + void Update() { /* follow route, stop at end */ } + public void TagOut() { /* disable, start respawn coroutine */ } + IEnumerator Respawn() { /* wait 3s, teleport to base, enable */ } + } + ``` + +- [x] **1.3** Create `RouteDrawer.cs`: + - On mouse down over player unit: start route + - While dragging: collect points, draw LineRenderer preview + - On mouse up: call `unit.SetRoute(points)` + - Clear line after route applied + +- [x] **1.4** Create `Game.cs` (partial): + ```csharp + public class Game : MonoBehaviour + { + public Unit[] playerUnits; // Assign in inspector + public Unit[] enemyUnits; + public Transform playerBase; + public Transform enemyBase; + + void Start() { SpawnUnits(); } + void SpawnUnits() { /* position 5 units at each base */ } + } + ``` + +- [x] **1.5** Wire up scene: + - Create 5 Unit prefabs per team + - Add colliders to obstacles + - Test: draw route, unit follows, stops at obstacle + +**Verification:** +- [ ] Can draw route on player unit, unit follows +- [ ] Unit stops when hitting obstacle +- [ ] Unit stops at end of route +- [ ] New route replaces old route +- [ ] Enemy units visible but not controllable + +--- + +## Phase 2: Flag, Tagging, Scoring (2-3 days) + +**Goal:** Grab flag, tag enemies, score points, win. + +### Tasks + +- [x] **2.1** Create `Flag.cs`: + ```csharp + public class Flag : MonoBehaviour + { + public Unit.Team team; + public Transform homePosition; + public Unit carriedBy; + float dropTimer; + + void Update() + { + if (carriedBy != null) + transform.position = carriedBy.transform.position; + else if (transform.position != homePosition.position) + HandleDroppedState(); + } + + public void Pickup(Unit unit) { carriedBy = unit; unit.hasFlag = true; } + public void Drop() { carriedBy.hasFlag = false; carriedBy = null; dropTimer = 5f; } + void ReturnHome() { transform.position = homePosition.position; } + } + ``` + +- [x] **2.2** Add flag pickup detection: + - OnTriggerEnter2D: if enemy unit enters flag trigger, Pickup() + - In `Game.cs`: when unit with flag enters own base, Score() + +- [x] **2.3** Add tagging to `Unit.cs`: + - OnTriggerEnter2D: if enemy unit overlaps + - Determine loser: farther from own base gets tagged + - (Or for more chaos: both get tagged) + - If tagged unit has flag, call flag.Drop() + +- [x] **2.4** Add scoring to `Game.cs`: + ```csharp + int playerScore, enemyScore; + + public void Score(Unit.Team team) + { + if (team == Unit.Team.Player) playerScore++; + else enemyScore++; + + Debug.Log($"Score: {playerScore} - {enemyScore}"); + + if (playerScore >= WinScore || enemyScore >= WinScore) + EndGame(); + else + ResetRound(); + } + + void ResetRound() + { + // Return flags, respawn all units at bases + } + ``` + +- [x] **2.5** Add simple UI: + - TextMeshPro showing score + - "YOU WIN" / "YOU LOSE" text on game end + - (No menu - press Play in editor) + +**Verification:** +- [ ] Walking over enemy flag picks it up +- [ ] Flag follows carrier +- [ ] Reaching base with flag scores point +- [ ] Overlapping enemy triggers tag-out +- [ ] Tagged unit respawns after 3 seconds +- [ ] Dropped flag returns home after 5 seconds +- [ ] First to 3 wins + +--- + +## Phase 3: Visibility & AI (2-3 days) + +**Goal:** Can't see enemies outside vision range. AI provides opposition. + +### Tasks + +- [x] **3.1** Create `Visibility.cs`: + ```csharp + public class Visibility : MonoBehaviour + { + public Unit[] playerUnits; + public Unit[] enemyUnits; + public Flag enemyFlag; + + void Update() + { + foreach (var enemy in enemyUnits) + { + bool visible = IsVisibleToAnyPlayerUnit(enemy.transform.position); + enemy.GetComponent().enabled = visible; + } + + // Also hide enemy flag if not carried and not visible + if (enemyFlag.carriedBy == null) + enemyFlag.GetComponent().enabled = + IsVisibleToAnyPlayerUnit(enemyFlag.transform.position); + } + + bool IsVisibleToAnyPlayerUnit(Vector2 pos) + { + foreach (var unit in playerUnits) + { + if (unit.isTaggedOut) continue; + if (Vector2.Distance(unit.transform.position, pos) < VisionRadius) + return true; + } + return false; + } + } + ``` + +- [x] **3.2** Create `SimpleAI.cs`: + ```csharp + public class SimpleAI : MonoBehaviour + { + public Unit[] aiUnits; + public Flag playerFlag; + public Transform aiBase; + float decisionTimer; + + void Update() + { + decisionTimer -= Time.deltaTime; + if (decisionTimer <= 0) + { + MakeDecisions(); + decisionTimer = 0.5f; // Decide every 0.5s + } + } + + void MakeDecisions() + { + foreach (var unit in aiUnits) + { + if (unit.isTaggedOut) continue; + + Vector2 target; + if (unit.hasFlag) + target = aiBase.position; // Return flag + else if (playerFlag.carriedBy != null) + target = playerFlag.carriedBy.transform.position; // Chase carrier + else + target = playerFlag.transform.position; // Go for flag + + // Simple route: straight line to target + unit.SetRoute(new List { target }); + } + } + } + ``` + +- [x] **3.3** Add slight route randomness: + - Offset target by small random amount + - Prevents all AI units clumping perfectly + +- [x] **3.4** Playtest & tune: + - Adjust VisionRadius, TagRadius, speeds + - Make AI beatable but not trivial + +**Verification:** +- [ ] Enemy units hidden when far from player units +- [ ] Enemies appear when player unit gets close +- [ ] AI units move toward player flag +- [ ] AI chases player flag carrier +- [ ] AI returns flag to base when carrying +- [ ] Can beat AI after 2-3 attempts + +--- + +## Acceptance Criteria (MVP) + +- [ ] Game starts with 5 units per side at bases +- [ ] Draw route on unit, unit follows path +- [ ] Unit stops at obstacles +- [ ] Grabbing enemy flag awards 1 point +- [ ] Returning flag to base (with own flag present) awards 1 more point +- [ ] Overlapping enemies triggers tag-out (farther from base loses) +- [ ] Tagged units respawn at base after 3 seconds +- [ ] Enemies only visible within vision radius of player units +- [ ] AI controls enemy team, chases flag +- [ ] First to 3 points wins + +--- + +## Out of Scope (v2) + +- Shader-based fog of war (smooth edges, feathering) +- Unit classes (Sneak/Patrol/Speed) +- Jail escort mechanics +- Motion lights +- Pre-phase setup +- Multiplayer +- AI with strategic roles +- Route obstacle preview +- Audio +- Main menu +- Mobile optimization + +--- + +## What We're Testing + +This prototype answers one question: **Is commanding units in CTF fun?** + +If yes → Add fog polish, AI strategy, classes +If no → Revisit core mechanics before adding complexity + +--- + +## References + +- Soul Doc: `soul.md` +- Brainstorm: `docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md` +- Input actions: `Assets/Settings/Input/GameInputActions.inputactions`