Compare commits
5 Commits
feat/asymm
...
0c43bd1c19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c43bd1c19 | ||
|
|
1911da1e33 | ||
|
|
c4b3507272 | ||
|
|
3fc62cde24 | ||
|
|
95b37a1606 |
@@ -5,23 +5,22 @@ public class CameraController : MonoBehaviour
|
|||||||
{
|
{
|
||||||
// Zoom settings
|
// Zoom settings
|
||||||
public float minZoom = 5f;
|
public float minZoom = 5f;
|
||||||
public float maxZoom = 35f;
|
public float maxZoom = 25f;
|
||||||
public float zoomSpeed = 2f;
|
public float zoomSpeed = 2f;
|
||||||
public float pinchZoomSpeed = 0.1f;
|
public float pinchZoomSpeed = 0.1f;
|
||||||
|
|
||||||
// Pan settings
|
// Pan settings
|
||||||
public float panSpeed = 1f;
|
public float panSpeed = 1f;
|
||||||
|
|
||||||
// Map bounds (grid-based)
|
|
||||||
float mapWidth = ZoneBoundaries.BoardWidth;
|
|
||||||
float mapHeight = ZoneBoundaries.BoardHeight;
|
|
||||||
|
|
||||||
Camera cam;
|
Camera cam;
|
||||||
Vector2 lastPanPosition;
|
Vector2 lastPanPosition;
|
||||||
bool isPanning;
|
bool isPanning;
|
||||||
float lastPinchDistance;
|
float lastPinchDistance;
|
||||||
bool isPinching;
|
bool isPinching;
|
||||||
|
|
||||||
|
// Track if we started on a unit (don't pan if drawing route)
|
||||||
|
bool startedOnUnit;
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
cam = Camera.main;
|
cam = Camera.main;
|
||||||
@@ -46,7 +45,7 @@ public class CameraController : MonoBehaviour
|
|||||||
Zoom(-scroll * zoomSpeed * Time.deltaTime * 10f);
|
Zoom(-scroll * zoomSpeed * Time.deltaTime * 10f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-click pan (left-click is for unit selection)
|
// Right-click pan (left-click is for route drawing)
|
||||||
if (mouse.rightButton.wasPressedThisFrame)
|
if (mouse.rightButton.wasPressedThisFrame)
|
||||||
{
|
{
|
||||||
lastPanPosition = mouse.position.ReadValue();
|
lastPanPosition = mouse.position.ReadValue();
|
||||||
@@ -116,8 +115,32 @@ public class CameraController : MonoBehaviour
|
|||||||
var primaryTouch = touch.primaryTouch;
|
var primaryTouch = touch.primaryTouch;
|
||||||
Vector2 touchPos = primaryTouch.position.ReadValue();
|
Vector2 touchPos = primaryTouch.position.ReadValue();
|
||||||
|
|
||||||
// Two-finger gestures only for pan on touch - single finger is for unit selection
|
// Check if touch started on a unit
|
||||||
// Don't initiate pan on single touch
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -151,14 +174,10 @@ public class CameraController : MonoBehaviour
|
|||||||
float halfHeight = cam.orthographicSize;
|
float halfHeight = cam.orthographicSize;
|
||||||
float halfWidth = halfHeight * cam.aspect;
|
float halfWidth = halfHeight * cam.aspect;
|
||||||
|
|
||||||
float minX = -mapWidth / 2f + halfWidth;
|
float minX = -Game.MapWidth / 2f + halfWidth;
|
||||||
float maxX = mapWidth / 2f - halfWidth;
|
float maxX = Game.MapWidth / 2f - halfWidth;
|
||||||
float minY = -mapHeight / 2f + halfHeight;
|
float minY = -Game.MapHeight / 2f + halfHeight;
|
||||||
float maxY = mapHeight / 2f - halfHeight;
|
float maxY = Game.MapHeight / 2f - halfHeight;
|
||||||
|
|
||||||
// Handle case where zoom is wider than map
|
|
||||||
if (minX > maxX) minX = maxX = 0;
|
|
||||||
if (minY > maxY) minY = maxY = 0;
|
|
||||||
|
|
||||||
Vector3 pos = cam.transform.position;
|
Vector3 pos = cam.transform.position;
|
||||||
pos.x = Mathf.Clamp(pos.x, minX, maxX);
|
pos.x = Mathf.Clamp(pos.x, minX, maxX);
|
||||||
|
|||||||
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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
|
|
||||||
@@ -14,21 +16,63 @@ public class Game : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================
|
// ===========================================
|
||||||
// CONFIGURATION
|
// CONFIGURATION - All tunable constants here
|
||||||
// ===========================================
|
// ===========================================
|
||||||
|
|
||||||
// Camera settings for grid view
|
// Gameplay
|
||||||
public const float CameraZoom = 28f; // Fits 20x50 grid
|
public const int UnitsPerTeam = 3;
|
||||||
public const float CameraMinZoom = 15f;
|
public const float UnitSpeed = 5f;
|
||||||
public const float CameraMaxZoom = 35f;
|
public const float UnitSize = 1f;
|
||||||
|
public const float UnitColliderRadius = 0.5f;
|
||||||
|
public const float MinGapSize = 2f; // Minimum gap between obstacles
|
||||||
|
|
||||||
// References
|
// 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 static Game Instance { get; private set; }
|
||||||
|
|
||||||
GridBoard gridBoard;
|
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 scoreText;
|
||||||
TextMeshProUGUI gameOverText;
|
TextMeshProUGUI gameOverText;
|
||||||
TextMeshProUGUI turnText;
|
|
||||||
|
|
||||||
void Awake()
|
void Awake()
|
||||||
{
|
{
|
||||||
@@ -37,9 +81,17 @@ public class Game : MonoBehaviour
|
|||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
|
// Seed random for consistent neighborhood layout
|
||||||
|
Random.InitState(42);
|
||||||
|
|
||||||
SetupCamera();
|
SetupCamera();
|
||||||
|
CreateGround();
|
||||||
|
CreateObstacles();
|
||||||
|
CreateBases();
|
||||||
|
CreateFlags();
|
||||||
|
SpawnUnits();
|
||||||
CreateUI();
|
CreateUI();
|
||||||
CreateGridBoard();
|
SetupSystems();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetupCamera()
|
void SetupCamera()
|
||||||
@@ -48,9 +100,8 @@ public class Game : MonoBehaviour
|
|||||||
if (cam != null)
|
if (cam != null)
|
||||||
{
|
{
|
||||||
cam.orthographic = true;
|
cam.orthographic = true;
|
||||||
cam.orthographicSize = CameraZoom;
|
cam.orthographicSize = CameraStartZoom;
|
||||||
cam.transform.position = new Vector3(0, 0, -10);
|
cam.transform.position = new Vector3(0, 0, -10);
|
||||||
cam.backgroundColor = new Color(0.1f, 0.1f, 0.15f);
|
|
||||||
|
|
||||||
// Add camera controller for zoom/pan
|
// Add camera controller for zoom/pan
|
||||||
var controller = cam.gameObject.AddComponent<CameraController>();
|
var controller = cam.gameObject.AddComponent<CameraController>();
|
||||||
@@ -59,6 +110,270 @@ public class Game : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CreateGround()
|
||||||
|
{
|
||||||
|
// Grass background
|
||||||
|
var ground = CreateSprite("Ground", new Color(0.2f, 0.35f, 0.15f), MapWidth, MapHeight);
|
||||||
|
ground.transform.position = Vector3.zero;
|
||||||
|
var sr = ground.GetComponent<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()
|
void CreateUI()
|
||||||
{
|
{
|
||||||
// Create Canvas
|
// Create Canvas
|
||||||
@@ -68,7 +383,7 @@ public class Game : MonoBehaviour
|
|||||||
canvasGO.AddComponent<UnityEngine.UI.CanvasScaler>();
|
canvasGO.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||||
canvasGO.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
canvasGO.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||||
|
|
||||||
// Score text (top center)
|
// Score text
|
||||||
var scoreGO = new GameObject("ScoreText");
|
var scoreGO = new GameObject("ScoreText");
|
||||||
scoreGO.transform.SetParent(canvasGO.transform, false);
|
scoreGO.transform.SetParent(canvasGO.transform, false);
|
||||||
scoreText = scoreGO.AddComponent<TextMeshProUGUI>();
|
scoreText = scoreGO.AddComponent<TextMeshProUGUI>();
|
||||||
@@ -84,38 +399,6 @@ public class Game : MonoBehaviour
|
|||||||
scoreRect.anchoredPosition = new Vector2(0, -20);
|
scoreRect.anchoredPosition = new Vector2(0, -20);
|
||||||
scoreRect.sizeDelta = new Vector2(200, 60);
|
scoreRect.sizeDelta = new Vector2(200, 60);
|
||||||
|
|
||||||
// Turn indicator (below score)
|
|
||||||
var turnGO = new GameObject("TurnText");
|
|
||||||
turnGO.transform.SetParent(canvasGO.transform, false);
|
|
||||||
turnText = turnGO.AddComponent<TextMeshProUGUI>();
|
|
||||||
turnText.text = "BLUE'S TURN";
|
|
||||||
turnText.fontSize = 32;
|
|
||||||
turnText.alignment = TextAlignmentOptions.Center;
|
|
||||||
turnText.color = Color.blue;
|
|
||||||
|
|
||||||
var turnRect = turnGO.GetComponent<RectTransform>();
|
|
||||||
turnRect.anchorMin = new Vector2(0.5f, 1f);
|
|
||||||
turnRect.anchorMax = new Vector2(0.5f, 1f);
|
|
||||||
turnRect.pivot = new Vector2(0.5f, 1f);
|
|
||||||
turnRect.anchoredPosition = new Vector2(0, -80);
|
|
||||||
turnRect.sizeDelta = new Vector2(300, 50);
|
|
||||||
|
|
||||||
// Instructions (bottom)
|
|
||||||
var instructionsGO = new GameObject("InstructionsText");
|
|
||||||
instructionsGO.transform.SetParent(canvasGO.transform, false);
|
|
||||||
var instructionsText = instructionsGO.AddComponent<TextMeshProUGUI>();
|
|
||||||
instructionsText.text = "Click unit to select, then click destination to move";
|
|
||||||
instructionsText.fontSize = 20;
|
|
||||||
instructionsText.alignment = TextAlignmentOptions.Center;
|
|
||||||
instructionsText.color = new Color(0.7f, 0.7f, 0.7f);
|
|
||||||
|
|
||||||
var instrRect = instructionsGO.GetComponent<RectTransform>();
|
|
||||||
instrRect.anchorMin = new Vector2(0.5f, 0f);
|
|
||||||
instrRect.anchorMax = new Vector2(0.5f, 0f);
|
|
||||||
instrRect.pivot = new Vector2(0.5f, 0f);
|
|
||||||
instrRect.anchoredPosition = new Vector2(0, 20);
|
|
||||||
instrRect.sizeDelta = new Vector2(500, 40);
|
|
||||||
|
|
||||||
// Game over text (hidden initially)
|
// Game over text (hidden initially)
|
||||||
var gameOverGO = new GameObject("GameOverText");
|
var gameOverGO = new GameObject("GameOverText");
|
||||||
gameOverGO.transform.SetParent(canvasGO.transform, false);
|
gameOverGO.transform.SetParent(canvasGO.transform, false);
|
||||||
@@ -134,14 +417,122 @@ public class Game : MonoBehaviour
|
|||||||
gameOverRect.sizeDelta = new Vector2(400, 100);
|
gameOverRect.sizeDelta = new Vector2(400, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CreateGridBoard()
|
void SetupSystems()
|
||||||
{
|
{
|
||||||
var boardGO = new GameObject("GridBoard");
|
// Add RouteDrawer
|
||||||
gridBoard = boardGO.AddComponent<GridBoard>();
|
gameObject.AddComponent<RouteDrawer>();
|
||||||
|
|
||||||
// Pass UI references
|
// Add Visibility system
|
||||||
gridBoard.scoreText = scoreText;
|
var visibility = gameObject.AddComponent<Visibility>();
|
||||||
gridBoard.gameOverText = gameOverText;
|
visibility.playerUnits = playerUnits.ToArray();
|
||||||
gridBoard.turnText = turnText;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using UnityEngine;
|
|
||||||
|
|
||||||
public class GridAI : MonoBehaviour
|
|
||||||
{
|
|
||||||
GridBoard board;
|
|
||||||
Team aiTeam;
|
|
||||||
float thinkDelay = 0.5f;
|
|
||||||
float thinkTimer = 0f;
|
|
||||||
bool hasMoved = false;
|
|
||||||
|
|
||||||
public void Initialize(GridBoard board, Team team)
|
|
||||||
{
|
|
||||||
this.board = board;
|
|
||||||
this.aiTeam = team;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Update()
|
|
||||||
{
|
|
||||||
if (board == null || board.IsGameOver()) return;
|
|
||||||
if (board.GetCurrentTeam() != aiTeam)
|
|
||||||
{
|
|
||||||
hasMoved = false;
|
|
||||||
thinkTimer = 0f;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasMoved) return;
|
|
||||||
|
|
||||||
// Small delay to make AI moves visible
|
|
||||||
thinkTimer += Time.deltaTime;
|
|
||||||
if (thinkTimer < thinkDelay) return;
|
|
||||||
|
|
||||||
MakeMove();
|
|
||||||
hasMoved = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void MakeMove()
|
|
||||||
{
|
|
||||||
var units = board.GetUnits(aiTeam);
|
|
||||||
var visibleCells = board.GetVisibleCells(aiTeam);
|
|
||||||
|
|
||||||
// Find a unit that can move
|
|
||||||
GridUnit bestUnit = null;
|
|
||||||
Vector2Int bestMove = default;
|
|
||||||
float bestScore = float.MinValue;
|
|
||||||
|
|
||||||
foreach (var unit in units)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) continue;
|
|
||||||
|
|
||||||
// Check speed nerf
|
|
||||||
if (board.IsDefending(unit) && unit.ConsecutiveDefenseMoves % 4 == 3)
|
|
||||||
{
|
|
||||||
continue; // This unit must skip
|
|
||||||
}
|
|
||||||
|
|
||||||
var validMoves = board.GetValidMoves(unit);
|
|
||||||
if (validMoves.Count == 0) continue;
|
|
||||||
|
|
||||||
foreach (var move in validMoves)
|
|
||||||
{
|
|
||||||
float score = EvaluateMove(unit, move, visibleCells);
|
|
||||||
if (score > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = score;
|
|
||||||
bestUnit = unit;
|
|
||||||
bestMove = move;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestUnit != null)
|
|
||||||
{
|
|
||||||
board.ExecuteAIMove(bestUnit, bestMove);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No valid moves, skip turn
|
|
||||||
board.AISkipTurn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
float EvaluateMove(GridUnit unit, Vector2Int move, HashSet<Vector2Int> visibleCells)
|
|
||||||
{
|
|
||||||
float score = 0f;
|
|
||||||
|
|
||||||
var enemyFlagPos = board.GetEnemyFlagPosition(aiTeam);
|
|
||||||
var ourFlagCarrier = board.GetFlagCarrier(aiTeam == Team.Blue ? Team.Red : Team.Blue);
|
|
||||||
var theirFlagCarrier = board.GetFlagCarrier(aiTeam);
|
|
||||||
|
|
||||||
// Are we carrying the enemy flag?
|
|
||||||
bool carryingFlag = theirFlagCarrier == unit;
|
|
||||||
|
|
||||||
if (carryingFlag)
|
|
||||||
{
|
|
||||||
// Priority: Return to base with flag
|
|
||||||
// Move toward our defense zone
|
|
||||||
int targetY = aiTeam == Team.Blue ? 0 : ZoneBoundaries.BoardHeight - 1;
|
|
||||||
float distToBase = Mathf.Abs(move.y - targetY);
|
|
||||||
score += 1000f - distToBase * 10f;
|
|
||||||
}
|
|
||||||
else if (ourFlagCarrier != null && visibleCells.Contains(ourFlagCarrier.GridPosition))
|
|
||||||
{
|
|
||||||
// Our flag is being carried - chase the carrier!
|
|
||||||
float distToCarrier = ChebyshevDistance(move, ourFlagCarrier.GridPosition);
|
|
||||||
score += 500f - distToCarrier * 15f;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Go for the enemy flag
|
|
||||||
float distToFlag = ChebyshevDistance(move, enemyFlagPos);
|
|
||||||
score += 100f - distToFlag * 5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small bonus for advancing toward enemy
|
|
||||||
if (aiTeam == Team.Blue)
|
|
||||||
{
|
|
||||||
score += move.y * 0.5f; // Blue advances up
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
score += (ZoneBoundaries.BoardHeight - move.y) * 0.5f; // Red advances down
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid staying in defense too long (speed penalty)
|
|
||||||
if (board.GetZoneOwner(move.y) == (aiTeam == Team.Blue ? ZoneOwner.Blue : ZoneOwner.Red))
|
|
||||||
{
|
|
||||||
score -= 5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small randomness to prevent predictability
|
|
||||||
score += Random.Range(0f, 2f);
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ChebyshevDistance(Vector2Int a, Vector2Int b)
|
|
||||||
{
|
|
||||||
return Mathf.Max(Mathf.Abs(a.x - b.x), Mathf.Abs(a.y - b.y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,905 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using UnityEngine;
|
|
||||||
using UnityEngine.InputSystem;
|
|
||||||
|
|
||||||
public enum ZoneOwner { Blue, Neutral, Red }
|
|
||||||
|
|
||||||
public static class ZoneBoundaries
|
|
||||||
{
|
|
||||||
public const int TeamBlueDefenseEnd = 20; // Y < 20 is Blue defense
|
|
||||||
public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamBlueDefenseEnd)
|
|
||||||
public const int BoardWidth = 20;
|
|
||||||
public const int BoardHeight = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GridBoard : MonoBehaviour
|
|
||||||
{
|
|
||||||
// Configuration
|
|
||||||
const float CellSize = 1f;
|
|
||||||
const float CellPadding = 0.05f;
|
|
||||||
const int UnitsPerTeam = 3;
|
|
||||||
const int WinScore = 3;
|
|
||||||
const int RespawnDelay = 2; // Turns until respawn
|
|
||||||
const int VisionRadius = 3; // Chebyshev distance for fog of war
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
static readonly Color BlueZoneColor = new Color(0.2f, 0.3f, 0.6f, 0.4f);
|
|
||||||
static readonly Color NeutralZoneColor = new Color(0.4f, 0.4f, 0.4f, 0.4f);
|
|
||||||
static readonly Color RedZoneColor = new Color(0.6f, 0.2f, 0.2f, 0.4f);
|
|
||||||
static readonly Color BlueUnitColor = new Color(0.3f, 0.5f, 1f);
|
|
||||||
static readonly Color RedUnitColor = new Color(1f, 0.3f, 0.3f);
|
|
||||||
static readonly Color ValidMoveColor = new Color(0.3f, 1f, 0.3f, 0.5f);
|
|
||||||
static readonly Color SelectedColor = new Color(1f, 1f, 0.3f, 0.8f);
|
|
||||||
static readonly Color FlagBlueColor = new Color(0.3f, 0.5f, 1f);
|
|
||||||
static readonly Color FlagRedColor = new Color(1f, 0.3f, 0.3f);
|
|
||||||
|
|
||||||
// Board state
|
|
||||||
GridUnit[,] cellOccupants;
|
|
||||||
Dictionary<GridUnit, Vector2Int> unitPositions = new();
|
|
||||||
List<GridUnit> blueUnits = new();
|
|
||||||
List<GridUnit> redUnits = new();
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
Vector2Int blueFlagPosition;
|
|
||||||
Vector2Int redFlagPosition;
|
|
||||||
Vector2Int blueFlagHome;
|
|
||||||
Vector2Int redFlagHome;
|
|
||||||
GridUnit blueFlagCarrier;
|
|
||||||
GridUnit redFlagCarrier;
|
|
||||||
GameObject blueFlagGO;
|
|
||||||
GameObject redFlagGO;
|
|
||||||
|
|
||||||
// Turn state
|
|
||||||
Team currentTeam = Team.Blue;
|
|
||||||
int turnNumber = 0;
|
|
||||||
|
|
||||||
// Scores
|
|
||||||
int blueScore = 0;
|
|
||||||
int redScore = 0;
|
|
||||||
bool gameOver = false;
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
GridUnit selectedUnit;
|
|
||||||
List<Vector2Int> validMoves = new();
|
|
||||||
List<GameObject> validMoveHighlights = new();
|
|
||||||
GameObject selectionHighlight;
|
|
||||||
|
|
||||||
// Visual objects
|
|
||||||
GameObject[,] cellVisuals;
|
|
||||||
|
|
||||||
// Visibility
|
|
||||||
HashSet<Vector2Int> visibleToBlue = new();
|
|
||||||
HashSet<Vector2Int> visibleToRed = new();
|
|
||||||
|
|
||||||
// AI
|
|
||||||
GridAI ai;
|
|
||||||
|
|
||||||
// UI references (set by Game.cs)
|
|
||||||
public TMPro.TextMeshProUGUI scoreText;
|
|
||||||
public TMPro.TextMeshProUGUI gameOverText;
|
|
||||||
public TMPro.TextMeshProUGUI turnText;
|
|
||||||
|
|
||||||
int nextUnitId = 0;
|
|
||||||
|
|
||||||
void Start()
|
|
||||||
{
|
|
||||||
InitializeBoard();
|
|
||||||
CreateBoardVisuals();
|
|
||||||
SpawnUnits();
|
|
||||||
CreateFlags();
|
|
||||||
RecalculateVisibility();
|
|
||||||
UpdateUI();
|
|
||||||
|
|
||||||
// Initialize AI for red team
|
|
||||||
ai = gameObject.AddComponent<GridAI>();
|
|
||||||
ai.Initialize(this, Team.Red);
|
|
||||||
}
|
|
||||||
|
|
||||||
void InitializeBoard()
|
|
||||||
{
|
|
||||||
cellOccupants = new GridUnit[ZoneBoundaries.BoardWidth, ZoneBoundaries.BoardHeight];
|
|
||||||
cellVisuals = new GameObject[ZoneBoundaries.BoardWidth, ZoneBoundaries.BoardHeight];
|
|
||||||
}
|
|
||||||
|
|
||||||
void CreateBoardVisuals()
|
|
||||||
{
|
|
||||||
// Create parent for organization
|
|
||||||
var boardParent = new GameObject("Board");
|
|
||||||
boardParent.transform.SetParent(transform);
|
|
||||||
|
|
||||||
for (int x = 0; x < ZoneBoundaries.BoardWidth; x++)
|
|
||||||
{
|
|
||||||
for (int y = 0; y < ZoneBoundaries.BoardHeight; y++)
|
|
||||||
{
|
|
||||||
Color zoneColor = GetZoneColor(y);
|
|
||||||
var cell = CreateSprite($"Cell_{x}_{y}", zoneColor, CellSize - CellPadding, CellSize - CellPadding);
|
|
||||||
cell.transform.SetParent(boardParent.transform);
|
|
||||||
cell.transform.position = GridToWorld(new Vector2Int(x, y));
|
|
||||||
cell.GetComponent<SpriteRenderer>().sortingOrder = -10;
|
|
||||||
cellVisuals[x, y] = cell;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color GetZoneColor(int y)
|
|
||||||
{
|
|
||||||
var zone = GetZoneOwner(y);
|
|
||||||
return zone switch
|
|
||||||
{
|
|
||||||
ZoneOwner.Blue => BlueZoneColor,
|
|
||||||
ZoneOwner.Neutral => NeutralZoneColor,
|
|
||||||
ZoneOwner.Red => RedZoneColor,
|
|
||||||
_ => NeutralZoneColor
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void SpawnUnits()
|
|
||||||
{
|
|
||||||
// Blue units spawn at bottom of blue zone
|
|
||||||
for (int i = 0; i < UnitsPerTeam; i++)
|
|
||||||
{
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3);
|
|
||||||
int y = 2 + (i / 3);
|
|
||||||
SpawnUnit(Team.Blue, new Vector2Int(x, y));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Red units spawn at top of red zone
|
|
||||||
for (int i = 0; i < UnitsPerTeam; i++)
|
|
||||||
{
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3);
|
|
||||||
int y = ZoneBoundaries.BoardHeight - 3 - (i / 3);
|
|
||||||
SpawnUnit(Team.Red, new Vector2Int(x, y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GridUnit SpawnUnit(Team team, Vector2Int position)
|
|
||||||
{
|
|
||||||
Color color = team == Team.Blue ? BlueUnitColor : RedUnitColor;
|
|
||||||
var go = CreateSprite($"{team}Unit_{nextUnitId}", color, CellSize * 0.8f, CellSize * 0.8f);
|
|
||||||
go.GetComponent<SpriteRenderer>().sortingOrder = 10;
|
|
||||||
|
|
||||||
var unit = new GridUnit(nextUnitId++, team, position, go);
|
|
||||||
unit.SetWorldPosition(GridToWorld(position));
|
|
||||||
|
|
||||||
cellOccupants[position.x, position.y] = unit;
|
|
||||||
unitPositions[unit] = position;
|
|
||||||
|
|
||||||
if (team == Team.Blue)
|
|
||||||
blueUnits.Add(unit);
|
|
||||||
else
|
|
||||||
redUnits.Add(unit);
|
|
||||||
|
|
||||||
return unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
void CreateFlags()
|
|
||||||
{
|
|
||||||
// Blue flag at center bottom of blue zone
|
|
||||||
blueFlagHome = new Vector2Int(ZoneBoundaries.BoardWidth / 2, 1);
|
|
||||||
blueFlagPosition = blueFlagHome;
|
|
||||||
blueFlagGO = CreateSprite("BlueFlag", FlagBlueColor, CellSize * 0.5f, CellSize * 0.8f);
|
|
||||||
blueFlagGO.GetComponent<SpriteRenderer>().sortingOrder = 5;
|
|
||||||
blueFlagGO.transform.position = GridToWorld(blueFlagPosition);
|
|
||||||
|
|
||||||
// Red flag at center top of red zone
|
|
||||||
redFlagHome = new Vector2Int(ZoneBoundaries.BoardWidth / 2, ZoneBoundaries.BoardHeight - 2);
|
|
||||||
redFlagPosition = redFlagHome;
|
|
||||||
redFlagGO = CreateSprite("RedFlag", FlagRedColor, CellSize * 0.5f, CellSize * 0.8f);
|
|
||||||
redFlagGO.GetComponent<SpriteRenderer>().sortingOrder = 5;
|
|
||||||
redFlagGO.transform.position = GridToWorld(redFlagPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Update()
|
|
||||||
{
|
|
||||||
if (gameOver) return;
|
|
||||||
|
|
||||||
// AI handles red team
|
|
||||||
if (currentTeam == Team.Red)
|
|
||||||
{
|
|
||||||
return; // AI takes control in GridAI.Update()
|
|
||||||
}
|
|
||||||
|
|
||||||
HandleInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
void HandleInput()
|
|
||||||
{
|
|
||||||
var mouse = Mouse.current;
|
|
||||||
if (mouse == null) return;
|
|
||||||
|
|
||||||
if (mouse.leftButton.wasPressedThisFrame)
|
|
||||||
{
|
|
||||||
Vector2 worldPos = Camera.main.ScreenToWorldPoint(mouse.position.ReadValue());
|
|
||||||
Vector2Int gridPos = WorldToGrid(worldPos);
|
|
||||||
|
|
||||||
if (!IsInBounds(gridPos)) return;
|
|
||||||
|
|
||||||
// If we have a unit selected and clicked on a valid move, execute it
|
|
||||||
if (selectedUnit != null && validMoves.Contains(gridPos))
|
|
||||||
{
|
|
||||||
ExecutePlayerMove(selectedUnit, gridPos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we clicked on a friendly unit to select it
|
|
||||||
var unitAtCell = cellOccupants[gridPos.x, gridPos.y];
|
|
||||||
if (unitAtCell != null && unitAtCell.Team == currentTeam && !unitAtCell.IsTaggedOut)
|
|
||||||
{
|
|
||||||
// Check if unit can move this turn (defense speed nerf)
|
|
||||||
if (IsDefending(unitAtCell) && unitAtCell.ConsecutiveDefenseMoves % 4 == 3)
|
|
||||||
{
|
|
||||||
// This unit must skip their move
|
|
||||||
Debug.Log($"Unit {unitAtCell.UnitId} must skip move (defense speed nerf)");
|
|
||||||
SelectUnit(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectUnit(unitAtCell);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Clicked on empty cell or enemy - deselect
|
|
||||||
SelectUnit(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right click to deselect
|
|
||||||
if (mouse.rightButton.wasPressedThisFrame)
|
|
||||||
{
|
|
||||||
SelectUnit(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void SelectUnit(GridUnit unit)
|
|
||||||
{
|
|
||||||
selectedUnit = unit;
|
|
||||||
ClearValidMoveHighlights();
|
|
||||||
|
|
||||||
if (unit == null)
|
|
||||||
{
|
|
||||||
if (selectionHighlight != null)
|
|
||||||
selectionHighlight.SetActive(false);
|
|
||||||
validMoves.Clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show selection highlight
|
|
||||||
if (selectionHighlight == null)
|
|
||||||
{
|
|
||||||
selectionHighlight = CreateSprite("SelectionHighlight", SelectedColor, CellSize * 0.95f, CellSize * 0.95f);
|
|
||||||
selectionHighlight.GetComponent<SpriteRenderer>().sortingOrder = 1;
|
|
||||||
}
|
|
||||||
selectionHighlight.SetActive(true);
|
|
||||||
selectionHighlight.transform.position = GridToWorld(unit.GridPosition);
|
|
||||||
|
|
||||||
// Calculate and show valid moves
|
|
||||||
validMoves = GetValidMoves(unit);
|
|
||||||
foreach (var move in validMoves)
|
|
||||||
{
|
|
||||||
var highlight = CreateSprite("ValidMove", ValidMoveColor, CellSize * 0.9f, CellSize * 0.9f);
|
|
||||||
highlight.GetComponent<SpriteRenderer>().sortingOrder = 0;
|
|
||||||
highlight.transform.position = GridToWorld(move);
|
|
||||||
validMoveHighlights.Add(highlight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ClearValidMoveHighlights()
|
|
||||||
{
|
|
||||||
foreach (var highlight in validMoveHighlights)
|
|
||||||
{
|
|
||||||
Destroy(highlight);
|
|
||||||
}
|
|
||||||
validMoveHighlights.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Vector2Int> GetValidMoves(GridUnit unit)
|
|
||||||
{
|
|
||||||
var moves = new List<Vector2Int>();
|
|
||||||
if (unit.IsTaggedOut) return moves;
|
|
||||||
|
|
||||||
var pos = unit.GridPosition;
|
|
||||||
bool isDefending = IsDefending(unit);
|
|
||||||
|
|
||||||
// Determine valid directions based on zone
|
|
||||||
Vector2Int[] directions;
|
|
||||||
if (isDefending)
|
|
||||||
{
|
|
||||||
// Diagonal movement (8 directions) in own defense zone
|
|
||||||
directions = new Vector2Int[]
|
|
||||||
{
|
|
||||||
new(-1, -1), new(0, -1), new(1, -1),
|
|
||||||
new(-1, 0), new(1, 0),
|
|
||||||
new(-1, 1), new(0, 1), new(1, 1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Orthogonal movement (4 directions) in offense/neutral zones
|
|
||||||
directions = new Vector2Int[]
|
|
||||||
{
|
|
||||||
new(0, -1), new(-1, 0), new(1, 0), new(0, 1)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var dir in directions)
|
|
||||||
{
|
|
||||||
var newPos = pos + dir;
|
|
||||||
if (IsInBounds(newPos))
|
|
||||||
{
|
|
||||||
// Can move to empty cells or cells with enemies (will trigger collision)
|
|
||||||
var occupant = cellOccupants[newPos.x, newPos.y];
|
|
||||||
if (occupant == null || occupant.Team != unit.Team)
|
|
||||||
{
|
|
||||||
moves.Add(newPos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moves;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ExecutePlayerMove(GridUnit unit, Vector2Int destination)
|
|
||||||
{
|
|
||||||
ExecuteMove(unit, destination);
|
|
||||||
EndTurn();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ExecuteMove(GridUnit unit, Vector2Int destination)
|
|
||||||
{
|
|
||||||
var startPos = unit.GridPosition;
|
|
||||||
bool wasDefending = IsDefending(unit);
|
|
||||||
|
|
||||||
// Move unit
|
|
||||||
MoveUnit(unit, destination);
|
|
||||||
|
|
||||||
// Update defense move counter
|
|
||||||
bool nowDefending = IsDefending(unit);
|
|
||||||
if (nowDefending)
|
|
||||||
{
|
|
||||||
unit.ConsecutiveDefenseMoves++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
unit.ConsecutiveDefenseMoves = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check collision
|
|
||||||
CheckCollision(destination);
|
|
||||||
|
|
||||||
// Check flag pickup
|
|
||||||
CheckFlagPickup(unit);
|
|
||||||
|
|
||||||
// Check scoring
|
|
||||||
CheckScoring(unit);
|
|
||||||
|
|
||||||
// Update visual
|
|
||||||
unit.SetWorldPosition(GridToWorld(destination));
|
|
||||||
UpdateFlagVisuals();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void MoveUnit(GridUnit unit, Vector2Int to)
|
|
||||||
{
|
|
||||||
var from = unitPositions[unit];
|
|
||||||
if (cellOccupants[from.x, from.y] == unit)
|
|
||||||
{
|
|
||||||
cellOccupants[from.x, from.y] = null;
|
|
||||||
}
|
|
||||||
cellOccupants[to.x, to.y] = unit;
|
|
||||||
unitPositions[unit] = to;
|
|
||||||
unit.GridPosition = to;
|
|
||||||
}
|
|
||||||
|
|
||||||
void CheckCollision(Vector2Int cell)
|
|
||||||
{
|
|
||||||
// Find all units at this cell
|
|
||||||
var unitsHere = new List<GridUnit>();
|
|
||||||
foreach (var unit in blueUnits)
|
|
||||||
{
|
|
||||||
if (!unit.IsTaggedOut && unit.GridPosition == cell)
|
|
||||||
unitsHere.Add(unit);
|
|
||||||
}
|
|
||||||
foreach (var unit in redUnits)
|
|
||||||
{
|
|
||||||
if (!unit.IsTaggedOut && unit.GridPosition == cell)
|
|
||||||
unitsHere.Add(unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unitsHere.Count < 2) return;
|
|
||||||
|
|
||||||
// Check for enemy collisions
|
|
||||||
for (int i = 0; i < unitsHere.Count; i++)
|
|
||||||
{
|
|
||||||
for (int j = i + 1; j < unitsHere.Count; j++)
|
|
||||||
{
|
|
||||||
var a = unitsHere[i];
|
|
||||||
var b = unitsHere[j];
|
|
||||||
if (a.Team != b.Team)
|
|
||||||
{
|
|
||||||
ResolveCollision(a, b, cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ResolveCollision(GridUnit unitA, GridUnit unitB, Vector2Int cell)
|
|
||||||
{
|
|
||||||
if (unitA.IsTaggedOut || unitB.IsTaggedOut) return;
|
|
||||||
|
|
||||||
var zoneOwner = GetZoneOwner(cell.y);
|
|
||||||
|
|
||||||
// Defender in their zone always wins
|
|
||||||
if (unitA.Team == Team.Blue && zoneOwner == ZoneOwner.Blue)
|
|
||||||
TagOut(unitB);
|
|
||||||
else if (unitB.Team == Team.Blue && zoneOwner == ZoneOwner.Blue)
|
|
||||||
TagOut(unitA);
|
|
||||||
else if (unitA.Team == Team.Red && zoneOwner == ZoneOwner.Red)
|
|
||||||
TagOut(unitB);
|
|
||||||
else if (unitB.Team == Team.Red && zoneOwner == ZoneOwner.Red)
|
|
||||||
TagOut(unitA);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Neutral zone: lower UnitId wins (deterministic)
|
|
||||||
if (unitA.UnitId < unitB.UnitId)
|
|
||||||
TagOut(unitB);
|
|
||||||
else
|
|
||||||
TagOut(unitA);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void TagOut(GridUnit unit)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) return;
|
|
||||||
|
|
||||||
unit.IsTaggedOut = true;
|
|
||||||
unit.RespawnTurnsRemaining = RespawnDelay;
|
|
||||||
unit.ConsecutiveDefenseMoves = 0;
|
|
||||||
|
|
||||||
// Drop flag if carrying
|
|
||||||
if (blueFlagCarrier == unit)
|
|
||||||
{
|
|
||||||
blueFlagCarrier = null;
|
|
||||||
// Flag stays at current position
|
|
||||||
Debug.Log("Blue flag dropped!");
|
|
||||||
}
|
|
||||||
if (redFlagCarrier == unit)
|
|
||||||
{
|
|
||||||
redFlagCarrier = null;
|
|
||||||
Debug.Log("Red flag dropped!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from cell
|
|
||||||
var pos = unit.GridPosition;
|
|
||||||
if (cellOccupants[pos.x, pos.y] == unit)
|
|
||||||
{
|
|
||||||
cellOccupants[pos.x, pos.y] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade out visual
|
|
||||||
if (unit.SpriteRenderer != null)
|
|
||||||
{
|
|
||||||
var color = unit.SpriteRenderer.color;
|
|
||||||
color.a = 0.3f;
|
|
||||||
unit.SpriteRenderer.color = color;
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.Log($"{unit.Team} unit {unit.UnitId} tagged out!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void CheckFlagPickup(GridUnit unit)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) return;
|
|
||||||
|
|
||||||
var pos = unit.GridPosition;
|
|
||||||
|
|
||||||
// Blue unit can pick up red flag
|
|
||||||
if (unit.Team == Team.Blue && pos == redFlagPosition && redFlagCarrier == null)
|
|
||||||
{
|
|
||||||
redFlagCarrier = unit;
|
|
||||||
Debug.Log($"Blue unit picked up red flag!");
|
|
||||||
}
|
|
||||||
// Red unit can pick up blue flag
|
|
||||||
else if (unit.Team == Team.Red && pos == blueFlagPosition && blueFlagCarrier == null)
|
|
||||||
{
|
|
||||||
blueFlagCarrier = unit;
|
|
||||||
Debug.Log($"Red unit picked up blue flag!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return own flag if it's dropped and friendly unit touches it
|
|
||||||
if (unit.Team == Team.Blue && pos == blueFlagPosition && blueFlagCarrier == null && blueFlagPosition != blueFlagHome)
|
|
||||||
{
|
|
||||||
blueFlagPosition = blueFlagHome;
|
|
||||||
Debug.Log("Blue flag returned home!");
|
|
||||||
}
|
|
||||||
if (unit.Team == Team.Red && pos == redFlagPosition && redFlagCarrier == null && redFlagPosition != redFlagHome)
|
|
||||||
{
|
|
||||||
redFlagPosition = redFlagHome;
|
|
||||||
Debug.Log("Red flag returned home!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void CheckScoring(GridUnit unit)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) return;
|
|
||||||
|
|
||||||
var pos = unit.GridPosition;
|
|
||||||
var zone = GetZoneOwner(pos.y);
|
|
||||||
|
|
||||||
// Blue scores by bringing red flag to blue zone
|
|
||||||
if (unit.Team == Team.Blue && redFlagCarrier == unit && zone == ZoneOwner.Blue)
|
|
||||||
{
|
|
||||||
Score(Team.Blue);
|
|
||||||
}
|
|
||||||
// Red scores by bringing blue flag to red zone
|
|
||||||
else if (unit.Team == Team.Red && blueFlagCarrier == unit && zone == ZoneOwner.Red)
|
|
||||||
{
|
|
||||||
Score(Team.Red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void Score(Team team)
|
|
||||||
{
|
|
||||||
if (team == Team.Blue)
|
|
||||||
{
|
|
||||||
blueScore++;
|
|
||||||
Debug.Log($"Blue scores! {blueScore} - {redScore}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
redScore++;
|
|
||||||
Debug.Log($"Red scores! {blueScore} - {redScore}");
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateUI();
|
|
||||||
|
|
||||||
if (blueScore >= WinScore)
|
|
||||||
{
|
|
||||||
EndGame(Team.Blue);
|
|
||||||
}
|
|
||||||
else if (redScore >= WinScore)
|
|
||||||
{
|
|
||||||
EndGame(Team.Red);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ResetRound();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void ResetRound()
|
|
||||||
{
|
|
||||||
// Return flags
|
|
||||||
blueFlagPosition = blueFlagHome;
|
|
||||||
redFlagPosition = redFlagHome;
|
|
||||||
blueFlagCarrier = null;
|
|
||||||
redFlagCarrier = null;
|
|
||||||
|
|
||||||
// Respawn all units
|
|
||||||
for (int i = 0; i < blueUnits.Count; i++)
|
|
||||||
{
|
|
||||||
var unit = blueUnits[i];
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3);
|
|
||||||
int y = 2 + (i / 3);
|
|
||||||
RespawnUnit(unit, new Vector2Int(x, y));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < redUnits.Count; i++)
|
|
||||||
{
|
|
||||||
var unit = redUnits[i];
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (i % 3);
|
|
||||||
int y = ZoneBoundaries.BoardHeight - 3 - (i / 3);
|
|
||||||
RespawnUnit(unit, new Vector2Int(x, y));
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateFlagVisuals();
|
|
||||||
RecalculateVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
void RespawnUnit(GridUnit unit, Vector2Int position)
|
|
||||||
{
|
|
||||||
unit.IsTaggedOut = false;
|
|
||||||
unit.RespawnTurnsRemaining = 0;
|
|
||||||
unit.ConsecutiveDefenseMoves = 0;
|
|
||||||
|
|
||||||
// Clear old position
|
|
||||||
var oldPos = unit.GridPosition;
|
|
||||||
if (cellOccupants[oldPos.x, oldPos.y] == unit)
|
|
||||||
{
|
|
||||||
cellOccupants[oldPos.x, oldPos.y] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find nearest empty cell if position is occupied
|
|
||||||
if (cellOccupants[position.x, position.y] != null)
|
|
||||||
{
|
|
||||||
position = FindNearestEmptyCell(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
unit.GridPosition = position;
|
|
||||||
unitPositions[unit] = position;
|
|
||||||
cellOccupants[position.x, position.y] = unit;
|
|
||||||
unit.SetWorldPosition(GridToWorld(position));
|
|
||||||
|
|
||||||
// Restore visual
|
|
||||||
if (unit.SpriteRenderer != null)
|
|
||||||
{
|
|
||||||
var color = unit.SpriteRenderer.color;
|
|
||||||
color.a = 1f;
|
|
||||||
unit.SpriteRenderer.color = color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Vector2Int FindNearestEmptyCell(Vector2Int center)
|
|
||||||
{
|
|
||||||
for (int radius = 1; radius < 10; radius++)
|
|
||||||
{
|
|
||||||
for (int dx = -radius; dx <= radius; dx++)
|
|
||||||
{
|
|
||||||
for (int dy = -radius; dy <= radius; dy++)
|
|
||||||
{
|
|
||||||
var pos = center + new Vector2Int(dx, dy);
|
|
||||||
if (IsInBounds(pos) && cellOccupants[pos.x, pos.y] == null)
|
|
||||||
{
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return center;
|
|
||||||
}
|
|
||||||
|
|
||||||
void EndTurn()
|
|
||||||
{
|
|
||||||
SelectUnit(null);
|
|
||||||
turnNumber++;
|
|
||||||
|
|
||||||
// Process respawn timers
|
|
||||||
ProcessRespawns();
|
|
||||||
|
|
||||||
// Recalculate visibility
|
|
||||||
RecalculateVisibility();
|
|
||||||
|
|
||||||
// Switch teams
|
|
||||||
currentTeam = currentTeam == Team.Blue ? Team.Red : Team.Blue;
|
|
||||||
|
|
||||||
UpdateUI();
|
|
||||||
|
|
||||||
Debug.Log($"Turn {turnNumber}: {currentTeam}'s turn");
|
|
||||||
}
|
|
||||||
|
|
||||||
void ProcessRespawns()
|
|
||||||
{
|
|
||||||
foreach (var unit in blueUnits)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut)
|
|
||||||
{
|
|
||||||
unit.RespawnTurnsRemaining--;
|
|
||||||
if (unit.RespawnTurnsRemaining <= 0)
|
|
||||||
{
|
|
||||||
int idx = blueUnits.IndexOf(unit);
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (idx % 3);
|
|
||||||
int y = 2 + (idx / 3);
|
|
||||||
RespawnUnit(unit, new Vector2Int(x, y));
|
|
||||||
Debug.Log($"Blue unit {unit.UnitId} respawned!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var unit in redUnits)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut)
|
|
||||||
{
|
|
||||||
unit.RespawnTurnsRemaining--;
|
|
||||||
if (unit.RespawnTurnsRemaining <= 0)
|
|
||||||
{
|
|
||||||
int idx = redUnits.IndexOf(unit);
|
|
||||||
int x = ZoneBoundaries.BoardWidth / 2 - 1 + (idx % 3);
|
|
||||||
int y = ZoneBoundaries.BoardHeight - 3 - (idx / 3);
|
|
||||||
RespawnUnit(unit, new Vector2Int(x, y));
|
|
||||||
Debug.Log($"Red unit {unit.UnitId} respawned!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void EndGame(Team winner)
|
|
||||||
{
|
|
||||||
gameOver = true;
|
|
||||||
|
|
||||||
if (gameOverText != null)
|
|
||||||
{
|
|
||||||
gameOverText.text = winner == Team.Blue ? "BLUE WINS!" : "RED WINS!";
|
|
||||||
gameOverText.color = winner == Team.Blue ? Color.blue : Color.red;
|
|
||||||
gameOverText.gameObject.SetActive(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug.Log($"{winner} wins!");
|
|
||||||
}
|
|
||||||
|
|
||||||
void UpdateUI()
|
|
||||||
{
|
|
||||||
if (scoreText != null)
|
|
||||||
{
|
|
||||||
scoreText.text = $"{blueScore} - {redScore}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (turnText != null)
|
|
||||||
{
|
|
||||||
string turnIndicator = currentTeam == Team.Blue ? "BLUE'S TURN" : "RED'S TURN";
|
|
||||||
turnText.text = turnIndicator;
|
|
||||||
turnText.color = currentTeam == Team.Blue ? Color.blue : Color.red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void UpdateFlagVisuals()
|
|
||||||
{
|
|
||||||
// Blue flag follows carrier or stays at position
|
|
||||||
if (blueFlagCarrier != null && !blueFlagCarrier.IsTaggedOut)
|
|
||||||
{
|
|
||||||
blueFlagPosition = blueFlagCarrier.GridPosition;
|
|
||||||
blueFlagGO.transform.position = GridToWorld(blueFlagPosition) + new Vector2(0.2f, 0.2f);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
blueFlagGO.transform.position = GridToWorld(blueFlagPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Red flag follows carrier or stays at position
|
|
||||||
if (redFlagCarrier != null && !redFlagCarrier.IsTaggedOut)
|
|
||||||
{
|
|
||||||
redFlagPosition = redFlagCarrier.GridPosition;
|
|
||||||
redFlagGO.transform.position = GridToWorld(redFlagPosition) + new Vector2(0.2f, 0.2f);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
redFlagGO.transform.position = GridToWorld(redFlagPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fog of war visibility to flags
|
|
||||||
bool blueFlagVisible = visibleToBlue.Contains(blueFlagPosition) || blueFlagCarrier != null;
|
|
||||||
bool redFlagVisible = visibleToBlue.Contains(redFlagPosition) || redFlagCarrier != null;
|
|
||||||
|
|
||||||
// For now, in single-player, Blue is the human player, so we apply Blue's visibility
|
|
||||||
redFlagGO.GetComponent<SpriteRenderer>().enabled = redFlagVisible;
|
|
||||||
// Blue flag is always visible to blue player
|
|
||||||
blueFlagGO.GetComponent<SpriteRenderer>().enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fog of War
|
|
||||||
void RecalculateVisibility()
|
|
||||||
{
|
|
||||||
visibleToBlue.Clear();
|
|
||||||
visibleToRed.Clear();
|
|
||||||
|
|
||||||
foreach (var unit in blueUnits)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) continue;
|
|
||||||
AddVisibleCells(unit.GridPosition, visibleToBlue);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var unit in redUnits)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) continue;
|
|
||||||
AddVisibleCells(unit.GridPosition, visibleToRed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update enemy visibility based on Blue's vision (human player)
|
|
||||||
foreach (var enemy in redUnits)
|
|
||||||
{
|
|
||||||
bool visible = visibleToBlue.Contains(enemy.GridPosition) || enemy.IsTaggedOut;
|
|
||||||
if (enemy.SpriteRenderer != null)
|
|
||||||
{
|
|
||||||
enemy.SpriteRenderer.enabled = visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateFlagVisuals();
|
|
||||||
}
|
|
||||||
|
|
||||||
void AddVisibleCells(Vector2Int center, HashSet<Vector2Int> visibleSet)
|
|
||||||
{
|
|
||||||
for (int dx = -VisionRadius; dx <= VisionRadius; dx++)
|
|
||||||
{
|
|
||||||
for (int dy = -VisionRadius; dy <= VisionRadius; dy++)
|
|
||||||
{
|
|
||||||
var cell = center + new Vector2Int(dx, dy);
|
|
||||||
if (IsInBounds(cell))
|
|
||||||
{
|
|
||||||
visibleSet.Add(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI interface
|
|
||||||
public void ExecuteAIMove(GridUnit unit, Vector2Int destination)
|
|
||||||
{
|
|
||||||
if (gameOver) return;
|
|
||||||
if (unit.Team != currentTeam) return;
|
|
||||||
|
|
||||||
// Check speed nerf
|
|
||||||
if (IsDefending(unit) && unit.ConsecutiveDefenseMoves % 4 == 3)
|
|
||||||
{
|
|
||||||
Debug.Log($"AI unit {unit.UnitId} skips move (defense speed nerf)");
|
|
||||||
EndTurn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ExecuteMove(unit, destination);
|
|
||||||
EndTurn();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AISkipTurn()
|
|
||||||
{
|
|
||||||
if (gameOver) return;
|
|
||||||
EndTurn();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Team GetCurrentTeam() => currentTeam;
|
|
||||||
public bool IsGameOver() => gameOver;
|
|
||||||
public List<GridUnit> GetUnits(Team team) => team == Team.Blue ? blueUnits : redUnits;
|
|
||||||
public HashSet<Vector2Int> GetVisibleCells(Team team) => team == Team.Blue ? visibleToBlue : visibleToRed;
|
|
||||||
public Vector2Int GetEnemyFlagPosition(Team team) => team == Team.Blue ? redFlagPosition : blueFlagPosition;
|
|
||||||
public GridUnit GetFlagCarrier(Team flagTeam) => flagTeam == Team.Blue ? blueFlagCarrier : redFlagCarrier;
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
public ZoneOwner GetZoneOwner(int y) => y switch
|
|
||||||
{
|
|
||||||
< ZoneBoundaries.TeamBlueDefenseEnd => ZoneOwner.Blue,
|
|
||||||
< ZoneBoundaries.NeutralEnd => ZoneOwner.Neutral,
|
|
||||||
_ => ZoneOwner.Red
|
|
||||||
};
|
|
||||||
|
|
||||||
public bool IsDefending(GridUnit unit)
|
|
||||||
{
|
|
||||||
var zone = GetZoneOwner(unit.GridPosition.y);
|
|
||||||
return (unit.Team == Team.Blue && zone == ZoneOwner.Blue) ||
|
|
||||||
(unit.Team == Team.Red && zone == ZoneOwner.Red);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsInBounds(Vector2Int pos) =>
|
|
||||||
pos.x >= 0 && pos.x < ZoneBoundaries.BoardWidth &&
|
|
||||||
pos.y >= 0 && pos.y < ZoneBoundaries.BoardHeight;
|
|
||||||
|
|
||||||
public Vector2 GridToWorld(Vector2Int gridPos)
|
|
||||||
{
|
|
||||||
// Center the board
|
|
||||||
float offsetX = -ZoneBoundaries.BoardWidth * CellSize / 2f + CellSize / 2f;
|
|
||||||
float offsetY = -ZoneBoundaries.BoardHeight * CellSize / 2f + CellSize / 2f;
|
|
||||||
return new Vector2(
|
|
||||||
gridPos.x * CellSize + offsetX,
|
|
||||||
gridPos.y * CellSize + offsetY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Vector2Int WorldToGrid(Vector2 worldPos)
|
|
||||||
{
|
|
||||||
float offsetX = -ZoneBoundaries.BoardWidth * CellSize / 2f;
|
|
||||||
float offsetY = -ZoneBoundaries.BoardHeight * CellSize / 2f;
|
|
||||||
int x = Mathf.FloorToInt((worldPos.x - offsetX) / CellSize);
|
|
||||||
int y = Mathf.FloorToInt((worldPos.y - offsetY) / CellSize);
|
|
||||||
return new Vector2Int(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create a colored sprite
|
|
||||||
GameObject CreateSprite(string name, Color color, float width, float height)
|
|
||||||
{
|
|
||||||
var go = new GameObject(name);
|
|
||||||
var sr = go.AddComponent<SpriteRenderer>();
|
|
||||||
sr.sprite = GetRectSprite();
|
|
||||||
sr.color = color;
|
|
||||||
go.transform.localScale = new Vector3(width, height, 1);
|
|
||||||
return go;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Sprite cachedSprite;
|
|
||||||
static Sprite GetRectSprite()
|
|
||||||
{
|
|
||||||
if (cachedSprite != null) return cachedSprite;
|
|
||||||
|
|
||||||
var tex = new Texture2D(1, 1);
|
|
||||||
tex.SetPixel(0, 0, Color.white);
|
|
||||||
tex.Apply();
|
|
||||||
|
|
||||||
cachedSprite = Sprite.Create(tex, new Rect(0, 0, 1, 1), new Vector2(0.5f, 0.5f), 1);
|
|
||||||
return cachedSprite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using UnityEngine;
|
|
||||||
|
|
||||||
public enum Team { Blue, Red }
|
|
||||||
|
|
||||||
public class GridUnit
|
|
||||||
{
|
|
||||||
public int UnitId;
|
|
||||||
public Team Team;
|
|
||||||
public Vector2Int GridPosition;
|
|
||||||
public int ConsecutiveDefenseMoves;
|
|
||||||
public bool IsTaggedOut;
|
|
||||||
public int RespawnTurnsRemaining;
|
|
||||||
|
|
||||||
// Visual representation
|
|
||||||
public GameObject GameObject;
|
|
||||||
public SpriteRenderer SpriteRenderer;
|
|
||||||
|
|
||||||
public GridUnit(int unitId, Team team, Vector2Int position, GameObject go)
|
|
||||||
{
|
|
||||||
UnitId = unitId;
|
|
||||||
Team = team;
|
|
||||||
GridPosition = position;
|
|
||||||
ConsecutiveDefenseMoves = 0;
|
|
||||||
IsTaggedOut = false;
|
|
||||||
RespawnTurnsRemaining = 0;
|
|
||||||
GameObject = go;
|
|
||||||
SpriteRenderer = go.GetComponent<SpriteRenderer>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetWorldPosition(Vector2 worldPos)
|
|
||||||
{
|
|
||||||
if (GameObject != null)
|
|
||||||
{
|
|
||||||
GameObject.transform.position = new Vector3(worldPos.x, worldPos.y, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Asymmetric Grid CTF Board
|
|
||||||
|
|
||||||
**Date:** 2026-02-04
|
|
||||||
**Status:** Ready for planning
|
|
||||||
|
|
||||||
## What We're Building
|
|
||||||
|
|
||||||
A turn-based capture-the-flag game on a 20x50 grid with asymmetric movement mechanics. Each team has 3 pieces that move differently depending on which zone they're in:
|
|
||||||
|
|
||||||
- **Offensive zone (enemy territory):** Orthogonal movement only (Manhattan distance)
|
|
||||||
- **Defensive zone (home territory):** Diagonal movement only (Chebyshev distance)
|
|
||||||
- **Neutral zone (center):** Both teams move orthogonally (on offense)
|
|
||||||
|
|
||||||
The board is mirrored: Team A's defensive zone is Team B's offensive zone, and vice versa.
|
|
||||||
|
|
||||||
### Core Mechanics
|
|
||||||
|
|
||||||
1. **Simultaneous turns:** Both teams plan moves secretly, then execute at the same time
|
|
||||||
2. **Fog of war:** Each piece sees 3 cells in any direction; the rest is hidden
|
|
||||||
3. **Collision resolution:** Defender wins ties (piece in their defensive zone captures invader)
|
|
||||||
4. **Defense speed nerf:** Defensive movement is 75% speed (skip every 4th move in defense zone)
|
|
||||||
5. **Victory condition:** Capture enemy flag from their base and return it to your base
|
|
||||||
|
|
||||||
## Why This Approach
|
|
||||||
|
|
||||||
### Pure Grid Replacement over Hybrid
|
|
||||||
|
|
||||||
The PDF's game theory analysis is fundamentally about discrete move counts (Manhattan vs Chebyshev distance). Free-form movement with grid constraints would:
|
|
||||||
- Complicate collision detection
|
|
||||||
- Make fog of war harder to compute
|
|
||||||
- Obscure the strategic depth the grid creates
|
|
||||||
|
|
||||||
A clean grid system directly implements the analyzed mechanics.
|
|
||||||
|
|
||||||
### Simultaneous Turns over Turn-Based
|
|
||||||
|
|
||||||
Simultaneous planning creates the "mixed-strategy game" described in the PDF. If turns were sequential, the reactive player always has perfect information. Simultaneous moves mean:
|
|
||||||
- Offense can commit to a direction without the defense knowing
|
|
||||||
- Both teams must predict opponent behavior
|
|
||||||
- Creates bluffing and misdirection opportunities
|
|
||||||
|
|
||||||
### Visible Grid
|
|
||||||
|
|
||||||
Players need to understand their movement options. The orthogonal green squares and diagonal red squares from the PDF communicate which directions are legal at a glance.
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
| Decision | Choice | Rationale |
|
|
||||||
|----------|--------|-----------|
|
|
||||||
| Board size | 20x50 | Wide enough for 3 pieces per side with meaningful positioning |
|
|
||||||
| Zone proportions | 20x20 defense, 10x10 neutral, 20x20 defense | Small neutral = more defensive play per user request |
|
|
||||||
| Movement per turn | 1 cell | Matches PDF analysis; multi-cell would change game theory |
|
|
||||||
| Vision radius | 3 cells | Creates meaningful information asymmetry without total blindness |
|
|
||||||
| Defense speed | 75% (3 moves per 4 turns) | PDF analysis shows this creates balanced mixed-strategy game |
|
|
||||||
| Collision rule | Defender wins | Rewards positioning in your territory |
|
|
||||||
| Flag location | Back of defensive zone | Classic CTF setup |
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **How to visualize fog of war?** Options: darken hidden cells, hide them entirely, show "last known" positions
|
|
||||||
2. **What happens to flag carrier if tagged?** Drop flag? Flag returns to base?
|
|
||||||
3. **Respawn mechanics?** Where do tagged pieces respawn? How long until they can act?
|
|
||||||
4. **Turn timer?** Unlimited planning time or forced time limit?
|
|
||||||
5. **AI opponent?** Should we build AI for single-player, or multiplayer-only initially?
|
|
||||||
|
|
||||||
## Grid Visual Reference
|
|
||||||
|
|
||||||
The PDF shows a pattern where:
|
|
||||||
- Green squares form an orthogonal grid (offense paths)
|
|
||||||
- Red diagonal lines overlay, creating larger diamond-shaped cells (defense paths)
|
|
||||||
- The two grids intersect, meaning some cells are reachable by both movement types
|
|
||||||
|
|
||||||
For implementation, we need to define:
|
|
||||||
- Cell size in world units
|
|
||||||
- How to render the dual-grid overlay
|
|
||||||
- Visual distinction between zones (Team A defense, neutral, Team B defense)
|
|
||||||
|
|
||||||
## Technical Considerations
|
|
||||||
|
|
||||||
### Current Architecture Impact
|
|
||||||
|
|
||||||
The existing `Game.cs`, `Unit.cs`, and `RouteDrawer.cs` will need significant changes:
|
|
||||||
- Replace `RouteDrawer` path drawing with click-to-select, click-to-move
|
|
||||||
- Replace continuous movement in `Unit.cs` with discrete grid steps
|
|
||||||
- Add turn manager for simultaneous move resolution
|
|
||||||
- Add fog of war system (current `Visibility.cs` is radius-based, needs grid conversion)
|
|
||||||
|
|
||||||
### New Components Needed
|
|
||||||
|
|
||||||
1. **GridBoard** - Manages 20x50 cell array, zone definitions, visual rendering
|
|
||||||
2. **TurnManager** - Handles move planning phase, simultaneous execution, turn counting
|
|
||||||
3. **GridMovement** - Validates moves based on zone type, handles defense speed nerf
|
|
||||||
4. **FogOfWar** - Computes visible cells per team, hides/reveals pieces
|
|
||||||
5. **CollisionResolver** - Determines outcomes when pieces occupy same cell
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Run `/workflows:plan` to create implementation plan addressing:
|
|
||||||
1. Grid data structure and rendering
|
|
||||||
2. Turn system and move planning UI
|
|
||||||
3. Zone-based movement validation
|
|
||||||
4. Fog of war implementation
|
|
||||||
5. Collision and capture mechanics
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
---
|
|
||||||
title: "feat: Asymmetric Grid CTF Board"
|
|
||||||
type: feat
|
|
||||||
date: 2026-02-04
|
|
||||||
brainstorm: docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# feat: Asymmetric Grid CTF Board
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Replace the current real-time continuous movement CTF game with a turn-based grid system featuring asymmetric movement mechanics. Each team's 3 pieces move differently based on which zone they occupy: orthogonal (Manhattan) in offensive zones, diagonal (Chebyshev) in defensive zones.
|
|
||||||
|
|
||||||
**Key changes from current implementation:**
|
|
||||||
- Continuous free-form movement → Discrete grid-based movement
|
|
||||||
- Real-time gameplay → Alternating turn-based
|
|
||||||
- Uniform movement → Zone-dependent movement rules
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
The current implementation is a real-time CTF game where:
|
|
||||||
- Units move continuously along drawn paths
|
|
||||||
- All units have the same movement capabilities
|
|
||||||
- The defense has no inherent advantage
|
|
||||||
|
|
||||||
The game design analysis (see `docs/CAPTURE THE FLAG.pdf`) shows that asymmetric movement creates interesting strategic depth:
|
|
||||||
- Defense moving diagonally covers more ground (Chebyshev distance)
|
|
||||||
- Offense moving orthogonally is predictable (Manhattan distance)
|
|
||||||
- With 75% defense speed nerf, this becomes a balanced mixed-strategy game
|
|
||||||
|
|
||||||
## Proposed Solution
|
|
||||||
|
|
||||||
Build a 20x50 grid-based board with three zones and alternating turn-based gameplay.
|
|
||||||
|
|
||||||
### Board Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────────────┐
|
|
||||||
│ TEAM B DEFENSE │ Y: 30-49
|
|
||||||
│ (Team A Offense) │ (20 rows)
|
|
||||||
│ [Diagonal Movement] │
|
|
||||||
│ 🚩 Flag B │
|
|
||||||
├────────────────────────────────────────────────────────┤
|
|
||||||
│ NEUTRAL ZONE │ Y: 20-29
|
|
||||||
│ (Both teams on offense) │ (10 rows)
|
|
||||||
│ [Orthogonal Movement] │
|
|
||||||
├────────────────────────────────────────────────────────┤
|
|
||||||
│ TEAM A DEFENSE │ Y: 0-19
|
|
||||||
│ (Team B Offense) │ (20 rows)
|
|
||||||
│ [Diagonal Movement] │
|
|
||||||
│ 🚩 Flag A │
|
|
||||||
└────────────────────────────────────────────────────────┘
|
|
||||||
X: 0 ←────────── 20 cells ──────────→ 19
|
|
||||||
```
|
|
||||||
|
|
||||||
### Movement Rules
|
|
||||||
|
|
||||||
| Zone (for piece) | Movement Type | Speed |
|
|
||||||
|------------------|---------------|-------|
|
|
||||||
| Own Defense | Diagonal (8 directions) | 75% (skip every 4th turn) |
|
|
||||||
| Enemy Defense (Offense) | Orthogonal (4 directions: N/S/E/W) | 100% |
|
|
||||||
| Neutral | Orthogonal (4 directions) | 100% |
|
|
||||||
|
|
||||||
### Turn Flow (Alternating)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ PLAYER TURN │ Click unit → click destination → unit moves
|
|
||||||
└────────┬────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ RESOLVE │ Check collision, flag pickup, scoring
|
|
||||||
└────────┬────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ AI TURN │ AI selects and executes move
|
|
||||||
└────────┬────────┘
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ RESOLVE │ Check collision, flag pickup, scoring
|
|
||||||
└────────┬────────┘
|
|
||||||
▼
|
|
||||||
Next turn
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Start with alternating turns for simplicity. Simultaneous turns can be added later if alternating feels too simple.
|
|
||||||
|
|
||||||
## Technical Approach
|
|
||||||
|
|
||||||
### Architecture (3 Classes)
|
|
||||||
|
|
||||||
```
|
|
||||||
Assets/Scripts/
|
|
||||||
├── Grid/
|
|
||||||
│ ├── GridBoard.cs # Board state, zones, rendering, input, turn logic
|
|
||||||
│ ├── GridUnit.cs # Unit data struct
|
|
||||||
│ └── GridAI.cs # AI decision-making (Phase 3)
|
|
||||||
├── Game.cs # Bootstrap, update for grid
|
|
||||||
├── Flag.cs # Adapt for grid coordinates
|
|
||||||
└── CameraController.cs # Keep as-is
|
|
||||||
```
|
|
||||||
|
|
||||||
### Zone Boundaries (Constants)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public static class ZoneBoundaries
|
|
||||||
{
|
|
||||||
public const int TeamADefenseEnd = 20; // Y < 20 is Team A defense
|
|
||||||
public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamADefenseEnd)
|
|
||||||
public const int BoardWidth = 20;
|
|
||||||
public const int BoardHeight = 50;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Structures
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public enum Team { Blue, Red } // Canonical names used everywhere
|
|
||||||
|
|
||||||
public enum ZoneOwner { Blue, Neutral, Red }
|
|
||||||
|
|
||||||
// GridUnit.cs - minimal data struct
|
|
||||||
public class GridUnit
|
|
||||||
{
|
|
||||||
public int UnitId; // Stable identifier for determinism
|
|
||||||
public Team Team;
|
|
||||||
public Vector2Int GridPosition;
|
|
||||||
public int ConsecutiveDefenseMoves; // Reset when leaving defense zone
|
|
||||||
public bool IsTaggedOut;
|
|
||||||
public int RespawnTurnsRemaining; // 0 when active
|
|
||||||
// Note: HasFlag NOT stored here - query Flag.CarriedBy instead
|
|
||||||
}
|
|
||||||
|
|
||||||
// GridBoard.cs - all game logic in one place
|
|
||||||
public class GridBoard : MonoBehaviour
|
|
||||||
{
|
|
||||||
// Board is a 2D array of unit references (null = empty)
|
|
||||||
GridUnit[,] cellOccupants = new GridUnit[BoardWidth, BoardHeight];
|
|
||||||
|
|
||||||
// Canonical position storage - single source of truth
|
|
||||||
Dictionary<GridUnit, Vector2Int> unitPositions = new();
|
|
||||||
|
|
||||||
public ZoneOwner GetZoneOwner(int y) => y switch
|
|
||||||
{
|
|
||||||
< ZoneBoundaries.TeamADefenseEnd => ZoneOwner.Blue,
|
|
||||||
< ZoneBoundaries.NeutralEnd => ZoneOwner.Neutral,
|
|
||||||
_ => ZoneOwner.Red
|
|
||||||
};
|
|
||||||
|
|
||||||
public bool IsDefending(GridUnit unit) =>
|
|
||||||
(unit.Team == Team.Blue && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Blue) ||
|
|
||||||
(unit.Team == Team.Red && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Red);
|
|
||||||
|
|
||||||
// Atomic move that keeps both data structures in sync
|
|
||||||
public void MoveUnit(GridUnit unit, Vector2Int to)
|
|
||||||
{
|
|
||||||
var from = unitPositions[unit];
|
|
||||||
cellOccupants[from.x, from.y] = null;
|
|
||||||
cellOccupants[to.x, to.y] = unit;
|
|
||||||
unitPositions[unit] = to;
|
|
||||||
unit.GridPosition = to;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Collision Resolution (Deterministic)
|
|
||||||
|
|
||||||
Collisions are resolved deterministically by unit ID (lower ID wins ties):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
void ResolveCollision(GridUnit unitA, GridUnit unitB, Vector2Int cell)
|
|
||||||
{
|
|
||||||
// Same team = no collision (can share cell temporarily)
|
|
||||||
if (unitA.Team == unitB.Team) return;
|
|
||||||
|
|
||||||
var zoneOwner = GetZoneOwner(cell.y);
|
|
||||||
|
|
||||||
// Defender in their zone always wins
|
|
||||||
if (unitA.Team == Team.Blue && zoneOwner == ZoneOwner.Blue)
|
|
||||||
TagOut(unitB);
|
|
||||||
else if (unitB.Team == Team.Blue && zoneOwner == ZoneOwner.Blue)
|
|
||||||
TagOut(unitA);
|
|
||||||
else if (unitA.Team == Team.Red && zoneOwner == ZoneOwner.Red)
|
|
||||||
TagOut(unitB);
|
|
||||||
else if (unitB.Team == Team.Red && zoneOwner == ZoneOwner.Red)
|
|
||||||
TagOut(unitA);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Neutral zone: lower UnitId wins (deterministic)
|
|
||||||
if (unitA.UnitId < unitB.UnitId)
|
|
||||||
TagOut(unitB);
|
|
||||||
else
|
|
||||||
TagOut(unitA);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Turn Execution Order
|
|
||||||
|
|
||||||
Each turn resolves in this exact order:
|
|
||||||
|
|
||||||
1. **Speed nerf check** - If unit is defending and `ConsecutiveDefenseMoves % 4 == 3`, skip move
|
|
||||||
2. **Execute move** - Update position
|
|
||||||
3. **Collision check** - Tag losing unit if two enemies on same cell
|
|
||||||
4. **Flag pickup** - Unit on enemy flag cell picks it up
|
|
||||||
5. **Flag drop** - Tagged unit drops flag at current position
|
|
||||||
6. **Score check** - Flag carrier in own base scores
|
|
||||||
7. **Respawn tick** - Decrement `RespawnTurnsRemaining`, respawn if 0
|
|
||||||
8. **Update visibility** - Toggle enemy sprite visibility (Phase 2)
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Playable Grid
|
|
||||||
|
|
||||||
**Goal:** Two humans can play hot-seat CTF on a grid.
|
|
||||||
|
|
||||||
**Tasks:**
|
|
||||||
- [x] Create `GridBoard.cs`:
|
|
||||||
- Render 20x50 grid using existing `CreateSprite()` helper
|
|
||||||
- Color zones (blue tint for Blue defense, gray for neutral, red tint for Red defense)
|
|
||||||
- Handle mouse input (click unit, click destination)
|
|
||||||
- Implement `GetValidMoves()` with zone-aware movement
|
|
||||||
- Implement `MoveUnit()` with atomic position update
|
|
||||||
- Implement collision resolution
|
|
||||||
- [x] Create `GridUnit.cs` as minimal data struct
|
|
||||||
- [x] Adapt `Flag.cs` for grid coordinates:
|
|
||||||
- Flag pickup on entering cell
|
|
||||||
- Flag drop on tag (stays at cell)
|
|
||||||
- Flag return when friendly touches it
|
|
||||||
- Score when carrier reaches own base
|
|
||||||
- [x] Modify `Game.cs` to instantiate `GridBoard` instead of current map
|
|
||||||
- [x] Alternating turns: `bool isPlayerTurn`, swap after each move
|
|
||||||
- [x] Win at 3 points, use existing `gameOverText`
|
|
||||||
|
|
||||||
**Files to create:**
|
|
||||||
- `Assets/Scripts/Grid/GridBoard.cs`
|
|
||||||
- `Assets/Scripts/Grid/GridUnit.cs`
|
|
||||||
|
|
||||||
**Files to modify:**
|
|
||||||
- `Assets/Scripts/Game.cs`
|
|
||||||
- `Assets/Scripts/Flag.cs`
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
```
|
|
||||||
Run game → See colored grid with 6 pieces and 2 flags
|
|
||||||
Click unit → Valid moves highlight (4 or 8 based on zone)
|
|
||||||
Click valid cell → Unit moves, turn swaps
|
|
||||||
Move to enemy flag → Pick up flag
|
|
||||||
Return to base with flag → Score point
|
|
||||||
Move onto enemy in your zone → Enemy respawns
|
|
||||||
First to 3 → "You Win" / "You Lose"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Asymmetric Mechanics
|
|
||||||
|
|
||||||
**Goal:** Add the mechanics that create strategic depth.
|
|
||||||
|
|
||||||
**Tasks:**
|
|
||||||
- [x] Add fog of war:
|
|
||||||
- Each unit sees 3 cells (Chebyshev distance)
|
|
||||||
- Toggle enemy sprite `enabled` based on visibility
|
|
||||||
- Recalculate after each move
|
|
||||||
- [x] Add defense speed nerf:
|
|
||||||
- Track `ConsecutiveDefenseMoves` on GridUnit
|
|
||||||
- Skip every 4th move when defending
|
|
||||||
- Reset counter when leaving defense zone
|
|
||||||
- Visual indicator (dim sprite) when next move will be skipped
|
|
||||||
|
|
||||||
**Fog visibility (method in GridBoard):**
|
|
||||||
```csharp
|
|
||||||
HashSet<Vector2Int> visibleToBlue = new();
|
|
||||||
|
|
||||||
void RecalculateVisibility()
|
|
||||||
{
|
|
||||||
visibleToBlue.Clear();
|
|
||||||
foreach (var unit in blueUnits)
|
|
||||||
{
|
|
||||||
if (unit.IsTaggedOut) continue;
|
|
||||||
for (int dx = -3; dx <= 3; dx++)
|
|
||||||
for (int dy = -3; dy <= 3; dy++)
|
|
||||||
{
|
|
||||||
var cell = unit.GridPosition + new Vector2Int(dx, dy);
|
|
||||||
if (IsInBounds(cell)) visibleToBlue.Add(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var enemy in redUnits)
|
|
||||||
enemy.Sprite.enabled = visibleToBlue.Contains(enemy.GridPosition);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
```
|
|
||||||
Start game → Red units partially hidden
|
|
||||||
Move Blue unit → Fog reveals new cells
|
|
||||||
Defender moves 3 times → 4th move skipped (unit dims beforehand)
|
|
||||||
Defender leaves zone → Counter resets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: AI Opponent
|
|
||||||
|
|
||||||
**Goal:** Single-player mode.
|
|
||||||
|
|
||||||
**Tasks:**
|
|
||||||
- [x] Create `GridAI.cs`:
|
|
||||||
- AI respects fog (only sees what its units see)
|
|
||||||
- Simple strategy: chase visible flag carrier, else advance toward flag
|
|
||||||
- AI takes turn after player
|
|
||||||
- [x] Delete `Assets/Scripts/SimpleAI.cs`
|
|
||||||
|
|
||||||
**AI decision (single difficulty):**
|
|
||||||
```csharp
|
|
||||||
Vector2Int? DecideMove(GridUnit unit)
|
|
||||||
{
|
|
||||||
var validMoves = board.GetValidMoves(unit);
|
|
||||||
if (validMoves.Count == 0) return null;
|
|
||||||
|
|
||||||
// Priority 1: Chase visible flag carrier
|
|
||||||
var carrier = GetVisibleEnemyFlagCarrier();
|
|
||||||
if (carrier != null)
|
|
||||||
return validMoves.OrderBy(m => Distance(m, carrier.GridPosition)).First();
|
|
||||||
|
|
||||||
// Priority 2: Advance toward enemy flag
|
|
||||||
var flagPos = enemyFlag.GridPosition;
|
|
||||||
return validMoves.OrderBy(m => Distance(m, flagPos)).First();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Files to create:**
|
|
||||||
- `Assets/Scripts/Grid/GridAI.cs`
|
|
||||||
|
|
||||||
**Files to delete:**
|
|
||||||
- `Assets/Scripts/SimpleAI.cs`
|
|
||||||
|
|
||||||
**Verification:**
|
|
||||||
```
|
|
||||||
Start game → AI takes turns after player
|
|
||||||
AI chases if it sees flag carrier
|
|
||||||
AI advances toward flag otherwise
|
|
||||||
AI doesn't react to units outside fog
|
|
||||||
Full game completes without errors
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deferred (Not MVP)
|
|
||||||
|
|
||||||
These features are explicitly deferred until the core loop is validated:
|
|
||||||
|
|
||||||
- Simultaneous turns (adds: planned moves storage, submit button, ghost indicators, conflict resolution)
|
|
||||||
- Turn timer
|
|
||||||
- Sound effects
|
|
||||||
- Camera auto-zoom
|
|
||||||
- Zone labels
|
|
||||||
- Win/lose screen (use `Debug.Log` or existing `gameOverText`)
|
|
||||||
- Multiple AI difficulties
|
|
||||||
- Respawn delay (use instant respawn)
|
|
||||||
- Flag auto-return timer (flags stay where dropped)
|
|
||||||
|
|
||||||
## Edge Cases to Handle
|
|
||||||
|
|
||||||
| Edge Case | Resolution |
|
|
||||||
|-----------|------------|
|
|
||||||
| Three+ units on same cell | Process collisions pairwise by UnitId order |
|
|
||||||
| Zone crossing during move | Speed nerf based on starting position |
|
|
||||||
| Flag dropped in neutral | Can be "returned" by either team touching it |
|
|
||||||
| Both carriers tagged same turn | Both flags drop (alternating turns makes this impossible) |
|
|
||||||
| Tagged while about to score | Tag resolves before score check |
|
|
||||||
| Skip turn with flag | Carrier stays in place, keeps flag |
|
|
||||||
| Respawn location occupied | Respawn at nearest empty cell in base |
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
### Functional (Phase 1 - MVP)
|
|
||||||
- [x] 20x50 grid renders with three colored zones
|
|
||||||
- [x] 6 pieces (3 per team) at starting positions
|
|
||||||
- [x] Orthogonal movement in offense zones, diagonal in defense
|
|
||||||
- [x] Alternating turns
|
|
||||||
- [x] Collision: defender wins in their zone
|
|
||||||
- [x] Flags: pickup, drop on tag, return on touch, score on base
|
|
||||||
- [x] First to 3 wins
|
|
||||||
|
|
||||||
### Functional (Phase 2)
|
|
||||||
- [x] 3-cell vision radius per unit
|
|
||||||
- [x] Enemy units hidden outside fog
|
|
||||||
- [x] Defenders skip every 4th move
|
|
||||||
|
|
||||||
### Functional (Phase 3)
|
|
||||||
- [x] AI plays Red team
|
|
||||||
- [x] AI respects fog
|
|
||||||
- [x] Full game completes
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
1. **Playable:** Full game loop from start to victory works
|
|
||||||
2. **Fun check:** Does asymmetric movement feel strategically interesting?
|
|
||||||
|
|
||||||
Balance testing and engagement metrics deferred until core loop is validated.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Brainstorm: `docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md`
|
|
||||||
- Game design PDF: `docs/CAPTURE THE FLAG.pdf`
|
|
||||||
- Current movement: `Assets/Scripts/Unit.cs:42`
|
|
||||||
- Bootstrap pattern: `Assets/Scripts/Game.cs:15`
|
|
||||||
Reference in New Issue
Block a user