feat(core): implement playable CTF prototype with 6 core scripts
Implements the minimal playable core for the teaser prototype: - Game.cs: Bootstrap, scene setup, scoring, round reset, win condition - Unit.cs: Movement, route following, tagging, respawn - RouteDrawer.cs: Click-drag route input with LineRenderer preview - Flag.cs: Pickup, drop, return mechanics - Visibility.cs: Fog of war via SpriteRenderer visibility - SimpleAI.cs: Enemy AI that chases player flag All game objects created programmatically (no Editor setup required). Uses RuntimeInitializeOnLoadMethod for automatic bootstrap. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
388
Backyard CTF/Assets/Scripts/Game.cs
Normal file
388
Backyard CTF/Assets/Scripts/Game.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
public class Game : MonoBehaviour
|
||||
{
|
||||
// Auto-bootstrap when game starts - no Editor setup needed
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Bootstrap()
|
||||
{
|
||||
if (Instance != null) return;
|
||||
|
||||
var gameGO = new GameObject("Game");
|
||||
gameGO.AddComponent<Game>();
|
||||
}
|
||||
|
||||
// Constants - tune later
|
||||
public const float UnitSpeed = 5f;
|
||||
public const float VisionRadius = 4f;
|
||||
public const float TagRadius = 0.75f;
|
||||
public const float RespawnDelay = 3f;
|
||||
public const float FlagReturnDelay = 5f;
|
||||
public const int WinScore = 3;
|
||||
|
||||
// Map dimensions
|
||||
const float MapWidth = 40f;
|
||||
const float MapHeight = 30f;
|
||||
|
||||
// References set during setup
|
||||
public static Game Instance { get; private set; }
|
||||
|
||||
public List<Unit> playerUnits = new();
|
||||
public List<Unit> enemyUnits = new();
|
||||
public Transform playerBase;
|
||||
public Transform enemyBase;
|
||||
public Flag playerFlag;
|
||||
public Flag enemyFlag;
|
||||
|
||||
int playerScore;
|
||||
int enemyScore;
|
||||
bool gameOver;
|
||||
|
||||
TextMeshProUGUI scoreText;
|
||||
TextMeshProUGUI gameOverText;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
SetupCamera();
|
||||
CreateGround();
|
||||
CreateObstacles();
|
||||
CreateBases();
|
||||
CreateFlags();
|
||||
SpawnUnits();
|
||||
CreateUI();
|
||||
SetupSystems();
|
||||
}
|
||||
|
||||
void SetupCamera()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
cam.orthographic = true;
|
||||
cam.orthographicSize = MapHeight / 2f + 2f;
|
||||
cam.transform.position = new Vector3(0, 0, -10);
|
||||
}
|
||||
}
|
||||
|
||||
void CreateGround()
|
||||
{
|
||||
var ground = CreateSprite("Ground", new Color(0.2f, 0.4f, 0.2f), MapWidth, MapHeight);
|
||||
ground.transform.position = Vector3.zero;
|
||||
var sr = ground.GetComponent<SpriteRenderer>();
|
||||
sr.sortingOrder = -10;
|
||||
}
|
||||
|
||||
void CreateObstacles()
|
||||
{
|
||||
// Houses as obstacles - asymmetric backyard layout
|
||||
Vector2[] housePositions = {
|
||||
new(-12f, 8f),
|
||||
new(-8f, -5f),
|
||||
new(0f, 3f),
|
||||
new(5f, -8f),
|
||||
new(10f, 6f),
|
||||
new(14f, -3f),
|
||||
};
|
||||
|
||||
Vector2[] houseSizes = {
|
||||
new(4f, 3f),
|
||||
new(3f, 4f),
|
||||
new(5f, 2.5f),
|
||||
new(3f, 3f),
|
||||
new(4f, 4f),
|
||||
new(3.5f, 3f),
|
||||
};
|
||||
|
||||
for (int i = 0; i < housePositions.Length; i++)
|
||||
{
|
||||
var house = CreateSprite($"House_{i}", new Color(0.4f, 0.4f, 0.4f), houseSizes[i].x, houseSizes[i].y);
|
||||
house.transform.position = new Vector3(housePositions[i].x, housePositions[i].y, 0);
|
||||
|
||||
var collider = house.AddComponent<BoxCollider2D>();
|
||||
// Size is (1,1) in local space - scale handles actual size
|
||||
collider.size = Vector2.one;
|
||||
|
||||
var sr = house.GetComponent<SpriteRenderer>();
|
||||
sr.sortingOrder = -5;
|
||||
}
|
||||
}
|
||||
|
||||
void CreateBases()
|
||||
{
|
||||
// Player base - bottom left
|
||||
var playerBaseGO = CreateSprite("PlayerBase", new Color(0.2f, 0.3f, 0.8f, 0.5f), 6f, 6f);
|
||||
playerBaseGO.transform.position = new Vector3(-MapWidth / 2f + 5f, -MapHeight / 2f + 5f, 0);
|
||||
playerBase = playerBaseGO.transform;
|
||||
var sr1 = playerBaseGO.GetComponent<SpriteRenderer>();
|
||||
sr1.sortingOrder = -8;
|
||||
|
||||
var playerBaseTrigger = playerBaseGO.AddComponent<BoxCollider2D>();
|
||||
playerBaseTrigger.isTrigger = true;
|
||||
playerBaseTrigger.size = Vector2.one; // Local space - scale handles actual size
|
||||
|
||||
var playerBaseZone = playerBaseGO.AddComponent<BaseZone>();
|
||||
playerBaseZone.team = Unit.Team.Player;
|
||||
|
||||
// Enemy base - top right
|
||||
var enemyBaseGO = CreateSprite("EnemyBase", new Color(0.8f, 0.2f, 0.2f, 0.5f), 6f, 6f);
|
||||
enemyBaseGO.transform.position = new Vector3(MapWidth / 2f - 5f, MapHeight / 2f - 5f, 0);
|
||||
enemyBase = enemyBaseGO.transform;
|
||||
var sr2 = enemyBaseGO.GetComponent<SpriteRenderer>();
|
||||
sr2.sortingOrder = -8;
|
||||
|
||||
var enemyBaseTrigger = enemyBaseGO.AddComponent<BoxCollider2D>();
|
||||
enemyBaseTrigger.isTrigger = true;
|
||||
enemyBaseTrigger.size = Vector2.one; // Local space - scale handles actual size
|
||||
|
||||
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 = 0.75f;
|
||||
|
||||
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 = 0.75f;
|
||||
|
||||
var rb2 = enemyFlagGO.AddComponent<Rigidbody2D>();
|
||||
rb2.bodyType = RigidbodyType2D.Kinematic;
|
||||
|
||||
var sr2 = enemyFlagGO.GetComponent<SpriteRenderer>();
|
||||
sr2.sortingOrder = 5;
|
||||
}
|
||||
|
||||
void SpawnUnits()
|
||||
{
|
||||
// Spawn 5 player units near player base
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var offset = new Vector3((i - 2) * 1.5f, -2f, 0);
|
||||
var unit = CreateUnit($"PlayerUnit_{i}", Unit.Team.Player, playerBase.position + offset);
|
||||
playerUnits.Add(unit);
|
||||
}
|
||||
|
||||
// Spawn 5 enemy units near enemy base
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var offset = new Vector3((i - 2) * 1.5f, 2f, 0);
|
||||
var unit = CreateUnit($"EnemyUnit_{i}", Unit.Team.Enemy, enemyBase.position + offset);
|
||||
enemyUnits.Add(unit);
|
||||
}
|
||||
}
|
||||
|
||||
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, 1f, 1f);
|
||||
unitGO.transform.position = position;
|
||||
|
||||
var unit = unitGO.AddComponent<Unit>();
|
||||
unit.team = team;
|
||||
|
||||
var collider = unitGO.AddComponent<CircleCollider2D>();
|
||||
collider.isTrigger = true;
|
||||
collider.radius = 0.5f;
|
||||
|
||||
var rb = unitGO.AddComponent<Rigidbody2D>();
|
||||
rb.bodyType = RigidbodyType2D.Kinematic;
|
||||
|
||||
var sr = unitGO.GetComponent<SpriteRenderer>();
|
||||
sr.sortingOrder = 10;
|
||||
|
||||
return unit;
|
||||
}
|
||||
|
||||
void CreateUI()
|
||||
{
|
||||
// Create Canvas
|
||||
var canvasGO = new GameObject("Canvas");
|
||||
var canvas = canvasGO.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvasGO.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
canvasGO.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
|
||||
// Score text
|
||||
var scoreGO = new GameObject("ScoreText");
|
||||
scoreGO.transform.SetParent(canvasGO.transform, false);
|
||||
scoreText = scoreGO.AddComponent<TextMeshProUGUI>();
|
||||
scoreText.text = "0 - 0";
|
||||
scoreText.fontSize = 48;
|
||||
scoreText.alignment = TextAlignmentOptions.Center;
|
||||
scoreText.color = Color.white;
|
||||
|
||||
var scoreRect = scoreGO.GetComponent<RectTransform>();
|
||||
scoreRect.anchorMin = new Vector2(0.5f, 1f);
|
||||
scoreRect.anchorMax = new Vector2(0.5f, 1f);
|
||||
scoreRect.pivot = new Vector2(0.5f, 1f);
|
||||
scoreRect.anchoredPosition = new Vector2(0, -20);
|
||||
scoreRect.sizeDelta = new Vector2(200, 60);
|
||||
|
||||
// Game over text (hidden initially)
|
||||
var gameOverGO = new GameObject("GameOverText");
|
||||
gameOverGO.transform.SetParent(canvasGO.transform, false);
|
||||
gameOverText = gameOverGO.AddComponent<TextMeshProUGUI>();
|
||||
gameOverText.text = "";
|
||||
gameOverText.fontSize = 72;
|
||||
gameOverText.alignment = TextAlignmentOptions.Center;
|
||||
gameOverText.color = Color.white;
|
||||
gameOverText.gameObject.SetActive(false);
|
||||
|
||||
var gameOverRect = gameOverGO.GetComponent<RectTransform>();
|
||||
gameOverRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.pivot = new Vector2(0.5f, 0.5f);
|
||||
gameOverRect.anchoredPosition = Vector2.zero;
|
||||
gameOverRect.sizeDelta = new Vector2(400, 100);
|
||||
}
|
||||
|
||||
void SetupSystems()
|
||||
{
|
||||
// Add RouteDrawer
|
||||
gameObject.AddComponent<RouteDrawer>();
|
||||
|
||||
// Add Visibility system
|
||||
var visibility = gameObject.AddComponent<Visibility>();
|
||||
visibility.playerUnits = playerUnits.ToArray();
|
||||
visibility.enemyUnits = enemyUnits.ToArray();
|
||||
visibility.enemyFlag = enemyFlag;
|
||||
|
||||
// Add SimpleAI
|
||||
var ai = gameObject.AddComponent<SimpleAI>();
|
||||
ai.aiUnits = enemyUnits.ToArray();
|
||||
ai.playerFlag = playerFlag;
|
||||
ai.aiBase = enemyBase;
|
||||
}
|
||||
|
||||
public void Score(Unit.Team team)
|
||||
{
|
||||
if (gameOver) return;
|
||||
|
||||
if (team == Unit.Team.Player)
|
||||
playerScore++;
|
||||
else
|
||||
enemyScore++;
|
||||
|
||||
UpdateScoreUI();
|
||||
Debug.Log($"Score: {playerScore} - {enemyScore}");
|
||||
|
||||
if (playerScore >= WinScore)
|
||||
EndGame(true);
|
||||
else if (enemyScore >= WinScore)
|
||||
EndGame(false);
|
||||
else
|
||||
ResetRound();
|
||||
}
|
||||
|
||||
void UpdateScoreUI()
|
||||
{
|
||||
if (scoreText != null)
|
||||
scoreText.text = $"{playerScore} - {enemyScore}";
|
||||
}
|
||||
|
||||
void ResetRound()
|
||||
{
|
||||
// Return flags to home
|
||||
playerFlag.ReturnHome();
|
||||
enemyFlag.ReturnHome();
|
||||
|
||||
// Respawn all units at bases
|
||||
for (int i = 0; i < playerUnits.Count; i++)
|
||||
{
|
||||
var offset = new Vector3((i - 2) * 1.5f, -2f, 0);
|
||||
playerUnits[i].ForceRespawn(playerBase.position + offset);
|
||||
}
|
||||
|
||||
for (int i = 0; i < enemyUnits.Count; i++)
|
||||
{
|
||||
var offset = new Vector3((i - 2) * 1.5f, 2f, 0);
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
231
Backyard CTF/Assets/Scripts/Unit.cs
Normal file
231
Backyard CTF/Assets/Scripts/Unit.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
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)
|
||||
{
|
||||
// Determine who gets tagged: farther from their own base loses
|
||||
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);
|
||||
|
||||
if (myDistance > theirDistance)
|
||||
{
|
||||
TagOut();
|
||||
}
|
||||
else if (theirDistance > myDistance)
|
||||
{
|
||||
other.TagOut();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Equal distance - both get tagged (more chaos!)
|
||||
TagOut();
|
||||
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
|
||||
Transform baseTransform = Game.Instance.GetBase(team);
|
||||
Vector3 offset = new Vector3(Random.Range(-2f, 2f), team == Team.Player ? -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user