diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b935a2f..55961630 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -23,6 +23,7 @@ "./.claude-plugin/skills/jaction", "./.claude-plugin/skills/jobjectpool", "./.claude-plugin/skills/messagebox", - "./.claude-plugin/skills/editor-ui" + "./.claude-plugin/skills/editor-ui", + "./.claude-plugin/skills/game-patterns" ] } diff --git a/.claude-plugin/skills/editor-ui/SKILL.md b/.claude-plugin/skills/editor-ui/SKILL.md index 9666d937..90fd827b 100644 --- a/.claude-plugin/skills/editor-ui/SKILL.md +++ b/.claude-plugin/skills/editor-ui/SKILL.md @@ -363,6 +363,134 @@ enum JustifyContent { Start, Center, End, SpaceBetween } enum AlignItems { Start, Center, End, Stretch } ``` +## Game Development Examples + +### Settings Panel +```csharp +public class GameSettingsWindow : EditorWindow +{ + private JToggle _vsyncToggle; + private JDropdown _fpsDropdown; + private JProgressBar _volumeSlider; + + [MenuItem("Game/Settings")] + public static void ShowWindow() => GetWindow("Game Settings"); + + public void CreateGUI() + { + var root = new JStack(GapSize.MD); + root.style.paddingTop = Tokens.Spacing.Lg; + root.style.paddingRight = Tokens.Spacing.Lg; + root.style.paddingBottom = Tokens.Spacing.Lg; + root.style.paddingLeft = Tokens.Spacing.Lg; + + // Graphics Section + var graphics = new JSection("Graphics") + .Add( + new JFormField("VSync", _vsyncToggle = new JToggle(true)), + new JFormField("Target FPS", _fpsDropdown = new JDropdown( + new() { 30, 60, 120, -1 }, + defaultValue: 60, + formatSelectedValue: static fps => fps == -1 ? "Unlimited" : $"{fps} FPS", + formatListItem: static fps => fps == -1 ? "Unlimited" : $"{fps} FPS"))); + + // Audio Section + var audio = new JSection("Audio") + .Add(new JFormField("Master Volume", _volumeSlider = new JProgressBar(0.8f) + .WithHeight(20))); + + // Actions + var actions = new JButtonGroup( + new JButton("Apply", ApplySettings, ButtonVariant.Primary), + new JButton("Reset", ResetSettings, ButtonVariant.Secondary)); + + root.Add(graphics, audio, actions); + rootVisualElement.Add(root); + } +} +``` + +### Build Tool Window +```csharp +public class BuildToolWindow : EditorWindow +{ + private JLogView _logView; + private JProgressBar _progress; + private JStatusBar _status; + + public void CreateGUI() + { + var root = new JStack(GapSize.MD); + + // Build Configuration + var config = new JSection("Build Configuration") + .Add( + new JFormField("Platform", JDropdown.ForEnum(BuildTarget.StandaloneWindows64)), + new JFormField("Development", new JToggle(false)), + new JFormField("Output", new JTextField("", "Select output folder..."))); + + // Progress Section + var progressSection = new JSection("Progress") + .Add( + _progress = new JProgressBar(0f).WithSuccessOnComplete(), + _status = new JStatusBar("Ready", StatusType.Info)); + + // Log Output + _logView = new JLogView(200).WithMinHeight(200).WithMaxHeight(400); + + // Actions + var actions = new JButtonGroup( + new JButton("Build", StartBuild, ButtonVariant.Primary), + new JButton("Clean", CleanBuild, ButtonVariant.Warning), + new JButton("Cancel", CancelBuild, ButtonVariant.Danger)); + + root.Add(config, progressSection, _logView, actions); + rootVisualElement.Add(root); + } + + private void UpdateProgress(float value, string message) + { + _progress.Progress = value; + _status.Text = message; + _logView.LogInfo(message); + } +} +``` + +### Asset Browser Panel +```csharp +public class AssetBrowserPanel : EditorWindow +{ + public void CreateGUI() + { + var root = new JStack(GapSize.MD); + + // Navigation + var nav = new JRow() + .Add( + new JIconButton("\u2190", GoBack, "Back"), + new JIconButton("\u2192", GoForward, "Forward"), + new JIconButton("\u2191", GoUp, "Parent"), + JBreadcrumb.FromPath("Assets", "Prefabs", "Characters")) + .WithAlign(AlignItems.Center); + + // Toolbar + var toolbar = new JRow() + .Add( + new JTextField("", "Search assets..."), + new JDropdown(new() { "All", "Prefabs", "Materials", "Textures" }), + new JToggleButton("Grid", "List", true)) + .WithJustify(JustifyContent.SpaceBetween); + + // Status + var status = new JStatusBar("24 items", StatusType.Info); + + root.Add(nav, toolbar, status); + rootVisualElement.Add(root); + } +} +``` + ## Example: Complete Editor Window ```csharp @@ -409,3 +537,60 @@ public class MyEditorWindow : EditorWindow } } ``` + +## Troubleshooting + +### Theme Not Updating +- **Problem:** Colors don't change when switching Unity theme +- **Solution:** Token colors are evaluated at component creation time. Recreate components or use `schedule.Execute()` to refresh on theme change: +```csharp +rootVisualElement.schedule.Execute(() => { + // Recreate or update component colors + myCard.style.backgroundColor = Tokens.Colors.BgSurface; +}).Every(1000); +``` + +### Binding Not Working +- **Problem:** SerializedProperty binding doesn't update UI +- **Solution:** Ensure the property path is correct and call `Bind()` on the root: +```csharp +var textField = new JTextField(); +textField.BindProperty(serializedObject.FindProperty("myField")); +rootVisualElement.Bind(serializedObject); +``` + +### Component Not Visible +- **Problem:** Added component doesn't appear +- **Check:** + - Parent has `flexGrow = 1` if using flex layout + - Component has non-zero width/height + - Parent visibility is not hidden + +### Buttons Not Responding +- **Problem:** Click events not firing +- **Solution:** Ensure callback is not null and component is enabled: +```csharp +var btn = new JButton("Click", () => Debug.Log("Clicked")); +btn.SetEnabled(true); // Ensure enabled +``` + +### Layout Issues +- **Problem:** Components overlap or have wrong size +- **Solution:** Use JStack for vertical, JRow for horizontal layouts: +```csharp +// Wrong: direct Add to root +rootVisualElement.Add(component1); +rootVisualElement.Add(component2); // May overlap + +// Correct: use layout container +var stack = new JStack(); +stack.Add(component1, component2); +rootVisualElement.Add(stack); +``` + +### Performance with Many Components +- **Problem:** Editor window slow with many items +- **Solution:** Use virtualization for large lists, limit JLogView maxLines: +```csharp +var log = new JLogView(maxLines: 100); // Limit entries +``` diff --git a/.claude-plugin/skills/game-patterns/SKILL.md b/.claude-plugin/skills/game-patterns/SKILL.md new file mode 100644 index 00000000..090475c9 --- /dev/null +++ b/.claude-plugin/skills/game-patterns/SKILL.md @@ -0,0 +1,460 @@ +--- +name: game-patterns +description: Zero-GC game patterns with JEngine using modern C# 9+. Triggers on: game loop, spawn system, wave spawner, cooldown, ability timer, damage over time, DoT, health regen, bullet pool, enemy pool, object pool pattern, projectile system, combat system, zero allocation, no GC, performance optimization +--- + +# Game Patterns with JEngine + +Zero-GC patterns using JAction + JObjectPool with modern C# 9+ features. + +## Principles + +1. **Async-First:** Always use `ExecuteAsync()` (non-blocking) +2. **Zero-GC:** Use static lambdas + reference type state + object pooling +3. **Modern C#:** Target-typed new, init properties, Span, in parameters + +## State Classes for Zero-GC + +JAction state parameters must be reference types to avoid boxing. +Pool these state objects for true zero-GC: + +```csharp +// Reusable state for abilities +public sealed class AbilityState +{ + public bool CanUse { get; set; } = true; + public float Cooldown { get; init; } +} + +// Reusable state for DoT effects +public sealed class DoTState +{ + public IDamageable Target { get; set; } + public float DamagePerTick { get; set; } + public int TicksRemaining { get; set; } + + public void Reset() + { + Target = null; + DamagePerTick = 0; + TicksRemaining = 0; + } +} + +// Reusable state for timers +public sealed class TimerState +{ + public float Duration { get; set; } + public float Elapsed { get; set; } + public Action OnComplete { get; set; } + + public void Reset() + { + Duration = 0; + Elapsed = 0; + OnComplete = null; + } +} +``` + +## Combat Patterns + +### Ability with Cooldown (Zero-GC) +```csharp +public sealed class AbilitySystem +{ + private readonly AbilityState _state = new() { Cooldown = 2f }; + + public async UniTaskVoid UseAbility() + { + if (!_state.CanUse) return; + _state.CanUse = false; + + PerformAbility(); + + using var action = await JAction.Create() + .Delay(_state.Cooldown) + .Do(static s => s.CanUse = true, _state) + .ExecuteAsync(); + } +} +``` + +### Damage Over Time (Zero-GC Pooled State) +```csharp +public static class DoTSystem +{ + public static async UniTaskVoid Apply( + IDamageable target, + float damagePerTick, + int ticks, + float interval) + { + var state = JObjectPool.Shared().Rent(); + state.Target = target; + state.DamagePerTick = damagePerTick; + + using var action = await JAction.Create() + .Repeat( + static s => s.Target?.TakeDamage(s.DamagePerTick), + state, + count: ticks, + interval: interval) + .ExecuteAsync(); + + state.Reset(); + JObjectPool.Shared().Return(state); + } +} +``` + +### Combo System (Zero-GC) +```csharp +public sealed class ComboState +{ + public int Count; + public JAction ResetAction; +} + +public sealed class ComboSystem : IDisposable +{ + private readonly ComboState _state = new(); + + public int ComboCount => _state.Count; + + public void OnHit() + { + _state.ResetAction?.Cancel(); + _state.ResetAction?.Dispose(); + _state.Count++; + + _state.ResetAction = JAction.Create() + .Delay(1.5f) + .Do(static s => s.Count = 0, _state); + + _ = _state.ResetAction.ExecuteAsync(); + } + + /// + /// Call from OnDestroy to prevent callbacks on destroyed objects. + /// + public void Dispose() + { + _state.ResetAction?.Cancel(); + _state.ResetAction?.Dispose(); + _state.ResetAction = null; + } +} +``` + +## Spawning Patterns + +### Wave Spawner (Async) +```csharp +public sealed class WaveSpawner +{ + private readonly EnemySpawner _spawner; + + // Use ReadOnlyMemory for async (ReadOnlySpan is a ref struct, invalid in async) + // Access .Span inside the loop for zero-allocation iteration + public async UniTask RunWaves(ReadOnlyMemory waves, CancellationToken ct = default) + { + foreach (var wave in waves.Span) + { + using var action = await JAction.Create() + .Delay(wave.StartDelay) + .Do(() => SpawnWave(wave.EnemyCount)) + .WaitUntil(() => ActiveCount == 0, timeout: 120f) + .ExecuteAsync(); + + if (action.Cancelled || ct.IsCancellationRequested) break; + } + } + + private void SpawnWave(int count) + { + for (var i = 0; i < count; i++) + { + var pos = GetSpawnPosition(i); + _spawner.Spawn(in pos); + } + } +} +``` + +### Pooled Spawner (Generic, Zero-GC) +```csharp +public interface IPoolable +{ + void OnSpawn(); + void OnDespawn(); +} + +public sealed class PooledSpawner where T : class, IPoolable, new() +{ + private readonly JObjectPool _pool; + + public PooledSpawner(int maxSize = 64) + { + _pool = new( + maxSize, + onRent: static obj => obj.OnSpawn(), + onReturn: static obj => obj.OnDespawn()); + } + + public T Spawn() => _pool.Rent(); + public void Despawn(T obj) => _pool.Return(obj); + public void Prewarm(int count) => _pool.Prewarm(count); + public int PooledCount => _pool.Count; +} +``` + +## Resource Patterns + +### Health Regeneration (Zero-GC) +```csharp +public sealed class RegenState +{ + public float Current; + public float Max; + public float PerTick; + public JAction Action; + + public void Cleanup() + { + Action?.Cancel(); + Action?.Dispose(); + Action = null; + } +} + +public static class RegenSystem +{ + public static void Start(RegenState state, float hpPerSecond) + { + state.Cleanup(); + state.PerTick = hpPerSecond * 0.1f; + + state.Action = JAction.Create() + .RepeatWhile( + static s => s.Current = MathF.Min(s.Current + s.PerTick, s.Max), + static s => s.Current < s.Max, + state, + frequency: 0.1f); + + _ = state.Action.ExecuteAsync(); + } + + /// + /// Stop regeneration. Call Cleanup() from OnDestroy to fully dispose. + /// + public static void Stop(RegenState state) => state.Cleanup(); +} +``` + +## Projectile Patterns + +### Bullet Manager (Zero-GC) +```csharp +public sealed class Bullet +{ + public Vector3 Position; + public Vector3 Velocity; + public float Damage; + public bool Active; + + public void Reset() + { + Position = default; + Velocity = default; + Damage = 0; + Active = false; + } +} + +public sealed class BulletManager +{ + public static BulletManager Instance { get; private set; } + + private readonly JObjectPool _pool = new( + maxSize: 500, + onReturn: static b => b.Reset()); + + public void Initialize() + { + Instance = this; + _pool.Prewarm(200); + } + + public Bullet Fire(in Vector3 pos, in Vector3 dir, float speed, float damage) + { + var bullet = _pool.Rent(); + bullet.Position = pos; + bullet.Velocity = dir * speed; + bullet.Damage = damage; + bullet.Active = true; + return bullet; + } + + public void Return(Bullet b) => _pool.Return(b); +} +``` + +### Auto-Return After Lifetime (Zero-GC) +```csharp +public sealed class BulletLifetimeState +{ + public Bullet Bullet; + public BulletManager Manager; + + public void Reset() + { + Bullet = null; + Manager = null; + } +} + +public static async UniTaskVoid FireWithLifetime( + BulletManager manager, + in Vector3 pos, + in Vector3 dir, + float speed, + float damage, + float lifetime) +{ + var bullet = manager.Fire(in pos, in dir, speed, damage); + + var state = JObjectPool.Shared().Rent(); + state.Bullet = bullet; + state.Manager = manager; + + using var action = await JAction.Create() + .Delay(lifetime) + .Do(static s => s.Manager?.Return(s.Bullet), state) + .ExecuteAsync(); + + state.Reset(); + JObjectPool.Shared().Return(state); +} +``` + +## UI Patterns + +### Delayed Tooltip (Zero-GC) +```csharp +public sealed class TooltipState +{ + public JAction Action; + public Action ShowCallback; + public Action HideCallback; + + public void Reset() + { + Action = null; + ShowCallback = null; + HideCallback = null; + } +} + +public sealed class TooltipTrigger : IDisposable +{ + private readonly TooltipState _state = new(); + + public void OnPointerEnter(Action show, Action hide) + { + _state.Action?.Cancel(); + _state.Action?.Dispose(); + + _state.ShowCallback = show; + _state.HideCallback = hide; + + _state.Action = JAction.Create() + .Delay(0.5f) + .Do(static s => s.ShowCallback?.Invoke(), _state); + + _ = _state.Action.ExecuteAsync(); + } + + public void OnPointerExit() + { + _state.Action?.Cancel(); + _state.Action?.Dispose(); + _state.Action = null; + _state.HideCallback?.Invoke(); + } + + /// + /// Call from OnDestroy to prevent callbacks on destroyed objects. + /// + public void Dispose() + { + _state.Action?.Cancel(); + _state.Action?.Dispose(); + _state.Reset(); + } +} +``` + +### Typewriter Effect (Zero-GC) +```csharp +public sealed class TypewriterState +{ + public string FullText; + public int CurrentIndex; + public Action OnUpdate; + public StringBuilder Builder; + + public void Reset() + { + FullText = null; + CurrentIndex = 0; + OnUpdate = null; + Builder = null; + } +} + +public static async UniTask TypeText(string content, float charDelay, Action onUpdate) +{ + var state = JObjectPool.Shared().Rent(); + var sb = JObjectPool.Shared().Rent(); + + state.FullText = content; + state.CurrentIndex = 0; + state.OnUpdate = onUpdate; + state.Builder = sb; + + using var action = await JAction.Create() + .Repeat( + static s => + { + if (s.CurrentIndex < s.FullText.Length) + { + s.Builder.Append(s.FullText[s.CurrentIndex++]); + s.OnUpdate?.Invoke(s.Builder.ToString()); + } + }, + state, + count: content.Length, + interval: charDelay) + .ExecuteAsync(); + + sb.Clear(); + JObjectPool.Shared().Return(sb); + + state.Reset(); + JObjectPool.Shared().Return(state); +} +``` + +## Best Practices + +1. **Always use ExecuteAsync()** - Non-blocking, proper frame delays +2. **Always use `using var`** - Ensures JAction returns to pool +3. **Use static lambdas + state** - Avoids closure allocations +4. **State must be reference type** - Value types get boxed +5. **Pool state objects** - Use JObjectPool.Shared() for state classes +6. **Reset state on return** - Clear all fields to prevent data leaks +7. **Use `in` parameters** - Avoid struct copies for Vector3, etc. +8. **Prewarm pools during loading** - Avoid runtime allocations +9. **Set timeouts** - Prevent infinite waits in production +10. **Cancel actions on disable** - Avoid callbacks on destroyed objects diff --git a/.claude-plugin/skills/jaction/SKILL.md b/.claude-plugin/skills/jaction/SKILL.md index cc228222..1aad02a3 100644 --- a/.claude-plugin/skills/jaction/SKILL.md +++ b/.claude-plugin/skills/jaction/SKILL.md @@ -14,6 +14,11 @@ Fluent API for composing complex action sequences in Unity with automatic object - Game timers and scheduled events - Zero-GC async operations +## Properties +- `.Executing` - Returns true if currently executing +- `.Cancelled` - Returns true if execution was cancelled +- `.IsParallel` - Returns true if parallel mode enabled + ## Core API ### Execution Methods @@ -22,24 +27,30 @@ Fluent API for composing complex action sequences in Unity with automatic object ### Action Execution - `.Do(Action)` - Execute synchronous action -- `.Do(Action, T state)` - Execute with state parameter (zero-allocation for reference types) +- `.Do(Action, TState)` - Execute with state (zero-alloc for reference types) - `.Do(Func)` - Execute async action -- `.Do(Func, T state)` - Async with state +- `.Do(Func, TState)` - Async with state ### Delays & Waits - `.Delay(float seconds)` - Wait specified seconds - `.DelayFrame(int frames)` - Wait specified frame count -- `.WaitUntil(Func)` - Wait until condition true -- `.WaitWhile(Func)` - Wait while condition true +- `.WaitUntil(Func, frequency, timeout)` - Wait until condition true +- `.WaitUntil(Func, TState, frequency, timeout)` - With state +- `.WaitWhile(Func, frequency, timeout)` - Wait while condition true +- `.WaitWhile(Func, TState, frequency, timeout)` - With state ### Loops -- `.Repeat(Action, int count, float interval)` - Repeat N times with interval -- `.RepeatWhile(Action, Func, float frequency, float timeout)` - Repeat while condition -- `.RepeatUntil(Action, Func, float frequency, float timeout)` - Repeat until condition +- `.Repeat(Action, count, interval)` - Repeat N times +- `.Repeat(Action, TState, count, interval)` - With state +- `.RepeatWhile(Action, Func, frequency, timeout)` - Repeat while condition +- `.RepeatWhile(Action, Func, TState, frequency, timeout)` - With state +- `.RepeatUntil(Action, Func, frequency, timeout)` - Repeat until condition +- `.RepeatUntil(Action, Func, TState, frequency, timeout)` - With state ### Configuration -- `.Parallel()` - Enable concurrent action execution +- `.Parallel()` - Enable concurrent execution - `.OnCancel(Action)` - Register cancellation callback +- `.OnCancel(Action, TState)` - With state ### Lifecycle - `.Cancel()` - Stop execution @@ -108,6 +119,147 @@ var task = action.ExecuteAsync(); action.Cancel(); ``` +## Game Patterns + +All patterns use `ExecuteAsync()` for non-blocking execution. + +### Cooldown Timer (Zero-GC) +```csharp +public sealed class AbilityState +{ + public bool CanUse = true; +} + +public class AbilitySystem +{ + private readonly AbilityState _state = new(); + private readonly float _cooldown; + + public async UniTaskVoid TryUseAbility() + { + if (!_state.CanUse) return; + _state.CanUse = false; + + PerformAbility(); + + // Zero-GC: static lambda + reference type state + using var action = await JAction.Create() + .Delay(_cooldown) + .Do(static s => s.CanUse = true, _state) + .ExecuteAsync(); + } +} +``` + +### Damage Over Time (Zero-GC) +```csharp +public sealed class DoTState +{ + public IDamageable Target; + public float DamagePerTick; +} + +public static async UniTaskVoid ApplyDoT(IDamageable target, float damage, int ticks, float interval) +{ + // Rent state from pool to avoid allocation + var state = JObjectPool.Shared().Rent(); + state.Target = target; + state.DamagePerTick = damage; + + using var action = await JAction.Create() + .Repeat( + static s => s.Target?.TakeDamage(s.DamagePerTick), + state, + count: ticks, + interval: interval) + .ExecuteAsync(); + + // Return state to pool + state.Target = null; + JObjectPool.Shared().Return(state); +} +``` + +### Wave Spawner (Async) +```csharp +// Async methods cannot use ReadOnlySpan (ref struct), use array instead +public async UniTask RunWaves(WaveConfig[] waves) +{ + foreach (var wave in waves) + { + using var action = await JAction.Create() + .Do(() => UI.ShowWaveStart(wave.Number)) + .Delay(2f) + .Do(() => SpawnWave(wave)) + .WaitUntil(() => ActiveEnemyCount == 0, timeout: 120f) + .Delay(wave.DelayAfter) + .ExecuteAsync(); + + if (action.Cancelled) break; + } +} + +// Sync methods can use ReadOnlySpan for zero-allocation iteration +public void RunWavesSync(ReadOnlySpan waves) +{ + foreach (ref readonly var wave in waves) + { + using var action = JAction.Create() + .Do(() => UI.ShowWaveStart(wave.Number)) + .Delay(2f) + .Do(() => SpawnWave(wave)) + .WaitUntil(() => ActiveEnemyCount == 0, timeout: 120f) + .Delay(wave.DelayAfter); + action.Execute(); + + if (action.Cancelled) break; + } +} +``` + +### Health Regeneration (Zero-GC) +```csharp +public sealed class RegenState +{ + public float Health; + public float MaxHealth; + public float HpPerTick; +} + +public static async UniTaskVoid StartRegen(RegenState state) +{ + using var action = await JAction.Create() + .RepeatWhile( + static s => s.Health = MathF.Min(s.Health + s.HpPerTick, s.MaxHealth), + static s => s.Health < s.MaxHealth, + state, + frequency: 0.1f) + .ExecuteAsync(); +} +``` + +## Troubleshooting + +### Nothing Happens +- **Forgot ExecuteAsync:** Must call `.ExecuteAsync()` at the end +- **Already disposed:** Don't reuse a JAction after Dispose() + +### Memory Leak +- **Missing `using var`:** Always use `using var action = await ...ExecuteAsync()` +- **Infinite loop:** Set timeouts on WaitUntil/WaitWhile in production + +### Frame Drops +- **Using Execute():** Switch to ExecuteAsync() for non-blocking +- **Heavy callbacks:** Keep .Do() callbacks lightweight + +### Unexpected Behavior +- **Value type state:** State overloads box value types; wrap in reference type +- **Check Cancelled:** After timeout, check `action.Cancelled` before continuing + +### GC Allocations +- **Closures:** Use static lambdas with state parameters +- **State must be reference type:** Value types get boxed + ## Common Mistakes - NOT using `using var` after ExecuteAsync (memory leak, never returns to pool) - Using Execute() in production (blocks main thread, causes frame drops) diff --git a/.claude-plugin/skills/jobjectpool/SKILL.md b/.claude-plugin/skills/jobjectpool/SKILL.md index 173ec7b3..f5bc224a 100644 --- a/.claude-plugin/skills/jobjectpool/SKILL.md +++ b/.claude-plugin/skills/jobjectpool/SKILL.md @@ -76,6 +76,132 @@ sb.Clear(); // Clean up before returning JObjectPool.Shared().Return(sb); ``` +## Game Patterns (Zero-GC) + +### Bullet Pool with Struct Config +```csharp +public sealed class Bullet +{ + public Vector3 Position; + public Vector3 Velocity; + public float Damage; + public float Lifetime; + + public void Reset() + { + Position = default; + Velocity = default; + Damage = 0; + Lifetime = 0; + } +} + +public sealed class BulletManager +{ + private readonly JObjectPool _pool = new( + maxSize: 200, + onReturn: static b => b.Reset()); + + public void Initialize() => _pool.Prewarm(100); + + public Bullet Fire(in Vector3 pos, in Vector3 dir, float speed, float damage) + { + var bullet = _pool.Rent(); + bullet.Position = pos; + bullet.Velocity = dir * speed; + bullet.Damage = damage; + return bullet; + } + + public void Return(Bullet b) => _pool.Return(b); +} +``` + +### Enemy Spawner (Zero-GC State) +```csharp +public sealed class Enemy : IPoolable +{ + public float Health { get; set; } + public Vector3 Position { get; set; } + public event Action OnDeath; + + public void OnSpawn() + { + Health = 100f; + } + + public void OnDespawn() + { + OnDeath = null; // Clear delegates to prevent leaks + } +} + +public sealed class EnemySpawner +{ + private readonly JObjectPool _pool; + + public EnemySpawner(int maxSize = 50) + { + _pool = new( + maxSize, + onRent: static e => e.OnSpawn(), + onReturn: static e => e.OnDespawn()); + } + + public Enemy Spawn(in Vector3 position) + { + var enemy = _pool.Rent(); + enemy.Position = position; + return enemy; + } + + public void Despawn(Enemy e) => _pool.Return(e); +} +``` + +### Temporary Collection (Zero-GC in Update) +```csharp +// Use in hot paths to avoid List allocations +public void ProcessNearbyEnemies(in Vector3 center, float radius) +{ + var list = JObjectPool.Shared>().Rent(); + try + { + FindEnemiesNonAlloc(center, radius, list); + foreach (var enemy in list) + { + ProcessEnemy(enemy); + } + } + finally + { + list.Clear(); + JObjectPool.Shared>().Return(list); + } +} +``` + +### StringBuilder Pool (Zero-GC String Building) +```csharp +public static string FormatDamage(float damage, string targetName) +{ + var sb = JObjectPool.Shared().Rent(); + try + { + sb.Append(targetName); + sb.Append(" took "); + sb.Append(damage.ToString("F1")); + sb.Append(" damage"); + return sb.ToString(); + } + finally + { + sb.Clear(); + JObjectPool.Shared().Return(sb); + } +} +``` + ## Best Practices 1. **Pre-allocate during loading** to prevent in-game allocation spikes 2. **Reset state on return** to prevent data leaks between reuses @@ -83,6 +209,28 @@ JObjectPool.Shared().Return(sb); 4. **Use shared pools** for simple objects without custom callbacks 5. **Monitor pool Count** to optimize sizing +## Troubleshooting + +### Objects Not Reused +- **Forgot to Return:** Always call `pool.Return(obj)` when done +- **Pool at capacity:** Increase maxSize if concurrent usage exceeds limit +- **Returning null:** Null values are silently ignored + +### Stale Data / Memory Leaks +- **Not resetting state:** Clear all fields in onReturn callback +- **Event delegates:** Unsubscribe all events in onReturn to prevent leaks +- **References to disposed objects:** Null out references in onReturn + +### Performance Issues +- **Not pre-warming:** Call `Prewarm()` during loading screens +- **maxSize too low:** Causes allocations when pool empties +- **maxSize too high:** Wastes memory + +### Thread Safety +- JObjectPool IS thread-safe (lock-free CAS) +- Safe to Rent/Return from any thread +- onRent/onReturn run on calling thread + ## Common Mistakes - Returning null to pool (ignored, but wasteful) - Not clearing object state on return (causes bugs from stale data)