scene-scripting · diff
git:20261015.ee152ef to git:20261015.a612b4f
65 added, 635 removed. Audit A to A.
---
name: scene-scripting
description: "Build scenes, levels, HUDs and wiring with one editor GDScript (summer_run_script) instead of call chains."
---
# Scene Scripting
## Overview
- `summer_run_script` executes a GDScript **inside the live editor, against the currently open scene**. One script replaces a chain of `summer_add_node` / `summer_set_prop` calls, and it can do what CRUD ops cannot: loops, randomness, math, procedural geometry, reading existing nodes before deciding.
+ `summer_run_script` runs one GDScript **inside the live editor, against the scene named by `scenePath`**. When that scene is not the edited one, it is opened first (a request of its own) and the person's tab comes back after the run; if it cannot be opened, the script does not run. One script replaces a chain of `summer_add_node` / `summer_set_prop` calls, and it does what single calls cannot: loops, randomness, math, procedural geometry, reading existing nodes before deciding.
- **The rule: 3 or more related ops, or ANY computed placement → write a script.** A single property tweak → `summer_set_prop`. A cold project-wide batch job → `summer_run_editor_script` (see below).
+ **The rule: 3 or more related scene edits, or ANY computed placement → one script.** A single property tweak → `summer_set_prop`. A cold project-wide batch job → `summer_run_editor_script` (below).
## The contract
- Your source is just the function — no `extends`, no `@tool`:
-
- ```gdscript
+ ```
+ summer_run_script(scenePath="res://levels/level_01.tscn", source="""
func run(ctx):
var root = ctx.get_scene_root()
var node := MeshInstance3D.new()
node.mesh = BoxMesh.new()
root.add_child(node)
- ctx.set_owner_recursive(node) # REQUIRED — see owner rules
+ ctx.set_owner_recursive(node) # REQUIRED: see owner rules
ctx.report("added", node.name)
+ ctx.save_scene()
+ """)
```
- - `ctx.get_scene_root()` — root node of the open scene.
- - `ctx.set_owner_recursive(node)` — stamp a created subtree with the scene-root owner.
- - `ctx.report(key, value)` — structured results back to you (`reports` in the result).
- - `print(...)` — captured into `output`.
+ - `scenePath` — the exact `res://` scene the script changes (from `summer_get_project_context` or the scene tree). Always pass it; without it the script runs against whichever tab happens to be open. The result's `scene_target` says whether the scene was opened and which tab came back.
+ - `source` — just the function: no `extends`, no `@tool` (missing ones are added and reported in `normalizations`).
+ - `ctx.get_scene_root()` — root node of that scene. `ctx.set_owner_recursive(node)` — stamp a created subtree with the scene-root owner. `ctx.report(key, value)` — structured results back (`reports`). `print(...)` — captured into `output`.
- Values are real GDScript: `Vector3(0, 10, 0)`, `Color(1, 0, 0, 1)`. The quoted variant-string convention (`"Vector3(0, 10, 0)"`) belongs to `summer_set_prop`, not to script code.
- - Budget: `max_seconds` default 20, clamp 5–120. The script blocks the editor between frames — keep it fast; move heavy batch work to `summer_run_editor_script`. On newer engines the budget is a HARD deadline: overrun raises the script error `"Summer script budget exceeded (Ns)"` (result `budget_enforced: true`). Split the work into smaller scripts — never resubmit the same oversized one.
- - Transactions: newer engines wrap the run in ONE named undo action (`undo: "action"`, the default) and roll it back on a mid-script runtime error — the result then carries `rolled_back: true` and the scene is untouched. Pass `undo: "none"` for checkpoint-only behavior (partial mutations survive an error). Older engines ignore the param; treat any `errors` there as a possible half-applied mutation.
+ - `ctx` is untyped, so its results are untyped: write `var wall = ctx.add_mesh(...)`. `var wall := ctx.add_mesh(...)` does not compile ("Cannot infer the type").
+ - Saving: `ctx.save_scene()` writes the scene (after an ownership audit). Without it the change stays in the editor, unsaved.
+ - Budget: `max_seconds` default 20, clamp 5–120. The script blocks the editor between frames; on newer engines the budget is a HARD deadline (`"Summer script budget exceeded (Ns)"`, `budget_enforced: true`). Split the work into smaller scripts — never resubmit the same oversized one.
+ - Transactions: newer engines wrap the run in ONE named undo action (`undo: "action"`, the default) and roll it back on a mid-script runtime error — the result carries `rolled_back: true` and the scene is untouched. `undo: "none"` keeps partial mutations. Older engines ignore the param; treat any `errors` there as a possible half-applied mutation.
- ## The ctx stdlib (newer engines)
+ ## The core ctx API
- Creation helpers that **set the owner for you** (to the edited scene root) and return the created node — prefer them over the manual `new()` + `add_child` + `set_owner_recursive` steps. Frozen signatures:
+ Creation helpers **set the owner for you** and return the created node — prefer them over `new()` + `add_child` + `set_owner_recursive`. Frozen signatures:
```gdscript
add_node(type: String, name: String, parent: Node = null, props: Dictionary = {}) -> Node
find(name: String) -> Node # recursive in edited scene; null on miss
get_or_create(type: String, name: String, parent: Node = null) -> Node
instance_scene(res_path: String, parent: Node = null, name: String = "") -> Node
add_mesh(shape: String, name: String, parent: Node = null, props: Dictionary = {}) -> MeshInstance3D
# shape: box|sphere|capsule|cylinder|plane
add_mesh_with_collision(shape, name, parent = null, props = {}) -> MeshInstance3D
# + StaticBody3D/CollisionShape3D child matching the mesh
mesh_from_arrays(vertices: PackedVector3Array, indices: PackedInt32Array,
uvs: PackedVector2Array = [], name: String = "Mesh",
parent: Node = null) -> MeshInstance3D
make_material(props: Dictionary) -> StandardMaterial3D
apply_material(node: Node, material: Material) -> bool
grid(count_x: int, count_z: int, spacing: Vector3, maker: Callable) -> Array
# maker(i: int, pos: Vector3) -> Node
scatter(area: AABB, count: int, maker: Callable, seed: int = 0) -> Array
add_light_rig(target: Node = null) -> Node3D # key/fill/rim under one Node3D
ensure_environment(props: Dictionary = {}) -> WorldEnvironment
add_camera(position: Vector3, look_at: Vector3 = Vector3.ZERO,
make_current: bool = false) -> Camera3D
summary() -> Dictionary # counts by class, lights, cameras, scene AABB
save_scene(path: String = "") -> bool # ownership audit first; false + report on audit failure
frames_budget_exceeded() -> bool
```
- `props` dictionaries apply via `set()` per key; unknown keys are collected into a `prop_warnings` report entry, never silently dropped — read it. On an **older engine** a missing helper is a plain GDScript error (`Invalid call to method 'add_mesh'…`): fall back to the manual form, which works everywhere.
-
- ## The geometry & authoring stdlib (geometry tier)
-
- Newer engines extend ctx with a second tier: real geometry (booleans, lathe, sweep, terrain), mesh post-processing (smooth, decimate, UVs, mirror, collision), keyframe animation, and text shaders. Frozen signatures:
-
- ```gdscript
- boolean(a: Node, b: Node, op: String = "union") -> MeshInstance3D
- # op: union|difference|intersection. Manifold-exact CSG, baked to a plain
- # ArrayMesh — no live CSG nodes remain. a and b are CONSUMED (freed) by
- # default; pass keep_inputs: bool = true as the final param to keep them.
- extrude_polygon(points: PackedVector2Array, depth: float, name: String,
- parent: Node = null) -> MeshInstance3D # 2D footprint -> prism, baked
- lathe(points: PackedVector2Array, name: String, parent: Node = null,
- spin_degrees: float = 360.0, sides: int = 32) -> MeshInstance3D # revolve a profile, baked
- sweep(points: PackedVector2Array, path_points: PackedVector3Array,
- name: String, parent: Node = null) -> MeshInstance3D # profile swept along a 3D path, baked
- # add_mesh shapes extended: torus | text (props.text: String, TextMesh)
- set_smooth(node: Node, angle_deg: float = 30.0) -> bool # shade smooth by angle
- terrain(size: Vector2, height: float, seed: int, name: String,
- parent: Node = null, image_path: String = "") -> MeshInstance3D
- # noise heightfield (or heightmap image via image_path); adds
- # HeightMapShape3D collision under a StaticBody3D child automatically
- decimate(node: Node, ratio: float) -> bool # simplify mesh to ~ratio of triangles
- convex_collision(node: Node, decompose: bool = false) -> Node
- # single convex hull, or multi-shape convex decomposition when decompose;
- # ALL created bodies are owned — they survive the save
- uv_planar(node: Node, axis: String = "y", scale: float = 1.0) -> bool
- uv_box(node: Node, scale: float = 1.0) -> bool # make textures land on generated geometry
- mirror(node: Node, axis: String = "x") -> Node # mirrored duplicate
- animate(node: Node, property: String, keys: Array, anim_name: String = "",
- loop: bool = false, player: AnimationPlayer = null) -> AnimationPlayer
- # keys: [[time_s, value], ...]; gets-or-creates the AnimationPlayer and
- # library; position/rotation/scale get dedicated 3D tracks (rotation keys
- # are converted for you — never hand-build quaternion tracks)
- make_shader(code: String, params: Dictionary = {}) -> ShaderMaterial
- # builds Shader + ShaderMaterial from source and sets shader parameters;
- # compile errors come back VERBATIM in the result (the `make_shader_errors`
- # report entry, plus errors[]) — read them
- ```
-
- Conventions, same as the rest of the stdlib: creation helpers set the owner and return the created node; bad input produces a per-helper `report` entry plus a `null`/`false` return, never a crash; every baked mesh carries generated normals (and tangents where UVs exist). On an older engine these helpers are missing — the CSG-node fallback recipe below still works everywhere.
-
- ## The animation stdlib (animation tier)
-
- Character-animation USAGE on top of `animate()` — state machines, method tracks, bone poses, head tracking. Same conventions. Frozen signatures:
-
- ```gdscript
- animate(...) v2 — additive extensions, signature unchanged:
- # keys entries also accept {time, value, interpolation: "nearest"|"linear"|"cubic"}
- # property may target bones: "<skeleton_node>:<bone_name>/position|rotation|scale"
- # -> creates bone position_3d/rotation_3d/scale_3d tracks (path "Skeleton:bone")
- animate_method(node: Node, calls: Array, anim_name: String = "",
- loop: bool = false, player: AnimationPlayer = null) -> AnimationPlayer
- # calls: [[time_s, method_name, args_array], ...] -> one method-call track
- anim_state_machine(target: Node, spec: Dictionary,
- player: AnimationPlayer = null) -> AnimationTree
- # spec: { states: {name: clip_name}, transitions: [[from, to, {auto?: bool,
- # blend_s?: float}], ...], start: name }
- # get-or-create AnimationTree wired to the (found or given) player,
- # AnimationNodeStateMachine, active=true; unknown clip names -> report
- # entry listing the player's actual clips (never silent)
- bone_pose(skeleton: Node, bone: String, pose: Dictionary) -> bool
- # pose keys position/rotation/scale -> set_bone_pose_* on Skeleton3D;
- # unknown bone -> false + report listing bone names (capped 64)
- look_at_modifier(node: Node, target: Node, props: Dictionary = {}) -> Node
- # LookAtModifier3D under the skeleton/node, owned
- ```
-
- Blend shapes key through plain `animate()` with property `"blend_shapes/<name>"` (value tracks) — no separate helper. The end-to-end recipe (inspect an imported rig's real clips/bones, locomotion wiring, footstep method tracks, root motion, the raw-GDScript fallback for engines without these helpers) lives in `character-animation-wiring` — one hop, not duplicated here.
-
- ## The game-completeness stdlib (game-completeness tier: 2D, UI, gameplay code)
-
- Everything a script needs to produce a GAME, not just a 3D scene: 2D levels, Control trees, persisted signal connections, attached scripts, prefabs, project settings. Same conventions (owner-by-default, return the node, `prop_warnings`, structured report entries on failure — never silent). 2D helpers work in any edited scene (root may be `Node2D` or `Node`); Control helpers parent under the edited root unless a parent is given. Frozen signatures:
-
- ```gdscript
- # 2D
- add_sprite(texture_path: String, name: String, parent: Node = null,
- props: Dictionary = {}) -> Sprite2D # missing texture -> report + null
- add_animated_sprite(frames: Dictionary, name: String, parent: Node = null,
- autoplay: String = "") -> AnimatedSprite2D
- # frames: { anim_name: { frames: [texture_paths], fps: float, loop: bool } } -> SpriteFrames
- add_tilemap(tileset_path: String, name: String, parent: Node = null) -> TileMapLayer
- paint_tiles(layer: TileMapLayer, cells: Array, source_id: int,
- atlas_coords: Vector2i = Vector2i.ZERO) -> int # cells: [Vector2i]; returns count
- paint_rect(layer: TileMapLayer, rect: Rect2i, source_id: int,
- atlas_coords: Vector2i = Vector2i.ZERO) -> int
- add_body_2d(kind: String, name: String, parent: Node = null,
- shape: Dictionary = {}, props: Dictionary = {}) -> Node
- # kind: static|rigid|character|area ; shape: {type: rect|circle|capsule, size|radius|height}
- # -> body + owned CollisionShape2D child. Empty shape = a 32x32 rect (confessed in
- # prop_warnings); size may be a float (square); props try the body, then the shape
- add_camera_2d(position: Vector2, zoom: Vector2 = Vector2.ONE, make_current: bool = true,
- limits: Rect2i = Rect2i()) -> Camera2D # empty limits = unlimited
- # Camera2D has no saved "current" flag: make_current persists as `enabled` (+ a live
- # make_current()); make_current: false saves the camera disabled
- add_parallax(layers: Array, name: String, parent: Node = null) -> Node
- # layers: [{texture: path, motion_scale: Vector2, repeat: Vector2i}] -> one Parallax2D per
- # layer under one Node2D group; repeat -> repeat_size. Parallax2D is in the engine tree
- # (no ParallaxBackground fallback); a layer whose texture fails to load is skipped + reported
-
- # UI (Control)
- add_ui(kind: String, name: String, parent: Node = null, props: Dictionary = {}) -> Control
- # kind: label|button|panel|vbox|hbox|grid|margin|texture_rect|progress_bar|line_edit|
- # rich_text|color_rect|center ; panel -> PanelContainer (container semantics for HUD
- # layouts; a plain Panel is add_node("Panel", ...)). props.anchor: full_rect|center|
- # top_left|top_right|bottom_left|bottom_right|top_wide|bottom_wide (+ the other
- # Control::PRESET_* names) -> set_anchors_and_offsets_preset, applied BEFORE the other
- # props so an explicit size/position wins; props.text/custom_minimum_size/etc via set();
- # props.texture as a "res://" path is loaded for you. A Control parented under a Node2D
- # gets a prop_warning (it scrolls with the camera — HUDs go under add_canvas_layer)
- add_canvas_layer(name: String, layer: int = 1, parent: Node = null) -> CanvasLayer
- set_theme_overrides(control: Control, overrides: Dictionary) -> int
- # {font_size: int, font_color: Color, <theme_item>: value} -> add_theme_*_override; count applied
- # dispatch by value: Color or "#hex" -> color; int/float -> font_size when the key is/ends with
- # font_size, else constant; StyleBox/Font/Texture2D (or a "res://" path) -> style/font/icon.
- # An item the control's theme type does not define is stored but confessed in prop_warnings
- connect_signal(emitter: Node, signal_name: String, target: Node, method: String,
- binds: Array = []) -> bool # PERSIST flag so it saves in .tscn;
- # unknown signal/method -> report, false
- # NOT `connect` — a ctx method of that name would shadow Object.connect on the ctx object,
- # so nothing named `connect` is bound. Method gate: target.has_method, else its script
- # has_method, else the script SOURCE declares `func <method>(` (attached this run, not yet
- # compiled). Already connected -> true (+ already_connected in the report). Both ends must
- # be owned by the edited root for the .tscn to carry the connection (prop_warning otherwise)
-
- # Gameplay-code lane
- attach_script(node: Node, source: String, path: String = "") -> Script
- # compile-validate first (GDScriptLanguage::validate; parse errors -> `attach_script_errors`
- # report entry {node, path, errors: [{line, column, message}]}, null, nothing written);
- # path empty -> res://scripts/<SceneName>/<NodeName>.gd (dirs created; the root node's name
- # when the scene was never saved, prop_warning); writes the file, FS rescan, set_script.
- # An existing file at path IS overwritten; its previous sha is recorded in the report (the
- # pre-run checkpoint covers rollback). Gate: the script's native base (`extends`) must be an
- # ancestor of the node's class — else the file is written but NOT attached (report, null)
- make_prefab(node: Node, path: String, replace_with_instance: bool = true) -> Node
- # pack the subtree to res:// path as a PackedScene (owner fix-up first), save; when
- # replace_with_instance the node is swapped for an instance of the new file (same
- # name/transform/parent; outgoing persisted connections re-established) and the instance
- # is returned; else returns node. REFUSES the scene root and a path open in an editor tab.
- # The node's own position is zeroed for the pack (prefab authored at its origin, like the
- # editor's Save Branch) and restored / copied onto the instance; rotation and scale stay in
- # the prefab. A failure AFTER the file was written keeps + returns the original node (report)
- add_autoload(name: String, path: String) -> bool # ProjectSettings autoload/<name>="*res://…"
- # same gates as the editor's autoload dock: valid identifier, no engine/global-class
- # collision, existing res:// path to a Script or PackedScene
- add_input_action(action: String, events: Array) -> bool
- # events: [{type: key, keycode: String|int} | {type: mouse_button, button_index: int} |
- # {type: joypad_button, button_index: int} | {type: joypad_axis, axis:int, value:float}]
- # writes input/<action> = {deadzone, events} in ProjectSettings + saves; every event is
- # built BEFORE anything is written — unknown shape -> report, false, nothing half-written.
- # New action: deadzone = the engine default 0.2. Existing action: keeps its deadzone and
- # gains only the events it does not already match. Keycode strings go through
- # find_keycode ("Space", "Left", "Ctrl+S" — modifiers become the event's modifier flags)
- set_main_scene(path: String) -> bool # application/run/main_scene + save
- # path must exist and be a PackedScene
- # Project settings are written in the res:// PATH form ("*res://…", "res://…"); the editor UI
- # writes uid:// forms — both resolve. Do not "fix" one into the other.
- ```
+ `props` apply via `set()` per key; unknown keys are collected into a `prop_warnings` report entry, never silently dropped — read it. On an **older engine** a missing helper is a plain GDScript error (`Invalid call to method 'add_mesh'…`): fall back to the manual form, which works everywhere.
- **Engine posture. Read before relying on any of this.** The game-completeness tier is a frozen contract that not every engine build exposes yet; treat it with the same preview posture as `summer_run_script` itself (which returns `engine_lacks_op` on engines without RunSceneScript). On an engine that has RunSceneScript but predates this tier, a missing helper is a plain GDScript error (`Invalid call to method 'add_ui'…`) and, under the default `undo: "action"`, the whole run rolls back. Fall back to what works everywhere:
+ Newer engines add three more tiers. Their frozen signatures, conventions and fallbacks are in `references/ctx-api.md` (read it with `summer_read_library` id `skill/scene-scripting/references/ctx-api.md`):
- | Game-completeness helper | Works on every engine |
- |---|---|
- | `add_tilemap` / `paint_tiles` / `paint_rect` | `TileMapLayer.new()` + `layer.tile_set = load(path)`, then `layer.set_cell(coords, source_id, atlas_coords)` in a loop — the GridMap recipe, in 2D |
- | `add_sprite` / `add_animated_sprite` / `add_body_2d` / `add_camera_2d` / `add_parallax` | manual `.new()` + `add_child` + `ctx.set_owner_recursive` |
- | `add_ui` / `add_canvas_layer` / `set_theme_overrides` | `.new()` + `set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)` + `add_theme_constant_override(...)`, owned as above — or the `ui-basics` skill's `summer_add_node` / `summer_set_prop` path |
- | `connect_signal` | `summer_connect_signal` (persists in the `.tscn`); a raw `emitter.pressed.connect(...)` inside the run script does NOT persist |
- | `attach_script` | `summer_write_file` the source, `summer_get_script_errors` on it, then `node.set_script(load(path))` in a script |
- | `make_prefab` | `PackedScene.new().pack(node)` + `ResourceSaver.save(...)` (pack includes only OWNED descendants — owner rules again), then `summer_instantiate_scene` |
- | `add_input_action` / `add_autoload` / `set_main_scene` | `summer_input_map_bind`; `summer_project_setting` (`autoload/<Name>`, `application/run/main_scene`) |
+ - **Geometry:** `boolean`, `extrude_polygon`, `lathe`, `sweep`, `terrain`, `set_smooth`, `decimate`, `convex_collision`, `uv_planar` / `uv_box`, `mirror`, `animate`, `make_shader`; `add_mesh` also takes `torus` and `text`.
+ - **Animation:** `animate_method`, `anim_state_machine`, `bone_pose`, `look_at_modifier` (the full recipe lives in `character-animation-wiring`).
+ - **Game completeness (2D, UI, gameplay code):** `add_sprite`, `add_animated_sprite`, `add_tilemap`, `paint_tiles` / `paint_rect`, `add_body_2d`, `add_camera_2d`, `add_parallax`, `add_ui`, `add_canvas_layer`, `set_theme_overrides`, `connect_signal`, `attach_script`, `make_prefab`, `add_autoload`, `add_input_action`, `set_main_scene`.
- **Undo posture.** File-writing helpers (`attach_script`, `make_prefab`) and project-settings helpers (`add_autoload`, `add_input_action`, `set_main_scene`) are covered by the pre-run checkpoint, NOT by the packed-scene undo action — a `rolled_back: true` result has reverted the scene but not those files or settings. The result's `undo_action` note says so whenever any of them ran (`files_written: [paths]`, `project_settings_changed: [keys]`): read it, and surface `no_rewind_point: true` to the user before more work of this kind.
+ **Engine posture.** Not every engine build exposes every tier. On an engine with RunSceneScript that predates a tier, a missing helper is a plain GDScript error and, under the default `undo: "action"`, the whole run rolls back. `references/ctx-api.md` has the table of what works on every engine.
## Owner rules
- <EXTREMELY-IMPORTANT>
**Every node you create must be owned by the scene root, or it silently vanishes when the scene saves.** Descendants too — being under an owned node is NOT enough. `add_child` succeeds, the screenshot even shows it, and the saved `.tscn` is missing it.
```gdscript
root.add_child(node)
ctx.set_owner_recursive(node) # node + every descendant
```
- `ctx.set_owner_recursive(node)` is the ctx helper for exactly this; the manual form is `node.owner = root` on the node AND each descendant. Either way, set owners AFTER `add_child` — owner assignment fails on a node not yet in the tree.
- </EXTREMELY-IMPORTANT>
+ The ctx creation helpers own what they create. For anything you build with `new()`, call `ctx.set_owner_recursive(node)` (or set `node.owner = root` on the node AND each descendant) AFTER `add_child` — owner assignment fails on a node not yet in the tree. Re-stamp the owner after any `reparent`.
## The loop
1. `summer_world_snapshot` — the structured BEFORE baseline; keep its `snapshot_id`.
2. `summer_api_docs` — verify every property/method name you are not certain of.
- 3. `summer_run_script` — run the script.
- 4. Read `errors`, `reports`, and `rolled_back` in the result — `rolled_back: true` means a runtime error undid everything (fix and re-run); on older engines / `undo: "none"`, an error can leave a partial mutation.
+ 3. `summer_run_script` with `scenePath` — run the script.
+ 4. Read `errors`, `reports`, `rolled_back` and `scene_target` in the result — `rolled_back: true` means a runtime error undid everything (fix and re-run); on older engines / `undo: "none"`, an error can leave a partial mutation.
5. `summer_snapshot_diff from_id:<the id>` — the structural receipt: exactly the nodes you meant to add were added, nothing else changed, nothing vanished (an unowned node dropping on save shows up HERE).
6. `summer_screenshot` — LOOK at it. Use `target:"scene" framing:"camera"` when the change touched lighting, environment, or emissive materials — the preset framings render a flat substitute environment and cannot show those.
- 7. Iterate. Never claim visual success without steps 5–6 (honesty rule: a capture is a fact; describing an unseen frame is fabrication). The full perception discipline lives in `verifying-scenes`.
+ 7. Iterate. Never claim visual success without steps 5–6 (a capture is a fact; describing an unseen frame is fabrication). The full perception discipline lives in `verifying-scenes`.
## Verify names with summer_api_docs — never guess
Wrong property names fail silently or throw mid-script after half the mutation applied. Before using an unfamiliar class:
```
- summer_api_docs class_name:"CylinderMesh" → all properties/methods
- summer_api_docs class_name:"BoxShape3D" member:"size" → {type:"Vector3", default:"Vector3(1, 1, 1)"}
+ summer_api_docs(class_name="CylinderMesh") # all properties/methods
+ summer_api_docs(class_name="BoxShape3D", member="size") # {type:"Vector3", default:"Vector3(1, 1, 1)"}
```
Entries list only members **declared on that class** — `position` lives on `Node3D`, not `MeshInstance3D`. Walk `inherits` upward when a member seems missing.
- ## Recipes
+ ## Two examples
- ### Scatter N instances with randomized transforms
+ Scatter 40 trees with deterministic randomness:
- ```gdscript
+ ```
+ summer_run_script(scenePath="res://levels/forest.tscn", source="""
func run(ctx):
- var root = ctx.get_scene_root()
var rng := RandomNumberGenerator.new()
- rng.seed = 12345 # deterministic re-runs
- var source: PackedScene = load("res://props/tree.tscn")
+ rng.seed = 12345 # same seed, same forest on a re-run
for i in range(40):
- var tree := source.instantiate()
- root.add_child(tree)
- ctx.set_owner_recursive(tree) # owns the whole instanced subtree
+ var tree = ctx.instance_scene("res://props/tree.tscn", null, "Tree%d" % i)
tree.position = Vector3(rng.randf_range(-20, 20), 0, rng.randf_range(-20, 20))
tree.rotation.y = rng.randf_range(0, TAU)
var s := rng.randf_range(0.8, 1.3)
tree.scale = Vector3(s, s, s)
ctx.report("scattered", 40)
- ```
-
- ### Procedural mesh via SurfaceTool
-
- ```gdscript
- func run(ctx):
- var root = ctx.get_scene_root()
- var st := SurfaceTool.new()
- st.begin(Mesh.PRIMITIVE_TRIANGLES)
- st.set_normal(Vector3.UP)
- st.add_vertex(Vector3(0, 0, 0))
- st.add_vertex(Vector3(1, 0, 0))
- st.add_vertex(Vector3(0, 0, 1))
- var mesh := st.commit() # ArrayMesh
- var mi := MeshInstance3D.new()
- mi.mesh = mesh
- mi.name = "GeneratedMesh"
- root.add_child(mi)
- ctx.set_owner_recursive(mi)
- ctx.report("surfaces", mesh.get_surface_count())
- ```
-
- For grids/heightfields, build `PackedVector3Array`s and feed `ArrayMesh.add_surface_from_arrays` directly. Call `st.generate_normals()` before `commit()` when you did not set normals by hand.
-
- ### CSG primitives (fast blockouts)
-
- ```gdscript
- func run(ctx):
- var root = ctx.get_scene_root()
- var body := CSGCombiner3D.new()
- body.name = "Blockout"
- root.add_child(body)
- var box := CSGBox3D.new()
- box.size = Vector3(6, 3, 6)
- body.add_child(box)
- var hole := CSGCylinder3D.new()
- hole.operation = CSGShape3D.OPERATION_SUBTRACTION
- hole.radius = 1.0
- hole.height = 7.0
- body.add_child(hole)
- ctx.set_owner_recursive(body) # owner is the SCENE root, not the parent
- ```
-
- Live CSG nodes are the **older-engine fallback** — they re-evaluate every frame and should be converted before shipping (`scene-composition`). On a geometry-tier engine prefer `ctx.boolean(...)`, which bakes to a plain ArrayMesh and leaves no CSG nodes behind.
-
- ### Boolean blockout — carve a doorway (geometry tier)
-
- Box minus box, baked. The inputs are consumed; only the result remains.
-
- ```gdscript
- func run(ctx):
- var wall := ctx.add_mesh("box", "Wall", null, {"size": Vector3(6, 3, 0.3)})
- var hole := ctx.add_mesh("box", "DoorHole", null, {"size": Vector3(1.0, 2.1, 0.5)})
- hole.position = Vector3(0, -0.45, 0) # sink the opening to floor level
- var carved := ctx.boolean(wall, hole, "difference")
- if carved == null:
- return # reports carry the reason — read them
- carved.name = "WallWithDoorway"
- ctx.uv_box(carved) # boolean output needs UVs to take a texture
- ctx.report("doorway", str(carved.get_path()))
- ```
-
- Then `summer_snapshot_diff` must show `Wall` and `DoorHole` GONE and `WallWithDoorway` added — leftover inputs mean the boolean failed and returned null. Screenshot to check the opening is where you meant.
-
- ### Lathe a goblet / pillar (geometry tier)
-
- `lathe` revolves a 2D profile (x = radius, y = height) around Y. Design the profile from the axis outward.
-
- ```gdscript
- func run(ctx):
- var profile := PackedVector2Array([
- Vector2(0.0, 0.0), Vector2(0.45, 0.0), # foot
- Vector2(0.08, 0.1), Vector2(0.08, 0.55), # stem
- Vector2(0.35, 0.7), Vector2(0.4, 1.1), # bowl
- Vector2(0.0, 1.15),
- ])
- var goblet := ctx.lathe(profile, "Goblet", null, 360.0, 48)
- ctx.set_smooth(goblet, 40.0) # lathes look faceted without it
- ctx.apply_material(goblet, ctx.make_material({"albedo_color": Color(0.85, 0.7, 0.2), "metallic": 0.8, "roughness": 0.25}))
- ```
-
- A square-ish profile with `sides: 8` and `spin_degrees: 360` makes a chamfered pillar; `spin_degrees: 180` makes an apse/half-dome.
-
- ### Sweep a rail / pipe (geometry tier)
-
- `sweep` extrudes a 2D cross-section along a 3D polyline.
-
- ```gdscript
- func run(ctx):
- var section := PackedVector2Array([ # small circle-ish octagon, the pipe wall
- Vector2(0.05, 0), Vector2(0.035, 0.035), Vector2(0, 0.05), Vector2(-0.035, 0.035),
- Vector2(-0.05, 0), Vector2(-0.035, -0.035), Vector2(0, -0.05), Vector2(0.035, -0.035),
- ])
- var path := PackedVector3Array([
- Vector3(0, 1, 0), Vector3(4, 1, 0), Vector3(6, 1, 2), Vector3(6, 3, 6),
- ])
- var pipe := ctx.sweep(section, path, "SteamPipe")
- ctx.set_smooth(pipe, 45.0)
- ```
-
- Rails, roads, cables, gutters — same recipe, different cross-section. Keep path points a reasonable distance apart; hairpin corners self-intersect.
-
- ### Terrain with collision (geometry tier)
-
- ```gdscript
- func run(ctx):
- var ground := ctx.terrain(Vector2(64, 64), 6.0, 1337, "Terrain")
- ctx.apply_material(ground, ctx.make_material({"albedo_color": Color(0.35, 0.5, 0.25), "roughness": 1.0}))
- ctx.report("terrain", ctx.summary())
- ```
-
- Collision arrives automatically (a `StaticBody3D` + `HeightMapShape3D` child), already owned. Same seed → same terrain, so re-runs are deterministic. Pass `image_path` to drive heights from a grayscale heightmap instead of noise. Verify with `summer_screenshot framing:"camera"` — terrain reads wrong from preset framings' top-down angles.
-
- ### Text signage (geometry tier)
-
- `add_mesh` accepts `torus` and `text` on geometry-tier engines:
-
- ```gdscript
- func run(ctx):
- var sign := ctx.add_mesh("text", "ExitSign", null, {"text": "EXIT", "depth": 0.08})
- sign.position = Vector3(0, 2.6, -4)
- ctx.apply_material(sign, ctx.make_material({"albedo_color": Color(1, 0.2, 0.2), "emission_enabled": true, "emission": Color(1, 0.1, 0.1), "emission_energy_multiplier": 3.0}))
- ```
-
- Emissive text only proves itself in a `framing:"camera"` screenshot — preset framings substitute the environment and mute emission.
-
- ### Decimate for LOD, convex collision for props (geometry tier)
-
- Dense generated/imported meshes (Meshy-class output) want both before they ship:
-
- ```gdscript
- func run(ctx):
- var prop := ctx.find("AncientStatue") # e.g. a generated import
- if prop == null:
- ctx.report("error", "AncientStatue not found")
- return
- ctx.decimate(prop, 0.35) # keep ~35% of the triangles
- var body := ctx.convex_collision(prop, true) # V-HACD multi-shape for concave props
- ctx.report("collision_body", str(body.get_path()))
- ```
-
- `decimate` returns `false` (with a report entry) instead of ruining the mesh when the ratio is out of range. `convex_collision` owns every body it creates — the diff after `save_scene` is your receipt that nothing was silently dropped. Simple convex props (crates, rocks): leave `decompose` false, one hull is cheaper.
-
- ### Mirror symmetry (geometry tier)
-
- ```gdscript
- func run(ctx):
- var left_tower := ctx.find("Tower")
- var right_tower := ctx.mirror(left_tower, "x") # mirrored duplicate, owned
- right_tower.name = "TowerMirrored"
- ```
-
- The mirror is a real duplicate with flipped winding/normals — edit either side independently afterward.
-
- ### Animate — camera flythrough and a door-open (geometry tier)
-
- `animate` collapses the AnimationPlayer/library/track boilerplate into one call per property. Multiple calls with the same `anim_name` append tracks to the same clip.
-
- ```gdscript
- func run(ctx):
- var cam := ctx.add_camera(Vector3(0, 4, 12), Vector3.ZERO, true)
- ctx.animate(cam, "position", [
- [0.0, Vector3(0, 4, 12)],
- [4.0, Vector3(8, 5, 4)],
- [8.0, Vector3(0, 6, -10)],
- ], "flythrough")
- ctx.animate(cam, "rotation_degrees", [
- [0.0, Vector3(-10, 0, 0)],
- [4.0, Vector3(-12, 60, 0)],
- [8.0, Vector3(-15, 175, 0)],
- ], "flythrough") # same clip — second track appended
-
- var door := ctx.find("Door")
- ctx.animate(door, "rotation_degrees", [
- [0.0, Vector3(0, 0, 0)],
- [0.8, Vector3(0, 110, 0)],
- ], "open")
- ```
-
- Pass plain `position` / `rotation_degrees` values — the helper picks the right track type and handles the quaternion conversion for rotation. Verify by playing: `summer_play`, trigger the clip (or set autoplay), `summer_screenshot target:"game"`, `summer_stop`.
-
- ### make_shader — dissolve / glow FX with the compile-error loop (geometry tier)
-
- `make_shader` is the safe lane for text shaders because compile errors come back **verbatim** in the result (the `make_shader_errors` report entry, with the line number) instead of failing silently to a magenta material:
-
- ```gdscript
- func run(ctx):
- var mat := ctx.make_shader("""
- shader_type spatial;
- uniform float threshold : hint_range(0.0, 1.0) = 0.3;
- uniform vec4 edge_color : source_color = vec4(1.0, 0.55, 0.1, 1.0);
- void fragment() {
- float n = fract(sin(dot(UV * 12.0, vec2(12.9898, 78.233))) * 43758.5453);
- if (n < threshold) { discard; }
- ALBEDO = vec3(0.8);
- EMISSION = edge_color.rgb * smoothstep(threshold + 0.08, threshold, n) * 4.0;
- }
- """, {"threshold": 0.35})
- ctx.apply_material(ctx.find("Crystal"), mat)
- ```
-
- The loop when it fails: read the exact compile error (line number included) from the `make_shader_errors` report entry → fix that line → re-run. Do not guess-and-mutate; the error text names the line and identifier. For production-grade dissolve/fire/glow (FBM noise include, controllers, cookbook variants) use the `vfx-<effect>` recipes — `make_shader` is the fast lane for one-off FX and for iterating on a shader before writing it to a file.
-
- ### GridMap fills
-
- ```gdscript
- func run(ctx):
- var root = ctx.get_scene_root()
- var grid: GridMap = root.get_node("GridMap") # needs mesh_library assigned
- for x in range(16):
- for z in range(16):
- grid.set_cell_item(Vector3i(x, 0, z), 0) # item = MeshLibrary index
- ctx.report("cells", 256)
- ```
-
- Cells serialize into packed data — scripting is the ONLY sane way to fill them; never hand-edit `tile_map_data`-style fields as text.
-
- ### 3-point lighting rig
-
- ```gdscript
- func run(ctx):
- var root = ctx.get_scene_root()
- var rig := Node3D.new()
- rig.name = "LightRig"
- root.add_child(rig)
-
- var key := DirectionalLight3D.new()
- key.name = "KeyLight"
- key.light_energy = 1.2
- key.shadow_enabled = true
- key.rotation_degrees = Vector3(-45, -30, 0)
-
- var fill := OmniLight3D.new()
- fill.name = "FillLight"
- fill.light_energy = 0.4
- fill.position = Vector3(-4, 2, 4)
-
- var rim := SpotLight3D.new()
- rim.name = "RimLight"
- rim.light_energy = 0.8
- rim.position = Vector3(0, 3, -5)
- rim.rotation_degrees = Vector3(-20, 180, 0)
-
- for light in [key, fill, rim]:
- rig.add_child(light)
- ctx.set_owner_recursive(rig)
- ```
-
- Then `summer_screenshot` — lighting is exactly the kind of change you cannot judge without pixels.
-
- ### 2D platformer level — tilemap paint, character body, camera limits, parallax (game-completeness tier)
-
- One script lays down the level: paint the ground, drop a character body with collision, bound the camera to the level, stack parallax behind it. Tile coordinates are cells; camera limits are pixels (cells × tile size — 32 here).
-
- ```gdscript
- func run(ctx):
- const TILE := 32
- var ground := ctx.add_tilemap("res://tilesets/grass.tres", "Ground")
- var painted := ctx.paint_rect(ground, Rect2i(0, 12, 64, 4), 0, Vector2i(0, 0)) # floor: 64 wide, 4 deep
- painted += ctx.paint_rect(ground, Rect2i(20, 8, 6, 1), 0, Vector2i(1, 0)) # a floating platform
- painted += ctx.paint_tiles(ground, [Vector2i(10, 11), Vector2i(11, 11), Vector2i(12, 11)], 0, Vector2i(2, 0)) # a step
- ctx.report("tiles_painted", painted)
-
- var player := ctx.add_body_2d("character", "Player", null,
- {"type": "capsule", "radius": 14.0, "height": 60.0},
- {"position": Vector2(3 * TILE, 11 * TILE)})
- ctx.add_sprite("res://sprites/player_idle.png", "Sprite", player)
-
- var cam := ctx.add_camera_2d(player.position, Vector2(2, 2), true, Rect2i(0, 0, 64 * TILE, 16 * TILE))
- cam.reparent(player, false) # follow the body...
- cam.position = Vector2.ZERO
- ctx.set_owner_recursive(cam) # ...and re-stamp the owner after ANY reparent
-
- ctx.add_parallax([
- {"texture": "res://backgrounds/sky.png", "motion_scale": Vector2(0.1, 0.1), "repeat": Vector2i(1024, 0)},
- {"texture": "res://backgrounds/hills.png", "motion_scale": Vector2(0.4, 0.4), "repeat": Vector2i(1024, 0)},
- ], "Background")
- ctx.report("level", ctx.summary())
- ```
-
- Compare `tiles_painted` with the cell areas you asked for (256 + 6 + 3 here) — a short count means cells were skipped. Then `summer_snapshot_diff`: `added` must list `Ground`, `Player` **with its `CollisionShape2D` child**, the camera under `Player`, and the `Background` group with one `Parallax2D` per layer. Screenshot `target:"scene" nodePath:"Player"` to see the body standing on the floor row, then the whole scene to see the platform is reachable. Movement needs the controller script and the input actions — two recipes below. Want a working baseline instead of a blank scene? The `2d-platformer` template already has tilemap + `CharacterBody2D` + parallax wired.
-
- ### Animated sprite (game-completeness tier)
-
- `add_animated_sprite` builds the `SpriteFrames` resource from per-frame texture paths — one entry per animation, `fps` and `loop` per clip — and optionally autoplays one. Cut a sheet into frames first (`summer_slice_asset_sheet`, or the `sprite-sheet` skill when generating them).
-
- ```gdscript
- func run(ctx):
- var player := ctx.find("Player")
- if player == null:
- ctx.report("error", "Player not found")
- return
- var anim := ctx.add_animated_sprite({
- "idle": {"frames": ["res://sprites/player/idle_0.png", "res://sprites/player/idle_1.png"], "fps": 4.0, "loop": true},
- "run": {"frames": ["res://sprites/player/run_0.png", "res://sprites/player/run_1.png",
- "res://sprites/player/run_2.png", "res://sprites/player/run_3.png"], "fps": 12.0, "loop": true},
- "jump": {"frames": ["res://sprites/player/jump_0.png"], "fps": 1.0, "loop": false},
- }, "Anim", player, "idle")
- ctx.report("animations", anim.sprite_frames.get_animation_names())
- ```
-
- A missing texture path comes back as a report entry — read it before wondering why a clip is short. Which clip plays when is gameplay code (`anim.play("run")` from the controller's `_physics_process`), not scene setup. Verify by playing: `summer_play`, move, `summer_screenshot target:"game"` — an edit-time preview shows frame 0 only.
-
- ### HUD — canvas layer, margin, vbox, label/progress/button, anchors, theme overrides (game-completeness tier)
-
- ```gdscript
- func run(ctx):
- var hud := ctx.add_canvas_layer("HUD", 1)
- var margin := ctx.add_ui("margin", "Margin", hud, {"anchor": "full_rect"})
- ctx.set_theme_overrides(margin, {"margin_left": 24, "margin_top": 24, "margin_right": 24, "margin_bottom": 24})
-
- var column := ctx.add_ui("vbox", "TopLeft", margin)
- var score := ctx.add_ui("label", "Score", column, {"text": "Score: 0"})
- ctx.set_theme_overrides(score, {"font_size": 28, "font_color": Color(1, 1, 1)})
- var health := ctx.add_ui("progress_bar", "Health", column,
- {"min_value": 0, "max_value": 100, "value": 100, "show_percentage": false,
- "custom_minimum_size": Vector2(240, 18)})
-
- var pause := ctx.add_ui("button", "PauseButton", hud, {"text": "Pause", "anchor": "top_right"})
- pause.process_mode = Node.PROCESS_MODE_ALWAYS # still clickable while the tree is paused
- ctx.report("hud", ctx.summary())
+ ctx.save_scene()
+ """)
```
- `props.anchor` runs `set_anchors_and_offsets_preset` — the right way to pin a Control; hand-set `anchor_*`/`offset_*` values drift the moment the window resizes. The preset is applied before the other props, so an explicit `size`/`position` in the same dict wins. `add_ui("panel", …)` is a **PanelContainer** (it sizes to its child — a backdrop for a vbox/hbox); a plain `Panel` is `add_node("Panel", …)`. `set_theme_overrides` returns how many overrides it applied — compare with what you passed (an `int` on a key that is not `font_size` becomes a *constant* override, as with the margins above). Unknown `props` keys land in `prop_warnings` as always (use real property names: `custom_minimum_size`, not `min_size` or a guess). HUD Controls belong under a `CanvasLayer` — a Control parented under a `Node2D` scrolls with the camera and gets a `prop_warning`. For the layout and theme *design* — which container for which job, theme vs inline styling — use `ui-basics`; this is its one-script form.
-
- ### Wire a button with connect_signal() (game-completeness tier)
-
- `ctx.connect_signal` makes a **persisted** connection (`CONNECT_PERSIST`) — it survives in the `.tscn`, exactly what the editor's ConnectionsDock writes. The target needs a method to call, so attach the script first; a script attached in the same run has not compiled yet, and `connect_signal` accepts it because the source declares `func _on_pause_pressed(` (the method gate):
-
- ```gdscript
- func run(ctx):
- var hud := ctx.find("HUD")
- var button := ctx.find("PauseButton")
- if ctx.attach_script(hud, """
- extends CanvasLayer
+ A key/fill/rim lighting rig:
- func _on_pause_pressed() -> void:
- get_tree().paused = not get_tree().paused
- """) == null:
- return # parse errors are in the report — fix, re-run
- if not ctx.connect_signal(button, "pressed", hud, "_on_pause_pressed"):
- return # the report names the unknown signal or method
- ctx.report("wired", "PauseButton.pressed -> HUD._on_pause_pressed")
```
-
- Never wire with a bare `button.pressed.connect(...)` inside the run script: that connection lives only in the editor process and is not saved. And the helper is `connect_signal`, not `connect` — `ctx.connect(...)` is `Object.connect` on the ctx object itself and fails with a wrong-signature error. `true` from `ctx.connect_signal` says the connection was made and flagged persistent (an already-connected pair also returns `true`, with `already_connected` in the report); both ends must be owned by the edited root or the `.tscn` cannot carry it. The proof is `summer_play`, click, `summer_screenshot target:"game"`.
-
- ### attach_script — behavior with the parse-error loop (game-completeness tier)
-
- `attach_script` validates the source **before** writing anything: a parse error comes back as a report entry (with the line) and the return is `null` — no half-written file, no broken node. Empty `path` → `res://scripts/<SceneName>/<NodeName>.gd`.
-
- ```gdscript
+ summer_run_script(scenePath="res://main.tscn", source="""
func run(ctx):
- var player := ctx.find("Player")
- var script := ctx.attach_script(player, """
- extends CharacterBody2D
-
- const SPEED := 220.0
- const JUMP_VELOCITY := -420.0
-
- @onready var anim: AnimatedSprite2D = $Anim
-
- func _physics_process(delta: float) -> void:
- if not is_on_floor():
- velocity += get_gravity() * delta
- if Input.is_action_just_pressed("jump") and is_on_floor():
- velocity.y = JUMP_VELOCITY
- var dir := Input.get_axis("move_left", "move_right")
- velocity.x = dir * SPEED
- if dir != 0.0:
- anim.flip_h = dir < 0.0
- anim.play("jump" if not is_on_floor() else ("run" if dir != 0.0 else "idle"))
- move_and_slide()
+ var rig = ctx.add_node("Node3D", "LightRig")
+ ctx.add_node("DirectionalLight3D", "KeyLight", rig, {"light_energy": 1.2, "shadow_enabled": true, "rotation_degrees": Vector3(-45, -30, 0)})
+ ctx.add_node("OmniLight3D", "FillLight", rig, {"light_energy": 0.4, "position": Vector3(-4, 2, 4)})
+ ctx.add_node("SpotLight3D", "RimLight", rig, {"light_energy": 0.8, "position": Vector3(0, 3, -5), "rotation_degrees": Vector3(-20, 180, 0)})
+ ctx.save_scene()
""")
- if script == null:
- return # read the report: line + message; fix THAT line; re-run
- ctx.report("script", script.resource_path)
```
- The loop: read the exact parse error (line and identifier) from the report → fix that line → re-run. Do not guess-and-mutate. An existing file at `path` is overwritten and its previous sha recorded in the report — the pre-run checkpoint is the rollback for that, not the undo action. Idioms (typed declarations, signals, `_ready` vs `_process`): `gdscript-patterns`. Later edits to a script already on disk: `summer_write_file` + `summer_get_script_errors`.
-
- ### make_prefab — turn a built subtree into a reusable .tscn (game-completeness tier)
-
- Build the thing once in-scene, then pack it. With `replace_with_instance` (the default) the inline subtree is swapped for an instance of the new file — same name, transform, parent — and the instance is returned.
-
- ```gdscript
- func run(ctx):
- var torch := ctx.find("Torch") # Sprite2D + PointLight2D + Area2D you built above
- if torch == null:
- return
- var first := ctx.make_prefab(torch, "res://prefabs/torch.tscn")
- for i in range(3):
- var t := ctx.instance_scene("res://prefabs/torch.tscn", null, "Torch%d" % (i + 2))
- t.position = first.position + Vector2(160 * (i + 1), 0)
- ctx.report("prefab", first.scene_file_path)
- ```
-
- The owner fix-up runs before packing, so a child you forgot to own is included instead of silently dropped. The prefab is authored at its origin — the node's position is zeroed for the pack (the editor's Save Branch default) and copied back onto the instance, so `first.position` above is still where the torch stood; rotation and scale stay in the prefab. `make_prefab` refuses the scene root (pack a child subtree, never `ctx.get_scene_root()`) and a path that is open in an editor tab. The receipt: `res://prefabs/torch.tscn` in the result's `files_written`, `Torch` now reporting a `scene_file_path`, and the copies in the diff's `added`. From here on, edit the prefab file, not the instances.
-
- ### Input actions, autoload, main scene (game-completeness tier)
-
- Project-level wiring in one run — the platformer controller above already reads these actions:
-
- ```gdscript
- func run(ctx):
- ctx.add_input_action("move_left", [{"type": "key", "keycode": "A"}, {"type": "key", "keycode": "Left"},
- {"type": "joypad_axis", "axis": 0, "value": -1.0}])
- ctx.add_input_action("move_right", [{"type": "key", "keycode": "D"}, {"type": "key", "keycode": "Right"},
- {"type": "joypad_axis", "axis": 0, "value": 1.0}])
- ctx.add_input_action("jump", [{"type": "key", "keycode": "Space"}, {"type": "joypad_button", "button_index": 0}])
- ctx.add_autoload("GameState", "res://scripts/autoload/game_state.gd") # the file must exist — write it first
- if ctx.save_scene("res://levels/level_01.tscn"):
- ctx.set_main_scene("res://levels/level_01.tscn")
- ```
+ Then `summer_screenshot` — lighting is exactly the kind of change you cannot judge without pixels.
- An unknown event shape → report + `false`, never a half-written action (every event is built before anything is written). A new action gets the engine's default deadzone, 0.2; re-running on an existing action keeps its deadzone and appends only the events it does not already match, so the recipe is safe to re-run. Keycode strings are `find_keycode` names (`"Space"`, `"Left"`, `"Ctrl+S"` — modifiers become the event's modifier flags). Write the autoload's script before registering it (`summer_write_file`, or `attach_script` on a node you then `make_prefab`) — `add_autoload` applies the editor dock's gates (valid identifier, no class-name collision, an existing `res://` Script or PackedScene). These helpers write the `res://` path form (`"*res://…"`, `"res://…"`); the editor UI writes `uid://` forms and both resolve — leave them be. They write `project.godot`, not the scene: the result's `undo_action` note lists them under `project_settings_changed`, outside the scene undo — checkpoint territory. Proof: `summer_play` → `summer_get_runtime_tree` shows `/root/GameState`; the keys move the body in a `target:"game"` capture.
+ More recipes, each the `source` of one call:
- ### Verifying 2D and UI work
+ - `references/recipes-3d.md` (`skill/scene-scripting/references/recipes-3d.md`): procedural meshes, CSG and boolean blockouts, lathe, sweep, terrain, text signage, decimate and convex collision, mirror, keyframe animation, `make_shader`, GridMap fills.
+ - `references/recipes-2d-ui.md` (`skill/scene-scripting/references/recipes-2d-ui.md`): a 2D platformer level, animated sprites, a HUD, a persisted button connection, `attach_script`, `make_prefab`, input actions, autoloads and the main scene, and how to verify 2D and UI work.
- Same loop, different framing:
+ ## Which tool
- - **Diff first.** `added` must carry the bodies' `CollisionShape2D` children, the `CanvasLayer` with its Controls, one `Parallax2D` per layer. Anchors and theme overrides show up as `changed` properties on the Controls.
- - **2D scene captures.** `summer_screenshot target:"scene"` on a 2D scene synthesizes a `Camera2D` and auto-fits the `CanvasItem` bounds — the 3D presets do not apply, and `framing:"camera"` errors on a scene with no 3D content. `nodePath:"Player"` frames one node (its 2D rect, children included); `size:[1280, 720]` sets the resolution that anchors resolve against.
- - **UI and anything input-driven** is only proven in the running game: `summer_play`, then `summer_screenshot target:"game"`. An edit-time preview of a HUD approximates a viewport the editor does not have.
- - Read the confession fields in every capture (synthetic camera, no camera, project mismatch). A capture you did not receive is not evidence — `verifying-scenes` has the full discipline.
+ | Job | Tool |
+ |---|---|
+ | One property on one node | `summer_set_prop` |
+ | One signal, one input binding, one project setting | `summer_connect_signal`, `summer_input_map_bind`, `summer_project_setting` |
+ | 3+ related scene edits, or anything computed | `summer_run_script` with `scenePath` |
+ | Many kit pieces at exact poses, read back as one receipt | `summer_batch` with placed `InstantiateScene` ops (`spatial-placement`) |
+ | Project-wide batch work on files on disk | `summer_run_editor_script` |
## summer_run_editor_script — the cold path
A different tool for a different job: it boots a **fresh headless child editor against the ON-DISK project**, runs one `EditorScript` (`func _run():`), and exits.
- Unsaved live edits are INVISIBLE to it; the live editor may need a file reload to show its output.
- Use for batch/project-wide work: re-saving every scene, sweeping resources, mass fixes, generating `.tres` assets.
- Budget default 120s, clamp 15–600 — include boot time (30s+ on large projects).
- It confesses `no_rewind_point:true` when no pre-run checkpoint could be taken — surface that to the user before more destructive work.
- - No renderer: it can never screenshot. Judge it by artifacts on disk (see `headless-scripting` for the full discipline).
+ - No renderer: it can never screenshot. Judge it by artifacts on disk (see `headless-scripting`).
- If `summer_run_script` fails with "doesn't support RunSceneScript yet", the engine build is too old — fall back to `summer_run_editor_script` or tell the user to update Summer Engine.
+ If `summer_run_script` answers `engine_lacks_op`, the engine build predates RunSceneScript (Summer Engine 0.5.66 or newer has it): update Summer Engine, or use `summer_run_editor_script` for the same work.
- ## Red Flags — STOP
+ ## Pitfalls
- | Red flag | Reality |
+ | Pitfall | Reality |
|---|---|
| Ten `summer_add_node`/`summer_set_prop` calls in a row | That is one script. Write the script. |
- | `add_child` without `ctx.set_owner_recursive(node)` (or manual `.owner`) | Saved scene silently loses the node. |
- | Guessing a property name "it's probably `color`" | `summer_api_docs` answers in one call. Wrong names fail after half the mutation applied. |
+ | A script without `scenePath` | It runs against whatever tab is open. Name the scene. |
+ | `add_child` without `ctx.set_owner_recursive(node)` (or manual `.owner`) | The saved scene silently loses the node. |
+ | `var node := ctx.add_node(...)` | Does not compile: `ctx` results are untyped. `var node = ctx.add_node(...)`. |
+ | Guessing a property name ("it's probably `color`") | `summer_api_docs` answers in one call. Wrong names fail after half the mutation applied. |
| Claiming "the forest looks great" without a screenshot | Describe only frames you received. Run `summer_screenshot`. |
| Heavy batch loop in `summer_run_script` | It blocks the live editor. Move it to `summer_run_editor_script`. |
| Using `summer_run_editor_script` to edit the OPEN scene | It sees only the on-disk file; live edits are invisible and collisions likely. Use `summer_run_script`. |
| `"Vector3(0,10,0)"` (quoted) inside script source | That is the `summer_set_prop` wire convention. In GDScript write `Vector3(0, 10, 0)`. |
| Ignoring `errors` because `ok` was true | A partially-failed script may have half-mutated the scene. Read them. |
- | Hand-building CSG node trees on a geometry-tier engine | `ctx.boolean/lathe/sweep/extrude_polygon` bake clean ArrayMeshes with no live CSG left behind. |
- | Re-running a failed `make_shader` with a guessed fix | The compile error is in the `make_shader_errors` report verbatim, with the line. Read it, fix that line. |
- | Hand-writing AnimationPlayer/library/track plumbing | `ctx.animate` is one call per property and dodges the quaternion trap. |
- | Painting tiles with `summer_set_prop`, or hand-editing `tile_map_data` | `ctx.paint_rect` / `paint_tiles` (or `set_cell` in a loop) — cells are packed data. |
- | Building a HUD with a dozen `summer_add_node`/`summer_set_prop` calls | One script: `add_canvas_layer` → `add_ui` → `set_theme_overrides`. |
- | `button.pressed.connect(...)` inside the run script | Not persisted — gone when the script ends. `ctx.connect_signal` (or `summer_connect_signal`) saves it in the `.tscn`. |
- | `ctx.connect(button, "pressed", hud, "_on_pressed")` | That is `Object.connect` on the ctx object, not the helper — wrong-signature error. The helper is `ctx.connect_signal(...)`. |
- | `{"min_size": ...}` in `add_ui` props | Not a Control property — lands in `prop_warnings` and does nothing. The property is `custom_minimum_size`. |
- | `ctx.make_prefab(ctx.get_scene_root(), ...)` | Refused. Pack a child subtree; the scene itself is `save_scene`. |
- | Re-running a failed `attach_script` with a guessed fix | The parse error is in the report with the line. Read it, fix that line. |
- | Judging a HUD from an edit-time scene preview | Anchors resolve against the real viewport. `summer_play` + `target:"game"`. |
- | Calling game-completeness helpers on an engine that predates them | `Invalid call to method` — the run rolls back. Use the fallback table above. |
- | Reading `rolled_back: true` as "nothing happened" after `attach_script` / `add_autoload` / `set_main_scene` | Files and `project.godot` sit outside the undo action. Read `files_written` / `project_settings_changed`; the checkpoint is the rollback. |
**Related skills:**
- `verifying-scenes` — the perception discipline: snapshot/diff/screenshot before-and-after, runtime reads, honest claims.
- `headless-scripting` — shell-launched engine scripts, imports, exports.
- `scene-composition` — what a well-structured scene looks like before you generate one.
- - `ui-basics` — Control hierarchy, containers, anchors, theme vs inline styling: the design behind the HUD recipe.
+ - `ui-basics` — Control hierarchy, containers, anchors, theme vs inline styling.
+ - `character-animation-wiring` — the animation tier end to end.
- `gdscript-patterns` — idioms for the scripts `attach_script` writes.
- - `sprite-sheet` — generating the frames `add_animated_sprite` consumes.
- `verification-before-completion` — proving the result before claiming done.