코드 리팩토링

재사용성 및 확장성을 고려하여 코드 전반을 리팩토링함
This commit is contained in:
2026-01-21 01:45:15 +09:00
parent b4ac8f600f
commit db5db4b106
45 changed files with 2775 additions and 248 deletions

View File

@@ -84,3 +84,18 @@ MonoBehaviour:
SourcePrefabToOverride: {fileID: 0} SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0 SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0} OverridingTargetPrefab: {fileID: 0}
- Override: 0
Prefab: {fileID: 3659626783364531313, guid: 038d8e53e81683f478bd4a50b71cdb82, type: 3}
SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0}
- Override: 0
Prefab: {fileID: 3321405240327640087, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0}
- Override: 0
Prefab: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3}
SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0}

View File

@@ -13,6 +13,8 @@ GameObject:
- component: {fileID: 6108920277980214130} - component: {fileID: 6108920277980214130}
- component: {fileID: 8496505565574430929} - component: {fileID: 8496505565574430929}
- component: {fileID: 6537968746546598605} - component: {fileID: 6537968746546598605}
- component: {fileID: 5420652907638236344}
- component: {fileID: 1005129893123536417}
m_Layer: 0 m_Layer: 0
m_Name: Core m_Name: Core
m_TagString: Core m_TagString: Core
@@ -68,8 +70,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: eda0106ca978373449769fd4ded4658f, type: 3} m_Script: {fileID: 11500000, guid: eda0106ca978373449769fd4ded4658f, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Core m_EditorClassIdentifier: Assembly-CSharp::Core
maxHealth: 100
currentHealth: 100
--- !u!33 &8496505565574430929 --- !u!33 &8496505565574430929
MeshFilter: MeshFilter:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -127,3 +127,44 @@ MeshRenderer:
m_SortingOrder: 0 m_SortingOrder: 0
m_MaskInteraction: 0 m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0} m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &5420652907638236344
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6403733529880835406}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 948195259
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
AlwaysReplicateAsRoot: 0
SynchronizeTransform: 1
ActiveSceneSynchronization: 0
SceneMigrationSynchronization: 1
SpawnWithObservers: 1
DontDestroyWithOwner: 0
AutoObjectParentSync: 1
SyncOwnerTransformWhenParented: 1
AllowOwnerToParent: 0
--- !u!114 &1005129893123536417
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6403733529880835406}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 1
destroyDelay: 0

View File

@@ -17,6 +17,8 @@ GameObject:
- component: {fileID: 7188026176818599596} - component: {fileID: 7188026176818599596}
- component: {fileID: 9004018437643743475} - component: {fileID: 9004018437643743475}
- component: {fileID: 2076938100137781138} - component: {fileID: 2076938100137781138}
- component: {fileID: -5264722347087192178}
- component: {fileID: 2217125893430872806}
m_Layer: 6 m_Layer: 6
m_Name: Enemy m_Name: Enemy
m_TagString: Enemy m_TagString: Enemy
@@ -211,4 +213,44 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: b2ce198f9f66bbe41a739abb07974082, type: 3} m_Script: {fileID: 11500000, guid: b2ce198f9f66bbe41a739abb07974082, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::EnemyHealth m_EditorClassIdentifier: Assembly-CSharp::EnemyHealth
maxHealth: 50 --- !u!114 &-5264722347087192178
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3659626783364531313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 61460115
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
AlwaysReplicateAsRoot: 0
SynchronizeTransform: 1
ActiveSceneSynchronization: 0
SceneMigrationSynchronization: 1
SpawnWithObservers: 1
DontDestroyWithOwner: 0
AutoObjectParentSync: 1
SyncOwnerTransformWhenParented: 1
AllowOwnerToParent: 0
--- !u!114 &2217125893430872806
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3659626783364531313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 1
destroyDelay: 0

View File

@@ -14,6 +14,8 @@ GameObject:
- component: {fileID: 1378637490813329061} - component: {fileID: 1378637490813329061}
- component: {fileID: 8775718735147868365} - component: {fileID: 8775718735147868365}
- component: {fileID: 8186900164864976727} - component: {fileID: 8186900164864976727}
- component: {fileID: 1850162772760692556}
- component: {fileID: 1697202366912309714}
m_Layer: 0 m_Layer: 0
m_Name: Gate m_Name: Gate
m_TagString: Untagged m_TagString: Untagged
@@ -121,8 +123,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: aca054ee474238545a8a396d410bf5a3, type: 3} m_Script: {fileID: 11500000, guid: aca054ee474238545a8a396d410bf5a3, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Gate m_EditorClassIdentifier: Assembly-CSharp::Gate
maxHealth: 50
currentHealth: 50
--- !u!65 &8186900164864976727 --- !u!65 &8186900164864976727
BoxCollider: BoxCollider:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -144,3 +144,44 @@ BoxCollider:
serializedVersion: 3 serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1} m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0.5, y: 0.5, z: 0.49999997} m_Center: {x: 0.5, y: 0.5, z: 0.49999997}
--- !u!114 &1850162772760692556
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3321405240327640087}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 2008860137
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
AlwaysReplicateAsRoot: 0
SynchronizeTransform: 1
ActiveSceneSynchronization: 0
SceneMigrationSynchronization: 1
SpawnWithObservers: 1
DontDestroyWithOwner: 0
AutoObjectParentSync: 1
SyncOwnerTransformWhenParented: 1
AllowOwnerToParent: 0
--- !u!114 &1697202366912309714
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3321405240327640087}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 0
destroyDelay: 0

View File

@@ -15,6 +15,7 @@ GameObject:
- component: {fileID: 7528764990365051674} - component: {fileID: 7528764990365051674}
- component: {fileID: 3421159559893464927} - component: {fileID: 3421159559893464927}
- component: {fileID: -6050457801577222831} - component: {fileID: -6050457801577222831}
- component: {fileID: 1313689911257131059}
m_Layer: 12 m_Layer: 12
m_Name: MineableBlock m_Name: MineableBlock
m_TagString: Untagged m_TagString: Untagged
@@ -154,9 +155,11 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::MineableBlock m_EditorClassIdentifier: Assembly-CSharp::MineableBlock
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
maxHp: 100 dropItemData: {fileID: 0}
genericDropPrefab: {fileID: 0}
shakeDuration: 0.1 shakeDuration: 0.1
shakeMagnitude: 0.1 shakeMagnitude: 0.1
darkIntensity: 0.2
--- !u!114 &-6050457801577222831 --- !u!114 &-6050457801577222831
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -175,3 +178,19 @@ MonoBehaviour:
precomputeOutline: 0 precomputeOutline: 0
bakeKeys: [] bakeKeys: []
bakeValues: [] bakeValues: []
--- !u!114 &1313689911257131059
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 989066657509100432}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 1
destroyDelay: 0

View File

@@ -15,6 +15,7 @@ GameObject:
- component: {fileID: 7528764990365051674} - component: {fileID: 7528764990365051674}
- component: {fileID: 3421159559893464927} - component: {fileID: 3421159559893464927}
- component: {fileID: -8077891763218709486} - component: {fileID: -8077891763218709486}
- component: {fileID: 1570038371682152649}
m_Layer: 12 m_Layer: 12
m_Name: ResourceBlock m_Name: ResourceBlock
m_TagString: Untagged m_TagString: Untagged
@@ -128,7 +129,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3} m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name: m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 822699884 GlobalObjectIdHash: 1191681468
InScenePlacedSourceGlobalObjectIdHash: 1191681468 InScenePlacedSourceGlobalObjectIdHash: 1191681468
DeferredDespawnTick: 0 DeferredDespawnTick: 0
Ownership: 1 Ownership: 1
@@ -154,11 +155,11 @@ MonoBehaviour:
m_Name: m_Name:
m_EditorClassIdentifier: Assembly-CSharp::MineableBlock m_EditorClassIdentifier: Assembly-CSharp::MineableBlock
ShowTopMostFoldoutHeaderGroup: 1 ShowTopMostFoldoutHeaderGroup: 1
maxHp: 100
dropItemData: {fileID: 11400000, guid: 953ceca9a25978549a56f0a4ff5d6a2c, type: 2} dropItemData: {fileID: 11400000, guid: 953ceca9a25978549a56f0a4ff5d6a2c, type: 2}
genericDropPrefab: {fileID: 1253970051563370359, guid: 1d7655b1088c3ea46b8f52f6c6760047, type: 3} genericDropPrefab: {fileID: 1253970051563370359, guid: 1d7655b1088c3ea46b8f52f6c6760047, type: 3}
shakeDuration: 0.1 shakeDuration: 0.1
shakeMagnitude: 0.1 shakeMagnitude: 0.1
darkIntensity: 0.2
--- !u!114 &-8077891763218709486 --- !u!114 &-8077891763218709486
MonoBehaviour: MonoBehaviour:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -177,3 +178,19 @@ MonoBehaviour:
precomputeOutline: 0 precomputeOutline: 0
bakeKeys: [] bakeKeys: []
bakeValues: [] bakeValues: []
--- !u!114 &1570038371682152649
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 989066657509100432}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 1
destroyDelay: 0

View File

@@ -15,11 +15,13 @@ MonoBehaviour:
itemID: 1 itemID: 1
itemName: "\uACE1\uAD2D\uC774" itemName: "\uACE1\uAD2D\uC774"
icon: {fileID: 1410797105229532982, guid: 206db4b680835834bb863402d6ff99f0, type: 3} icon: {fileID: 1410797105229532982, guid: 206db4b680835834bb863402d6ff99f0, type: 3}
description:
weight: 0 weight: 0
maxStack: 1 maxStack: 1
originalBlockPrefab: {fileID: 919132149155446097, guid: ebadcbcc9e8baa1469830ca427684d65, type: 3} originalBlockPrefab: {fileID: 919132149155446097, guid: ebadcbcc9e8baa1469830ca427684d65, type: 3}
isTool: 1 behavior: {fileID: 11400000, guid: 239d2d4ad6cebe042813b3d938255356, type: 2}
toolAction: {fileID: 11400000, guid: 677adf45880ed9e4a80b2f113fff07f8, type: 2} isEquippable: 1
toolPrefab: {fileID: 919132149155446097, guid: ebadcbcc9e8baa1469830ca427684d65, type: 3} equipmentPrefab: {fileID: 1272226527344106, guid: 346845f0154817c4297d6af7c695b28a, type: 3}
equipPositionOffset: {x: 0, y: 0, z: 0} equipPositionOffset: {x: 0, y: 0, z: 0}
equipRotationOffset: {x: 0, y: 0, z: 0} equipRotationOffset: {x: 0, y: 0, z: 0}
attachmentPointName: handslot.r

View File

@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c1deeb9de56edff4ca77ddabf9db691a, type: 3}
m_Name: New Mining Behavior
m_EditorClassIdentifier: Assembly-CSharp::MiningBehavior
behaviorName: Mining
duration: 0.5
animTrigger: Attack
animSpeed: 1
impactDelay: 0.2
canRepeat: 1
useSound: {fileID: 0}
useEffect: {fileID: 0}
damage: 50

View File

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

View File

@@ -926,6 +926,22 @@ NavMeshObstacle:
m_CarveOnlyStationary: 1 m_CarveOnlyStationary: 1
m_Center: {x: 0, y: 0, z: 0} m_Center: {x: 0, y: 0, z: 0}
m_TimeToStationary: 0.5 m_TimeToStationary: 0.5
--- !u!114 &445606028
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 445606021}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 1
destroyDelay: 0
--- !u!1001 &497942047 --- !u!1001 &497942047
PrefabInstance: PrefabInstance:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -1091,7 +1107,7 @@ MonoBehaviour:
m_DisconnectTimeoutMS: 30000 m_DisconnectTimeoutMS: 30000
ConnectionData: ConnectionData:
Address: 127.0.0.1 Address: 127.0.0.1
Port: 7793 Port: 7781
ServerListenAddress: 127.0.0.1 ServerListenAddress: 127.0.0.1
ClientBindPort: 0 ClientBindPort: 0
DebugSimulator: DebugSimulator:
@@ -1724,6 +1740,18 @@ PrefabInstance:
propertyPath: m_LocalEulerAnglesHint.z propertyPath: m_LocalEulerAnglesHint.z
value: 0 value: 0
objectReference: {fileID: 0} objectReference: {fileID: 0}
- target: {fileID: 5420652907638236344, guid: 443aa97110814434cb36b26656f1884c, type: 3}
propertyPath: GlobalObjectIdHash
value: 3705943614
objectReference: {fileID: 0}
- target: {fileID: 5420652907638236344, guid: 443aa97110814434cb36b26656f1884c, type: 3}
propertyPath: SceneMigrationSynchronization
value: 0
objectReference: {fileID: 0}
- target: {fileID: 5420652907638236344, guid: 443aa97110814434cb36b26656f1884c, type: 3}
propertyPath: InScenePlacedSourceGlobalObjectIdHash
value: 948195259
objectReference: {fileID: 0}
- target: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3} - target: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3}
propertyPath: m_Name propertyPath: m_Name
value: Core value: Core
@@ -1739,6 +1767,9 @@ PrefabInstance:
- targetCorrespondingSourceObject: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3} - targetCorrespondingSourceObject: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3}
insertIndex: -1 insertIndex: -1
addedObject: {fileID: 445606027} addedObject: {fileID: 445606027}
- targetCorrespondingSourceObject: {fileID: 6403733529880835406, guid: 443aa97110814434cb36b26656f1884c, type: 3}
insertIndex: -1
addedObject: {fileID: 445606028}
m_SourcePrefab: {fileID: 100100000, guid: 443aa97110814434cb36b26656f1884c, type: 3} m_SourcePrefab: {fileID: 100100000, guid: 443aa97110814434cb36b26656f1884c, type: 3}
--- !u!1 &1044242050 --- !u!1 &1044242050
GameObject: GameObject:
@@ -1864,6 +1895,18 @@ PrefabInstance:
propertyPath: 'm_Materials.Array.data[0]' propertyPath: 'm_Materials.Array.data[0]'
value: value:
objectReference: {fileID: 2100000, guid: 2425c03ff18262a4eaa45371f0fe6dcf, type: 2} objectReference: {fileID: 2100000, guid: 2425c03ff18262a4eaa45371f0fe6dcf, type: 2}
- target: {fileID: 1850162772760692556, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
propertyPath: GlobalObjectIdHash
value: 4230179970
objectReference: {fileID: 0}
- target: {fileID: 1850162772760692556, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
propertyPath: SceneMigrationSynchronization
value: 0
objectReference: {fileID: 0}
- target: {fileID: 1850162772760692556, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
propertyPath: InScenePlacedSourceGlobalObjectIdHash
value: 2008860137
objectReference: {fileID: 0}
- target: {fileID: 3321405240327640087, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3} - target: {fileID: 3321405240327640087, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
propertyPath: m_Name propertyPath: m_Name
value: Gate value: Gate
@@ -1919,8 +1962,32 @@ PrefabInstance:
m_RemovedComponents: [] m_RemovedComponents: []
m_RemovedGameObjects: [] m_RemovedGameObjects: []
m_AddedGameObjects: [] m_AddedGameObjects: []
m_AddedComponents: [] m_AddedComponents:
- targetCorrespondingSourceObject: {fileID: 3321405240327640087, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
insertIndex: -1
addedObject: {fileID: 1075139052}
m_SourcePrefab: {fileID: 100100000, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3} m_SourcePrefab: {fileID: 100100000, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
--- !u!1 &1075139051 stripped
GameObject:
m_CorrespondingSourceObject: {fileID: 3321405240327640087, guid: 2bb7e098e271eb44a873c856dbf59c7c, type: 3}
m_PrefabInstance: {fileID: 1075139050}
m_PrefabAsset: {fileID: 0}
--- !u!114 &1075139052
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1075139051}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a4b9c07450e6c9c4b8c741b633a2702e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::HealthComponent
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 100
destroyOnDeath: 0
destroyDelay: 0
--- !u!1 &1095978102 --- !u!1 &1095978102
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -3049,6 +3116,136 @@ Camera:
m_CorrespondingSourceObject: {fileID: 5650099317679730308, guid: 2b08dd32e48ef5e4aa65a6122099152e, type: 3} m_CorrespondingSourceObject: {fileID: 5650099317679730308, guid: 2b08dd32e48ef5e4aa65a6122099152e, type: 3}
m_PrefabInstance: {fileID: 3690888448170635710} m_PrefabInstance: {fileID: 3690888448170635710}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
--- !u!1 &1782529040
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1782529044}
- component: {fileID: 1782529043}
- component: {fileID: 1782529042}
- component: {fileID: 1782529041}
- component: {fileID: 1782529045}
m_Layer: 0
m_Name: FogOverlay
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!64 &1782529041
MeshCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1782529040}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 5
m_Convex: 0
m_CookingOptions: 30
m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0}
--- !u!23 &1782529042
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1782529040}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 3deb4a29e039554439a14651844edec2, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!33 &1782529043
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1782529040}
m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0}
--- !u!4 &1782529044
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1782529040}
serializedVersion: 2
m_LocalRotation: {x: -0.7071068, y: 0, z: 0, w: 0.7071068}
m_LocalPosition: {x: 0, y: 0, z: 10}
m_LocalScale: {x: 1000, y: 1, z: 1000}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: -90, y: 0, z: 0}
--- !u!114 &1782529045
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1782529040}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4867a597119adb047ab17b69beca76be, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::FogOfWarManager
worldSize: 1000
textureSize: 512
revealRadius: 3
groundLevelY: -1
--- !u!1 &1842105545 --- !u!1 &1842105545
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@@ -3739,3 +3936,4 @@ SceneRoots:
- {fileID: 556982644} - {fileID: 556982644}
- {fileID: 2067098344} - {fileID: 2067098344}
- {fileID: 1384281111} - {fileID: 1384281111}
- {fileID: 1782529044}

View File

@@ -1,33 +1,66 @@
using UnityEngine; using UnityEngine;
using System;
public class EnemyHealth : MonoBehaviour, IDamageable /// <summary>
/// Enemy health handler.
/// Uses HealthComponent for health management.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class EnemyHealth : MonoBehaviour
{ {
public float maxHealth = 50f; private HealthComponent _health;
private float _currentHealth;
void Start() /// <summary>
/// Event fired when this enemy dies.
/// </summary>
public event Action OnEnemyDeath;
void Awake()
{ {
_currentHealth = maxHealth; _health = GetComponent<HealthComponent>();
// Subscribe to health component events
_health.OnDamaged += HandleDamaged;
_health.OnDeath += HandleDeath;
} }
public void TakeDamage(float damage) void OnDestroy()
{ {
_currentHealth -= damage; if (_health != null)
Debug.Log($"{gameObject.name} 남은 체력: {_currentHealth}");
// 데미지 입었을 때 반짝이는 효과를 여기서 호출해도 좋습니다.
// 예: GetComponent<EnemyAttack>().StartFlash();
if (_currentHealth <= 0)
{ {
Die(); _health.OnDamaged -= HandleDamaged;
_health.OnDeath -= HandleDeath;
} }
} }
private void Die() private void HandleDamaged(DamageInfo info)
{ {
Debug.Log($"{gameObject.name} 사망!"); Debug.Log($"{gameObject.name} took {info.Amount} damage. Remaining: {_health.CurrentHealth}");
// 여기서 파티클 생성이나 점수 추가 등을 처리합니다. }
private void HandleDeath()
{
Debug.Log($"{gameObject.name} died!");
// Fire event for external listeners (score system, spawner, etc.)
OnEnemyDeath?.Invoke();
// Destroy the enemy
Destroy(gameObject); Destroy(gameObject);
} }
/// <summary>
/// Get the health component for direct access if needed.
/// </summary>
public HealthComponent Health => _health;
/// <summary>
/// Get the current health (convenience property).
/// </summary>
public float CurrentHealth => _health != null ? _health.CurrentHealth : 0f;
/// <summary>
/// Get the max health (convenience property).
/// </summary>
public float MaxHealth => _health != null ? _health.MaxHealth : 0f;
} }

View File

@@ -1,24 +1,21 @@
using UnityEngine; using UnityEngine;
using System;
public class Core : MonoBehaviour, IDamageable /// <summary>
/// Core structure that players must defend.
/// Uses HealthComponent for health management.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class Core : MonoBehaviour
{ {
[SerializeField] private float maxHealth = 100f; private HealthComponent _health;
[SerializeField] private float currentHealth = 100f;
private float CurrentHealth;
// 체력이 변경될 때 UI 등에 알리기 위한 이벤트 (Observer 패턴) void Awake()
public static event Action<float> OnHealthChanged;
public static event Action OnCoreDestroyed;
void Awake() => currentHealth = maxHealth;
public void TakeDamage(float amount)
{ {
currentHealth -= amount; _health = GetComponent<HealthComponent>();
OnHealthChanged?.Invoke(currentHealth / maxHealth);
if (currentHealth <= 0)
OnCoreDestroyed?.Invoke();
} }
/// <summary>
/// Get the health component for direct access.
/// </summary>
public HealthComponent Health => _health;
} }

View File

@@ -1,19 +1,42 @@
using UnityEngine; using UnityEngine;
using UnityEngine.SceneManagement; // 씬 재시작용 using UnityEngine.SceneManagement;
/// <summary>
/// Manages game state and responds to core destruction.
/// </summary>
public class GameManager : MonoBehaviour public class GameManager : MonoBehaviour
{ {
private bool _isGameOver = false; [SerializeField] private Core coreReference;
private void OnEnable() private bool _isGameOver = false;
private HealthComponent _coreHealth;
private void Start()
{ {
// Core의 파괴 이벤트를 구독 // Find Core if not assigned
Core.OnCoreDestroyed += GameOver; if (coreReference == null)
{
coreReference = FindFirstObjectByType<Core>();
}
// Subscribe to core's health component
if (coreReference != null)
{
_coreHealth = coreReference.Health;
if (_coreHealth != null)
{
_coreHealth.OnDeath += GameOver;
}
}
} }
private void OnDisable() private void OnDestroy()
{ {
Core.OnCoreDestroyed -= GameOver; // Unsubscribe to prevent memory leaks
if (_coreHealth != null)
{
_coreHealth.OnDeath -= GameOver;
}
} }
private void GameOver() private void GameOver()
@@ -23,14 +46,13 @@ public class GameManager : MonoBehaviour
_isGameOver = true; _isGameOver = true;
Debug.Log("Game Over! Core has been destroyed."); Debug.Log("Game Over! Core has been destroyed.");
// 여기에 패배 UI 표시 로직 등을 넣습니다. // Show defeat UI here
// 예: 3초 후 게임 재시작 // Example: restart game after 3 seconds
Invoke(nameof(RestartGame), 3f); Invoke(nameof(RestartGame), 3f);
} }
private void RestartGame() private void RestartGame()
{ {
// 현재 활성화된 씬을 다시 로드
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
} }
} }

View File

@@ -1,34 +1,51 @@
using UnityEngine; using UnityEngine;
using System; using UnityEngine.AI;
public class Gate : MonoBehaviour, IDamageable /// <summary>
/// Gate structure that can be destroyed.
/// Uses HealthComponent for health management.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class Gate : MonoBehaviour
{ {
[SerializeField] private float maxHealth = 50f; private HealthComponent _health;
[SerializeField] private float currentHealth = 50f; private NavMeshObstacle _obstacle;
private float CurrentHealth;
// 체력이 변경될 때 UI 등에 알리기 위한 이벤트 (Observer 패턴) void Awake()
public static event Action<float> OnHealthChanged;
public static event Action OnGateDestroyed;
void Awake() => currentHealth = maxHealth;
public void TakeDamage(float amount)
{ {
currentHealth -= amount; _health = GetComponent<HealthComponent>();
OnHealthChanged?.Invoke(currentHealth / maxHealth); _obstacle = GetComponent<NavMeshObstacle>();
if (currentHealth <= 0) // Subscribe to health component events
_health.OnDeath += HandleDeath;
}
void OnDestroy()
{
if (_health != null)
{ {
gameObject.SetActive(false); _health.OnDeath -= HandleDeath;
var obstacle = GetComponent<UnityEngine.AI.NavMeshObstacle>();
if(obstacle != null)
{
obstacle.carving = false;
obstacle.enabled = false;
}
OnGateDestroyed?.Invoke();
Destroy(gameObject, 0.1f);
} }
} }
private void HandleDeath()
{
// Disable the gate visually
gameObject.SetActive(false);
// Disable NavMesh carving so enemies can pass through
if (_obstacle != null)
{
_obstacle.carving = false;
_obstacle.enabled = false;
}
// Destroy the object after a short delay
Destroy(gameObject, 0.1f);
}
/// <summary>
/// Get the health component for direct access.
/// </summary>
public HealthComponent Health => _health;
} }

View File

@@ -0,0 +1,384 @@
using System;
using UnityEngine;
using Unity.Netcode;
/// <summary>
/// Reusable health component that can be attached to any entity.
/// Supports both networked (multiplayer) and local-only usage.
/// Implements IDamageable for compatibility with existing damage systems.
/// </summary>
public class HealthComponent : NetworkBehaviour, IDamageable
{
[Header("Health Settings")]
[SerializeField] private float maxHealth = 100f;
[SerializeField] private bool destroyOnDeath = false;
[SerializeField] private float destroyDelay = 0f;
// Network-synced health for multiplayer
private NetworkVariable<float> _networkHealth = new NetworkVariable<float>(
readPerm: NetworkVariableReadPermission.Everyone,
writePerm: NetworkVariableWritePermission.Server);
// Local health for non-networked entities or single-player
private float _localHealth;
// Track if we're in a networked context
private bool _isNetworked;
#region Events
/// <summary>
/// Fired when health changes. Parameters: (currentHealth, maxHealth)
/// </summary>
public event Action<float, float> OnHealthChanged;
/// <summary>
/// Fired when damage is taken. Parameter: DamageInfo
/// </summary>
public event Action<DamageInfo> OnDamaged;
/// <summary>
/// Fired when healed. Parameter: healAmount
/// </summary>
public event Action<float> OnHealed;
/// <summary>
/// Fired when health reaches zero.
/// </summary>
public event Action OnDeath;
/// <summary>
/// Fired when health is restored from zero (revived).
/// </summary>
public event Action OnRevived;
#endregion
#region IDamageable Implementation
public float CurrentHealth => _isNetworked ? _networkHealth.Value : _localHealth;
public float MaxHealth => maxHealth;
public bool IsAlive => CurrentHealth > 0;
#endregion
#region Unity Lifecycle
private void Awake()
{
_localHealth = maxHealth;
}
public override void OnNetworkSpawn()
{
_isNetworked = true;
if (IsServer)
{
_networkHealth.Value = maxHealth;
}
_networkHealth.OnValueChanged += HandleNetworkHealthChanged;
}
public override void OnNetworkDespawn()
{
_networkHealth.OnValueChanged -= HandleNetworkHealthChanged;
}
#endregion
#region Damage Methods
/// <summary>
/// Simple damage method (backwards compatible).
/// </summary>
public void TakeDamage(float amount)
{
TakeDamage(new DamageInfo(amount));
}
/// <summary>
/// Enhanced damage method with full context.
/// </summary>
public void TakeDamage(DamageInfo damageInfo)
{
if (!IsAlive) return;
if (damageInfo.Amount <= 0) return;
if (_isNetworked)
{
// In networked mode, request damage through server
ApplyDamageServerRpc(damageInfo.Amount, (int)damageInfo.Type);
}
else
{
// Local mode - apply directly
ApplyDamageLocal(damageInfo);
}
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void ApplyDamageServerRpc(float amount, int damageType)
{
if (!IsAlive) return;
float newHealth = Mathf.Max(0, _networkHealth.Value - amount);
_networkHealth.Value = newHealth;
// Notify all clients about the damage
NotifyDamageClientRpc(amount, damageType);
if (newHealth <= 0)
{
HandleDeathServer();
}
}
[ClientRpc]
private void NotifyDamageClientRpc(float amount, int damageType)
{
OnDamaged?.Invoke(new DamageInfo(amount, (DamageType)damageType));
}
private void ApplyDamageLocal(DamageInfo info)
{
float previousHealth = _localHealth;
_localHealth = Mathf.Max(0, _localHealth - info.Amount);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
OnDamaged?.Invoke(info);
if (_localHealth <= 0 && previousHealth > 0)
{
HandleDeathLocal();
}
}
#endregion
#region Healing Methods
/// <summary>
/// Heal the entity by a specified amount.
/// </summary>
public void Heal(float amount)
{
if (amount <= 0) return;
if (_isNetworked)
{
HealServerRpc(amount);
}
else
{
HealLocal(amount);
}
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void HealServerRpc(float amount)
{
bool wasAlive = _networkHealth.Value > 0;
float newHealth = Mathf.Min(maxHealth, _networkHealth.Value + amount);
_networkHealth.Value = newHealth;
NotifyHealClientRpc(amount, !wasAlive && newHealth > 0);
}
[ClientRpc]
private void NotifyHealClientRpc(float amount, bool revived)
{
OnHealed?.Invoke(amount);
if (revived)
{
OnRevived?.Invoke();
}
}
private void HealLocal(float amount)
{
bool wasAlive = _localHealth > 0;
_localHealth = Mathf.Min(maxHealth, _localHealth + amount);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
OnHealed?.Invoke(amount);
if (!wasAlive && _localHealth > 0)
{
OnRevived?.Invoke();
}
}
/// <summary>
/// Fully restore health to maximum.
/// </summary>
public void HealToFull()
{
Heal(maxHealth - CurrentHealth);
}
#endregion
#region Health Modification
/// <summary>
/// Set the maximum health value.
/// </summary>
/// <param name="newMax">New maximum health</param>
/// <param name="healToMax">If true, also sets current health to the new max</param>
public void SetMaxHealth(float newMax, bool healToMax = false)
{
maxHealth = Mathf.Max(1, newMax);
if (healToMax)
{
if (_isNetworked && IsServer)
{
_networkHealth.Value = maxHealth;
}
else if (!_isNetworked)
{
_localHealth = maxHealth;
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
else
{
// Clamp current health to new max
if (_isNetworked && IsServer)
{
_networkHealth.Value = Mathf.Min(_networkHealth.Value, maxHealth);
}
else if (!_isNetworked)
{
_localHealth = Mathf.Min(_localHealth, maxHealth);
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
}
/// <summary>
/// Directly set the current health (use with caution).
/// </summary>
public void SetHealth(float health)
{
float newHealth = Mathf.Clamp(health, 0, maxHealth);
if (_isNetworked && IsServer)
{
_networkHealth.Value = newHealth;
}
else if (!_isNetworked)
{
_localHealth = newHealth;
OnHealthChanged?.Invoke(_localHealth, maxHealth);
}
}
#endregion
#region Death Handling
private void HandleDeathServer()
{
NotifyDeathClientRpc();
if (destroyOnDeath)
{
if (destroyDelay > 0)
{
// Schedule despawn
Invoke(nameof(DespawnObject), destroyDelay);
}
else
{
DespawnObject();
}
}
}
private void DespawnObject()
{
if (TryGetComponent<NetworkObject>(out var netObj))
{
netObj.Despawn();
}
}
[ClientRpc]
private void NotifyDeathClientRpc()
{
OnDeath?.Invoke();
}
private void HandleDeathLocal()
{
OnDeath?.Invoke();
if (destroyOnDeath)
{
if (destroyDelay > 0)
{
Destroy(gameObject, destroyDelay);
}
else
{
Destroy(gameObject);
}
}
}
#endregion
#region Network Health Change Handler
private void HandleNetworkHealthChanged(float previousValue, float newValue)
{
OnHealthChanged?.Invoke(newValue, maxHealth);
// Check for death on clients (server handles it in ApplyDamageServerRpc)
if (previousValue > 0 && newValue <= 0 && !IsServer)
{
// Death event already sent via ClientRpc, just update local state
}
}
#endregion
#region Utility Methods
/// <summary>
/// Get health as a normalized value (0-1).
/// </summary>
public float GetHealthNormalized()
{
return maxHealth > 0 ? CurrentHealth / maxHealth : 0f;
}
/// <summary>
/// Check if health is below a certain percentage.
/// </summary>
public bool IsHealthBelow(float percentage)
{
return GetHealthNormalized() < percentage;
}
/// <summary>
/// Check if at full health.
/// </summary>
public bool IsAtFullHealth()
{
return CurrentHealth >= maxHealth;
}
/// <summary>
/// Kill the entity instantly.
/// </summary>
public void Kill()
{
TakeDamage(new DamageInfo(CurrentHealth + 1, DamageType.True));
}
#endregion
}

View File

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

View File

@@ -1,5 +1,141 @@
// IDamageable.cs using UnityEngine;
/// <summary>
/// Types of damage that can be dealt.
/// Used for damage resistance/vulnerability systems.
/// </summary>
public enum DamageType
{
Physical,
Magical,
Mining,
Environmental,
True // Ignores resistances
}
/// <summary>
/// Contains all information about a damage event.
/// </summary>
[System.Serializable]
public struct DamageInfo
{
/// <summary>
/// Amount of damage to deal.
/// </summary>
public float Amount;
/// <summary>
/// Type of damage being dealt.
/// </summary>
public DamageType Type;
/// <summary>
/// The GameObject that caused the damage (can be null).
/// </summary>
public GameObject Source;
/// <summary>
/// World position where the damage was applied.
/// </summary>
public Vector3 HitPoint;
/// <summary>
/// Direction the damage came from (for knockback, effects, etc.).
/// </summary>
public Vector3 HitDirection;
/// <summary>
/// Create a simple damage info with just an amount.
/// </summary>
public DamageInfo(float amount)
{
Amount = amount;
Type = DamageType.Physical;
Source = null;
HitPoint = Vector3.zero;
HitDirection = Vector3.zero;
}
/// <summary>
/// Create damage info with amount and type.
/// </summary>
public DamageInfo(float amount, DamageType type)
{
Amount = amount;
Type = type;
Source = null;
HitPoint = Vector3.zero;
HitDirection = Vector3.zero;
}
/// <summary>
/// Create full damage info with all parameters.
/// </summary>
public DamageInfo(float amount, DamageType type, GameObject source,
Vector3 hitPoint = default, Vector3 hitDirection = default)
{
Amount = amount;
Type = type;
Source = source;
HitPoint = hitPoint;
HitDirection = hitDirection;
}
/// <summary>
/// Create mining damage.
/// </summary>
public static DamageInfo Mining(float amount, GameObject source = null)
{
return new DamageInfo(amount, DamageType.Mining, source);
}
/// <summary>
/// Create physical damage with source and direction.
/// </summary>
public static DamageInfo Physical(float amount, GameObject source, Vector3 hitPoint, Vector3 direction)
{
return new DamageInfo(amount, DamageType.Physical, source, hitPoint, direction);
}
}
/// <summary>
/// Interface for any object that can take damage.
/// </summary>
public interface IDamageable public interface IDamageable
{ {
/// <summary>
/// Simple damage method for backwards compatibility.
/// </summary>
/// <param name="amount">Amount of damage to deal</param>
void TakeDamage(float amount); void TakeDamage(float amount);
/// <summary>
/// Enhanced damage method with full context.
/// Default implementation calls the simple TakeDamage for backwards compatibility.
/// </summary>
/// <param name="damageInfo">Full damage information</param>
void TakeDamage(DamageInfo damageInfo)
{
TakeDamage(damageInfo.Amount);
}
/// <summary>
/// Current health value.
/// </summary>
float CurrentHealth { get; }
/// <summary>
/// Maximum health value.
/// </summary>
float MaxHealth { get; }
/// <summary>
/// Whether this object is still alive.
/// </summary>
bool IsAlive { get; }
/// <summary>
/// Health as a percentage (0-1).
/// </summary>
float HealthPercent => MaxHealth > 0 ? CurrentHealth / MaxHealth : 0f;
} }

View File

@@ -1,23 +1,118 @@
using UnityEngine; using UnityEngine;
/// <summary>
/// ScriptableObject defining item properties.
/// Implements IUsableItem and IEquippableItem for extensibility.
/// </summary>
[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/Item")] [CreateAssetMenu(fileName = "New Item", menuName = "Inventory/Item")]
public class ItemData : ScriptableObject public class ItemData : ScriptableObject, IUsableItem, IEquippableItem
{ {
[Header("Basic Info")]
public int itemID; public int itemID;
public string itemName; public string itemName;
public Sprite icon; public Sprite icon;
public float weight; // 아이템 개당 무게 [TextArea] public string description;
public int maxStack = 99; // 최대 중첩 개수
[Header("Stack & Weight")]
public float weight = 1f;
public int maxStack = 99;
[Header("Visual Source")] [Header("Visual Source")]
public GameObject originalBlockPrefab; // 이제 이것만 있으면 됩니다! [Tooltip("Original prefab for dropped item visuals")]
public GameObject originalBlockPrefab;
[Header("Tool Settings")] [Header("Item Behavior")]
public bool isTool; // 도구 여부 [Tooltip("Defines what happens when the item is used")]
public PlayerActionData toolAction; // 이 도구를 들었을 때 나갈 액션 (예: MiningActionData) public ItemBehavior behavior;
[Header("Visual Settings")] [Header("Equipment Settings")]
public GameObject toolPrefab; // 캐릭터 손에 스폰될 3D 프리팹 [Tooltip("Whether this item can be equipped (shows in hand)")]
public Vector3 equipPositionOffset; // 손 위치 미세 조정 public bool isEquippable;
public Vector3 equipRotationOffset; // 손 회전 미세 조정 [Tooltip("Prefab spawned in player's hand when equipped")]
public GameObject equipmentPrefab;
public Vector3 equipPositionOffset;
public Vector3 equipRotationOffset;
[Tooltip("Name of the transform to attach equipment to")]
public string attachmentPointName = "ToolAnchor";
#region IUsableItem Implementation
public bool IsConsumable => behavior != null && behavior.IsConsumable;
public bool CanUse(GameObject user, GameObject target)
{
return behavior != null && behavior.CanUse(user, target);
}
public void Use(GameObject user, GameObject target)
{
if (behavior != null)
{
behavior.Use(user, target);
}
}
public ActionDescriptor GetUseAction()
{
return behavior?.GetActionDescriptor();
}
#endregion
#region IEquippableItem Implementation
public GameObject GetEquipmentPrefab() => equipmentPrefab;
public Vector3 GetPositionOffset() => equipPositionOffset;
public Vector3 GetRotationOffset() => equipRotationOffset;
public string GetAttachmentPointName() => attachmentPointName;
public Transform FindAttachmentPoint(GameObject user)
{
if (string.IsNullOrEmpty(attachmentPointName))
return user.transform;
var transforms = user.GetComponentsInChildren<Transform>();
foreach (var t in transforms)
{
if (t.name == attachmentPointName)
return t;
}
return user.transform;
}
#endregion
#region Utility Properties
/// <summary>
/// Check if this item has any usable behavior.
/// </summary>
public bool HasBehavior => behavior != null;
/// <summary>
/// Check if this item can be equipped.
/// </summary>
public bool CanBeEquipped => isEquippable && equipmentPrefab != null;
#endregion
#region Utility Methods
/// <summary>
/// Get display name for UI.
/// </summary>
public string GetDisplayName() => string.IsNullOrEmpty(itemName) ? name : itemName;
/// <summary>
/// Get description for UI.
/// </summary>
public string GetDescription() => description;
/// <summary>
/// Calculate total weight for a stack.
/// </summary>
public float GetStackWeight(int count) => weight * count;
#endregion
} }

View File

@@ -1,6 +1,172 @@
using UnityEngine; using UnityEngine;
/// <summary>
/// Types of interactions available in the game.
/// </summary>
public enum InteractionType
{
Generic,
Pickup,
Use,
Talk,
Enter,
Build,
Open,
Activate
}
/// <summary>
/// Contains preview information about an interaction for UI display.
/// </summary>
[System.Serializable]
public struct InteractionPreview
{
/// <summary>
/// The action verb (e.g., "Pick up", "Enter", "Use").
/// </summary>
public string ActionVerb;
/// <summary>
/// The name of the target object (e.g., "Iron Ore", "Tunnel").
/// </summary>
public string TargetName;
/// <summary>
/// Optional icon to display in UI.
/// </summary>
public Sprite Icon;
/// <summary>
/// The type of interaction.
/// </summary>
public InteractionType Type;
/// <summary>
/// Whether this interaction requires holding the button.
/// </summary>
public bool RequiresHold;
/// <summary>
/// Duration to hold if RequiresHold is true.
/// </summary>
public float HoldDuration;
/// <summary>
/// Input hint to display (e.g., "[F]", "[E]").
/// </summary>
public string InputHint;
/// <summary>
/// Create a simple interaction preview.
/// </summary>
public static InteractionPreview Simple(string actionVerb, string targetName,
InteractionType type = InteractionType.Generic)
{
return new InteractionPreview
{
ActionVerb = actionVerb,
TargetName = targetName,
Type = type,
RequiresHold = false,
HoldDuration = 0f,
InputHint = "[F]"
};
}
/// <summary>
/// Create a pickup interaction preview.
/// </summary>
public static InteractionPreview Pickup(string itemName, Sprite icon = null)
{
return new InteractionPreview
{
ActionVerb = "Pick up",
TargetName = itemName,
Icon = icon,
Type = InteractionType.Pickup,
RequiresHold = false,
InputHint = "[F]"
};
}
/// <summary>
/// Create an enter/use interaction preview.
/// </summary>
public static InteractionPreview Enter(string targetName)
{
return new InteractionPreview
{
ActionVerb = "Enter",
TargetName = targetName,
Type = InteractionType.Enter,
RequiresHold = false,
InputHint = "[F]"
};
}
/// <summary>
/// Create a hold-to-interact preview.
/// </summary>
public static InteractionPreview Hold(string actionVerb, string targetName, float duration,
InteractionType type = InteractionType.Use)
{
return new InteractionPreview
{
ActionVerb = actionVerb,
TargetName = targetName,
Type = type,
RequiresHold = true,
HoldDuration = duration,
InputHint = "[Hold F]"
};
}
/// <summary>
/// Get the full display string (e.g., "[F] Pick up Iron Ore").
/// </summary>
public string GetDisplayString()
{
return $"{InputHint} {ActionVerb} {TargetName}";
}
}
/// <summary>
/// Interface for objects that can be interacted with by the player.
/// </summary>
public interface IInteractable public interface IInteractable
{ {
/// <summary>
/// Perform the interaction.
/// </summary>
/// <param name="interactor">The GameObject performing the interaction (usually the player)</param>
void Interact(GameObject interactor); void Interact(GameObject interactor);
/// <summary>
/// Get preview information about this interaction for UI display.
/// Default implementation returns a generic preview.
/// </summary>
InteractionPreview GetInteractionPreview()
{
return InteractionPreview.Simple("Interact", "Object");
}
/// <summary>
/// Check if this object can currently be interacted with.
/// Default implementation always returns true.
/// </summary>
/// <param name="interactor">The GameObject attempting to interact</param>
/// <returns>True if interaction is possible</returns>
bool CanInteract(GameObject interactor)
{
return true;
}
/// <summary>
/// Get the world position of this interactable for distance calculations.
/// Default implementation returns zero vector (override in implementation).
/// </summary>
Vector3 GetInteractionPoint()
{
return Vector3.zero;
}
} }

View File

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

View File

@@ -0,0 +1,63 @@
using UnityEngine;
/// <summary>
/// Interface for items that can be used/activated by the player.
/// </summary>
public interface IUsableItem
{
/// <summary>
/// Check if the item can be used.
/// </summary>
/// <param name="user">The GameObject using the item (player)</param>
/// <param name="target">Optional target for the use action</param>
/// <returns>True if the item can be used</returns>
bool CanUse(GameObject user, GameObject target);
/// <summary>
/// Use the item.
/// </summary>
/// <param name="user">The GameObject using the item (player)</param>
/// <param name="target">Optional target for the use action</param>
void Use(GameObject user, GameObject target);
/// <summary>
/// Get the action descriptor for using this item.
/// </summary>
ActionDescriptor GetUseAction();
/// <summary>
/// Whether using this item consumes it (reduces stack count).
/// </summary>
bool IsConsumable { get; }
}
/// <summary>
/// Interface for items that can be equipped on the player.
/// </summary>
public interface IEquippableItem
{
/// <summary>
/// Get the equipment prefab to spawn.
/// </summary>
GameObject GetEquipmentPrefab();
/// <summary>
/// Get position offset for equipment placement.
/// </summary>
Vector3 GetPositionOffset();
/// <summary>
/// Get rotation offset for equipment placement.
/// </summary>
Vector3 GetRotationOffset();
/// <summary>
/// Get the name of the attachment point on the character.
/// </summary>
string GetAttachmentPointName();
/// <summary>
/// Find the attachment point transform on a user.
/// </summary>
Transform FindAttachmentPoint(GameObject user);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 686008e086e9d2247b33d5828e0efa5f

View File

@@ -0,0 +1,67 @@
using UnityEngine;
/// <summary>
/// Base class for item behaviors. Allows different items to have
/// completely different use effects without modifying ItemData.
/// This is the Strategy pattern for item actions.
/// </summary>
public abstract class ItemBehavior : ScriptableObject
{
[Header("Basic Settings")]
[SerializeField] protected string behaviorName = "Use";
[SerializeField] protected float duration = 0.5f;
[SerializeField] protected string animTrigger = "Use";
[SerializeField] protected float animSpeed = 1f;
[SerializeField] protected float impactDelay = 0.2f;
[SerializeField] protected bool canRepeat = false;
[Header("Effects")]
[SerializeField] protected AudioClip useSound;
[SerializeField] protected GameObject useEffect;
/// <summary>
/// Whether this behavior consumes the item when used.
/// </summary>
public virtual bool IsConsumable => false;
/// <summary>
/// Check if this behavior can be used with the given user and target.
/// </summary>
/// <param name="user">The player/entity using the item</param>
/// <param name="target">Optional target of the use</param>
/// <returns>True if the behavior can be executed</returns>
public abstract bool CanUse(GameObject user, GameObject target);
/// <summary>
/// Execute the behavior's effect.
/// </summary>
/// <param name="user">The player/entity using the item</param>
/// <param name="target">Optional target of the use</param>
public abstract void Use(GameObject user, GameObject target);
/// <summary>
/// Get the action descriptor for this behavior.
/// </summary>
public virtual ActionDescriptor GetActionDescriptor()
{
return new ActionDescriptor
{
ActionName = behaviorName,
Duration = duration,
AnimTrigger = animTrigger,
AnimSpeed = animSpeed,
ImpactDelay = impactDelay,
CanRepeat = canRepeat,
SoundEffect = useSound,
ParticleEffect = useEffect
};
}
/// <summary>
/// Get a description of why the behavior cannot be used.
/// </summary>
public virtual string GetBlockedReason(GameObject user, GameObject target)
{
return null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 73a8d5e271a199f4598ae20f5b20a466

View File

@@ -1,98 +1,131 @@
using NUnit.Framework.Interfaces;
using System.Collections; using System.Collections;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
public class MineableBlock : NetworkBehaviour /// <summary>
/// A block that can be mined by players.
/// Uses HealthComponent for health management and implements IDamageable.
/// </summary>
[RequireComponent(typeof(HealthComponent))]
public class MineableBlock : NetworkBehaviour, IDamageable
{ {
[Header("Block Stats")] [Header("Drop Settings")]
[SerializeField] private int maxHp = 100;
// [동기화] 모든 플레이어가 동일한 블록 체력을 보게 함
private NetworkVariable<int> _currentHp = new NetworkVariable<int>();
[SerializeField] private ItemData dropItemData; [SerializeField] private ItemData dropItemData;
[SerializeField] private GameObject genericDropPrefab; // 여기에 위에서 만든 'GenericDroppedItem' 프리팹을 넣으세요. [SerializeField] private GameObject genericDropPrefab;
[Header("Visuals")] [Header("Visuals")]
private Outline _outline; private Outline _outline;
private Vector3 _originalPos; private Vector3 _originalPos;
[Header("Shake Settings")] [Header("Shake Settings")]
[SerializeField] private float shakeDuration = 0.15f; // 흔들리는 시간 [SerializeField] private float shakeDuration = 0.15f;
[SerializeField] private float shakeMagnitude = 0.1f; // 흔들리는 강도 [SerializeField] private float shakeMagnitude = 0.1f;
private Coroutine _shakeCoroutine; private Coroutine _shakeCoroutine;
private Color _originalColor; // 본래의 색상을 저장할 변수 private Color _originalColor;
private float _lastVisibleTime; private float _lastVisibleTime;
private const float VisibilityThreshold = 0.25f; private const float VisibilityThreshold = 0.25f;
[Header("Fog Settings")] [Header("Fog Settings")]
[Range(0f, 1f)] [Range(0f, 1f)]
[SerializeField] private float darkIntensity = 0.2f; // 안개 속에서 얼마나 어두워질지 (0: 완전 검정, 1: 원본) [SerializeField] private float darkIntensity = 0.2f;
private MaterialPropertyBlock _propBlock; private MaterialPropertyBlock _propBlock;
private NetworkVariable<bool> isDiscovered = new NetworkVariable<bool>(false); private NetworkVariable<bool> isDiscovered = new NetworkVariable<bool>(false);
private MeshRenderer _renderer; private MeshRenderer _renderer;
private HealthComponent _health;
#region IDamageable Implementation
public float CurrentHealth => _health != null ? _health.CurrentHealth : 0f;
public float MaxHealth => _health != null ? _health.MaxHealth : 0f;
public bool IsAlive => _health != null && _health.IsAlive;
public void TakeDamage(float amount)
{
TakeDamage(new DamageInfo(amount, DamageType.Mining));
}
public void TakeDamage(DamageInfo damageInfo)
{
if (_health != null)
{
_health.TakeDamage(damageInfo);
}
}
#endregion
void Awake() void Awake()
{ {
_health = GetComponent<HealthComponent>();
_renderer = GetComponentInChildren<MeshRenderer>(); _renderer = GetComponentInChildren<MeshRenderer>();
_propBlock = new MaterialPropertyBlock(); _propBlock = new MaterialPropertyBlock();
// 시작 시에는 보이지 않게 설정 // Start hidden
if (_renderer != null) _renderer.enabled = false; if (_renderer != null) _renderer.enabled = false;
_originalColor = _renderer.sharedMaterial.HasProperty("_BaseColor") if (_renderer != null && _renderer.sharedMaterial != null)
{
_originalColor = _renderer.sharedMaterial.HasProperty("_BaseColor")
? _renderer.sharedMaterial.GetColor("_BaseColor") ? _renderer.sharedMaterial.GetColor("_BaseColor")
: _renderer.sharedMaterial.GetColor("_Color"); : _renderer.sharedMaterial.GetColor("_Color");
}
_renderer.enabled = false; // Find outline component
// 해당 오브젝트 혹은 자식에게서 Outline 컴포넌트를 찾습니다.
_outline = GetComponentInChildren<Outline>(); _outline = GetComponentInChildren<Outline>();
_originalPos = transform.localPosition; // 로컬 위치 저장 _originalPos = transform.localPosition;
if (_outline != null) if (_outline != null)
{ {
// 게임 시작 시 하이라이트는 꺼둡니다.
_outline.enabled = false; _outline.enabled = false;
} }
else
// Subscribe to health events
if (_health != null)
{ {
Debug.LogWarning($"{gameObject.name}: QuickOutline 에셋의 Outline 컴포넌트를 찾을 수 없습니다."); _health.OnDamaged += HandleDamaged;
_health.OnDeath += HandleDeath;
}
}
void OnDestroy()
{
if (_health != null)
{
_health.OnDamaged -= HandleDamaged;
_health.OnDeath -= HandleDeath;
} }
} }
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
if (IsServer) // Update visuals when discovered state syncs
{
_currentHp.Value = maxHp;
}
// 데이터가 동기화될 때 비주얼 업데이트
UpdateVisuals(isDiscovered.Value); UpdateVisuals(isDiscovered.Value);
isDiscovered.OnValueChanged += (oldVal, newVal) => { isDiscovered.OnValueChanged += (oldVal, newVal) =>
{
if (newVal) UpdateState(); if (newVal) UpdateState();
}; };
} }
void Update() void Update()
{ {
// 1. 이미 발견된 블록인지는 서버 변수(isDiscovered)로 확인 // Check if block should be discovered based on player distance
// 2. 현재 내 위치가 안개에서 벗어났는지 확인 (매우 단순화된 로직) if (!isDiscovered.Value && NetworkManager.Singleton != null &&
if (!isDiscovered.Value) NetworkManager.Singleton.LocalClient != null &&
NetworkManager.Singleton.LocalClient.PlayerObject != null)
{ {
float dist = Vector3.Distance(transform.position, NetworkManager.Singleton.LocalClient.PlayerObject.transform.position); float dist = Vector3.Distance(transform.position,
if (dist < FogOfWarManager.Instance.revealRadius) NetworkManager.Singleton.LocalClient.PlayerObject.transform.position);
if (FogOfWarManager.Instance != null && dist < FogOfWarManager.Instance.revealRadius)
{ {
// 서버에 "나 발견됐어!"라고 보고
RequestRevealServerRpc(); RequestRevealServerRpc();
} }
} }
// 3. 비주얼 업데이트: 발견된 적이 있을 때만 렌더러를 켬 // Update renderer visibility
if (_renderer != null) if (_renderer != null)
{ {
_renderer.enabled = isDiscovered.Value; _renderer.enabled = isDiscovered.Value;
@@ -109,64 +142,64 @@ public class MineableBlock : NetworkBehaviour
private void UpdateState() private void UpdateState()
{ {
if (_renderer == null) return; if (_renderer == null || !_renderer.enabled) return;
// 2. 현재 시야 안에 있는지 판단합니다.
bool isCurrentlyVisible = (Time.time - _lastVisibleTime) < VisibilityThreshold; bool isCurrentlyVisible = (Time.time - _lastVisibleTime) < VisibilityThreshold;
// 3. 상태에 따라 색상과 렌더러 상태를 결정합니다.
if (_renderer.enabled == false) return;
_renderer.GetPropertyBlock(_propBlock); _renderer.GetPropertyBlock(_propBlock);
// 2. 시야 내에 있으면 원본 색상(_originalColor), 멀어지면 어둡게 만든 색상을 적용합니다.
Color targetColor = isCurrentlyVisible ? _originalColor : _originalColor * darkIntensity; Color targetColor = isCurrentlyVisible ? _originalColor : _originalColor * darkIntensity;
_propBlock.SetColor("_BaseColor", targetColor); _propBlock.SetColor("_BaseColor", targetColor);
_renderer.SetPropertyBlock(_propBlock); _renderer.SetPropertyBlock(_propBlock);
} }
public void RevealBlock() // 서버에서 호출 /// <summary>
/// Reveal this block (called by server).
/// </summary>
public void RevealBlock()
{ {
if (IsServer && !isDiscovered.Value) isDiscovered.Value = true; if (IsServer && !isDiscovered.Value)
{
isDiscovered.Value = true;
}
} }
// 플레이어가 주변을 훑을 때 호출해줄 함수 /// <summary>
/// Update local visibility for fog of war.
/// </summary>
public void UpdateLocalVisibility() public void UpdateLocalVisibility()
{ {
_lastVisibleTime = Time.time; _lastVisibleTime = Time.time;
} }
// 서버에서만 대미지를 처리하도록 제한 private void HandleDamaged(DamageInfo info)
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void TakeDamageRpc(int damageAmount)
{ {
if (_currentHp.Value <= 0) return; // Play hit effect on all clients
PlayHitEffectClientRpc();
}
_currentHp.Value -= damageAmount; private void HandleDeath()
{
if (_currentHp.Value <= 0) if (IsServer)
{ {
DestroyBlock(); DropItem();
GetComponent<NetworkObject>().Despawn();
} }
} }
private void DestroyBlock() /// <summary>
{ /// Set highlight state for targeting feedback.
DropItem(); /// </summary>
// 2. 서버에서 네트워크 오브젝트 제거 (모든 클라이언트에서 사라짐)
GetComponent<NetworkObject>().Despawn();
}
// 하이라이트 상태를 설정하는 공개 메서드
public void SetHighlight(bool isOn) public void SetHighlight(bool isOn)
{ {
if (_outline == null) return; if (_outline != null)
{
// 외곽선 컴포넌트 활성화/비활성화 _outline.enabled = isOn;
_outline.enabled = isOn; }
} }
// 서버에서 호출하여 모든 클라이언트에게 흔들림 지시 /// <summary>
/// Play hit visual effect on all clients.
/// </summary>
[ClientRpc] [ClientRpc]
public void PlayHitEffectClientRpc() public void PlayHitEffectClientRpc()
{ {
@@ -181,14 +214,9 @@ public class MineableBlock : NetworkBehaviour
{ {
Vector3 randomOffset = Random.insideUnitSphere * shakeMagnitude; Vector3 randomOffset = Random.insideUnitSphere * shakeMagnitude;
transform.localPosition = _originalPos + randomOffset; transform.localPosition = _originalPos + randomOffset;
// 좌표가 실제로 바뀌고 있는지 로그 출력
// Debug.Log($"현재 좌표: {transform.localPosition}");
elapsed += Time.deltaTime; elapsed += Time.deltaTime;
yield return null; yield return null;
} }
transform.localPosition = _originalPos; transform.localPosition = _originalPos;
} }
@@ -196,13 +224,11 @@ public class MineableBlock : NetworkBehaviour
{ {
if (!IsServer || dropItemData == null || genericDropPrefab == null) return; if (!IsServer || dropItemData == null || genericDropPrefab == null) return;
// 원본 블록이 아니라 '범용 컨테이너'를 소환합니다.
GameObject dropObj = Instantiate(genericDropPrefab, transform.position + Vector3.up * 0.5f, Quaternion.identity); GameObject dropObj = Instantiate(genericDropPrefab, transform.position + Vector3.up * 0.5f, Quaternion.identity);
NetworkObject netObj = dropObj.GetComponent<NetworkObject>(); NetworkObject netObj = dropObj.GetComponent<NetworkObject>();
netObj.Spawn(); netObj.Spawn();
// 소환된 컨테이너에 "너는 어떤 아이템의 모양을 따라해야 해"라고 알려줍니다.
if (dropObj.TryGetComponent<DroppedItem>(out var droppedItem)) if (dropObj.TryGetComponent<DroppedItem>(out var droppedItem))
{ {
droppedItem.Initialize(dropItemData.itemID); droppedItem.Initialize(dropItemData.itemID);
@@ -212,7 +238,11 @@ public class MineableBlock : NetworkBehaviour
private void UpdateVisuals(bool discovered) private void UpdateVisuals(bool discovered)
{ {
if (_renderer != null) _renderer.enabled = discovered; if (_renderer != null) _renderer.enabled = discovered;
// 발견되지 않은 블록은 아웃라인도 표시되지 않아야 함
if (!discovered && _outline != null) _outline.enabled = false; if (!discovered && _outline != null) _outline.enabled = false;
} }
/// <summary>
/// Get the health component for direct access if needed.
/// </summary>
public HealthComponent Health => _health;
} }

View File

@@ -0,0 +1,20 @@
using UnityEngine;
/// <summary>
/// Bridge class that wraps ItemBehavior for use with PlayerActionHandler.
/// This allows the new behavior system to work with the existing action handler.
/// </summary>
[CreateAssetMenu(menuName = "Actions/Behavior Action")]
public class BehaviorActionData : PlayerActionData
{
[HideInInspector]
public ItemBehavior behavior;
public override void ExecuteEffect(GameObject performer, GameObject target)
{
if (behavior != null)
{
behavior.Use(performer, target);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a373ecb07ad66848923d4a455b6d236

View File

@@ -0,0 +1,50 @@
using UnityEngine;
/// <summary>
/// Consumable behavior for healing items, food, etc.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Consumable Behavior")]
public class ConsumableBehavior : ItemBehavior
{
[Header("Consumable Settings")]
[SerializeField] private float healAmount = 20f;
[SerializeField] private float staminaRestore = 0f;
public override bool IsConsumable => true;
private void OnEnable()
{
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Consume";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Consume";
canRepeat = false;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Can use if user has a health component that isn't at full health
var health = user.GetComponent<HealthComponent>();
if (health == null) return false;
// Can use if not at full health (healing) or if it restores stamina
return !health.IsAtFullHealth() || staminaRestore > 0;
}
public override void Use(GameObject user, GameObject target)
{
var health = user.GetComponent<HealthComponent>();
if (health != null && healAmount > 0)
{
health.Heal(healAmount);
}
// Stamina restoration would go here when stamina system is implemented
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
var health = user.GetComponent<HealthComponent>();
if (health == null) return "Cannot use this item";
if (health.IsAtFullHealth() && staminaRestore <= 0) return "Already at full health";
return null;
}
}

View File

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

View File

@@ -0,0 +1,230 @@
using System;
using UnityEngine;
/// <summary>
/// Types of equipment slots available.
/// </summary>
public enum EquipmentSlotType
{
MainHand,
OffHand,
Head,
Body,
Back,
Accessory
}
/// <summary>
/// Represents a single equipment slot that can hold an equipped item.
/// </summary>
[Serializable]
public class EquipmentSlot
{
/// <summary>
/// Type of this equipment slot.
/// </summary>
public EquipmentSlotType SlotType;
/// <summary>
/// Transform where equipment is attached.
/// </summary>
public Transform AttachPoint;
/// <summary>
/// Currently spawned equipment instance.
/// </summary>
[NonSerialized]
public GameObject CurrentEquipment;
/// <summary>
/// Currently equipped item data.
/// </summary>
[NonSerialized]
public ItemData EquippedItem;
/// <summary>
/// Event fired when equipment changes.
/// </summary>
public event Action<EquipmentSlot, ItemData> OnEquipmentChanged;
/// <summary>
/// Equip an item to this slot.
/// </summary>
/// <param name="item">Item to equip (or null to unequip)</param>
public void Equip(ItemData item)
{
// Remove current equipment
Unequip();
if (item == null) return;
// Check if item can be equipped
if (!item.CanBeEquipped) return;
EquippedItem = item;
// Spawn equipment visual
var prefab = item.GetEquipmentPrefab();
if (prefab != null && AttachPoint != null)
{
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, AttachPoint);
CurrentEquipment.transform.localPosition = item.GetPositionOffset();
CurrentEquipment.transform.localRotation = Quaternion.Euler(item.GetRotationOffset());
}
OnEquipmentChanged?.Invoke(this, item);
}
/// <summary>
/// Equip using IEquippableItem interface (more generic).
/// </summary>
/// <param name="equippable">Equippable item</param>
/// <param name="user">The user equipping the item</param>
public void Equip(IEquippableItem equippable, GameObject user)
{
Unequip();
if (equippable == null) return;
var prefab = equippable.GetEquipmentPrefab();
if (prefab == null) return;
// Find or use the configured attach point
Transform attachTo = AttachPoint;
if (attachTo == null && user != null)
{
attachTo = equippable.FindAttachmentPoint(user);
}
if (attachTo != null)
{
CurrentEquipment = UnityEngine.Object.Instantiate(prefab, attachTo);
CurrentEquipment.transform.localPosition = equippable.GetPositionOffset();
CurrentEquipment.transform.localRotation = Quaternion.Euler(equippable.GetRotationOffset());
}
OnEquipmentChanged?.Invoke(this, EquippedItem);
}
/// <summary>
/// Unequip the current item.
/// </summary>
public void Unequip()
{
if (CurrentEquipment != null)
{
UnityEngine.Object.Destroy(CurrentEquipment);
CurrentEquipment = null;
}
var previousItem = EquippedItem;
EquippedItem = null;
if (previousItem != null)
{
OnEquipmentChanged?.Invoke(this, null);
}
}
/// <summary>
/// Check if this slot has equipment.
/// </summary>
public bool HasEquipment => CurrentEquipment != null || EquippedItem != null;
/// <summary>
/// Check if a specific item can be equipped in this slot.
/// </summary>
public bool CanEquip(ItemData item)
{
if (item == null) return true; // Can always "unequip"
return item.CanBeEquipped;
}
}
/// <summary>
/// Manages multiple equipment slots for a character.
/// </summary>
[Serializable]
public class EquipmentManager
{
[SerializeField]
private EquipmentSlot[] _slots;
/// <summary>
/// All equipment slots.
/// </summary>
public EquipmentSlot[] Slots => _slots;
/// <summary>
/// Get a slot by type.
/// </summary>
public EquipmentSlot GetSlot(EquipmentSlotType type)
{
if (_slots == null) return null;
foreach (var slot in _slots)
{
if (slot.SlotType == type)
return slot;
}
return null;
}
/// <summary>
/// Equip an item to the appropriate slot.
/// </summary>
public bool TryEquip(ItemData item, EquipmentSlotType preferredSlot = EquipmentSlotType.MainHand)
{
var slot = GetSlot(preferredSlot);
if (slot != null && slot.CanEquip(item))
{
slot.Equip(item);
return true;
}
return false;
}
/// <summary>
/// Unequip all slots.
/// </summary>
public void UnequipAll()
{
if (_slots == null) return;
foreach (var slot in _slots)
{
slot.Unequip();
}
}
/// <summary>
/// Initialize slots with attach points found on the character.
/// </summary>
public void Initialize(GameObject character, params (EquipmentSlotType type, string attachPointName)[] slotConfigs)
{
_slots = new EquipmentSlot[slotConfigs.Length];
for (int i = 0; i < slotConfigs.Length; i++)
{
var config = slotConfigs[i];
Transform attachPoint = null;
// Find attach point
var transforms = character.GetComponentsInChildren<Transform>();
foreach (var t in transforms)
{
if (t.name == config.attachPointName)
{
attachPoint = t;
break;
}
}
_slots[i] = new EquipmentSlot
{
SlotType = config.type,
AttachPoint = attachPoint
};
}
}
}

View File

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

View File

@@ -1,13 +1,171 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Requirement for performing an action (item cost, etc.).
/// </summary>
[System.Serializable]
public struct ActionRequirement
{
/// <summary>
/// Item ID required (use -1 if no item required).
/// </summary>
public int ItemID;
/// <summary>
/// Amount of the item required.
/// </summary>
public int Amount;
/// <summary>
/// Whether the item is consumed when the action is performed.
/// </summary>
public bool ConsumeOnUse;
public ActionRequirement(int itemID, int amount, bool consumeOnUse = true)
{
ItemID = itemID;
Amount = amount;
ConsumeOnUse = consumeOnUse;
}
/// <summary>
/// No requirement.
/// </summary>
public static ActionRequirement None => new ActionRequirement(-1, 0, false);
}
/// <summary>
/// Describes an action that can be performed.
/// </summary>
[System.Serializable] [System.Serializable]
public class ActionDescriptor public class ActionDescriptor
{ {
public float duration = 0.5f; /// <summary>
public string animTrigger = "Interact"; /// Display name of the action.
// 필요하다면 여기에 사운드 이펙트나 파티클 정보를 추가할 수 있습니다. /// </summary>
public string ActionName = "Action";
/// <summary>
/// Total duration of the action in seconds.
/// </summary>
public float Duration = 0.5f;
/// <summary>
/// Animation trigger name.
/// </summary>
public string AnimTrigger = "Interact";
/// <summary>
/// Animation playback speed multiplier.
/// </summary>
public float AnimSpeed = 1f;
/// <summary>
/// Time within the animation when the effect occurs (for syncing hit with animation).
/// </summary>
public float ImpactDelay = 0f;
/// <summary>
/// Sound effect to play.
/// </summary>
public AudioClip SoundEffect;
/// <summary>
/// Particle effect prefab to spawn.
/// </summary>
public GameObject ParticleEffect;
/// <summary>
/// Stamina cost to perform this action.
/// </summary>
public float StaminaCost = 0f;
/// <summary>
/// Item requirements for this action.
/// </summary>
public ActionRequirement[] ItemRequirements;
/// <summary>
/// Whether this action can be repeated by holding the button.
/// </summary>
public bool CanRepeat = false;
/// <summary>
/// Cooldown time before this action can be performed again.
/// </summary>
public float Cooldown = 0f;
/// <summary>
/// Create a simple action descriptor.
/// </summary>
public static ActionDescriptor Simple(string name, float duration, string animTrigger = "Interact")
{
return new ActionDescriptor
{
ActionName = name,
Duration = duration,
AnimTrigger = animTrigger
};
}
/// <summary>
/// Create an action descriptor for repeatable actions (like mining).
/// </summary>
public static ActionDescriptor Repeatable(string name, float duration, string animTrigger,
float impactDelay, float animSpeed = 1f)
{
return new ActionDescriptor
{
ActionName = name,
Duration = duration,
AnimTrigger = animTrigger,
AnimSpeed = animSpeed,
ImpactDelay = impactDelay,
CanRepeat = true
};
}
} }
// 명세를 제공하는 인터페이스 /// <summary>
/// Interface for objects that can provide action descriptors.
/// Implement this to define what actions can be performed on or with an object.
/// </summary>
public interface IActionProvider public interface IActionProvider
{ {
/// <summary>
/// Get the primary action descriptor for this provider.
/// </summary>
ActionDescriptor GetActionDescriptor(); ActionDescriptor GetActionDescriptor();
/// <summary>
/// Get all available actions from this provider.
/// Default implementation returns only the primary action.
/// </summary>
IEnumerable<ActionDescriptor> GetAvailableActions()
{
yield return GetActionDescriptor();
}
/// <summary>
/// Check if a specific action can be performed.
/// </summary>
/// <param name="performer">The GameObject attempting the action</param>
/// <param name="action">The action to check</param>
/// <returns>True if the action can be performed</returns>
bool CanPerformAction(GameObject performer, ActionDescriptor action)
{
return true;
}
/// <summary>
/// Get the reason why an action cannot be performed.
/// </summary>
/// <param name="performer">The GameObject attempting the action</param>
/// <param name="action">The action to check</param>
/// <returns>Reason string, or null if action can be performed</returns>
string GetActionBlockedReason(GameObject performer, ActionDescriptor action)
{
return null;
}
} }

View File

@@ -7,13 +7,12 @@ public class MiningActionData : PlayerActionData
public override void ExecuteEffect(GameObject performer, GameObject target) public override void ExecuteEffect(GameObject performer, GameObject target)
{ {
if(target == null) return; if (target == null) return;
if (target.TryGetComponent<MineableBlock>(out var block)) // Use IDamageable interface for all damageable objects
if (target.TryGetComponent<IDamageable>(out var damageable))
{ {
// 서버 RPC 호출은 블록 내부의 로직을 그대로 사용합니다. damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, performer));
block.TakeDamageRpc(damage);
block.PlayHitEffectClientRpc();
} }
} }
} }

View File

@@ -0,0 +1,44 @@
using UnityEngine;
/// <summary>
/// Mining behavior for pickaxes and similar tools.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Mining Behavior")]
public class MiningBehavior : ItemBehavior
{
[Header("Mining Settings")]
[SerializeField] private int damage = 50;
private void OnEnable()
{
// Set default mining values
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Mine";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Attack";
canRepeat = true;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Can always swing, but only deals damage if hitting a mineable block
return true;
}
public override void Use(GameObject user, GameObject target)
{
if (target == null) return;
// Use IDamageable interface for all damageable objects
if (target.TryGetComponent<IDamageable>(out var damageable))
{
damageable.TakeDamage(new DamageInfo(damage, DamageType.Mining, user));
}
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
if (target == null) return "No target";
if (!target.TryGetComponent<IDamageable>(out _))
return "Cannot mine this object";
return null;
}
}

View File

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

View File

@@ -0,0 +1,41 @@
using UnityEngine;
/// <summary>
/// Placeable behavior for building/placing items.
/// </summary>
[CreateAssetMenu(menuName = "Items/Behaviors/Placeable Behavior")]
public class PlaceableBehavior : ItemBehavior
{
[Header("Placement Settings")]
[SerializeField] private GameObject placeablePrefab;
[SerializeField] private bool requiresGround = true;
[SerializeField] private float placementRange = 5f;
public override bool IsConsumable => true;
private void OnEnable()
{
if (string.IsNullOrEmpty(behaviorName)) behaviorName = "Place";
if (string.IsNullOrEmpty(animTrigger)) animTrigger = "Place";
canRepeat = false;
}
public override bool CanUse(GameObject user, GameObject target)
{
// Would integrate with BuildManager for placement validation
return placeablePrefab != null;
}
public override void Use(GameObject user, GameObject target)
{
// Actual placement would be handled by BuildManager
// This is a placeholder for the behavior pattern
Debug.Log($"[PlaceableBehavior] Would place {placeablePrefab?.name}");
}
public override string GetBlockedReason(GameObject user, GameObject target)
{
if (placeablePrefab == null) return "Invalid placement item";
return null;
}
}

View File

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

View File

@@ -33,11 +33,11 @@ public class PlayerActionHandler : NetworkBehaviour
private IEnumerator InteractionRoutine(ActionDescriptor desc, IInteractable target) private IEnumerator InteractionRoutine(ActionDescriptor desc, IInteractable target)
{ {
_isBusy = true; _isBusy = true;
if (desc != null) _animator.SetTrigger(desc.animTrigger); if (desc != null) _animator.SetTrigger(desc.AnimTrigger);
target.Interact(gameObject); // 로직 실행 target.Interact(gameObject); // 로직 실행
yield return new WaitForSeconds(desc?.duration ?? 0.1f); yield return new WaitForSeconds(desc?.Duration ?? 0.1f);
_isBusy = false; _isBusy = false;
} }

View File

@@ -1,25 +1,63 @@
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
/// <summary>
/// Handles equipment visuals for the player.
/// Uses the new EquipmentSlot system while maintaining backwards compatibility.
/// </summary>
public class PlayerEquipmentHandler : NetworkBehaviour public class PlayerEquipmentHandler : NetworkBehaviour
{ {
[SerializeField] private Transform toolAnchor; // 캐릭터 손의 소켓 위치 [Header("Equipment Settings")]
[SerializeField] private Transform mainHandAnchor;
[SerializeField] private Transform offHandAnchor;
[Header("Slot Configuration")]
[SerializeField] private EquipmentSlot mainHandSlot;
private PlayerInventory _inventory; private PlayerInventory _inventory;
private GameObject _currentToolInstance; // 현재 생성된 도구 모델 private GameObject _currentToolInstance;
void Awake() void Awake()
{ {
_inventory = GetComponent<PlayerInventory>(); _inventory = GetComponent<PlayerInventory>();
// Initialize main hand slot if not configured in inspector
if (mainHandSlot == null)
{
mainHandSlot = new EquipmentSlot
{
SlotType = EquipmentSlotType.MainHand,
AttachPoint = mainHandAnchor
};
}
else if (mainHandSlot.AttachPoint == null)
{
mainHandSlot.AttachPoint = mainHandAnchor;
}
} }
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
// 인벤토리의 슬롯 변경 이벤트 구독 // Subscribe to inventory slot changes
// OnSlotChanged는 (이전 값, 새 값) 두 개의 인자를 전달합니다. if (_inventory != null)
_inventory.OnSlotChanged += HandleSlotChanged; {
_inventory.OnSlotChanged += HandleSlotChanged;
// 게임 시작 시 처음에 들고 있는 아이템 모델 생성 // Initialize with current slot
UpdateEquippedModel(_inventory.SelectedSlotIndex); UpdateEquippedModel(_inventory.SelectedSlotIndex);
}
}
public override void OnNetworkDespawn()
{
// Unsubscribe to prevent memory leaks
if (_inventory != null)
{
_inventory.OnSlotChanged -= HandleSlotChanged;
}
// Clean up equipment
mainHandSlot?.Unequip();
} }
private void HandleSlotChanged(int previousValue, int newValue) private void HandleSlotChanged(int previousValue, int newValue)
@@ -29,30 +67,58 @@ public class PlayerEquipmentHandler : NetworkBehaviour
private void UpdateEquippedModel(int slotIndex) private void UpdateEquippedModel(int slotIndex)
{ {
// 1. 기존 도구가 있다면 파괴 // Get item data for the selected slot
if (_currentToolInstance != null) ItemData data = _inventory?.GetItemDataInSlot(slotIndex);
// Use new equipment slot system
if (data != null && data.CanBeEquipped)
{ {
Destroy(_currentToolInstance); // Use IEquippableItem interface
mainHandSlot.Equip(data);
}
else
{
mainHandSlot.Unequip();
} }
// 2. 현재 선택된 슬롯의 데이터 확인 // Update legacy reference for any code that might check it
ItemData data = _inventory.GetItemDataInSlot(slotIndex); _currentToolInstance = mainHandSlot.CurrentEquipment;
// 3. 도구인 경우에만 모델 생성
if (data != null && data.isTool && data.toolPrefab != null)
{
_currentToolInstance = Instantiate(data.toolPrefab, toolAnchor);
// ItemData에 설정된 오프셋 적용
_currentToolInstance.transform.localPosition = data.equipPositionOffset;
_currentToolInstance.transform.localRotation = Quaternion.Euler(data.equipRotationOffset);
}
} }
public override void OnNetworkDespawn() /// <summary>
/// Get the currently equipped item data.
/// </summary>
public ItemData GetEquippedItem()
{ {
// 이벤트 구독 해제 (메모리 누수 방지) return mainHandSlot?.EquippedItem;
if (_inventory != null)
_inventory.OnSlotChanged -= HandleSlotChanged;
} }
/// <summary>
/// Get the currently equipped tool instance.
/// </summary>
public GameObject GetCurrentToolInstance()
{
return mainHandSlot?.CurrentEquipment ?? _currentToolInstance;
}
/// <summary>
/// Check if player has equipment in main hand.
/// </summary>
public bool HasMainHandEquipment => mainHandSlot?.HasEquipment ?? false;
/// <summary>
/// Force refresh the equipped model.
/// </summary>
public void RefreshEquipment()
{
if (_inventory != null)
{
UpdateEquippedModel(_inventory.SelectedSlotIndex);
}
}
/// <summary>
/// Get the main hand equipment slot for advanced usage.
/// </summary>
public EquipmentSlot MainHandSlot => mainHandSlot;
} }

View File

@@ -204,35 +204,50 @@ public class PlayerNetworkController : NetworkBehaviour
if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); if (_isGrounded) _velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
} }
// 1. 액션 (좌클릭) - 대상이 없어도 나감 // 1. Action (Left Click) - executes even without target
// PlayerNetworkController.cs 중 일부
private void OnActionInput() private void OnActionInput()
{ {
if (!IsOwner || _actionHandler.IsBusy) return; if (!IsOwner || _actionHandler.IsBusy) return;
ItemData selectedItem = _inventory.GetSelectedItemData(); ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null) return;
// 로그 1: 아이템 확인 // Check if item has behavior (new system)
if (selectedItem == null) { Debug.Log("선택된 아이템이 없음"); return; } if (selectedItem.behavior != null)
// 로그 2: 도구 여부 및 액션 데이터 확인
Debug.Log($"현재 아이템: {selectedItem.itemName}, 도구여부: {selectedItem.isTool}, 액션데이터: {selectedItem.toolAction != null}");
if (selectedItem.isTool && selectedItem.toolAction != null)
{ {
if (_lastHighlightedBlock != null) GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
// Use the new behavior system
if (selectedItem.CanUse(gameObject, target))
{ {
Debug.Log($"채광 시작: {_lastHighlightedBlock.name}"); // Get action descriptor and perform action
_actionHandler.PerformAction(selectedItem.toolAction, _lastHighlightedBlock.gameObject); var actionDesc = selectedItem.GetUseAction();
} if (actionDesc != null)
else {
{ _actionHandler.PerformAction(
Debug.Log("조준된 블록이 없음 (하이라이트 확인 필요)"); CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
_actionHandler.PerformAction(selectedItem.toolAction, null); target
);
}
} }
} }
} }
// Helper to bridge between new ActionDescriptor and legacy PlayerActionData
private PlayerActionData CreateActionDataFromDescriptor(ActionDescriptor desc, ItemBehavior behavior)
{
// Create a temporary runtime action data
var actionData = ScriptableObject.CreateInstance<BehaviorActionData>();
actionData.actionName = desc.ActionName;
actionData.duration = desc.Duration;
actionData.animTrigger = desc.AnimTrigger;
actionData.impactDelay = desc.ImpactDelay;
actionData.baseSpeed = desc.AnimSpeed;
actionData.canRepeat = desc.CanRepeat;
actionData.behavior = behavior;
return actionData;
}
// 2. 인터랙션 (F키) - 대상이 없으면 아예 시작 안 함 // 2. 인터랙션 (F키) - 대상이 없으면 아예 시작 안 함
private void OnInteractTap() private void OnInteractTap()
{ {
@@ -250,13 +265,13 @@ public class PlayerNetworkController : NetworkBehaviour
{ {
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out var target)) if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetId, out var target))
{ {
if (target.TryGetComponent<MineableBlock>(out var block)) // Use IDamageable interface instead of MineableBlock directly
if (target.TryGetComponent<IDamageable>(out var damageable))
{ {
// 서버에서 최종 거리 검증 후 대미지 적용 // Server-side distance validation before applying damage
if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f) if (Vector3.Distance(transform.position, target.transform.position) <= attackRange + 1.0f)
{ {
block.TakeDamageRpc(miningDamage); damageable.TakeDamage(new DamageInfo(miningDamage, DamageType.Mining, gameObject));
block.PlayHitEffectClientRpc();
} }
} }
} }
@@ -487,10 +502,11 @@ public class PlayerNetworkController : NetworkBehaviour
private void HandleContinuousAction() private void HandleContinuousAction()
{ {
ItemData selectedItem = _inventory.GetSelectedItemData(); ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem == null || !selectedItem.isTool || selectedItem.toolAction == null) return; if (selectedItem == null || selectedItem.behavior == null) return;
// [핵심] 반복 가능한 액션일 때만 Update에서 재실행 // Only repeat if action supports it
if (selectedItem.toolAction.canRepeat) var actionDesc = selectedItem.GetUseAction();
if (actionDesc != null && actionDesc.CanRepeat)
{ {
TryExecuteAction(); TryExecuteAction();
} }
@@ -501,15 +517,23 @@ public class PlayerNetworkController : NetworkBehaviour
if (_actionHandler.IsBusy) return; if (_actionHandler.IsBusy) return;
ItemData selectedItem = _inventory.GetSelectedItemData(); ItemData selectedItem = _inventory.GetSelectedItemData();
if (selectedItem != null && selectedItem.isTool && selectedItem.toolAction != null) if (selectedItem == null || selectedItem.behavior == null) return;
var actionDesc = selectedItem.GetUseAction();
if (actionDesc == null) return;
// Skip if non-repeatable action already executed once
if (!actionDesc.CanRepeat && _hasExecutedOnce) return;
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null;
if (selectedItem.CanUse(gameObject, target))
{ {
// 단발성 액션인데 이미 한 번 실행했다면 스킵 _actionHandler.PerformAction(
if (!selectedItem.toolAction.canRepeat && _hasExecutedOnce) return; CreateActionDataFromDescriptor(actionDesc, selectedItem.behavior),
target
GameObject target = _lastHighlightedBlock != null ? _lastHighlightedBlock.gameObject : null; );
_actionHandler.PerformAction(selectedItem.toolAction, target); _hasExecutedOnce = true;
_hasExecutedOnce = true; // 실행 기록 저장
} }
} }

View File

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

View File

@@ -0,0 +1,215 @@
using System;
using System.Collections;
using UnityEngine;
/// <summary>
/// Reusable action execution system with busy state management.
/// Replaces repeated coroutine busy-state patterns across the codebase.
/// </summary>
public class ActionExecutor : MonoBehaviour
{
public bool IsBusy { get; private set; }
private Coroutine _currentAction;
/// <summary>
/// Event fired when an action starts.
/// </summary>
public event Action OnActionStarted;
/// <summary>
/// Event fired when an action completes.
/// </summary>
public event Action OnActionCompleted;
/// <summary>
/// Event fired when an action is cancelled.
/// </summary>
public event Action OnActionCancelled;
/// <summary>
/// Try to execute an action. Returns false if already busy.
/// </summary>
/// <param name="request">The action request to execute</param>
/// <returns>True if action started, false if busy</returns>
public bool TryExecute(ActionRequest request)
{
if (IsBusy) return false;
_currentAction = StartCoroutine(ExecuteRoutine(request));
return true;
}
/// <summary>
/// Cancel the current action if one is running.
/// </summary>
public void Cancel()
{
if (_currentAction != null)
{
StopCoroutine(_currentAction);
_currentAction = null;
IsBusy = false;
OnActionCancelled?.Invoke();
}
}
/// <summary>
/// Force reset the busy state. Use with caution.
/// </summary>
public void ForceReset()
{
if (_currentAction != null)
{
StopCoroutine(_currentAction);
_currentAction = null;
}
IsBusy = false;
}
private IEnumerator ExecuteRoutine(ActionRequest request)
{
IsBusy = true;
OnActionStarted?.Invoke();
// Pre-action callback
request.OnStart?.Invoke();
// Animation trigger
if (request.Animator != null && !string.IsNullOrEmpty(request.AnimTrigger))
{
if (request.AnimSpeed > 0)
{
request.Animator.SetFloat("ActionSpeed", request.AnimSpeed);
}
request.Animator.SetTrigger(request.AnimTrigger);
}
// Wait for impact point
if (request.ImpactDelay > 0)
{
float adjustedDelay = request.AnimSpeed > 0
? request.ImpactDelay / request.AnimSpeed
: request.ImpactDelay;
yield return new WaitForSeconds(adjustedDelay);
}
// Execute main effect
request.OnImpact?.Invoke();
// Wait for remaining duration
float remainingTime = request.TotalDuration - request.ImpactDelay;
if (request.AnimSpeed > 0)
{
remainingTime /= request.AnimSpeed;
}
if (remainingTime > 0)
{
yield return new WaitForSeconds(remainingTime);
}
// Completion callback
request.OnComplete?.Invoke();
IsBusy = false;
_currentAction = null;
OnActionCompleted?.Invoke();
}
}
/// <summary>
/// Configuration for an action to be executed by ActionExecutor.
/// </summary>
[Serializable]
public struct ActionRequest
{
/// <summary>
/// Animator to trigger animations on.
/// </summary>
public Animator Animator;
/// <summary>
/// Animation trigger name.
/// </summary>
public string AnimTrigger;
/// <summary>
/// Animation playback speed multiplier.
/// </summary>
public float AnimSpeed;
/// <summary>
/// Time delay before the impact/effect happens (for syncing with animation).
/// </summary>
public float ImpactDelay;
/// <summary>
/// Total duration of the action.
/// </summary>
public float TotalDuration;
/// <summary>
/// Callback invoked when action starts.
/// </summary>
public Action OnStart;
/// <summary>
/// Callback invoked at the impact moment.
/// </summary>
public Action OnImpact;
/// <summary>
/// Callback invoked when action completes.
/// </summary>
public Action OnComplete;
/// <summary>
/// Create a simple action request with just timing.
/// </summary>
public static ActionRequest Simple(float duration, Action onComplete)
{
return new ActionRequest
{
TotalDuration = duration,
AnimSpeed = 1f,
OnComplete = onComplete
};
}
/// <summary>
/// Create an animated action request.
/// </summary>
public static ActionRequest Animated(Animator animator, string trigger, float duration,
float impactDelay, Action onImpact, float speed = 1f)
{
return new ActionRequest
{
Animator = animator,
AnimTrigger = trigger,
AnimSpeed = speed,
ImpactDelay = impactDelay,
TotalDuration = duration,
OnImpact = onImpact
};
}
/// <summary>
/// Create a full action request with all callbacks.
/// </summary>
public static ActionRequest Full(Animator animator, string trigger, float duration,
float impactDelay, float speed, Action onStart, Action onImpact, Action onComplete)
{
return new ActionRequest
{
Animator = animator,
AnimTrigger = trigger,
AnimSpeed = speed,
ImpactDelay = impactDelay,
TotalDuration = duration,
OnStart = onStart,
OnImpact = onImpact,
OnComplete = onComplete
};
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27e07922480ecf44faf5135ad4872531

View File

@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Utility class for common physics queries.
/// Eliminates repeated OverlapSphere patterns across the codebase.
/// </summary>
public static class PhysicsQueryUtility
{
// Reusable buffer to avoid allocations (32 should be enough for most cases)
private static readonly Collider[] OverlapBuffer = new Collider[32];
/// <summary>
/// Find the closest object implementing interface T within radius.
/// </summary>
/// <typeparam name="T">Interface or component type to search for</typeparam>
/// <param name="origin">Center point of the search</param>
/// <param name="radius">Search radius</param>
/// <param name="layerMask">Layer mask to filter objects</param>
/// <returns>Closest object of type T, or null if none found</returns>
public static T FindClosest<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
T closest = null;
float minDistSqr = float.MaxValue;
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null)
{
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
if (distSqr < minDistSqr)
{
minDistSqr = distSqr;
closest = component;
}
}
}
return closest;
}
/// <summary>
/// Find the closest object implementing interface T within radius, also returning distance.
/// </summary>
public static T FindClosest<T>(Vector3 origin, float radius, LayerMask layerMask, out float distance) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
T closest = null;
float minDistSqr = float.MaxValue;
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null)
{
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
if (distSqr < minDistSqr)
{
minDistSqr = distSqr;
closest = component;
}
}
}
distance = closest != null ? Mathf.Sqrt(minDistSqr) : 0f;
return closest;
}
/// <summary>
/// Execute action on all objects of type T within radius.
/// </summary>
/// <typeparam name="T">Interface or component type to search for</typeparam>
/// <param name="origin">Center point of the search</param>
/// <param name="radius">Search radius</param>
/// <param name="layerMask">Layer mask to filter objects</param>
/// <param name="action">Action to execute on each found object</param>
public static void ForEachInRadius<T>(Vector3 origin, float radius, LayerMask layerMask, Action<T> action) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
// Track processed objects to avoid duplicates (same object hit by multiple colliders)
var processed = new HashSet<T>();
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null && !processed.Contains(component))
{
processed.Add(component);
action(component);
}
}
}
/// <summary>
/// Execute action on all objects of type T within radius, with additional context.
/// </summary>
public static void ForEachInRadius<T>(Vector3 origin, float radius, LayerMask layerMask,
Action<T, Collider> action) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
var processed = new HashSet<T>();
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null && !processed.Contains(component))
{
processed.Add(component);
action(component, OverlapBuffer[i]);
}
}
}
/// <summary>
/// Get all objects of type T within radius.
/// </summary>
/// <typeparam name="T">Interface or component type to search for</typeparam>
/// <param name="origin">Center point of the search</param>
/// <param name="radius">Search radius</param>
/// <param name="layerMask">Layer mask to filter objects</param>
/// <returns>List of all objects of type T within radius</returns>
public static List<T> FindAllInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
{
var results = new List<T>();
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null && !results.Contains(component))
{
results.Add(component);
}
}
return results;
}
/// <summary>
/// Find all objects of type T within radius, sorted by distance (closest first).
/// </summary>
public static List<T> FindAllInRadiusSorted<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
var resultsWithDistance = new List<(T component, float distSqr)>();
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null)
{
bool alreadyAdded = false;
foreach (var item in resultsWithDistance)
{
if (ReferenceEquals(item.component, component))
{
alreadyAdded = true;
break;
}
}
if (!alreadyAdded)
{
float distSqr = (origin - OverlapBuffer[i].transform.position).sqrMagnitude;
resultsWithDistance.Add((component, distSqr));
}
}
}
// Sort by distance
resultsWithDistance.Sort((a, b) => a.distSqr.CompareTo(b.distSqr));
var results = new List<T>(resultsWithDistance.Count);
foreach (var item in resultsWithDistance)
{
results.Add(item.component);
}
return results;
}
/// <summary>
/// Check if any object of type T exists within radius.
/// </summary>
public static bool AnyInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
for (int i = 0; i < count; i++)
{
if (OverlapBuffer[i].GetComponentInParent<T>() != null)
{
return true;
}
}
return false;
}
/// <summary>
/// Count objects of type T within radius.
/// </summary>
public static int CountInRadius<T>(Vector3 origin, float radius, LayerMask layerMask) where T : class
{
int count = Physics.OverlapSphereNonAlloc(origin, radius, OverlapBuffer, layerMask);
var processed = new HashSet<T>();
for (int i = 0; i < count; i++)
{
T component = OverlapBuffer[i].GetComponentInParent<T>();
if (component != null)
{
processed.Add(component);
}
}
return processed.Count;
}
}

View File

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