Compare commits
1 Commits
b1b3e4d0b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de174eb1a |
8
Backyard CTF/Assets/Scripts.meta
Normal file
8
Backyard CTF/Assets/Scripts.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d761750f862c443dc8458191d85f0d4d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
187
Backyard CTF/Assets/Scripts/CameraController.cs
Normal file
187
Backyard CTF/Assets/Scripts/CameraController.cs
Normal file
@@ -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<Unit>() != 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/CameraController.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/CameraController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a481d18ae14b94a098fb1659b13c68a5
|
||||||
97
Backyard CTF/Assets/Scripts/Flag.cs
Normal file
97
Backyard CTF/Assets/Scripts/Flag.cs
Normal file
@@ -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<Unit>();
|
||||||
|
if (unit != null && unit.team == team && !unit.isTaggedOut)
|
||||||
|
{
|
||||||
|
ReturnHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/Flag.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/Flag.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 95e5070ee05d6462bbee9fa1ca4917d9
|
||||||
538
Backyard CTF/Assets/Scripts/Game.cs
Normal file
538
Backyard CTF/Assets/Scripts/Game.cs
Normal file
@@ -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<Game>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// 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<Unit> playerUnits = new();
|
||||||
|
public List<Unit> 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<CameraController>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
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<SpriteRenderer>().sortingOrder = -9;
|
||||||
|
|
||||||
|
// Upper horizontal street
|
||||||
|
var upperStreet = CreateSprite("UpperStreet", streetColor, MapWidth - 30f, StreetWidth);
|
||||||
|
upperStreet.transform.position = new Vector3(0, 10f, 0);
|
||||||
|
upperStreet.GetComponent<SpriteRenderer>().sortingOrder = -9;
|
||||||
|
|
||||||
|
// Lower horizontal street
|
||||||
|
var lowerStreet = CreateSprite("LowerStreet", streetColor, MapWidth - 30f, StreetWidth);
|
||||||
|
lowerStreet.transform.position = new Vector3(0, -10f, 0);
|
||||||
|
lowerStreet.GetComponent<SpriteRenderer>().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<SpriteRenderer>().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<BoxCollider2D>();
|
||||||
|
collider.size = Vector2.one;
|
||||||
|
|
||||||
|
var sr = house.GetComponent<SpriteRenderer>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
sr1.sortingOrder = -8;
|
||||||
|
|
||||||
|
var playerBaseTrigger = playerBaseGO.AddComponent<BoxCollider2D>();
|
||||||
|
playerBaseTrigger.isTrigger = true;
|
||||||
|
playerBaseTrigger.size = Vector2.one;
|
||||||
|
|
||||||
|
var playerBaseZone = playerBaseGO.AddComponent<BaseZone>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
sr2.sortingOrder = -8;
|
||||||
|
|
||||||
|
var enemyBaseTrigger = enemyBaseGO.AddComponent<BoxCollider2D>();
|
||||||
|
enemyBaseTrigger.isTrigger = true;
|
||||||
|
enemyBaseTrigger.size = Vector2.one;
|
||||||
|
|
||||||
|
var enemyBaseZone = enemyBaseGO.AddComponent<BaseZone>();
|
||||||
|
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<Flag>();
|
||||||
|
playerFlag.team = Unit.Team.Player;
|
||||||
|
playerFlag.homePosition = playerBase;
|
||||||
|
|
||||||
|
var flagCollider1 = playerFlagGO.AddComponent<CircleCollider2D>();
|
||||||
|
flagCollider1.isTrigger = true;
|
||||||
|
flagCollider1.radius = FlagPickupRadius;
|
||||||
|
|
||||||
|
var rb1 = playerFlagGO.AddComponent<Rigidbody2D>();
|
||||||
|
rb1.bodyType = RigidbodyType2D.Kinematic;
|
||||||
|
|
||||||
|
var sr1 = playerFlagGO.GetComponent<SpriteRenderer>();
|
||||||
|
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<Flag>();
|
||||||
|
enemyFlag.team = Unit.Team.Enemy;
|
||||||
|
enemyFlag.homePosition = enemyBase;
|
||||||
|
|
||||||
|
var flagCollider2 = enemyFlagGO.AddComponent<CircleCollider2D>();
|
||||||
|
flagCollider2.isTrigger = true;
|
||||||
|
flagCollider2.radius = FlagPickupRadius;
|
||||||
|
|
||||||
|
var rb2 = enemyFlagGO.AddComponent<Rigidbody2D>();
|
||||||
|
rb2.bodyType = RigidbodyType2D.Kinematic;
|
||||||
|
|
||||||
|
var sr2 = enemyFlagGO.GetComponent<SpriteRenderer>();
|
||||||
|
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>();
|
||||||
|
unit.team = team;
|
||||||
|
|
||||||
|
var collider = unitGO.AddComponent<CircleCollider2D>();
|
||||||
|
collider.isTrigger = true;
|
||||||
|
collider.radius = UnitColliderRadius;
|
||||||
|
|
||||||
|
var rb = unitGO.AddComponent<Rigidbody2D>();
|
||||||
|
rb.bodyType = RigidbodyType2D.Kinematic;
|
||||||
|
|
||||||
|
var sr = unitGO.GetComponent<SpriteRenderer>();
|
||||||
|
sr.sortingOrder = 10;
|
||||||
|
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CreateUI()
|
||||||
|
{
|
||||||
|
// Create Canvas
|
||||||
|
var canvasGO = new GameObject("Canvas");
|
||||||
|
var canvas = canvasGO.AddComponent<Canvas>();
|
||||||
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
|
canvasGO.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||||
|
canvasGO.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||||
|
|
||||||
|
// Score text
|
||||||
|
var scoreGO = new GameObject("ScoreText");
|
||||||
|
scoreGO.transform.SetParent(canvasGO.transform, false);
|
||||||
|
scoreText = scoreGO.AddComponent<TextMeshProUGUI>();
|
||||||
|
scoreText.text = "0 - 0";
|
||||||
|
scoreText.fontSize = 48;
|
||||||
|
scoreText.alignment = TextAlignmentOptions.Center;
|
||||||
|
scoreText.color = Color.white;
|
||||||
|
|
||||||
|
var scoreRect = scoreGO.GetComponent<RectTransform>();
|
||||||
|
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<TextMeshProUGUI>();
|
||||||
|
gameOverText.text = "";
|
||||||
|
gameOverText.fontSize = 72;
|
||||||
|
gameOverText.alignment = TextAlignmentOptions.Center;
|
||||||
|
gameOverText.color = Color.white;
|
||||||
|
gameOverText.gameObject.SetActive(false);
|
||||||
|
|
||||||
|
var gameOverRect = gameOverGO.GetComponent<RectTransform>();
|
||||||
|
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<RouteDrawer>();
|
||||||
|
|
||||||
|
// Add Visibility system
|
||||||
|
var visibility = gameObject.AddComponent<Visibility>();
|
||||||
|
visibility.playerUnits = playerUnits.ToArray();
|
||||||
|
visibility.enemyUnits = enemyUnits.ToArray();
|
||||||
|
visibility.enemyFlag = enemyFlag;
|
||||||
|
|
||||||
|
// Add SimpleAI
|
||||||
|
var ai = gameObject.AddComponent<SimpleAI>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/Game.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/Game.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f62c6569cd5544a03b7d7d1cee7da3f6
|
||||||
147
Backyard CTF/Assets/Scripts/RouteDrawer.cs
Normal file
147
Backyard CTF/Assets/Scripts/RouteDrawer.cs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
public class RouteDrawer : MonoBehaviour
|
||||||
|
{
|
||||||
|
Unit selectedUnit;
|
||||||
|
List<Vector2> 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>();
|
||||||
|
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<Unit>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/RouteDrawer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8c4467d56dec84c76a24b128afc73a3f
|
||||||
70
Backyard CTF/Assets/Scripts/SimpleAI.cs
Normal file
70
Backyard CTF/Assets/Scripts/SimpleAI.cs
Normal file
@@ -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<Vector2> { finalTarget });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/SimpleAI.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/SimpleAI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: faa2f6c6b185d4351829086d17055949
|
||||||
232
Backyard CTF/Assets/Scripts/Unit.cs
Normal file
232
Backyard CTF/Assets/Scripts/Unit.cs
Normal file
@@ -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<Vector2> route = new();
|
||||||
|
int routeIndex;
|
||||||
|
bool isMoving;
|
||||||
|
|
||||||
|
SpriteRenderer spriteRenderer;
|
||||||
|
CircleCollider2D circleCollider;
|
||||||
|
|
||||||
|
void Awake()
|
||||||
|
{
|
||||||
|
spriteRenderer = GetComponent<SpriteRenderer>();
|
||||||
|
circleCollider = GetComponent<CircleCollider2D>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vector2> waypoints)
|
||||||
|
{
|
||||||
|
if (isTaggedOut) return;
|
||||||
|
|
||||||
|
route = new List<Vector2>(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<Unit>();
|
||||||
|
if (otherUnit != null && otherUnit.team != team && !otherUnit.isTaggedOut)
|
||||||
|
{
|
||||||
|
HandleTagCollision(otherUnit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for flag pickup
|
||||||
|
var flag = other.GetComponent<Flag>();
|
||||||
|
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<BaseZone>();
|
||||||
|
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<Vector2> CurrentRoute => route;
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/Unit.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/Unit.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6ea1fe45ed12847fdbf61f14ae451c47
|
||||||
59
Backyard CTF/Assets/Scripts/Visibility.cs
Normal file
59
Backyard CTF/Assets/Scripts/Visibility.cs
Normal file
@@ -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<SpriteRenderer>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
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<SpriteRenderer>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Backyard CTF/Assets/Scripts/Visibility.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/Visibility.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 02e237c0633a948708044095c3dc90c5
|
||||||
@@ -39,7 +39,7 @@ MonoBehaviour:
|
|||||||
m_ExplicitNullChecks: 0
|
m_ExplicitNullChecks: 0
|
||||||
m_ExplicitDivideByZeroChecks: 0
|
m_ExplicitDivideByZeroChecks: 0
|
||||||
m_ExplicitArrayBoundsChecks: 0
|
m_ExplicitArrayBoundsChecks: 0
|
||||||
m_CompressionType: -1
|
m_CompressionType: 0
|
||||||
m_InstallInBuildFolder: 0
|
m_InstallInBuildFolder: 0
|
||||||
m_InsightsSettingsContainer:
|
m_InsightsSettingsContainer:
|
||||||
m_BuildProfileEngineDiagnosticsState: 2
|
m_BuildProfileEngineDiagnosticsState: 2
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ MonoBehaviour:
|
|||||||
m_ExplicitNullChecks: 0
|
m_ExplicitNullChecks: 0
|
||||||
m_ExplicitDivideByZeroChecks: 0
|
m_ExplicitDivideByZeroChecks: 0
|
||||||
m_ExplicitArrayBoundsChecks: 0
|
m_ExplicitArrayBoundsChecks: 0
|
||||||
m_CompressionType: -1
|
m_CompressionType: 0
|
||||||
m_InstallInBuildFolder: 0
|
m_InstallInBuildFolder: 0
|
||||||
m_InsightsSettingsContainer:
|
m_InsightsSettingsContainer:
|
||||||
m_BuildProfileEngineDiagnosticsState: 2
|
m_BuildProfileEngineDiagnosticsState: 2
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
shaderVariantLimit: 128
|
shaderVariantLimit: 128
|
||||||
|
overrideShaderVariantLimit: 0
|
||||||
customInterpolatorErrorThreshold: 32
|
customInterpolatorErrorThreshold: 32
|
||||||
customInterpolatorWarningThreshold: 16
|
customInterpolatorWarningThreshold: 16
|
||||||
customHeatmapValues: {fileID: 0}
|
customHeatmapValues: {fileID: 0}
|
||||||
|
|||||||
67
docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md
Normal file
67
docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md
Normal file
@@ -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.
|
||||||
@@ -254,18 +254,18 @@ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Unity 6 LTS project opens without errors
|
- [x] Unity 6 LTS project opens without errors
|
||||||
- [ ] URP 2D Renderer is active (check Graphics settings)
|
- [x] URP 2D Renderer is active (check Graphics settings)
|
||||||
- [ ] New Input System is the active input handling mode
|
- [x] New Input System is the active input handling mode
|
||||||
- [ ] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`)
|
- [x] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`)
|
||||||
- [ ] `GameInputActions` asset exists with placeholder actions
|
- [x] `GameInputActions` asset exists with placeholder actions
|
||||||
- [ ] Android build target configured (IL2CPP, ARM64, API 24+)
|
- [~] Android build target configured (IL2CPP, ARM64, API 24+) - partially configured, UI differs from plan
|
||||||
- [ ] iOS build target configured (IL2CPP, ARM64, iOS 13+)
|
- [~] iOS build target configured (IL2CPP, ARM64, iOS 13+) - partially configured, UI differs from plan
|
||||||
- [ ] Desktop build targets configured
|
- [x] Desktop build targets configured
|
||||||
- [ ] Main.unity scene has Global Light 2D with low intensity
|
- [ ] Main.unity scene has Global Light 2D with low intensity - TODO: add in Unity Editor
|
||||||
- [ ] `.gitignore` excludes Library/, Temp/, builds
|
- [x] `.gitignore` excludes Library/, Temp/, builds
|
||||||
- [ ] Project uses text-based asset serialization
|
- [x] Project uses text-based asset serialization
|
||||||
- [ ] Initial git commit created
|
- [x] Initial git commit created
|
||||||
|
|
||||||
## Files Created/Modified
|
## Files Created/Modified
|
||||||
|
|
||||||
|
|||||||
@@ -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<Vector2> route;
|
||||||
|
int routeIndex;
|
||||||
|
|
||||||
|
public void SetRoute(List<Vector2> 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<SpriteRenderer>().enabled = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also hide enemy flag if not carried and not visible
|
||||||
|
if (enemyFlag.carriedBy == null)
|
||||||
|
enemyFlag.GetComponent<SpriteRenderer>().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<Vector2> { 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`
|
||||||
Reference in New Issue
Block a user