Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions Fantasy-Grower/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,59 +55,66 @@
## 10. Code Formatting
- **CSharpier**: All C# code must be formatted strictly following the **CSharpier** formatting style. Do not use custom formatting or spacing that contradicts CSharpier conventions.

## 11. Unity and C# Compatibility
## 11. Encoding and File Reading
- Treat project text files as UTF-8. When reading files through PowerShell, prefer `Get-Content -Encoding UTF8` and set UTF-8 output encoding when practical.
- The Codex/terminal output pipeline may still display Korean comments, tooltips, headers, or XML documentation as mojibake. Do not infer code behavior from garbled Korean text.
- Do not treat garbled Korean comments or Inspector strings as evidence of a source-file corruption by themselves. Verify using C# syntax, symbols, braces, declarations, references, compiler/formatter output, and Unity serialization data.
- If a line appears to contain code after `//` because of mojibake, do not edit it merely based on the displayed Korean text. First confirm that the actual C# token is missing, duplicated, unreachable, or otherwise causing a real compile/logic issue.
- When adding new Korean comments or Inspector text, save files as UTF-8 and keep executable C# statements on their own lines, separate from comments and attributes.

## 12. Unity and C# Compatibility
- The project targets Unity 6 and C# 9, but available BCL APIs are determined by Unity's configured API compatibility level. Do not assume that every API from modern standalone .NET is available.
- Before introducing a new runtime or BCL type, verify that Unity can compile it under the project's current API compatibility settings.
- Prefer APIs already used successfully in the project when an equivalent modern API has uncertain Unity support.
- Never edit Unity-generated `.csproj` or `.sln` files manually. Configure compatibility and packages through Unity.
- Do not add, remove, or upgrade packages without explicit user approval.

## 12. Unity Serialization and Asset Safety
## 13. Unity Serialization and Asset Safety
- Preserve `.meta` files and Unity GUIDs. Never recreate, delete, or move assets casually.
- When renaming a serialized field, use `[FormerlySerializedAs]` or provide an explicit migration so existing scenes, prefabs, and ScriptableObjects retain their values.
- Do not change a serialized field's type without checking all affected scenes, prefabs, and assets.
- ScriptableObjects define shared game content and configuration. Do not store per-player mutable runtime state in shared ScriptableObject assets.
- Keep player progression/state separate from definitions such as weapon, skill, dungeon, wave, reward, and stat data.
- Do not directly edit Unity YAML assets unless necessary and understood; prefer Unity Editor operations for structural scene/prefab changes.

## 13. Inspector and Runtime Validation
## 14. Inspector and Runtime Validation
- `OnValidate()` is an editor-time aid, not a runtime safety mechanism. Public entry points and lifecycle methods must still handle invalid or missing dependencies safely.
- Use `NaughtyAttributes` such as `ShowIf`, `HideIf`, and validation attributes when they materially improve Inspector clarity.
- Log validation errors with the component context (`this`) and enough information to identify the affected object and field.
- Do not spam logs every frame or repeatedly report the same recoverable error.
- Required component dependencies should use `[RequireComponent]` where appropriate.

## 14. Lifecycle, Events, Coroutines, and Tweening
## 15. Lifecycle, Events, Coroutines, and Tweening
- Every event subscription must have a matching unsubscription at the appropriate lifecycle boundary.
- Stop or kill owned coroutines and tweens when their owner is disabled or destroyed when continued execution could access stale objects.
- Before starting a replacement tween, kill the previous tween that writes to the same state or UI element.
- Guard delayed callbacks against destroyed or invalid Unity objects.
- Avoid static events unless global broadcast semantics are genuinely required; static subscribers must always unsubscribe.
- Singleton components must reject duplicates safely and must not destroy unrelated components attached to the same GameObject.

## 15. Performance and Allocation Policy
## 16. Performance and Allocation Policy
- Optimize measured or clearly hot paths such as per-frame combat, frequently refreshed UI, and repeated spawning. Do not complicate cold code for speculative micro-optimization.
- Avoid repeated LINQ, reflection, `Find*`, string interpolation, and avoidable allocations in `Update`, combat loops, and high-frequency UI callbacks.
- For allocation-sensitive TMP number displays, prefer reusable `char[]`/`Span<char>`, project `SpanExtension` helpers, and `TMP_Text.SetCharArray` when Unity compatibility is verified.
- Reuse existing caches such as `YieldInstructionCache` for repeated fixed waits.
- Object pooling is preferred for frequently spawned combat effects and entities once their spawn rate justifies it.

## 16. Persistence, Networking, and Security
## 17. Persistence, Networking, and Security
- PlayerPrefs is only for non-sensitive device-local preferences. Never treat it as authoritative storage for currency, progression, inventory, equipment, skills, dungeon records, or credentials.
- Never store passwords, session secrets, or long-lived authentication tokens in plain PlayerPrefs or unencrypted JSON.
- Server-authoritative values must be validated and calculated by the server; never trust client-submitted rewards, prices, or balances.
- Persist stable string IDs for externally stored data. Do not persist Unity object references or rely on enum ordinal values as long-term network/database identifiers.
- Network mutations that grant or consume value must be idempotent and safe against duplicate requests.
- Keep DTOs separate from Unity domain objects and ScriptableObjects.

## 17. Error Handling and Data Integrity
## 18. Error Handling and Data Integrity
- Validate currency balance, index bounds, null references, maximum levels, and state transitions before mutating state.
- Avoid unsigned subtraction when the minuend may be smaller; validate first to prevent underflow.
- A multi-step operation such as purchase, synthesis, upgrade, reward grant, or skill unlock must either complete fully or leave state unchanged.
- Do not silently swallow failures. Return a meaningful result or log a clear error at the correct ownership layer.
- Do not use placeholder conditions such as `if (true)` or ship test-only behavior in production paths.

## 18. Testing, Verification, and Git Safety
## 19. Testing, Verification, and Git Safety
- Do not claim Unity compilation, Play Mode behavior, or Inspector wiring was verified unless it was actually checked in the Unity Editor.
- Add EditMode or PlayMode tests for deterministic core logic when practical, especially currency consumption, rewards, damage, skill unlock rules, and progression calculations.
- After code changes, inspect the diff and run whitespace/error checks. Report any verification that could not be performed.
Expand Down
20 changes: 20 additions & 0 deletions Fantasy-Grower/Assets/Prefabs/PlayerCharacter/Archer.prefab
Original file line number Diff line number Diff line change
Expand Up @@ -3268,6 +3268,7 @@ GameObject:
- component: {fileID: -934695906495670747}
- component: {fileID: 4682663325673948411}
- component: {fileID: 713001000000000003}
- component: {fileID: 713002000000000003}
m_Layer: 0
m_Name: Archer
m_TagString: Untagged
Expand Down Expand Up @@ -6336,6 +6337,25 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
targets: {fileID: 430098703366088485}
--- !u!114 &713002000000000003
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5857381939795342657}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0733f8d1a3f642ca9bcfa8813c6cb14b, type: 3}
m_Name:
m_EditorClassIdentifier:
enableKeyboardInput: 1
slotKeys:
- 49
- 50
- 51
- 52
- 53
--- !u!114 &6034931146787275272
MonoBehaviour:
m_ObjectHideFlags: 0
Expand Down
20 changes: 20 additions & 0 deletions Fantasy-Grower/Assets/Prefabs/PlayerCharacter/Warrior.prefab
Original file line number Diff line number Diff line change
Expand Up @@ -3670,6 +3670,7 @@ GameObject:
- component: {fileID: 9091531221583279582}
- component: {fileID: 8937113908660665095}
- component: {fileID: 713001000000000002}
- component: {fileID: 713002000000000002}
m_Layer: 0
m_Name: Warrior
m_TagString: Untagged
Expand Down Expand Up @@ -6748,6 +6749,25 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
targets: {fileID: 5115161450785208119}
--- !u!114 &713002000000000002
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6499316856751531827}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0733f8d1a3f642ca9bcfa8813c6cb14b, type: 3}
m_Name:
m_EditorClassIdentifier:
enableKeyboardInput: 1
slotKeys:
- 49
- 50
- 51
- 52
- 53
--- !u!114 &6107641911005882464
MonoBehaviour:
m_ObjectHideFlags: 0
Expand Down
20 changes: 20 additions & 0 deletions Fantasy-Grower/Assets/Prefabs/PlayerCharacter/Wizard.prefab
Original file line number Diff line number Diff line change
Expand Up @@ -3173,6 +3173,7 @@ GameObject:
- component: {fileID: 4753827104680141411}
- component: {fileID: 4530378413672165486}
- component: {fileID: 713001000000000004}
- component: {fileID: 713002000000000004}
m_Layer: 0
m_Name: Wizard
m_TagString: Untagged
Expand Down Expand Up @@ -6319,6 +6320,25 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
targets: {fileID: 6694508944490675349}
--- !u!114 &713002000000000004
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6063808467322970300}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0733f8d1a3f642ca9bcfa8813c6cb14b, type: 3}
m_Name:
m_EditorClassIdentifier:
enableKeyboardInput: 1
slotKeys:
- 49
- 50
- 51
- 52
- 53
--- !u!114 &4616874832124039420
MonoBehaviour:
m_ObjectHideFlags: 0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine;

/// <summary>
Expand All @@ -10,6 +11,9 @@ public sealed class SingleTargetAttackBehaviour : EntityAttackBehaviour
[SerializeField]
private AttackTargetsSensing targets;

private readonly List<Entity> extensionTargetBuffer = new();
private readonly List<Entity> nearestTargetBuffer = new();

private void Awake()
{
if (targets == null)
Expand All @@ -21,17 +25,83 @@ public override bool TryAttack(Entity attacker)
if (attacker == null || targets == null)
return false;

Entity target = targets.GetFirstTarget();
if (target == null)
SkillTreeComponent skillTreeComponent = null;
BasicAttackSkillData basicAttack = null;
if (attacker.TryGetComponent(out SkillTreeComponent foundSkillTreeComponent))
{
skillTreeComponent = foundSkillTreeComponent;
basicAttack = skillTreeComponent.GetUnlockedBasicAttack();
}

// 인식 사거리 콜라이더에 적이 1마리라도 있어야 공격 모션을 시작함
var sensingTargets = targets.GetTargets();
if (sensingTargets == null || sensingTargets.Count == 0)
return false;

(float damage, _) = DamageCalculator.Calculate(
attacker.AttackPower,
target.DamageReduction,
attacker.CriticalPercentage
);
target.TakeDamage(damage);
return true;
int maxTargets =
(basicAttack != null ? basicAttack.MaxTargets : 1)
+ attacker.BasicAttackTargetCountBonus;

// 연장 사거리가 있으면 거리 기반 O(N*K) 탐색으로 타겟을 수집
System.Collections.Generic.IReadOnlyList<Entity> attackTargets;
if (basicAttack != null && basicAttack.ExtensionRange > 0f)
{
float attackAreaMultiplier =
skillTreeComponent != null ? skillTreeComponent.GetAttackAreaMultiplier() : 1f;
float totalRange =
(attacker.AttackRange > 0f ? attacker.AttackRange : 0f)
+ basicAttack.ExtensionRange * attackAreaMultiplier;
WaveController.TryCollectActiveEnemies(extensionTargetBuffer);

CollectNearestInRange(
extensionTargetBuffer,
attacker.transform.position,
totalRange,
maxTargets,
nearestTargetBuffer
);
attackTargets = nearestTargetBuffer;
}
else
{
attackTargets = sensingTargets;
}

int attackedCount = 0;
for (int i = 0; i < attackTargets.Count && attackedCount < maxTargets; i++)
{
Entity target = attackTargets[i];
if (target == null || target.Hp <= 0f)
continue;

(float damage, _) = DamageCalculator.Calculate(
attacker.AttackPower,
target.DamageReduction,
attacker.CriticalPercentage,
attacker.CriticalDamageMultiplier
);

if (basicAttack != null)
damage *= basicAttack.DamageRate;

if (skillTreeComponent != null)
{
damage *= skillTreeComponent.GetOutgoingDamageMultiplier();
damage *= skillTreeComponent.GetBasicAttackDamageMultiplier();
}
damage *= attacker.OutgoingDamageMultiplier;

if (target is Enemy { IsEliteTarget: true } && skillTreeComponent != null)
{
damage *= skillTreeComponent.GetEliteDamageMultiplier();
}

float actualDamage = target.TakeDamage(damage, attacker);
attacker.NotifyDamageDealt(target, actualDamage);
attackedCount++;
}

return attackedCount > 0;
}

private void OnValidate()
Expand All @@ -47,4 +117,50 @@ private void OnValidate()
);
}
}

/// <summary>
/// 원점으로부터 totalRange 이내의 생존 적 중 가장 가까운 K마리를 수집합니다.
/// O(N * K) 삽입 방식: 람다·Sort·Sqrt 없이 sqrMagnitude만 비교합니다.
/// </summary>
private static void CollectNearestInRange(
List<Entity> candidates,
Vector3 origin,
float totalRange,
int maxTargets,
List<Entity> results
)
{
results.Clear();
float rangeSqr = totalRange * totalRange;
int k = Mathf.Max(0, maxTargets);

for (int i = 0; i < candidates.Count; i++)
{
Entity candidate = candidates[i];
if (candidate == null || candidate.Hp <= 0f)
continue;

float sqrDist = (candidate.transform.position - origin).sqrMagnitude;
if (sqrDist > rangeSqr)
continue;

int insertIndex = results.Count;
for (int j = results.Count - 1; j >= 0; j--)
{
float existingSqrDist = (results[j].transform.position - origin).sqrMagnitude;
if (sqrDist < existingSqrDist)
insertIndex = j;
else
break;
}

if (insertIndex < k)
{
results.Insert(insertIndex, candidate);

if (results.Count > k)
results.RemoveAt(results.Count - 1);
}
}
}
}
Loading