feat(grid): Replace real-time CTF with turn-based grid system

Replace continuous free-form movement with discrete 20x50 grid-based
gameplay featuring asymmetric movement mechanics:

- Blue/Red teams with 3 units each
- Zone-based movement: orthogonal (4 dir) in offense, diagonal (8 dir) in defense
- Alternating turns with click-to-select, click-to-move input
- Fog of war: 3-cell Chebyshev vision radius per unit
- Defense speed nerf: skip every 4th move in own zone
- AI opponent that chases flag carriers and advances toward enemy flag
- Collision resolution: defender wins in their zone, lower ID wins in neutral

Implements all 3 phases from the plan:
- Phase 1: Playable grid with hot-seat two-player
- Phase 2: Fog of war + defense speed nerf
- Phase 3: AI opponent

Deleted obsolete files: Flag.cs, Unit.cs, RouteDrawer.cs, SimpleAI.cs, Visibility.cs

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John Lamb
2026-02-07 17:05:44 -06:00
parent 0de174eb1a
commit e4ac24f989
17 changed files with 1658 additions and 1092 deletions

View 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));
}
}