Adding 9 Player level

This commit is contained in:
Matt F 2026-05-21 23:36:19 -07:00
parent fdada6f132
commit a7be12fa9b
30 changed files with 45984 additions and 300 deletions

View file

@ -0,0 +1,137 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: M_Dirt
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.5
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.5450981, g: 0.2705882, b: 0.07450981, a: 1}
- _Color: {r: 0.54509807, g: 0.27058816, b: 0.07450979, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &7846884589835172231
MonoBehaviour:
m_ObjectHideFlags: 11
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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 24952dbaf661e434480183e106050156
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:

View file

@ -157,13 +157,13 @@ namespace TD.Levels
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);
// Convert tile-corner indices to world-space corners. Tiles are edge-aligned with
// TILE_SIZE = 1, so a tile at (x,y) spans world XZ from (x, y) to (x+1, y+1).
float tileSize = GridCoordinates.TILE_SIZE;
Vector3 sw = new Vector3(minTile.x * tileSize, MapBoundsY, minTile.y * tileSize);
Vector3 se = new Vector3((maxTile.x + 1) * tileSize, MapBoundsY, minTile.y * tileSize);
Vector3 ne = new Vector3((maxTile.x + 1) * tileSize, MapBoundsY, (maxTile.y + 1) * tileSize);
Vector3 nw = new Vector3(minTile.x * tileSize, MapBoundsY, (maxTile.y + 1) * tileSize);
Color prev = Gizmos.color;
Gizmos.color = new Color(1f, 1f, 1f, 0.6f); // muted white
@ -219,8 +219,9 @@ namespace TD.Levels
VolumeBase.RasterizeBoundsToTiles(col.bounds, t => set.Add(t));
}
// For each owner, draw perimeter using the general algorithm.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
// For each owner, draw perimeter using the general algorithm. Tile (x,y) spans
// world XZ from (x, y) to (x+1, y+1) (edge-aligned, TILE_SIZE = 1).
float tileSize = GridCoordinates.TILE_SIZE;
Color prev = Gizmos.color;
foreach (var kv in perOwner)
@ -231,10 +232,10 @@ namespace TD.Levels
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);
Vector3 sw = new Vector3(tile.x * tileSize, CombinedZoneOutlineY, tile.y * tileSize);
Vector3 se = new Vector3((tile.x + 1) * tileSize, CombinedZoneOutlineY, tile.y * tileSize);
Vector3 ne = new Vector3((tile.x + 1) * tileSize, CombinedZoneOutlineY, (tile.y + 1) * tileSize);
Vector3 nw = new Vector3(tile.x * tileSize, CombinedZoneOutlineY, (tile.y + 1) * tileSize);
// 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).

View file

@ -38,6 +38,15 @@ namespace TD.Levels
[Tooltip("Path to the scene this LevelData was baked from. Auto-populated by bake.")]
public string ScenePath;
/// <summary>
/// Just the scene name (no path, no extension) — what NetworkManager.SceneManager.LoadScene
/// expects. Derived from <see cref="ScenePath"/>; returns empty string if ScenePath is unset.
/// </summary>
public string SceneName =>
string.IsNullOrEmpty(ScenePath)
? string.Empty
: System.IO.Path.GetFileNameWithoutExtension(ScenePath);
// -------------------------------------------------------------------
// Bake metadata (used for dirty-detection and diagnostic display).
// -------------------------------------------------------------------

View file

@ -75,21 +75,28 @@ namespace TD.Levels
if (!TryGetTightTileRect(out Vector2Int minTile, out Vector2Int maxTile)) return;
const float thickness = 0.04f;
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
float tileSize = GridCoordinates.TILE_SIZE;
// Three concentric rectangles: the original edge, slightly inset, slightly outset.
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, 0f, outlineColor);
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, +thickness, outlineColor);
DrawOutlineAtInset(minTile, maxTile, halfTile, yLevel, -thickness, outlineColor);
DrawOutlineAtInset(minTile, maxTile, tileSize, yLevel, 0f, outlineColor);
DrawOutlineAtInset(minTile, maxTile, tileSize, yLevel, +thickness, outlineColor);
DrawOutlineAtInset(minTile, maxTile, tileSize, yLevel, -thickness, outlineColor);
}
private static void DrawOutlineAtInset(Vector2Int minTile, Vector2Int maxTile, float halfTile,
// Tile (x, y) spans world XZ [x, x+1] (edge-aligned). The outline corners are at the
// tile rect's outer edges; `inset` shifts them outward (positive) or inward (negative)
// to draw the concentric thickness rectangles.
private static void DrawOutlineAtInset(Vector2Int minTile, Vector2Int maxTile, float tileSize,
float yLevel, float inset, Color color)
{
Vector3 sw = new Vector3(minTile.x - halfTile - inset, yLevel, minTile.y - halfTile - inset);
Vector3 se = new Vector3(maxTile.x + halfTile + inset, yLevel, minTile.y - halfTile - inset);
Vector3 ne = new Vector3(maxTile.x + halfTile + inset, yLevel, maxTile.y + halfTile + inset);
Vector3 nw = new Vector3(minTile.x - halfTile - inset, yLevel, maxTile.y + halfTile + inset);
float minX = minTile.x * tileSize - inset;
float maxX = (maxTile.x + 1) * tileSize + inset;
float minZ = minTile.y * tileSize - inset;
float maxZ = (maxTile.y + 1) * tileSize + inset;
Vector3 sw = new Vector3(minX, yLevel, minZ);
Vector3 se = new Vector3(maxX, yLevel, minZ);
Vector3 ne = new Vector3(maxX, yLevel, maxZ);
Vector3 nw = new Vector3(minX, yLevel, maxZ);
Color prev = Gizmos.color;
Gizmos.color = color;

View file

@ -91,8 +91,12 @@ namespace TD.Levels
// -------------------------------------------------------------------
// 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.
// set of tiles its rasterization covers. Uses a half-open interval (min inclusive,
// max exclusive) on XZ so that:
// - a volume sized to N whole tiles rasterizes to exactly N tiles (no off-by-one),
// - two volumes that share a boundary (e.g., one ending at X=20, next starting at
// X=20) do not double-claim the boundary tile.
// Gizmos and bake share this helper so they stay in lock-step.
// -------------------------------------------------------------------
/// <summary>
@ -100,8 +104,8 @@ namespace TD.Levels
/// <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.
/// under-shoot by one when bounds align with tile edges); the per-tile half-open
/// interval test inside the loop guarantees correctness.
/// </summary>
public static void RasterizeBoundsToTiles(Bounds bounds, System.Action<Vector2Int> onTile)
{
@ -120,8 +124,11 @@ namespace TD.Levels
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))
// Half-open interval on XZ: min inclusive, max exclusive. Y axis is implicitly
// satisfied because we test at bounds.center.y, which is always within Y range.
bool xIn = tileCenter.x >= bounds.min.x && tileCenter.x < bounds.max.x;
bool zIn = tileCenter.z >= bounds.min.z && tileCenter.z < bounds.max.z;
if (xIn && zIn)
{
onTile(new Vector2Int(x, y));
}
@ -226,12 +233,12 @@ namespace TD.Levels
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);
// from (x, y) to (x+1, y+1) (TILE_SIZE = 1, edge-aligned).
float tileSize = GridCoordinates.TILE_SIZE;
Vector3 swCorner = new Vector3(minTile.x * tileSize, yLevel, minTile.y * tileSize);
Vector3 seCorner = new Vector3((maxTile.x + 1) * tileSize, yLevel, minTile.y * tileSize);
Vector3 neCorner = new Vector3((maxTile.x + 1) * tileSize, yLevel, (maxTile.y + 1) * tileSize);
Vector3 nwCorner = new Vector3(minTile.x * tileSize, yLevel, (maxTile.y + 1) * tileSize);
Color prev = Gizmos.color;
Gizmos.color = outlineColor;

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 74fdeec477e257d4484b38108c283d84
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e47a49b9bdc21d0b40f3f39d9f16c74113d2d90de7ffc2ece86b2ea8b916d19
size 26196

View file

@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: af7534c7dbffefc4c8794fab0a322480
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View file

@ -146,7 +146,7 @@ Transform:
m_GameObject: {fileID: 154690529}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 27, y: 0, z: 52.05}
m_LocalPosition: {x: 13, y: 0, z: 60}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -185,8 +185,8 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 29, y: 1, z: 34}
m_Center: {x: -14, y: 0, z: 8.5}
m_Size: {x: 30, y: 1, z: 34}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &167151707
GameObject:
m_ObjectHideFlags: 0
@ -264,7 +264,7 @@ Transform:
m_GameObject: {fileID: 304575571}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 13, y: 0, z: 81}
m_LocalPosition: {x: 14, y: 0, z: 80}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -305,7 +305,7 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 7}
m_Size: {x: 7, y: 1, z: 6}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &330585543
GameObject:
@ -745,118 +745,6 @@ Transform:
m_Children: []
m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &643505902
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 643505906}
- component: {fileID: 643505905}
- component: {fileID: 643505904}
- component: {fileID: 643505903}
m_Layer: 7
m_Name: Cube (6)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!65 &643505903
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 643505902}
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: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &643505904
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 643505902}
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 &643505905
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 643505902}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &643505906
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 643505902}
serializedVersion: 2
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
m_LocalPosition: {x: -35, y: 0, z: 1}
m_LocalScale: {x: 8, y: 5, z: 12.6126}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!1 &832575517
GameObject:
m_ObjectHideFlags: 0
@ -906,6 +794,118 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &857729253
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 857729254}
- component: {fileID: 857729257}
- component: {fileID: 857729256}
- component: {fileID: 857729255}
m_Layer: 7
m_Name: Cube (8)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &857729254
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 857729253}
serializedVersion: 2
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
m_LocalPosition: {x: -35, y: 0, z: 1.5}
m_LocalScale: {x: 7, y: 5, z: 12}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1994440963}
m_LocalEulerAnglesHint: {x: 0, y: 90, z: 0}
--- !u!65 &857729255
BoxCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 857729253}
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: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &857729256
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 857729253}
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 &857729257
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 857729253}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &902199259
GameObject:
m_ObjectHideFlags: 0
@ -1146,7 +1146,7 @@ Transform:
m_GameObject: {fileID: 1064792475}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 11, y: 0, z: 40.5}
m_LocalPosition: {x: 13, y: 0, z: 40}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -1187,8 +1187,8 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 6, y: 1, z: 8}
m_Center: {x: 2, y: 0, z: -1.5}
m_Size: {x: 8, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1078485323
GameObject:
m_ObjectHideFlags: 0
@ -1216,7 +1216,7 @@ Transform:
m_GameObject: {fileID: 1078485323}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 13, y: 0, z: 38}
m_LocalPosition: {x: 13, y: 0, z: 39}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -1257,8 +1257,8 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 7}
m_Center: {x: 0, y: 0, z: 0}
m_Size: {x: 8, y: 1, z: 4}
m_Center: {x: 0, y: 0, z: -1}
--- !u!1 &1119106649
GameObject:
m_ObjectHideFlags: 0
@ -1552,7 +1552,7 @@ Transform:
m_GameObject: {fileID: 1360337262}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 13, y: 0, z: -3}
m_LocalPosition: {x: 13, y: 0, z: -1}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -1590,8 +1590,8 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 7, y: 1, z: 4}
m_Center: {x: 0, y: 0, z: 1.5}
m_Size: {x: 8, y: 1, z: 6}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1380211460
GameObject:
m_ObjectHideFlags: 0
@ -2184,7 +2184,7 @@ Transform:
m_GameObject: {fileID: 1975687919}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 24, y: 0, z: 31.25}
m_LocalPosition: {x: 13, y: 0, z: 19}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@ -2223,8 +2223,8 @@ BoxCollider:
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 3
m_Size: {x: 29, y: 1, z: 34}
m_Center: {x: -10.5, y: 0, z: -13.5}
m_Size: {x: 30, y: 1, z: 34}
m_Center: {x: 0, y: 0, z: 0}
--- !u!1 &1994440962
GameObject:
m_ObjectHideFlags: 0
@ -2256,8 +2256,8 @@ Transform:
m_Children:
- {fileID: 2024858689}
- {fileID: 1949204945}
- {fileID: 643505906}
- {fileID: 2105067738}
- {fileID: 857729254}
- {fileID: 611926976}
- {fileID: 1464027364}
m_Father: {fileID: 0}
@ -2480,8 +2480,8 @@ Transform:
m_GameObject: {fileID: 2105067734}
serializedVersion: 2
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
m_LocalPosition: {x: -15, y: 0, z: 1}
m_LocalScale: {x: 8, y: 5, z: 12.6126}
m_LocalPosition: {x: -15, y: 0, z: 1.5}
m_LocalScale: {x: 7, y: 5, z: 12}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1994440963}

File diff suppressed because one or more lines are too long

View file

@ -606,6 +606,53 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &976902009
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 976902011}
- component: {fileID: 976902010}
m_Layer: 0
m_Name: MapRegistry
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &976902010
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 976902009}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c4dafc49d16a2eb4c8db0b00440af991, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.MapRegistry
maps:
- {fileID: 11400000, guid: e65cce766e92f604f85ab939cf374abd, type: 2}
- {fileID: 11400000, guid: 9cc56fbc3ae460a4b862f8510fdf5f09, type: 2}
--- !u!4 &976902011
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 976902009}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1648104262
GameObject:
m_ObjectHideFlags: 0
@ -660,3 +707,4 @@ SceneRoots:
- {fileID: 1648104264}
- {fileID: 626141754}
- {fileID: 514623722}
- {fileID: 976902011}

View file

@ -12,8 +12,11 @@ namespace TD.Core
///
/// Conventions:
/// - Tiles are 1.0 world unit on each side (TILE_SIZE).
/// - Tiles are CENTER-BASED: tile (0, 0) has its center at world (0, 0, 0)
/// and occupies world XZ from (-0.5, -0.5) to (+0.5, +0.5).
/// - Tiles are EDGE-ALIGNED: tile (N, N) occupies world XZ from (N, N) to (N+1, N+1),
/// with its center at (N+0.5, N+0.5). This makes integer-aligned BoxCollider bounds
/// align naturally with tile boundaries — a volume sized to N whole tiles at an
/// integer position covers exactly N tiles, with the rasterized tile geometry
/// matching the bounds rectangle exactly (no half-tile overhang).
/// - The grid lives on the XZ plane at Y = BUILDABLE_PLANE_Y. Grid-y maps to world-z.
/// - 4-connected (no diagonals).
/// </summary>
@ -32,26 +35,27 @@ namespace TD.Core
/// <summary>
/// Returns the world-space center of the given tile, on the buildable plane.
/// Use this for placing towers, drawing ghost previews, and for A* path waypoints.
/// Tile (N, N) is centered at world (N+0.5, N+0.5) since tiles occupy [N, N+1].
/// </summary>
public static Vector3 GridToWorld(Vector2Int gridPos)
{
return new Vector3(
gridPos.x * TILE_SIZE,
(gridPos.x + 0.5f) * TILE_SIZE,
BUILDABLE_PLANE_Y,
gridPos.y * TILE_SIZE);
(gridPos.y + 0.5f) * TILE_SIZE);
}
/// <summary>
/// Returns the tile that contains the given world position.
/// The Y component of worldPos is ignored. Uses round-to-nearest because
/// tiles are center-based — any world point within ±0.5 of a tile's center
/// belongs to that tile.
/// The Y component of worldPos is ignored. Uses floor because tiles are
/// edge-aligned — tile N occupies the half-open interval [N, N+1) on each axis,
/// so any world point in that range floors to tile N.
/// </summary>
public static Vector2Int WorldToGrid(Vector3 worldPos)
{
return new Vector2Int(
Mathf.RoundToInt(worldPos.x / TILE_SIZE),
Mathf.RoundToInt(worldPos.z / TILE_SIZE));
Mathf.FloorToInt(worldPos.x / TILE_SIZE),
Mathf.FloorToInt(worldPos.z / TILE_SIZE));
}
/// <summary>
@ -61,8 +65,8 @@ namespace TD.Core
public static Vector2Int WorldToGrid(Vector2 worldPosXZ)
{
return new Vector2Int(
Mathf.RoundToInt(worldPosXZ.x / TILE_SIZE),
Mathf.RoundToInt(worldPosXZ.y / TILE_SIZE));
Mathf.FloorToInt(worldPosXZ.x / TILE_SIZE),
Mathf.FloorToInt(worldPosXZ.y / TILE_SIZE));
}
// ----- Grid helpers --------------------------------------------------------
@ -200,9 +204,10 @@ namespace TD.Core
/// <summary>
/// Returns the world-space center of a footprint anchored at the given tile.
/// For a 2x2 footprint at anchor (5, 7) with TILE_SIZE = 1.0, returns (5.5, 0, 7.5).
/// Use this to position the tower's visual GameObject so it sits centered on its
/// footprint rather than on the anchor tile's center.
/// For a 2x2 footprint at anchor (5, 7) with TILE_SIZE = 1.0, returns (6, 0, 8) —
/// the geometric center of the four tiles (5,7),(6,7),(5,8),(6,8), which span
/// world XZ [5, 7]. Use this to position the tower's visual GameObject so it sits
/// centered on its footprint rather than on the anchor tile's center.
/// </summary>
public static Vector3 GetFootprintCenterWorld(Vector2Int anchor, Vector2Int footprintSize)
{

View file

@ -1231,12 +1231,13 @@ namespace TD.Levels.Editor
private static bool RenderThumbnail(BakeContext ctx, string thumbnailAssetPath)
{
// Compute world-space bounds of the map's tile region.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
float minX = ctx.MapMinTile.x - halfTile;
float maxX = ctx.MapMaxTile.x + halfTile;
float minZ = ctx.MapMinTile.y - halfTile;
float maxZ = ctx.MapMaxTile.y + halfTile;
// Compute world-space bounds of the map's tile region. Tile N spans world [N, N+1]
// (edge-aligned), so the rect spans from MapMinTile to MapMaxTile + 1 on each axis.
float tileSize = GridCoordinates.TILE_SIZE;
float minX = ctx.MapMinTile.x * tileSize;
float maxX = (ctx.MapMaxTile.x + 1) * tileSize;
float minZ = ctx.MapMinTile.y * tileSize;
float maxZ = (ctx.MapMaxTile.y + 1) * tileSize;
float worldW = maxX - minX;
float worldH = maxZ - minZ;

View file

@ -156,7 +156,6 @@ namespace TD.Levels.Editor
float outwardDelta = edgeIsPositive ? worldDeltaSnapped : -worldDeltaSnapped;
Vector3 size = col.size;
Vector3 center = col.center;
float currentSize = axisIsX ? size.x : size.z;
float newSize = currentSize + outwardDelta;
@ -165,29 +164,44 @@ namespace TD.Levels.Editor
// (don't move the edge at all). This is more predictable than partially honoring it.
if (newSize < MinSize) return;
// Apply the change. The edge that's NOT being dragged should stay put. To keep the
// opposite edge fixed, the center must shift by half the size change in the direction
// of the edge being dragged.
// Apply the change. The edge that's NOT being dragged should stay put. We achieve this
// by adjusting transform.position rather than BoxCollider.center, so the collider's
// center stays locked at (0, 0, 0) — the "center" of the volume's local frame is always
// the geometric center of the box. The position shifts by half the size change in the
// direction of the edge being dragged.
//
// Example (east edge dragged outward by 2 tiles): size.x += 2; center.x += 1.
// Example (west edge dragged outward by 1 tile): size.x += 1; center.x -= 0.5.
float centerShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
// Example (east edge dragged outward by 2 tiles): size.x += 2; transform.position.x += 1.
// Example (west edge dragged outward by 1 tile): size.x += 1; transform.position.x -= 0.5.
//
// Note: positions may land at half-integer values when the size is odd. That's correct
// under the edge-aligned tile convention — bounds align with tile edges when
// (position - size/2) and (position + size/2) are both integers,
// which requires position to have the same fractional part as size/2.
float positionShift = (edgeIsPositive ? 1f : -1f) * (outwardDelta * 0.5f);
if (axisIsX)
{
size.x = newSize;
center.x += centerShift;
}
else
{
size.z = newSize;
center.z += centerShift;
}
Vector3 newPosition = col.transform.position;
if (axisIsX) newPosition.x += positionShift;
else newPosition.z += positionShift;
Undo.RecordObject(col.transform, "Resize Volume Edge");
Undo.RecordObject(col, "Resize Volume Edge");
col.transform.position = newPosition;
col.size = size;
col.center = center;
// Force-lock collider center to zero in case it had drifted from prior edits made
// before this behavior change. Safe to do unconditionally — by design, this tool now
// never wants a non-zero center.
col.center = Vector3.zero;
EditorUtility.SetDirty(col);
EditorUtility.SetDirty(col.transform);
}
private static Vector3 WithY(Vector3 v, float y)

View file

@ -470,13 +470,13 @@ namespace TD.Gameplay
{
Vector3 builderXZ = new Vector3(transform.position.x, 0f, transform.position.z);
// Find the point on the footprint rectangle nearest to the builder.
float minX = anchor.x * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxX = (anchor.x + footprintSize.x - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
float minZ = anchor.y * GridCoordinates.TILE_SIZE - GridCoordinates.TILE_SIZE * 0.5f;
float maxZ = (anchor.y + footprintSize.y - 1) * GridCoordinates.TILE_SIZE
+ GridCoordinates.TILE_SIZE * 0.5f;
// Find the point on the footprint rectangle nearest to the builder. Tile N spans
// world [N, N+1] (edge-aligned), so a footprint at anchor (ax, ay) with size (sx, sy)
// spans world [ax, ax+sx] × [ay, ay+sy].
float minX = anchor.x * GridCoordinates.TILE_SIZE;
float maxX = (anchor.x + footprintSize.x) * GridCoordinates.TILE_SIZE;
float minZ = anchor.y * GridCoordinates.TILE_SIZE;
float maxZ = (anchor.y + footprintSize.y) * GridCoordinates.TILE_SIZE;
float nearestX = Mathf.Clamp(builderXZ.x, minX, maxX);
float nearestZ = Mathf.Clamp(builderXZ.z, minZ, maxZ);

View file

@ -219,19 +219,19 @@ namespace TD.Gameplay
//
// The grid covers tiles from GridOriginTile (inclusive, SW corner)
// to GridOriginTile + GridSize - (1,1) (inclusive, NE corner).
// Each tile is TILE_SIZE wide and centered on its integer coords.
// Each tile is TILE_SIZE wide and edge-aligned: tile N occupies world [N, N+1].
//
// World extent on X:
// left = (GridOriginTile.x - 0.5) * TILE_SIZE
// right = (GridOriginTile.x + GridSize.x - 0.5) * TILE_SIZE
// left = GridOriginTile.x * TILE_SIZE
// right = (GridOriginTile.x + GridSize.x) * TILE_SIZE
// width = GridSize.x * TILE_SIZE
// centerX = (left + right) / 2 = (GridOriginTile.x + (GridSize.x - 1) / 2) * TILE_SIZE
// centerX = (left + right) / 2 = (GridOriginTile.x + GridSize.x / 2) * TILE_SIZE
//
// Same shape on Z (grid-y maps to world-z).
float worldCenterX =
(level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.y + level.GridSize.y * 0.5f) * GridCoordinates.TILE_SIZE;
float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
@ -442,16 +442,17 @@ namespace TD.Gameplay
private void DrawGridBoundsGizmo()
{
// One outlined wire box covering the entire grid extent.
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
// One outlined wire box covering the entire grid extent. Tile N spans world
// [N, N+1], so the grid's SW corner is at GridOriginTile and its NE corner
// is at GridOriginTile + GridSize.
Vector3 sw = new Vector3(
level.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile,
level.GridOriginTile.x * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
level.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile);
level.GridOriginTile.y * GridCoordinates.TILE_SIZE);
Vector3 ne = new Vector3(
(level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile,
(level.GridOriginTile.x + level.GridSize.x) * GridCoordinates.TILE_SIZE,
GridCoordinates.BUILDABLE_PLANE_Y,
(level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile);
(level.GridOriginTile.y + level.GridSize.y) * GridCoordinates.TILE_SIZE);
Gizmos.color = new Color(1f, 1f, 1f, 0.9f); // bright white outline
Vector3 nw = new Vector3(sw.x, sw.y, ne.z);
@ -466,11 +467,12 @@ namespace TD.Gameplay
{
// In play mode the collider exists; draw it directly. In edit mode
// we don't have a collider yet, but we can draw the rectangle that
// the loader WOULD instantiate, so designers can preview it.
// the loader WOULD instantiate, so designers can preview it. Uses
// the same formula as SpawnBuildablePlane (tiles are edge-aligned).
float worldCenterX =
(level.GridOriginTile.x + (level.GridSize.x - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.x + level.GridSize.x * 0.5f) * GridCoordinates.TILE_SIZE;
float worldCenterZ =
(level.GridOriginTile.y + (level.GridSize.y - 1) * 0.5f) * GridCoordinates.TILE_SIZE;
(level.GridOriginTile.y + level.GridSize.y * 0.5f) * GridCoordinates.TILE_SIZE;
float worldSizeX = level.GridSize.x * GridCoordinates.TILE_SIZE;
float worldSizeZ = level.GridSize.y * GridCoordinates.TILE_SIZE;
@ -505,10 +507,11 @@ namespace TD.Gameplay
{
int idx = y * level.GridSize.x + x;
Gizmos.color = walk[idx] ? walkable : blocked;
Vector3 c = new Vector3(
(level.GridOriginTile.x + x) * tile,
drawY,
(level.GridOriginTile.y + y) * tile);
// Use GridToWorld so tile centers stay consistent with the convention
// (tile (N, N) center at world (N+0.5, N+0.5)).
Vector3 c = GridCoordinates.GridToWorld(
new Vector2Int(level.GridOriginTile.x + x, level.GridOriginTile.y + y));
c.y = drawY;
Gizmos.DrawCube(c, size);
}
}
@ -523,7 +526,6 @@ namespace TD.Gameplay
level.OwnerGrid.Length != level.GridSize.x * level.GridSize.y) return;
float tile = GridCoordinates.TILE_SIZE;
float halfTile = tile * 0.5f;
float drawY = GridCoordinates.BUILDABLE_PLANE_Y + 0.010f;
for (int y = 0; y < level.GridSize.y; y++)
@ -534,18 +536,21 @@ namespace TD.Gameplay
PlayerSlot owner = level.OwnerGrid[idx];
if (owner == PlayerSlot.None) continue;
Vector3 c = new Vector3(
(level.GridOriginTile.x + x) * tile,
drawY,
(level.GridOriginTile.y + y) * tile);
// Tile (gx, gy) spans world XZ from (gx, gy) to (gx+1, gy+1) (edge-aligned).
int gx = level.GridOriginTile.x + x;
int gy = level.GridOriginTile.y + y;
float wMinX = gx * tile;
float wMaxX = (gx + 1) * tile;
float wMinZ = gy * tile;
float wMaxZ = (gy + 1) * tile;
Gizmos.color = PlayerColors.Get(owner);
// Draw four edges as a wire square. We could DrawWireCube
// but it would also draw vertical edges we don't want.
Vector3 sw = new Vector3(c.x - halfTile, drawY, c.z - halfTile);
Vector3 nw = new Vector3(c.x - halfTile, drawY, c.z + halfTile);
Vector3 ne = new Vector3(c.x + halfTile, drawY, c.z + halfTile);
Vector3 se = new Vector3(c.x + halfTile, drawY, c.z - halfTile);
Vector3 sw = new Vector3(wMinX, drawY, wMinZ);
Vector3 nw = new Vector3(wMinX, drawY, wMaxZ);
Vector3 ne = new Vector3(wMaxX, drawY, wMaxZ);
Vector3 se = new Vector3(wMaxX, drawY, wMinZ);
Gizmos.DrawLine(sw, nw);
Gizmos.DrawLine(nw, ne);
Gizmos.DrawLine(ne, se);

View file

@ -2,6 +2,7 @@
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Levels;
using TD.Net;
namespace TD.Gameplay
@ -51,6 +52,40 @@ namespace TD.Gameplay
public static LobbyService Instance { get; private set; }
// ----- Networked lobby state --------------------------------------
// Index into MapRegistry.Maps of the currently selected map. Server-write,
// everyone-read. Default 0 (the first registered map = MapRegistry.Default).
// UI subscribes to OnValueChanged to refresh the map browser highlight.
// A stale index (map removed between sessions) is tolerated by MapRegistry.Get
// returning null; RequestStartMatchRpc validates before loading.
private readonly NetworkVariable<int> selectedMapIndex =
new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
/// <summary>
/// The index of the currently selected map within <see cref="MapRegistry.Maps"/>.
/// All clients read the same value; only the host can change it via
/// <see cref="RequestSelectMapRpc"/>.
/// </summary>
public int SelectedMapIndex => selectedMapIndex.Value;
/// <summary>
/// Exposed for UI subscription to <c>OnValueChanged</c>. Treat as read-only —
/// mutations must go through the server via <see cref="RequestSelectMapRpc"/>
/// so the host-only gate is enforced.
/// </summary>
public NetworkVariable<int> SelectedMapIndexVar => selectedMapIndex;
/// <summary>
/// Convenience: resolves the currently selected <see cref="LevelData"/> via
/// <see cref="MapRegistry"/>. Returns null if the registry is missing (e.g. the
/// editor was started directly in the Lobby scene) or the index is stale.
/// </summary>
public LevelData SelectedMap =>
MapRegistry.Instance != null
? MapRegistry.Instance.Get(selectedMapIndex.Value)
: null;
// ----- Lifecycle --------------------------------------------------
public override void OnNetworkSpawn()
@ -67,7 +102,19 @@ namespace TD.Gameplay
// are preserved from the previous lobby visit, but ready state
// resets so each match requires explicit re-readying.
if (IsServer)
{
ResetAllReady();
// If the current selection is invalid for any reason (registry missing,
// index stale from a previous session), snap to the default. Index 0
// is already the default-default; this is a no-op except after the
// registry's contents change between sessions.
var registry = MapRegistry.Instance;
if (registry != null && registry.Get(selectedMapIndex.Value) == null && registry.Count > 0)
{
selectedMapIndex.Value = 0;
}
}
}
public override void OnNetworkDespawn()
@ -79,8 +126,9 @@ namespace TD.Gameplay
/// <summary>
/// Host-only request to begin the match. Validates that every connected
/// player has picked a race and is ready, then transitions every peer
/// to the Match scene via NGO scene management.
/// player has picked a race and is ready AND that the selected map can
/// accommodate the current lobby's player count, then transitions every
/// peer to the selected map's scene via NGO scene management.
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void RequestStartMatchRpc(RpcParams rpcParams = default)
@ -100,7 +148,81 @@ namespace TD.Gameplay
return;
}
NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
// Re-validate the selected map server-side. The UI greys out unselectable
// maps based on lobby size, but a late join could push us above the map's
// PlayerCount between the click and the RPC arriving. Defensive check.
var registry = MapRegistry.Instance;
if (registry == null)
{
Debug.LogError("[LobbyService] Cannot start match: MapRegistry.Instance is null. " +
"Make sure MainMenu was loaded before the lobby (the registry " +
"DontDestroyOnLoads from there).");
return;
}
var selected = registry.Get(selectedMapIndex.Value);
if (selected == null)
{
Debug.LogError($"[LobbyService] Cannot start match: selected map index " +
$"{selectedMapIndex.Value} is not registered.");
return;
}
int playerCount = CountConnectedPlayers();
if (!MapRegistry.IsSelectableFor(selected, playerCount))
{
Debug.Log($"[LobbyService] Cannot start match: map '{selected.MapName}' supports " +
$"up to {selected.PlayerCount} players but the lobby has {playerCount}.");
return;
}
if (string.IsNullOrEmpty(selected.SceneName))
{
Debug.LogError($"[LobbyService] Cannot start match: '{selected.MapName}' has " +
$"empty SceneName (ScenePath='{selected.ScenePath}'). Re-bake the level.");
return;
}
Debug.Log($"[LobbyService] Starting match on '{selected.MapName}' " +
$"(index={selectedMapIndex.Value}, scene='{selected.SceneName}', " +
$"scenePath='{selected.ScenePath}', players={playerCount}).");
NetworkBootstrap.LoadSceneAsHost(selected.SceneName);
}
/// <summary>
/// Host-only request to change the selected map. Validates that the requested index
/// resolves to a real map in <see cref="MapRegistry"/>; if so, writes
/// <see cref="SelectedMapIndex"/> which replicates to every client.
/// </summary>
/// <remarks>
/// Selectability for the current lobby size is NOT enforced here — players are
/// allowed to highlight an oversized map (e.g. while waiting for more friends to
/// join); the actual Start Match call enforces the rule. This keeps the host's
/// intent visible to everyone without preventing them from "claiming" a future map.
/// </remarks>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void RequestSelectMapRpc(int mapIndex, RpcParams rpcParams = default)
{
if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId)
{
Debug.LogWarning("[LobbyService] Non-host client attempted to change the map. Ignored.");
return;
}
var registry = MapRegistry.Instance;
if (registry == null)
{
Debug.LogError("[LobbyService] Cannot change map: MapRegistry.Instance is null.");
return;
}
if (registry.Get(mapIndex) == null)
{
Debug.LogWarning($"[LobbyService] Rejected map index {mapIndex} — not registered.");
return;
}
selectedMapIndex.Value = mapIndex;
}
/// <summary>
@ -154,6 +276,18 @@ namespace TD.Gameplay
return true;
}
/// <summary>
/// Current number of connected players (every <see cref="PlayerMatchState"/> in the
/// static registry). Used by map selectability checks both in the UI and the server-side
/// Start Match validator.
/// </summary>
public static int CountConnectedPlayers()
{
int n = 0;
foreach (var _ in PlayerMatchState.AllPlayers) n++;
return n;
}
// ----- Server helpers --------------------------------------------
private static void ResetAllReady()

View file

@ -0,0 +1,196 @@
// Assets/_Project/Scripts/Gameplay/MapRegistry.cs
using System.Collections.Generic;
using UnityEngine;
using TD.Levels;
namespace TD.Gameplay
{
/// <summary>
/// Persistent (DontDestroyOnLoad) singleton that holds every <see cref="LevelData"/> available
/// to the lobby's map browser. Mirrors <see cref="RaceRegistry"/>'s pattern.
/// </summary>
/// <remarks>
/// <para><b>Inspector setup.</b> Place ONE <c>MapRegistry</c> GameObject in the <b>MainMenu
/// scene</b> only. Drag every <see cref="LevelData"/> asset that should appear in the lobby's
/// map browser into the <c>Maps</c> array. The first non-null entry becomes the
/// <see cref="Default"/> map (auto-selected when a lobby first opens). Mark itself
/// <c>DontDestroyOnLoad</c> on Awake so it survives MainMenu → Lobby → Match transitions and
/// is reachable from any scene via <see cref="Instance"/>.</para>
///
/// <para><b>Why MainMenu-only?</b> Same reason as <see cref="RaceRegistry"/>: a single
/// authoritative array prevents the "I updated one and forgot the other" designer trap.
/// Duplicate instances dropped into Lobby or Match scenes self-destroy on Awake.</para>
///
/// <para><b>Editor-only standalone testing.</b> If you open the Lobby or Match scene directly
/// from the editor without going through MainMenu, no MapRegistry exists. <see cref="Instance"/>
/// is null; callers should handle that (the lobby UI shows an empty browser, Quick Start falls
/// back to a hardcoded scene, etc.). For standalone testing, temporarily add a registry to
/// whatever scene you're testing — but don't commit it.</para>
///
/// <para><b>Selectability.</b> A map is selectable in a lobby of N players iff
/// <c>N &lt;= map.PlayerCount</c>. The lobby UI still shows maps it can't currently use, just
/// greyed out with an explanatory label — so players can see what other maps exist and how
/// many players they'd need to play them.</para>
///
/// <para><b>Plain MonoBehaviour.</b> Not a NetworkBehaviour — every peer has the same LevelData
/// assets in the build. Network state tracks only the selected map's index via
/// <c>LobbyService.SelectedMapIndex</c>.</para>
/// </remarks>
public class MapRegistry : MonoBehaviour
{
// ----- Singleton -------------------------------------------------
public static MapRegistry Instance { get; private set; }
// ----- Inspector --------------------------------------------------
[Tooltip("All LevelData assets that should appear in the lobby's map browser. Drag each " +
"asset into the array. The FIRST non-null entry becomes the default selection " +
"when a lobby first opens; order subsequent entries however you want them sorted " +
"in the UI. Null entries and assets with empty MapName are skipped with a warning.")]
[SerializeField] private LevelData[] maps;
// ----- Public API -------------------------------------------------
/// <summary>
/// All registered maps in inspector order. Never returns null entries; entries that failed
/// validation in Awake are filtered out. Safe to iterate any time after Awake.
/// </summary>
public IReadOnlyList<LevelData> Maps => validatedMaps;
/// <summary>
/// Total number of valid registered maps. Equivalent to <c>Maps.Count</c> but cheaper
/// since it doesn't allocate an enumerator.
/// </summary>
public int Count => validatedMaps.Count;
/// <summary>
/// The map auto-selected when a lobby first opens. Returns the first valid entry in the
/// inspector array, or null if the registry has no valid maps (which would be a setup
/// error — Quick Start and lobby will both log and degrade).
/// </summary>
public LevelData Default => validatedMaps.Count > 0 ? validatedMaps[0] : null;
/// <summary>
/// Returns the map at the given index, or null if the index is out of range. Tolerant of
/// stale indices that might survive a registry edit (e.g. a NetworkVariable holding an
/// index whose map was removed).
/// </summary>
public LevelData Get(int index)
{
if (index < 0 || index >= validatedMaps.Count) return null;
return validatedMaps[index];
}
/// <summary>
/// Returns the index of the given map in <see cref="Maps"/>, or -1 if it isn't registered.
/// Use this when you have a LevelData reference and need to sync the selection over the
/// network as an integer.
/// </summary>
public int IndexOf(LevelData map)
{
if (map == null) return -1;
for (int i = 0; i < validatedMaps.Count; i++)
{
if (validatedMaps[i] == map) return i;
}
return -1;
}
/// <summary>
/// True if a lobby with <paramref name="playerCount"/> players is allowed to start a match
/// on <paramref name="map"/>. Currently the rule is just "lobby size must fit"; if minimum
/// player counts become a thing later, gate them here.
/// </summary>
public static bool IsSelectableFor(LevelData map, int playerCount)
{
if (map == null) return false;
if (playerCount < 1) return false;
return playerCount <= map.PlayerCount;
}
// ----- Lifecycle --------------------------------------------------
// Validated subset of the inspector array; built once in Awake and never mutated.
// Holding a separate list lets us silently filter out null/invalid entries without
// mutating the inspector array (which would surprise designers).
private readonly List<LevelData> validatedMaps = new List<LevelData>();
private void Awake()
{
// Persistent-singleton pattern: first instance wins, duplicates self-destroy.
if (Instance != null && Instance != this)
{
Debug.LogWarning(
$"[MapRegistry] Persistent instance already exists. " +
$"Destroying duplicate in scene '{gameObject.scene.name}'. " +
$"Keep MapRegistry in the MainMenu scene only.");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
BuildLookup();
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ----- Private ----------------------------------------------------
private void BuildLookup()
{
validatedMaps.Clear();
if (maps == null || maps.Length == 0)
{
Debug.LogWarning("[MapRegistry] No LevelData assets assigned. Drag assets into " +
"the Maps array. Lobby map browser will be empty.");
return;
}
var seen = new HashSet<LevelData>();
foreach (var map in maps)
{
if (map == null) continue;
// Duplicates are likely an authoring mistake (designer dragged the same asset
// twice); keep the first occurrence and warn.
if (!seen.Add(map))
{
Debug.LogWarning($"[MapRegistry] Duplicate LevelData '{map.name}' in Maps " +
$"array. Keeping first occurrence only.");
continue;
}
if (string.IsNullOrEmpty(map.MapName))
{
Debug.LogWarning($"[MapRegistry] '{map.name}' has empty MapName — skipping. " +
"Set MapName on the LevelData asset.");
continue;
}
if (string.IsNullOrEmpty(map.ScenePath))
{
Debug.LogWarning($"[MapRegistry] '{map.name}' has empty ScenePath — skipping. " +
"Bake the level so ScenePath gets populated.");
continue;
}
if (map.PlayerCount < 1)
{
Debug.LogWarning($"[MapRegistry] '{map.name}' has PlayerCount={map.PlayerCount} " +
"(must be >= 1) — skipping.");
continue;
}
validatedMaps.Add(map);
}
Debug.Log($"[MapRegistry] Registered {validatedMaps.Count} map(s). " +
$"Default = '{(Default != null ? Default.MapName : "<none>")}'.");
}
}
}

View file

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

View file

@ -6,6 +6,7 @@ using UnityEngine;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
using TD.Levels;
using TD.Net;
namespace TD.UI
@ -41,11 +42,18 @@ namespace TD.UI
{
// ----- Cached UI elements -----------------------------------------
private VisualElement mapListContainer;
private VisualElement playerListContainer;
private Button startMatchButton;
private Button leaveButton;
private Label statusLabel;
// Signature snapshot for the map list, mirroring the player-list pattern.
// Components: registry count, selected index, current player count, host flag.
// Without this, the cards rebuild every frame and the Clickable manipulator
// loses presses (same root cause as the player-list signature).
private string lastMapListSignature = string.Empty;
// ----- Race selection overlay ------------------------------------
[Tooltip("Sibling RaceSelectionOverlay component that owns the race-pick UI. " +
@ -88,6 +96,7 @@ namespace TD.UI
private void Update()
{
if (playerListContainer == null) return;
RefreshMapList();
RefreshPlayerList();
RefreshStartButton();
}
@ -112,6 +121,28 @@ namespace TD.UI
title.style.marginBottom = 24;
root.Add(title);
// Map selection panel — host clicks to change, everyone sees the current pick.
// Horizontal row of cards rebuilt by RefreshMapList when its signature changes.
var mapPanelLabel = new Label("Map");
mapPanelLabel.style.fontSize = 18;
mapPanelLabel.style.color = new Color(0.85f, 0.85f, 0.85f);
mapPanelLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
mapPanelLabel.style.marginBottom = 6;
root.Add(mapPanelLabel);
mapListContainer = new VisualElement();
mapListContainer.style.flexDirection = FlexDirection.Row;
mapListContainer.style.justifyContent = Justify.Center;
mapListContainer.style.flexWrap = Wrap.Wrap;
mapListContainer.style.minWidth = 520;
mapListContainer.style.paddingTop = 8;
mapListContainer.style.paddingBottom = 8;
mapListContainer.style.paddingLeft = 8;
mapListContainer.style.paddingRight = 8;
mapListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
mapListContainer.style.marginBottom = 24;
root.Add(mapListContainer);
// Player list container (rebuilt every Update from AllPlayers).
playerListContainer = new VisualElement();
playerListContainer.style.flexDirection = FlexDirection.Column;
@ -153,6 +184,157 @@ namespace TD.UI
// ----- Per-frame refresh ------------------------------------------
private void RefreshMapList()
{
// Inputs that determine the rendered state of the map row. Anything not in this
// signature won't trigger a rebuild — match what BuildMapCard reads.
var registry = MapRegistry.Instance;
var svc = LobbyService.Instance;
int registryCount = registry != null ? registry.Count : 0;
int selectedIndex = svc != null ? svc.SelectedMapIndex : -1;
int playerCount = LobbyService.CountConnectedPlayers();
bool isHost = NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost;
string signature = $"{registryCount}:{selectedIndex}:{playerCount}:{(isHost ? 'H' : 'C')}";
if (signature == lastMapListSignature) return;
lastMapListSignature = signature;
mapListContainer.Clear();
if (registry == null || registryCount == 0)
{
var emptyLabel = new Label("(no maps available — MapRegistry missing)");
emptyLabel.style.color = new Color(0.7f, 0.5f, 0.5f);
emptyLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
mapListContainer.Add(emptyLabel);
return;
}
for (int i = 0; i < registryCount; i++)
{
var map = registry.Get(i);
if (map == null) continue;
bool isSelected = i == selectedIndex;
bool fitsLobby = MapRegistry.IsSelectableFor(map, playerCount);
bool clickable = isHost && fitsLobby;
mapListContainer.Add(BuildMapCard(map, i, isSelected, fitsLobby, clickable, playerCount));
}
}
private VisualElement BuildMapCard(LevelData map, int index, bool isSelected,
bool fitsLobby, bool clickable, int playerCount)
{
var card = new VisualElement();
card.style.width = 180;
card.style.height = 220;
card.style.marginRight = 8;
card.style.marginLeft = 8;
card.style.paddingTop = 8;
card.style.paddingBottom = 8;
card.style.paddingLeft = 8;
card.style.paddingRight = 8;
card.style.alignItems = Align.Center;
card.style.justifyContent = Justify.FlexStart;
// Background reflects selectability: highlighted when selected, dim when oversized.
Color background = isSelected
? new Color(0.20f, 0.32f, 0.55f)
: new Color(0.12f, 0.12f, 0.16f);
if (!fitsLobby) background.a *= 0.7f;
card.style.backgroundColor = background;
// Border to make the selection visually unambiguous.
float borderWidth = isSelected ? 3f : 1f;
Color borderColor = isSelected
? new Color(0.45f, 0.70f, 1f)
: new Color(0.25f, 0.25f, 0.30f);
card.style.borderTopWidth = borderWidth;
card.style.borderBottomWidth = borderWidth;
card.style.borderLeftWidth = borderWidth;
card.style.borderRightWidth = borderWidth;
card.style.borderTopColor = borderColor;
card.style.borderBottomColor = borderColor;
card.style.borderLeftColor = borderColor;
card.style.borderRightColor = borderColor;
// Thumbnail (top of card). Falls back to a neutral placeholder if MapThumbnail isn't set.
var thumb = new VisualElement();
thumb.style.width = 140;
thumb.style.height = 105;
thumb.style.marginBottom = 6;
thumb.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f);
if (map.MapThumbnail != null)
{
thumb.style.backgroundImage = new StyleBackground(map.MapThumbnail);
}
// Dim the thumbnail when the map doesn't fit the lobby — reinforces the disabled look.
if (!fitsLobby)
{
thumb.style.opacity = 0.45f;
}
card.Add(thumb);
// Map name.
var nameLabel = new Label(map.MapName);
nameLabel.style.color = fitsLobby ? Color.white : new Color(0.7f, 0.6f, 0.6f);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.fontSize = 14;
nameLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
card.Add(nameLabel);
// Player count label — tells the user the capacity, and why the card is greyed out
// when oversized. Live player count is shown alongside the cap so it's obvious why.
string countText = fitsLobby
? $"Up to {map.PlayerCount} players"
: $"Up to {map.PlayerCount} — needs ≤ {map.PlayerCount} (lobby has {playerCount})";
var countLabel = new Label(countText);
countLabel.style.color = fitsLobby
? new Color(0.75f, 0.75f, 0.75f)
: new Color(0.85f, 0.55f, 0.45f);
countLabel.style.fontSize = 11;
countLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
countLabel.style.marginTop = 2;
countLabel.style.whiteSpace = WhiteSpace.Normal;
card.Add(countLabel);
// Make the whole card clickable for host (when the map fits the lobby). Non-hosts
// see the cards but can't change selection. Capture `index` in a local so the
// closure doesn't bind to the loop variable.
if (clickable)
{
int capturedIndex = index;
card.RegisterCallback<ClickEvent>(_ => OnMapCardClicked(capturedIndex));
// Standard hover affordance to advertise interactivity.
card.RegisterCallback<MouseEnterEvent>(_ =>
{
if (!isSelected)
card.style.backgroundColor = new Color(0.17f, 0.20f, 0.28f);
});
card.RegisterCallback<MouseLeaveEvent>(_ =>
{
if (!isSelected)
card.style.backgroundColor = new Color(0.12f, 0.12f, 0.16f);
});
}
return card;
}
private void OnMapCardClicked(int index)
{
var svc = LobbyService.Instance;
if (svc == null)
{
Debug.LogError("[LobbyController] LobbyService.Instance is null — cannot change map.");
return;
}
// Don't bother round-tripping if we're already on this map.
if (svc.SelectedMapIndex == index) return;
svc.RequestSelectMapRpc(index);
}
private void RefreshPlayerList()
{
// Sort by slot for stable ordering. AllPlayers is keyed by clientId
@ -285,7 +467,32 @@ namespace TD.UI
startMatchButton.style.display = isHost ? DisplayStyle.Flex : DisplayStyle.None;
if (!isHost) return;
bool canStart = LobbyService.AreAllPlayersReady(out string reason);
// Two gates: readiness (existing) and map selectability (new). Show whichever
// failure is preventing the start so the host knows what to fix. Readiness
// is checked first because it's the more common blocker.
bool readyOk = LobbyService.AreAllPlayersReady(out string readyReason);
string reason = readyOk ? string.Empty : readyReason;
bool mapOk = true;
if (readyOk)
{
var svc = LobbyService.Instance;
var selected = svc != null ? svc.SelectedMap : null;
int playerCount = LobbyService.CountConnectedPlayers();
if (selected == null)
{
mapOk = false;
reason = "No map selected.";
}
else if (!MapRegistry.IsSelectableFor(selected, playerCount))
{
mapOk = false;
reason = $"'{selected.MapName}' supports up to {selected.PlayerCount} " +
$"players; lobby has {playerCount}.";
}
}
bool canStart = readyOk && mapOk;
startMatchButton.SetEnabled(canStart);
statusLabel.text = canStart ? string.Empty : reason;
}

View file

@ -191,13 +191,13 @@ namespace TD.UI.Minimap
terrainLayer.style.backgroundImage =
new StyleBackground(Background.FromTexture2D(bakedTerrain));
// World extents of the baked rectangle. Tile (n) covers world n - 0.5 to n + 0.5.
// World extents of the baked rectangle. Tile (n) spans world [n, n+1] (edge-aligned),
// so the rectangle covers [GridOriginTile, GridOriginTile + GridSize] on each axis.
var data = loader.LevelData;
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
float minX = data.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile;
float maxX = (data.GridOriginTile.x + data.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile;
float minZ = data.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile;
float maxZ = (data.GridOriginTile.y + data.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile;
float minX = data.GridOriginTile.x * GridCoordinates.TILE_SIZE;
float maxX = (data.GridOriginTile.x + data.GridSize.x) * GridCoordinates.TILE_SIZE;
float minZ = data.GridOriginTile.y * GridCoordinates.TILE_SIZE;
float maxZ = (data.GridOriginTile.y + data.GridSize.y) * GridCoordinates.TILE_SIZE;
worldMin = new Vector2(minX, minZ);
worldMax = new Vector2(maxX, maxZ);