Minimap!
This commit is contained in:
parent
f7720a9915
commit
6c37e569ab
18 changed files with 1169 additions and 323 deletions
|
|
@ -6,7 +6,8 @@
|
|||
"Bash(findstr /v \"Library\\\\PackageCache\")",
|
||||
"Bash(dir \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\" /a-d)",
|
||||
"Bash(mkdir -p \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\UI\")",
|
||||
"Bash(mkdir -p \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\Scripts\\\\UI\")"
|
||||
"Bash(mkdir -p \"C:\\\\Users\\\\catos\\\\UnityTowerDefense\\\\Assets\\\\_Project\\\\Scripts\\\\UI\")",
|
||||
"Bash(mkdir -p \"C:/Users/catos/UnityTowerDefense/Assets/_Project/Scripts/UI/Minimap\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!84 &8400000
|
||||
RenderTexture:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: MinimapRT
|
||||
m_ImageContentsHash:
|
||||
serializedVersion: 2
|
||||
Hash: 00000000000000000000000000000000
|
||||
m_IsAlphaChannelOptional: 0
|
||||
serializedVersion: 6
|
||||
m_Width: 256
|
||||
m_Height: 256
|
||||
m_AntiAliasing: 1
|
||||
m_MipCount: -1
|
||||
m_DepthStencilFormat: 94
|
||||
m_ColorFormat: 8
|
||||
m_MipMap: 0
|
||||
m_GenerateMips: 1
|
||||
m_SRGB: 0
|
||||
m_UseDynamicScale: 0
|
||||
m_UseDynamicScaleExplicit: 0
|
||||
m_BindMS: 0
|
||||
m_EnableCompatibleFormat: 1
|
||||
m_EnableRandomWrite: 0
|
||||
m_TextureSettings:
|
||||
serializedVersion: 2
|
||||
m_FilterMode: 1
|
||||
m_Aniso: 0
|
||||
m_MipBias: 0
|
||||
m_WrapU: 1
|
||||
m_WrapV: 1
|
||||
m_WrapW: 1
|
||||
m_Dimension: 2
|
||||
m_VolumeDepth: 1
|
||||
m_ShadowSamplingMode: 2
|
||||
|
|
@ -237,134 +237,6 @@ Transform:
|
|||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &260379225
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 260379229}
|
||||
- component: {fileID: 260379228}
|
||||
- component: {fileID: 260379226}
|
||||
m_Layer: 0
|
||||
m_Name: MinimapCamera
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &260379226
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 260379225}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
|
||||
m_RenderShadows: 1
|
||||
m_RequiresDepthTextureOption: 2
|
||||
m_RequiresOpaqueTextureOption: 2
|
||||
m_CameraType: 0
|
||||
m_Cameras: []
|
||||
m_RendererIndex: -1
|
||||
m_VolumeLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 1
|
||||
m_VolumeTrigger: {fileID: 0}
|
||||
m_VolumeFrameworkUpdateModeOption: 2
|
||||
m_RenderPostProcessing: 0
|
||||
m_Antialiasing: 0
|
||||
m_AntialiasingQuality: 2
|
||||
m_StopNaN: 0
|
||||
m_Dithering: 0
|
||||
m_ClearDepth: 1
|
||||
m_AllowXRRendering: 1
|
||||
m_AllowHDROutput: 1
|
||||
m_UseScreenCoordOverride: 0
|
||||
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_RequiresDepthTexture: 0
|
||||
m_RequiresColorTexture: 0
|
||||
m_TaaSettings:
|
||||
m_Quality: 3
|
||||
m_FrameInfluence: 0.1
|
||||
m_JitterScale: 1
|
||||
m_MipBias: 0
|
||||
m_VarianceClampScale: 0.9
|
||||
m_ContrastAdaptiveSharpening: 0
|
||||
m_Version: 2
|
||||
--- !u!20 &260379228
|
||||
Camera:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 260379225}
|
||||
m_Enabled: 1
|
||||
serializedVersion: 2
|
||||
m_ClearFlags: 1
|
||||
m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
|
||||
m_projectionMatrixMode: 1
|
||||
m_GateFitMode: 2
|
||||
m_FOVAxisMode: 0
|
||||
m_Iso: 200
|
||||
m_ShutterSpeed: 0.005
|
||||
m_Aperture: 16
|
||||
m_FocusDistance: 10
|
||||
m_FocalLength: 50
|
||||
m_BladeCount: 5
|
||||
m_Curvature: {x: 2, y: 11}
|
||||
m_BarrelClipping: 0.25
|
||||
m_Anamorphism: 0
|
||||
m_SensorSize: {x: 36, y: 24}
|
||||
m_LensShift: {x: 0, y: 0}
|
||||
m_NormalizedViewPortRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 1
|
||||
height: 1
|
||||
near clip plane: 0.3
|
||||
far clip plane: 1000
|
||||
field of view: 60
|
||||
orthographic: 1
|
||||
orthographic size: 80
|
||||
m_Depth: 0
|
||||
m_CullingMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 983
|
||||
m_RenderingPath: -1
|
||||
m_TargetTexture: {fileID: 8400000, guid: e640b4de7a1dee049b38712e7eff82ea, type: 2}
|
||||
m_TargetDisplay: 0
|
||||
m_TargetEye: 3
|
||||
m_HDR: 1
|
||||
m_AllowMSAA: 1
|
||||
m_AllowDynamicResolution: 0
|
||||
m_ForceIntoRT: 0
|
||||
m_OcclusionCulling: 1
|
||||
m_StereoConvergence: 10
|
||||
m_StereoSeparation: 0.022
|
||||
--- !u!4 &260379229
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 260379225}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068}
|
||||
m_LocalPosition: {x: 14, y: 100, z: 39}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0}
|
||||
--- !u!1 &304575571
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -748,7 +620,7 @@ Transform:
|
|||
m_GameObject: {fileID: 441239879}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 1, y: 0, z: 2}
|
||||
m_LocalPosition: {x: 5, y: 0, z: 3}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
|
|
@ -867,7 +739,7 @@ Transform:
|
|||
m_GameObject: {fileID: 611926972}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||
m_LocalPosition: {x: 39, y: 2, z: 40}
|
||||
m_LocalPosition: {x: 43, y: 2, z: 41}
|
||||
m_LocalScale: {x: 100, y: 5, z: 20}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -979,7 +851,7 @@ Transform:
|
|||
m_GameObject: {fileID: 643505902}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||
m_LocalPosition: {x: 4, y: 2, z: 41}
|
||||
m_LocalPosition: {x: 8, y: 2, z: 42}
|
||||
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -1098,8 +970,8 @@ BoxCollider:
|
|||
m_ProvidesContacts: 0
|
||||
m_Enabled: 1
|
||||
serializedVersion: 3
|
||||
m_Size: {x: 32, y: 0.5, z: 87}
|
||||
m_Center: {x: 14.5, y: 0, z: 41}
|
||||
m_Size: {x: 36, y: 0.5, z: 88}
|
||||
m_Center: {x: 12.5, y: 0, z: 40.5}
|
||||
--- !u!1 &1058315973
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -1132,7 +1004,7 @@ MonoBehaviour:
|
|||
m_EditorClassIdentifier: Assembly-CSharp::TD.UI.HUDController
|
||||
placementController: {fileID: 1597884411}
|
||||
placementManager: {fileID: 1507514108}
|
||||
minimapRenderTexture: {fileID: 8400000, guid: e640b4de7a1dee049b38712e7eff82ea, type: 2}
|
||||
cameraController: {fileID: 1239994223}
|
||||
rejectionMessageDuration: 2.5
|
||||
--- !u!114 &1058315975
|
||||
MonoBehaviour:
|
||||
|
|
@ -1479,8 +1351,8 @@ BoxCollider:
|
|||
m_ProvidesContacts: 0
|
||||
m_Enabled: 1
|
||||
serializedVersion: 3
|
||||
m_Size: {x: 7, y: 1, z: 2}
|
||||
m_Center: {x: 0, y: 0, z: 2.5}
|
||||
m_Size: {x: 7, y: 1, z: 4}
|
||||
m_Center: {x: 0, y: 0, z: 1.5}
|
||||
--- !u!1 &1464027360
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
|
@ -1588,7 +1460,7 @@ Transform:
|
|||
m_GameObject: {fileID: 1464027360}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0.7071068, z: 0, w: 0.7071068}
|
||||
m_LocalPosition: {x: 14, y: 0, z: 39}
|
||||
m_LocalPosition: {x: 14, y: 0, z: 41}
|
||||
m_LocalScale: {x: 10, y: 1, z: 5}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -1990,7 +1862,7 @@ Transform:
|
|||
m_GameObject: {fileID: 1949204941}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||
m_LocalPosition: {x: -11, y: 2, z: 40}
|
||||
m_LocalPosition: {x: -7, y: 2, z: 41}
|
||||
m_LocalScale: {x: 100, y: 5, z: 20}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -2170,7 +2042,7 @@ Transform:
|
|||
m_GameObject: {fileID: 2024858685}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 14, y: 2, z: 89}
|
||||
m_LocalPosition: {x: 18, y: 2, z: 90}
|
||||
m_LocalScale: {x: 50, y: 5, z: 5}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -2282,7 +2154,7 @@ Transform:
|
|||
m_GameObject: {fileID: 2105067734}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: -0, y: 0.70710576, z: -0, w: 0.70710784}
|
||||
m_LocalPosition: {x: 24, y: 2, z: 41}
|
||||
m_LocalPosition: {x: 28, y: 2, z: 42}
|
||||
m_LocalScale: {x: 8, y: 5, z: 12.6126}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
|
|
@ -2309,4 +2181,3 @@ SceneRoots:
|
|||
- {fileID: 611926976}
|
||||
- {fileID: 1222526238}
|
||||
- {fileID: 1058315976}
|
||||
- {fileID: 260379229}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57a63c71a6e777bc1d8852d8ba23b96474946cb7aa9b424f35a64cf7fd3149b4
|
||||
size 8744
|
||||
oid sha256:af5c029677d941da474a8bf47c7bafbf704308b7bde5335f4f311d0da621316c
|
||||
size 9770
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ namespace TD.Core
|
|||
/// Canonical color palette for player slots, used by editor gizmos to color volumes by owner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Color names follow the 9-player map design doc (red, green, blue, purple, yellow, gray,
|
||||
/// teal, olive, dark gray). The exact RGB values have been tuned for readability when drawn
|
||||
/// as translucent gizmos against Unity's default scene-view background. Tweak the constants
|
||||
/// below if any color reads poorly in practice.
|
||||
/// Color names follow the 9-player map design doc (orange, green, blue, purple, yellow, gray,
|
||||
/// teal, olive, dark gray). Red is reserved for enemy units on the minimap, so Player 1 uses
|
||||
/// orange instead. The exact RGB values have been tuned for readability when drawn as
|
||||
/// translucent gizmos against Unity's default scene-view background. Tweak the constants below
|
||||
/// if any color reads poorly in practice.
|
||||
///
|
||||
/// The "ErrorPink" color is reserved for diagnostic use: if a volume's owner is
|
||||
/// <see cref="PlayerSlot.None"/> (which should never happen on a valid volume), the gizmo
|
||||
|
|
@ -19,7 +20,7 @@ namespace TD.Core
|
|||
{
|
||||
// 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 Player1Orange = HexRGB(0xE0, 0x7A, 0x2E); // orange (red reserved for enemies)
|
||||
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
|
||||
|
|
@ -43,7 +44,7 @@ namespace TD.Core
|
|||
{
|
||||
switch (slot)
|
||||
{
|
||||
case PlayerSlot.Player1: return Player1Red;
|
||||
case PlayerSlot.Player1: return Player1Orange;
|
||||
case PlayerSlot.Player2: return Player2Green;
|
||||
case PlayerSlot.Player3: return Player3Blue;
|
||||
case PlayerSlot.Player4: return Player4Purple;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Unity.Netcode;
|
|||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Towers;
|
||||
using TD.UI.Minimap;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -45,7 +46,7 @@ namespace TD.Gameplay
|
|||
/// traversal.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class Builder : NetworkBehaviour
|
||||
public class Builder : NetworkBehaviour, IMinimapEntity
|
||||
{
|
||||
// ----- Static registry --------------------------------------------
|
||||
|
||||
|
|
@ -188,6 +189,7 @@ namespace TD.Gameplay
|
|||
public override void OnNetworkSpawn()
|
||||
{
|
||||
s_byClientId[OwnerClientId] = this;
|
||||
MinimapEntityRegistry.Register(this);
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
|
|
@ -217,6 +219,7 @@ namespace TD.Gameplay
|
|||
{
|
||||
if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
|
||||
s_byClientId.Remove(OwnerClientId);
|
||||
MinimapEntityRegistry.Deregister(this);
|
||||
|
||||
// Server-only cleanup: despawn any remaining build-site visuals so they
|
||||
// don't leak when a player disconnects mid-construction.
|
||||
|
|
@ -233,6 +236,32 @@ namespace TD.Gameplay
|
|||
|
||||
// (NetworkList is owned by NGO; no manual Dispose needed in NGO 2.x.)
|
||||
|
||||
// ----- IMinimapEntity ---------------------------------------------
|
||||
//
|
||||
// Read every minimap refresh tick. Position is read live (NetworkTransform
|
||||
// interpolation handles remote-client smoothing). Color comes from the same
|
||||
// OwnerClientId → PlayerSlot stub mapping used by ApplyOwnerColor; both will pick
|
||||
// up the real mapping when MatchState lands.
|
||||
|
||||
Vector3 IMinimapEntity.WorldPosition => transform.position;
|
||||
|
||||
Color IMinimapEntity.MinimapColor
|
||||
{
|
||||
get
|
||||
{
|
||||
byte slotByte = (byte)(OwnerClientId + 1);
|
||||
PlayerSlot slot = (slotByte >= 1 && slotByte <= 9)
|
||||
? (PlayerSlot)slotByte : PlayerSlot.None;
|
||||
return PlayerColors.Get(slot);
|
||||
}
|
||||
}
|
||||
|
||||
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Builder;
|
||||
|
||||
// Diameter of the builder dot in world units. The view enforces a pixel-size floor so
|
||||
// builders remain visible at full zoom-out regardless of this value.
|
||||
float IMinimapEntity.MinimapWorldSize => 0.6f;
|
||||
|
||||
// ----- Owner color tinting ----------------------------------------
|
||||
|
||||
// Lazily allocated; reused across renderers. Construction in a field initializer
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using UnityEngine;
|
|||
using UnityEngine.InputSystem;
|
||||
using TD.Core;
|
||||
using TD.Levels;
|
||||
using TD.UI;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -218,6 +219,12 @@ namespace TD.Gameplay
|
|||
var kb = Keyboard.current;
|
||||
if (mouse == null) return;
|
||||
|
||||
// If the cursor is over an interactive HUD element (e.g., the minimap, which has
|
||||
// its own scroll-wheel zoom), don't also drive the world camera. UI Toolkit's
|
||||
// WheelEvent.StopPropagation only blocks UI-side bubbling — it has no effect on
|
||||
// raw Input System polling, so we have to gate here explicitly.
|
||||
if (HUDController.IsPointerOverInteractiveHud(mouse.position.ReadValue())) return;
|
||||
|
||||
// Scroll wheel values are inconsistent across platforms and devices:
|
||||
// - Windows click-wheel mice: ±120 per click (legacy Win32 convention)
|
||||
// - Free-spin wheels and trackpads: small fractional values per tick
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Unity.Netcode;
|
|||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Towers;
|
||||
using TD.UI.Minimap;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
|
|
@ -42,7 +43,7 @@ namespace TD.Gameplay
|
|||
/// <c>TowerCombat</c> component added to the same prefab.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class TowerInstance : NetworkBehaviour
|
||||
public class TowerInstance : NetworkBehaviour, IMinimapEntity
|
||||
{
|
||||
// ----- Networked state ------------------------------------------------
|
||||
|
||||
|
|
@ -167,6 +168,9 @@ namespace TD.Gameplay
|
|||
// Apply owner color to the mesh renderer.
|
||||
ApplyOwnerColor();
|
||||
|
||||
// Register for minimap rendering.
|
||||
MinimapEntityRegistry.Register(this);
|
||||
|
||||
if (resolvedDefinition != null)
|
||||
{
|
||||
Debug.Log($"[TowerInstance] Spawned '{resolvedDefinition.DisplayName}' " +
|
||||
|
|
@ -180,6 +184,33 @@ namespace TD.Gameplay
|
|||
// Un-stamp the footprint when the tower is destroyed (sold, wave end, etc.)
|
||||
// so the tiles become walkable and buildable again.
|
||||
StampFootprint(walkable: true, occupied: false);
|
||||
|
||||
MinimapEntityRegistry.Deregister(this);
|
||||
}
|
||||
|
||||
// ----- IMinimapEntity -------------------------------------------------
|
||||
//
|
||||
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
||||
// the replicated ownerSlot; reads safely on every client because ownerSlot is set in
|
||||
// OnNetworkSpawn before this entity is added to the registry.
|
||||
|
||||
Vector3 IMinimapEntity.WorldPosition => transform.position;
|
||||
Color IMinimapEntity.MinimapColor => PlayerColors.Get(ownerSlot.Value);
|
||||
MinimapIconKind IMinimapEntity.IconKind => MinimapIconKind.Tower;
|
||||
|
||||
// Tower footprint in world units. Uses the larger axis if the footprint isn't square,
|
||||
// so an Nx1 tower still occupies its full long-side on the minimap.
|
||||
// Falls back to one tile if the definition hasn't resolved yet (transient, harmless).
|
||||
float IMinimapEntity.MinimapWorldSize
|
||||
{
|
||||
get
|
||||
{
|
||||
if (resolvedDefinition == null) return GridCoordinates.TILE_SIZE;
|
||||
int extent = Mathf.Max(
|
||||
resolvedDefinition.FootprintSize.x,
|
||||
resolvedDefinition.FootprintSize.y);
|
||||
return extent * GridCoordinates.TILE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Private helpers ------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using UnityEngine;
|
|||
using UnityEngine.UIElements;
|
||||
using TD.Gameplay;
|
||||
using TD.Towers;
|
||||
using TD.UI.Minimap;
|
||||
|
||||
namespace TD.UI
|
||||
{
|
||||
|
|
@ -24,9 +25,9 @@ namespace TD.UI
|
|||
[Tooltip("The TowerPlacementManager NetworkObject in the scene.")]
|
||||
[SerializeField] private TowerPlacementManager placementManager;
|
||||
|
||||
[Header("Minimap")]
|
||||
[Tooltip("RenderTexture that the minimap camera renders into.")]
|
||||
[SerializeField] private RenderTexture minimapRenderTexture;
|
||||
[Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " +
|
||||
"and drag-to-pan.")]
|
||||
[SerializeField] private CameraController cameraController;
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float rejectionMessageDuration = 2.5f;
|
||||
|
|
@ -48,6 +49,52 @@ namespace TD.UI
|
|||
private Coroutine rejectionFadeCoroutine;
|
||||
private bool gridPopulated;
|
||||
private bool uiInitialized;
|
||||
private MinimapView minimapView;
|
||||
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
|
||||
|
||||
// ----- Static hit-test probe --------------------------------------
|
||||
|
||||
// Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera,
|
||||
// input handlers) can query IsPointerOverInteractiveHud without taking a direct
|
||||
// reference to HUDController.
|
||||
private static IPanel s_hudPanel;
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="screenMousePos"/> falls over an interactive (non-ignore)
|
||||
/// HUD element. Non-UI systems that consume mouse input (camera scroll-zoom, edge-pan)
|
||||
/// should gate their handling on this so a cursor over the minimap, command grid, or
|
||||
/// any other interactive HUD region doesn't drive both the HUD and the world at once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Convention: <paramref name="screenMousePos"/> uses Unity Input System screen coords
|
||||
/// (origin bottom-left, y up). Returns false before the HUD has initialized; safe to
|
||||
/// call from any system at any time.
|
||||
/// </remarks>
|
||||
public static bool IsPointerOverInteractiveHud(Vector2 screenMousePos)
|
||||
{
|
||||
if (s_hudPanel == null) return false;
|
||||
|
||||
// Coord convention rabbit hole:
|
||||
// - Screen mouse position: origin bottom-left, y up (Unity Input System).
|
||||
// - UI Toolkit panel coords: origin top-left, y down.
|
||||
//
|
||||
// RuntimePanelUtils.ScreenToPanel converts the SCALE (e.g., reference resolution
|
||||
// vs. actual resolution) but does NOT flip Y. We flip manually using the visual
|
||||
// tree's height so the result works regardless of PanelSettings scale mode.
|
||||
//
|
||||
// Subtle: visualTree.worldBound height may be 0 for one frame on the very first
|
||||
// layout pass. The caller (CameraController) checks the result against "is over
|
||||
// interactive HUD"; a one-frame false positive (camera zooms when it shouldn't)
|
||||
// is harmless and self-corrects the next frame.
|
||||
Vector2 scaled = RuntimePanelUtils.ScreenToPanel(s_hudPanel, screenMousePos);
|
||||
float panelHeight = s_hudPanel.visualTree.worldBound.height;
|
||||
Vector2 panelPos = new Vector2(scaled.x, panelHeight - scaled.y);
|
||||
|
||||
// panel.Pick returns null when the topmost element under the point has
|
||||
// PickingMode.Ignore (or there's no element there at all). Non-null means an
|
||||
// interactive HUD element is under the cursor.
|
||||
return s_hudPanel.Pick(panelPos) != null;
|
||||
}
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
|
|
@ -101,15 +148,24 @@ namespace TD.UI
|
|||
SetEnabled(root, "upgrade-btn", false);
|
||||
SetEnabled(root, "sell-btn", false);
|
||||
|
||||
// Minimap RenderTexture.
|
||||
if (minimapRenderTexture != null)
|
||||
// Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
|
||||
// and drives them; we just hand it the host container and the camera controller.
|
||||
// Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
|
||||
var minimapContainer = root.Q<VisualElement>("minimap");
|
||||
if (minimapContainer != null)
|
||||
{
|
||||
var minimap = root.Q<VisualElement>("minimap");
|
||||
if (minimap != null)
|
||||
minimap.style.backgroundImage =
|
||||
Background.FromRenderTexture(minimapRenderTexture);
|
||||
if (cameraController == null)
|
||||
Debug.LogWarning("[HUDController] cameraController not assigned. " +
|
||||
"Click-to-jump and drag-to-pan on the minimap will be disabled.");
|
||||
minimapView = new MinimapView(minimapContainer, cameraController);
|
||||
}
|
||||
|
||||
// Publish the panel so non-UI systems can query "is pointer over the HUD".
|
||||
// Stored on `myPanel` too so OnDestroy only clears the static if it still
|
||||
// points at this instance (defensive against re-creation overlap).
|
||||
myPanel = root.panel;
|
||||
s_hudPanel = myPanel;
|
||||
|
||||
uiInitialized = true;
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +187,17 @@ namespace TD.UI
|
|||
TryPopulateCommandGrid();
|
||||
|
||||
RefreshGoldDisplay();
|
||||
minimapView?.Tick();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
minimapView?.Dispose();
|
||||
minimapView = null;
|
||||
|
||||
if (s_hudPanel != null && s_hudPanel == myPanel)
|
||||
s_hudPanel = null;
|
||||
myPanel = null;
|
||||
}
|
||||
|
||||
// ----- Gold display -----------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e640b4de7a1dee049b38712e7eff82ea
|
||||
NativeFormatImporter:
|
||||
guid: dd886c53adc748f4391bc440c289d0ea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 8400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
94
Assets/_Project/Scripts/UI/Minimap/MinimapEntityRegistry.cs
Normal file
94
Assets/_Project/Scripts/UI/Minimap/MinimapEntityRegistry.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Assets/_Project/Scripts/UI/Minimap/MinimapEntityRegistry.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TD.UI.Minimap
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual category for a minimap entity. Determines the shape and size of the icon drawn
|
||||
/// by <see cref="MinimapView"/>. Color is supplied separately by the entity itself so the
|
||||
/// view does not need to know about ownership / faction mapping.
|
||||
/// </summary>
|
||||
public enum MinimapIconKind : byte
|
||||
{
|
||||
Enemy,
|
||||
Tower,
|
||||
Builder,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anything that wants to appear on the minimap as a dynamic icon implements this. The view
|
||||
/// reads every getter on each refresh tick, so values may change between reads (movement is
|
||||
/// fine — no caching needed).
|
||||
/// </summary>
|
||||
public interface IMinimapEntity
|
||||
{
|
||||
Vector3 WorldPosition { get; }
|
||||
Color MinimapColor { get; }
|
||||
MinimapIconKind IconKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The entity's diameter (or footprint extent) in world units. The view converts this
|
||||
/// to pixels using the current minimap zoom, so a 2×2 tower will appear as a 2-tile
|
||||
/// square on the minimap and adjacent towers will visually touch — matching their
|
||||
/// world-space relationship. For point-like entities (builders, enemies) the view
|
||||
/// also enforces a minimum pixel size so they stay visible when fully zoomed out.
|
||||
/// </summary>
|
||||
float MinimapWorldSize { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static registry of every entity currently visible on the minimap. Entities register
|
||||
/// themselves on spawn and deregister on despawn; the <see cref="MinimapView"/> iterates the
|
||||
/// set on each refresh.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Backing store is a <see cref="HashSet{T}"/> so duplicate Register calls are no-ops and
|
||||
/// Deregister is O(1). Iteration uses the struct enumerator via <see cref="ForEach"/> to
|
||||
/// avoid the boxing that would happen if we exposed an <see cref="IEnumerable{T}"/>.
|
||||
///
|
||||
/// Static lifetime is intentional: the registry survives scene transitions only if entities
|
||||
/// remember to deregister. NGO's <c>OnNetworkDespawn</c> covers that for towers / builders /
|
||||
/// enemies. Manual <see cref="Clear"/> is provided for tests and for explicit reset on match
|
||||
/// teardown if needed.
|
||||
/// </remarks>
|
||||
public static class MinimapEntityRegistry
|
||||
{
|
||||
private static readonly HashSet<IMinimapEntity> s_entities = new HashSet<IMinimapEntity>();
|
||||
|
||||
/// <summary>Number of currently-registered entities. Cheap, no allocation.</summary>
|
||||
public static int Count => s_entities.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Registers <paramref name="entity"/> for minimap rendering. No-op if already registered.
|
||||
/// Safe to call from any thread that Unity allows (i.e., the main thread).
|
||||
/// </summary>
|
||||
public static void Register(IMinimapEntity entity)
|
||||
{
|
||||
if (entity == null) return;
|
||||
s_entities.Add(entity);
|
||||
}
|
||||
|
||||
/// <summary>Removes <paramref name="entity"/> from the registry. No-op if not present.</summary>
|
||||
public static void Deregister(IMinimapEntity entity)
|
||||
{
|
||||
if (entity == null) return;
|
||||
s_entities.Remove(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes <paramref name="action"/> once per registered entity. Uses HashSet's struct
|
||||
/// enumerator so iteration is allocation-free (the only allocation is the closure that
|
||||
/// <paramref name="action"/> may carry, which is the caller's choice).
|
||||
/// </summary>
|
||||
public static void ForEach(Action<IMinimapEntity> action)
|
||||
{
|
||||
if (action == null) return;
|
||||
foreach (var e in s_entities) action(e);
|
||||
}
|
||||
|
||||
/// <summary>Drops every registered entity. Tests and explicit match-teardown use only.</summary>
|
||||
public static void Clear() => s_entities.Clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 683a64c92b1c7a144b7f9717b854ef38
|
||||
247
Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs
Normal file
247
Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// Assets/_Project/Scripts/UI/Minimap/MinimapTerrainBaker.cs
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Gameplay;
|
||||
using TD.Levels;
|
||||
|
||||
namespace TD.UI.Minimap
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the static terrain layer of the WC3-style minimap as a <see cref="Texture2D"/>
|
||||
/// from the baked <see cref="LevelData"/>. One pixel per tile; the UI element scales the
|
||||
/// texture up with Point filtering so the result reads as crisp, chunky map abstraction
|
||||
/// rather than a tiny blurred photograph.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The terrain layer represents what cannot change during a match: zone ownership, spawner
|
||||
/// locations, goal locations, and the map shape itself. Towers and enemies are drawn on a
|
||||
/// separate dynamic overlay by <see cref="MinimapView"/>, so this texture is baked once at
|
||||
/// level load and never re-baked.
|
||||
/// </remarks>
|
||||
public static class MinimapTerrainBaker
|
||||
{
|
||||
// ----- Palette ----------------------------------------------------
|
||||
// Picked for legibility at low resolution. Aim: each tile category reads as a distinct
|
||||
// color block when scaled up 4–8×, even on a Steam Deck screen at running brightness.
|
||||
|
||||
/// <summary>Tiles outside the playable map area. Fully transparent so the panel
|
||||
/// background shows through.</summary>
|
||||
private static readonly Color32 OutOfMap = new Color32(0, 0, 0, 0);
|
||||
|
||||
/// <summary>In-map tiles that are not owned by any player and not part of a goal /
|
||||
/// spawner. Dark neutral grey — reads as "playable but unallocated".</summary>
|
||||
private static readonly Color32 NeutralInMap = new Color32(46, 46, 51, 255);
|
||||
|
||||
/// <summary>Spawner tiles. Bright cyan — pops against every player zone color.</summary>
|
||||
private static readonly Color32 SpawnerTile = new Color32(102, 217, 255, 255);
|
||||
|
||||
/// <summary>Goal tiles. Matches <see cref="PlayerColors.Goal"/> (gold).</summary>
|
||||
private static readonly Color32 GoalTile = new Color32(224, 176, 32, 255);
|
||||
|
||||
// ----- Public API -------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Bakes the current level into a Texture2D and returns it. Returns null if the loader
|
||||
/// is not ready. The returned texture has FilterMode.Point and is non-readable after
|
||||
/// bake (CPU-side pixel data is released).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Caller is responsible for the texture lifetime: assign it to the minimap element and
|
||||
/// destroy it when no longer needed. Currently <see cref="MinimapView"/> holds the only
|
||||
/// reference and lets it live for the duration of the match.
|
||||
/// </remarks>
|
||||
public static Texture2D Bake(LevelLoader loader)
|
||||
{
|
||||
if (loader == null || !loader.IsLoaded)
|
||||
{
|
||||
Debug.LogWarning("[MinimapTerrainBaker] LevelLoader not ready; cannot bake terrain.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = loader.LevelData;
|
||||
int w = data.GridSize.x;
|
||||
int h = data.GridSize.y;
|
||||
if (w <= 0 || h <= 0)
|
||||
{
|
||||
Debug.LogWarning($"[MinimapTerrainBaker] Invalid grid size {data.GridSize}; cannot bake.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// RGBA32 supports alpha for out-of-map transparency. mipChain off — the texture
|
||||
// is rendered at near-1:1 in the UI and mipmaps would only blur the abstract look.
|
||||
var tex = new Texture2D(w, h, TextureFormat.RGBA32, mipChain: false, linear: false)
|
||||
{
|
||||
filterMode = FilterMode.Point,
|
||||
wrapMode = TextureWrapMode.Clamp,
|
||||
name = "MinimapTerrain"
|
||||
};
|
||||
|
||||
var pixels = new Color32[w * h];
|
||||
|
||||
// Pass 1: base tile color from MapArea + Owner.
|
||||
// Texture y=0 is the BOTTOM row in Unity convention. World z and grid y both
|
||||
// increase northward, so a tile at grid-y=0 should land at the bottom of the
|
||||
// texture. That's exactly what `pixels[y * w + x] = ...` gives us, since SetPixels32
|
||||
// treats index 0 as bottom-left. No flip needed during bake — the flip happens at
|
||||
// display time (UI y-axis points down, world z points up; MinimapView handles it).
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int idx = y * w + x;
|
||||
pixels[idx] = ResolveBaseColor(data, idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: stamp special tile categories on top — spawners and goals override base.
|
||||
// PlayerZones[].LeakExits intentionally NOT painted: leak exits are conceptually
|
||||
// part of the source player's zone (an enemy boundary, not a player-visible
|
||||
// landmark), and visually highlighting them would clutter the minimap.
|
||||
StampZoneSpawners(pixels, data);
|
||||
StampGoals(pixels, data);
|
||||
|
||||
tex.SetPixels32(pixels);
|
||||
tex.Apply(updateMipmaps: false, makeNoLongerReadable: true);
|
||||
|
||||
LogBakeSummary(data, w, h);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// ----- Diagnostics ------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// One-shot log of the bake breakdown. Useful for diagnosing apparent asymmetries in
|
||||
/// the rendered minimap — a symmetric authoring should produce mirrored counts on
|
||||
/// opposite edges. Per-player min/max columns reveal exactly where each zone starts
|
||||
/// and ends across the grid.
|
||||
/// </summary>
|
||||
private static void LogBakeSummary(LevelData data, int w, int h)
|
||||
{
|
||||
int outOfMap = 0, neutralInMap = 0, spawnerTiles = 0, goalTiles = 0;
|
||||
// Per-slot owned-tile bounds (1..9).
|
||||
int[] ownedCount = new int[10];
|
||||
int[] minX = new int[10]; int[] maxX = new int[10];
|
||||
int[] minY = new int[10]; int[] maxY = new int[10];
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
minX[i] = int.MaxValue; minY[i] = int.MaxValue;
|
||||
maxX[i] = int.MinValue; maxY[i] = int.MinValue;
|
||||
}
|
||||
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int idx = y * w + x;
|
||||
bool inMap = data.MapAreaGrid != null && data.MapAreaGrid[idx];
|
||||
if (!inMap) { outOfMap++; continue; }
|
||||
|
||||
PlayerSlot s = data.OwnerGrid != null ? data.OwnerGrid[idx] : PlayerSlot.None;
|
||||
if (s == PlayerSlot.None) { neutralInMap++; continue; }
|
||||
|
||||
int si = (int)s;
|
||||
if (si < 0 || si > 9) continue;
|
||||
ownedCount[si]++;
|
||||
if (x < minX[si]) minX[si] = x;
|
||||
if (x > maxX[si]) maxX[si] = x;
|
||||
if (y < minY[si]) minY[si] = y;
|
||||
if (y > maxY[si]) maxY[si] = y;
|
||||
}
|
||||
|
||||
if (data.PlayerZones != null)
|
||||
foreach (var z in data.PlayerZones)
|
||||
if (z?.Spawners != null)
|
||||
foreach (var sp in z.Spawners)
|
||||
if (sp?.TileArea != null) spawnerTiles += sp.TileArea.Length;
|
||||
if (data.Goals != null)
|
||||
foreach (var g in data.Goals)
|
||||
if (g?.TileArea != null) goalTiles += g.TileArea.Length;
|
||||
|
||||
var sb = new System.Text.StringBuilder(256);
|
||||
sb.AppendFormat("[MinimapTerrainBaker] '{0}' baked. Grid {1}×{2} origin {3}. ",
|
||||
data.MapName, w, h, data.GridOriginTile);
|
||||
sb.AppendFormat("Tiles: outOfMap={0}, neutralInMap={1}, spawner={2}, goal={3}.",
|
||||
outOfMap, neutralInMap, spawnerTiles, goalTiles);
|
||||
for (int i = 1; i <= 9; i++)
|
||||
{
|
||||
if (ownedCount[i] == 0) continue;
|
||||
sb.AppendFormat(" P{0}={1} (x:{2}–{3}, y:{4}–{5})",
|
||||
i, ownedCount[i], minX[i], maxX[i], minY[i], maxY[i]);
|
||||
}
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
|
||||
// ----- Pass implementations ---------------------------------------
|
||||
|
||||
private static Color32 ResolveBaseColor(LevelData data, int idx)
|
||||
{
|
||||
// MapAreaGrid may be null on levels baked before that field existed; treat as
|
||||
// "not in map" so we render transparent rather than a misleading neutral block.
|
||||
bool inMap = data.MapAreaGrid != null
|
||||
&& idx < data.MapAreaGrid.Length
|
||||
&& data.MapAreaGrid[idx];
|
||||
if (!inMap) return OutOfMap;
|
||||
|
||||
PlayerSlot owner = (data.OwnerGrid != null && idx < data.OwnerGrid.Length)
|
||||
? data.OwnerGrid[idx]
|
||||
: PlayerSlot.None;
|
||||
|
||||
if (owner == PlayerSlot.None) return NeutralInMap;
|
||||
return ToZoneColor(PlayerColors.Get(owner));
|
||||
}
|
||||
|
||||
private static void StampZoneSpawners(Color32[] pixels, LevelData data)
|
||||
{
|
||||
if (data.PlayerZones == null) return;
|
||||
foreach (var zone in data.PlayerZones)
|
||||
{
|
||||
if (zone == null || zone.Spawners == null) continue;
|
||||
foreach (var spawner in zone.Spawners)
|
||||
{
|
||||
if (spawner == null || spawner.TileArea == null) continue;
|
||||
foreach (var tile in spawner.TileArea)
|
||||
PaintTile(pixels, data, tile, SpawnerTile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampGoals(Color32[] pixels, LevelData data)
|
||||
{
|
||||
if (data.Goals == null) return;
|
||||
foreach (var goal in data.Goals)
|
||||
{
|
||||
if (goal == null || goal.TileArea == null) continue;
|
||||
foreach (var tile in goal.TileArea)
|
||||
PaintTile(pixels, data, tile, GoalTile);
|
||||
}
|
||||
}
|
||||
|
||||
private static void PaintTile(Color32[] pixels, LevelData data, Vector2Int worldTile, Color32 color)
|
||||
{
|
||||
int x = worldTile.x - data.GridOriginTile.x;
|
||||
int y = worldTile.y - data.GridOriginTile.y;
|
||||
if (x < 0 || x >= data.GridSize.x || y < 0 || y >= data.GridSize.y) return;
|
||||
pixels[y * data.GridSize.x + x] = color;
|
||||
}
|
||||
|
||||
// ----- Color helpers ----------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Translates a saturated player gizmo color into the dimmer, slightly desaturated tone
|
||||
/// used for zone fill on the minimap. The full-saturation original is reserved for the
|
||||
/// player's icons (towers, builders) so units pop against their own zone tint.
|
||||
/// </summary>
|
||||
private static Color32 ToZoneColor(Color full)
|
||||
{
|
||||
// Desaturate ~45% toward equal-luminance grey, then darken to ~70% brightness.
|
||||
float lum = full.r * 0.299f + full.g * 0.587f + full.b * 0.114f;
|
||||
float r = Mathf.Lerp(full.r, lum, 0.45f) * 0.70f;
|
||||
float g = Mathf.Lerp(full.g, lum, 0.45f) * 0.70f;
|
||||
float b = Mathf.Lerp(full.b, lum, 0.45f) * 0.70f;
|
||||
return new Color32(
|
||||
(byte)Mathf.RoundToInt(r * 255f),
|
||||
(byte)Mathf.RoundToInt(g * 255f),
|
||||
(byte)Mathf.RoundToInt(b * 255f),
|
||||
255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3a3122ca07726044f8b63bbfb8f0230c
|
||||
502
Assets/_Project/Scripts/UI/Minimap/MinimapView.cs
Normal file
502
Assets/_Project/Scripts/UI/Minimap/MinimapView.cs
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
// Assets/_Project/Scripts/UI/Minimap/MinimapView.cs
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using TD.Core;
|
||||
using TD.Gameplay;
|
||||
using TD.Levels;
|
||||
|
||||
namespace TD.UI.Minimap
|
||||
{
|
||||
/// <summary>
|
||||
/// UI Toolkit minimap controller. Manages two stacked sub-elements (terrain + entities)
|
||||
/// inside a host <see cref="VisualElement"/>, handles click-to-jump, drag-to-pan,
|
||||
/// right-click-to-move-builder, and scroll-wheel zoom against a <see cref="CameraController"/>
|
||||
/// and the local <see cref="SelectionState"/>. Refreshes the entity overlay at a throttled
|
||||
/// rate so 500+ enemies don't dominate frame time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Coordinate spaces.</b> Three are in play:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>World</b> — XZ plane positions of game-side entities. Z+ is north.</item>
|
||||
/// <item><b>UI local</b> — pixel coordinates within the minimap container. Y+ is DOWN.</item>
|
||||
/// <item><b>Texture</b> — handled entirely by the baker; not exposed here.</item>
|
||||
/// </list>
|
||||
/// World↔UI conversion uses the currently-visible world rectangle, which is the full map
|
||||
/// at zoom 1 and a smaller rect centered on <see cref="viewCenter"/> at zoom > 1.</para>
|
||||
///
|
||||
/// <para><b>Zoom model.</b> Zoom is anchored to the cursor position: the world point under
|
||||
/// the cursor before the scroll stays under the cursor after, so the user can dial in on a
|
||||
/// region by hovering over it and scrolling. Zoom defaults to 1 (fully zoomed out) and is
|
||||
/// clamped to <see cref="MaxZoom"/>. When zoom returns to 1 the visible rect snaps back to
|
||||
/// the full map.</para>
|
||||
///
|
||||
/// <para><b>Terrain rendering.</b> The terrain sub-element is sized and positioned so the
|
||||
/// currently-visible world rect maps to the container's contentRect. At zoom 1 the element
|
||||
/// fills the container exactly; at higher zoom it grows (in pixels) past the container and
|
||||
/// the container's <c>overflow: hidden</c> clips the off-view portion. The texture itself
|
||||
/// is baked once and never re-baked.</para>
|
||||
/// </remarks>
|
||||
public class MinimapView
|
||||
{
|
||||
// ----- Tuning -----------------------------------------------------
|
||||
|
||||
/// <summary>Entity overlay refresh rate. 20Hz handles 500+ enemies comfortably without
|
||||
/// burning the frame budget on Painter2D. Revisit if testing shows it's not enough.</summary>
|
||||
private const float EntityRepaintHz = 20f;
|
||||
|
||||
private const float MinZoom = 1f;
|
||||
private const float MaxZoom = 4f;
|
||||
/// <summary>Multiplicative factor applied per scroll-wheel tick. 1.2 ≈ 6 ticks to span
|
||||
/// 1x → 4x, which feels neither sluggish nor twitchy in playtest.</summary>
|
||||
private const float ZoomStepFactor = 1.2f;
|
||||
|
||||
// Pixel-size floor for point-like icons (builders, enemies). Ensures they stay visible
|
||||
// even at full zoom-out on a large map. Towers use their actual footprint instead and
|
||||
// do not need a floor — they're always at least a couple of pixels by virtue of being
|
||||
// 2×2 or larger.
|
||||
private const float BuilderMinRadiusPx = 2.4f;
|
||||
private const float EnemyMinRadiusPx = 1.5f;
|
||||
|
||||
// Outline added to builder icons so they read against same-color zone fill.
|
||||
private static readonly Color BuilderOutline = new Color(1f, 1f, 1f, 0.85f);
|
||||
|
||||
// ----- Refs -------------------------------------------------------
|
||||
|
||||
private readonly VisualElement container;
|
||||
private readonly VisualElement terrainLayer;
|
||||
private readonly VisualElement entityLayer;
|
||||
private readonly CameraController cameraController;
|
||||
|
||||
// ----- Bake state -------------------------------------------------
|
||||
|
||||
// World-space rectangle the texture covers (the full map). Cached once at bake.
|
||||
private Vector2 worldMin;
|
||||
private Vector2 worldMax;
|
||||
|
||||
// Owned texture — kept so we can destroy it cleanly. Null until first successful bake.
|
||||
private Texture2D bakedTerrain;
|
||||
|
||||
// True once terrain has been baked successfully.
|
||||
private bool ready;
|
||||
|
||||
// ----- View state -------------------------------------------------
|
||||
|
||||
// Current zoom in [MinZoom, MaxZoom]. 1 = full map visible.
|
||||
private float zoom = 1f;
|
||||
|
||||
// World-XZ point the visible rect is centered on. At zoom 1 this is forced to the
|
||||
// world center; at higher zoom the user's scrolling determines it (clamped so the
|
||||
// visible rect never leaves the map).
|
||||
private Vector2 viewCenter;
|
||||
|
||||
// Visible world rect, derived from zoom + viewCenter in ApplyView().
|
||||
private Vector2 visibleWorldMin;
|
||||
private Vector2 visibleWorldMax;
|
||||
|
||||
// ----- Input state ------------------------------------------------
|
||||
|
||||
private bool isDragging;
|
||||
private int dragPointerId;
|
||||
|
||||
// ----- Repaint throttle -------------------------------------------
|
||||
|
||||
private float lastEntityRepaintTime;
|
||||
|
||||
// ----- Construction -----------------------------------------------
|
||||
|
||||
public MinimapView(VisualElement container, CameraController cameraController)
|
||||
{
|
||||
this.container = container;
|
||||
this.cameraController = cameraController;
|
||||
|
||||
// Two stacked sub-elements. Terrain is painted via background-image; the entity
|
||||
// overlay uses generateVisualContent + Painter2D. Both are PickingMode.Ignore so
|
||||
// pointer events bubble back to the container, where we capture them.
|
||||
terrainLayer = new VisualElement { name = "minimap-terrain" };
|
||||
terrainLayer.AddToClassList("minimap-terrain");
|
||||
terrainLayer.pickingMode = PickingMode.Ignore;
|
||||
container.Add(terrainLayer);
|
||||
|
||||
entityLayer = new VisualElement { name = "minimap-entities" };
|
||||
entityLayer.AddToClassList("minimap-entities");
|
||||
entityLayer.pickingMode = PickingMode.Ignore;
|
||||
entityLayer.generateVisualContent += DrawEntities;
|
||||
container.Add(entityLayer);
|
||||
|
||||
// Pointer events on the container drive click-to-jump, drag-to-pan, right-click-move.
|
||||
container.RegisterCallback<PointerDownEvent>(OnPointerDown);
|
||||
container.RegisterCallback<PointerMoveEvent>(OnPointerMove);
|
||||
container.RegisterCallback<PointerUpEvent>(OnPointerUp);
|
||||
container.RegisterCallback<PointerCaptureOutEvent>(OnPointerCaptureOut);
|
||||
container.RegisterCallback<WheelEvent>(OnWheel);
|
||||
|
||||
// Recompute terrain layout when the container resizes — the absolute pixel
|
||||
// positions we set on terrainLayer are container-relative.
|
||||
container.RegisterCallback<GeometryChangedEvent>(_ => { if (ready) ApplyView(); });
|
||||
}
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Called from <c>HUDController.Update</c>. Performs lazy bake on the first frame
|
||||
/// after <see cref="LevelLoader"/> finishes loading, then triggers throttled entity
|
||||
/// overlay repaints.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (!ready)
|
||||
{
|
||||
TryBake();
|
||||
if (!ready) return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
if (now - lastEntityRepaintTime >= 1f / EntityRepaintHz)
|
||||
{
|
||||
lastEntityRepaintTime = now;
|
||||
entityLayer.MarkDirtyRepaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the baked texture. Call from <c>HUDController.OnDestroy</c>.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (bakedTerrain != null)
|
||||
{
|
||||
Object.Destroy(bakedTerrain);
|
||||
bakedTerrain = null;
|
||||
}
|
||||
ready = false;
|
||||
}
|
||||
|
||||
// ----- Bake -------------------------------------------------------
|
||||
|
||||
private void TryBake()
|
||||
{
|
||||
var loader = LevelLoader.Instance;
|
||||
if (loader == null || !loader.IsLoaded) return;
|
||||
|
||||
bakedTerrain = MinimapTerrainBaker.Bake(loader);
|
||||
if (bakedTerrain == null) return;
|
||||
|
||||
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.
|
||||
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;
|
||||
worldMin = new Vector2(minX, minZ);
|
||||
worldMax = new Vector2(maxX, maxZ);
|
||||
|
||||
// Default view: centered, fully zoomed out.
|
||||
zoom = MinZoom;
|
||||
viewCenter = (worldMin + worldMax) * 0.5f;
|
||||
ApplyView();
|
||||
|
||||
ready = true;
|
||||
}
|
||||
|
||||
// ----- View math --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes <see cref="visibleWorldMin"/>/<see cref="visibleWorldMax"/> from the
|
||||
/// current <see cref="zoom"/> and <see cref="viewCenter"/>, clamps the view to stay
|
||||
/// within the map, and resizes/positions the terrain sub-element so its rendered
|
||||
/// region matches the visible world rect.
|
||||
/// </summary>
|
||||
private void ApplyView()
|
||||
{
|
||||
var rect = container.contentRect;
|
||||
if (rect.width <= 0f || rect.height <= 0f) return;
|
||||
|
||||
// Half-extents of the visible world rect.
|
||||
float halfWorldX = (worldMax.x - worldMin.x) * 0.5f / zoom;
|
||||
float halfWorldZ = (worldMax.y - worldMin.y) * 0.5f / zoom;
|
||||
|
||||
// Clamp viewCenter so the visible rect doesn't leave the map. At zoom 1 the
|
||||
// clamp range collapses to a single point (the world center), forcing the view.
|
||||
viewCenter.x = Mathf.Clamp(viewCenter.x,
|
||||
worldMin.x + halfWorldX, worldMax.x - halfWorldX);
|
||||
viewCenter.y = Mathf.Clamp(viewCenter.y,
|
||||
worldMin.y + halfWorldZ, worldMax.y - halfWorldZ);
|
||||
|
||||
visibleWorldMin = new Vector2(viewCenter.x - halfWorldX, viewCenter.y - halfWorldZ);
|
||||
visibleWorldMax = new Vector2(viewCenter.x + halfWorldX, viewCenter.y + halfWorldZ);
|
||||
|
||||
// Position the terrain element so visibleWorldMin maps to container (0, 0) — in
|
||||
// UI coords where y is top-down and the texture is flipped to put north at top.
|
||||
// Terrain at zoom z is rect.size * z big, and we offset it so the visible window
|
||||
// lands inside the container.
|
||||
float worldRangeX = worldMax.x - worldMin.x;
|
||||
float worldRangeZ = worldMax.y - worldMin.y;
|
||||
|
||||
float terrainW = rect.width * zoom;
|
||||
float terrainH = rect.height * zoom;
|
||||
float left = -(visibleWorldMin.x - worldMin.x) / worldRangeX * terrainW;
|
||||
// Top: container's top corresponds to visibleWorldMax.z. Distance from texture's
|
||||
// top (worldMax.z) to visibleWorldMax.z, as a fraction of world range, times height.
|
||||
float top = -(worldMax.y - visibleWorldMax.y) / worldRangeZ * terrainH;
|
||||
|
||||
terrainLayer.style.width = terrainW;
|
||||
terrainLayer.style.height = terrainH;
|
||||
terrainLayer.style.left = left;
|
||||
terrainLayer.style.top = top;
|
||||
terrainLayer.style.right = StyleKeyword.Auto;
|
||||
terrainLayer.style.bottom = StyleKeyword.Auto;
|
||||
|
||||
// Entity overlay needs a redraw to reflect the new visible rect.
|
||||
entityLayer.MarkDirtyRepaint();
|
||||
}
|
||||
|
||||
// ----- Pointer handling -------------------------------------------
|
||||
|
||||
private void OnPointerDown(PointerDownEvent evt)
|
||||
{
|
||||
if (!ready) return;
|
||||
|
||||
if (evt.button == 0)
|
||||
{
|
||||
// Left button: jump camera + start drag-to-pan.
|
||||
if (cameraController == null) return;
|
||||
isDragging = true;
|
||||
dragPointerId = evt.pointerId;
|
||||
container.CapturePointer(evt.pointerId);
|
||||
cameraController.BeginDrag();
|
||||
cameraController.JumpTo(UIToWorld(evt.localPosition));
|
||||
evt.StopPropagation();
|
||||
}
|
||||
else if (evt.button == 1)
|
||||
{
|
||||
// Right button: move-and-pause command on the selected builder. Fires
|
||||
// immediately; no drag/capture semantics (single-shot like an RTS).
|
||||
HandleRightClickMove(evt.localPosition);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerMove(PointerMoveEvent evt)
|
||||
{
|
||||
if (!isDragging || evt.pointerId != dragPointerId) return;
|
||||
cameraController.JumpTo(UIToWorld(evt.localPosition));
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
private void OnPointerUp(PointerUpEvent evt)
|
||||
{
|
||||
if (!isDragging || evt.pointerId != dragPointerId) return;
|
||||
EndDragging(evt.pointerId);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
// Lost capture — end drag cleanly so we don't leave CameraController stuck.
|
||||
private void OnPointerCaptureOut(PointerCaptureOutEvent evt)
|
||||
{
|
||||
if (!isDragging || evt.pointerId != dragPointerId) return;
|
||||
EndDragging(evt.pointerId);
|
||||
}
|
||||
|
||||
private void EndDragging(int pointerId)
|
||||
{
|
||||
isDragging = false;
|
||||
cameraController?.EndDrag();
|
||||
if (container.HasPointerCapture(pointerId))
|
||||
container.ReleasePointer(pointerId);
|
||||
}
|
||||
|
||||
private void HandleRightClickMove(Vector2 uiLocal)
|
||||
{
|
||||
var selection = SelectionState.Instance;
|
||||
if (selection == null || !selection.HasSelection) return;
|
||||
|
||||
Vector3 worldTarget = UIToWorld(uiLocal);
|
||||
// Same RPC the world right-click uses; server validates and side-effects the queue.
|
||||
selection.SelectedBuilder.RequestMoveAndPauseRpc(worldTarget);
|
||||
}
|
||||
|
||||
// ----- Zoom -------------------------------------------------------
|
||||
|
||||
private void OnWheel(WheelEvent evt)
|
||||
{
|
||||
if (!ready) return;
|
||||
|
||||
// Cursor-anchored zoom: capture the world point under the cursor before the
|
||||
// zoom change, then move viewCenter so that same world point lands at the same
|
||||
// UI position afterward. Result: the cursor "drills in" on whatever it's over.
|
||||
Vector3 cursorWorldBefore = UIToWorld(evt.localMousePosition);
|
||||
|
||||
// WheelEvent.delta.y: positive = scroll down (typically zoom out), negative = up.
|
||||
// Multiplicative steps feel natural and keep the rate consistent across zoom levels.
|
||||
float steps = -evt.delta.y;
|
||||
float newZoom = Mathf.Clamp(zoom * Mathf.Pow(ZoomStepFactor, steps), MinZoom, MaxZoom);
|
||||
if (Mathf.Approximately(newZoom, zoom))
|
||||
{
|
||||
evt.StopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
zoom = newZoom;
|
||||
|
||||
// Compute viewCenter that keeps cursorWorldBefore under the cursor at the new zoom.
|
||||
// visibleWorldMin = viewCenter - halfWorld; visibleWorldMax = viewCenter + halfWorld
|
||||
// UIToWorld(uiLocal) = visibleWorldMin + frac * (visibleWorldMax - visibleWorldMin)
|
||||
// = viewCenter - halfWorld + frac * 2 * halfWorld
|
||||
// = viewCenter + (2*frac - 1) * halfWorld
|
||||
// Solve viewCenter from desired cursorWorldBefore at the cursor's frac.
|
||||
var rect = container.contentRect;
|
||||
float fx = Mathf.Clamp01(evt.localMousePosition.x / rect.width);
|
||||
float fyTopDown = Mathf.Clamp01(evt.localMousePosition.y / rect.height);
|
||||
float fzBottomUp = 1f - fyTopDown;
|
||||
|
||||
float halfWorldX = (worldMax.x - worldMin.x) * 0.5f / zoom;
|
||||
float halfWorldZ = (worldMax.y - worldMin.y) * 0.5f / zoom;
|
||||
viewCenter.x = cursorWorldBefore.x - (2f * fx - 1f) * halfWorldX;
|
||||
viewCenter.y = cursorWorldBefore.z - (2f * fzBottomUp - 1f) * halfWorldZ;
|
||||
|
||||
ApplyView(); // clamps viewCenter and updates terrain element
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
// ----- Coordinate transforms --------------------------------------
|
||||
|
||||
// UI local (y down) → world (z up). Clamps to visible map rect so dragging past the
|
||||
// edge doesn't fly the camera off the map.
|
||||
private Vector3 UIToWorld(Vector2 uiLocal)
|
||||
{
|
||||
var rect = container.contentRect;
|
||||
if (rect.width <= 0f || rect.height <= 0f) return Vector3.zero;
|
||||
|
||||
float fx = Mathf.Clamp01(uiLocal.x / rect.width);
|
||||
float fyTopDown = Mathf.Clamp01(uiLocal.y / rect.height);
|
||||
float fzBottomUp = 1f - fyTopDown;
|
||||
|
||||
float worldX = Mathf.Lerp(visibleWorldMin.x, visibleWorldMax.x, fx);
|
||||
float worldZ = Mathf.Lerp(visibleWorldMin.y, visibleWorldMax.y, fzBottomUp);
|
||||
return new Vector3(worldX, GridCoordinates.BUILDABLE_PLANE_Y, worldZ);
|
||||
}
|
||||
|
||||
// World → UI local. Used for placing entity icons.
|
||||
//
|
||||
// IMPORTANT: this must NOT clamp. Mathf.InverseLerp clamps to [0,1], which would
|
||||
// pin off-screen entities to the minimap edge — making zoomed-in views show
|
||||
// ghost icons stuck against every border. We compute the fraction manually so
|
||||
// out-of-view entities get UI coords outside the container's rect, where the
|
||||
// bounds check in DrawOneEntity culls them.
|
||||
private Vector2 WorldToUI(Vector3 world)
|
||||
{
|
||||
var rect = container.contentRect;
|
||||
float rangeX = visibleWorldMax.x - visibleWorldMin.x;
|
||||
float rangeZ = visibleWorldMax.y - visibleWorldMin.y;
|
||||
if (rangeX <= 0.0001f || rangeZ <= 0.0001f) return Vector2.zero;
|
||||
|
||||
float fx = (world.x - visibleWorldMin.x) / rangeX;
|
||||
float fzBottomUp = (world.z - visibleWorldMin.y) / rangeZ;
|
||||
float fyTopDown = 1f - fzBottomUp;
|
||||
return new Vector2(fx * rect.width, fyTopDown * rect.height);
|
||||
}
|
||||
|
||||
/// <summary>Pixels per world unit at the current zoom. Used to scale entity icons so
|
||||
/// e.g. a 2×2 tower footprint reads as a 2-tile square on the minimap.</summary>
|
||||
private float PixelsPerWorldUnit
|
||||
{
|
||||
get
|
||||
{
|
||||
var rect = container.contentRect;
|
||||
float visibleWorldWidth = visibleWorldMax.x - visibleWorldMin.x;
|
||||
return visibleWorldWidth > 0.0001f ? rect.width / visibleWorldWidth : 1f;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Entity overlay drawing -------------------------------------
|
||||
|
||||
private void DrawEntities(MeshGenerationContext mgc)
|
||||
{
|
||||
if (!ready) return;
|
||||
var painter = mgc.painter2D;
|
||||
float pxPerWorld = PixelsPerWorldUnit;
|
||||
|
||||
// The local player's builder always draws on top. Cache the reference once
|
||||
// (Builder.Local does a dictionary lookup) and cast to the interface so the
|
||||
// ReferenceEquals compare against registry entries works without boxing.
|
||||
IMinimapEntity localBuilder = Builder.Local;
|
||||
|
||||
// Pass 1: every entity except the local builder. Order within this pass is
|
||||
// arbitrary (registry is a HashSet); revisit if cross-entity layering between
|
||||
// non-local entities ever matters.
|
||||
MinimapEntityRegistry.ForEach(e =>
|
||||
{
|
||||
if (ReferenceEquals(e, localBuilder)) return;
|
||||
DrawOneEntity(painter, e, pxPerWorld);
|
||||
});
|
||||
|
||||
// Pass 2: local builder on top. Skipped if the local builder isn't spawned
|
||||
// (e.g., on a dedicated server or before the local client's builder arrives).
|
||||
if (localBuilder != null)
|
||||
DrawOneEntity(painter, localBuilder, pxPerWorld);
|
||||
}
|
||||
|
||||
private void DrawOneEntity(Painter2D p, IMinimapEntity entity, float pxPerWorld)
|
||||
{
|
||||
if (entity == null) return;
|
||||
|
||||
Vector2 ui = WorldToUI(entity.WorldPosition);
|
||||
|
||||
// Skip if obviously off-screen — overflow:hidden clips it anyway, but skipping
|
||||
// saves Painter2D work for entities far outside the zoomed-in window.
|
||||
var rect = container.contentRect;
|
||||
if (ui.x < -8f || ui.y < -8f || ui.x > rect.width + 8f || ui.y > rect.height + 8f) return;
|
||||
|
||||
Color col = entity.MinimapColor; col.a = 1f;
|
||||
p.fillColor = col;
|
||||
|
||||
switch (entity.IconKind)
|
||||
{
|
||||
case MinimapIconKind.Enemy:
|
||||
{
|
||||
float r = Mathf.Max(entity.MinimapWorldSize * 0.5f * pxPerWorld, EnemyMinRadiusPx);
|
||||
DrawCircle(p, ui, r);
|
||||
break;
|
||||
}
|
||||
case MinimapIconKind.Tower:
|
||||
{
|
||||
// Tower size follows footprint exactly — no pixel floor. Adjacent towers
|
||||
// visually touch on the minimap because their drawn squares span their
|
||||
// full footprint extent in world units.
|
||||
float halfPx = entity.MinimapWorldSize * 0.5f * pxPerWorld;
|
||||
DrawSquare(p, ui, halfPx);
|
||||
break;
|
||||
}
|
||||
case MinimapIconKind.Builder:
|
||||
{
|
||||
float r = Mathf.Max(entity.MinimapWorldSize * 0.5f * pxPerWorld, BuilderMinRadiusPx);
|
||||
DrawCircle(p, ui, r);
|
||||
p.strokeColor = BuilderOutline;
|
||||
p.lineWidth = 1f;
|
||||
p.Stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawCircle(Painter2D p, Vector2 center, float radius)
|
||||
{
|
||||
p.BeginPath();
|
||||
p.Arc(center, radius, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree));
|
||||
p.Fill();
|
||||
}
|
||||
|
||||
private static void DrawSquare(Painter2D p, Vector2 center, float halfSize)
|
||||
{
|
||||
p.BeginPath();
|
||||
p.MoveTo(new Vector2(center.x - halfSize, center.y - halfSize));
|
||||
p.LineTo(new Vector2(center.x + halfSize, center.y - halfSize));
|
||||
p.LineTo(new Vector2(center.x + halfSize, center.y + halfSize));
|
||||
p.LineTo(new Vector2(center.x - halfSize, center.y + halfSize));
|
||||
p.ClosePath();
|
||||
p.Fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/UI/Minimap/MinimapView.cs.meta
Normal file
2
Assets/_Project/Scripts/UI/Minimap/MinimapView.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0462e237a5a906242a62de1740fc4365
|
||||
|
|
@ -165,13 +165,28 @@
|
|||
border-top-color: rgba(90, 74, 16, 1);
|
||||
}
|
||||
|
||||
/* Minimap — RenderTexture applied as background-image at runtime */
|
||||
/* Minimap — host container. MinimapView injects two stacked sub-elements
|
||||
(.minimap-terrain and .minimap-entities) at runtime. */
|
||||
.minimap {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
background-color: rgb(10, 26, 10);
|
||||
background-color: rgb(8, 10, 12);
|
||||
border-right-width: 1px;
|
||||
border-right-color: rgba(42, 58, 26, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Static baked terrain. Background-image assigned by MinimapView. */
|
||||
.minimap-terrain {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
-unity-background-scale-mode: stretch-to-fill;
|
||||
}
|
||||
|
||||
/* Dynamic entity overlay. Drawn via Painter2D in generateVisualContent. */
|
||||
.minimap-entities {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
}
|
||||
|
||||
/* Portrait */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue