用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/curtbushko/nixos-config --skill godot-csharp命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
Comprehensive Nix best practices for flakes, NixOS, home-manager, nix-darwin, devshells, package management, and system configuration. Use this skill when working with Nix flakes, NixOS configuration, home-manager dotfiles, nix-darwin macOS setup, development shells (nix develop/nix-shell), package searching, testing, overlays, or any Nix-related tasks including debugging derivations and garbage collection.
Implements phases defined in .phases/ directory. Reads current phase from index.yaml, breaks down into tasks, then executes Builder -> Reviewer for each task.
Implements phases defined in .phases/ directory for Node.js projects. Reads current phase from index.yaml, breaks down into tasks, then executes Builder -> Reviewer for each task.
正在显示 SKILL.md
基于 SOC 职业分类
| name | godot-csharp |
| description | Godot C# Game Development Skill |
You are an expert Godot C# game developer who follows Test-Driven Development (TDD) and Behavior-Driven Development (BDD) principles using modern .NET practices.
dotnet build succeeds with no errorsALWAYS follow the TDD cycle when implementing new functionality:
RED: Write a failing test first
GREEN: Write minimal code to make the test pass
REFACTOR: Improve the code while keeping tests green
project/
├── addons/ # Godot addons (GdUnit4, etc.)
├── assets/ # Raw assets
│ ├── sprites/
│ ├── audio/
│ │ ├── music/
│ │ └── sfx/
│ ├── fonts/
│ └── shaders/
├── resources/ # .tres resource files
│ ├── themes/
│ ├── materials/
│ └── data/
├── scenes/ # .tscn scene files
│ ├── actors/
│ ├── levels/
│ ├── ui/
│ └── components/
├── scripts/ # C# source files
│ ├── Autoloads/ # Singleton scripts
│ ├── Components/ # Reusable components
│ ├── Entities/ # Game entities (Player, Enemy, etc.)
│ ├── Resources/ # Custom Resource classes
│ ├── States/ # State machine states
│ ├── Systems/ # Game systems (Input, Audio, etc.)
│ └── Utils/ # Utility classes
├── tests/ # Test projects
│ ├── Unit/ # Unit tests (xUnit/NUnit/GdUnit4)
│ ├── Integration/ # Integration tests
│ ├── Features/ # BDD feature files (.feature)
│ └── StepDefinitions/ # Reqnroll step definitions
├── project.godot
├── MyGame.csproj
├── MyGame.Tests.csproj # Test project
└── MyGame.sln
PascalCase.tscn (e.g., PlayerCharacter.tscn)PascalCase.cs (e.g., PlayerController.cs)PascalCase.tres (e.g., PlayerStats.tres)<ClassName>Tests.cs (e.g., PlayerControllerTests.cs)<Feature>.feature (e.g., PlayerMovement.feature)<Feature>Steps.cs (e.g., PlayerMovementSteps.cs)GdUnit4 is an embedded unit testing framework for Godot 4 supporting C#.
Installation:
<!-- In your .csproj -->
<ItemGroup>
<PackageReference Include="gdUnit4.api" Version="4.*" />
</ItemGroup>
Test Structure:
using GdUnit4;
using static GdUnit4.Assertions;
namespace MyGame.Tests;
[TestSuite]
public class HealthComponentTests
{
private HealthComponent _healthComponent = null!;
[Before]
public void Setup()
{
_healthComponent = new HealthComponent();
}
[After]
public void Teardown()
{
_healthComponent?.Free();
}
[TestCase]
public void InitialHealth_ShouldEqualMaxHealth()
{
// Arrange
_healthComponent.MaxHealth = 100;
// Act
_healthComponent._Ready();
// Assert
AssertThat(_healthComponent.Health).IsEqual(100);
}
[TestCase]
public void TakeDamage_ShouldReduceHealth()
{
// Arrange
_healthComponent.MaxHealth = 100;
_healthComponent._Ready();
// Act
_healthComponent.TakeDamage(25);
// Assert
AssertThat(_healthComponent.Health).IsEqual(75);
}
[TestCase]
public void ()
{
_healthComponent.MaxHealth = ;
_healthComponent._Ready();
monitor = _healthComponent.MonitorSignal();
_healthComponent.TakeDamage();
AssertThat(monitor).IsEmitted();
}
}
Use standard .NET testing frameworks for non-Godot-specific logic.
Installation:
<!-- MyGame.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="NSubstitute" Version="5.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyGame.csproj" />
</ItemGroup>
</Project>
Test Structure:
using FluentAssertions;
using NSubstitute;
using Xunit;
namespace MyGame.Tests;
public class DamageCalculatorTests
{
[Fact]
public void CalculateDamage_WithNoArmor_ReturnsFullDamage()
{
// Arrange
var calculator = new DamageCalculator();
var baseDamage = 100;
var armor = 0;
// Act
var result = calculator.Calculate(baseDamage, armor);
// Assert
result.Should().Be(100);
}
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 50, 50)]
[InlineData(100, 100, 1)] // Minimum 1 damage
public void CalculateDamage_WithArmor_ReducesDamageCorrectly(
int baseDamage, int armor, int expected)
{
// Arrange
var calculator = new DamageCalculator();
// Act
var result = calculator.Calculate(baseDamage, armor);
// Assert
result.Should().Be(expected);
}
}
For tests that need to run within Godot's runtime.
Installation:
<ItemGroup>
<PackageReference Include="Chickensoft.GoDotTest" Version="2.*" />
</ItemGroup>
Test Structure:
using Chickensoft.GoDotTest;
using Godot;
using Shouldly;
namespace MyGame.Tests;
public class PlayerTests : TestClass
{
private Player _player = null!;
public PlayerTests(Node testScene) : base(testScene) { }
[Setup]
public void Setup()
{
_player = new Player();
TestScene.AddChild(_player);
}
[Cleanup]
public void Cleanup()
{
_player.QueueFree();
}
[Test]
public void Player_ShouldMoveRight_WhenInputPressed()
{
// Arrange
var initialPosition = _player.Position;
Input.ActionPress("move_right");
// Act
_player._PhysicsProcess(0.016); // Simulate one frame
// Assert
_player.Position.X.ShouldBeGreaterThan(initialPosition.X);
// Cleanup
Input.ActionRelease("move_right");
}
}
<!-- MyGame.Tests.csproj -->
<ItemGroup>
<PackageReference Include="Reqnroll" Version="2.*" />
<PackageReference Include="Reqnroll.xUnit" Version="2.*" />
</ItemGroup>
# tests/Features/PlayerHealth.feature
Feature: Player Health System
As a player
I want to have a health system
So that I can take damage and die
Background:
Given a player with 100 max health
Scenario: Player takes damage
When the player takes 25 damage
Then the player health should be 75
Scenario: Player cannot have negative health
When the player takes 9999 damage
Then the player health should be 0
And the player should be dead
Scenario: Player heals after taking damage
Given the player has taken 50 damage
When the player heals 30 health
Then the player health should be 80
Scenario Outline: Damage calculation with armor
Given the player has <armor> armor
When the player takes <damage> raw damage
Then the player should receive <actual> damage
Examples:
| armor | damage | actual |
| 0 | 100 | 100 |
| 50 | 100 | 50 |
| 100 | 100 | 1 |
// tests/StepDefinitions/PlayerHealthSteps.cs
using Reqnroll;
using FluentAssertions;
namespace MyGame.Tests.StepDefinitions;
[Binding]
public class PlayerHealthSteps
{
private HealthComponent _healthComponent = null!;
private int _lastDamageReceived;
[Given(@"a player with (\d+) max health")]
public void GivenAPlayerWithMaxHealth(int maxHealth)
{
_healthComponent = new HealthComponent
{
MaxHealth = maxHealth
};
_healthComponent._Ready();
}
[Given(@"the player has (\d+) armor")]
public void GivenThePlayerHasArmor(int armor)
{
_healthComponent.Armor = armor;
}
[Given(@"the player has taken (\d+) damage")]
public void GivenThePlayerHasTakenDamage(int damage)
{
_healthComponent.TakeDamage(damage);
}
[When(@"the player takes (\d+) damage")]
public void WhenThePlayerTakesDamage(int damage)
{
_healthComponent.TakeDamage(damage);
}
[When(@)]
{
_lastDamageReceived = _healthComponent.CalculateDamage(damage);
_healthComponent.TakeDamage(_lastDamageReceived);
}
[]
{
_healthComponent.Heal(amount);
}
[]
{
_healthComponent.Health.Should().Be(expected);
}
[]
{
_healthComponent.IsDead.Should().BeTrue();
}
[]
{
_lastDamageReceived.Should().Be(expected);
}
}
// tests/StepDefinitions/Hooks.cs
using Reqnroll;
namespace MyGame.Tests.StepDefinitions;
[Binding]
public class Hooks
{
[BeforeScenario]
public void BeforeScenario(ScenarioContext context)
{
// Setup before each scenario
}
[AfterScenario]
public void AfterScenario(ScenarioContext context)
{
// Cleanup after each scenario
}
[BeforeFeature]
public static void BeforeFeature(FeatureContext context)
{
// Setup before each feature
}
[AfterFeature]
public static void AfterFeature(FeatureContext context)
{
// Cleanup after each feature
}
}
public partial class Player : CharacterBody2D
{
// Use [Export] for editor-configurable references
[Export] public HealthComponent? HealthComponent { get; set; }
[Export] public Sprite2D? Sprite { get; set; }
// Cache node references in _Ready
private AnimationPlayer _animationPlayer = null!;
private StateMachine _stateMachine = null!;
public override void _Ready()
{
_animationPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
_stateMachine = GetNode<StateMachine>("StateMachine");
// Null check exports
if (HealthComponent is null)
GD.PushError("HealthComponent not assigned!");
}
}
public partial class HealthComponent : Node
{
// Signal definitions using delegates
[Signal]
public delegate void HealthChangedEventHandler(int newHealth, int maxHealth);
[Signal]
public delegate void DamagedEventHandler(int amount, Node? source);
[Signal]
public delegate void DiedEventHandler();
private int _health;
public int Health
{
get => _health;
private set
{
var oldHealth = _health;
_health = Math.Clamp(value, 0, MaxHealth);
if (_health != oldHealth)
{
EmitSignal(SignalName.HealthChanged, _health, MaxHealth);
}
if (_health <= 0 && oldHealth > 0)
{
EmitSignal(SignalName.Died);
}
}
}
[Export]
public int MaxHealth { get; set; } = 100;
IsDead => Health <= ;
_Ready()
{
Health = MaxHealth;
}
{
(IsDead) ;
Health -= amount;
EmitSignal(SignalName.Damaged, amount, source);
}
{
(IsDead) ;
Health += amount;
}
}
public override void _Ready()
{
// Connect to signals using C# events
HealthComponent.HealthChanged += OnHealthChanged;
HealthComponent.Died += OnDied;
// Or using Godot's Connect method
HealthComponent.Connect(
HealthComponent.SignalName.HealthChanged,
Callable.From<int, int>(OnHealthChanged)
);
}
public override void _ExitTree()
{
// Disconnect signals
HealthComponent.HealthChanged -= OnHealthChanged;
HealthComponent.Died -= OnDied;
}
private void OnHealthChanged(int newHealth, int maxHealth)
{
GD.Print($"Health: {newHealth}/{maxHealth}");
}
private void OnDied()
{
GD.Print("Player died!");
}
public async Task PlayAttackAnimation()
{
_animationPlayer.Play("attack");
// Wait for animation to finish
await ToSignal(_animationPlayer, AnimationPlayer.SignalName.AnimationFinished);
_animationPlayer.Play("idle");
}
public async Task WaitForSeconds(double seconds)
{
await ToSignal(
GetTree().CreateTimer(seconds),
SceneTreeTimer.SignalName.Timeout
);
}
public async Task FadeOut(float duration)
{
var tween = CreateTween();
tween.TweenProperty(this, "modulate:a", 0f, duration);
await ToSignal(tween, Tween.SignalName.Finished);
}
public partial class Enemy : CharacterBody2D
{
// Use [Export] for editor-visible properties
[Export]
public float MoveSpeed { get; set; } = 100f;
[Export]
public int Damage { get; set; } = 10;
// Private fields with underscore prefix
private Vector2 _targetPosition;
private bool _isChasing;
// Read-only computed properties
public bool IsMoving => Velocity.LengthSquared() > 0.01f;
public float DistanceToTarget => Position.DistanceTo(_targetPosition);
}
// States/IState.cs
public interface IState
{
void Enter();
void Exit();
void Update(double delta);
void PhysicsUpdate(double delta);
void HandleInput(InputEvent @event);
}
// States/StateMachine.cs
public partial class StateMachine : Node
{
[Signal]
public delegate void StateChangedEventHandler(IState? oldState, IState newState);
[Export]
public NodePath? InitialStatePath { get; set; }
public IState? CurrentState { get; private set; }
private Dictionary<string, IState> _states = new();
public override void _Ready()
{
foreach (var child ())
{
(child IState state)
{
_states[child.Name.ToString().ToLower()] = state;
}
}
(InitialStatePath )
{
initialState = GetNode<Node>(InitialStatePath);
(initialState IState state)
{
TransitionTo(initialState.Name);
}
}
}
_Process( delta)
{
CurrentState?.Update(delta);
}
_PhysicsProcess( delta)
{
CurrentState?.PhysicsUpdate(delta);
}
_UnhandledInput(InputEvent @event)
{
CurrentState?.HandleInput(@event);
}
{
key = stateName.ToLower();
(!_states.TryGetValue(key, newState))
{
GD.PushError();
;
}
oldState = CurrentState;
CurrentState?.Exit();
CurrentState = newState;
CurrentState.Enter();
EmitSignal(SignalName.StateChanged, oldState, newState);
}
}
: ,
{
[]
CharacterBody2D? Actor { ; ; }
[]
AnimatedSprite2D? AnimatedSprite { ; ; }
StateMachine _stateMachine = !;
_Ready()
{
_stateMachine = GetParent<StateMachine>();
}
{
AnimatedSprite?.Play();
}
{ }
{ }
{
direction = Input.GetAxis(, );
(Math.Abs(direction) > )
{
_stateMachine.TransitionTo();
}
(Input.IsActionJustPressed())
{
_stateMachine.TransitionTo();
}
}
{ }
}
// Components/HealthComponent.cs
public partial class HealthComponent : Node
{
[Signal]
public delegate void HealthChangedEventHandler(int newHealth, int maxHealth);
[Signal]
public delegate void DiedEventHandler();
[Export]
public int MaxHealth { get; set; } = 100;
[Export]
public float InvincibilityDuration { get; set; } = 0f;
public int Health { get; private set; }
public bool IsInvincible { get; private set; }
public bool IsDead => Health <= 0;
public override void _Ready()
{
Health = MaxHealth;
}
public void TakeDamage(int amount, Node? source = )
{
(IsInvincible || IsDead) ;
Health = Math.Max(, Health - amount);
EmitSignal(SignalName.HealthChanged, Health, MaxHealth);
(IsDead)
{
EmitSignal(SignalName.Died);
}
(InvincibilityDuration > )
{
StartInvincibility();
}
}
{
(IsDead) ;
Health = Math.Min(MaxHealth, Health + amount);
EmitSignal(SignalName.HealthChanged, Health, MaxHealth);
}
{
IsInvincible = ;
ToSignal(
GetTree().CreateTimer(InvincibilityDuration),
SceneTreeTimer.SignalName.Timeout
);
IsInvincible = ;
}
}
:
{
[]
;
[]
Damage { ; ; } = ;
[]
KnockbackForce { ; ; } = ;
_Ready()
{
AreaEntered += OnAreaEntered;
}
{
(area HurtboxComponent hurtbox)
{
hurtbox.ReceiveHit();
EmitSignal(SignalName.Hit, hurtbox);
}
}
}
:
{
[]
;
[]
HealthComponent? HealthComponent { ; ; }
{
HealthComponent?.TakeDamage(hitbox.Damage, hitbox.Owner);
EmitSignal(SignalName.Hurt, hitbox);
}
}
// Autoloads/Events.cs
public partial class Events : Node
{
// Singleton instance
public static Events Instance { get; private set; } = null!;
// Player events
[Signal]
public delegate void PlayerSpawnedEventHandler(Node player);
[Signal]
public delegate void PlayerDiedEventHandler(Node player);
[Signal]
public delegate void PlayerHealthChangedEventHandler(int health, int maxHealth);
// Game events
[Signal]
public delegate void LevelStartedEventHandler(string levelName);
[Signal]
public delegate void LevelCompletedEventHandler(string levelName);
[Signal]
;
[]
;
[]
;
_Ready()
{
Instance = ;
}
}
_Ready()
{
Events.Instance.EmitSignal(Events.SignalName.PlayerSpawned, );
}
_Ready()
{
Events.Instance.PlayerHealthChanged += OnPlayerHealthChanged;
Events.Instance.PlayerDied += OnPlayerDied;
}
_ExitTree()
{
Events.Instance.PlayerHealthChanged -= OnPlayerHealthChanged;
Events.Instance.PlayerDied -= OnPlayerDied;
}
// Commands/ICommand.cs
public interface ICommand
{
void Execute();
void Undo();
}
// Commands/MoveCommand.cs
public class MoveCommand : ICommand
{
private readonly Node2D _actor;
private readonly Vector2 _direction;
private readonly float _distance;
public MoveCommand(Node2D actor, Vector2 direction, float distance)
{
_actor = actor;
_direction = direction;
_distance = distance;
}
public void Execute()
{
_actor.Position += _direction * _distance;
}
public void Undo()
{
_actor.Position -= _direction * _distance;
}
}
// Commands/CommandHistory.cs
public class CommandHistory
{
private readonly List<ICommand> _history = new();
private int _currentIndex = -1;
public void Execute(ICommand command)
{
(_currentIndex < _history.Count - )
{
_history.RemoveRange(_currentIndex + , _history.Count - _currentIndex - );
}
command.Execute();
_history.Add(command);
_currentIndex++;
}
{
(_currentIndex < ) ;
_history[_currentIndex].Undo();
_currentIndex--;
;
}
{
(_currentIndex >= _history.Count - ) ;
_currentIndex++;
_history[_currentIndex].Execute();
;
}
}
// Systems/ObjectPool.cs
public partial class ObjectPool<T> : Node where T : Node
{
private readonly PackedScene _scene;
private readonly Queue<T> _available = new();
private readonly HashSet<T> _inUse = new();
private readonly int _initialSize;
private readonly bool _canGrow;
public ObjectPool(PackedScene scene, int initialSize = 20, bool canGrow = true)
{
_scene = scene;
_initialSize = initialSize;
_canGrow = canGrow;
}
public override void _Ready()
{
for (int i = 0; i < _initialSize; i++)
{
CreateInstance();
}
}
private T CreateInstance()
{
var instance = _scene.Instantiate<T>();
instance.SetProcess(false);
instance.SetPhysicsProcess(false);
if (instance is Node2D node2D)
node2D.Visible = false;
else (instance Node3D node3D)
node3D.Visible = ;
AddChild(instance);
_available.Enqueue(instance);
instance;
}
T? Acquire()
{
T instance;
(_available.Count == )
{
(_canGrow)
{
instance = CreateInstance();
_available.Dequeue();
}
{
GD.PushWarning();
;
}
}
{
instance = _available.Dequeue();
}
_inUse.Add(instance);
instance.SetProcess();
instance.SetPhysicsProcess();
(instance Node2D node2D)
node2D.Visible = ;
(instance Node3D node3D)
node3D.Visible = ;
(instance IPoolable poolable)
poolable.OnAcquire();
instance;
}
{
(!_inUse.Contains(instance))
{
GD.PushWarning();
;
}
(instance IPoolable poolable)
poolable.OnRelease();
instance.SetProcess();
instance.SetPhysicsProcess();
(instance Node2D node2D)
node2D.Visible = ;
(instance Node3D node3D)
node3D.Visible = ;
_inUse.Remove(instance);
_available.Enqueue(instance);
}
}
{
;
;
}
# Run all tests from command line
godot --headless -s addons/gdUnit4/bin/GdUnitCmdTool.gd --run-all
# Run specific test suite
godot --headless -s addons/gdUnit4/bin/GdUnitCmdTool.gd --run=res://tests/Unit/HealthComponentTests.cs
# Run all tests
dotnet test
# Run with verbosity
dotnet test --verbosity normal
# Run specific test class
dotnet test --filter "FullyQualifiedName~HealthComponentTests"
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"
# Run BDD tests (they use the underlying test framework)
dotnet test --filter "Category=BDD"
# Generate living documentation
dotnet reqnroll livingdoc test-assembly MyGame.Tests.dll -t TestExecution.json
dotnet build)| Pattern | Use When |
|---|---|
| State Machine | Complex behavior with distinct states |
| Component | Reusable behavior across actors |
| Observer (Events) | Decoupled communication |
| Command | Undo/redo, input buffering, replays |
| Object Pool | Frequently spawned/despawned objects |
| Do | Don't |
|---|---|
| Use nullable reference types | Ignore null warnings |
| Cache node references | Call GetNode() every frame |
| Use signals for communication | Direct method calls across scenes |
| Use Resources for data | Hardcode game data in scripts |
| Write tests first (TDD) | Write tests after (or never) |
| Use [Export] for editor values | Hardcode values |
| Properly disconnect signals | Leave signal connections |
references/testing-patterns.md - GdUnit4, xUnit, Reqnroll patternsreferences/csharp-patterns.md - C# design patterns and SOLID principlesreferences/godot-csharp-api.md - Godot C# API reference and differences