Adding big batch of structural code provided by Claude to start designing levels

This commit is contained in:
Matt F 2026-04-27 22:55:23 -07:00
parent 0ed4df8bc9
commit a4e28bc93f
26 changed files with 1951 additions and 0 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ae52c3dd5edc3ae4da7d4129059d3637
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,66 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking the win-condition target. Enemies that reach a goal volume reduce
/// the shared player life pool. Goal tiles are <see cref="PlacementState.Restricted"/> in the
/// baked grid but remain walkable so enemies can enter them.
/// </summary>
/// <remarks>
/// Goals have no ownership — they are shared across all players. Multiple goal volumes are
/// allowed; the designer specifies the expected count via
/// <see cref="LevelAuthoring.expectedGoalCount"/> and the bake validates the count matches.
///
/// Final defenders are identified by their player zone being 4-adjacent to a goal volume's
/// tile coverage; they do not have a LeakExitVolume.
/// </remarks>
public class GoalVolume : VolumeBase
{
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid.")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Goals draw above player zones to be visually anchoring landmarks. Higher alpha than
// other volume types (60% per gizmo design).
private const float FillYLevel = 0.04f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowGoals;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color goalColor = PlayerColors.Goal;
DrawTileCoverageFill(goalColor, alpha: 0.60f, yLevel: FillYLevel);
DrawRectangularOutline(goalColor, yLevel: FillYLevel);
#if UNITY_EDITOR
var col = Collider;
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, "GOAL");
}
#endif
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 755948f0244afea4da6ed2e4e3d27579

View file

@ -0,0 +1,131 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking the boundary where enemies leak from one player's zone into
/// another's. Leak exit tiles are <see cref="PlacementState.Restricted"/> in the baked grid
/// but remain walkable.
/// </summary>
/// <remarks>
/// Leak topology is recorded as metadata in the baked <see cref="LevelData"/> for lobby UI
/// and future wave-balancing systems, but the runtime gameplay loop does not depend on it —
/// enemies pathfind on the unified walkability grid and naturally cross zone boundaries.
///
/// Final defenders (the player whose zone is goal-adjacent) do NOT have a LeakExitVolume.
/// They are identified by zone-to-goal adjacency at bake time.
///
/// Multiple leak exits with the same source zone may exist (e.g., Player 5 in the 9-player
/// map splits leaks 50/50 between Player 4 and Player 6). The <see cref="weight"/> field
/// controls the split. The bake normalizes weights to sum to 1.0 across a source zone's
/// leak exits.
/// </remarks>
public class LeakExitVolume : VolumeBase
{
[Tooltip("Which player's zone this leak exits FROM.")]
public PlayerSlot sourceZone = PlayerSlot.Player1;
[Tooltip("Which player's zone this leak feeds INTO.")]
public PlayerSlot target = PlayerSlot.Player2;
[Tooltip("Relative weight for split exits. Bake normalizes weights to sum to 1.0 across all " +
"leak exits sharing the same source zone. Set both leaks' weights to 1.0 for a 50/50 split.")]
public float weight = 1.0f;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid.")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Leak exits draw above player zones but below spawners.
private const float FillYLevel = 0.04f;
private const float ArrowYLevel = 0.05f;
private const float ArrowLength = 1.5f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowLeakExits;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
// Leak exit fill uses the SOURCE zone's color (visually attaches the leak to the zone
// it leaves). The target is conveyed by the arrow direction and label.
Color baseColor = PlayerColors.Get(sourceZone);
DrawTileCoverageFill(baseColor, alpha: 0.40f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
// Arrow points from this leak exit's center toward the target zone's center.
var col = Collider;
if (col != null)
{
Vector3 origin = new Vector3(col.bounds.center.x, ArrowYLevel, col.bounds.center.z);
Vector3 targetCenter = FindTargetZoneCenter();
if (targetCenter.sqrMagnitude > 0f) // sentinel: zero means "not found"
{
Vector3 directionXZ = new Vector3(targetCenter.x - origin.x, 0f, targetCenter.z - origin.z);
DrawArrow(origin, directionXZ, ArrowLength, baseColor);
}
}
#if UNITY_EDITOR
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, ArrowYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, $"→ {FormatPlayerName(target)}");
}
#endif
}
/// <summary>
/// Computes the world-space center of the target zone by averaging the bounds-centers
/// of all PlayerZoneVolumes whose <see cref="PlayerZoneVolume.owner"/> matches
/// <see cref="target"/>. Returns <c>Vector3.zero</c> if no matching zone is found —
/// the caller treats zero as a "not found" sentinel.
/// </summary>
/// <remarks>
/// Performs a scene-wide query each gizmo draw. This is fine for our worst case
/// (9-player map: 8 leak exits × ~10 zone volumes = ~80 considerations per frame, only
/// when gizmos are drawing). If this ever shows up as an editor-perf issue, switch to a
/// cached lookup invalidated on hierarchy change.
/// </remarks>
private Vector3 FindTargetZoneCenter()
{
var zones = Object.FindObjectsByType<PlayerZoneVolume>(FindObjectsInactive.Exclude);
if (zones == null || zones.Length == 0) return Vector3.zero;
Vector3 accumulated = Vector3.zero;
int count = 0;
for (int i = 0; i < zones.Length; i++)
{
if (zones[i] == null) continue;
if (zones[i].owner != target) continue;
var c = zones[i].GetComponent<BoxCollider>();
if (c == null) continue;
accumulated += c.bounds.center;
count++;
}
if (count == 0) return Vector3.zero;
return accumulated / count;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4da5d6673a12f546ac47563abc49d5a

View file

@ -0,0 +1,258 @@
using System.Collections.Generic;
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Scene-level coordinator for level authoring. One per scene, attached to an empty
/// GameObject named <c>_LevelAuthoring</c> whose subtree contains all the volumes for the map.
/// </summary>
/// <remarks>
/// LevelAuthoring is the entry point for the bake operation: the custom inspector exposes a
/// "Bake LevelData" button that triggers the seven-phase algorithm, writing into the
/// <see cref="targetAsset"/> ScriptableObject.
///
/// This class also owns the per-volume-type "always show gizmos" toggles. Each volume walks
/// its parent hierarchy to find this LevelAuthoring and reads the relevant toggle.
///
/// In the current session the bake methods are stubs — only the field schema and gizmo
/// rendering are implemented. The full bake script is the next session's work.
/// </remarks>
public class LevelAuthoring : MonoBehaviour
{
// -------------------------------------------------------------------
// Bake target and lobby-facing metadata.
// -------------------------------------------------------------------
[Header("Bake Target")]
[Tooltip("The ScriptableObject this scene bakes into. Must be set before baking.")]
public LevelData targetAsset;
[Header("Map Metadata")]
[Tooltip("Display name for the lobby UI.")]
public string mapName = "";
[Tooltip("Number of players this map is designed for. Used by the lobby to filter maps " +
"and by bake validation. Allowed values: 1, 2, 3, 4, 5, 9.")]
public int playerCount = 1;
[Tooltip("Expected number of GoalVolume instances. Bake validates the count matches.")]
public int expectedGoalCount = 1;
[Tooltip("Optional description for the lobby UI. Empty triggers a soft warning at bake time.")]
[TextArea(2, 5)]
public string mapDescription = "";
[Tooltip("Optional author/credit text. Empty triggers a soft warning at bake time.")]
public string author = "";
// -------------------------------------------------------------------
// Gizmo visibility toggles. Each volume type reads its own toggle to decide whether to
// draw always-on or only-when-selected.
// -------------------------------------------------------------------
[Header("Gizmo Visibility (Always-Show Toggles)")]
[Tooltip("If true, PlayerZoneVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowPlayerZones = false;
[Tooltip("If true, SpawnerVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowSpawners = false;
[Tooltip("If true, LeakExitVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowLeakExits = false;
[Tooltip("If true, GoalVolume gizmos draw whether or not the volume is selected.")]
public bool alwaysShowGoals = false;
// -------------------------------------------------------------------
// Bake API (stubs this session — full implementation comes next session).
// -------------------------------------------------------------------
/// <summary>
/// Runs the seven-phase bake algorithm and writes the result into <see cref="targetAsset"/>.
/// </summary>
/// <returns>True if the bake succeeded (with or without warnings); false if validation
/// failed or any other hard error occurred. On failure, the existing targetAsset on disk
/// is left untouched.</returns>
/// <remarks>
/// STUB — full implementation is the next session's work. Currently logs a not-implemented
/// message and returns false.
/// </remarks>
public bool BakeLevelData()
{
Debug.LogWarning("[LevelAuthoring] BakeLevelData is not yet implemented. " +
"The seven-phase bake algorithm will be added in the next session.");
return false;
}
/// <summary>
/// Re-renders just the lobby thumbnail without doing a full bake. Useful when only visual
/// scene content (terrain, decorations) has changed.
/// </summary>
/// <remarks>
/// STUB — full implementation is part of the bake script work. Currently logs a
/// not-implemented message.
/// </remarks>
public void RefreshThumbnail()
{
Debug.LogWarning("[LevelAuthoring] RefreshThumbnail is not yet implemented. " +
"Thumbnail rendering will be added with the bake script.");
}
// -------------------------------------------------------------------
// Map-level gizmos: origin marker, map bounding rect, combined player zone outlines.
// Always draw when LevelAuthoring is in the scene (no per-toggle gating for these).
// -------------------------------------------------------------------
private const float OriginMarkerY = 0.005f;
private const float MapBoundsY = 0.01f;
private const float CombinedZoneOutlineY = 0.03f;
private const float OriginMarkerSize = 0.4f;
private void OnDrawGizmos()
{
DrawOriginMarker();
DrawMapBoundingRect();
DrawCombinedPlayerZoneOutlines();
}
private void DrawOriginMarker()
{
Color prev = Gizmos.color;
Gizmos.color = Color.white;
// A small "+" cross at world (0,0) plus a sphere for visibility.
Vector3 origin = new Vector3(0f, OriginMarkerY, 0f);
Gizmos.DrawSphere(origin, 0.1f);
Gizmos.DrawLine(origin + new Vector3(-OriginMarkerSize, 0f, 0f),
origin + new Vector3( OriginMarkerSize, 0f, 0f));
Gizmos.DrawLine(origin + new Vector3(0f, 0f, -OriginMarkerSize),
origin + new Vector3(0f, 0f, OriginMarkerSize));
Gizmos.color = prev;
#if UNITY_EDITOR
Handles.Label(origin + new Vector3(0.15f, 0.05f, 0.15f), "Tile (0,0)");
#endif
}
private void DrawMapBoundingRect()
{
// Find every VolumeBase in this LevelAuthoring's subtree (matches the bake's scoped scan)
// and union their tile bounding rectangles.
var volumes = GetComponentsInChildren<VolumeBase>(includeInactive: false);
if (volumes == null || volumes.Length == 0) return;
bool initialized = false;
Vector2Int minTile = Vector2Int.zero;
Vector2Int maxTile = Vector2Int.zero;
for (int i = 0; i < volumes.Length; i++)
{
var col = volumes[i] != null ? volumes[i].GetComponent<BoxCollider>() : null;
if (col == null) continue;
if (!VolumeBase.TryGetTightTileRect(col.bounds, out Vector2Int vMin, out Vector2Int vMax))
{
continue; // volume covers no tiles (e.g., bounds don't intersect Y=0)
}
if (!initialized)
{
minTile = vMin;
maxTile = vMax;
initialized = true;
}
else
{
if (vMin.x < minTile.x) minTile.x = vMin.x;
if (vMin.y < minTile.y) minTile.y = vMin.y;
if (vMax.x > maxTile.x) maxTile.x = vMax.x;
if (vMax.y > maxTile.y) maxTile.y = vMax.y;
}
}
if (!initialized) return;
// Convert tile-corner indices to world-space corners. Tiles are center-based with
// TILE_SIZE = 1, so a tile at (x,y) spans world XZ from (x-0.5, y-0.5) to (x+0.5, y+0.5).
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Vector3 sw = new Vector3(minTile.x - halfTile, MapBoundsY, minTile.y - halfTile);
Vector3 se = new Vector3(maxTile.x + halfTile, MapBoundsY, minTile.y - halfTile);
Vector3 ne = new Vector3(maxTile.x + halfTile, MapBoundsY, maxTile.y + halfTile);
Vector3 nw = new Vector3(minTile.x - halfTile, MapBoundsY, maxTile.y + halfTile);
Color prev = Gizmos.color;
Gizmos.color = new Color(1f, 1f, 1f, 0.6f); // muted white
Gizmos.DrawLine(sw, se);
Gizmos.DrawLine(se, ne);
Gizmos.DrawLine(ne, nw);
Gizmos.DrawLine(nw, sw);
Gizmos.color = prev;
}
private void DrawCombinedPlayerZoneOutlines()
{
// Group all PlayerZoneVolumes in this subtree by owner, then for each owner compute
// the union of their tile coverage and draw the perimeter using the general
// (non-rectangular) algorithm. This visualizes L-shaped multi-volume zones as a
// single unified outline.
var zones = GetComponentsInChildren<PlayerZoneVolume>(includeInactive: false);
if (zones == null || zones.Length == 0) return;
// Build per-owner tile sets.
var perOwner = new Dictionary<PlayerSlot, HashSet<Vector2Int>>();
for (int i = 0; i < zones.Length; i++)
{
var z = zones[i];
if (z == null) continue;
var col = z.GetComponent<BoxCollider>();
if (col == null) continue;
if (!perOwner.TryGetValue(z.owner, out var set))
{
set = new HashSet<Vector2Int>();
perOwner[z.owner] = set;
}
// Use the shared rasterizer so this stays in lock-step with what the bake will see.
VolumeBase.RasterizeBoundsToTiles(col.bounds, t => set.Add(t));
}
// For each owner, draw perimeter using the general algorithm.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Color prev = Gizmos.color;
foreach (var kv in perOwner)
{
Color outlineColor = PlayerColors.Get(kv.Key);
Gizmos.color = outlineColor;
foreach (var tile in kv.Value)
{
// World-space corners of this tile.
Vector3 sw = new Vector3(tile.x - halfTile, CombinedZoneOutlineY, tile.y - halfTile);
Vector3 se = new Vector3(tile.x + halfTile, CombinedZoneOutlineY, tile.y - halfTile);
Vector3 ne = new Vector3(tile.x + halfTile, CombinedZoneOutlineY, tile.y + halfTile);
Vector3 nw = new Vector3(tile.x - halfTile, CombinedZoneOutlineY, tile.y + halfTile);
// For each of the four edges, draw it ONLY if the neighbor across that edge
// is not in the covered set (i.e., the edge is on the perimeter).
if (!kv.Value.Contains(new Vector2Int(tile.x, tile.y - 1))) Gizmos.DrawLine(sw, se); // south edge
if (!kv.Value.Contains(new Vector2Int(tile.x + 1, tile.y))) Gizmos.DrawLine(se, ne); // east edge
if (!kv.Value.Contains(new Vector2Int(tile.x, tile.y + 1))) Gizmos.DrawLine(ne, nw); // north edge
if (!kv.Value.Contains(new Vector2Int(tile.x - 1, tile.y))) Gizmos.DrawLine(nw, sw); // west edge
}
}
Gizmos.color = prev;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 37de128911d7bbc4299ea87cdb520ed7

View file

@ -0,0 +1,144 @@
using System;
using UnityEngine;
using TD.Core;
namespace TD.Levels
{
/// <summary>
/// Baked level data. Authored in a Unity scene via volume MonoBehaviours; produced by
/// <see cref="LevelAuthoring.BakeLevelData"/>; consumed at runtime as the canonical map
/// definition.
/// </summary>
/// <remarks>
/// LevelData is the canonical map identity. The lobby browses LevelData assets; loading a
/// map = read LevelData → load the referenced scene at <see cref="ScenePath"/>.
///
/// All flat grid arrays use row-major indexing: <c>grid[y * GridSize.x + x]</c>.
/// Origin-relative: <see cref="GridOriginTile"/> is the world-tile coordinate that grid index
/// (0,0) corresponds to. Convert world-tile to grid-index via
/// <c>worldTile - GridOriginTile</c>.
/// </remarks>
[CreateAssetMenu(fileName = "LevelData", menuName = "TD/Level Data", order = 1)]
public class LevelData : ScriptableObject
{
// -------------------------------------------------------------------
// Lobby-facing metadata.
// -------------------------------------------------------------------
[Header("Map Metadata")]
public string MapName;
public int PlayerCount;
public string MapDescription;
public string Author;
[Tooltip("Auto-generated by bake. Use the Refresh Thumbnail button on LevelAuthoring to " +
"re-render without doing a full bake.")]
public Sprite MapThumbnail;
[Tooltip("Path to the scene this LevelData was baked from. Auto-populated by bake.")]
public string ScenePath;
// -------------------------------------------------------------------
// Bake metadata (used for dirty-detection and diagnostic display).
// -------------------------------------------------------------------
[Header("Bake Metadata")]
[Tooltip("Hash of the authoring inputs at last bake. Compared at Play-mode entry to detect " +
"unbaked changes. Captures volumes + LevelAuthoring metadata; does NOT capture " +
"visual scene content.")]
public string AuthoringHash;
[Tooltip("ISO 8601 UTC timestamp of the last successful bake.")]
public string LastBakeTimestamp;
public BakeOutcome LastBakeOutcome;
public int LastBakeWarningCount;
// -------------------------------------------------------------------
// Grid metadata.
// -------------------------------------------------------------------
[Header("Grid")]
[Tooltip("World-tile coordinate that grid index (0,0) corresponds to. Origin-relative " +
"addressing: worldTile - GridOriginTile gives the index into the flat arrays.")]
public Vector2Int GridOriginTile;
[Tooltip("Width × height of the grid in tiles.")]
public Vector2Int GridSize;
// -------------------------------------------------------------------
// Per-tile flat arrays. All indexed as grid[y * GridSize.x + x].
// -------------------------------------------------------------------
[Tooltip("Per-tile placement state: Outside / Buildable / Restricted. Length = GridSize.x * GridSize.y.")]
public PlacementState[] PlacementGrid;
[Tooltip("Per-tile initial walkability (does NOT account for towers — they stamp footprints " +
"at runtime). Length = GridSize.x * GridSize.y.")]
public bool[] WalkabilityGrid;
[Tooltip("Per-tile owning player slot. PlayerSlot.None for tiles not in any player zone. " +
"Length = GridSize.x * GridSize.y.")]
public PlayerSlot[] OwnerGrid;
// -------------------------------------------------------------------
// Per-zone and per-goal structures (populated by bake from volume data).
// -------------------------------------------------------------------
[Tooltip("One entry per declared player zone, sorted by Owner.")]
public PlayerZoneData[] PlayerZones;
[Tooltip("One entry per GoalVolume, sorted by min tile coordinate.")]
public GoalData[] Goals;
}
/// <summary>
/// Per-zone baked data. The runtime can use this to enumerate a player's spawners and leak
/// exits without scanning the full grid.
/// </summary>
[Serializable]
public class PlayerZoneData
{
public PlayerSlot Owner;
[Tooltip("Spawners in this zone, sorted by SpawnerIdInZone.")]
public SpawnerData[] Spawners;
[Tooltip("Leak exits OUT OF this zone, sorted by Target enum value. Empty for the final " +
"defender (whose zone is goal-adjacent and has no leak exit).")]
public LeakExitData[] LeakExits;
}
[Serializable]
public class SpawnerData
{
public int SpawnerIdInZone;
[Tooltip("Tile coordinate of the spawner's center (its volume's bounds center, projected to grid).")]
public Vector2Int TilePosition;
[Tooltip("Full tile coverage of the spawner volume.")]
public Vector2Int[] TileArea;
public Direction Facing;
}
[Serializable]
public class LeakExitData
{
public PlayerSlot Target;
[Tooltip("Full tile coverage of the leak exit volume.")]
public Vector2Int[] TileArea;
[Tooltip("Weight normalized so all leak exits sharing the same source zone sum to 1.0.")]
public float NormalizedWeight;
}
[Serializable]
public class GoalData
{
[Tooltip("Full tile coverage of the goal volume.")]
public Vector2Int[] TileArea;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6d4e9c37b9205f3408a8225823f7a4da

View file

@ -0,0 +1,68 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume that defines a player's owned territory. Tiles covered by a
/// PlayerZoneVolume are <see cref="PlacementState.Buildable"/> in the baked grid (unless
/// covered by an Invalid-validity volume — Invalid wins).
/// </summary>
/// <remarks>
/// A single player's zone may consist of multiple PlayerZoneVolume instances with the same
/// <see cref="owner"/>; the bake unions their tile coverage. This supports L-shapes and other
/// non-rectangular zone footprints.
/// </remarks>
public class PlayerZoneVolume : VolumeBase
{
[Tooltip("Which player owns this zone.")]
public PlayerSlot owner = PlayerSlot.Player1;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Allowed.")]
public PlacementValidity placementValidity = PlacementValidity.Allowed;
// Per-volume gizmo Y level (see gizmo design summary). Player zones draw lowest so
// overlapping spawners/leak exits/goals stack on top of them.
private const float FillYLevel = 0.02f;
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowPlayerZones;
}
// Selection-only by default. Always-on path runs from OnDrawGizmos when the toggle is set.
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color baseColor = PlayerColors.Get(owner);
DrawTileCoverageFill(baseColor, alpha: 0.35f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
#if UNITY_EDITOR
// Label centered on the volume's bounds, identifying which player owns this build area.
var col = Collider;
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, FillYLevel + 0.1f, col.bounds.center.z);
Handles.Label(labelPos, $"{FormatPlayerName(owner)} Build Area");
}
#endif
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e8ab74b9038bd64782043bfc21f4cd7

View file

@ -0,0 +1,118 @@
using UnityEngine;
using TD.Core;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace TD.Levels
{
/// <summary>
/// Authoring volume marking where enemies spawn into a player's zone. Spawner tiles are
/// <see cref="PlacementState.Restricted"/> in the baked grid (no tower placement allowed)
/// but remain walkable so enemies can leave the spawner.
/// </summary>
/// <remarks>
/// A zone may have multiple spawners; <see cref="spawnerIdInZone"/> disambiguates them.
/// Player 5 in the 9-player Wintermaul map is the canonical multi-spawner zone.
///
/// The spawner declares an owning player via <see cref="owner"/> rather than relying on
/// spatial containment within a PlayerZoneVolume. The bake validates (with a soft warning)
/// that the spawner's tiles fall inside its declared owner's zone.
/// </remarks>
public class SpawnerVolume : VolumeBase
{
[Tooltip("Which player's zone owns this spawner. Explicit — not inferred from spatial overlap.")]
public PlayerSlot owner = PlayerSlot.Player1;
[Tooltip("Disambiguator for zones with multiple spawners. Should be 0 for single-spawner zones, " +
"and contiguous starting from 0 for multi-spawner zones (e.g., 0 and 1).")]
public int spawnerIdInZone = 0;
[Tooltip("Direction enemies face when they spawn. Used for visual orientation and may bias " +
"initial enemy movement direction.")]
public Direction spawnFacing = Direction.South;
[Tooltip("Whether tiles in this volume are buildable. Defaults to Invalid (no placement on spawners).")]
public PlacementValidity placementValidity = PlacementValidity.Invalid;
// Spawners draw above player zones so the player zone color reads as background.
private const float FillYLevel = 0.06f;
private const float ArrowYLevel = 0.07f;
private const float ArrowLength = 1.5f; // 1.5 tiles per gizmo design
protected override bool GetAlwaysShowToggle(LevelAuthoring authoring)
{
return authoring.alwaysShowSpawners;
}
private void OnDrawGizmosSelected()
{
DrawGizmosCore();
}
private void OnDrawGizmos()
{
if (ShouldDrawAlwaysOn())
{
DrawGizmosCore();
}
}
private void DrawGizmosCore()
{
Color baseColor = PlayerColors.Get(owner);
DrawTileCoverageFill(baseColor, alpha: 0.40f, yLevel: FillYLevel);
DrawRectangularOutline(baseColor, yLevel: FillYLevel);
// Direction arrow originates from the volume's center and extends 1.5 tiles in the
// declared facing direction, rendered in opaque owner color.
var col = Collider;
if (col != null)
{
Vector3 arrowOrigin = new Vector3(col.bounds.center.x, ArrowYLevel, col.bounds.center.z);
Vector3 arrowDir = DirectionToWorld(spawnFacing);
DrawArrow(arrowOrigin, arrowDir, ArrowLength, baseColor);
}
#if UNITY_EDITOR
// Label conditional on multi-spawner status:
// - Single-spawner zone: "Player N Spawn"
// - Multi-spawner zone: "Player N Spawn 0", "Player N Spawn 1", etc.
// Multi-spawner detection scans other SpawnerVolumes in the scene with the same owner.
// Same posture as the leak-exit target lookup: cheap on realistic maps, can be cached
// if it ever shows up as an editor-perf issue.
if (col != null)
{
Vector3 labelPos = new Vector3(col.bounds.center.x, ArrowYLevel + 0.1f, col.bounds.center.z);
string labelText = ZoneHasMultipleSpawners()
? $"{FormatPlayerName(owner)} Spawn {spawnerIdInZone}"
: $"{FormatPlayerName(owner)} Spawn";
Handles.Label(labelPos, labelText);
}
#endif
}
/// <summary>
/// Returns true if more than one active SpawnerVolume in the scene shares this spawner's
/// <see cref="owner"/>. Used to decide whether to suffix the gizmo label with the spawner ID.
/// </summary>
private bool ZoneHasMultipleSpawners()
{
var spawners = Object.FindObjectsByType<SpawnerVolume>(FindObjectsInactive.Exclude);
if (spawners == null) return false;
int count = 0;
for (int i = 0; i < spawners.Length; i++)
{
if (spawners[i] == null) continue;
if (spawners[i].owner == owner)
{
count++;
if (count > 1) return true;
}
}
return false;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a756762f899008f45b2986bdcb11c819

View file

@ -0,0 +1,319 @@
using UnityEngine;
using TD.Core;
namespace TD.Levels
{
/// <summary>
/// Abstract base class for all level authoring volumes (player zones, spawners, leak exits, goals).
/// </summary>
/// <remarks>
/// Every concrete volume requires a <see cref="BoxCollider"/>. The collider's bounds (in world
/// space) define the volume's spatial extent, which the bake script rasterizes to tiles via
/// <see cref="BoxCollider.bounds"/> and <c>bounds.Contains(tileCenter)</c>.
///
/// Volumes are authoring-time artifacts only — they are NOT part of the runtime gameplay scene.
/// At runtime the bake's output (<see cref="LevelData"/>) is the sole source of truth.
///
/// Visibility model: gizmos draw via <c>OnDrawGizmosSelected</c> by default (selection-only).
/// The owning <see cref="LevelAuthoring"/> exposes per-type toggles to make a category always-on.
/// Volumes not parented under a <c>LevelAuthoring</c> (orphans) always behave as selection-only;
/// they will also produce warnings during bake.
/// </remarks>
[RequireComponent(typeof(BoxCollider))]
[DisallowMultipleComponent]
public abstract class VolumeBase : MonoBehaviour
{
// Cached collider reference. Looked up lazily and refreshed if it goes null
// (e.g., the user manually removed the component).
private BoxCollider _cachedCollider;
// Cached LevelAuthoring lookup. We walk parents to find it once, then keep the reference.
// If the cached reference becomes null (parent moved, scene changed), the next access re-resolves.
private LevelAuthoring _cachedAuthoring;
/// <summary>
/// Gets the volume's <see cref="BoxCollider"/>, looking it up lazily. Returns null if the
/// component has been removed (which violates <c>[RequireComponent]</c> but can happen if
/// a user manually removes it).
/// </summary>
protected BoxCollider Collider
{
get
{
if (_cachedCollider == null)
{
_cachedCollider = GetComponent<BoxCollider>();
}
return _cachedCollider;
}
}
/// <summary>
/// Walks the parent transform hierarchy to find the owning <see cref="LevelAuthoring"/>
/// component. Returns null if this volume is orphaned (not parented under a LevelAuthoring).
/// Result is cached; the cache invalidates automatically if the reference goes null.
/// </summary>
protected LevelAuthoring FindOwningAuthoring()
{
if (_cachedAuthoring != null)
{
return _cachedAuthoring;
}
// GetComponentInParent walks up the hierarchy. Includes inactive parents to handle the
// case where _LevelAuthoring is temporarily disabled during scene work.
_cachedAuthoring = GetComponentInParent<LevelAuthoring>(includeInactive: true);
return _cachedAuthoring;
}
/// <summary>
/// When implemented by a subclass, returns whether the corresponding "always show" toggle
/// is enabled on the supplied <see cref="LevelAuthoring"/>. Each volume type maps to a
/// different toggle field.
/// </summary>
/// <param name="authoring">The owning LevelAuthoring (guaranteed non-null).</param>
protected abstract bool GetAlwaysShowToggle(LevelAuthoring authoring);
/// <summary>
/// Decides whether the "always show" path should run for this volume. Returns true if a
/// LevelAuthoring is found in parents AND its corresponding toggle is enabled. Orphans
/// always return false (always-show requires an authoring to read the toggle from).
/// </summary>
protected bool ShouldDrawAlwaysOn()
{
var authoring = FindOwningAuthoring();
if (authoring == null)
{
return false;
}
return GetAlwaysShowToggle(authoring);
}
// -------------------------------------------------------------------
// Static rasterization helper. Single source of truth for converting a Bounds to the
// set of tiles its rasterization covers. The bake will use the same primitive (a tile
// is "covered" iff bounds.Contains(tileCenter)) so gizmos and bake stay in lock-step.
// -------------------------------------------------------------------
/// <summary>
/// Iterates every tile whose center lies inside <paramref name="bounds"/> and invokes
/// <paramref name="onTile"/> for each one. The candidate range is computed via
/// <see cref="GridCoordinates.WorldToGrid"/> with a one-tile padding on each side to guard
/// against rounding surprises (WorldToGrid uses round-to-nearest, which can over- or
/// under-shoot by one when bounds align with tile edges); the per-tile
/// <c>bounds.Contains(tileCenter)</c> test inside the loop guarantees correctness.
/// </summary>
public static void RasterizeBoundsToTiles(Bounds bounds, System.Action<Vector2Int> onTile)
{
if (onTile == null) return;
Vector2Int candidateMin = GridCoordinates.WorldToGrid(new Vector2(bounds.min.x, bounds.min.z));
Vector2Int candidateMax = GridCoordinates.WorldToGrid(new Vector2(bounds.max.x, bounds.max.z));
int xLo = candidateMin.x - 1;
int xHi = candidateMax.x + 1;
int yLo = candidateMin.y - 1;
int yHi = candidateMax.y + 1;
for (int x = xLo; x <= xHi; x++)
{
for (int y = yLo; y <= yHi; y++)
{
Vector3 tileCenter = GridCoordinates.GridToWorld(new Vector2Int(x, y));
Vector3 testPoint = new Vector3(tileCenter.x, bounds.center.y, tileCenter.z);
if (bounds.Contains(testPoint))
{
onTile(new Vector2Int(x, y));
}
}
}
}
/// <summary>
/// Computes the tight tile rectangle for a <see cref="Bounds"/> — the rectangle that
/// exactly contains every tile whose center is inside the bounds. Returns false if no
/// tile centers fall inside the bounds.
/// </summary>
public static bool TryGetTightTileRect(Bounds bounds, out Vector2Int minTile, out Vector2Int maxTile)
{
bool any = false;
int minX = 0, maxX = 0, minY = 0, maxY = 0;
RasterizeBoundsToTiles(bounds, t =>
{
if (!any)
{
minX = maxX = t.x;
minY = maxY = t.y;
any = true;
}
else
{
if (t.x < minX) minX = t.x;
if (t.x > maxX) maxX = t.x;
if (t.y < minY) minY = t.y;
if (t.y > maxY) maxY = t.y;
}
});
if (!any)
{
minTile = Vector2Int.zero;
maxTile = Vector2Int.zero;
return false;
}
minTile = new Vector2Int(minX, minY);
maxTile = new Vector2Int(maxX, maxY);
return true;
}
/// <summary>
/// Instance-side convenience: computes the tight tile rectangle for THIS volume's collider.
/// </summary>
protected bool TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)
{
minTile = Vector2Int.zero;
maxTile = Vector2Int.zero;
var col = Collider;
if (col == null) return false;
return TryGetTightTileRect(col.bounds, out minTile, out maxTile);
}
/// <summary>
/// Draws a translucent fill over the volume's tile coverage at the specified world Y.
/// Uses per-tile flat cube gizmos (Option A from the gizmo design discussion).
/// </summary>
/// <param name="fillColor">Fully-opaque color; alpha will be applied via <paramref name="alpha"/>.</param>
/// <param name="alpha">Alpha 0..1 applied to the fill.</param>
/// <param name="yLevel">World Y at which to draw the fill. Stagger by volume type to avoid z-fighting.</param>
protected void DrawTileCoverageFill(Color fillColor, float alpha, float yLevel)
{
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
// For a single BoxCollider, every tile in the tight rectangle is covered (the rect was
// built from actually-covered tiles, and the covered set is always rectangular for a
// BoxCollider's bounds). So we can iterate the rect directly without re-checking
// bounds.Contains per tile.
Color prev = Gizmos.color;
Gizmos.color = PlayerColors.WithAlpha(fillColor, alpha);
Vector3 tileSize = new Vector3(GridCoordinates.TILE_SIZE, 0.01f, GridCoordinates.TILE_SIZE);
for (int x = minTile.x; x <= maxTile.x; x++)
{
for (int y = minTile.y; y <= maxTile.y; y++)
{
Vector3 center = GridCoordinates.GridToWorld(new Vector2Int(x, y));
Vector3 drawCenter = new Vector3(center.x, yLevel, center.z);
Gizmos.DrawCube(drawCenter, tileSize);
}
}
Gizmos.color = prev;
}
/// <summary>
/// Draws an opaque rectangular perimeter outline around the volume's tile coverage at the
/// specified world Y. For single-volume rasterization the coverage is always rectangular
/// (a BoxCollider's tile rasterization can only produce a tile rectangle), so we draw the
/// four edges of the tight tile rect.
/// </summary>
/// <param name="outlineColor">Outline color (alpha applied as supplied).</param>
/// <param name="yLevel">World Y at which to draw the outline.</param>
protected void DrawRectangularOutline(Color outlineColor, float yLevel)
{
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
// Convert tile-corner indices to world-space corners. A tile at (x, y) spans world XZ
// from (x - 0.5, y - 0.5) to (x + 0.5, y + 0.5) (TILE_SIZE = 1, center-based).
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
Vector3 swCorner = new Vector3(minTile.x - halfTile, yLevel, minTile.y - halfTile);
Vector3 seCorner = new Vector3(maxTile.x + halfTile, yLevel, minTile.y - halfTile);
Vector3 neCorner = new Vector3(maxTile.x + halfTile, yLevel, maxTile.y + halfTile);
Vector3 nwCorner = new Vector3(minTile.x - halfTile, yLevel, maxTile.y + halfTile);
Color prev = Gizmos.color;
Gizmos.color = outlineColor;
Gizmos.DrawLine(swCorner, seCorner);
Gizmos.DrawLine(seCorner, neCorner);
Gizmos.DrawLine(neCorner, nwCorner);
Gizmos.DrawLine(nwCorner, swCorner);
Gizmos.color = prev;
}
/// <summary>
/// Draws a line+cone arrow from <paramref name="origin"/> in the given world-space
/// direction. The arrow shaft is drawn with <see cref="Gizmos.DrawLine"/> and the
/// arrowhead is built from four lines forming a small cone.
/// </summary>
/// <param name="origin">Arrow start point (world space).</param>
/// <param name="direction">Unit-length direction the arrow points in.</param>
/// <param name="length">Total arrow length in world units.</param>
/// <param name="color">Arrow color (alpha applied as supplied).</param>
protected void DrawArrow(Vector3 origin, Vector3 direction, float length, Color color)
{
if (direction.sqrMagnitude < 0.0001f) return;
direction = direction.normalized;
Vector3 tip = origin + direction * length;
// Pick an "up" axis perpendicular to the arrow direction in the XZ plane. Since our
// arrows are always horizontal (Y is fixed), we want the arrowhead to flare in the
// horizontal plane. The right-vector relative to the direction does this:
// right = cross(direction, world up). If direction is parallel to world up
// (vertical arrow, which we don't expect), fall back to world forward.
Vector3 right = Vector3.Cross(direction, Vector3.up);
if (right.sqrMagnitude < 0.0001f)
{
right = Vector3.Cross(direction, Vector3.forward);
}
right.Normalize();
// Arrowhead size proportional to arrow length.
float headLength = length * 0.25f;
float headWidth = length * 0.15f;
Vector3 headBase = tip - direction * headLength;
Vector3 leftWing = headBase - right * headWidth;
Vector3 rightWing = headBase + right * headWidth;
Color prev = Gizmos.color;
Gizmos.color = color;
Gizmos.DrawLine(origin, tip);
Gizmos.DrawLine(tip, leftWing);
Gizmos.DrawLine(tip, rightWing);
Gizmos.DrawLine(leftWing, rightWing); // close the head for visibility
Gizmos.color = prev;
}
/// <summary>
/// Converts a <see cref="Direction"/> enum to a unit world-space vector in the XZ plane.
/// North = +Z, South = -Z, East = +X, West = -X (matches grid-y mapped to world-z).
/// </summary>
protected static Vector3 DirectionToWorld(Direction direction)
{
switch (direction)
{
case Direction.North: return new Vector3(0f, 0f, 1f);
case Direction.South: return new Vector3(0f, 0f, -1f);
case Direction.East: return new Vector3(1f, 0f, 0f);
case Direction.West: return new Vector3(-1f, 0f, 0f);
default: return Vector3.zero;
}
}
/// <summary>
/// Returns a human-readable label for a player slot, used by gizmo labels for consistency
/// across volume types. Currently formatted as "Player N" (or "Player ?" for None / unknown).
/// </summary>
public static string FormatPlayerName(PlayerSlot slot)
{
if (slot == PlayerSlot.None) return "Player ?";
return "Player " + ((byte)slot).ToString();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c95a277ca66b4a34e8de9e5ee420f145

View file

@ -119,6 +119,74 @@ NavMeshSettings:
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &154690529
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 154690530}
- component: {fileID: 154690532}
- component: {fileID: 154690531}
m_Layer: 0
m_Name: Player1Zone
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &154690530
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 154690529}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -12, y: 0, z: 13}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &154690531
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 154690529}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5e8ab74b9038bd64782043bfc21f4cd7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.PlayerZoneVolume
owner: 1
placementValidity: 1
--- !u!65 &154690532
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 154690529}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 28, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &239104687
GameObject:
m_ObjectHideFlags: 0
@ -191,6 +259,76 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &304575571
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 304575572}
- component: {fileID: 304575574}
- component: {fileID: 304575573}
m_Layer: 0
m_Name: Player1Spawn
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &304575572
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 304575571}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -29, y: 0, z: 13}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &304575573
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 304575571}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a756762f899008f45b2986bdcb11c819, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.SpawnerVolume
owner: 1
spawnerIdInZone: 0
spawnFacing: 2
placementValidity: 0
--- !u!65 &304575574
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 304575571}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &330585543
GameObject:
m_ObjectHideFlags: 0
@ -455,6 +593,66 @@ MonoBehaviour:
m_ShadowLayerMask: 1
m_RenderingLayers: 1
m_ShadowRenderingLayers: 1
--- !u!1 &441239879
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 441239881}
- component: {fileID: 441239880}
m_Layer: 0
m_Name: _LevelAuthoring
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &441239880
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 441239879}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 37de128911d7bbc4299ea87cdb520ed7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelAuthoring
targetAsset: {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
mapName:
playerCount: 1
expectedGoalCount: 1
mapDescription:
author:
alwaysShowPlayerZones: 1
alwaysShowSpawners: 1
alwaysShowLeakExits: 1
alwaysShowGoals: 1
--- !u!4 &441239881
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 441239879}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -35.58358, y: 0, z: -6.35952}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 154690530}
- {fileID: 1975687920}
- {fileID: 304575572}
- {fileID: 1078485324}
- {fileID: 1064792476}
- {fileID: 1360337263}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &832575517
GameObject:
m_ObjectHideFlags: 0
@ -504,6 +702,326 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1064792475
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1064792476}
- component: {fileID: 1064792478}
- component: {fileID: 1064792477}
m_Layer: 0
m_Name: Player1Leak
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1064792476
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1064792475}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 3, y: 0, z: 13}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1064792477
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1064792475}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d4da5d6673a12f546ac47563abc49d5a, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LeakExitVolume
sourceZone: 1
target: 2
weight: 1
placementValidity: 0
--- !u!65 &1064792478
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1064792475}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 2, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1078485323
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1078485324}
- component: {fileID: 1078485326}
- component: {fileID: 1078485325}
m_Layer: 0
m_Name: Player2Spawn
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1078485324
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1078485323}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 8, y: 0, z: 22}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1078485325
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1078485323}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a756762f899008f45b2986bdcb11c819, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.SpawnerVolume
owner: 2
spawnerIdInZone: 0
spawnFacing: 1
placementValidity: 0
--- !u!65 &1078485326
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1078485323}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 10}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1360337262
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1360337263}
- component: {fileID: 1360337265}
- component: {fileID: 1360337264}
m_Layer: 0
m_Name: Goal
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1360337263
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360337262}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 33.5, y: 0, z: 13.5}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1360337264
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360337262}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 755948f0244afea4da6ed2e4e3d27579, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.GoalVolume
placementValidity: 0
--- !u!65 &1360337265
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360337262}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 3, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1464027360
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1464027364}
- component: {fileID: 1464027363}
- component: {fileID: 1464027362}
- component: {fileID: 1464027361}
m_Layer: 0
m_Name: Plane
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!64 &1464027361
MeshCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1464027360}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 5
m_Convex: 0
m_CookingOptions: 30
m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &1464027362
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1464027360}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &1464027363
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1464027360}
m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1464027364
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1464027360}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -35, y: 0, z: 15}
m_LocalScale: {x: 7, y: 1, z: 3}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1682341399
GameObject:
m_ObjectHideFlags: 0
@ -610,6 +1128,74 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1975687919
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1975687920}
- component: {fileID: 1975687922}
- component: {fileID: 1975687921}
m_Layer: 0
m_Name: Player2Zone
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1975687920
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1975687919}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 18, y: 0, z: 13}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 441239881}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1975687921
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1975687919}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5e8ab74b9038bd64782043bfc21f4cd7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.PlayerZoneVolume
owner: 2
placementValidity: 1
--- !u!65 &1975687922
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1975687919}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 28, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
@ -619,3 +1205,5 @@ SceneRoots:
- {fileID: 832575519}
- {fileID: 1682341402}
- {fileID: 239104690}
- {fileID: 441239881}
- {fileID: 1464027364}

View file

@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6d4e9c37b9205f3408a8225823f7a4da, type: 3}
m_Name: TestLevel
m_EditorClassIdentifier: Assembly-CSharp::TD.Levels.LevelData
MapName:
PlayerCount: 0
MapDescription:
Author:
MapThumbnail: {fileID: 0}
ScenePath:
AuthoringHash:
LastBakeTimestamp:
LastBakeOutcome: 0
LastBakeWarningCount: 0
GridOriginTile: {x: 0, y: 0}
GridSize: {x: 0, y: 0}
PlacementGrid:
WalkabilityGrid:
OwnerGrid:
PlayerZones: []
Goals: []

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9cc56fbc3ae460a4b862f8510fdf5f09
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,88 @@
namespace TD.Core
{
/// <summary>
/// Identifies a player slot in a match. Backed by byte to keep grid arrays compact.
/// </summary>
/// <remarks>
/// <c>None</c> is a sentinel value used in <c>OwnerGrid</c> to mark tiles not owned by any player zone.
/// Player1..Player9 cover the maximum supported player count. Maps using fewer players use a
/// contiguous prefix (e.g., a 3-player map uses Player1, Player2, Player3 only).
/// </remarks>
public enum PlayerSlot : byte
{
None = 0,
Player1 = 1,
Player2 = 2,
Player3 = 3,
Player4 = 4,
Player5 = 5,
Player6 = 6,
Player7 = 7,
Player8 = 8,
Player9 = 9,
}
/// <summary>
/// Whether a volume permits tower placement on the tiles it covers.
/// </summary>
/// <remarks>
/// Defaults: <c>Allowed</c> for <see cref="TD.Levels.PlayerZoneVolume"/>, <c>Invalid</c> for
/// <see cref="TD.Levels.SpawnerVolume"/>, <see cref="TD.Levels.LeakExitVolume"/>, and
/// <see cref="TD.Levels.GoalVolume"/>.
/// Composition rule when volumes overlap: "Invalid wins" — any tile covered by an Invalid volume
/// becomes <see cref="PlacementState.Restricted"/> regardless of other volumes covering it.
/// </remarks>
public enum PlacementValidity
{
Invalid = 0,
Allowed = 1,
}
/// <summary>
/// Cardinal direction for spawner facing. Used by gizmos (direction arrow) and may be used
/// at runtime to bias initial enemy movement direction out of a spawner.
/// </summary>
public enum Direction
{
North,
South,
East,
West,
}
/// <summary>
/// Per-tile placement state in the baked <c>PlacementGrid</c>.
/// </summary>
/// <remarks>
/// Backed by byte so default-initialized arrays (all zero) start as <c>Outside</c>, which is
/// the correct default for any tile not covered by an authoring volume.
/// </remarks>
public enum PlacementState : byte
{
/// <summary>Tile is not covered by any authoring volume. Towers cannot be placed.</summary>
Outside = 0,
/// <summary>Tile is inside a player zone and not covered by any Invalid-validity volume.
/// Towers can be placed here (subject to runtime checks: ownership, footprint, gold, path).</summary>
Buildable = 1,
/// <summary>Tile is covered by at least one Invalid-validity volume (spawner, leak exit, goal).
/// Towers cannot be placed here. Note: this affects placement only; pathfinding still treats
/// the tile as walkable.</summary>
Restricted = 2,
}
/// <summary>
/// Outcome of a bake operation, recorded on the baked <see cref="TD.Levels.LevelData"/> asset.
/// </summary>
/// <remarks>
/// Failure is not represented here because failed bakes do not persist any state to the asset —
/// the previous successful bake (if any) remains on disk untouched. Failure state lives in
/// editor-only memory and is not part of the data schema.
/// </remarks>
public enum BakeOutcome
{
Success,
SuccessWithWarnings,
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7dd18da945084fc4fb2b403cf1557515

View file

@ -0,0 +1,82 @@
using UnityEngine;
namespace TD.Core
{
/// <summary>
/// Canonical color palette for player slots, used by editor gizmos to color volumes by owner.
/// </summary>
/// <remarks>
/// Color names follow the 9-player map design doc (red, green, blue, purple, yellow, gray,
/// teal, olive, dark gray). The exact RGB values have been tuned for readability when drawn
/// as translucent gizmos against Unity's default scene-view background. Tweak the constants
/// below if any color reads poorly in practice.
///
/// The "ErrorPink" color is reserved for diagnostic use: if a volume's owner is
/// <see cref="PlayerSlot.None"/> (which should never happen on a valid volume), the gizmo
/// renders in error pink to make the bug obvious.
/// </remarks>
public static class PlayerColors
{
// Player colors. Hex values are RGB; alpha is set per-gizmo at draw time.
// Values are tuned to be saturated enough to read against Unity's default scene background.
private static readonly Color Player1Red = HexRGB(0xE0, 0x3A, 0x3A); // red
private static readonly Color Player2Green = HexRGB(0x3A, 0xC0, 0x4A); // green
private static readonly Color Player3Blue = HexRGB(0x3A, 0x7A, 0xE0); // blue
private static readonly Color Player4Purple = HexRGB(0xA0, 0x4A, 0xC0); // purple
private static readonly Color Player5Yellow = HexRGB(0xE0, 0xC8, 0x3A); // yellow
private static readonly Color Player6Gray = HexRGB(0xB0, 0xB0, 0xB8); // gray (slightly cool)
private static readonly Color Player7Teal = HexRGB(0x3A, 0xC0, 0xB8); // teal
private static readonly Color Player8Olive = HexRGB(0x9A, 0x9A, 0x3A); // olive
private static readonly Color Player9DarkGray = HexRGB(0x60, 0x60, 0x68); // dark gray (slightly cool)
// Non-player colors.
private static readonly Color GoalGold = HexRGB(0xE0, 0xB0, 0x20); // gold
private static readonly Color ErrorPink = HexRGB(0xFF, 0x4A, 0xC8); // diagnostic
/// <summary>
/// Returns the canonical color for a player slot. Returns the diagnostic error pink if
/// <paramref name="slot"/> is <see cref="PlayerSlot.None"/> or out of range.
/// </summary>
/// <param name="slot">The player slot to look up.</param>
public static Color Get(PlayerSlot slot)
{
switch (slot)
{
case PlayerSlot.Player1: return Player1Red;
case PlayerSlot.Player2: return Player2Green;
case PlayerSlot.Player3: return Player3Blue;
case PlayerSlot.Player4: return Player4Purple;
case PlayerSlot.Player5: return Player5Yellow;
case PlayerSlot.Player6: return Player6Gray;
case PlayerSlot.Player7: return Player7Teal;
case PlayerSlot.Player8: return Player8Olive;
case PlayerSlot.Player9: return Player9DarkGray;
case PlayerSlot.None:
default:
return ErrorPink;
}
}
/// <summary>The canonical color used for goal volumes. Not tied to any player.</summary>
public static Color Goal => GoalGold;
/// <summary>Diagnostic color used when a volume has an invalid/missing owner.</summary>
public static Color Error => ErrorPink;
/// <summary>
/// Returns a copy of <paramref name="color"/> with its alpha channel replaced by
/// <paramref name="alpha"/>. Convenience for translucent gizmo fills.
/// </summary>
public static Color WithAlpha(Color color, float alpha)
{
color.a = alpha;
return color;
}
// Helper: build a Color from 0-255 RGB byte channels (alpha defaults to 1.0).
private static Color HexRGB(byte r, byte g, byte b)
{
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7d03e9552345e7c4098b141ac5f587e2

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a007a87be458dd34da6bc808ea5a322c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5495ed4dd0ad5e1479790b6199648b5f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: