Compare commits
2 Commits
0c43bd1c19
...
feat/asymm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ac24f989 | ||
| 0de174eb1a |
8
Backyard CTF/Assets/Scripts.meta
Normal file
8
Backyard CTF/Assets/Scripts.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d761750f862c443dc8458191d85f0d4d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
168
Backyard CTF/Assets/Scripts/CameraController.cs
Normal file
168
Backyard CTF/Assets/Scripts/CameraController.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
public class CameraController : MonoBehaviour
|
||||
{
|
||||
// Zoom settings
|
||||
public float minZoom = 5f;
|
||||
public float maxZoom = 35f;
|
||||
public float zoomSpeed = 2f;
|
||||
public float pinchZoomSpeed = 0.1f;
|
||||
|
||||
// Pan settings
|
||||
public float panSpeed = 1f;
|
||||
|
||||
// Map bounds (grid-based)
|
||||
float mapWidth = ZoneBoundaries.BoardWidth;
|
||||
float mapHeight = ZoneBoundaries.BoardHeight;
|
||||
|
||||
Camera cam;
|
||||
Vector2 lastPanPosition;
|
||||
bool isPanning;
|
||||
float lastPinchDistance;
|
||||
bool isPinching;
|
||||
|
||||
void Start()
|
||||
{
|
||||
cam = Camera.main;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
HandleMouseInput();
|
||||
HandleTouchInput();
|
||||
ClampCameraPosition();
|
||||
}
|
||||
|
||||
void HandleMouseInput()
|
||||
{
|
||||
var mouse = Mouse.current;
|
||||
if (mouse == null) return;
|
||||
|
||||
// Scroll wheel zoom
|
||||
float scroll = mouse.scroll.ReadValue().y;
|
||||
if (Mathf.Abs(scroll) > 0.01f)
|
||||
{
|
||||
Zoom(-scroll * zoomSpeed * Time.deltaTime * 10f);
|
||||
}
|
||||
|
||||
// Right-click pan (left-click is for unit selection)
|
||||
if (mouse.rightButton.wasPressedThisFrame)
|
||||
{
|
||||
lastPanPosition = mouse.position.ReadValue();
|
||||
isPanning = true;
|
||||
}
|
||||
else if (mouse.rightButton.wasReleasedThisFrame)
|
||||
{
|
||||
isPanning = false;
|
||||
}
|
||||
|
||||
if (isPanning && mouse.rightButton.isPressed)
|
||||
{
|
||||
Vector2 currentPos = mouse.position.ReadValue();
|
||||
Vector2 delta = currentPos - lastPanPosition;
|
||||
Pan(-delta);
|
||||
lastPanPosition = currentPos;
|
||||
}
|
||||
}
|
||||
|
||||
void HandleTouchInput()
|
||||
{
|
||||
var touch = Touchscreen.current;
|
||||
if (touch == null) return;
|
||||
|
||||
int touchCount = 0;
|
||||
foreach (var t in touch.touches)
|
||||
{
|
||||
if (t.press.isPressed) touchCount++;
|
||||
}
|
||||
|
||||
if (touchCount == 2)
|
||||
{
|
||||
// Two finger pinch zoom and pan
|
||||
var touch0 = touch.touches[0];
|
||||
var touch1 = touch.touches[1];
|
||||
|
||||
Vector2 pos0 = touch0.position.ReadValue();
|
||||
Vector2 pos1 = touch1.position.ReadValue();
|
||||
float currentDistance = Vector2.Distance(pos0, pos1);
|
||||
|
||||
if (!isPinching)
|
||||
{
|
||||
isPinching = true;
|
||||
lastPinchDistance = currentDistance;
|
||||
lastPanPosition = (pos0 + pos1) / 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pinch zoom
|
||||
float deltaDistance = lastPinchDistance - currentDistance;
|
||||
Zoom(deltaDistance * pinchZoomSpeed);
|
||||
lastPinchDistance = currentDistance;
|
||||
|
||||
// Two-finger pan
|
||||
Vector2 currentCenter = (pos0 + pos1) / 2f;
|
||||
Vector2 delta = currentCenter - lastPanPosition;
|
||||
Pan(-delta);
|
||||
lastPanPosition = currentCenter;
|
||||
}
|
||||
|
||||
isPanning = false; // Don't single-finger pan while pinching
|
||||
}
|
||||
else if (touchCount == 1)
|
||||
{
|
||||
isPinching = false;
|
||||
|
||||
var primaryTouch = touch.primaryTouch;
|
||||
Vector2 touchPos = primaryTouch.position.ReadValue();
|
||||
|
||||
// Two-finger gestures only for pan on touch - single finger is for unit selection
|
||||
// Don't initiate pan on single touch
|
||||
}
|
||||
else
|
||||
{
|
||||
isPinching = false;
|
||||
isPanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
void Zoom(float delta)
|
||||
{
|
||||
float newSize = cam.orthographicSize + delta;
|
||||
cam.orthographicSize = Mathf.Clamp(newSize, minZoom, maxZoom);
|
||||
}
|
||||
|
||||
void Pan(Vector2 screenDelta)
|
||||
{
|
||||
// Convert screen delta to world delta
|
||||
float worldUnitsPerPixel = cam.orthographicSize * 2f / Screen.height;
|
||||
Vector3 worldDelta = new Vector3(
|
||||
screenDelta.x * worldUnitsPerPixel * panSpeed,
|
||||
screenDelta.y * worldUnitsPerPixel * panSpeed,
|
||||
0
|
||||
);
|
||||
|
||||
cam.transform.position += worldDelta;
|
||||
}
|
||||
|
||||
void ClampCameraPosition()
|
||||
{
|
||||
// Keep camera within map bounds (with some padding for zoom)
|
||||
float halfHeight = cam.orthographicSize;
|
||||
float halfWidth = halfHeight * cam.aspect;
|
||||
|
||||
float minX = -mapWidth / 2f + halfWidth;
|
||||
float maxX = mapWidth / 2f - halfWidth;
|
||||
float minY = -mapHeight / 2f + halfHeight;
|
||||
float maxY = mapHeight / 2f - halfHeight;
|
||||
|
||||
// Handle case where zoom is wider than map
|
||||
if (minX > maxX) minX = maxX = 0;
|
||||
if (minY > maxY) minY = maxY = 0;
|
||||
|
||||
Vector3 pos = cam.transform.position;
|
||||
pos.x = Mathf.Clamp(pos.x, minX, maxX);
|
||||
pos.y = Mathf.Clamp(pos.y, minY, maxY);
|
||||
cam.transform.position = pos;
|
||||
}
|
||||
}
|
||||
2
Backyard CTF/Assets/Scripts/CameraController.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/CameraController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a481d18ae14b94a098fb1659b13c68a5
|
||||
147
Backyard CTF/Assets/Scripts/Game.cs
Normal file
147
Backyard CTF/Assets/Scripts/Game.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
public class Game : MonoBehaviour
|
||||
{
|
||||
// Auto-bootstrap when game starts - no Editor setup needed
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Bootstrap()
|
||||
{
|
||||
if (Instance != null) return;
|
||||
|
||||
var gameGO = new GameObject("Game");
|
||||
gameGO.AddComponent<Game>();
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// CONFIGURATION
|
||||
// ===========================================
|
||||
|
||||
// Camera settings for grid view
|
||||
public const float CameraZoom = 28f; // Fits 20x50 grid
|
||||
public const float CameraMinZoom = 15f;
|
||||
public const float CameraMaxZoom = 35f;
|
||||
|
||||
// References
|
||||
public static Game Instance { get; private set; }
|
||||
|
||||
GridBoard gridBoard;
|
||||
TextMeshProUGUI scoreText;
|
||||
TextMeshProUGUI gameOverText;
|
||||
TextMeshProUGUI turnText;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
SetupCamera();
|
||||
CreateUI();
|
||||
CreateGridBoard();
|
||||
}
|
||||
|
||||
void SetupCamera()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
cam.orthographic = true;
|
||||
cam.orthographicSize = CameraZoom;
|
||||
cam.transform.position = new Vector3(0, 0, -10);
|
||||
cam.backgroundColor = new Color(0.1f, 0.1f, 0.15f);
|
||||
|
||||
// Add camera controller for zoom/pan
|
||||
var controller = cam.gameObject.AddComponent<CameraController>();
|
||||
controller.minZoom = CameraMinZoom;
|
||||
controller.maxZoom = CameraMaxZoom;
|
||||
}
|
||||
}
|
||||
|
||||
void CreateUI()
|
||||
{
|
||||
// Create Canvas
|
||||
var canvasGO = new GameObject("Canvas");
|
||||
var canvas = canvasGO.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvasGO.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
canvasGO.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
|
||||
// Score text (top center)
|
||||
var scoreGO = new GameObject("ScoreText");
|
||||
scoreGO.transform.SetParent(canvasGO.transform, false);
|
||||
scoreText = scoreGO.AddComponent<TextMeshProUGUI>();
|
||||
scoreText.text = "0 - 0";
|
||||
scoreText.fontSize = 48;
|
||||
scoreText.alignment = TextAlignmentOptions.Center;
|
||||
scoreText.color = Color.white;
|
||||
|
||||
var scoreRect = scoreGO.GetComponent<RectTransform>();
|
||||
scoreRect.anchorMin = new Vector2(0.5f, 1f);
|
||||
scoreRect.anchorMax = new Vector2(0.5f, 1f);
|
||||
scoreRect.pivot = new Vector2(0.5f, 1f);
|
||||
scoreRect.anchoredPosition = new Vector2(0, -20);
|
||||
scoreRect.sizeDelta = new Vector2(200, 60);
|
||||
|
||||
// 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)
|
||||
var gameOverGO = new GameObject("GameOverText");
|
||||
gameOverGO.transform.SetParent(canvasGO.transform, false);
|
||||
gameOverText = gameOverGO.AddComponent<TextMeshProUGUI>();
|
||||
gameOverText.text = "";
|
||||
gameOverText.fontSize = 72;
|
||||
gameOverText.alignment = TextAlignmentOptions.Center;
|
||||
gameOverText.color = Color.white;
|
||||
gameOverText.gameObject.SetActive(false);
|
||||
|
||||
var gameOverRect = gameOverGO.GetComponent<RectTransform>();
|
||||
gameOverRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.anchoredPosition = Vector2.zero;
|
||||
gameOverRect.sizeDelta = new Vector2(400, 100);
|
||||
}
|
||||
|
||||
void CreateGridBoard()
|
||||
{
|
||||
var boardGO = new GameObject("GridBoard");
|
||||
gridBoard = boardGO.AddComponent<GridBoard>();
|
||||
|
||||
// Pass UI references
|
||||
gridBoard.scoreText = scoreText;
|
||||
gridBoard.gameOverText = gameOverText;
|
||||
gridBoard.turnText = turnText;
|
||||
}
|
||||
}
|
||||
2
Backyard CTF/Assets/Scripts/Game.cs.meta
Normal file
2
Backyard CTF/Assets/Scripts/Game.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f62c6569cd5544a03b7d7d1cee7da3f6
|
||||
143
Backyard CTF/Assets/Scripts/Grid/GridAI.cs
Normal file
143
Backyard CTF/Assets/Scripts/Grid/GridAI.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
public class GridAI : MonoBehaviour
|
||||
{
|
||||
GridBoard board;
|
||||
Team aiTeam;
|
||||
float thinkDelay = 0.5f;
|
||||
float thinkTimer = 0f;
|
||||
bool hasMoved = false;
|
||||
|
||||
public void Initialize(GridBoard board, Team team)
|
||||
{
|
||||
this.board = board;
|
||||
this.aiTeam = team;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (board == null || board.IsGameOver()) return;
|
||||
if (board.GetCurrentTeam() != aiTeam)
|
||||
{
|
||||
hasMoved = false;
|
||||
thinkTimer = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMoved) return;
|
||||
|
||||
// Small delay to make AI moves visible
|
||||
thinkTimer += Time.deltaTime;
|
||||
if (thinkTimer < thinkDelay) return;
|
||||
|
||||
MakeMove();
|
||||
hasMoved = true;
|
||||
}
|
||||
|
||||
void MakeMove()
|
||||
{
|
||||
var units = board.GetUnits(aiTeam);
|
||||
var visibleCells = board.GetVisibleCells(aiTeam);
|
||||
|
||||
// Find a unit that can move
|
||||
GridUnit bestUnit = null;
|
||||
Vector2Int bestMove = default;
|
||||
float bestScore = float.MinValue;
|
||||
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (unit.IsTaggedOut) continue;
|
||||
|
||||
// Check speed nerf
|
||||
if (board.IsDefending(unit) && unit.ConsecutiveDefenseMoves % 4 == 3)
|
||||
{
|
||||
continue; // This unit must skip
|
||||
}
|
||||
|
||||
var validMoves = board.GetValidMoves(unit);
|
||||
if (validMoves.Count == 0) continue;
|
||||
|
||||
foreach (var move in validMoves)
|
||||
{
|
||||
float score = EvaluateMove(unit, move, visibleCells);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestUnit = unit;
|
||||
bestMove = move;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestUnit != null)
|
||||
{
|
||||
board.ExecuteAIMove(bestUnit, bestMove);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No valid moves, skip turn
|
||||
board.AISkipTurn();
|
||||
}
|
||||
}
|
||||
|
||||
float EvaluateMove(GridUnit unit, Vector2Int move, HashSet<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));
|
||||
}
|
||||
}
|
||||
905
Backyard CTF/Assets/Scripts/Grid/GridBoard.cs
Normal file
905
Backyard CTF/Assets/Scripts/Grid/GridBoard.cs
Normal file
@@ -0,0 +1,905 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
public enum ZoneOwner { Blue, Neutral, Red }
|
||||
|
||||
public static class ZoneBoundaries
|
||||
{
|
||||
public const int TeamBlueDefenseEnd = 20; // Y < 20 is Blue defense
|
||||
public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamBlueDefenseEnd)
|
||||
public const int BoardWidth = 20;
|
||||
public const int BoardHeight = 50;
|
||||
}
|
||||
|
||||
public class GridBoard : MonoBehaviour
|
||||
{
|
||||
// Configuration
|
||||
const float CellSize = 1f;
|
||||
const float CellPadding = 0.05f;
|
||||
const int UnitsPerTeam = 3;
|
||||
const int WinScore = 3;
|
||||
const int RespawnDelay = 2; // Turns until respawn
|
||||
const int VisionRadius = 3; // Chebyshev distance for fog of war
|
||||
|
||||
// Colors
|
||||
static readonly Color BlueZoneColor = new Color(0.2f, 0.3f, 0.6f, 0.4f);
|
||||
static readonly Color NeutralZoneColor = new Color(0.4f, 0.4f, 0.4f, 0.4f);
|
||||
static readonly Color RedZoneColor = new Color(0.6f, 0.2f, 0.2f, 0.4f);
|
||||
static readonly Color BlueUnitColor = new Color(0.3f, 0.5f, 1f);
|
||||
static readonly Color RedUnitColor = new Color(1f, 0.3f, 0.3f);
|
||||
static readonly Color ValidMoveColor = new Color(0.3f, 1f, 0.3f, 0.5f);
|
||||
static readonly Color SelectedColor = new Color(1f, 1f, 0.3f, 0.8f);
|
||||
static readonly Color FlagBlueColor = new Color(0.3f, 0.5f, 1f);
|
||||
static readonly Color FlagRedColor = new Color(1f, 0.3f, 0.3f);
|
||||
|
||||
// Board state
|
||||
GridUnit[,] cellOccupants;
|
||||
Dictionary<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;
|
||||
}
|
||||
}
|
||||
37
Backyard CTF/Assets/Scripts/Grid/GridUnit.cs
Normal file
37
Backyard CTF/Assets/Scripts/Grid/GridUnit.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using UnityEngine;
|
||||
|
||||
public enum Team { Blue, Red }
|
||||
|
||||
public class GridUnit
|
||||
{
|
||||
public int UnitId;
|
||||
public Team Team;
|
||||
public Vector2Int GridPosition;
|
||||
public int ConsecutiveDefenseMoves;
|
||||
public bool IsTaggedOut;
|
||||
public int RespawnTurnsRemaining;
|
||||
|
||||
// Visual representation
|
||||
public GameObject GameObject;
|
||||
public SpriteRenderer SpriteRenderer;
|
||||
|
||||
public GridUnit(int unitId, Team team, Vector2Int position, GameObject go)
|
||||
{
|
||||
UnitId = unitId;
|
||||
Team = team;
|
||||
GridPosition = position;
|
||||
ConsecutiveDefenseMoves = 0;
|
||||
IsTaggedOut = false;
|
||||
RespawnTurnsRemaining = 0;
|
||||
GameObject = go;
|
||||
SpriteRenderer = go.GetComponent<SpriteRenderer>();
|
||||
}
|
||||
|
||||
public void SetWorldPosition(Vector2 worldPos)
|
||||
{
|
||||
if (GameObject != null)
|
||||
{
|
||||
GameObject.transform.position = new Vector3(worldPos.x, worldPos.y, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ MonoBehaviour:
|
||||
m_ExplicitNullChecks: 0
|
||||
m_ExplicitDivideByZeroChecks: 0
|
||||
m_ExplicitArrayBoundsChecks: 0
|
||||
m_CompressionType: -1
|
||||
m_CompressionType: 0
|
||||
m_InstallInBuildFolder: 0
|
||||
m_InsightsSettingsContainer:
|
||||
m_BuildProfileEngineDiagnosticsState: 2
|
||||
|
||||
@@ -39,7 +39,7 @@ MonoBehaviour:
|
||||
m_ExplicitNullChecks: 0
|
||||
m_ExplicitDivideByZeroChecks: 0
|
||||
m_ExplicitArrayBoundsChecks: 0
|
||||
m_CompressionType: -1
|
||||
m_CompressionType: 0
|
||||
m_InstallInBuildFolder: 0
|
||||
m_InsightsSettingsContainer:
|
||||
m_BuildProfileEngineDiagnosticsState: 2
|
||||
|
||||
@@ -13,6 +13,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
shaderVariantLimit: 128
|
||||
overrideShaderVariantLimit: 0
|
||||
customInterpolatorErrorThreshold: 32
|
||||
customInterpolatorWarningThreshold: 16
|
||||
customHeatmapValues: {fileID: 0}
|
||||
|
||||
67
docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md
Normal file
67
docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
date: 2026-02-01
|
||||
topic: teaser-prototype
|
||||
---
|
||||
|
||||
# Teaser Prototype: Playable Game Core
|
||||
|
||||
## What We're Building
|
||||
|
||||
A minimal playable teaser that captures the "practiced chaos" of Neighborhood Quarterback - the Rocket League-style feeling where chaos has patterns you can learn to exploit.
|
||||
|
||||
**Scope:**
|
||||
- 1v1 vs AI opponent
|
||||
- 5 identical units per side
|
||||
- Draw-routes for commanding units
|
||||
- Tag-out respawn (captured units respawn at base after delay)
|
||||
- Simple fog of war (see near your units only)
|
||||
- Backyard-style asymmetric map with houses/fences
|
||||
- First to 3 points (points for flag grab AND returning flag to base)
|
||||
|
||||
## Why This Approach
|
||||
|
||||
We chose **Minimal Playable Core** over polished slice or systems-first approaches because:
|
||||
|
||||
1. **Validate the feel first** - The soul doc's "practiced chaos" needs playtesting to verify
|
||||
2. **Fast iteration** - Rough edges are fine if we can quickly change what matters
|
||||
3. **Avoid over-engineering** - Don't build robust systems for unvalidated design
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **AI opponent over multiplayer**: Simpler to build, can playtest alone, control pacing
|
||||
- **Draw routes over click-to-move**: More tactical, matches the "quarterback" command fantasy
|
||||
- **Tag-out over jail escort**: Simpler first pass; jail escort adds complexity we can add later
|
||||
- **Fog of war included**: Core to the mind game, worth the complexity
|
||||
- **5 units (not 3)**: Matches soul doc, enables interesting squad tactics
|
||||
- **All identical units**: No classes yet; focus on positioning/routes before differentiation
|
||||
- **Asymmetric map**: Thematic "backyard" feel even if harder to balance
|
||||
- **First to 3 with grab+return points**: Creates multiple scoring opportunities per round
|
||||
|
||||
## What Success Looks Like
|
||||
|
||||
When playing, you should see:
|
||||
- Moments where you read the AI's pattern and exploit it
|
||||
- Chaotic scrambles when plans collide
|
||||
- "Almost had it" flag runs that feel learnable
|
||||
- Fog reveal moments that create tension
|
||||
|
||||
## Out of Scope (For Now)
|
||||
|
||||
- Class differentiation (Sneak/Patrol/Speed)
|
||||
- Jail escort mechanics
|
||||
- Motion lights
|
||||
- Pre-phase setup (placing flag/jail)
|
||||
- Multiplayer networking
|
||||
- Polish/juice/animations
|
||||
|
||||
## Open Questions for Planning
|
||||
|
||||
1. **Map layout**: What's the minimum topology for interesting play? Lanes, chokepoints, shortcuts?
|
||||
2. **AI behavior**: How smart does AI need to be to create "practiced chaos"?
|
||||
3. **Route-drawing UX**: Click-drag? Waypoints? How to visualize planned route?
|
||||
4. **Fog implementation**: Tile-based? Raycast? Mesh-based reveal?
|
||||
5. **Scoring flow**: What happens after a point? Reset positions? Continuous play?
|
||||
|
||||
## Next Steps
|
||||
|
||||
Run `/workflows:plan` to break this down into implementation tasks.
|
||||
103
docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md
Normal file
103
docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Asymmetric Grid CTF Board
|
||||
|
||||
**Date:** 2026-02-04
|
||||
**Status:** Ready for planning
|
||||
|
||||
## What We're Building
|
||||
|
||||
A turn-based capture-the-flag game on a 20x50 grid with asymmetric movement mechanics. Each team has 3 pieces that move differently depending on which zone they're in:
|
||||
|
||||
- **Offensive zone (enemy territory):** Orthogonal movement only (Manhattan distance)
|
||||
- **Defensive zone (home territory):** Diagonal movement only (Chebyshev distance)
|
||||
- **Neutral zone (center):** Both teams move orthogonally (on offense)
|
||||
|
||||
The board is mirrored: Team A's defensive zone is Team B's offensive zone, and vice versa.
|
||||
|
||||
### Core Mechanics
|
||||
|
||||
1. **Simultaneous turns:** Both teams plan moves secretly, then execute at the same time
|
||||
2. **Fog of war:** Each piece sees 3 cells in any direction; the rest is hidden
|
||||
3. **Collision resolution:** Defender wins ties (piece in their defensive zone captures invader)
|
||||
4. **Defense speed nerf:** Defensive movement is 75% speed (skip every 4th move in defense zone)
|
||||
5. **Victory condition:** Capture enemy flag from their base and return it to your base
|
||||
|
||||
## Why This Approach
|
||||
|
||||
### Pure Grid Replacement over Hybrid
|
||||
|
||||
The PDF's game theory analysis is fundamentally about discrete move counts (Manhattan vs Chebyshev distance). Free-form movement with grid constraints would:
|
||||
- Complicate collision detection
|
||||
- Make fog of war harder to compute
|
||||
- Obscure the strategic depth the grid creates
|
||||
|
||||
A clean grid system directly implements the analyzed mechanics.
|
||||
|
||||
### Simultaneous Turns over Turn-Based
|
||||
|
||||
Simultaneous planning creates the "mixed-strategy game" described in the PDF. If turns were sequential, the reactive player always has perfect information. Simultaneous moves mean:
|
||||
- Offense can commit to a direction without the defense knowing
|
||||
- Both teams must predict opponent behavior
|
||||
- Creates bluffing and misdirection opportunities
|
||||
|
||||
### Visible Grid
|
||||
|
||||
Players need to understand their movement options. The orthogonal green squares and diagonal red squares from the PDF communicate which directions are legal at a glance.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Board size | 20x50 | Wide enough for 3 pieces per side with meaningful positioning |
|
||||
| Zone proportions | 20x20 defense, 10x10 neutral, 20x20 defense | Small neutral = more defensive play per user request |
|
||||
| Movement per turn | 1 cell | Matches PDF analysis; multi-cell would change game theory |
|
||||
| Vision radius | 3 cells | Creates meaningful information asymmetry without total blindness |
|
||||
| Defense speed | 75% (3 moves per 4 turns) | PDF analysis shows this creates balanced mixed-strategy game |
|
||||
| Collision rule | Defender wins | Rewards positioning in your territory |
|
||||
| Flag location | Back of defensive zone | Classic CTF setup |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **How to visualize fog of war?** Options: darken hidden cells, hide them entirely, show "last known" positions
|
||||
2. **What happens to flag carrier if tagged?** Drop flag? Flag returns to base?
|
||||
3. **Respawn mechanics?** Where do tagged pieces respawn? How long until they can act?
|
||||
4. **Turn timer?** Unlimited planning time or forced time limit?
|
||||
5. **AI opponent?** Should we build AI for single-player, or multiplayer-only initially?
|
||||
|
||||
## Grid Visual Reference
|
||||
|
||||
The PDF shows a pattern where:
|
||||
- Green squares form an orthogonal grid (offense paths)
|
||||
- Red diagonal lines overlay, creating larger diamond-shaped cells (defense paths)
|
||||
- The two grids intersect, meaning some cells are reachable by both movement types
|
||||
|
||||
For implementation, we need to define:
|
||||
- Cell size in world units
|
||||
- How to render the dual-grid overlay
|
||||
- Visual distinction between zones (Team A defense, neutral, Team B defense)
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Current Architecture Impact
|
||||
|
||||
The existing `Game.cs`, `Unit.cs`, and `RouteDrawer.cs` will need significant changes:
|
||||
- Replace `RouteDrawer` path drawing with click-to-select, click-to-move
|
||||
- Replace continuous movement in `Unit.cs` with discrete grid steps
|
||||
- Add turn manager for simultaneous move resolution
|
||||
- Add fog of war system (current `Visibility.cs` is radius-based, needs grid conversion)
|
||||
|
||||
### New Components Needed
|
||||
|
||||
1. **GridBoard** - Manages 20x50 cell array, zone definitions, visual rendering
|
||||
2. **TurnManager** - Handles move planning phase, simultaneous execution, turn counting
|
||||
3. **GridMovement** - Validates moves based on zone type, handles defense speed nerf
|
||||
4. **FogOfWar** - Computes visible cells per team, hides/reveals pieces
|
||||
5. **CollisionResolver** - Determines outcomes when pieces occupy same cell
|
||||
|
||||
## Next Steps
|
||||
|
||||
Run `/workflows:plan` to create implementation plan addressing:
|
||||
1. Grid data structure and rendering
|
||||
2. Turn system and move planning UI
|
||||
3. Zone-based movement validation
|
||||
4. Fog of war implementation
|
||||
5. Collision and capture mechanics
|
||||
@@ -254,18 +254,18 @@ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Unity 6 LTS project opens without errors
|
||||
- [ ] URP 2D Renderer is active (check Graphics settings)
|
||||
- [ ] New Input System is the active input handling mode
|
||||
- [ ] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`)
|
||||
- [ ] `GameInputActions` asset exists with placeholder actions
|
||||
- [ ] Android build target configured (IL2CPP, ARM64, API 24+)
|
||||
- [ ] iOS build target configured (IL2CPP, ARM64, iOS 13+)
|
||||
- [ ] Desktop build targets configured
|
||||
- [ ] Main.unity scene has Global Light 2D with low intensity
|
||||
- [ ] `.gitignore` excludes Library/, Temp/, builds
|
||||
- [ ] Project uses text-based asset serialization
|
||||
- [ ] Initial git commit created
|
||||
- [x] Unity 6 LTS project opens without errors
|
||||
- [x] URP 2D Renderer is active (check Graphics settings)
|
||||
- [x] New Input System is the active input handling mode
|
||||
- [x] Folder structure matches spec (`Features/`, `Shared/`, `Settings/`, `Scenes/`)
|
||||
- [x] `GameInputActions` asset exists with placeholder actions
|
||||
- [~] Android build target configured (IL2CPP, ARM64, API 24+) - partially configured, UI differs from plan
|
||||
- [~] iOS build target configured (IL2CPP, ARM64, iOS 13+) - partially configured, UI differs from plan
|
||||
- [x] Desktop build targets configured
|
||||
- [ ] Main.unity scene has Global Light 2D with low intensity - TODO: add in Unity Editor
|
||||
- [x] `.gitignore` excludes Library/, Temp/, builds
|
||||
- [x] Project uses text-based asset serialization
|
||||
- [x] Initial git commit created
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
title: "feat: Teaser Prototype Playable Core"
|
||||
type: feat
|
||||
date: 2026-02-01
|
||||
revised: 2026-02-01
|
||||
---
|
||||
|
||||
# Teaser Prototype: Playable Core (Simplified)
|
||||
|
||||
## Overview
|
||||
|
||||
Build a minimal playable 1v1 capture-the-flag teaser that captures the "practiced chaos" of Neighborhood Quarterback - where chaos has patterns you can learn to exploit, like Rocket League.
|
||||
|
||||
**Target experience:** Fast rounds with flag grabs, chases, tag-outs, and scrambles. Players should feel "I almost had it" and "I can learn this."
|
||||
|
||||
## Guiding Principle
|
||||
|
||||
**Build the skateboard, not the car chassis.** Get something playable in days, not weeks. Polish comes after validating the core loop is fun.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
6 scripts, 3 phases, ~15 tasks:
|
||||
|
||||
```
|
||||
Assets/Scripts/
|
||||
├── Game.cs # Score, reset, win, spawn
|
||||
├── Unit.cs # Movement, state, respawn, flag carrying
|
||||
├── RouteDrawer.cs # Click-drag to draw routes
|
||||
├── Flag.cs # Pickup, drop, return
|
||||
├── Visibility.cs # Simple sprite show/hide (no shaders)
|
||||
└── SimpleAI.cs # Chase flag or flag carrier
|
||||
```
|
||||
|
||||
No feature folders for MVP. No managers. Refactor when needed.
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Pathfinding | None | Draw route, unit follows, stops at obstacles |
|
||||
| Fog of War | Sprite SetActive | Hide enemies outside vision radius. No shaders. |
|
||||
| State | Bools/enums on scripts | No state machine frameworks |
|
||||
| AI | One behavior | Chase player flag (or flag carrier) |
|
||||
| Events | Direct method calls | No event bus for 6 scripts |
|
||||
|
||||
## Constants (Hardcoded, Tune Later)
|
||||
|
||||
```csharp
|
||||
// In Game.cs - move to ScriptableObject if needed
|
||||
const float UnitSpeed = 5f;
|
||||
const float VisionRadius = 4f;
|
||||
const float TagRadius = 0.75f;
|
||||
const float RespawnDelay = 3f;
|
||||
const float FlagReturnDelay = 5f;
|
||||
const int WinScore = 3;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Movement & Map (3-4 days)
|
||||
|
||||
**Goal:** Draw routes, units follow them, obstacles block.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **1.1** Create placeholder art in Main.unity:
|
||||
- Green plane (ground, ~40x30 units)
|
||||
- Gray rectangles (4-6 houses as obstacles with BoxCollider2D)
|
||||
- Colored circles (units - blue team, red team)
|
||||
- Two base zones (opposite corners)
|
||||
|
||||
- [x] **1.2** Create `Unit.cs`:
|
||||
```csharp
|
||||
public class Unit : MonoBehaviour
|
||||
{
|
||||
public enum Team { Player, Enemy }
|
||||
public Team team;
|
||||
public bool isTaggedOut;
|
||||
public bool hasFlag;
|
||||
|
||||
List<Vector2> route;
|
||||
int routeIndex;
|
||||
|
||||
public void SetRoute(List<Vector2> waypoints) { ... }
|
||||
void Update() { /* follow route, stop at end */ }
|
||||
public void TagOut() { /* disable, start respawn coroutine */ }
|
||||
IEnumerator Respawn() { /* wait 3s, teleport to base, enable */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **1.3** Create `RouteDrawer.cs`:
|
||||
- On mouse down over player unit: start route
|
||||
- While dragging: collect points, draw LineRenderer preview
|
||||
- On mouse up: call `unit.SetRoute(points)`
|
||||
- Clear line after route applied
|
||||
|
||||
- [x] **1.4** Create `Game.cs` (partial):
|
||||
```csharp
|
||||
public class Game : MonoBehaviour
|
||||
{
|
||||
public Unit[] playerUnits; // Assign in inspector
|
||||
public Unit[] enemyUnits;
|
||||
public Transform playerBase;
|
||||
public Transform enemyBase;
|
||||
|
||||
void Start() { SpawnUnits(); }
|
||||
void SpawnUnits() { /* position 5 units at each base */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **1.5** Wire up scene:
|
||||
- Create 5 Unit prefabs per team
|
||||
- Add colliders to obstacles
|
||||
- Test: draw route, unit follows, stops at obstacle
|
||||
|
||||
**Verification:**
|
||||
- [ ] Can draw route on player unit, unit follows
|
||||
- [ ] Unit stops when hitting obstacle
|
||||
- [ ] Unit stops at end of route
|
||||
- [ ] New route replaces old route
|
||||
- [ ] Enemy units visible but not controllable
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Flag, Tagging, Scoring (2-3 days)
|
||||
|
||||
**Goal:** Grab flag, tag enemies, score points, win.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **2.1** Create `Flag.cs`:
|
||||
```csharp
|
||||
public class Flag : MonoBehaviour
|
||||
{
|
||||
public Unit.Team team;
|
||||
public Transform homePosition;
|
||||
public Unit carriedBy;
|
||||
float dropTimer;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (carriedBy != null)
|
||||
transform.position = carriedBy.transform.position;
|
||||
else if (transform.position != homePosition.position)
|
||||
HandleDroppedState();
|
||||
}
|
||||
|
||||
public void Pickup(Unit unit) { carriedBy = unit; unit.hasFlag = true; }
|
||||
public void Drop() { carriedBy.hasFlag = false; carriedBy = null; dropTimer = 5f; }
|
||||
void ReturnHome() { transform.position = homePosition.position; }
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **2.2** Add flag pickup detection:
|
||||
- OnTriggerEnter2D: if enemy unit enters flag trigger, Pickup()
|
||||
- In `Game.cs`: when unit with flag enters own base, Score()
|
||||
|
||||
- [x] **2.3** Add tagging to `Unit.cs`:
|
||||
- OnTriggerEnter2D: if enemy unit overlaps
|
||||
- Determine loser: farther from own base gets tagged
|
||||
- (Or for more chaos: both get tagged)
|
||||
- If tagged unit has flag, call flag.Drop()
|
||||
|
||||
- [x] **2.4** Add scoring to `Game.cs`:
|
||||
```csharp
|
||||
int playerScore, enemyScore;
|
||||
|
||||
public void Score(Unit.Team team)
|
||||
{
|
||||
if (team == Unit.Team.Player) playerScore++;
|
||||
else enemyScore++;
|
||||
|
||||
Debug.Log($"Score: {playerScore} - {enemyScore}");
|
||||
|
||||
if (playerScore >= WinScore || enemyScore >= WinScore)
|
||||
EndGame();
|
||||
else
|
||||
ResetRound();
|
||||
}
|
||||
|
||||
void ResetRound()
|
||||
{
|
||||
// Return flags, respawn all units at bases
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **2.5** Add simple UI:
|
||||
- TextMeshPro showing score
|
||||
- "YOU WIN" / "YOU LOSE" text on game end
|
||||
- (No menu - press Play in editor)
|
||||
|
||||
**Verification:**
|
||||
- [ ] Walking over enemy flag picks it up
|
||||
- [ ] Flag follows carrier
|
||||
- [ ] Reaching base with flag scores point
|
||||
- [ ] Overlapping enemy triggers tag-out
|
||||
- [ ] Tagged unit respawns after 3 seconds
|
||||
- [ ] Dropped flag returns home after 5 seconds
|
||||
- [ ] First to 3 wins
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Visibility & AI (2-3 days)
|
||||
|
||||
**Goal:** Can't see enemies outside vision range. AI provides opposition.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **3.1** Create `Visibility.cs`:
|
||||
```csharp
|
||||
public class Visibility : MonoBehaviour
|
||||
{
|
||||
public Unit[] playerUnits;
|
||||
public Unit[] enemyUnits;
|
||||
public Flag enemyFlag;
|
||||
|
||||
void Update()
|
||||
{
|
||||
foreach (var enemy in enemyUnits)
|
||||
{
|
||||
bool visible = IsVisibleToAnyPlayerUnit(enemy.transform.position);
|
||||
enemy.GetComponent<SpriteRenderer>().enabled = visible;
|
||||
}
|
||||
|
||||
// Also hide enemy flag if not carried and not visible
|
||||
if (enemyFlag.carriedBy == null)
|
||||
enemyFlag.GetComponent<SpriteRenderer>().enabled =
|
||||
IsVisibleToAnyPlayerUnit(enemyFlag.transform.position);
|
||||
}
|
||||
|
||||
bool IsVisibleToAnyPlayerUnit(Vector2 pos)
|
||||
{
|
||||
foreach (var unit in playerUnits)
|
||||
{
|
||||
if (unit.isTaggedOut) continue;
|
||||
if (Vector2.Distance(unit.transform.position, pos) < VisionRadius)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **3.2** Create `SimpleAI.cs`:
|
||||
```csharp
|
||||
public class SimpleAI : MonoBehaviour
|
||||
{
|
||||
public Unit[] aiUnits;
|
||||
public Flag playerFlag;
|
||||
public Transform aiBase;
|
||||
float decisionTimer;
|
||||
|
||||
void Update()
|
||||
{
|
||||
decisionTimer -= Time.deltaTime;
|
||||
if (decisionTimer <= 0)
|
||||
{
|
||||
MakeDecisions();
|
||||
decisionTimer = 0.5f; // Decide every 0.5s
|
||||
}
|
||||
}
|
||||
|
||||
void MakeDecisions()
|
||||
{
|
||||
foreach (var unit in aiUnits)
|
||||
{
|
||||
if (unit.isTaggedOut) continue;
|
||||
|
||||
Vector2 target;
|
||||
if (unit.hasFlag)
|
||||
target = aiBase.position; // Return flag
|
||||
else if (playerFlag.carriedBy != null)
|
||||
target = playerFlag.carriedBy.transform.position; // Chase carrier
|
||||
else
|
||||
target = playerFlag.transform.position; // Go for flag
|
||||
|
||||
// Simple route: straight line to target
|
||||
unit.SetRoute(new List<Vector2> { target });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **3.3** Add slight route randomness:
|
||||
- Offset target by small random amount
|
||||
- Prevents all AI units clumping perfectly
|
||||
|
||||
- [x] **3.4** Playtest & tune:
|
||||
- Adjust VisionRadius, TagRadius, speeds
|
||||
- Make AI beatable but not trivial
|
||||
|
||||
**Verification:**
|
||||
- [ ] Enemy units hidden when far from player units
|
||||
- [ ] Enemies appear when player unit gets close
|
||||
- [ ] AI units move toward player flag
|
||||
- [ ] AI chases player flag carrier
|
||||
- [ ] AI returns flag to base when carrying
|
||||
- [ ] Can beat AI after 2-3 attempts
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (MVP)
|
||||
|
||||
- [ ] Game starts with 5 units per side at bases
|
||||
- [ ] Draw route on unit, unit follows path
|
||||
- [ ] Unit stops at obstacles
|
||||
- [ ] Grabbing enemy flag awards 1 point
|
||||
- [ ] Returning flag to base (with own flag present) awards 1 more point
|
||||
- [ ] Overlapping enemies triggers tag-out (farther from base loses)
|
||||
- [ ] Tagged units respawn at base after 3 seconds
|
||||
- [ ] Enemies only visible within vision radius of player units
|
||||
- [ ] AI controls enemy team, chases flag
|
||||
- [ ] First to 3 points wins
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (v2)
|
||||
|
||||
- Shader-based fog of war (smooth edges, feathering)
|
||||
- Unit classes (Sneak/Patrol/Speed)
|
||||
- Jail escort mechanics
|
||||
- Motion lights
|
||||
- Pre-phase setup
|
||||
- Multiplayer
|
||||
- AI with strategic roles
|
||||
- Route obstacle preview
|
||||
- Audio
|
||||
- Main menu
|
||||
- Mobile optimization
|
||||
|
||||
---
|
||||
|
||||
## What We're Testing
|
||||
|
||||
This prototype answers one question: **Is commanding units in CTF fun?**
|
||||
|
||||
If yes → Add fog polish, AI strategy, classes
|
||||
If no → Revisit core mechanics before adding complexity
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Soul Doc: `soul.md`
|
||||
- Brainstorm: `docs/brainstorms/2026-02-01-teaser-prototype-brainstorm.md`
|
||||
- Input actions: `Assets/Settings/Input/GameInputActions.inputactions`
|
||||
403
docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md
Normal file
403
docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
title: "feat: Asymmetric Grid CTF Board"
|
||||
type: feat
|
||||
date: 2026-02-04
|
||||
brainstorm: docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Asymmetric Grid CTF Board
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current real-time continuous movement CTF game with a turn-based grid system featuring asymmetric movement mechanics. Each team's 3 pieces move differently based on which zone they occupy: orthogonal (Manhattan) in offensive zones, diagonal (Chebyshev) in defensive zones.
|
||||
|
||||
**Key changes from current implementation:**
|
||||
- Continuous free-form movement → Discrete grid-based movement
|
||||
- Real-time gameplay → Alternating turn-based
|
||||
- Uniform movement → Zone-dependent movement rules
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current implementation is a real-time CTF game where:
|
||||
- Units move continuously along drawn paths
|
||||
- All units have the same movement capabilities
|
||||
- The defense has no inherent advantage
|
||||
|
||||
The game design analysis (see `docs/CAPTURE THE FLAG.pdf`) shows that asymmetric movement creates interesting strategic depth:
|
||||
- Defense moving diagonally covers more ground (Chebyshev distance)
|
||||
- Offense moving orthogonally is predictable (Manhattan distance)
|
||||
- With 75% defense speed nerf, this becomes a balanced mixed-strategy game
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Build a 20x50 grid-based board with three zones and alternating turn-based gameplay.
|
||||
|
||||
### Board Layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ TEAM B DEFENSE │ Y: 30-49
|
||||
│ (Team A Offense) │ (20 rows)
|
||||
│ [Diagonal Movement] │
|
||||
│ 🚩 Flag B │
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ NEUTRAL ZONE │ Y: 20-29
|
||||
│ (Both teams on offense) │ (10 rows)
|
||||
│ [Orthogonal Movement] │
|
||||
├────────────────────────────────────────────────────────┤
|
||||
│ TEAM A DEFENSE │ Y: 0-19
|
||||
│ (Team B Offense) │ (20 rows)
|
||||
│ [Diagonal Movement] │
|
||||
│ 🚩 Flag A │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
X: 0 ←────────── 20 cells ──────────→ 19
|
||||
```
|
||||
|
||||
### Movement Rules
|
||||
|
||||
| Zone (for piece) | Movement Type | Speed |
|
||||
|------------------|---------------|-------|
|
||||
| Own Defense | Diagonal (8 directions) | 75% (skip every 4th turn) |
|
||||
| Enemy Defense (Offense) | Orthogonal (4 directions: N/S/E/W) | 100% |
|
||||
| Neutral | Orthogonal (4 directions) | 100% |
|
||||
|
||||
### Turn Flow (Alternating)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ PLAYER TURN │ Click unit → click destination → unit moves
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RESOLVE │ Check collision, flag pickup, scoring
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ AI TURN │ AI selects and executes move
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ RESOLVE │ Check collision, flag pickup, scoring
|
||||
└────────┬────────┘
|
||||
▼
|
||||
Next turn
|
||||
```
|
||||
|
||||
**Note:** Start with alternating turns for simplicity. Simultaneous turns can be added later if alternating feels too simple.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture (3 Classes)
|
||||
|
||||
```
|
||||
Assets/Scripts/
|
||||
├── Grid/
|
||||
│ ├── GridBoard.cs # Board state, zones, rendering, input, turn logic
|
||||
│ ├── GridUnit.cs # Unit data struct
|
||||
│ └── GridAI.cs # AI decision-making (Phase 3)
|
||||
├── Game.cs # Bootstrap, update for grid
|
||||
├── Flag.cs # Adapt for grid coordinates
|
||||
└── CameraController.cs # Keep as-is
|
||||
```
|
||||
|
||||
### Zone Boundaries (Constants)
|
||||
|
||||
```csharp
|
||||
public static class ZoneBoundaries
|
||||
{
|
||||
public const int TeamADefenseEnd = 20; // Y < 20 is Team A defense
|
||||
public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamADefenseEnd)
|
||||
public const int BoardWidth = 20;
|
||||
public const int BoardHeight = 50;
|
||||
}
|
||||
```
|
||||
|
||||
### Data Structures
|
||||
|
||||
```csharp
|
||||
public enum Team { Blue, Red } // Canonical names used everywhere
|
||||
|
||||
public enum ZoneOwner { Blue, Neutral, Red }
|
||||
|
||||
// GridUnit.cs - minimal data struct
|
||||
public class GridUnit
|
||||
{
|
||||
public int UnitId; // Stable identifier for determinism
|
||||
public Team Team;
|
||||
public Vector2Int GridPosition;
|
||||
public int ConsecutiveDefenseMoves; // Reset when leaving defense zone
|
||||
public bool IsTaggedOut;
|
||||
public int RespawnTurnsRemaining; // 0 when active
|
||||
// Note: HasFlag NOT stored here - query Flag.CarriedBy instead
|
||||
}
|
||||
|
||||
// GridBoard.cs - all game logic in one place
|
||||
public class GridBoard : MonoBehaviour
|
||||
{
|
||||
// Board is a 2D array of unit references (null = empty)
|
||||
GridUnit[,] cellOccupants = new GridUnit[BoardWidth, BoardHeight];
|
||||
|
||||
// Canonical position storage - single source of truth
|
||||
Dictionary<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