From 0de174eb1a7c18c57000f94f7ad4ca1d9021493e Mon Sep 17 00:00:00 2001 From: john Date: Thu, 5 Feb 2026 02:30:45 +0000 Subject: [PATCH] feat/teaser-prototype-playable-core (#1) Co-authored-by: John Lamb Reviewed-on: https://git.lambwire.net/john/Backyard-CTF/pulls/1 --- Backyard CTF/Assets/Scripts.meta | 8 + .../Assets/Scripts/CameraController.cs | 187 ++++++ .../Assets/Scripts/CameraController.cs.meta | 2 + Backyard CTF/Assets/Scripts/Flag.cs | 97 ++++ Backyard CTF/Assets/Scripts/Flag.cs.meta | 2 + Backyard CTF/Assets/Scripts/Game.cs | 538 ++++++++++++++++++ Backyard CTF/Assets/Scripts/Game.cs.meta | 2 + 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 + .../Settings/Build Profiles/Linux.asset | 2 +- .../Assets/Settings/Build Profiles/iOS.asset | 2 +- .../ProjectSettings/ShaderGraphSettings.asset | 1 + .../2026-02-01-teaser-prototype-brainstorm.md | 67 +++ ...-01-31-feat-unity-project-scaffold-plan.md | 24 +- ...eat-teaser-prototype-playable-core-plan.md | 346 +++++++++++ 21 files changed, 1780 insertions(+), 14 deletions(-) create mode 100644 Backyard CTF/Assets/Scripts.meta create mode 100644 Backyard CTF/Assets/Scripts/CameraController.cs create mode 100644 Backyard CTF/Assets/Scripts/CameraController.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/Flag.cs create mode 100644 Backyard CTF/Assets/Scripts/Flag.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/Game.cs create mode 100644 Backyard CTF/Assets/Scripts/Game.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/RouteDrawer.cs create mode 100644 Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/SimpleAI.cs create mode 100644 Backyard CTF/Assets/Scripts/SimpleAI.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/Unit.cs create mode 100644 Backyard CTF/Assets/Scripts/Unit.cs.meta create mode 100644 Backyard CTF/Assets/Scripts/Visibility.cs create mode 100644 Backyard CTF/Assets/Scripts/Visibility.cs.meta create mode 100644 docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md create mode 100644 docs/plans/2026-02-01-feat-teaser-prototype-playable-core-plan.md diff --git a/Backyard CTF/Assets/Scripts.meta b/Backyard CTF/Assets/Scripts.meta new file mode 100644 index 0000000..00fa896 --- /dev/null +++ b/Backyard CTF/Assets/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d761750f862c443dc8458191d85f0d4d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Backyard CTF/Assets/Scripts/CameraController.cs b/Backyard CTF/Assets/Scripts/CameraController.cs new file mode 100644 index 0000000..dd05d3b --- /dev/null +++ b/Backyard CTF/Assets/Scripts/CameraController.cs @@ -0,0 +1,187 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +public class CameraController : MonoBehaviour +{ + // Zoom settings + public float minZoom = 5f; + public float maxZoom = 25f; + public float zoomSpeed = 2f; + public float pinchZoomSpeed = 0.1f; + + // Pan settings + public float panSpeed = 1f; + + 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; + } + + void Update() + { + HandleMouseInput(); + HandleTouchInput(); + ClampCameraPosition(); + } + + void HandleMouseInput() + { + var mouse = Mouse.current; + if (mouse == null) return; + + // Scroll wheel zoom + float scroll = mouse.scroll.ReadValue().y; + if (Mathf.Abs(scroll) > 0.01f) + { + Zoom(-scroll * zoomSpeed * Time.deltaTime * 10f); + } + + // Right-click pan (left-click is for route drawing) + if (mouse.rightButton.wasPressedThisFrame) + { + lastPanPosition = mouse.position.ReadValue(); + isPanning = true; + } + else if (mouse.rightButton.wasReleasedThisFrame) + { + isPanning = false; + } + + if (isPanning && mouse.rightButton.isPressed) + { + Vector2 currentPos = mouse.position.ReadValue(); + Vector2 delta = currentPos - lastPanPosition; + Pan(-delta); + lastPanPosition = currentPos; + } + } + + void HandleTouchInput() + { + var touch = Touchscreen.current; + if (touch == null) return; + + int touchCount = 0; + foreach (var t in touch.touches) + { + if (t.press.isPressed) touchCount++; + } + + if (touchCount == 2) + { + // Two finger pinch zoom and pan + var touch0 = touch.touches[0]; + var touch1 = touch.touches[1]; + + Vector2 pos0 = touch0.position.ReadValue(); + Vector2 pos1 = touch1.position.ReadValue(); + float currentDistance = Vector2.Distance(pos0, pos1); + + if (!isPinching) + { + isPinching = true; + lastPinchDistance = currentDistance; + lastPanPosition = (pos0 + pos1) / 2f; + } + else + { + // Pinch zoom + float deltaDistance = lastPinchDistance - currentDistance; + Zoom(deltaDistance * pinchZoomSpeed); + lastPinchDistance = currentDistance; + + // Two-finger pan + Vector2 currentCenter = (pos0 + pos1) / 2f; + Vector2 delta = currentCenter - lastPanPosition; + Pan(-delta); + lastPanPosition = currentCenter; + } + + isPanning = false; // Don't single-finger pan while pinching + } + else if (touchCount == 1) + { + isPinching = false; + + 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; + } + } + else + { + isPinching = false; + isPanning = false; + } + } + + void Zoom(float delta) + { + float newSize = cam.orthographicSize + delta; + cam.orthographicSize = Mathf.Clamp(newSize, minZoom, maxZoom); + } + + void Pan(Vector2 screenDelta) + { + // Convert screen delta to world delta + float worldUnitsPerPixel = cam.orthographicSize * 2f / Screen.height; + Vector3 worldDelta = new Vector3( + screenDelta.x * worldUnitsPerPixel * panSpeed, + screenDelta.y * worldUnitsPerPixel * panSpeed, + 0 + ); + + cam.transform.position += worldDelta; + } + + void ClampCameraPosition() + { + // Keep camera within map bounds (with some padding for zoom) + 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; + + Vector3 pos = cam.transform.position; + pos.x = Mathf.Clamp(pos.x, minX, maxX); + pos.y = Mathf.Clamp(pos.y, minY, maxY); + cam.transform.position = pos; + } +} diff --git a/Backyard CTF/Assets/Scripts/CameraController.cs.meta b/Backyard CTF/Assets/Scripts/CameraController.cs.meta new file mode 100644 index 0000000..4df1f84 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/CameraController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a481d18ae14b94a098fb1659b13c68a5 \ No newline at end of file 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/Flag.cs.meta b/Backyard CTF/Assets/Scripts/Flag.cs.meta new file mode 100644 index 0000000..2400d90 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Flag.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..540448f --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Game.cs @@ -0,0 +1,538 @@ +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(); + } + + // =========================================== + // CONFIGURATION - All tunable constants here + // =========================================== + + // 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 + + // 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 + 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() + { + // Seed random for consistent neighborhood layout + Random.InitState(42); + + SetupCamera(); + CreateGround(); + CreateObstacles(); + CreateBases(); + CreateFlags(); + SpawnUnits(); + CreateUI(); + SetupSystems(); + } + + void SetupCamera() + { + var cam = Camera.main; + if (cam != null) + { + cam.orthographic = true; + cam.orthographicSize = CameraStartZoom; + cam.transform.position = new Vector3(0, 0, -10); + + // Add camera controller for zoom/pan + var controller = cam.gameObject.AddComponent(); + controller.minZoom = CameraMinZoom; + controller.maxZoom = CameraMaxZoom; + } + } + + 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 + 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 = 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; + } +} + +// Simple component to identify base zones +public class BaseZone : MonoBehaviour +{ + public Unit.Team team; +} diff --git a/Backyard CTF/Assets/Scripts/Game.cs.meta b/Backyard CTF/Assets/Scripts/Game.cs.meta new file mode 100644 index 0000000..2e22b8b --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Game.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f62c6569cd5544a03b7d7d1cee7da3f6 \ No newline at end of file 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/RouteDrawer.cs.meta b/Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta new file mode 100644 index 0000000..34215f7 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta @@ -0,0 +1,2 @@ +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 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/SimpleAI.cs.meta b/Backyard CTF/Assets/Scripts/SimpleAI.cs.meta new file mode 100644 index 0000000..806d13a --- /dev/null +++ b/Backyard CTF/Assets/Scripts/SimpleAI.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..5f22ab2 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Unit.cs @@ -0,0 +1,232 @@ +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 new file mode 100644 index 0000000..28c948e --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Unit.cs.meta @@ -0,0 +1,2 @@ +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 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/Backyard CTF/Assets/Scripts/Visibility.cs.meta b/Backyard CTF/Assets/Scripts/Visibility.cs.meta new file mode 100644 index 0000000..3028324 --- /dev/null +++ b/Backyard CTF/Assets/Scripts/Visibility.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 02e237c0633a948708044095c3dc90c5 \ No newline at end of file diff --git a/Backyard CTF/Assets/Settings/Build Profiles/Linux.asset b/Backyard CTF/Assets/Settings/Build Profiles/Linux.asset index 2bd3cbf..90c8720 100644 --- a/Backyard CTF/Assets/Settings/Build Profiles/Linux.asset +++ b/Backyard CTF/Assets/Settings/Build Profiles/Linux.asset @@ -39,7 +39,7 @@ MonoBehaviour: m_ExplicitNullChecks: 0 m_ExplicitDivideByZeroChecks: 0 m_ExplicitArrayBoundsChecks: 0 - m_CompressionType: -1 + m_CompressionType: 0 m_InstallInBuildFolder: 0 m_InsightsSettingsContainer: m_BuildProfileEngineDiagnosticsState: 2 diff --git a/Backyard CTF/Assets/Settings/Build Profiles/iOS.asset b/Backyard CTF/Assets/Settings/Build Profiles/iOS.asset index e9dc0f0..17dbacb 100644 --- a/Backyard CTF/Assets/Settings/Build Profiles/iOS.asset +++ b/Backyard CTF/Assets/Settings/Build Profiles/iOS.asset @@ -39,7 +39,7 @@ MonoBehaviour: m_ExplicitNullChecks: 0 m_ExplicitDivideByZeroChecks: 0 m_ExplicitArrayBoundsChecks: 0 - m_CompressionType: -1 + m_CompressionType: 0 m_InstallInBuildFolder: 0 m_InsightsSettingsContainer: m_BuildProfileEngineDiagnosticsState: 2 diff --git a/Backyard CTF/Backyard CTF/ProjectSettings/ShaderGraphSettings.asset b/Backyard CTF/Backyard CTF/ProjectSettings/ShaderGraphSettings.asset index 10b82f0..0421697 100644 --- a/Backyard CTF/Backyard CTF/ProjectSettings/ShaderGraphSettings.asset +++ b/Backyard CTF/Backyard CTF/ProjectSettings/ShaderGraphSettings.asset @@ -13,6 +13,7 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: shaderVariantLimit: 128 + overrideShaderVariantLimit: 0 customInterpolatorErrorThreshold: 32 customInterpolatorWarningThreshold: 16 customHeatmapValues: {fileID: 0} 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-01-31-feat-unity-project-scaffold-plan.md b/docs/plans/2026-01-31-feat-unity-project-scaffold-plan.md index 73d61c4..af9c273 100644 --- a/docs/plans/2026-01-31-feat-unity-project-scaffold-plan.md +++ b/docs/plans/2026-01-31-feat-unity-project-scaffold-plan.md @@ -254,18 +254,18 @@ Co-Authored-By: Claude Opus 4.5 " ## Acceptance Criteria -- [ ] Unity 6 LTS project opens without errors -- [ ] URP 2D Renderer is active (check Graphics settings) -- [ ] New Input System is the active input handling mode -- [ ] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`) -- [ ] `GameInputActions` asset exists with placeholder actions -- [ ] Android build target configured (IL2CPP, ARM64, API 24+) -- [ ] iOS build target configured (IL2CPP, ARM64, iOS 13+) -- [ ] Desktop build targets configured -- [ ] Main.unity scene has Global Light 2D with low intensity -- [ ] `.gitignore` excludes Library/, Temp/, builds -- [ ] Project uses text-based asset serialization -- [ ] Initial git commit created +- [x] Unity 6 LTS project opens without errors +- [x] URP 2D Renderer is active (check Graphics settings) +- [x] New Input System is the active input handling mode +- [x] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`) +- [x] `GameInputActions` asset exists with placeholder actions +- [~] Android build target configured (IL2CPP, ARM64, API 24+) - partially configured, UI differs from plan +- [~] iOS build target configured (IL2CPP, ARM64, iOS 13+) - partially configured, UI differs from plan +- [x] Desktop build targets configured +- [ ] Main.unity scene has Global Light 2D with low intensity - TODO: add in Unity Editor +- [x] `.gitignore` excludes Library/, Temp/, builds +- [x] Project uses text-based asset serialization +- [x] Initial git commit created ## Files Created/Modified 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`