summer-engine 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +18 -10
- package/.codex-plugin/plugin.json +36 -8
- package/.cursor-plugin/plugin.json +19 -11
- package/.opencode/INSTALL.md +69 -0
- package/.opencode/plugins/summer.js +9 -4
- package/CHANGELOG.md +31 -1
- package/README.md +268 -86
- package/dist/bin/summer.js +0 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +189 -0
- package/dist/commands/create.js +55 -17
- package/dist/commands/debug.d.ts +2 -0
- package/dist/commands/debug.js +127 -0
- package/dist/commands/list.js +24 -5
- package/dist/commands/project.d.ts +2 -0
- package/dist/commands/project.js +170 -0
- package/dist/commands/scene.d.ts +2 -0
- package/dist/commands/scene.js +290 -0
- package/dist/lib/banner.js +2 -2
- package/dist/lib/log-filters.d.ts +53 -0
- package/dist/lib/log-filters.js +130 -0
- package/dist/lib/plugin-manifests.test.d.ts +1 -0
- package/dist/lib/plugin-manifests.test.js +134 -0
- package/dist/lib/remote-templates.d.ts +25 -0
- package/dist/lib/remote-templates.js +86 -0
- package/dist/lib/skills-registry.d.ts +33 -1
- package/dist/lib/skills-registry.js +56 -1
- package/dist/lib/telemetry.d.ts +13 -0
- package/dist/lib/telemetry.js +63 -0
- package/dist/lib/with-engine.d.ts +14 -0
- package/dist/lib/with-engine.js +109 -0
- package/dist/mcp/tools/asset-tools.js +12 -13
- package/dist/mcp/tools/debug-tools.js +32 -9
- package/dist/mcp/tools/with-engine.js +4 -0
- package/gemini-extension.json +2 -2
- package/hooks/pre-commit-doctor.sh +0 -0
- package/hooks/run-hook.cmd +0 -0
- package/hooks/session-start.sh +0 -0
- package/marketplace.json +2 -2
- package/package.json +3 -3
- package/skills/README.md +1 -1
- package/skills/_meta/skill-create/SKILL.md +1 -1
- package/skills/_meta/skill-improve/SKILL.md +1 -1
- package/skills/_meta/skill-test/SKILL.md +1 -1
- package/skills/_meta/using-summer/SKILL.md +130 -0
- package/skills/_tests/specs/design-npc.md +43 -12
- package/skills/_tests/specs/fps-controller.md +33 -20
- package/skills/_tests/specs/host-authoritative-state.md +104 -0
- package/skills/_tests/specs/vfx.md +56 -38
- package/skills/ai-and-npcs/design-npc/SKILL.md +264 -122
- package/skills/asset-pipeline/asset-strategy/SKILL.md +1 -1
- package/skills/catalog.yaml +21 -4
- package/skills/character-controllers/fps-controller/SKILL.md +260 -79
- package/skills/debugging/debug/SKILL.md +102 -82
- package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +332 -0
- package/skills/rendering-and-lighting/3d-lighting/SKILL.md +1 -1
- package/skills/scene-and-project/browse-templates/SKILL.md +105 -0
- package/skills/scene-and-project/make-game/SKILL.md +159 -346
- package/skills/scene-and-project/new-project/SKILL.md +106 -0
- package/skills/scene-and-project/scene-composition/SKILL.md +1 -1
- package/skills/scripting-patterns/gdscript-patterns/SKILL.md +1 -1
- package/skills/ui-and-ux/ui-basics/SKILL.md +1 -1
- package/skills/visual-effects/vfx/SKILL.md +233 -134
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: design-npc
|
|
3
|
-
description: Use when the user wants to design an enemy, NPC, boss, companion, civilian, or wave-spawned mob's behavior. Walks perception,
|
|
3
|
+
description: Use when the user wants to design an enemy, NPC, boss, companion, civilian, or wave-spawned mob's behavior. Walks perception, personality knobs, intent layer, action state machine, telegraphs, defeat handling, and group emergence — outputs a state-machine GDScript stub plus the recommended node tree. Trigger on "enemy", "NPC", "boss", "companion", "AI", "behavior", "mob", "perception", "design enemy".
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: [Cursor, Claude Code, Windsurf, Codex]
|
|
6
6
|
category: ai-and-npcs
|
|
@@ -13,9 +13,11 @@ paths: ["**/*.gd", "**/*.tscn"]
|
|
|
13
13
|
|
|
14
14
|
## Overview
|
|
15
15
|
|
|
16
|
-
A good NPC is legible
|
|
16
|
+
A good NPC is legible and *alive*. The player can read its mood, predict its next move, and feel the consequence of theirs — and the four enemies in a patrol squad don't all behave like clones of one designer-authored script. This skill walks the canonical NPC AI pattern from the inside out: what the NPC perceives, what it *wants* (intent), what it *does* (action), what tells signal each state, and how it dies.
|
|
17
17
|
|
|
18
|
-
**Core principle:** behavior is what the player *sees
|
|
18
|
+
**Core principle:** behavior is what the player *sees*. Two layers separate doing from wanting: an action state machine the body runs, and an intent layer above it that reads perception + personality and tells the action SM what to aim at.
|
|
19
|
+
|
|
20
|
+
**Second principle:** identical NPCs are predictable, and predictable is boring. Every spawned instance jitters its personality knobs so a squad of four grunts produces emergent variety with zero designer authoring.
|
|
19
21
|
|
|
20
22
|
## Steps
|
|
21
23
|
|
|
@@ -33,30 +35,29 @@ Open with the question. Wait.
|
|
|
33
35
|
|
|
34
36
|
Each archetype maps to a different complexity budget:
|
|
35
37
|
|
|
36
|
-
| Archetype |
|
|
38
|
+
| Archetype | Action states | Intents | Perception | Personality range |
|
|
37
39
|
|---|---|---|---|---|
|
|
38
|
-
| Basic enemy | 4 (
|
|
39
|
-
| Boss | 6–10 (per phase) | Sight + hearing + scripted |
|
|
40
|
-
| Quest-giver | 2 (idle / talking) |
|
|
41
|
-
| Companion | 5 (follow /
|
|
42
|
-
| Civilian | 3 (wander / panic / dead) |
|
|
43
|
-
| Wave mob | 2 (charge / dead) | Direct line to player | None |
|
|
40
|
+
| Basic enemy | 4 (calm / alert / aggressive / defeated) | 5 (idle / investigate / hunt / kill / retreat) | Sight cone + LOS raycast | Wide |
|
|
41
|
+
| Boss | 6–10 (per phase) | 5–7 incl. `RESCUE_ALLY` | Sight + hearing + scripted | Narrow, biased high |
|
|
42
|
+
| Quest-giver | 2 (idle / talking) | 1 (idle) | Trigger area | None — fixed |
|
|
43
|
+
| Companion | 5 | 4 (follow / support / engage / retreat) | Sight + group | Narrow, biased low aggression |
|
|
44
|
+
| Civilian | 3 (wander / panic / dead) | 2 (idle / retreat) | Hearing | Caution biased high |
|
|
45
|
+
| Wave mob | 2 (charge / dead) | 1 (kill) | Direct line to player | None |
|
|
44
46
|
|
|
45
|
-
### 2.
|
|
47
|
+
### 2. Walk the design pillars
|
|
46
48
|
|
|
47
|
-
Walk them in order. Don't skip.
|
|
49
|
+
Walk them in order. Don't skip. The pillars are **Perception → Personality → Intent → Action → Telegraph → Defeat**.
|
|
48
50
|
|
|
49
|
-
#### a) Perception — what does this NPC
|
|
51
|
+
#### a) Perception — what does this NPC sense?
|
|
50
52
|
|
|
51
|
-
Pick the minimum. Over-perception = unfair AI.
|
|
53
|
+
Pick the minimum. Over-perception = unfair AI.
|
|
52
54
|
|
|
53
55
|
| Sensor | Node | Cost | Use case |
|
|
54
56
|
|---|---|---|---|
|
|
55
57
|
| Sight cone | RayCast3D + dot product check | Cheap | Standard enemy |
|
|
56
58
|
| Sphere proximity | Area3D with sphere shape | Cheap | "Felt your presence" / aggro range |
|
|
57
|
-
| Hearing event | Signal from world events
|
|
58
|
-
| Group awareness | Shared signal bus (autoload) | Cheap | Pack tactics |
|
|
59
|
-
| Scripted scripted (boss) | Manual state changes | Free | Phase transitions |
|
|
59
|
+
| Hearing event | Signal from world events | Cheapest | Alert nearby NPCs |
|
|
60
|
+
| Group awareness | Shared signal bus (autoload) | Cheap | Pack tactics, rescue intent |
|
|
60
61
|
|
|
61
62
|
Recommended setup for a basic enemy:
|
|
62
63
|
|
|
@@ -66,68 +67,115 @@ Enemy (CharacterBody3D)
|
|
|
66
67
|
├── CollisionShape3D
|
|
67
68
|
├── Vision (Area3D) # sphere shape, radius = sight range
|
|
68
69
|
│ └── CollisionShape3D
|
|
69
|
-
└── Sight (RayCast3D) # confirms
|
|
70
|
+
└── Sight (RayCast3D) # confirms LOS for candidates inside Vision
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
Two-step (Area3D filter, then RayCast LOS) is the right default. Don't raycast to every potential target every frame.
|
|
74
|
+
|
|
75
|
+
#### b) Personality — four floats that make this NPC *not* a clone
|
|
76
|
+
|
|
77
|
+
Every NPC instance gets **four knobs**, each in `[0.0, 1.0]`, declared as `@export_range` so designers can tune the *base* per archetype, then **randomized per-instance at `_ready`** so spawned siblings differ.
|
|
78
|
+
|
|
79
|
+
| Knob | What it controls | High value reads as | Low value reads as |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `aggression` | How fast the intent layer escalates to `KILL`; shorter pre-attack hesitation | Berserker that closes distance | Cautious skirmisher that waits |
|
|
82
|
+
| `patience` | How long the NPC stays in `INVESTIGATE` before giving up; tolerance for circling without attacking | Will hunt the player across the level | Loses interest quickly, returns to patrol |
|
|
83
|
+
| `caution` | How eagerly it breaks line-of-sight when hurt; how often it backs off after a hit trade | Kites and uses cover | Trades blows recklessly |
|
|
84
|
+
| `punishment` | Probability of swapping intent to `KILL` when the player is recovering, reloading, healing | Punishes whiffs and animation locks | Lets the player breathe |
|
|
85
|
+
|
|
86
|
+
**Threshold mapping** (used inside the intent layer): `engage_threshold = 2.0 - aggression` (higher aggression = engage faster), `search_duration = 4.0 + patience * 6.0` (patient NPC searches up to 10s), `break_los_chance = caution * (1 - hp_ratio)` (cautious AND hurt = break LOS), and `should_punish() = randf() < punishment` (rolled when the player is recovering / reloading).
|
|
73
87
|
|
|
74
|
-
|
|
88
|
+
Declare the knobs as `@export_range(0.0, 1.0)` so each archetype scene tunes them per-instance, and jitter them by `+/- personality_jitter` (default 0.15) inside `_ready` — same script + same archetype scene produces NPCs whose feel is shaped by four numbers alone. Full example in the script stub at step 7.
|
|
75
89
|
|
|
76
|
-
|
|
90
|
+
#### c) Intent layer — what the NPC is *trying* to do
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
The intent layer sits **above** the action SM. It runs each tick, reads perception + personality + group state, and writes a single `current_intent`. The action SM reads `current_intent` to pick its transitions; it never sets intent itself. See `_decide_intent` in the stub (step 7) for the canonical priority order: `RESCUE_ALLY` > `RETREAT` (low HP + caution) > `KILL` (pressure built) > `INVESTIGATE` (lost LOS, within memory) > `HUNT` (LOS, out of range) > `PATROL` / `IDLE`.
|
|
93
|
+
|
|
94
|
+
Five-to-seven intents is the sweet spot:
|
|
95
|
+
|
|
96
|
+
| Intent | When | Effect on action SM |
|
|
79
97
|
|---|---|---|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
98
|
+
| `IDLE` | No target, no patrol route | Action SM stays in `Calm` |
|
|
99
|
+
| `PATROL` | No target, route exists | `Calm`, walks the path |
|
|
100
|
+
| `INVESTIGATE` | Lost LOS, within memory window | `Alert`, moves to last-known position |
|
|
101
|
+
| `HUNT` | LOS, out of attack range | `Alert/Aggressive`, closes distance |
|
|
102
|
+
| `KILL` | LOS, in attack range, pressure built | `Aggressive`, executes attack |
|
|
103
|
+
| `RETREAT` | Low HP and high caution, OR over-extended | `Alert`, moves away, breaks LOS |
|
|
104
|
+
| `RESCUE_ALLY` | Allied NPC emitted `needs_help` | `Alert`, moves to ally |
|
|
84
105
|
|
|
85
|
-
|
|
86
|
-
- You have 10+ states with priority interactions.
|
|
87
|
-
- You want designers (not coders) editing logic.
|
|
88
|
-
- The NPC has simultaneous goals (hunt + survive + cover).
|
|
106
|
+
The action SM's job is now narrow: given an intent, what locomotion + animation + hitbox state matches?
|
|
89
107
|
|
|
90
|
-
|
|
108
|
+
#### d) Action SM — what the body is *currently doing*
|
|
91
109
|
|
|
92
|
-
|
|
110
|
+
Four states is plenty for a basic enemy. They are the visible mood, not the goal.
|
|
93
111
|
|
|
94
|
-
|
|
112
|
+
```gdscript
|
|
113
|
+
enum State { CALM, ALERT, AGGRESSIVE, DEFEATED }
|
|
114
|
+
```
|
|
95
115
|
|
|
96
|
-
| State |
|
|
97
|
-
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
| Hurt | Stagger anim | Pain sound | Hit flash (see `vfx`) |
|
|
103
|
-
| Defeated | Death anim + ragdoll | Death sound | Loot drop / dissolve |
|
|
116
|
+
| State | Reads intent | Animation | Audio | VFX |
|
|
117
|
+
|---|---|---|---|---|
|
|
118
|
+
| `CALM` | `IDLE`, `PATROL` | Breathing loop, look-around | Ambient | None |
|
|
119
|
+
| `ALERT` | `INVESTIGATE`, `HUNT`, `RETREAT`, `RESCUE_ALLY` | Posture up, head tracks target | "Huh?" / radio | Eye glow |
|
|
120
|
+
| `AGGRESSIVE` | `KILL` | Combat pose, weapon raised, swing windup | Battle cry | Muzzle flash / windup glow |
|
|
121
|
+
| `DEFEATED` | (terminal) | Death anim → ragdoll | Death sound | Loot drop / dissolve |
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
Action transitions are computed from `current_intent` and a few action-local conditions (HP, animation locks). The intent layer does not transition action states directly — it sets the *goal*, action follows.
|
|
106
124
|
|
|
107
|
-
####
|
|
125
|
+
#### e) Telegraph — every aggressive action has a tell
|
|
126
|
+
|
|
127
|
+
**Telegraph is non-negotiable for any attack.** 0.3–0.7 sec of windup the player can react to. Faster than 0.3 = "unfair"; slower than 0.7 = "boring". Personality bleeds into telegraph length:
|
|
128
|
+
|
|
129
|
+
```gdscript
|
|
130
|
+
func attack_windup_time() -> float:
|
|
131
|
+
# high aggression = shorter hesitation, but never below 0.25
|
|
132
|
+
return maxf(0.25, attack_telegraph * (1.5 - aggression))
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Every aggressive action has at least one of: animation windup, audio cue, VFX glow, particle trail, hitbox preview. Cheap mobs can collapse all three into one anim — but the windup *must* exist.
|
|
136
|
+
|
|
137
|
+
#### f) Defeat — what happens when it dies
|
|
108
138
|
|
|
109
139
|
Plan it. "It just disappears" is bad. Choices:
|
|
110
140
|
|
|
111
141
|
- **Death animation** plays, body stays for N sec, then `queue_free`.
|
|
112
|
-
- **Ragdoll**: switch CollisionShape from
|
|
113
|
-
- **Loot drop**: spawn
|
|
114
|
-
- **Cleanup**:
|
|
115
|
-
- **Score / quest hook**: emit
|
|
142
|
+
- **Ragdoll**: switch CollisionShape from CharacterBody3D to RigidBody3D for weight.
|
|
143
|
+
- **Loot drop**: spawn pickup at death position.
|
|
144
|
+
- **Cleanup**: leave any group, disconnect signals, free children.
|
|
145
|
+
- **Score / quest hook**: emit `died(global_position)` for the gameplay layer.
|
|
146
|
+
- **Group signal**: emit `ally_defeated` so squadmates can update their `RESCUE_ALLY` checks (or panic).
|
|
147
|
+
|
|
148
|
+
### 3. Group emergence — varied personality across a squad
|
|
149
|
+
|
|
150
|
+
When you spawn a squad of four with the same archetype but personality jittered per-instance, you get behavioral roles for free.
|
|
151
|
+
|
|
152
|
+
Worked example — four basic_grunts with `aggression` after jitter at `0.3 / 0.5 / 0.7 / 0.9`:
|
|
153
|
+
|
|
154
|
+
| Instance | aggression | Emergent role | Why |
|
|
155
|
+
|---|---|---|---|
|
|
156
|
+
| 1 | 0.3 | Sniper | Slow to engage, stays at range, builds pressure slowly |
|
|
157
|
+
| 2 | 0.5 | Anchor | Holds the line, neither rushes nor retreats |
|
|
158
|
+
| 3 | 0.7 | Flanker | Closes mid-fight, looks for openings |
|
|
159
|
+
| 4 | 0.9 | Berserker | Rushes immediately, short windups |
|
|
116
160
|
|
|
117
|
-
|
|
161
|
+
No designer authored "the sniper". The jitter on a single knob produced a four-role squad. Stack `caution` and `patience` on top and you get richer roles (a high-caution / low-aggression NPC reads as "the careful one who hangs back and supports"). The squad feels coordinated even though each NPC is making local decisions.
|
|
162
|
+
|
|
163
|
+
**Implementation note:** spawners should call `randomize()` once at `_ready` and let each NPC's own `_ready` handle the jitter. Don't try to author roles centrally — the variation *is* the design.
|
|
164
|
+
|
|
165
|
+
### 4. Read the scene to find an attach point
|
|
118
166
|
|
|
119
167
|
```
|
|
120
168
|
summer_get_scene_tree
|
|
121
169
|
summer_inspect_node "./World"
|
|
122
170
|
```
|
|
123
171
|
|
|
124
|
-
Identify where the NPC will live (
|
|
172
|
+
Identify where the NPC will live (an `Enemies` parent, or directly under World). Confirm the player exists for line-of-sight.
|
|
125
173
|
|
|
126
|
-
###
|
|
174
|
+
### 5. Propose the bundle
|
|
127
175
|
|
|
128
|
-
> I'm about to design a basic patrol-and-chase enemy. Scene tree: Enemy (CharacterBody3D) + Vision Area3D + Sight RayCast3D + CollisionShape3D + MeshInstance3D. Script `scripts/enemy_ai.gd` with
|
|
176
|
+
> I'm about to design a basic patrol-and-chase enemy with the personality + intent layer. Scene tree: Enemy (CharacterBody3D) + Vision Area3D + Sight RayCast3D + CollisionShape3D + MeshInstance3D. Script `scripts/enemy_ai.gd` with personality knobs (aggression / patience / caution / punishment), intent enum (IDLE / PATROL / INVESTIGATE / HUNT / KILL / RETREAT / RESCUE_ALLY), action SM (Calm / Alert / Aggressive / Defeated). Telegraph 0.3–0.7 sec, scaled by aggression. Drop loot on death, emit `died` signal. May I create the scene + script?
|
|
129
177
|
|
|
130
|
-
###
|
|
178
|
+
### 6. Build it
|
|
131
179
|
|
|
132
180
|
**Preferred (Summer MCP):**
|
|
133
181
|
|
|
@@ -142,113 +190,180 @@ summer_set_prop(path="./World/Enemies/Enemy/Sight", key="enabled", value="true")
|
|
|
142
190
|
summer_set_prop(path="./World/Enemies/Enemy/Sight", key="target_position", value="Vector3(0, 0, -12)")
|
|
143
191
|
```
|
|
144
192
|
|
|
145
|
-
|
|
193
|
+
Save the Vision sphere as standalone `.tres` (do NOT inline sub_resource — see `_shared/mcp-tools-reference.md` § "Trap"):
|
|
146
194
|
|
|
147
195
|
```
|
|
148
|
-
# Save shape resource externally, then:
|
|
149
196
|
summer_set_prop(path="./World/Enemies/Enemy/Vision/VisionShape", key="shape", value="res://shapes/vision_sphere.tres")
|
|
150
197
|
```
|
|
151
198
|
|
|
152
|
-
Connect
|
|
199
|
+
Connect signals:
|
|
153
200
|
|
|
154
201
|
```
|
|
155
|
-
summer_connect_signal(from="./World/Enemies/Enemy/Vision", signal="body_entered", to="./World/Enemies/Enemy", method="
|
|
202
|
+
summer_connect_signal(from="./World/Enemies/Enemy/Vision", signal="body_entered", to="./World/Enemies/Enemy", method="_on_body_entered")
|
|
156
203
|
```
|
|
157
204
|
|
|
158
|
-
**Fallback (no MCP):** write the scene as `.tscn` text. Ask user to paste their existing scene first, then propose a unified diff.
|
|
205
|
+
**Fallback (no MCP):** write the scene as `.tscn` text. Ask the user to paste their existing scene first, then propose a unified diff.
|
|
159
206
|
|
|
160
|
-
###
|
|
207
|
+
### 7. Drop the script
|
|
161
208
|
|
|
162
|
-
`scripts/enemy_ai.gd
|
|
209
|
+
`scripts/enemy_ai.gd`:
|
|
163
210
|
|
|
164
211
|
```gdscript
|
|
165
212
|
class_name EnemyAI
|
|
166
213
|
extends CharacterBody3D
|
|
167
214
|
|
|
168
|
-
enum State {
|
|
215
|
+
enum State { CALM, ALERT, AGGRESSIVE, DEFEATED }
|
|
216
|
+
enum Intent { IDLE, PATROL, INVESTIGATE, HUNT, KILL, RETREAT, RESCUE_ALLY }
|
|
169
217
|
|
|
218
|
+
# Tuning
|
|
170
219
|
@export var move_speed: float = 3.5
|
|
171
220
|
@export var attack_range: float = 2.0
|
|
172
221
|
@export var attack_telegraph: float = 0.5
|
|
173
222
|
@export var attack_damage: int = 10
|
|
174
223
|
@export var max_health: int = 30
|
|
224
|
+
@export var memory_duration: float = 8.0
|
|
225
|
+
|
|
226
|
+
# Personality (base, jittered at spawn)
|
|
227
|
+
@export_range(0.0, 1.0) var aggression: float = 0.5
|
|
228
|
+
@export_range(0.0, 1.0) var patience: float = 0.5
|
|
229
|
+
@export_range(0.0, 1.0) var caution: float = 0.3
|
|
230
|
+
@export_range(0.0, 1.0) var punishment: float = 0.5
|
|
231
|
+
@export var personality_jitter: float = 0.15
|
|
175
232
|
|
|
176
233
|
@onready var sight: RayCast3D = $Sight
|
|
177
234
|
@onready var vision: Area3D = $Vision
|
|
178
235
|
@onready var anim: AnimationPlayer = $AnimationPlayer
|
|
179
236
|
|
|
180
237
|
signal died(position: Vector3)
|
|
238
|
+
signal needs_help(who: Node3D)
|
|
239
|
+
signal ally_defeated(who: Node3D)
|
|
181
240
|
|
|
182
|
-
var _state: State = State.
|
|
241
|
+
var _state: State = State.CALM
|
|
242
|
+
var current_intent: Intent = Intent.IDLE
|
|
183
243
|
var _target: Node3D = null
|
|
244
|
+
var _last_seen_pos: Vector3 = Vector3.ZERO
|
|
245
|
+
var _memory_timer: float = 0.0
|
|
246
|
+
var _intent_timer: float = 0.0
|
|
247
|
+
var _aggression_buildup: float = 0.0
|
|
248
|
+
var _retreat_desire: float = 0.0
|
|
184
249
|
var _health: int = 30
|
|
185
250
|
|
|
186
251
|
func _ready() -> void:
|
|
187
252
|
_health = max_health
|
|
253
|
+
aggression = clampf(aggression + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
|
|
254
|
+
patience = clampf(patience + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
|
|
255
|
+
caution = clampf(caution + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
|
|
256
|
+
punishment = clampf(punishment + randf_range(-personality_jitter, personality_jitter), 0.0, 1.0)
|
|
188
257
|
vision.body_entered.connect(_on_body_entered)
|
|
189
258
|
|
|
190
259
|
func _physics_process(delta: float) -> void:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
State.ATTACK:
|
|
197
|
-
_process_attack(delta)
|
|
198
|
-
State.DEAD:
|
|
199
|
-
pass
|
|
260
|
+
if _state == State.DEFEATED:
|
|
261
|
+
return
|
|
262
|
+
_update_memory(delta)
|
|
263
|
+
_update_intent(delta)
|
|
264
|
+
_update_action(delta)
|
|
200
265
|
move_and_slide()
|
|
201
266
|
|
|
202
|
-
|
|
203
|
-
velocity = Vector3.ZERO
|
|
204
|
-
# transition handled by _on_body_entered
|
|
267
|
+
# --- Intent layer (above action SM) -----------------------------------------
|
|
205
268
|
|
|
206
|
-
func
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
214
|
-
var dir := (_target.global_position - global_position).normalized()
|
|
215
|
-
velocity = dir * move_speed
|
|
216
|
-
look_at(_target.global_position, Vector3.UP)
|
|
217
|
-
if global_position.distance_squared_to(_target.global_position) < attack_range * attack_range:
|
|
218
|
-
_change_state(State.ATTACK)
|
|
269
|
+
func _update_intent(delta: float) -> void:
|
|
270
|
+
_intent_timer += delta
|
|
271
|
+
if _state == State.AGGRESSIVE:
|
|
272
|
+
_aggression_buildup += delta * (1.0 - patience) * 0.5
|
|
273
|
+
else:
|
|
274
|
+
_aggression_buildup = maxf(_aggression_buildup - delta * 0.3, 0.0)
|
|
275
|
+
_retreat_desire = maxf(_retreat_desire - delta * 0.4, 0.0)
|
|
219
276
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
277
|
+
var prev: Intent = current_intent
|
|
278
|
+
current_intent = _decide_intent()
|
|
279
|
+
if current_intent != prev:
|
|
280
|
+
_intent_timer = 0.0
|
|
223
281
|
|
|
224
|
-
func
|
|
282
|
+
func _decide_intent() -> Intent:
|
|
283
|
+
if _target == null:
|
|
284
|
+
return Intent.IDLE
|
|
285
|
+
var dist: float = global_position.distance_squared_to(_target.global_position)
|
|
286
|
+
var attack_sq: float = attack_range * attack_range
|
|
287
|
+
if _retreat_desire > 0.5:
|
|
288
|
+
return Intent.RETREAT
|
|
289
|
+
if _aggression_buildup > (2.0 - aggression) and dist < attack_sq:
|
|
290
|
+
return Intent.KILL
|
|
291
|
+
if not _has_line_of_sight(_target):
|
|
292
|
+
return Intent.INVESTIGATE if _intent_timer < (4.0 + patience * 6.0) else Intent.IDLE
|
|
293
|
+
if dist < attack_sq * 4.0:
|
|
294
|
+
return Intent.HUNT
|
|
295
|
+
return Intent.HUNT
|
|
296
|
+
|
|
297
|
+
# --- Action SM (reads intent) -----------------------------------------------
|
|
298
|
+
|
|
299
|
+
func _update_action(delta: float) -> void:
|
|
300
|
+
match current_intent:
|
|
301
|
+
Intent.IDLE, Intent.PATROL:
|
|
302
|
+
_enter_state(State.CALM)
|
|
303
|
+
velocity = Vector3.ZERO
|
|
304
|
+
Intent.INVESTIGATE:
|
|
305
|
+
_enter_state(State.ALERT)
|
|
306
|
+
_move_toward(_last_seen_pos, delta)
|
|
307
|
+
Intent.HUNT:
|
|
308
|
+
_enter_state(State.ALERT)
|
|
309
|
+
if _target != null:
|
|
310
|
+
_move_toward(_target.global_position, delta)
|
|
311
|
+
Intent.KILL:
|
|
312
|
+
_enter_state(State.AGGRESSIVE)
|
|
313
|
+
velocity = Vector3.ZERO
|
|
314
|
+
_try_attack()
|
|
315
|
+
Intent.RETREAT:
|
|
316
|
+
_enter_state(State.ALERT)
|
|
317
|
+
if _target != null:
|
|
318
|
+
_move_toward(global_position * 2.0 - _target.global_position, delta)
|
|
319
|
+
Intent.RESCUE_ALLY:
|
|
320
|
+
_enter_state(State.ALERT)
|
|
321
|
+
# locomotion handled by ally-position lookup elsewhere
|
|
322
|
+
|
|
323
|
+
func _enter_state(new_state: State) -> void:
|
|
225
324
|
if _state == new_state:
|
|
226
325
|
return
|
|
227
326
|
_state = new_state
|
|
228
327
|
match new_state:
|
|
229
|
-
State.
|
|
328
|
+
State.CALM:
|
|
230
329
|
anim.play("idle")
|
|
231
330
|
State.ALERT:
|
|
232
331
|
anim.play("alert")
|
|
233
|
-
State.
|
|
234
|
-
|
|
235
|
-
State.
|
|
332
|
+
State.AGGRESSIVE:
|
|
333
|
+
anim.play("ready")
|
|
334
|
+
State.DEFEATED:
|
|
236
335
|
anim.play("death")
|
|
237
336
|
died.emit(global_position)
|
|
238
|
-
|
|
239
|
-
|
|
337
|
+
ally_defeated.emit(self)
|
|
338
|
+
|
|
339
|
+
# --- Helpers ----------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
func _move_toward(target_pos: Vector3, _delta: float) -> void:
|
|
342
|
+
var dir: Vector3 = (target_pos - global_position).normalized()
|
|
343
|
+
velocity = dir * move_speed
|
|
344
|
+
look_at(target_pos, Vector3.UP)
|
|
240
345
|
|
|
241
|
-
func
|
|
346
|
+
func _try_attack() -> void:
|
|
347
|
+
if _state != State.AGGRESSIVE or _target == null:
|
|
348
|
+
return
|
|
242
349
|
anim.play("attack_windup")
|
|
243
|
-
|
|
244
|
-
|
|
350
|
+
var windup: float = maxf(0.25, attack_telegraph * (1.5 - aggression))
|
|
351
|
+
await get_tree().create_timer(windup).timeout
|
|
352
|
+
if _state != State.AGGRESSIVE or _target == null:
|
|
245
353
|
return
|
|
246
|
-
if global_position.distance_squared_to(_target.global_position) < (attack_range * 1.2)
|
|
354
|
+
if global_position.distance_squared_to(_target.global_position) < (attack_range * 1.2) * (attack_range * 1.2):
|
|
247
355
|
if _target.has_method("take_damage"):
|
|
248
356
|
_target.take_damage(attack_damage)
|
|
249
357
|
anim.play("attack_recover")
|
|
250
|
-
|
|
251
|
-
|
|
358
|
+
|
|
359
|
+
func _update_memory(delta: float) -> void:
|
|
360
|
+
if _target != null and _has_line_of_sight(_target):
|
|
361
|
+
_last_seen_pos = _target.global_position
|
|
362
|
+
_memory_timer = memory_duration
|
|
363
|
+
else:
|
|
364
|
+
_memory_timer = maxf(_memory_timer - delta, 0.0)
|
|
365
|
+
if _memory_timer <= 0.0:
|
|
366
|
+
_target = null
|
|
252
367
|
|
|
253
368
|
func _has_line_of_sight(target: Node3D) -> bool:
|
|
254
369
|
sight.target_position = sight.to_local(target.global_position)
|
|
@@ -258,26 +373,49 @@ func _has_line_of_sight(target: Node3D) -> bool:
|
|
|
258
373
|
return sight.get_collider() == target
|
|
259
374
|
|
|
260
375
|
func _on_body_entered(body: Node3D) -> void:
|
|
261
|
-
if body.is_in_group("player") and _state == State.
|
|
376
|
+
if body.is_in_group("player") and _state == State.CALM:
|
|
262
377
|
_target = body
|
|
263
|
-
|
|
378
|
+
_last_seen_pos = body.global_position
|
|
379
|
+
_memory_timer = memory_duration
|
|
264
380
|
|
|
265
381
|
func take_damage(amount: int) -> void:
|
|
266
|
-
if _state == State.
|
|
382
|
+
if _state == State.DEFEATED:
|
|
267
383
|
return
|
|
268
384
|
_health -= amount
|
|
385
|
+
var hp_ratio: float = float(_health) / float(max_health)
|
|
386
|
+
if randf() < caution * (1.0 - hp_ratio):
|
|
387
|
+
_retreat_desire = 1.0
|
|
269
388
|
if _health <= 0:
|
|
270
|
-
|
|
389
|
+
_enter_state(State.DEFEATED)
|
|
390
|
+
await get_tree().create_timer(2.0).timeout
|
|
391
|
+
queue_free()
|
|
271
392
|
```
|
|
272
393
|
|
|
273
394
|
Key design notes embedded above:
|
|
274
|
-
-
|
|
275
|
-
- `
|
|
276
|
-
-
|
|
277
|
-
-
|
|
278
|
-
-
|
|
395
|
+
- `aggression`, `patience`, `caution`, `punishment` are tuned per-archetype and jittered per-instance.
|
|
396
|
+
- Intent SM (`_update_intent` / `_decide_intent`) sits above the action SM (`_update_action`).
|
|
397
|
+
- Action SM reads `current_intent`. It does not write intent.
|
|
398
|
+
- Telegraph length scales with aggression — angrier NPCs swing faster.
|
|
399
|
+
- Damage triggers a `caution * (1 - hp_ratio)` chance to set `_retreat_desire`, so cautious + hurt = back off.
|
|
400
|
+
- `died` signal is the gameplay-layer hook; `ally_defeated` is the squad hook.
|
|
279
401
|
|
|
280
|
-
###
|
|
402
|
+
### 8. Tunable matrices — concrete ranges per archetype
|
|
403
|
+
|
|
404
|
+
These are the *base* values; the spawn-time `personality_jitter` (default 0.15) widens them further.
|
|
405
|
+
|
|
406
|
+
| Archetype | aggression | patience | caution | punishment | Notes |
|
|
407
|
+
|---|---|---|---|---|---|
|
|
408
|
+
| `basic_grunt` | 0.5–0.7 | 0.3–0.5 | 0.2–0.4 | 0.4–0.6 | Wants to engage; not too smart |
|
|
409
|
+
| `ranged_skirmisher` | 0.3–0.5 | 0.5–0.7 | 0.6–0.8 | 0.5–0.7 | Kites, breaks LOS, punishes reload |
|
|
410
|
+
| `heavy_brute` | 0.7–0.9 | 0.2–0.4 | 0.1–0.3 | 0.6–0.8 | Closes fast, low retreat |
|
|
411
|
+
| `boss` | 0.7–0.9 | 0.7–0.9 | 0.5–0.7 | 0.8–1.0 | Patient, punishing, never panics |
|
|
412
|
+
| `wave_mob` | 0.9–1.0 | 0.0–0.1 | 0.0 | 0.0 | Single intent: KILL |
|
|
413
|
+
| `civilian` | 0.0 | 0.8–1.0 | 0.7–0.9 | 0.0 | Retreat-only |
|
|
414
|
+
| `companion` | 0.4–0.6 | 0.6–0.8 | 0.5–0.7 | 0.3–0.5 | Supports, doesn't over-commit |
|
|
415
|
+
|
|
416
|
+
Set base values via `@export` in the scene's inspector, not hard-coded in script. That keeps the same `enemy_ai.gd` reusable across archetypes.
|
|
417
|
+
|
|
418
|
+
### 9. Verify
|
|
281
419
|
|
|
282
420
|
```
|
|
283
421
|
summer_save_scene
|
|
@@ -291,28 +429,34 @@ If the enemy doesn't aggro, common causes:
|
|
|
291
429
|
- Player isn't in `player` group.
|
|
292
430
|
- Vision Area3D's sphere shape is inline sub_resource (silent fail).
|
|
293
431
|
- Sight RayCast3D `enabled = false` or `collide_with_areas = true` blocking on its own Vision area.
|
|
432
|
+
- Personality jitter pushed `aggression` close to 0 — check inspector values.
|
|
294
433
|
|
|
295
434
|
## Common mistakes
|
|
296
435
|
|
|
297
436
|
| Don't | Do | Why |
|
|
298
437
|
|---|---|---|
|
|
299
438
|
| 360° perception | Sight cone or sphere with raycast LOS | "Sees through walls" feels unfair |
|
|
300
|
-
| Instant attack | 0.3–0.7 sec telegraph | Player needs to read the swing |
|
|
301
|
-
|
|
|
302
|
-
|
|
|
439
|
+
| Instant attack | 0.3–0.7 sec telegraph, scaled by aggression | Player needs to read the swing |
|
|
440
|
+
| All NPCs identical at spawn | Jitter personality knobs in `_ready` | Identical NPCs are predictable; predictable is boring |
|
|
441
|
+
| Action SM writes intent | Intent layer writes intent, action reads | Tangled SMs are unmaintainable |
|
|
442
|
+
| One enum for action + intent | Two enums, two SMs | Different time scales, different inputs |
|
|
443
|
+
| Hard-coded role per spawn ("this is the sniper") | Let personality variation produce roles | Squad emergence comes for free; authored roles fight the system |
|
|
444
|
+
| Behavior tree for a grunt | Action SM with 4 states + intent layer | BT overhead unjustified at this complexity |
|
|
445
|
+
| Per-frame group lookup | Cache target on aggro, listen for `ally_defeated` | Tree walks aren't free |
|
|
303
446
|
| `distance_to` in `_physics_process` | `distance_squared_to` for comparisons | Square root costs at scale |
|
|
304
|
-
| Same animation in all states | Distinct
|
|
447
|
+
| Same animation in all states | Distinct anim per action state | The state machine is invisible; animation makes it visible |
|
|
305
448
|
| `queue_free()` instantly on death | Death anim → 2 sec → free | Instant despawn breaks immersion + skips loot drops |
|
|
306
449
|
| Inline SphereShape3D for Vision Area3D | Standalone `.tres` | Inline sub_resources break SetResourceProperty |
|
|
307
|
-
| 100+ enemies with full FSM | Wave-mob archetype (
|
|
308
|
-
| No `died`
|
|
450
|
+
| 100+ enemies with full FSM | Wave-mob archetype (no intent layer, single KILL intent) | Cheap mobs need cheap brains |
|
|
451
|
+
| No `died` / `ally_defeated` signals | Emit both | Gameplay + squad logic both need hooks |
|
|
309
452
|
|
|
310
453
|
## Anti-patterns
|
|
311
454
|
|
|
312
|
-
- **"AI that dodges everything."** Enemies that 100% dodge feel cheap.
|
|
455
|
+
- **"AI that dodges everything."** Enemies that 100% dodge feel cheap. A `caution` knob plus randomized reaction time (50–250 ms) reads as skill, not aimbot.
|
|
313
456
|
- **Boss with 12 unique mechanics.** Cap at 4–6 distinct mechanics across phases. Players can't track more.
|
|
314
457
|
- **Companion that walks through doors.** NavigationAgent3D with proper navmesh. Don't fake it with raw `move_toward`.
|
|
315
458
|
- **Civilian that screams forever.** Add a calm-down state on a 5–10 sec timer.
|
|
459
|
+
- **Setting `current_intent` from inside an action callback.** Intent is computed by the intent layer from inputs (perception + personality + group). Letting action callbacks write intent inverts the architecture.
|
|
316
460
|
|
|
317
461
|
## Collaborative protocol
|
|
318
462
|
|
|
@@ -323,9 +467,7 @@ This skill writes scenes and scripts. Always ask before each step. Group related
|
|
|
323
467
|
For a fully-rigged enemy with anim controller + loot drop:
|
|
324
468
|
|
|
325
469
|
→ **template-id**: `template-3d-fps` — ships with a sample patrol enemy
|
|
326
|
-
→
|
|
327
|
-
|
|
328
|
-
For higher-complexity AI (boss with phases, behavior trees), defer to `ai-and-npcs/behavior-trees/SKILL.md`.
|
|
470
|
+
→ For higher-complexity AI (boss with phases, behavior trees), defer to `ai-and-npcs/behavior-trees/SKILL.md`.
|
|
329
471
|
|
|
330
472
|
## See also
|
|
331
473
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: asset-strategy
|
|
3
|
-
description:
|
|
3
|
+
description: Use when planning asset creation, picking an asset pipeline, or writing image-to-3D prompts — decision guide for whether to generate 3D, search the library, generate AI textures, or use primitives. Includes prompt templates for the AI image-to-3D pipeline. Trigger on "assets", "3D models", "textures", "art pipeline", "make a model", "generate asset".
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: [Cursor, Claude Code, Windsurf, Codex]
|
|
6
6
|
category: asset-pipeline
|
package/skills/catalog.yaml
CHANGED
|
@@ -146,6 +146,20 @@ skills:
|
|
|
146
146
|
recommended: true
|
|
147
147
|
user-invocable: true
|
|
148
148
|
last-tested: 2026-05-05
|
|
149
|
+
- name: new-project
|
|
150
|
+
category: scene-and-project
|
|
151
|
+
status: HAVE
|
|
152
|
+
public: true
|
|
153
|
+
recommended: true
|
|
154
|
+
user-invocable: true
|
|
155
|
+
last-tested: 2026-05-09
|
|
156
|
+
- name: browse-templates
|
|
157
|
+
category: scene-and-project
|
|
158
|
+
status: HAVE
|
|
159
|
+
public: true
|
|
160
|
+
recommended: true
|
|
161
|
+
user-invocable: true
|
|
162
|
+
last-tested: 2026-05-09
|
|
149
163
|
- name: scene-composition
|
|
150
164
|
category: scene-and-project
|
|
151
165
|
status: HAVE
|
|
@@ -163,8 +177,9 @@ skills:
|
|
|
163
177
|
category: scene-and-project
|
|
164
178
|
status: HAVE
|
|
165
179
|
public: true
|
|
166
|
-
recommended:
|
|
167
|
-
|
|
180
|
+
recommended: true
|
|
181
|
+
user-invocable: true
|
|
182
|
+
last-tested: 2026-05-09
|
|
168
183
|
- name: godot-project-conventions
|
|
169
184
|
category: scene-and-project
|
|
170
185
|
status: NEXT
|
|
@@ -437,16 +452,18 @@ skills:
|
|
|
437
452
|
last-tested: 2026-05-05
|
|
438
453
|
- name: peer-to-peer-multiplayer
|
|
439
454
|
category: multiplayer-and-networking
|
|
440
|
-
status:
|
|
455
|
+
status: HAVE
|
|
441
456
|
public: true
|
|
442
457
|
recommended: true
|
|
443
458
|
user-invocable: true
|
|
459
|
+
last-tested: 2026-05-05
|
|
444
460
|
- name: host-authoritative-state
|
|
445
461
|
category: multiplayer-and-networking
|
|
446
|
-
status:
|
|
462
|
+
status: HAVE
|
|
447
463
|
public: true
|
|
448
464
|
recommended: true
|
|
449
465
|
user-invocable: true
|
|
466
|
+
last-tested: 2026-05-05
|
|
450
467
|
- name: multiplayerapi-basics
|
|
451
468
|
category: multiplayer-and-networking
|
|
452
469
|
status: NEXT
|