home / skills / chen19007 / my_skills / godot-design

godot-design skill

/godot-design

This skill provides design guidelines for Godot GDScript interfaces focusing on testability, maintainability, and TDD-friendly patterns.

npx playbooks add skill chen19007/my_skills --skill godot-design

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.9 KB
---
name: godot-design
description: Godot GDScript 接口设计规范与指导,强调 TDD 友好、可测试、可维护的设计原则。
---

# Godot 接口设计 Skill

## 概述

本 Skill 提供 Godot GDScript 接口设计的规范和指导,确保代码符合 TDD 友好、可测试、可维护的设计原则。

## 适用场景

- 设计新的游戏逻辑类
- 定义模块间接口
- 编写供测试使用的 API
- 重构现有代码结构

## Godot 类系统基础规则

### 脚本 ≠ 类

在 Godot 中,脚本不是真正的类,而是附加到引擎内置类的资源:

```gdscript
# 脚本继承 Node,成为其扩展
class_name Player extends Node

# 也可以不写 extends,隐式继承 RefCounted
class_name MathUtils
```

**关键点**:
- 脚本通过 `class_name` 注册到 ClassDB
- 无 `extends` 时隐式继承 `RefCounted`
- 只能附加到对应类型的节点

### 场景 = 类

场景在 Godot 中扮演类的角色:

```gdscript
# 场景是可复用、可实例化、可继承的节点组
# Player.tscn (场景)
# ├─ Sprite2D (子节点)
# └─ CollisionShape2D (子节点)

# Player.gd (脚本根节点)
class_name Player extends Node2D

@onready var sprite: Sprite2D = $Sprite2D
@onready var collision: CollisionShape2D = $CollisionShape2D
```

**场景定义的内容**:
- 节点组成
- 组织方式
- 初始化顺序
- 信号连接

## 接口设计规则

### 1. 状态查询方法(必备)

测试需要能够验证对象状态,必须提供查询方法:

```gdscript
class_name Player extends Node

var _health: int = 100
var _speed: float = 300.0
var _is_moving: bool = false

# 状态查询方法(供测试断言使用)
func get_health() -> int:
    return _health

func get_speed() -> float:
    return _speed

func is_alive() -> bool:
    return _health > 0

func is_moving() -> bool:
    return _is_moving
```

### 2. 动作方法签名

```gdscript
# 好的例子:有明确的类型注解和返回值
func move_to(target: Vector2) -> void:
    ...

func take_damage(amount: int) -> void:
    ...

func heal(amount: int) -> int:  # 返回实际恢复的血量
    ...

# 避免:缺少类型注解
func move_to(target):  # 错误
    ...

func take_damage(amount: int):  # 缺少返回类型
    ...
```

### 3. 信号定义(解耦)

使用信号进行事件通知,避免直接耦合:

```gdscript
class_name Player extends Node

# 状态变化信号
signal health_changed(new_health: int, old_health: int)
signal died()
signal revived()

# 位置变化信号
signal position_changed(new_position: Vector2)

# 使用示例
func take_damage(amount: int) -> void:
    var old_health = _health
    _health = max(0, _health - amount)
    health_changed.emit(_health, old_health)

    if _health == 0:
        died.emit()
```

### 4. 错误处理

```gdscript
class_name FileManager extends Node

# 使用 Error 枚举作为返回值
func load_file(path: String) -> Error:
    var file = FileAccess.open(path, FileAccess.READ)
    if not file:
        return FileAccess.get_open_error()
    return OK

# 使用 Result 类型模式
func save_data(data: Dictionary) -> Variant:
    # 成功返回数据,失败返回 Error 信息
    if not is_valid_path(data.path):
        return error("Invalid path")
    ...
```

### 5. 异步操作

```gdscript
class_name AsyncLoader extends Node

# 使用 async/await + Signal
signal loading_completed(resource: Resource)
signal loading_progress(progress: float)

func load_resource(path: String) -> void:
    var loader = ResourceLoader.load_threaded_request(path)
    while true:
        var status = loader.poll_status()
        if status == ResourceLoader.STATUS_LOADED:
            var resource = loader.get_resource()
            loading_completed.emit(resource)
            break
        elif status == ResourceLoader.STATUS_IN_PROGRESS:
            loading_progress.emit(loader.get_progress())
            await get_tree().process_frame
        else:
            break

# 调用示例
func _ready() -> void:
    load_resource("res://player.tscn")
    await loading_completed
```

## 设计检查清单

### 单一职责原则 (SRP)

```gdscript
# 好的例子:单一职责
class_name PlayerHealth extends Node

signal health_changed(new_health: int)

var _health: int = 100

func take_damage(amount: int) -> void:
    ...

func heal(amount: int) -> void:
    ...

# 避免:职责过多
class_name Player extends Node:
    # 血量管理
    var _health: int = 100
    # 移动控制
    var _velocity: Vector2
    # 动画管理
    var _animation: AnimationPlayer
    # 音效管理
    var _audio: AudioStreamPlayer
    # 太多职责!
```

### 类型注解完整性

```gdscript
# 变量类型注解
var health: int = 100
var speed: float = 300.0
var target: Vector2 = Vector2.ZERO

# 函数参数和返回值
func move_to(target: Vector2) -> void:
    ...

func get_damage_multiplier(attack_type: String) -> float:
    ...

# 导出变量
@export var max_health: int = 100
@export var move_speed: float = 300.0
```

### 可测试性

```gdscript
# 好的例子:依赖注入,易于测试
class_name DamageCalculator extends RefCounted

var _base_damage: float

func _init(base_damage: float = 10.0) -> void:
    _base_damage = base_damage

func calculate_damage(base: float, multiplier: float) -> float:
    return base * multiplier * _base_damage

# 测试时可以直接传入不同的 base_damage
```

### 避免 Godot 内置模式

| 场景 | 使用 | 避免 |
|-----|------|------|
| 事件通知 | Signal | 手动回调函数 |
| 节点引用 | @onready | find_node() / get_node() |
| 全局状态 | Autoload | 全局变量 |
| 编辑器配置 | @export | 硬编码 |

## 完整示例:可测试的接口设计

```gdscript
# Player.gd - 符合 TDD 设计的 Player 类
class_name Player extends CharacterBody2D

# 导出配置
@export var max_health: int = 100
@export var move_speed: float = 300.0
@export var invincibility_time: float = 0.5

# 状态变量(私有)
var _health: int = 100
var _is_invincible: bool = false
var _invincibility_timer: float = 0.0

# 状态查询方法(供测试使用)
func get_health() -> int:
    return _health

func get_max_health() -> int:
    return max_health

func is_alive() -> bool:
    return _health > 0

func is_invincible() -> bool:
    return _is_invincible

func get_velocity() -> Vector2:
    return velocity

# 动作方法
func take_damage(amount: int) -> bool:
    if _is_invincible or _health <= 0:
        return false

    _health = max(0, _health - amount)
    _start_invincibility()

    if _health == 0:
        died.emit()
    else:
        health_changed.emit(_health)

    return true

func heal(amount: int) -> int:
    var old_health = _health
    _health = min(max_health, _health + amount)
    health_changed.emit(_health)
    return _health - old_health

func move(direction: Vector2, delta: float) -> void:
    velocity = direction.normalized() * move_speed
    move_and_slide()

# 信号
signal health_changed(new_health: int)
signal died()
signal invincibility_started()
signal invincibility_ended()

# 内部方法
func _start_invincibility() -> void:
    _is_invincible = true
    _invincibility_timer = invincibility_time
    invincibility_started.emit()

func _process(delta: float) -> void:
    if _is_invincible:
        _invincibility_timer -= delta
        if _invincibility_timer <= 0:
            _is_invincible = false
            invincibility_ended.emit()
```

## 常见问题

### Q: 什么时候使用 `class_name`?

A: 当需要满足以下条件时使用:
- 其他脚本需要通过类名引用它
- 需要使用类型检查 `is`
- 需要在编辑器中快速找到它

### Q: 私有属性如何命名?

A: 使用下划线前缀:
```gdscript
var _health: int = 100
var _velocity: Vector2 = Vector2.ZERO
```

### Q: 常量如何命名?

A: 使用全大写加下划线:
```gdscript
const MAX_HEALTH := 100
const MOVE_SPEED := 300.0
const GRAVITY := 980.0
```

### Q: 何时使用 `RefCounted` 而非 `Node`?

A: 当该类不需要场景树功能时:
- 工具函数
- 数据模型
- 计算类

---

## 测试用例设计规范

设计接口时同时规划测试用例,确保接口可测试。

### 1. 等价类划分 (Equivalence Partitioning)

将输入分成有效和无效的等价类:

```gdscript
# 设计时考虑:
# - 有效类:level 1-100
# - 无效类:level < 1, level > 100

class_name Player extends Node

# 对应的测试接口
func set_level(level: int) -> bool:
    if level < 1 or level > 100:
        return false
    _level = level
    return true

func get_level() -> int:
    return _level
```

**对应的测试用例设计**:
```gdscript
# 测试代码
func test_level_valid():
    assert_true(player.set_level(50))

func test_level_below_min():
    assert_false(player.set_level(0))

func test_level_above_max():
    assert_false(player.set_level(101))
```

### 2. 边界值分析 (Boundary Value Analysis)

在边界值处设计测试用例:

```gdscript
# 设计时考虑边界:damage 的 0, 1, 99, 100
class_name DamageCalculator extends RefCounted

const MIN_DAMAGE := 0
const MAX_DAMAGE := 100

func calculate(damage: int) -> int:
    return clamp(damage, MIN_DAMAGE, MAX_DAMAGE)
```

**对应的测试用例设计**:
```gdscript
func test_damage_zero():
    assert_eq(calc.calculate(0), 0)

func test_damage_min():
    assert_eq(calc.calculate(1), 1)

func test_damage_max_minus_one():
    assert_eq(calc.calculate(99), 99)

func test_damage_max():
    assert_eq(calc.calculate(100), 100)
```

### 3. 状态转换测试 (State Transition)

设计状态查询方法支持状态测试:

```gdscript
# 设计时考虑状态:IDLE → WALKING → JUMPING → DEAD
class_name Player extends Node

var _state: String = "IDLE"

func get_state() -> String:
    return _state

func start_walk() -> void:
    _state = "WALKING"

func jump() -> void:
    _state = "JUMPING"

func land() -> void:
    _state = "IDLE"

func take_damage(amount: int) -> void:
    if amount >= _health:
        _state = "DEAD"
```

**对应的测试用例设计**:
```gdscript
func test_idle_to_walking():
    assert_eq(player.get_state(), "IDLE")
    player.start_walk()
    assert_eq(player.get_state(), "WALKING")

func test_any_state_to_dead():
    player.take_damage(1000)
    assert_eq(player.get_state(), "DEAD")
```

### 4. 决策表测试 (Decision Table)

设计接口支持多条件组合测试:

```gdscript
# 设计时考虑:is_critical, has_weapon, target_armor
class_name AttackCalculator extends RefCounted

func calculate_damage(is_critical: bool, has_weapon: bool, has_armor: bool) -> int:
    var base = 10 if has_weapon else 5
    var multiplier = 2.0 if is_critical else 1.0
    var reduction = 0.5 if has_armor else 0.0
    return int(base * multiplier * (1.0 - reduction))
```

**对应的测试用例设计**:
```gdscript
func test_critical_with_weapon_no_armor():
    assert_eq(calc.calculate_damage(true, true, false), 20)

func test_critical_with_weapon_with_armor():
    assert_eq(calc.calculate_damage(true, true, true), 10)
```

### 5. 错误场景设计

设计接口时考虑错误情况:

```gdscript
class_name FileLoader extends Node

func load_file(path: String) -> Variant:
    # 返回值设计:成功返回数据,失败返回 Error
    if not path.begins_with("res://"):
        return error("Invalid path format")

    var file = FileAccess.open(path, FileAccess.READ)
    if not file:
        return FileAccess.get_open_error()

    return file.get_as_text()

func is_valid_path(path: String) -> bool:
    return path.begins_with("res://") and FileAccess.file_exists(path)
```

**对应的测试用例设计**:
```gdscript
func test_invalid_path():
    assert_true(loader.load_file("invalid") is Error)

func test_nonexistent_file():
    var result = loader.load_file("res://nonexistent.gd")
    assert_true(result is Error)
```

### 设计检查清单

设计接口时确保:

- [ ] 定义有效的等价类范围
- [ ] 识别所有边界值
- [ ] 设计状态查询方法
- [ ] 规划状态转换路径
- [ ] 支持多条件组合测试
- [ ] 定义错误返回值类型

Overview

This skill provides compact, practical guidelines for designing Godot GDScript interfaces that are TDD-friendly, testable, and maintainable. It codifies patterns for state queries, action signatures, signals, error handling, async operations, and test-driven design checks. The guidance is oriented toward clear, decoupled APIs and measurable test outcomes.

How this skill works

The skill inspects common Godot design decisions and recommends concrete API patterns: explicit type annotations, query methods for state assertions, well-defined signals for eventing, and error/Result-style returns. It maps those patterns to test strategies such as equivalence classes, boundary values, state-transition tests, and decision-table scenarios. Example code snippets illustrate how to structure scenes, scripts, and RefCounted utilities for easy unit and integration testing.

When to use it

  • Designing new game logic classes or services in Godot
  • Defining or refactoring module-to-module interfaces
  • Preparing code for unit and integration tests under TDD
  • Designing async resource loading and event-driven systems
  • Creating reusable scene-based classes and data models

Best practices

  • Always provide state query methods (getters and boolean checks) to support test assertions
  • Annotate variables, function parameters, and returns with types for clarity and safety
  • Use signals to decouple event notifications instead of direct callbacks
  • Prefer RefCounted for pure logic/data classes and Node for scene/tree-bound behavior
  • Return Error or Result-like values for failure cases and avoid hiding errors
  • Design APIs with dependency injection and small, single-responsibility classes

Example use cases

  • Player class exposing get_health(), is_alive(), and signals for health changes and death
  • DamageCalculator or AttackCalculator implemented as RefCounted for deterministic unit tests
  • AsyncLoader that emits loading_progress and loading_completed signals for integration tests
  • FileLoader that returns Error or data to let tests validate error paths
  • Splitting Player responsibilities into PlayerHealth, Movement, and Animation to simplify SRP-focused tests

FAQ

When should I add class_name to a script?

Add class_name when you need editor discovery, type checks with is, or cross-script references by name.

How should private properties be named?

Prefix private properties with a single underscore, e.g. _health or _velocity, to signal internal state.

When use RefCounted instead of Node?

Use RefCounted for pure logic, calculators, or data models that do not require the scene tree or lifecycle callbacks.