summer-engine 1.4.0 → 1.5.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/dist/lib/skills-registry.d.ts +16 -0
- package/dist/lib/skills-registry.js +33 -0
- package/package.json +1 -1
- 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/catalog.yaml +4 -2
- package/skills/character-controllers/fps-controller/SKILL.md +260 -79
- package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +332 -0
- package/skills/visual-effects/vfx/SKILL.md +233 -134
|
@@ -1,160 +1,341 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fps-controller
|
|
3
|
-
description: First-person character controller with WASD
|
|
3
|
+
description: First-person character controller with WASD, mouse look, jump, coyote time, jump buffering, air control, and external-velocity handling. Use when building an FPS, first-person movement, or a 3D character that walks, jumps, and gets shoved around. Trigger on "FPS", "first-person", "WASD", "character controller", "mouse look".
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: [Cursor, Claude Code, Windsurf, Codex]
|
|
6
6
|
category: character-controllers
|
|
7
7
|
user-invocable: false
|
|
8
8
|
template-id: template-3d-fps
|
|
9
|
-
allowed-tools: Read Grep summer_add_node summer_set_prop summer_set_resource_property summer_input_map_bind summer_save_scene summer_get_script_errors
|
|
9
|
+
allowed-tools: Read Grep summer_get_scene_tree summer_add_node summer_set_prop summer_set_resource_property summer_input_map_bind summer_save_scene summer_get_script_errors
|
|
10
10
|
paths: ["**/*.gd", "**/*.tscn"]
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# FPS Controller for Summer Engine
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The canonical first-person controller. Coyote time, jump buffering, air control, external-velocity, the works. This is the production minimum for an FPS that doesn't feel like a Unity tutorial — it follows the shipping FPS standard for movement (gravity, grace timers, separate ground/air acceleration, external force accumulator) so the player never says "this feels stiff" or "I jumped right at the edge and died unfairly".
|
|
16
16
|
|
|
17
17
|
## Scene Structure
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
Player (CharacterBody3D)
|
|
21
|
-
├── CollisionShape3D #
|
|
22
|
-
├──
|
|
23
|
-
└──
|
|
21
|
+
├── CollisionShape3D # CapsuleShape3D, body
|
|
22
|
+
├── Head (Node3D) # yaw is on Player, pitch is on Head — keeps camera roll-free
|
|
23
|
+
│ └── Camera3D # at eye level
|
|
24
|
+
└── (optional) RayCast3D # ground / interaction probe
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
The Head/Camera split is the industry-standard movement model: yaw rotates the body, pitch rotates only the head. Don't pitch the body or you get tilted capsules and slope-collision bugs.
|
|
28
|
+
|
|
26
29
|
## Step-by-Step Setup via MCP
|
|
27
30
|
|
|
28
|
-
### 1.
|
|
31
|
+
### 1. Verify the scene first
|
|
32
|
+
|
|
33
|
+
Always call `summer_get_scene_tree` before mutating. If `World/Player`, `World/PlayerOld`, or any existing CharacterBody3D is present, ASK the user how to proceed before adding nodes.
|
|
34
|
+
|
|
35
|
+
### 2. Add the Player root
|
|
29
36
|
|
|
30
37
|
```
|
|
31
38
|
summer_add_node(parent="./World", type="CharacterBody3D", name="Player")
|
|
32
39
|
summer_set_prop(path="./World/Player", key="position", value="Vector3(0, 1, 0)")
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
###
|
|
42
|
+
### 3. Add the collision shape (standalone resource, NOT inline)
|
|
36
43
|
|
|
37
44
|
```
|
|
38
|
-
summer_add_node(parent="./World/Player", type="CollisionShape3D", name="
|
|
39
|
-
summer_set_prop(path="./World/Player/
|
|
45
|
+
summer_add_node(parent="./World/Player", type="CollisionShape3D", name="Collision")
|
|
46
|
+
summer_set_prop(path="./World/Player/Collision", key="shape", value="CapsuleShape3D")
|
|
47
|
+
summer_set_resource_property(nodePath="./World/Player/Collision", resourceProperty="shape", subProperty="radius", value="0.4")
|
|
48
|
+
summer_set_resource_property(nodePath="./World/Player/Collision", resourceProperty="shape", subProperty="height", value="1.8")
|
|
40
49
|
```
|
|
41
50
|
|
|
42
|
-
|
|
51
|
+
`summer_set_prop` with a class-name string creates a standalone sub-resource. NEVER call `summer_set_resource_property` against an inline `sub_resource` — the value is silently dropped. See `_shared/mcp-tools-reference.md`.
|
|
52
|
+
|
|
53
|
+
### 4. Add the Head pivot + Camera
|
|
43
54
|
|
|
44
55
|
```
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
summer_add_node(parent="./World/Player", type="Node3D", name="Head")
|
|
57
|
+
summer_set_prop(path="./World/Player/Head", key="position", value="Vector3(0, 1.6, 0)")
|
|
58
|
+
summer_add_node(parent="./World/Player/Head", type="Camera3D", name="Camera")
|
|
47
59
|
```
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
Eye height of 1.6 sits the camera right at the top of a 1.8-tall capsule (capsule center 0.9, plus 0.7 to the eye). Tweak per art style.
|
|
62
|
+
|
|
63
|
+
### 5. InputMap actions
|
|
50
64
|
|
|
51
65
|
```
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
summer_input_map_bind(name="move_forward", events=[{type:"key", key:"W"}])
|
|
67
|
+
summer_input_map_bind(name="move_back", events=[{type:"key", key:"S"}])
|
|
68
|
+
summer_input_map_bind(name="move_left", events=[{type:"key", key:"A"}])
|
|
69
|
+
summer_input_map_bind(name="move_right", events=[{type:"key", key:"D"}])
|
|
70
|
+
summer_input_map_bind(name="jump", events=[{type:"key", key:"Space"}])
|
|
71
|
+
summer_input_map_bind(name="sprint", events=[{type:"key", key:"Shift"}])
|
|
54
72
|
```
|
|
55
73
|
|
|
56
|
-
|
|
74
|
+
### 6. Attach the controller script
|
|
57
75
|
|
|
58
|
-
|
|
76
|
+
Ask before writing: "May I create `scripts/player_controller.gd` and attach it to `./World/Player`?" Then `Write` the file with the skeleton in the next section, attach via inspector, and finish with `summer_save_scene` + `summer_get_script_errors`.
|
|
59
77
|
|
|
60
|
-
|
|
78
|
+
## Physics-Process Flow (the order matters)
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
summer_input_map_bind(name="move_forward", events=[{type:"key", key:"W"}])
|
|
64
|
-
summer_input_map_bind(name="move_back", events=[{type:"key", key:"S"}])
|
|
65
|
-
summer_input_map_bind(name="move_left", events=[{type:"key", key:"A"}])
|
|
66
|
-
summer_input_map_bind(name="move_right", events=[{type:"key", key:"D"}])
|
|
67
|
-
summer_input_map_bind(name="jump", events=[{type:"key", key:"Space"}])
|
|
68
|
-
```
|
|
80
|
+
Each frame, `_physics_process` runs these steps in this exact order. Reordering breaks specific edge cases — they are flagged inline.
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
1. **Subtract last frame's external velocity contribution.** If you skip this, knockback and explosions accumulate forever.
|
|
83
|
+
2. **Apply gravity** (asymmetric — heavier when falling).
|
|
84
|
+
3. **Update coyote timer.** Reset to full when grounded, tick down when airborne.
|
|
85
|
+
4. **Update jump buffer + fire jump** if both timers are active. Clearing the buffer on press-instead-of-fire is the bug everyone makes.
|
|
86
|
+
5. **Read input, build wish-direction in worldspace** by rotating the local 2D input through `transform.basis`.
|
|
87
|
+
6. **Pick acceleration / friction** based on grounded state, then `move_toward` the horizontal velocity toward `wish_dir * target_speed`.
|
|
88
|
+
7. **Apply external velocity, save it for next-frame subtraction, decay it** via `move_toward(0, damping * delta)`.
|
|
89
|
+
8. **`move_and_slide()`** — last, after everything else has settled `velocity`.
|
|
71
90
|
|
|
72
|
-
|
|
91
|
+
The skeleton below implements this exactly.
|
|
92
|
+
|
|
93
|
+
## GDScript Skeleton (drop into `scripts/player_controller.gd`)
|
|
73
94
|
|
|
74
95
|
```gdscript
|
|
96
|
+
class_name PlayerController
|
|
75
97
|
extends CharacterBody3D
|
|
76
98
|
|
|
77
|
-
|
|
78
|
-
@export var
|
|
79
|
-
@export var
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
# Movement tunables — the canonical defaults. Tweak in inspector, don't fork the script.
|
|
100
|
+
@export var walk_speed: float = 5.0
|
|
101
|
+
@export var sprint_speed: float = 8.0
|
|
102
|
+
@export var jump_velocity: float = 5.5 # ~1.5m peak with default gravity
|
|
103
|
+
@export var gravity: float = 20.0 # heavier than Earth — feels better
|
|
104
|
+
@export var fall_gravity_multiplier: float = 1.4 # snappier descent than ascent
|
|
105
|
+
|
|
106
|
+
# Acceleration model — separate ground/air so air control feels real, not on/off.
|
|
107
|
+
@export var ground_accel: float = 80.0
|
|
108
|
+
@export var ground_friction: float = 90.0
|
|
109
|
+
@export var air_accel: float = 25.0 # ~30% of ground — Quake-ish, not Mario
|
|
110
|
+
@export var air_friction: float = 5.0
|
|
111
|
+
|
|
112
|
+
# Grace timers — the difference between "feels tight" and "feels broken".
|
|
113
|
+
@export var coyote_time: float = 0.1 # jump-after-walking-off-ledge window
|
|
114
|
+
@export var jump_buffer_time: float = 0.1 # press-just-before-landing window
|
|
115
|
+
|
|
116
|
+
# Camera.
|
|
117
|
+
@export_range(0.0001, 0.01) var mouse_sensitivity: float = 0.002
|
|
118
|
+
@export var pitch_min: float = -1.4 # ~-80°
|
|
119
|
+
@export var pitch_max: float = 1.4 # ~+80°
|
|
120
|
+
|
|
121
|
+
# External-velocity damping — knockback / explosions / conveyor belts decay over time.
|
|
122
|
+
@export var external_damping: float = 8.0
|
|
123
|
+
|
|
124
|
+
@onready var head: Node3D = $Head
|
|
125
|
+
@onready var camera: Camera3D = $Head/Camera
|
|
126
|
+
|
|
127
|
+
# Internal state.
|
|
128
|
+
var _coyote_timer: float = 0.0
|
|
129
|
+
var _jump_buffer_timer: float = 0.0
|
|
130
|
+
var _was_on_floor: bool = false
|
|
131
|
+
|
|
132
|
+
# External velocity accumulator. Any system can push the player by adding to this;
|
|
133
|
+
# we apply it, then decay it. Keeps designer-driven forces from fighting input.
|
|
134
|
+
var external_velocity: Vector3 = Vector3.ZERO
|
|
135
|
+
var _prev_external_applied: Vector3 = Vector3.ZERO
|
|
136
|
+
|
|
137
|
+
func _ready() -> void:
|
|
138
|
+
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
139
|
+
|
|
140
|
+
func _unhandled_input(event: InputEvent) -> void:
|
|
141
|
+
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
142
|
+
rotate_y(-event.relative.x * mouse_sensitivity)
|
|
143
|
+
head.rotate_x(-event.relative.y * mouse_sensitivity)
|
|
144
|
+
head.rotation.x = clamp(head.rotation.x, pitch_min, pitch_max)
|
|
145
|
+
elif event.is_action_pressed("ui_cancel"):
|
|
146
|
+
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
147
|
+
|
|
148
|
+
func _physics_process(delta: float) -> void:
|
|
149
|
+
# 1. Strip last frame's external contribution so it doesn't compound with input.
|
|
150
|
+
velocity -= _prev_external_applied
|
|
151
|
+
|
|
152
|
+
# 2. Gravity (asymmetric — falling faster than rising feels better).
|
|
153
|
+
if not is_on_floor():
|
|
154
|
+
var g := gravity * (fall_gravity_multiplier if velocity.y < 0.0 else 1.0)
|
|
155
|
+
velocity.y -= g * delta
|
|
156
|
+
|
|
157
|
+
# 3. Coyote-time bookkeeping. Reset on landing; tick down when airborne.
|
|
158
|
+
if is_on_floor():
|
|
159
|
+
_coyote_timer = coyote_time
|
|
160
|
+
else:
|
|
161
|
+
_coyote_timer = max(_coyote_timer - delta, 0.0)
|
|
162
|
+
|
|
163
|
+
# 4. Jump buffer. Holding/tapping jump up to `jump_buffer_time` before landing
|
|
164
|
+
# queues the jump — fires the instant we touch ground.
|
|
165
|
+
if Input.is_action_just_pressed("jump"):
|
|
166
|
+
_jump_buffer_timer = jump_buffer_time
|
|
88
167
|
else:
|
|
89
|
-
|
|
90
|
-
velocity.z = move_toward(velocity.z, 0, move_speed)
|
|
168
|
+
_jump_buffer_timer = max(_jump_buffer_timer - delta, 0.0)
|
|
91
169
|
|
|
92
|
-
|
|
93
|
-
if is_on_floor() and Input.is_action_just_pressed("jump"):
|
|
170
|
+
if _jump_buffer_timer > 0.0 and _coyote_timer > 0.0:
|
|
94
171
|
velocity.y = jump_velocity
|
|
172
|
+
_jump_buffer_timer = 0.0
|
|
173
|
+
_coyote_timer = 0.0
|
|
174
|
+
|
|
175
|
+
# 5. Build wish-direction in worldspace from camera yaw.
|
|
176
|
+
var input_v := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
|
177
|
+
var wish_dir := (transform.basis * Vector3(input_v.x, 0.0, input_v.y))
|
|
178
|
+
wish_dir.y = 0.0
|
|
179
|
+
wish_dir = wish_dir.normalized() if wish_dir.length() > 0.001 else Vector3.ZERO
|
|
180
|
+
|
|
181
|
+
# 6. Pick speed + accel/friction based on grounded state.
|
|
182
|
+
var target_speed := sprint_speed if Input.is_action_pressed("sprint") else walk_speed
|
|
183
|
+
var accel := ground_accel if is_on_floor() else air_accel
|
|
184
|
+
var friction := ground_friction if is_on_floor() else air_friction
|
|
185
|
+
|
|
186
|
+
var horiz := Vector3(velocity.x, 0.0, velocity.z)
|
|
187
|
+
if wish_dir.length() > 0.0:
|
|
188
|
+
# Accelerate toward wish_dir at `accel` units/sec — not instant snap.
|
|
189
|
+
horiz = horiz.move_toward(wish_dir * target_speed, accel * delta)
|
|
190
|
+
else:
|
|
191
|
+
# No input — decelerate via friction.
|
|
192
|
+
horiz = horiz.move_toward(Vector3.ZERO, friction * delta)
|
|
193
|
+
velocity.x = horiz.x
|
|
194
|
+
velocity.z = horiz.z
|
|
95
195
|
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
196
|
+
# 7. Apply external velocity, remember it for next-frame subtraction, decay it.
|
|
197
|
+
velocity += external_velocity
|
|
198
|
+
_prev_external_applied = external_velocity
|
|
199
|
+
external_velocity = external_velocity.move_toward(Vector3.ZERO, external_damping * delta)
|
|
99
200
|
|
|
201
|
+
_was_on_floor = is_on_floor()
|
|
100
202
|
move_and_slide()
|
|
203
|
+
|
|
204
|
+
# Public API for other systems — knockback, explosions, jump pads, conveyor belts, etc.
|
|
205
|
+
func add_external_velocity(impulse: Vector3) -> void:
|
|
206
|
+
external_velocity += impulse
|
|
101
207
|
```
|
|
102
208
|
|
|
103
|
-
|
|
209
|
+
## Property Reference
|
|
104
210
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
211
|
+
| Property | Default | Why |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `walk_speed` | `5.0` | Standard FPS pace; feels right at 80–90° FOV. |
|
|
214
|
+
| `sprint_speed` | `8.0` | ~60% faster — noticeable without trivializing distances. |
|
|
215
|
+
| `jump_velocity` | `5.5` | ~1.5m apex — clears typical step + crate heights. |
|
|
216
|
+
| `gravity` | `20.0` | Heavier than 9.8 — game-feel default, not realism. |
|
|
217
|
+
| `fall_gravity_multiplier` | `1.4` | Snappier descent than ascent (variable-jump-feel staple). |
|
|
218
|
+
| `ground_accel` / `ground_friction` | `80 / 90` | Crisp start, near-instant stop. |
|
|
219
|
+
| `air_accel` / `air_friction` | `25 / 5` | Reduced air control + minimal air drag. |
|
|
220
|
+
| `coyote_time` | `0.1` | Standard 0.08–0.15s grace — invisible to players, fixes 60% of "unfair death" complaints. |
|
|
221
|
+
| `jump_buffer_time` | `0.1` | Same window the other direction. |
|
|
222
|
+
| `mouse_sensitivity` | `0.002` | Default raw multiplier; expose via settings. |
|
|
223
|
+
| `external_damping` | `8.0` | Knockback decays over ~0.5s. |
|
|
224
|
+
|
|
225
|
+
## Why Each Feature Matters
|
|
226
|
+
|
|
227
|
+
**Coyote time (~0.1s)** — Players judge ledges by what they see, not by frame-perfect collision. A small grace period after walking off a ledge where jump still works fixes the "I jumped right at the edge and died unfairly" complaint that breaks 60% of new players' tutorials. Cost: 3 lines. Industry-standard window is 0.08–0.15s — short enough to be invisible, long enough to mask the imprecision of human reaction time.
|
|
228
|
+
|
|
229
|
+
**Jump buffering (~0.1s)** — Pressing jump 50ms before landing should not be punished. Buffer the input for ~0.1s and fire on touchdown. Without this, fast-moving players feel like the controller "ate their input" — they pressed jump, they saw nothing happen, they blame the game. With it, every "near-miss" jump becomes a successful one. Cost: 4 lines.
|
|
230
|
+
|
|
231
|
+
**Air control with reduced acceleration (~30% of ground)** — Zero air control feels like ice; the player commits to a direction at jump-takeoff and can't course-correct. Full air control feels like a Unity tutorial; momentum is meaningless and a strafe-jump cancels itself. The shipping FPS standard sits around 25–30% of ground accel: enough to course-correct mid-jump, not enough to cancel realistic momentum. The `move_toward` formulation lets designers tune this with one number — `air_accel` — without forking code.
|
|
232
|
+
|
|
233
|
+
**External velocity accumulator** — Knockback, explosions, jump pads, conveyor belts, wind volumes, and grapple-yanks must not fight player input. Storing them in a separate `external_velocity`, applying once per frame, and decaying via `move_toward(0, damping * delta)` means an explosion shoves the player and then control returns smoothly — no "stuck at terminal velocity" or "input cancels the punch" bugs. The subtract-previous-contribution trick (line 1 of `_physics_process`) is the production fix that prevents accumulation when the player is also sprinting; it's the kind of detail you only learn by shipping multiplayer. Public API (`add_external_velocity`) means any system — AOE attack, jump pad scene, grapple ability — can shove the player with one call, no coupling.
|
|
112
234
|
|
|
113
|
-
|
|
235
|
+
**Acceleration / friction via `move_toward`** — Setting `velocity.x = direction.x * speed` is the AI-tutorial pattern: instant snap to target, instant snap to zero. Real controllers ramp up and ramp down. `move_toward(target, rate * delta)` gives four separate knobs (`ground_accel`, `ground_friction`, `air_accel`, `air_friction`) so designers can dial in "snappy military shooter" (high both) vs "floaty arena shooter" (high accel, low friction) without rewriting code. This is the single biggest reason a controller built from this skeleton feels like a real shipped FPS instead of a prototype.
|
|
114
236
|
|
|
115
|
-
|
|
237
|
+
## Tuning By Feel — Recipe Cards
|
|
116
238
|
|
|
117
|
-
|
|
118
|
-
|----------|-------|
|
|
119
|
-
| position | `"Vector3(0, 1, 0)"` |
|
|
120
|
-
| rotation_degrees | `"Vector3(0, 0, 0)"` |
|
|
121
|
-
| shape | `"CapsuleShape3D"` |
|
|
239
|
+
Once the skeleton is in, designers tune by feel. Common targets:
|
|
122
240
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
241
|
+
**Snappy military shooter (industry-standard FPS feel):**
|
|
242
|
+
- `walk_speed = 5.5`, `sprint_speed = 8.0`
|
|
243
|
+
- `ground_accel = 90`, `ground_friction = 100`
|
|
244
|
+
- `air_accel = 20`, `air_friction = 4`
|
|
245
|
+
- `jump_velocity = 5.0`, `gravity = 22`, `fall_gravity_multiplier = 1.5`
|
|
246
|
+
- `coyote_time = 0.08`, `jump_buffer_time = 0.08`
|
|
247
|
+
|
|
248
|
+
**Floaty arena shooter (longer airtime, more air control):**
|
|
249
|
+
- `walk_speed = 6.5`, `sprint_speed = 10.0`
|
|
250
|
+
- `ground_accel = 60`, `ground_friction = 60`
|
|
251
|
+
- `air_accel = 35`, `air_friction = 2` (low air friction = momentum preserved)
|
|
252
|
+
- `jump_velocity = 7.0`, `gravity = 16`, `fall_gravity_multiplier = 1.2`
|
|
253
|
+
|
|
254
|
+
**Heavy / tactical (slower, more committed):**
|
|
255
|
+
- `walk_speed = 4.0`, `sprint_speed = 6.0`
|
|
256
|
+
- `ground_accel = 50`, `ground_friction = 60`
|
|
257
|
+
- `air_accel = 12`, `air_friction = 6`
|
|
258
|
+
- `jump_velocity = 4.5`, `gravity = 24`
|
|
259
|
+
|
|
260
|
+
The skeleton doesn't change — only the `@export` defaults. That's the point of typed exports.
|
|
128
261
|
|
|
129
262
|
## Common Issues
|
|
130
263
|
|
|
131
|
-
- **
|
|
132
|
-
- **
|
|
133
|
-
- **
|
|
264
|
+
- **Controller feels stiff or instant** — verify `ground_accel` and `ground_friction` are not absurdly high (>500). If they are, `move_toward` saturates in one frame and you get the snap-to-target behaviour you were trying to avoid.
|
|
265
|
+
- **Controller feels mushy / slidey** — `ground_friction` is too low (under 30). Crank to 80–120 for crisp stops.
|
|
266
|
+
- **Jump occasionally fails near ledges** — coyote isn't being reset on landing. Confirm the `is_on_floor()` branch in step 3 runs before the buffer-fires branch in step 4.
|
|
267
|
+
- **Pressing jump just before landing does nothing** — `_jump_buffer_timer` is being decremented to zero before the player touches floor. The buffer must NOT be cleared by anything except a successful jump.
|
|
268
|
+
- **Knockback compounds forever** — `_prev_external_applied` isn't being subtracted. Step 1 of `_physics_process` MUST run before gravity, or external impulses pile up frame after frame.
|
|
269
|
+
- **Knockback decays too fast / too slow** — tune `external_damping`. `8.0` decays a 5 m/s impulse to near-zero in ~0.6s. Lower = longer hangtime.
|
|
270
|
+
- **Air control feels like ice OR like a Unity tutorial** — `air_accel` is the only knob. Industry-standard is 20–30% of `ground_accel`. Zero = ice. Equal = arcade-y. The shipping FPS standard sits around 25.
|
|
271
|
+
- **Camera tilts on slopes** — pitch is on the body instead of the Head. Pitch only on `Head`; yaw only on `Player`.
|
|
272
|
+
- **Camera rolls when looking around** — you're calling `rotate_x` on something that's already rotated on Y. Fix: only ever rotate Y on Player and X on Head, never combine.
|
|
273
|
+
- **Mouse look doesn't work** — `Input.mouse_mode` isn't set to `MOUSE_MODE_CAPTURED`, or another node is consuming `_input` before this script. Use `_unhandled_input` to be a good citizen.
|
|
274
|
+
- **Mouse look feels laggy at low FPS** — sensitivity is being applied in `_physics_process`. Mouse-motion handling MUST stay in `_input` or `_unhandled_input` — those fire per-event, not per-physics-tick.
|
|
275
|
+
- **Falling through floor** — floor needs a StaticBody3D + CollisionShape3D on the same physics layer as the player.
|
|
276
|
+
- **Camera clipping geometry** — push `Camera.near` to ~0.05 or shrink the capsule radius slightly.
|
|
277
|
+
- **Player jitters on slopes / stairs** — enable `floor_snap_length = 0.5` and `floor_max_angle = 0.785` (~45°) on the CharacterBody3D. Out of scope for this skeleton; see the character-body-tuning skill when it ships.
|
|
278
|
+
|
|
279
|
+
## Networking Note
|
|
280
|
+
|
|
281
|
+
For multiplayer, two viable patterns:
|
|
282
|
+
|
|
283
|
+
1. **Server-authoritative (the open-source-friendly path):** gate physics with `if not multiplayer.is_server(): return` for non-local players, sync `global_position` + `velocity` via `MultiplayerSynchronizer`, send input via RPC. This skeleton is compatible — wrap the `_physics_process` body in a `is_local_player` check and add a separate `apply_remote_player_physics` that interpolates from network snapshots.
|
|
284
|
+
2. **Custom action protocol (the shipped-game path):** the same skeleton runs on every client; one peer is "host" and broadcasts authoritative state at a fixed interval. Out of scope for this skill — see the multiplayer-and-networking category.
|
|
285
|
+
|
|
286
|
+
The external-velocity accumulator pattern is a multiplayer-correctness gift either way: explosions and knockbacks happen on the host, the impulse is RPC'd to the affected player, and the client's controller absorbs it without fighting local input. That's why the subtract-previous-contribution trick at the top of `_physics_process` exists — it is the single fix that prevents knockback drift in netcode.
|
|
287
|
+
|
|
288
|
+
## Beyond This Skeleton
|
|
289
|
+
|
|
290
|
+
The skeleton is intentionally ~90 lines. When you outgrow it, add features by *layering*, not by stuffing more branches into `_physics_process`:
|
|
291
|
+
|
|
292
|
+
- **Headbob.** Drive `Head.position.y` from a sine of `accumulated_walk_distance`, not of time — bob disappears when you stop without a fade-out timer. Amplitude scales with current horizontal speed / `walk_speed`. Disable while airborne.
|
|
293
|
+
- **Crouch.** Lerp the capsule height (e.g., 1.8 → 1.0) and the Head Y (1.6 → 0.9) over ~0.15s. Halve `walk_speed` while crouched. Block standing if a short upward shapecast hits ceiling.
|
|
294
|
+
- **Sprint FOV punch.** Tween `Camera.fov` from 75 → 82 over 0.2s when sprint starts, back when it ends. The single cheapest "feel of speed" effect available.
|
|
295
|
+
- **Slide.** When sprint + crouch are pressed together with horizontal speed above a threshold, lock direction to current velocity, set a `slide_timer`, and skip the wish-direction step. Apply slide-specific friction. Restore on timer expiry, ceiling clearance, or speed-below-threshold.
|
|
296
|
+
- **Wallrun, dash, double-jump.** Each is its own override state. Build a state machine (see `scripting-patterns/state-machine-patterns/SKILL.md`) and let each state run its own `_physics_process` branch with an early `move_and_slide()` return. Don't pile them as `if/elif` in a single function.
|
|
297
|
+
- **Camera shake.** Add a `trauma` float on the camera, accumulate via `add_trauma(amount)`, decay each frame, apply a randomized rotation offset proportional to `trauma * trauma`. Subtract the previous frame's shake offset before applying the new one to prevent drift. See the post-processing/screen-shake skill.
|
|
298
|
+
|
|
299
|
+
Each of these is a separate skill or a separate function, not a fork of this script.
|
|
134
300
|
|
|
135
301
|
## Fallback (no MCP — edit `.tscn` directly)
|
|
136
302
|
|
|
137
|
-
|
|
303
|
+
Save as `res://scenes/player.tscn`:
|
|
138
304
|
|
|
139
305
|
```
|
|
140
|
-
[gd_scene load_steps=
|
|
306
|
+
[gd_scene load_steps=3 format=3]
|
|
141
307
|
|
|
142
|
-
[
|
|
308
|
+
[ext_resource type="Script" path="res://scripts/player_controller.gd" id="1"]
|
|
309
|
+
|
|
310
|
+
[sub_resource type="CapsuleShape3D" id="caps"]
|
|
143
311
|
radius = 0.4
|
|
144
|
-
height = 1.
|
|
312
|
+
height = 1.8
|
|
145
313
|
|
|
146
314
|
[node name="Player" type="CharacterBody3D"]
|
|
147
315
|
position = Vector3(0, 1, 0)
|
|
316
|
+
script = ExtResource("1")
|
|
148
317
|
|
|
149
|
-
[node name="
|
|
150
|
-
shape = SubResource("
|
|
318
|
+
[node name="Collision" type="CollisionShape3D" parent="."]
|
|
319
|
+
shape = SubResource("caps")
|
|
151
320
|
|
|
152
|
-
[node name="
|
|
321
|
+
[node name="Head" type="Node3D" parent="."]
|
|
153
322
|
position = Vector3(0, 1.6, 0)
|
|
323
|
+
|
|
324
|
+
[node name="Camera" type="Camera3D" parent="Head"]
|
|
154
325
|
```
|
|
155
326
|
|
|
156
|
-
Then
|
|
327
|
+
Then patch `project.godot` with the InputMap actions (`input/move_forward = { ... }` etc.) and `Write` the script body from the section above.
|
|
328
|
+
|
|
329
|
+
## Collaborative Protocol
|
|
330
|
+
|
|
331
|
+
This skill writes scene nodes, an InputMap, and a `.gd` script. Always ask before applying. Group writes per phase: "May I add the Player + Collision + Head + Camera, bind WASD/jump/sprint, and attach `player_controller.gd`?" See `../../_shared/collaborative-protocol.md`.
|
|
332
|
+
|
|
333
|
+
If the user asks for a "third-person FPS", flag the contradiction: FPS = first-person. Either clarify, or hand off to the `tps-controller` skill when it ships.
|
|
157
334
|
|
|
158
|
-
##
|
|
335
|
+
## See Also
|
|
159
336
|
|
|
160
|
-
|
|
337
|
+
- `_shared/gd-style.md` — typed GDScript conventions used in the skeleton.
|
|
338
|
+
- `_shared/mcp-tools-reference.md` — `summer_set_resource_property` rules + the inline-sub-resource silent-fail trap.
|
|
339
|
+
- `_shared/collaborative-protocol.md` — when to ask before writing.
|
|
340
|
+
- `scripting-patterns/state-machine-patterns/SKILL.md` — once movement grows past this skeleton (slide, grapple, hover), refactor into a state machine instead of stuffing more branches into `_physics_process`.
|
|
341
|
+
- `physics/character-body-tuning/SKILL.md` (when shipped) — slope handling, step-up, floor-snap.
|