From a4e28bc93f47a52b57c2b56173bfdbed00d2fb93 Mon Sep 17 00:00:00 2001 From: Matt F Date: Mon, 27 Apr 2026 22:55:23 -0700 Subject: [PATCH] Adding big batch of structural code provided by Claude to start designing levels --- Assets/New Terrain.asset | Bin 0 -> 557360 bytes Assets/New Terrain.asset.meta | 8 + Assets/_Project/Levels.meta | 8 + Assets/_Project/Levels/GoalVolume.cs | 66 ++ Assets/_Project/Levels/GoalVolume.cs.meta | 2 + Assets/_Project/Levels/LeakExitVolume.cs | 131 ++++ Assets/_Project/Levels/LeakExitVolume.cs.meta | 2 + Assets/_Project/Levels/LevelAuthoring.cs | 258 ++++++++ Assets/_Project/Levels/LevelAuthoring.cs.meta | 2 + Assets/_Project/Levels/LevelData.cs | 144 +++++ Assets/_Project/Levels/LevelData.cs.meta | 2 + Assets/_Project/Levels/PlayerZoneVolume.cs | 68 ++ .../_Project/Levels/PlayerZoneVolume.cs.meta | 2 + Assets/_Project/Levels/SpawnerVolume.cs | 118 ++++ Assets/_Project/Levels/SpawnerVolume.cs.meta | 2 + Assets/_Project/Levels/VolumeBase.cs | 319 ++++++++++ Assets/_Project/Levels/VolumeBase.cs.meta | 2 + Assets/_Project/Scenes/Levels/Main.unity | 588 ++++++++++++++++++ Assets/_Project/Scenes/Levels/TestLevel.asset | 31 + .../Scenes/Levels/TestLevel.asset.meta | 8 + Assets/_Project/Scripts/Core/Enums.cs | 88 +++ Assets/_Project/Scripts/Core/Enums.cs.meta | 2 + Assets/_Project/Scripts/Core/PlayerColors.cs | 82 +++ .../Scripts/Core/PlayerColors.cs.meta | 2 + Assets/_Project/Scripts/Editor.meta | 8 + Assets/_Project/Scripts/Editor/Levels.meta | 8 + 26 files changed, 1951 insertions(+) create mode 100644 Assets/New Terrain.asset create mode 100644 Assets/New Terrain.asset.meta create mode 100644 Assets/_Project/Levels.meta create mode 100644 Assets/_Project/Levels/GoalVolume.cs create mode 100644 Assets/_Project/Levels/GoalVolume.cs.meta create mode 100644 Assets/_Project/Levels/LeakExitVolume.cs create mode 100644 Assets/_Project/Levels/LeakExitVolume.cs.meta create mode 100644 Assets/_Project/Levels/LevelAuthoring.cs create mode 100644 Assets/_Project/Levels/LevelAuthoring.cs.meta create mode 100644 Assets/_Project/Levels/LevelData.cs create mode 100644 Assets/_Project/Levels/LevelData.cs.meta create mode 100644 Assets/_Project/Levels/PlayerZoneVolume.cs create mode 100644 Assets/_Project/Levels/PlayerZoneVolume.cs.meta create mode 100644 Assets/_Project/Levels/SpawnerVolume.cs create mode 100644 Assets/_Project/Levels/SpawnerVolume.cs.meta create mode 100644 Assets/_Project/Levels/VolumeBase.cs create mode 100644 Assets/_Project/Levels/VolumeBase.cs.meta create mode 100644 Assets/_Project/Scenes/Levels/TestLevel.asset create mode 100644 Assets/_Project/Scenes/Levels/TestLevel.asset.meta create mode 100644 Assets/_Project/Scripts/Core/Enums.cs create mode 100644 Assets/_Project/Scripts/Core/Enums.cs.meta create mode 100644 Assets/_Project/Scripts/Core/PlayerColors.cs create mode 100644 Assets/_Project/Scripts/Core/PlayerColors.cs.meta create mode 100644 Assets/_Project/Scripts/Editor.meta create mode 100644 Assets/_Project/Scripts/Editor/Levels.meta diff --git a/Assets/New Terrain.asset b/Assets/New Terrain.asset new file mode 100644 index 0000000000000000000000000000000000000000..e41b51bad195087a40c25dd9ac9810664b28f141 GIT binary patch literal 557360 zcmeI*d#q(wT?g=e?qffr8pHVe?Z=(7@4ojo zA;o6OXC-Ideb)EvwbuTu{W!OenWES#FW=fKZn&e`e!;tMuiI|eXtUSfe*5jW-F4et zkKR#y@$%)%gFysOXL$MYKfLXcKf3kS$Nu9}zw_i@Km8YnKlW?UddFN*Z7Ytn+yA~QpKN_#d;iz;@A`FBG!Z$JK=|0kk7=l{ygCs8$JP;}@2b&32rnKw-nRuW1 z{&-E#Z$94reYE{+yz=;O&U_M8Q`+_Y?|7eiUgzEOyX*6fnSXlv{O+#rEtyZ9-!n0* z*_crJWdE<}^*>*oUoVc&Wd2RPd|&7Mx;698^>t0>{QBl?;}42!iciG)x7XLV^zwTA z8=LXJ^%=&```ljRe|xY0?)b0m<@NRTnr8gls_~<0%J%c~JEDEB^E00*(bZN-|2ITK z9{)I6<382N^ZdIj?k`8;GkN`eXD{E^x&H2~@=4Tn85H|E*I(Z2yXWtGb$vY4^#8gl zZ+5P>yZyhuw|}?)c+U0uzZA!|5dG)*|As1$swsJVnXfeYcSn0{Zk0Aa*W_>9C4X0w zziEd&ug?!O`Q1C@mm>dYlYh@H`L8$mJ-g(8(B${-kbhtF|Eb9H-hZIV_eWiqT%Svk z|5>yDebGE9_BUy6*ZF6f{LQ=Mf8OK=cga87ra zx-M~l>YQJvqPgo^=k5AEyi4A$UyQZm{_Xlb(#vP6@t2zUzrD(%YDzxe()XQB{{7J& zOR-hj{3A{N13Too{=e1aKe$7l-!J|+@_c^%Q09~A=VSNz`NNrao?qSP=Z{qVPwzih zd^Y;d`*%KD^Sj#g^EJ=E&&T+G7oW-d_dBXQpMrI1ukX(_^B+d@py=oS+~j9=$^WIv z7kYVpfBk%uFIM^V{OkM+OzaINTi)(} zeUmS5%eU_z&OP5;|C^fqAM54y^ZS;_bNyE`pG4J^?a%N0o;^6wra4x3{ns+T5?A@_ zqVHV){BFJ8%j@U&!T3zB|3)wGK3{*V$|q6RC69ml_qS0rZ-0L0_48o#pU>CLDzEp= zy1oBB+~oNkqdWiF=N*x!&$-MeQ8i_|&-rK{9B9+*tLyW4=2zk>&)<*ICy)O^FYkW; zzF6gxsOysRpFaL4qIvrG^ZWA?G505%`Tuz4lX~B*+wadGZt|bVwmq)@{5@)~>;Iiq zpVQaRT(Q6T{u-A^@vdH8`##l-KgoQL@!y^K9`~R3^!lIce1HC_US5y?8_oDnKEwF$ zeTMPh*XzGK{`-6ROmQsM{`X`2JTHE_>OZQcwCnR%&HO$P?R)wBOy+y|{A}h$cYQ9! z_<8>2_3?APy!QF0X8inJcdzk3l=&Xx|9r3ix$61Wj{o6aUO(UeE5^_H{X&&T)s*zf z``b)}nSUf+-Rogj>Tu(sa&vv<+&JbKjc<hjtn%ZuZsVs&_-*;rmXQZAoe8fR0pwYI*zS)MG*#iHKY7?s1tVrhM) zJXLOv9~v$$pW6)NSd{U_VyPUijF&DRTwhrq6{F&8ai$m+i=(!oTw5$h$JZCj;@oC= zqMh0Ca&svgnl)QqJDcwg)m@%k7_MYX)MwXDtv@hYKex76Y^=v{A^hO-cwCN(@o;pu z93Q;GQlTA;kH(SY*|D&6a=5y&50BQ9Pdfd4y%irF*>{FvIHnS*RV) zo*5Q#_)FtByK36yfV>dHXXjgv^;lbPW9josD9(iX{lkU0c#5^*`NTf9o>PwtWo3Q1 z*qjabjaP=7)zwz7dkhty>2K$mek2xu>*ukz<`sB&ZFpv-tb(S)gX^m?fz7;D>%kVH z;oP`fJUkl3R8~(PUtT*tyihGpJxq4lxp-{0H{cJ3j+ZR8yC3~(E&LW0t5&UAV7cs0RjXF5FkL{%OWs0`=@XGvUIFd0tB9O zf#00{x38${`;)W({v{pn^nSVhyDxmsm+m>8z_iD6IuU~tAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5;&t60Flf4_1T7`Mvc2-|M^Ec-cOfDT?}! S0r;PLi{j;niXwj&#s2~N6t1EG literal 0 HcmV?d00001 diff --git a/Assets/New Terrain.asset.meta b/Assets/New Terrain.asset.meta new file mode 100644 index 0000000..830694d --- /dev/null +++ b/Assets/New Terrain.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e75cb6747dd151148b0f1bf06123e9cf +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 15600000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Levels.meta b/Assets/_Project/Levels.meta new file mode 100644 index 0000000..08206bb --- /dev/null +++ b/Assets/_Project/Levels.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ae52c3dd5edc3ae4da7d4129059d3637 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Levels/GoalVolume.cs b/Assets/_Project/Levels/GoalVolume.cs new file mode 100644 index 0000000..086f066 --- /dev/null +++ b/Assets/_Project/Levels/GoalVolume.cs @@ -0,0 +1,66 @@ +using UnityEngine; +using TD.Core; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace TD.Levels +{ + /// + /// Authoring volume marking the win-condition target. Enemies that reach a goal volume reduce + /// the shared player life pool. Goal tiles are in the + /// baked grid but remain walkable so enemies can enter them. + /// + /// + /// Goals have no ownership — they are shared across all players. Multiple goal volumes are + /// allowed; the designer specifies the expected count via + /// 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. + /// + 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 + } + } +} diff --git a/Assets/_Project/Levels/GoalVolume.cs.meta b/Assets/_Project/Levels/GoalVolume.cs.meta new file mode 100644 index 0000000..98dcd13 --- /dev/null +++ b/Assets/_Project/Levels/GoalVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 755948f0244afea4da6ed2e4e3d27579 \ No newline at end of file diff --git a/Assets/_Project/Levels/LeakExitVolume.cs b/Assets/_Project/Levels/LeakExitVolume.cs new file mode 100644 index 0000000..93732e0 --- /dev/null +++ b/Assets/_Project/Levels/LeakExitVolume.cs @@ -0,0 +1,131 @@ +using UnityEngine; +using TD.Core; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace TD.Levels +{ + /// + /// Authoring volume marking the boundary where enemies leak from one player's zone into + /// another's. Leak exit tiles are in the baked grid + /// but remain walkable. + /// + /// + /// Leak topology is recorded as metadata in the baked 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 field + /// controls the split. The bake normalizes weights to sum to 1.0 across a source zone's + /// leak exits. + /// + 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 + } + + /// + /// Computes the world-space center of the target zone by averaging the bounds-centers + /// of all PlayerZoneVolumes whose matches + /// . Returns Vector3.zero if no matching zone is found — + /// the caller treats zero as a "not found" sentinel. + /// + /// + /// 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. + /// + private Vector3 FindTargetZoneCenter() + { + var zones = Object.FindObjectsByType(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(); + if (c == null) continue; + + accumulated += c.bounds.center; + count++; + } + + if (count == 0) return Vector3.zero; + return accumulated / count; + } + } +} \ No newline at end of file diff --git a/Assets/_Project/Levels/LeakExitVolume.cs.meta b/Assets/_Project/Levels/LeakExitVolume.cs.meta new file mode 100644 index 0000000..2621ab7 --- /dev/null +++ b/Assets/_Project/Levels/LeakExitVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d4da5d6673a12f546ac47563abc49d5a \ No newline at end of file diff --git a/Assets/_Project/Levels/LevelAuthoring.cs b/Assets/_Project/Levels/LevelAuthoring.cs new file mode 100644 index 0000000..60991c4 --- /dev/null +++ b/Assets/_Project/Levels/LevelAuthoring.cs @@ -0,0 +1,258 @@ +using System.Collections.Generic; +using UnityEngine; +using TD.Core; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace TD.Levels +{ + /// + /// Scene-level coordinator for level authoring. One per scene, attached to an empty + /// GameObject named _LevelAuthoring whose subtree contains all the volumes for the map. + /// + /// + /// 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 + /// 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. + /// + 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). + // ------------------------------------------------------------------- + + /// + /// Runs the seven-phase bake algorithm and writes the result into . + /// + /// 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. + /// + /// STUB — full implementation is the next session's work. Currently logs a not-implemented + /// message and returns false. + /// + 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; + } + + /// + /// Re-renders just the lobby thumbnail without doing a full bake. Useful when only visual + /// scene content (terrain, decorations) has changed. + /// + /// + /// STUB — full implementation is part of the bake script work. Currently logs a + /// not-implemented message. + /// + 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(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() : 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(includeInactive: false); + if (zones == null || zones.Length == 0) return; + + // Build per-owner tile sets. + var perOwner = new Dictionary>(); + for (int i = 0; i < zones.Length; i++) + { + var z = zones[i]; + if (z == null) continue; + + var col = z.GetComponent(); + if (col == null) continue; + + if (!perOwner.TryGetValue(z.owner, out var set)) + { + set = new HashSet(); + 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; + } + } +} diff --git a/Assets/_Project/Levels/LevelAuthoring.cs.meta b/Assets/_Project/Levels/LevelAuthoring.cs.meta new file mode 100644 index 0000000..6ed5287 --- /dev/null +++ b/Assets/_Project/Levels/LevelAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 37de128911d7bbc4299ea87cdb520ed7 \ No newline at end of file diff --git a/Assets/_Project/Levels/LevelData.cs b/Assets/_Project/Levels/LevelData.cs new file mode 100644 index 0000000..df02d83 --- /dev/null +++ b/Assets/_Project/Levels/LevelData.cs @@ -0,0 +1,144 @@ +using System; +using UnityEngine; +using TD.Core; + +namespace TD.Levels +{ + /// + /// Baked level data. Authored in a Unity scene via volume MonoBehaviours; produced by + /// ; consumed at runtime as the canonical map + /// definition. + /// + /// + /// LevelData is the canonical map identity. The lobby browses LevelData assets; loading a + /// map = read LevelData → load the referenced scene at . + /// + /// All flat grid arrays use row-major indexing: grid[y * GridSize.x + x]. + /// Origin-relative: is the world-tile coordinate that grid index + /// (0,0) corresponds to. Convert world-tile to grid-index via + /// worldTile - GridOriginTile. + /// + [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; + } + + /// + /// Per-zone baked data. The runtime can use this to enumerate a player's spawners and leak + /// exits without scanning the full grid. + /// + [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; + } +} diff --git a/Assets/_Project/Levels/LevelData.cs.meta b/Assets/_Project/Levels/LevelData.cs.meta new file mode 100644 index 0000000..46f2565 --- /dev/null +++ b/Assets/_Project/Levels/LevelData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d4e9c37b9205f3408a8225823f7a4da \ No newline at end of file diff --git a/Assets/_Project/Levels/PlayerZoneVolume.cs b/Assets/_Project/Levels/PlayerZoneVolume.cs new file mode 100644 index 0000000..d3ff359 --- /dev/null +++ b/Assets/_Project/Levels/PlayerZoneVolume.cs @@ -0,0 +1,68 @@ +using UnityEngine; +using TD.Core; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace TD.Levels +{ + /// + /// Authoring volume that defines a player's owned territory. Tiles covered by a + /// PlayerZoneVolume are in the baked grid (unless + /// covered by an Invalid-validity volume — Invalid wins). + /// + /// + /// A single player's zone may consist of multiple PlayerZoneVolume instances with the same + /// ; the bake unions their tile coverage. This supports L-shapes and other + /// non-rectangular zone footprints. + /// + 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 + } + } +} \ No newline at end of file diff --git a/Assets/_Project/Levels/PlayerZoneVolume.cs.meta b/Assets/_Project/Levels/PlayerZoneVolume.cs.meta new file mode 100644 index 0000000..2a3ff35 --- /dev/null +++ b/Assets/_Project/Levels/PlayerZoneVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e8ab74b9038bd64782043bfc21f4cd7 \ No newline at end of file diff --git a/Assets/_Project/Levels/SpawnerVolume.cs b/Assets/_Project/Levels/SpawnerVolume.cs new file mode 100644 index 0000000..6eb6e62 --- /dev/null +++ b/Assets/_Project/Levels/SpawnerVolume.cs @@ -0,0 +1,118 @@ +using UnityEngine; +using TD.Core; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace TD.Levels +{ + /// + /// Authoring volume marking where enemies spawn into a player's zone. Spawner tiles are + /// in the baked grid (no tower placement allowed) + /// but remain walkable so enemies can leave the spawner. + /// + /// + /// A zone may have multiple spawners; disambiguates them. + /// Player 5 in the 9-player Wintermaul map is the canonical multi-spawner zone. + /// + /// The spawner declares an owning player via 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. + /// + 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 + } + + /// + /// Returns true if more than one active SpawnerVolume in the scene shares this spawner's + /// . Used to decide whether to suffix the gizmo label with the spawner ID. + /// + private bool ZoneHasMultipleSpawners() + { + var spawners = Object.FindObjectsByType(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; + } + } +} \ No newline at end of file diff --git a/Assets/_Project/Levels/SpawnerVolume.cs.meta b/Assets/_Project/Levels/SpawnerVolume.cs.meta new file mode 100644 index 0000000..3b12c00 --- /dev/null +++ b/Assets/_Project/Levels/SpawnerVolume.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a756762f899008f45b2986bdcb11c819 \ No newline at end of file diff --git a/Assets/_Project/Levels/VolumeBase.cs b/Assets/_Project/Levels/VolumeBase.cs new file mode 100644 index 0000000..cea9b97 --- /dev/null +++ b/Assets/_Project/Levels/VolumeBase.cs @@ -0,0 +1,319 @@ +using UnityEngine; +using TD.Core; + +namespace TD.Levels +{ + /// + /// Abstract base class for all level authoring volumes (player zones, spawners, leak exits, goals). + /// + /// + /// Every concrete volume requires a . The collider's bounds (in world + /// space) define the volume's spatial extent, which the bake script rasterizes to tiles via + /// and bounds.Contains(tileCenter). + /// + /// Volumes are authoring-time artifacts only — they are NOT part of the runtime gameplay scene. + /// At runtime the bake's output () is the sole source of truth. + /// + /// Visibility model: gizmos draw via OnDrawGizmosSelected by default (selection-only). + /// The owning exposes per-type toggles to make a category always-on. + /// Volumes not parented under a LevelAuthoring (orphans) always behave as selection-only; + /// they will also produce warnings during bake. + /// + [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; + + /// + /// Gets the volume's , looking it up lazily. Returns null if the + /// component has been removed (which violates [RequireComponent] but can happen if + /// a user manually removes it). + /// + protected BoxCollider Collider + { + get + { + if (_cachedCollider == null) + { + _cachedCollider = GetComponent(); + } + return _cachedCollider; + } + } + + /// + /// Walks the parent transform hierarchy to find the owning + /// 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. + /// + 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(includeInactive: true); + return _cachedAuthoring; + } + + /// + /// When implemented by a subclass, returns whether the corresponding "always show" toggle + /// is enabled on the supplied . Each volume type maps to a + /// different toggle field. + /// + /// The owning LevelAuthoring (guaranteed non-null). + protected abstract bool GetAlwaysShowToggle(LevelAuthoring authoring); + + /// + /// 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). + /// + 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. + // ------------------------------------------------------------------- + + /// + /// Iterates every tile whose center lies inside and invokes + /// for each one. The candidate range is computed via + /// 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 + /// bounds.Contains(tileCenter) test inside the loop guarantees correctness. + /// + public static void RasterizeBoundsToTiles(Bounds bounds, System.Action 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)); + } + } + } + } + + /// + /// Computes the tight tile rectangle for a — the rectangle that + /// exactly contains every tile whose center is inside the bounds. Returns false if no + /// tile centers fall inside the bounds. + /// + 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; + } + + /// + /// Instance-side convenience: computes the tight tile rectangle for THIS volume's collider. + /// + 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); + } + + /// + /// 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). + /// + /// Fully-opaque color; alpha will be applied via . + /// Alpha 0..1 applied to the fill. + /// World Y at which to draw the fill. Stagger by volume type to avoid z-fighting. + 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; + } + + /// + /// 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. + /// + /// Outline color (alpha applied as supplied). + /// World Y at which to draw the outline. + 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; + } + + /// + /// Draws a line+cone arrow from in the given world-space + /// direction. The arrow shaft is drawn with and the + /// arrowhead is built from four lines forming a small cone. + /// + /// Arrow start point (world space). + /// Unit-length direction the arrow points in. + /// Total arrow length in world units. + /// Arrow color (alpha applied as supplied). + 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; + } + + /// + /// Converts a 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). + /// + 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; + } + } + + /// + /// 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). + /// + public static string FormatPlayerName(PlayerSlot slot) + { + if (slot == PlayerSlot.None) return "Player ?"; + return "Player " + ((byte)slot).ToString(); + } + } +} \ No newline at end of file diff --git a/Assets/_Project/Levels/VolumeBase.cs.meta b/Assets/_Project/Levels/VolumeBase.cs.meta new file mode 100644 index 0000000..894d2e9 --- /dev/null +++ b/Assets/_Project/Levels/VolumeBase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c95a277ca66b4a34e8de9e5ee420f145 \ No newline at end of file diff --git a/Assets/_Project/Scenes/Levels/Main.unity b/Assets/_Project/Scenes/Levels/Main.unity index 97c598a..9733e97 100644 --- a/Assets/_Project/Scenes/Levels/Main.unity +++ b/Assets/_Project/Scenes/Levels/Main.unity @@ -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} diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset b/Assets/_Project/Scenes/Levels/TestLevel.asset new file mode 100644 index 0000000..b21d1f1 --- /dev/null +++ b/Assets/_Project/Scenes/Levels/TestLevel.asset @@ -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: [] diff --git a/Assets/_Project/Scenes/Levels/TestLevel.asset.meta b/Assets/_Project/Scenes/Levels/TestLevel.asset.meta new file mode 100644 index 0000000..740cb4f --- /dev/null +++ b/Assets/_Project/Scenes/Levels/TestLevel.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9cc56fbc3ae460a4b862f8510fdf5f09 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Core/Enums.cs b/Assets/_Project/Scripts/Core/Enums.cs new file mode 100644 index 0000000..5f28068 --- /dev/null +++ b/Assets/_Project/Scripts/Core/Enums.cs @@ -0,0 +1,88 @@ +namespace TD.Core +{ + /// + /// Identifies a player slot in a match. Backed by byte to keep grid arrays compact. + /// + /// + /// None is a sentinel value used in OwnerGrid 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). + /// + public enum PlayerSlot : byte + { + None = 0, + Player1 = 1, + Player2 = 2, + Player3 = 3, + Player4 = 4, + Player5 = 5, + Player6 = 6, + Player7 = 7, + Player8 = 8, + Player9 = 9, + } + + /// + /// Whether a volume permits tower placement on the tiles it covers. + /// + /// + /// Defaults: Allowed for , Invalid for + /// , , and + /// . + /// Composition rule when volumes overlap: "Invalid wins" — any tile covered by an Invalid volume + /// becomes regardless of other volumes covering it. + /// + public enum PlacementValidity + { + Invalid = 0, + Allowed = 1, + } + + /// + /// 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. + /// + public enum Direction + { + North, + South, + East, + West, + } + + /// + /// Per-tile placement state in the baked PlacementGrid. + /// + /// + /// Backed by byte so default-initialized arrays (all zero) start as Outside, which is + /// the correct default for any tile not covered by an authoring volume. + /// + public enum PlacementState : byte + { + /// Tile is not covered by any authoring volume. Towers cannot be placed. + Outside = 0, + + /// 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). + Buildable = 1, + + /// 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. + Restricted = 2, + } + + /// + /// Outcome of a bake operation, recorded on the baked asset. + /// + /// + /// 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. + /// + public enum BakeOutcome + { + Success, + SuccessWithWarnings, + } +} diff --git a/Assets/_Project/Scripts/Core/Enums.cs.meta b/Assets/_Project/Scripts/Core/Enums.cs.meta new file mode 100644 index 0000000..6bb5af3 --- /dev/null +++ b/Assets/_Project/Scripts/Core/Enums.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7dd18da945084fc4fb2b403cf1557515 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Core/PlayerColors.cs b/Assets/_Project/Scripts/Core/PlayerColors.cs new file mode 100644 index 0000000..d220f7f --- /dev/null +++ b/Assets/_Project/Scripts/Core/PlayerColors.cs @@ -0,0 +1,82 @@ +using UnityEngine; + +namespace TD.Core +{ + /// + /// Canonical color palette for player slots, used by editor gizmos to color volumes by owner. + /// + /// + /// 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 + /// (which should never happen on a valid volume), the gizmo + /// renders in error pink to make the bug obvious. + /// + 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 + + /// + /// Returns the canonical color for a player slot. Returns the diagnostic error pink if + /// is or out of range. + /// + /// The player slot to look up. + 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; + } + } + + /// The canonical color used for goal volumes. Not tied to any player. + public static Color Goal => GoalGold; + + /// Diagnostic color used when a volume has an invalid/missing owner. + public static Color Error => ErrorPink; + + /// + /// Returns a copy of with its alpha channel replaced by + /// . Convenience for translucent gizmo fills. + /// + 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); + } + } +} diff --git a/Assets/_Project/Scripts/Core/PlayerColors.cs.meta b/Assets/_Project/Scripts/Core/PlayerColors.cs.meta new file mode 100644 index 0000000..9d60a92 --- /dev/null +++ b/Assets/_Project/Scripts/Core/PlayerColors.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d03e9552345e7c4098b141ac5f587e2 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Editor.meta b/Assets/_Project/Scripts/Editor.meta new file mode 100644 index 0000000..1a93ffc --- /dev/null +++ b/Assets/_Project/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a007a87be458dd34da6bc808ea5a322c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Editor/Levels.meta b/Assets/_Project/Scripts/Editor/Levels.meta new file mode 100644 index 0000000..acd5c11 --- /dev/null +++ b/Assets/_Project/Scripts/Editor/Levels.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5495ed4dd0ad5e1479790b6199648b5f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: