Merge pull request 'tower-sell' (#12) from tower-sell into main
Reviewed-on: #12
This commit is contained in:
commit
52f7bfdf51
28 changed files with 5746 additions and 33 deletions
138
Assets/_Project/Art/Materials/M_Gold.mat
Normal file
138
Assets/_Project/Art/Materials/M_Gold.mat
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
%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_Gold
|
||||||
|
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
|
||||||
|
m_Parent: {fileID: 0}
|
||||||
|
m_ModifiedSerializedProperties: 0
|
||||||
|
m_ValidKeywords:
|
||||||
|
- _EMISSION
|
||||||
|
m_InvalidKeywords: []
|
||||||
|
m_LightmapFlags: 2
|
||||||
|
m_EnableInstancingVariants: 1
|
||||||
|
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.3
|
||||||
|
- _OcclusionStrength: 1
|
||||||
|
- _Parallax: 0.005
|
||||||
|
- _QueueOffset: 0
|
||||||
|
- _ReceiveShadows: 1
|
||||||
|
- _Smoothness: 0.85
|
||||||
|
- _SmoothnessTextureChannel: 0
|
||||||
|
- _SpecularHighlights: 1
|
||||||
|
- _SrcBlend: 1
|
||||||
|
- _SrcBlendAlpha: 1
|
||||||
|
- _Surface: 0
|
||||||
|
- _WorkflowMode: 1
|
||||||
|
- _XRMotionVectorsPass: 1
|
||||||
|
- _ZWrite: 1
|
||||||
|
m_Colors:
|
||||||
|
- _BaseColor: {r: 0.83137256, g: 0.6862745, b: 0.21568628, a: 1}
|
||||||
|
- _Color: {r: 0.83137256, g: 0.68627447, b: 0.2156862, a: 1}
|
||||||
|
- _EmissionColor: {r: 2, g: 0.6755797, b: 0.031372547, a: 1}
|
||||||
|
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
|
||||||
|
m_BuildTextureStacks: []
|
||||||
|
m_AllowLocking: 1
|
||||||
|
--- !u!114 &1062544154538963945
|
||||||
|
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
|
||||||
8
Assets/_Project/Art/Materials/M_Gold.mat.meta
Normal file
8
Assets/_Project/Art/Materials/M_Gold.mat.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 915f376be23f20941837c0809626fa5f
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 2100000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/_Project/Art/Models/Blend Files.meta
Normal file
8
Assets/_Project/Art/Models/Blend Files.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d70d84aef04e61749a232234c80f08ce
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/_Project/Art/Models/Coin.fbx
(Stored with Git LFS)
Normal file
BIN
Assets/_Project/Art/Models/Coin.fbx
(Stored with Git LFS)
Normal file
Binary file not shown.
110
Assets/_Project/Art/Models/Coin.fbx.meta
Normal file
110
Assets/_Project/Art/Models/Coin.fbx.meta
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 280a727da941fc544b9aab7df7fe9f2d
|
||||||
|
ModelImporter:
|
||||||
|
serializedVersion: 24200
|
||||||
|
internalIDToNameTable: []
|
||||||
|
externalObjects: {}
|
||||||
|
materials:
|
||||||
|
materialImportMode: 2
|
||||||
|
materialName: 0
|
||||||
|
materialSearch: 1
|
||||||
|
materialLocation: 1
|
||||||
|
animations:
|
||||||
|
legacyGenerateAnimations: 4
|
||||||
|
bakeSimulation: 0
|
||||||
|
resampleCurves: 1
|
||||||
|
optimizeGameObjects: 0
|
||||||
|
removeConstantScaleCurves: 0
|
||||||
|
motionNodeName:
|
||||||
|
animationImportErrors:
|
||||||
|
animationImportWarnings:
|
||||||
|
animationRetargetingWarnings:
|
||||||
|
animationDoRetargetingWarnings: 0
|
||||||
|
importAnimatedCustomProperties: 0
|
||||||
|
importConstraints: 0
|
||||||
|
animationCompression: 1
|
||||||
|
animationRotationError: 0.5
|
||||||
|
animationPositionError: 0.5
|
||||||
|
animationScaleError: 0.5
|
||||||
|
animationWrapMode: 0
|
||||||
|
extraExposedTransformPaths: []
|
||||||
|
extraUserProperties: []
|
||||||
|
clipAnimations: []
|
||||||
|
isReadable: 0
|
||||||
|
meshes:
|
||||||
|
lODScreenPercentages: []
|
||||||
|
globalScale: 1
|
||||||
|
meshCompression: 0
|
||||||
|
addColliders: 0
|
||||||
|
useSRGBMaterialColor: 1
|
||||||
|
sortHierarchyByName: 1
|
||||||
|
importPhysicalCameras: 1
|
||||||
|
importVisibility: 1
|
||||||
|
importBlendShapes: 1
|
||||||
|
importCameras: 1
|
||||||
|
importLights: 1
|
||||||
|
nodeNameCollisionStrategy: 1
|
||||||
|
fileIdsGeneration: 2
|
||||||
|
swapUVChannels: 0
|
||||||
|
generateSecondaryUV: 0
|
||||||
|
useFileUnits: 1
|
||||||
|
keepQuads: 0
|
||||||
|
weldVertices: 1
|
||||||
|
bakeAxisConversion: 0
|
||||||
|
preserveHierarchy: 0
|
||||||
|
skinWeightsMode: 0
|
||||||
|
maxBonesPerVertex: 4
|
||||||
|
minBoneWeight: 0.001
|
||||||
|
optimizeBones: 1
|
||||||
|
generateMeshLods: 0
|
||||||
|
meshLodGenerationFlags: 0
|
||||||
|
maximumMeshLod: -1
|
||||||
|
meshOptimizationFlags: -1
|
||||||
|
indexFormat: 0
|
||||||
|
secondaryUVAngleDistortion: 8
|
||||||
|
secondaryUVAreaDistortion: 15.000001
|
||||||
|
secondaryUVHardAngle: 88
|
||||||
|
secondaryUVMarginMethod: 1
|
||||||
|
secondaryUVMinLightmapResolution: 40
|
||||||
|
secondaryUVMinObjectScale: 1
|
||||||
|
secondaryUVPackMargin: 4
|
||||||
|
useFileScale: 1
|
||||||
|
strictVertexDataChecks: 0
|
||||||
|
tangentSpace:
|
||||||
|
normalSmoothAngle: 60
|
||||||
|
normalImportMode: 0
|
||||||
|
tangentImportMode: 3
|
||||||
|
normalCalculationMode: 4
|
||||||
|
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
|
||||||
|
blendShapeNormalImportMode: 1
|
||||||
|
normalSmoothingSource: 0
|
||||||
|
referencedClips: []
|
||||||
|
importAnimation: 1
|
||||||
|
humanDescription:
|
||||||
|
serializedVersion: 3
|
||||||
|
human: []
|
||||||
|
skeleton: []
|
||||||
|
armTwist: 0.5
|
||||||
|
foreArmTwist: 0.5
|
||||||
|
upperLegTwist: 0.5
|
||||||
|
legTwist: 0.5
|
||||||
|
armStretch: 0.05
|
||||||
|
legStretch: 0.05
|
||||||
|
feetSpacing: 0
|
||||||
|
globalScale: 1
|
||||||
|
rootMotionBoneName:
|
||||||
|
hasTranslationDoF: 0
|
||||||
|
hasExtraRoot: 0
|
||||||
|
skeletonHasParents: 1
|
||||||
|
lastHumanDescriptionAvatarSource: {instanceID: 0}
|
||||||
|
autoGenerateAvatarMappingIfUnspecified: 1
|
||||||
|
animationType: 2
|
||||||
|
humanoidOversampling: 1
|
||||||
|
avatarSetup: 0
|
||||||
|
addHumanoidExtraRootOnlyWhenUsingAvatar: 1
|
||||||
|
importBlendShapeDeformPercent: 1
|
||||||
|
remapMaterialsIfMaterialImportModeIsNone: 0
|
||||||
|
additionalBone: 0
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
4896
Assets/_Project/Art/VFX/FX_SellTower.prefab
Normal file
4896
Assets/_Project/Art/VFX/FX_SellTower.prefab
Normal file
File diff suppressed because it is too large
Load diff
7
Assets/_Project/Art/VFX/FX_SellTower.prefab.meta
Normal file
7
Assets/_Project/Art/VFX/FX_SellTower.prefab.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 31f8d8c320b42c54d886d610fb13a1e7
|
||||||
|
PrefabImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/_Project/Audio/Sound Effects.meta
Normal file
8
Assets/_Project/Audio/Sound Effects.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d1508e070d3666a4282a219e30ffdbf6
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/_Project/Audio/Sound Effects/SellSoundEffect.ogg
(Stored with Git LFS)
Normal file
BIN
Assets/_Project/Audio/Sound Effects/SellSoundEffect.ogg
(Stored with Git LFS)
Normal file
Binary file not shown.
23
Assets/_Project/Audio/Sound Effects/SellSoundEffect.ogg.meta
Normal file
23
Assets/_Project/Audio/Sound Effects/SellSoundEffect.ogg.meta
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 778b3d5981f4c3045a475794435f43cc
|
||||||
|
AudioImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 8
|
||||||
|
defaultSettings:
|
||||||
|
serializedVersion: 2
|
||||||
|
loadType: 0
|
||||||
|
sampleRateSetting: 0
|
||||||
|
sampleRateOverride: 44100
|
||||||
|
compressionFormat: 1
|
||||||
|
quality: 1
|
||||||
|
conversionMode: 0
|
||||||
|
preloadAudioData: 0
|
||||||
|
platformSettingOverrides: {}
|
||||||
|
forceToMono: 0
|
||||||
|
normalize: 1
|
||||||
|
loadInBackground: 0
|
||||||
|
ambisonic: 0
|
||||||
|
3D: 1
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -15492,6 +15492,7 @@ MonoBehaviour:
|
||||||
m_Script: {fileID: 11500000, guid: de7d013503af0f74c950f215f8dae1c0, type: 3}
|
m_Script: {fileID: 11500000, guid: de7d013503af0f74c950f215f8dae1c0, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PathfindingService
|
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PathfindingService
|
||||||
|
recomputeBudgetMs: 1.5
|
||||||
--- !u!4 &1149980841
|
--- !u!4 &1149980841
|
||||||
Transform:
|
Transform:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ namespace TD.Gameplay.Draft
|
||||||
// ----- Owner → server RPCs ----------------------------------------
|
// ----- Owner → server RPCs ----------------------------------------
|
||||||
|
|
||||||
/// <summary>Owning client: pick one of the offered options by id.</summary>
|
/// <summary>Owning client: pick one of the offered options by id.</summary>
|
||||||
[Rpc(SendTo.Server, RequireOwnership = true)]
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||||
public void RequestPickRpc(int optionId)
|
public void RequestPickRpc(int optionId)
|
||||||
{
|
{
|
||||||
ServerResolve(optionId);
|
ServerResolve(optionId);
|
||||||
|
|
@ -148,7 +148,7 @@ namespace TD.Gameplay.Draft
|
||||||
/// afford it or already has an unresolved draft (resolve the current one first so a
|
/// afford it or already has an unresolved draft (resolve the current one first so a
|
||||||
/// free pick is never silently overwritten).
|
/// free pick is never silently overwritten).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Rpc(SendTo.Server, RequireOwnership = true)]
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||||
public void RequestBuyRerollRpc()
|
public void RequestBuyRerollRpc()
|
||||||
{
|
{
|
||||||
if (HasActiveDraft) return; // resolve the pending draft before buying another
|
if (HasActiveDraft) return; // resolve the pending draft before buying another
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ namespace TD.Gameplay
|
||||||
/// and stores the tile waypoint list.</item>
|
/// and stores the tile waypoint list.</item>
|
||||||
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
|
/// <item>Each frame: moves toward the world center of <c>remainingPath[0]</c>.
|
||||||
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
|
/// When within snap distance, pops the waypoint and checks for zone transitions.</item>
|
||||||
/// <item>When <see cref="PathfindingService.OnPathsInvalidated"/> fires (tower placed /
|
/// <item>When the maze changes (tower placed / sold), <see cref="PathfindingService"/>'s
|
||||||
/// sold), <see cref="RecomputePath"/> reruns A* from the current tile.</item>
|
/// budgeted scheduler calls <see cref="RecomputePath"/> (registered via
|
||||||
|
/// <see cref="PathfindingService.RegisterMover"/>), rerunning A* from the current
|
||||||
|
/// tile — spread across frames so a full wave never spikes.</item>
|
||||||
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
|
/// <item>When <c>remainingPath</c> is empty after a pop, the enemy has reached the
|
||||||
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
|
/// goal — <see cref="OnReachedGoal"/> fires and the enemy is despawned.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
|
@ -168,15 +170,18 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// Recompute when a tower is placed or sold — grounded enemies only.
|
// Recompute when a tower is placed or sold — grounded enemies only.
|
||||||
// Flyers path on the static baked grid, so tower changes can never affect
|
// Flyers path on the static baked grid, so tower changes can never affect
|
||||||
// their route; subscribing would just trigger needless recomputes.
|
// their route; registering would just trigger needless recomputes.
|
||||||
|
// Registration (not a direct event subscription) lets PathfindingService
|
||||||
|
// spread the recomputes across frames under a per-frame budget, so a maze
|
||||||
|
// change with a full wave present never spikes a single frame.
|
||||||
if (!isFlying && PathfindingService.Instance != null)
|
if (!isFlying && PathfindingService.Instance != null)
|
||||||
PathfindingService.Instance.OnPathsInvalidated += RecomputePath;
|
PathfindingService.Instance.RegisterMover(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnNetworkDespawn()
|
public override void OnNetworkDespawn()
|
||||||
{
|
{
|
||||||
if (PathfindingService.Instance != null)
|
if (PathfindingService.Instance != null)
|
||||||
PathfindingService.Instance.OnPathsInvalidated -= RecomputePath;
|
PathfindingService.Instance.UnregisterMover(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Server update --------------------------------------------------
|
// ----- Server update --------------------------------------------------
|
||||||
|
|
@ -274,8 +279,9 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
// ----- Path invalidation ----------------------------------------------
|
// ----- Path invalidation ----------------------------------------------
|
||||||
|
|
||||||
// Called on server when LevelLoader.OnWalkabilityChanged fires (tower placed/sold).
|
// Called by PathfindingService's budgeted scheduler after a walkability change
|
||||||
private void RecomputePath()
|
// (tower placed/sold). Public so the scheduler can drive it across frames.
|
||||||
|
public void RecomputePath()
|
||||||
{
|
{
|
||||||
if (!IsServer) return;
|
if (!IsServer) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,15 @@ namespace TD.Gameplay
|
||||||
/// <b>Who calls this:</b>
|
/// <b>Who calls this:</b>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
|
/// <item><see cref="EnemyMovement"/> calls <see cref="ComputePath"/> once on
|
||||||
/// spawn and again whenever <see cref="OnPathsInvalidated"/> fires.</item>
|
/// spawn, then registers via <see cref="RegisterMover"/> to be re-pathed when
|
||||||
|
/// the maze changes.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
|
/// <b>Invalidation:</b> Subscribes to <see cref="LevelLoader.OnWalkabilityChanged"/>.
|
||||||
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event
|
/// When a tower is placed or sold, <c>LevelLoader.SetWalkable</c> fires that event; the
|
||||||
/// and <see cref="OnPathsInvalidated"/> is relayed to all active enemies, which
|
/// service then enqueues every registered grounded enemy and recomputes them under a
|
||||||
/// each recompute their own path from their current tile.
|
/// per-frame time budget (<c>recomputeBudgetMs</c>), draining across frames so a maze
|
||||||
|
/// change with a full wave present never spikes a single frame.
|
||||||
///
|
///
|
||||||
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
|
/// <b>Goal tile set:</b> Built once on <c>Start</c> from
|
||||||
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
|
/// <c>LevelLoader.LevelData.Goals[].TileArea</c>. Goal tiles never change at
|
||||||
|
|
@ -52,7 +54,10 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired on every peer when the walkability grid changes (tower placed/sold).
|
/// Fired on every peer when the walkability grid changes (tower placed/sold).
|
||||||
/// <see cref="EnemyMovement"/> subscribes per-instance to recompute its path.
|
/// Enemies no longer subscribe here — they register with the budgeted re-path
|
||||||
|
/// scheduler (<see cref="RegisterMover"/>) so recomputes spread across frames.
|
||||||
|
/// This event remains as an extension point for any non-enemy listener that wants
|
||||||
|
/// immediate notification of a maze change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event System.Action OnPathsInvalidated;
|
public event System.Action OnPathsInvalidated;
|
||||||
|
|
||||||
|
|
@ -79,6 +84,28 @@ namespace TD.Gameplay
|
||||||
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
|
private readonly Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>();
|
||||||
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
private readonly SimplePriorityQueue openSet = new SimplePriorityQueue();
|
||||||
|
|
||||||
|
// ----- Deferred re-path scheduler ---------------------------------
|
||||||
|
//
|
||||||
|
// Grounded enemies register here (flyers never re-path — baked grid). On a
|
||||||
|
// walkability change we enqueue every registered enemy and recompute a
|
||||||
|
// TIME-BUDGETED number of them per frame, draining the backlog over subsequent
|
||||||
|
// frames. Recomputing them all synchronously on the change frame spiked to ~0.5s
|
||||||
|
// with a full wave on this large grid whenever a tower was sold/placed — this
|
||||||
|
// caps the per-frame cost so no single frame ever hitches. The maze can only be
|
||||||
|
// opened (sell) or narrowed with a guaranteed remaining route (placement, BFS-
|
||||||
|
// validated), so an enemy briefly following its slightly-stale path for a few
|
||||||
|
// frames until its turn comes up is safe and visually negligible.
|
||||||
|
|
||||||
|
[Tooltip("Max wall-clock milliseconds spent recomputing enemy paths per frame " +
|
||||||
|
"after a maze change. The backlog drains over following frames; at least " +
|
||||||
|
"one enemy is always processed per frame so it converges. Lower = smoother " +
|
||||||
|
"but slower to fully update; higher = faster to update but larger frame cost.")]
|
||||||
|
[SerializeField] private float recomputeBudgetMs = 1.5f;
|
||||||
|
|
||||||
|
private readonly HashSet<EnemyMovement> movers = new HashSet<EnemyMovement>();
|
||||||
|
private readonly Queue<EnemyMovement> recomputeQueue = new Queue<EnemyMovement>();
|
||||||
|
private readonly HashSet<EnemyMovement> queued = new HashSet<EnemyMovement>();
|
||||||
|
|
||||||
// ----- Lifecycle --------------------------------------------------
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
|
|
@ -115,6 +142,48 @@ namespace TD.Gameplay
|
||||||
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
|
loader.OnWalkabilityChanged -= HandleWalkabilityChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drains the deferred re-path backlog under a per-frame time budget. Runs on all
|
||||||
|
// peers, but the queue is only ever populated on the server (clients never register
|
||||||
|
// movers), so this is a no-op cost on clients. At least one enemy is processed per
|
||||||
|
// frame whenever the queue is non-empty, so it always converges.
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
if (recomputeQueue.Count == 0) return;
|
||||||
|
|
||||||
|
double startMs = Time.realtimeSinceStartupAsDouble * 1000.0;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var mover = recomputeQueue.Dequeue();
|
||||||
|
if (!queued.Remove(mover)) continue; // unregistered/cancelled after enqueue
|
||||||
|
if (mover != null) mover.RecomputePath(); // reads the CURRENT grid state
|
||||||
|
}
|
||||||
|
while (recomputeQueue.Count > 0
|
||||||
|
&& Time.realtimeSinceStartupAsDouble * 1000.0 - startMs < recomputeBudgetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Deferred re-path scheduler API -----------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a grounded enemy to be re-pathed (budgeted, over following frames)
|
||||||
|
/// whenever the maze changes. Flyers must NOT register — their baked-grid route
|
||||||
|
/// never changes. Called on the server from <see cref="EnemyMovement"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterMover(EnemyMovement mover)
|
||||||
|
{
|
||||||
|
if (mover != null) movers.Add(mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an enemy from the scheduler (on despawn). Any stale entry still sitting
|
||||||
|
/// in the pending queue is skipped when dequeued (the <c>queued</c> membership check).
|
||||||
|
/// </summary>
|
||||||
|
public void UnregisterMover(EnemyMovement mover)
|
||||||
|
{
|
||||||
|
if (mover == null) return;
|
||||||
|
movers.Remove(mover);
|
||||||
|
queued.Remove(mover);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Public API -------------------------------------------------
|
// ----- Public API -------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -471,7 +540,16 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
private void HandleWalkabilityChanged()
|
private void HandleWalkabilityChanged()
|
||||||
{
|
{
|
||||||
|
// Notify any non-enemy listeners immediately (kept for API compatibility).
|
||||||
OnPathsInvalidated?.Invoke();
|
OnPathsInvalidated?.Invoke();
|
||||||
|
|
||||||
|
// Enqueue every registered grounded enemy for a budgeted, deferred recompute
|
||||||
|
// instead of recomputing them all on this frame. Already-queued enemies are
|
||||||
|
// deduped, so rapid successive maze changes just lengthen the drain rather
|
||||||
|
// than compounding into a spike.
|
||||||
|
foreach (var mover in movers)
|
||||||
|
if (queued.Add(mover))
|
||||||
|
recomputeQueue.Enqueue(mover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ namespace TD.Gameplay
|
||||||
/// a random buff from the category at <paramref name="categoryIndex"/>.
|
/// a random buff from the category at <paramref name="categoryIndex"/>.
|
||||||
/// The server validates gold and adds the buff if the purchase succeeds.
|
/// The server validates gold and adds the buff if the purchase succeeds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Rpc(SendTo.Server, RequireOwnership = true)]
|
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||||
public void RequestPurchaseBuffRpc(int categoryIndex)
|
public void RequestPurchaseBuffRpc(int categoryIndex)
|
||||||
{
|
{
|
||||||
if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length)
|
if (categories == null || categoryIndex < 0 || categoryIndex >= categories.Length)
|
||||||
|
|
|
||||||
|
|
@ -143,11 +143,17 @@ namespace TD.Gameplay
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
/// Server-side entry point for awarding gold (wave clear, enemy kill).
|
||||||
/// Direct call — not Rpc-wrapped — because awards always originate
|
/// Direct call — not Rpc-wrapped — because awards always originate
|
||||||
/// from server-authoritative game events. Also increments
|
/// from server-authoritative game events. When <paramref name="countAsEarned"/>
|
||||||
/// <see cref="GoldEarnedThisWave"/> so the HUD's per-wave counter reflects
|
/// is true (the default) it also increments <see cref="GoldEarnedThisWave"/> so the
|
||||||
/// it; spending does not decrement that counter (it tracks earnings, not balance).
|
/// HUD's per-wave counter reflects it; spending does not decrement that counter (it
|
||||||
|
/// tracks earnings, not balance).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void AwardGold(int amount)
|
/// <param name="countAsEarned">
|
||||||
|
/// Pass false for gold returned to the player that isn't income — e.g. a tower-sell
|
||||||
|
/// refund. The player's balance still rises, but the per-wave "earned" counter does not,
|
||||||
|
/// so refunds don't inflate round-income stats.
|
||||||
|
/// </param>
|
||||||
|
public void AwardGold(int amount, bool countAsEarned = true)
|
||||||
{
|
{
|
||||||
if (!IsServer)
|
if (!IsServer)
|
||||||
{
|
{
|
||||||
|
|
@ -158,7 +164,7 @@ namespace TD.Gameplay
|
||||||
|
|
||||||
if (amount <= 0) return;
|
if (amount <= 0) return;
|
||||||
currentGold.Value += amount;
|
currentGold.Value += amount;
|
||||||
goldEarnedThisWave.Value += amount;
|
if (countAsEarned) goldEarnedThisWave.Value += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Assets/_Project/Scripts/Gameplay/TowerInstance.cs
|
// Assets/_Project/Scripts/Gameplay/TowerInstance.cs
|
||||||
|
using System.Collections.Generic;
|
||||||
using Unity.Netcode;
|
using Unity.Netcode;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
|
|
@ -105,6 +106,24 @@ namespace TD.Gameplay
|
||||||
readPerm: NetworkVariableReadPermission.Everyone,
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
writePerm: NetworkVariableWritePermission.Server);
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
// Total gold sunk into this tower: its placement cost plus any gold later spent
|
||||||
|
// upgrading it. Set on the server at spawn (= placement cost) and grown by the
|
||||||
|
// (future) upgrade system via ServerAddUpgradeInvestment. Replicated so the HUD's
|
||||||
|
// Sell button can preview the exact refund without a server round-trip.
|
||||||
|
private readonly NetworkVariable<int> goldInvested =
|
||||||
|
new NetworkVariable<int>(
|
||||||
|
0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
// Number of upgrades applied to this tower. 0 = never upgraded. Drives the Wall's
|
||||||
|
// "full refund only while un-upgraded" rule and will back tier display later.
|
||||||
|
private readonly NetworkVariable<int> upgradeCount =
|
||||||
|
new NetworkVariable<int>(
|
||||||
|
0,
|
||||||
|
readPerm: NetworkVariableReadPermission.Everyone,
|
||||||
|
writePerm: NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
// ----- Local resolved state -------------------------------------------
|
// ----- Local resolved state -------------------------------------------
|
||||||
|
|
||||||
// Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog.
|
// Resolved on every client in OnNetworkSpawn from definitionTypeId via the catalog.
|
||||||
|
|
@ -144,6 +163,12 @@ namespace TD.Gameplay
|
||||||
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
/// <summary>The footprint anchor tile (SW corner, world-tile coords).</summary>
|
||||||
public Vector2Int AnchorTile => anchorTile.Value;
|
public Vector2Int AnchorTile => anchorTile.Value;
|
||||||
|
|
||||||
|
/// <summary>Total gold sunk into this tower so far (placement + upgrades).</summary>
|
||||||
|
public int GoldInvested => goldInvested.Value;
|
||||||
|
|
||||||
|
/// <summary>How many upgrades have been applied to this tower (0 = never upgraded).</summary>
|
||||||
|
public int UpgradeCount => upgradeCount.Value;
|
||||||
|
|
||||||
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
|
/// <summary>World-unit height the post-construction drop animation falls from.</summary>
|
||||||
public float DropHeight => dropHeight;
|
public float DropHeight => dropHeight;
|
||||||
|
|
||||||
|
|
@ -254,6 +279,11 @@ namespace TD.Gameplay
|
||||||
anchorTile.Value = pendingAnchor;
|
anchorTile.Value = pendingAnchor;
|
||||||
ownerSlot.Value = pendingOwner;
|
ownerSlot.Value = pendingOwner;
|
||||||
|
|
||||||
|
// Seed invested gold with the placement cost — the same amount
|
||||||
|
// TowerPlacementManager deducted to build this tower. The upgrade system
|
||||||
|
// grows this later via ServerAddUpgradeInvestment.
|
||||||
|
goldInvested.Value = pendingDefinition != null ? pendingDefinition.GoldCost : 0;
|
||||||
|
|
||||||
// Clear the pending data — it's now committed to NetworkVariables.
|
// Clear the pending data — it's now committed to NetworkVariables.
|
||||||
hasPendingInit = false;
|
hasPendingInit = false;
|
||||||
}
|
}
|
||||||
|
|
@ -345,6 +375,84 @@ namespace TD.Gameplay
|
||||||
// Re-tint on every client (and the server) when the replicated paint color changes.
|
// Re-tint on every client (and the server) when the replicated paint color changes.
|
||||||
private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint();
|
private void HandlePaintColorChanged(PaintColor previous, PaintColor current) => ApplyTint();
|
||||||
|
|
||||||
|
// ----- Selling --------------------------------------------------------
|
||||||
|
|
||||||
|
// Server-only guard: a fast double-click could deliver two sell RPCs before the
|
||||||
|
// despawn propagates. Mirrors BuildSiteVisual.serverCancelled.
|
||||||
|
private bool serverSold;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gold returned if this tower is sold right now. Single source of truth for both
|
||||||
|
/// the server (which awards it) and the HUD (which labels the Sell button). By
|
||||||
|
/// default this is <see cref="TowerDefinition.SellRefundPercent"/> of everything
|
||||||
|
/// invested; a tower flagged <see cref="TowerDefinition.FullRefundIfUnupgraded"/>
|
||||||
|
/// (the Wall) returns the full amount while it has never been upgraded.
|
||||||
|
/// </summary>
|
||||||
|
public int ComputeSellRefund()
|
||||||
|
{
|
||||||
|
int invested = goldInvested.Value;
|
||||||
|
if (resolvedDefinition == null)
|
||||||
|
return Mathf.RoundToInt(invested * 0.75f);
|
||||||
|
|
||||||
|
if (resolvedDefinition.FullRefundIfUnupgraded && upgradeCount.Value == 0)
|
||||||
|
return invested;
|
||||||
|
|
||||||
|
return Mathf.RoundToInt(invested * resolvedDefinition.SellRefundPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: records gold spent upgrading this tower (so a later sell refunds a
|
||||||
|
/// share of it) and marks the tower upgraded — which forfeits any
|
||||||
|
/// full-refund-while-unupgraded rule. The upgrade system calls this when it lands.
|
||||||
|
/// </summary>
|
||||||
|
public void ServerAddUpgradeInvestment(int cost)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (cost > 0) goldInvested.Value += cost;
|
||||||
|
upgradeCount.Value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client → server request to sell this tower. Accepted only from the tower's owner
|
||||||
|
/// (same ownership rule as placement and paint). The server refunds gold, broadcasts
|
||||||
|
/// the sell VFX/SFX from a persistent object, then despawns the tower —
|
||||||
|
/// <see cref="OnNetworkDespawn"/> restores the footprint's grid state and clears
|
||||||
|
/// selection on every peer.
|
||||||
|
/// </summary>
|
||||||
|
[Rpc(SendTo.Server)]
|
||||||
|
public void RequestSellServerRpc(RpcParams rpcParams = default)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
if (serverSold) return; // idempotent guard against a double-click
|
||||||
|
|
||||||
|
ulong senderClientId = rpcParams.Receive.SenderClientId;
|
||||||
|
PlayerSlot senderSlot = PlayerMatchState.SlotForClient(senderClientId);
|
||||||
|
if (senderSlot == PlayerSlot.None || senderSlot != ownerSlot.Value)
|
||||||
|
{
|
||||||
|
Debug.Log($"[TowerInstance] Sell rejected: client {senderClientId} " +
|
||||||
|
$"({senderSlot}) does not own tower owned by {ownerSlot.Value}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverSold = true;
|
||||||
|
|
||||||
|
int refund = ComputeSellRefund();
|
||||||
|
var goldManager = PlayerGoldManager.GetForClient(senderClientId);
|
||||||
|
if (goldManager != null && refund > 0)
|
||||||
|
// countAsEarned: false — a sell refund returns spent gold, it is not round
|
||||||
|
// income, so it must not inflate the per-wave "earned" counter.
|
||||||
|
goldManager.AwardGold(refund, countAsEarned: false);
|
||||||
|
|
||||||
|
// Broadcast VFX/SFX from the persistent placement manager, capturing the world
|
||||||
|
// position NOW — this NetworkObject despawns below, so it can't carry the RPC to
|
||||||
|
// remote peers itself (same reason WaveManager routes kill/leak popups).
|
||||||
|
var pm = TowerPlacementManager.Instance;
|
||||||
|
if (pm != null) pm.BroadcastSellEffect(transform.position);
|
||||||
|
|
||||||
|
if (NetworkObject != null && NetworkObject.IsSpawned)
|
||||||
|
NetworkObject.Despawn(destroy: true);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- IMinimapEntity -------------------------------------------------
|
// ----- IMinimapEntity -------------------------------------------------
|
||||||
//
|
//
|
||||||
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
// Towers are static, so WorldPosition is cheap (no movement to track). Color reflects
|
||||||
|
|
@ -404,11 +512,20 @@ namespace TD.Gameplay
|
||||||
? resolvedDefinition.FootprintSize
|
? resolvedDefinition.FootprintSize
|
||||||
: new Vector2Int(2, 2);
|
: new Vector2Int(2, 2);
|
||||||
|
|
||||||
|
// Collect the footprint, then stamp walkability as a BATCH so a single
|
||||||
|
// OnWalkabilityChanged fires for the whole footprint. Per-tile SetWalkable fires
|
||||||
|
// that event once PER TILE — on despawn (sell) that meant up to 4 full enemy A*
|
||||||
|
// re-paths for a 2×2 tower in one frame, which was the sell hitch. Placement
|
||||||
|
// batches for the same reason (TowerPlacementManager.StampWalkable). Occupancy
|
||||||
|
// doesn't fire walkability events, so it stays per-tile.
|
||||||
|
var footprint = new List<Vector2Int>(footprintSize.x * footprintSize.y);
|
||||||
foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
|
foreach (var tile in GridCoordinates.GetFootprintTiles(anchorTile.Value, footprintSize))
|
||||||
{
|
{
|
||||||
loader.SetWalkable(tile, walkable);
|
footprint.Add(tile);
|
||||||
loader.SetOccupied(tile, occupied);
|
loader.SetOccupied(tile, occupied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loader.SetWalkableBatch(footprint, walkable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reused per-instance across color updates to avoid per-call GC allocation.
|
// Reused per-instance across color updates to avoid per-call GC allocation.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using UnityEngine;
|
||||||
using TD.Core;
|
using TD.Core;
|
||||||
using TD.Levels;
|
using TD.Levels;
|
||||||
using TD.Towers;
|
using TD.Towers;
|
||||||
|
using TD.VFX;
|
||||||
|
|
||||||
namespace TD.Gameplay
|
namespace TD.Gameplay
|
||||||
{
|
{
|
||||||
|
|
@ -350,6 +351,43 @@ namespace TD.Gameplay
|
||||||
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
|
$"client {req.SenderClientId} ({placingSlot}) at anchor {req.Anchor}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Sell effects (persistent broadcaster) ----------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only: tells every peer to play the tower-sold VFX/SFX at
|
||||||
|
/// <paramref name="worldPos"/>. Called by <see cref="TowerInstance"/> as it sells,
|
||||||
|
/// because the tower's own NetworkObject despawns the same frame — a persistent
|
||||||
|
/// object must carry the broadcast so it isn't dropped.
|
||||||
|
/// </summary>
|
||||||
|
public void BroadcastSellEffect(Vector3 worldPos)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
PlaySellEffectRpc(worldPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time guard so a missing spawner warns once per peer instead of on every sale.
|
||||||
|
private static bool s_warnedNoSellSpawner;
|
||||||
|
|
||||||
|
[Rpc(SendTo.Everyone)]
|
||||||
|
private void PlaySellEffectRpc(Vector3 worldPos)
|
||||||
|
{
|
||||||
|
var spawner = SellEffectSpawner.Instance;
|
||||||
|
if (spawner == null)
|
||||||
|
{
|
||||||
|
if (!s_warnedNoSellSpawner)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[TowerPlacementManager] A tower was sold, but there is no " +
|
||||||
|
"SellEffectSpawner in the scene — no coin VFX or sell sound " +
|
||||||
|
"will play. Add a SellEffectSpawner GameObject to each Match " +
|
||||||
|
"scene (assign its Sell Sound clip; the coin burst works even " +
|
||||||
|
"with no VFX prefab).");
|
||||||
|
s_warnedNoSellSpawner = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawner.Play(worldPos);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Server-side commit hooks called by Builder ------------------
|
// ----- Server-side commit hooks called by Builder ------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,22 @@ namespace TD.Towers
|
||||||
"successful server-side placement validation.")]
|
"successful server-side placement validation.")]
|
||||||
public int GoldCost;
|
public int GoldCost;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Selling
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Header("Selling")]
|
||||||
|
[Tooltip("Portion of the gold INVESTED in this tower (placement cost plus any gold " +
|
||||||
|
"later spent upgrading it) that is refunded when it is sold, as a 0–1 factor. " +
|
||||||
|
"Default 0.75 = 75%.")]
|
||||||
|
[Range(0f, 1f)]
|
||||||
|
public float SellRefundPercent = 0.75f;
|
||||||
|
|
||||||
|
[Tooltip("When true, this tower refunds 100% of its invested gold while it has never " +
|
||||||
|
"been upgraded (upgradeCount == 0); once upgraded it falls back to " +
|
||||||
|
"SellRefundPercent. Set on the Wall so a fresh wall can be re-mazed for free.")]
|
||||||
|
public bool FullRefundIfUnupgraded = false;
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Construction
|
// Construction
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1327,19 +1327,30 @@ namespace TD.UI
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sell action for a completed tower. Refund is computed on the TowerInstance (single
|
||||||
|
// source of truth shared with the server) so the badge shows exactly what the player
|
||||||
|
// gets back. Hotkey is Key.None: the bottom-right slot's letter (B) is already the
|
||||||
|
// global buff-menu toggle — see CreateBuffMenuButton — so binding it here would fire
|
||||||
|
// both. The button stays click-only.
|
||||||
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
|
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
|
||||||
{
|
{
|
||||||
int sellValue = tower.Definition != null
|
// Only the tower's owner can sell it (the server enforces this too). A non-owner
|
||||||
? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
|
// may have this tower view-selected — show the slot disabled with no refund badge
|
||||||
: 0;
|
// so the action reads as unavailable rather than misleading.
|
||||||
|
bool ownedByLocal = tower != null
|
||||||
|
&& PlayerMatchState.Local != null
|
||||||
|
&& tower.Owner == PlayerMatchState.Local.Slot;
|
||||||
|
|
||||||
|
int sellValue = ownedByLocal ? tower.ComputeSellRefund() : 0;
|
||||||
var btn = CreateActionButton(
|
var btn = CreateActionButton(
|
||||||
costText: sellValue > 0 ? $"+{sellValue}g" : "",
|
costText: sellValue > 0 ? $"+{sellValue}g" : "",
|
||||||
hotkey: hotkey,
|
hotkey: Key.None,
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
{
|
{
|
||||||
/* TODO: sell flow */
|
if (tower != null)
|
||||||
|
tower.RequestSellServerRpc();
|
||||||
});
|
});
|
||||||
btn.SetEnabled(false);
|
btn.SetEnabled(ownedByLocal);
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1586,7 +1597,11 @@ namespace TD.UI
|
||||||
ttStats.text = "(stats pending)";
|
ttStats.text = "(stats pending)";
|
||||||
}
|
}
|
||||||
|
|
||||||
int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f);
|
// Sell preview for a freshly placed (un-upgraded) tower: mirrors
|
||||||
|
// TowerInstance.ComputeSellRefund for invested == GoldCost, upgradeCount == 0.
|
||||||
|
int sellValue = def.FullRefundIfUnupgraded
|
||||||
|
? def.GoldCost
|
||||||
|
: Mathf.RoundToInt(def.GoldCost * def.SellRefundPercent);
|
||||||
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
|
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
101
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
Normal file
101
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Assets/_Project/Scripts/VFX/CoinBurstVfx.cs
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.VFX
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Self-contained, code-configured gold-coin burst used as the zero-art placeholder for the
|
||||||
|
/// tower-sell effect. Configures its own <see cref="ParticleSystem"/> for a short radial
|
||||||
|
/// spray of gold specks thrown up in random arcs under gravity, then destroys itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Instantiated at runtime by <see cref="SellEffectSpawner"/> when no authored VFX prefab is
|
||||||
|
/// assigned. It can also be dropped on a prefab in the editor and tuned. Replace with an
|
||||||
|
/// authored particle / VFX-graph prefab (assigned on the spawner) when art is ready — nothing
|
||||||
|
/// else needs to change.
|
||||||
|
/// </remarks>
|
||||||
|
[RequireComponent(typeof(ParticleSystem))]
|
||||||
|
public class CoinBurstVfx : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("How many coin specks to throw.")]
|
||||||
|
[SerializeField] private int coinCount = 14;
|
||||||
|
|
||||||
|
[Tooltip("Longest a speck lives (seconds). The whole effect self-destructs shortly after.")]
|
||||||
|
[SerializeField] private float lifetime = 0.6f;
|
||||||
|
|
||||||
|
[Tooltip("Gold tint applied to the specks.")]
|
||||||
|
[SerializeField] private Color coinColor = new Color(1f, 0.84f, 0.2f, 1f);
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
var ps = GetComponent<ParticleSystem>();
|
||||||
|
if (ps == null) ps = gameObject.AddComponent<ParticleSystem>();
|
||||||
|
Configure(ps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Configure(ParticleSystem ps)
|
||||||
|
{
|
||||||
|
// Reconfiguring modules requires the system to be stopped first.
|
||||||
|
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||||
|
|
||||||
|
var main = ps.main;
|
||||||
|
main.duration = 0.1f;
|
||||||
|
main.loop = false;
|
||||||
|
main.playOnAwake = false;
|
||||||
|
main.startLifetime = new ParticleSystem.MinMaxCurve(lifetime * 0.6f, lifetime);
|
||||||
|
main.startSpeed = new ParticleSystem.MinMaxCurve(2.5f, 5f);
|
||||||
|
main.startSize = new ParticleSystem.MinMaxCurve(0.07f, 0.16f);
|
||||||
|
main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f);
|
||||||
|
main.startColor = coinColor;
|
||||||
|
main.gravityModifier = 2.5f; // arc up then fall back down quickly
|
||||||
|
main.simulationSpace = ParticleSystemSimulationSpace.World;
|
||||||
|
main.maxParticles = 64;
|
||||||
|
main.stopAction = ParticleSystemStopAction.None;
|
||||||
|
|
||||||
|
var emission = ps.emission;
|
||||||
|
emission.enabled = true;
|
||||||
|
emission.rateOverTime = 0f; // burst only, no continuous stream
|
||||||
|
emission.SetBursts(new[] { new ParticleSystem.Burst(0f, (short)coinCount) });
|
||||||
|
|
||||||
|
// Hemisphere pointing up → specks spray outward and upward in random directions.
|
||||||
|
var shape = ps.shape;
|
||||||
|
shape.enabled = true;
|
||||||
|
shape.shapeType = ParticleSystemShapeType.Hemisphere;
|
||||||
|
shape.radius = 0.12f;
|
||||||
|
|
||||||
|
// Fade the specks out over the back half of their life so they "decay quickly".
|
||||||
|
var col = ps.colorOverLifetime;
|
||||||
|
col.enabled = true;
|
||||||
|
var grad = new Gradient();
|
||||||
|
grad.SetKeys(
|
||||||
|
new[] { new GradientColorKey(coinColor, 0f), new GradientColorKey(coinColor, 1f) },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new GradientAlphaKey(1f, 0f),
|
||||||
|
new GradientAlphaKey(1f, 0.55f),
|
||||||
|
new GradientAlphaKey(0f, 1f),
|
||||||
|
});
|
||||||
|
col.color = new ParticleSystem.MinMaxGradient(grad);
|
||||||
|
|
||||||
|
// Give the specks a visible, transparent-capable material even with no assigned art.
|
||||||
|
var renderer = GetComponent<ParticleSystemRenderer>();
|
||||||
|
if (renderer != null)
|
||||||
|
{
|
||||||
|
renderer.renderMode = ParticleSystemRenderMode.Billboard;
|
||||||
|
if (renderer.sharedMaterial == null)
|
||||||
|
{
|
||||||
|
Shader shader = Shader.Find("Universal Render Pipeline/Particles/Unlit")
|
||||||
|
?? Shader.Find("Sprites/Default")
|
||||||
|
?? Shader.Find("Legacy Shaders/Particles/Alpha Blended Premultiply");
|
||||||
|
if (shader != null)
|
||||||
|
renderer.material = new Material(shader) { color = coinColor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.Play();
|
||||||
|
|
||||||
|
// Tear down after the last speck has died (plus a small margin).
|
||||||
|
Destroy(gameObject, lifetime + 0.25f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta
Normal file
2
Assets/_Project/Scripts/VFX/CoinBurstVfx.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bdff685029f23004796671150c7cf39b
|
||||||
91
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
Normal file
91
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Assets/_Project/Scripts/VFX/SellEffectSpawner.cs
|
||||||
|
using UnityEngine;
|
||||||
|
using TD.Audio;
|
||||||
|
|
||||||
|
namespace TD.VFX
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scene singleton that plays the "tower sold" feedback — a burst of gold coins plus a
|
||||||
|
/// coin-rustle sound — at a world position. Mirrors <see cref="TD.UI.FloatingTextSpawner"/>:
|
||||||
|
/// visual-only, plain <c>MonoBehaviour</c>, invoked on every peer via a ClientRpc so all
|
||||||
|
/// players see and hear a sale locally.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Who calls this:</b> <see cref="TD.Gameplay.TowerPlacementManager.BroadcastSellEffect"/>
|
||||||
|
/// routes a ClientRpc here when a tower is sold.
|
||||||
|
///
|
||||||
|
/// <b>Inspector setup:</b> drop this on a <c>SellEffectSpawner</c> GameObject in each Match
|
||||||
|
/// scene. The VFX prefab is OPTIONAL — leave it empty to use the built-in
|
||||||
|
/// <see cref="CoinBurstVfx"/> placeholder, or assign an authored particle/VFX-graph prefab.
|
||||||
|
/// Assign the coin-rustle clip on <see cref="sellSound"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public class SellEffectSpawner : MonoBehaviour
|
||||||
|
{
|
||||||
|
// ----- Singleton --------------------------------------------------
|
||||||
|
|
||||||
|
public static SellEffectSpawner Instance { get; private set; }
|
||||||
|
|
||||||
|
// ----- Inspector --------------------------------------------------
|
||||||
|
|
||||||
|
[Tooltip("Optional authored VFX prefab spawned at the sale position. Leave empty to " +
|
||||||
|
"fall back to the built-in code-generated CoinBurstVfx placeholder.")]
|
||||||
|
[SerializeField] private GameObject coinBurstPrefab;
|
||||||
|
|
||||||
|
[Tooltip("Coin-rustle sound played (2D, via AudioManager) when a tower is sold.")]
|
||||||
|
[SerializeField] private SoundConfig sellSound;
|
||||||
|
|
||||||
|
[Tooltip("Vertical offset above the sale position where the coin burst originates, so " +
|
||||||
|
"it reads as coming from the tower body rather than the ground.")]
|
||||||
|
[SerializeField] private float verticalOffset = 0.75f;
|
||||||
|
|
||||||
|
// ----- Lifecycle --------------------------------------------------
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (Instance != null && Instance != this)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[SellEffectSpawner] Duplicate instance detected. " +
|
||||||
|
"Only one should exist per scene.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
if (Instance == this) Instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Public API -------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Plays the coin burst + rustle sound at <paramref name="worldPos"/>.</summary>
|
||||||
|
public void Play(Vector3 worldPos)
|
||||||
|
{
|
||||||
|
Vector3 spawnPos = worldPos + Vector3.up * verticalOffset;
|
||||||
|
|
||||||
|
if (coinBurstPrefab != null)
|
||||||
|
{
|
||||||
|
var instance = Instantiate(coinBurstPrefab, spawnPos, Quaternion.identity);
|
||||||
|
|
||||||
|
// Force the burst to play. The spawner's whole job is to play this effect, so
|
||||||
|
// it shouldn't depend on the prefab's ParticleSystem having "Play On Awake"
|
||||||
|
// ticked — if that's off, the effect would silently never appear. Harmless when
|
||||||
|
// Play On Awake is already on (a freshly instantiated one-shot just restarts at
|
||||||
|
// t=0). VFX Graph effects play from their own settings, so a null PS is fine.
|
||||||
|
var ps = instance.GetComponentInChildren<ParticleSystem>();
|
||||||
|
if (ps != null) ps.Play(withChildren: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Zero-art fallback: a self-configuring, self-destroying particle burst.
|
||||||
|
var go = new GameObject("CoinBurst");
|
||||||
|
go.transform.position = spawnPos;
|
||||||
|
go.AddComponent<CoinBurstVfx>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sellSound.clip != null)
|
||||||
|
AudioManager.Instance?.Play(sellSound.clip, AudioCategory.UI,
|
||||||
|
sellSound.RandomPitch(), sellSound.volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta
Normal file
2
Assets/_Project/Scripts/VFX/SellEffectSpawner.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e2708c6bac8136f45b48c899d3d40630
|
||||||
30
Assets/_Project/Scripts/VFX/TransientEffect.cs
Normal file
30
Assets/_Project/Scripts/VFX/TransientEffect.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Assets/_Project/Scripts/VFX/TransientEffect.cs
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace TD.VFX
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Destroys its GameObject a fixed time after it spawns. Drop this onto any one-shot
|
||||||
|
/// effect prefab — a particle burst, a VFX Graph effect, a sound-only object — so it
|
||||||
|
/// cleans itself up without any bespoke code. Designed for prefabs spawned by
|
||||||
|
/// <see cref="SellEffectSpawner"/> (and any future effect spawner) that fire once and
|
||||||
|
/// should disappear.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Shuriken shortcut:</b> a plain <see cref="ParticleSystem"/> can instead self-destroy
|
||||||
|
/// with no component at all — set <i>Main → Stop Action → Destroy</i> and make sure Looping
|
||||||
|
/// is off. Use this component for <b>VFX Graph</b> effects (which have no Stop Action) or for
|
||||||
|
/// prefabs that combine several systems and need one predictable lifetime.
|
||||||
|
///
|
||||||
|
/// <para>Set <see cref="lifetime"/> to comfortably exceed the effect's visible duration so it
|
||||||
|
/// isn't cut off mid-play.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public class TransientEffect : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("Seconds after spawn before this GameObject destroys itself. Set it a little " +
|
||||||
|
"longer than the effect's visible length so nothing is cut off.")]
|
||||||
|
[SerializeField, Min(0f)] private float lifetime = 1.5f;
|
||||||
|
|
||||||
|
private void Start() => Destroy(gameObject, lifetime);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/_Project/Scripts/VFX/TransientEffect.cs.meta
Normal file
2
Assets/_Project/Scripts/VFX/TransientEffect.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 739f3145f66002d47850b603164d07b8
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
A snapshot of **where the project is and how it works** — the authoritative reference for current architecture, implemented systems, conventions, and known debt. It pairs with [`Project_Roadmap.md`](Project_Roadmap.md), which is the forward-looking plan. When the two disagree, this document describes *what exists today*; the roadmap describes *what's planned next*.
|
A snapshot of **where the project is and how it works** — the authoritative reference for current architecture, implemented systems, conventions, and known debt. It pairs with [`Project_Roadmap.md`](Project_Roadmap.md), which is the forward-looking plan. When the two disagree, this document describes *what exists today*; the roadmap describes *what's planned next*.
|
||||||
|
|
||||||
Last substantial update: 2026-06-24.
|
Last substantial update: 2026-07-14.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -56,10 +56,11 @@ Unity **6.4 (6000.4.4f1)**, URP, IL2CPP, .NET Standard 2.1, Linear color space,
|
||||||
- `TowerPlacementManager` (server-validated placement queue) — `SpawnTower` now **seats towers flush on the ground from their mesh bounds** (`SeatOnGround`), so any pivot works (replaced the old hard-coded y=0.5). `Builder` build queue with staged construction/pause/cancel/refund, RTS selection, owner tinting.
|
- `TowerPlacementManager` (server-validated placement queue) — `SpawnTower` now **seats towers flush on the ground from their mesh bounds** (`SeatOnGround`), so any pivot works (replaced the old hard-coded y=0.5). `Builder` build queue with staged construction/pause/cancel/refund, RTS selection, owner tinting.
|
||||||
- **Construction-phase build visuals:** `BuildSiteVisual` swaps between an ordered set of phase prefabs as a tower builds — `ConstructionPhaseSet` SO + optional per-tower `TowerDefinition.ConstructionPhases`, with a project-default set and a legacy cube Y-scale fallback. `BuildSiteVisual` resolves its def via `TowerTypeId` (catalog), like `TowerInstance`.
|
- **Construction-phase build visuals:** `BuildSiteVisual` swaps between an ordered set of phase prefabs as a tower builds — `ConstructionPhaseSet` SO + optional per-tower `TowerDefinition.ConstructionPhases`, with a project-default set and a legacy cube Y-scale fallback. `BuildSiteVisual` resolves its def via `TowerTypeId` (catalog), like `TowerInstance`.
|
||||||
- **Tesla Coil tower:** Electric, `AllInRange` (zaps every enemy in range each 0.5s tick). `TeslaArcVisual` draws arced, crackling, HDR `LineRenderer` bolts that flicker, plus **real point lights** (emitter flash + per-impact lights at struck enemies, capped) so the electricity actually illuminates the scene — emission/bloom alone don't cast light.
|
- **Tesla Coil tower:** Electric, `AllInRange` (zaps every enemy in range each 0.5s tick). `TeslaArcVisual` draws arced, crackling, HDR `LineRenderer` bolts that flicker, plus **real point lights** (emitter flash + per-impact lights at struck enemies, capped) so the electricity actually illuminates the scene — emission/bloom alone don't cast light.
|
||||||
|
- **Tower Sell** (branch `tower-sell`, verified in-engine 2026-07-14): selecting an owned, completed tower shows a **Sell** action in the command grid's **bottom-right** slot (owner-gated; a non-owner sees it disabled). `TowerInstance.RequestSellServerRpc` (owner-validated, like paint) refunds `TowerDefinition.SellRefundPercent` (default 0.75) of the tower's replicated `goldInvested` (placement + any future upgrade spend), except a `FullRefundIfUnupgraded` tower (the **Wall**) refunds 100% while `upgradeCount == 0`. The refund is **not** counted as round income — `PlayerGoldManager.AwardGold(amount, countAsEarned:false)`. Selling despawns the tower (`OnNetworkDespawn` un-stamps the footprint as a **batch** and clears selection) and broadcasts a coin **VFX + rustle SFX** through `SellEffectSpawner` (scene singleton mirroring `FloatingTextSpawner`) via `TowerPlacementManager.PlaySellEffectRpc` (routed off a persistent object because the tower despawns the same frame). `TowerInstance.ServerAddUpgradeInvestment` is the seam the future upgrade system writes to. The coin VFX is an **artist-authored** prefab (`FX_SellTower`: a Shuriken **mesh-particle** burst of the Blender `Coin` model with an **emissive** gold material — emissive so it survives the grimdark grade); `CoinBurstVfx` is the zero-art code-gen fallback, and new reusable `TransientEffect` self-destructs one-shot effect prefabs (for VFX-Graph effects that lack a Stop Action).
|
||||||
|
|
||||||
### Enemies & pathfinding
|
### Enemies & pathfinding
|
||||||
- `EnemyHealth` (replicated HP, damage types, `IsFlying`, held state), `EnemyStatus` (lingering effects: slow/DoT), `EnemyMovement` (A* path following, zone-leak attribution, death/sink sequence).
|
- `EnemyHealth` (replicated HP, damage types, `IsFlying`, held state), `EnemyStatus` (lingering effects: slow/DoT), `EnemyMovement` (A* path following, zone-leak attribution, death/sink sequence).
|
||||||
- `PathfindingService` (A* on the runtime walkability grid, octile heuristic, corner-cut prevention, line-of-sight path smoothing; re-paths on tower placement/removal).
|
- `PathfindingService` (A* on the runtime walkability grid, octile heuristic, corner-cut prevention, line-of-sight path smoothing). **Re-paths via a budgeted scheduler:** grounded enemies `RegisterMover`/`UnregisterMover`; on a maze change the service enqueues them and recomputes under a per-frame `recomputeBudgetMs` (default 1.5 ms) drain, spreading the work across frames so a placement/sell with a full wave present no longer spikes a frame (was ~0.5 s). Safe because selling only opens the maze and placement is BFS-guaranteed to leave a route, so a few frames of slightly-stale path is invisible.
|
||||||
- **Flying enemies:** path on the **baked terrain grid** (`LevelLoader.IsBaseWalkable`) so they soar over towers; compute once, never re-path; spawn elevated by `EnemyDefinition.FlightHeight`.
|
- **Flying enemies:** path on the **baked terrain grid** (`LevelLoader.IsBaseWalkable`) so they soar over towers; compute once, never re-path; spawn elevated by `EnemyDefinition.FlightHeight`.
|
||||||
- Content: ~10 enemy definitions/prefabs (Crystal Golem / Cyclops / Ent variants, Undead Drake = the flying test enemy), 10 wave definitions.
|
- Content: ~10 enemy definitions/prefabs (Crystal Golem / Cyclops / Ent variants, Undead Drake = the flying test enemy), 10 wave definitions.
|
||||||
|
|
||||||
|
|
@ -102,4 +103,4 @@ Unity **6.4 (6000.4.4f1)**, URP, IL2CPP, .NET Standard 2.1, Linear color space,
|
||||||
- **Catalog index 0 is a reserved sentinel** (valid `TowerTypeId`s start at 1) — easy to forget when wiring the catalog in the inspector.
|
- **Catalog index 0 is a reserved sentinel** (valid `TowerTypeId`s start at 1) — easy to forget when wiring the catalog in the inspector.
|
||||||
- **Catalog-index identifiers aren't session-stable** — blocks cross-match persistence until a stable ID is added.
|
- **Catalog-index identifiers aren't session-stable** — blocks cross-match persistence until a stable ID is added.
|
||||||
- **Paint system frozen**; **Race vs Builder** naming unresolved; the gold **"Buy Roll available any time"** rule is provisional and may change.
|
- **Paint system frozen**; **Race vs Builder** naming unresolved; the gold **"Buy Roll available any time"** rule is provisional and may change.
|
||||||
- **Stubbed/unbuilt:** tower Upgrade/Sell actions (HUD buttons disabled), enemy resistances/weaknesses, in-match race-pick countdown.
|
- **Stubbed/unbuilt:** tower **Upgrade** action (HUD button still disabled; the `TowerInstance.ServerAddUpgradeInvestment` seam is ready for it), enemy resistances/weaknesses, in-match race-pick countdown. *(Tower **Sell** is now done — see Combat & towers.)*
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
The **forward-looking plan**: what's done, what's next, and the sequencing. Current-state architecture and the full list of implemented systems live in [`Project_Context.md`](Project_Context.md) — read that first for "how things work today." This document focuses on direction and remaining work.
|
The **forward-looking plan**: what's done, what's next, and the sequencing. Current-state architecture and the full list of implemented systems live in [`Project_Context.md`](Project_Context.md) — read that first for "how things work today." This document focuses on direction and remaining work.
|
||||||
|
|
||||||
Last substantial update: 2026-06-24.
|
Last substantial update: 2026-07-14.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -36,6 +36,8 @@ All players start with the **same three towers** — **Basic Arrow** (ground+air
|
||||||
|
|
||||||
**Done & merged:** functional match loop end-to-end, HUD, lobby + race selection, economy, enemies + A* pathfinding, **flying enemies + air-targeting**, **per-player tower deck** (keystone #1), **draft Slice 1** (spine + "new tower"), **TowerRegistry removal** (towers replicate by `TowerTypeId`), **per-tower construction-phase build visuals**, **ground-seating from mesh bounds**, **Tesla Coil tower** (Electric + AllInRange + arc VFX with real flash/impact lights).
|
**Done & merged:** functional match loop end-to-end, HUD, lobby + race selection, economy, enemies + A* pathfinding, **flying enemies + air-targeting**, **per-player tower deck** (keystone #1), **draft Slice 1** (spine + "new tower"), **TowerRegistry removal** (towers replicate by `TowerTypeId`), **per-tower construction-phase build visuals**, **ground-seating from mesh bounds**, **Tesla Coil tower** (Electric + AllInRange + arc VFX with real flash/impact lights).
|
||||||
|
|
||||||
|
**Done, on branch `tower-sell` (pushed to origin; NOT yet merged to `main`):** **Tower Sell** — owner-gated Sell action, refunds `SellRefundPercent` (default 75%) of invested gold, the **Wall refunds 100% while un-upgraded**, refund excluded from round income, coin **VFX** (artist-authored emissive mesh-particle `FX_SellTower`, with a `CoinBurstVfx` code-gen fallback) + rustle **SFX**. Also a **budgeted enemy re-path scheduler** in `PathfindingService` that removes the ~0.5 s frame hitch on any maze change (sell/placement) with a full wave present, plus a batched footprint un-stamp and the NGO `RequireOwnership`→`InvokePermission` deprecation cleanup. Verified in-engine 2026-07-14.
|
||||||
|
|
||||||
**In progress (branch `feature/post-processing`):** project-wide **grimdark post-processing** grade + HDR; Tesla arc lighting polish. *(This branch was fast-forwarded onto the Tesla work; commit the post-processing assets + the `TeslaArcVisual` light additions.)*
|
**In progress (branch `feature/post-processing`):** project-wide **grimdark post-processing** grade + HDR; Tesla arc lighting polish. *(This branch was fast-forwarded onto the Tesla work; commit the post-processing assets + the `TeslaArcVisual` light additions.)*
|
||||||
|
|
||||||
**Paused:** in-match Paint system (overlaps with systemic upgrades).
|
**Paused:** in-match Paint system (overlaps with systemic upgrades).
|
||||||
|
|
@ -80,7 +82,8 @@ Player profile saving owned content; **win/loss reward pools** drawn server-side
|
||||||
|
|
||||||
## Part B — Functional gameplay remainder (not roguelike-specific)
|
## Part B — Functional gameplay remainder (not roguelike-specific)
|
||||||
|
|
||||||
- **Tower Upgrade / Sell actions** — HUD buttons exist but are disabled. Sell first (refund + despawn), then per-tower upgrade if the systemic model leaves room for it.
|
- **Tower Sell — ✅ DONE** (branch `tower-sell`): refund + despawn + coin VFX/SFX; 75% of invested gold, Wall 100% while un-upgraded, refund not counted as income; `TowerInstance.ServerAddUpgradeInvestment` records upgrade spend for the refund and is the seam the Upgrade system will write to.
|
||||||
|
- **Tower Upgrade action** — HUD button still disabled. Build it once the systemic-upgrade model (A3) settles whether per-tower upgrade trees are wanted on top of the systemic modifiers.
|
||||||
- **Enemy resistances / weaknesses** — `EnemyHealth.TakeDamage` has the stub slot; info panel has the placeholder.
|
- **Enemy resistances / weaknesses** — `EnemyHealth.TakeDamage` has the stub slot; info panel has the placeholder.
|
||||||
- **Terrain architecture decision** — Unity Terrain vs mesh vs ProBuilder. Builder code is terrain-agnostic; decide before heavy art.
|
- **Terrain architecture decision** — Unity Terrain vs mesh vs ProBuilder. Builder code is terrain-agnostic; decide before heavy art.
|
||||||
- **Camera polish** — cursor-anchored zoom near map edges, center-on-builder hotkey, race/phase-aware initial position.
|
- **Camera polish** — cursor-anchored zoom near map edges, center-on-builder hotkey, race/phase-aware initial position.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue