diff --git a/.agents/skills/spine-godot/SKILL.md b/.agents/skills/spine-godot/SKILL.md new file mode 100644 index 0000000..364d9ce --- /dev/null +++ b/.agents/skills/spine-godot/SKILL.md @@ -0,0 +1,276 @@ +--- +name: spine-godot +description: spine-godot runtime for Godot 4 — Spine skeleton display, animation playback, bone/slot manipulation, skin mixing, 2D lighting, and runtime asset loading. Use when working with Spine skeletons, animations, SpineSprite nodes, SpineBoneNode, SpineSlotNode, or SpineAnimationTrack in this project. +--- + +# spine-godot Skill + +The spine-godot runtime provides Godot nodes and APIs to load, display, animate, and manipulate skeletons exported from Spine. It wraps the spine-cpp runtime and exposes it to GDScript and C#. + +> **Official docs:** https://en.esotericsoftware.com/spine-godot +> **Spine Runtimes Guide:** https://en.esotericsoftware.com/spine-runtimes-guide +> **Source:** https://github.com/EsotericSoftware/spine-runtimes (under `spine-godot/`) + +## Architecture Overview + +``` +spine-godot Runtime +├── SpineSprite — displays a skeleton from SkeletonDataResource +│ ├── SpineSkeleton — skeleton instance (bones, slots, attachments, skins, constraints) +│ ├── SpineAnimationState — animation playback, queuing, mixing via tracks +│ └── Signals — animation_started, animation_completed, animation_event, etc. +├── SpineBoneNode — follows or drives a bone's transform +├── SpineSlotNode — inserts nodes into drawing order or overrides slot materials +└── SpineAnimationTrack — animates a SpineSprite via Godot AnimationPlayer (C++ engine module only) +``` + +## Resource Types + +| Resource | Purpose | +|----------|---------| +| `SpineSkeletonFileResource` | Imported `.skel` or `.spine-json` skeleton data | +| `SpineAtlasResource` | Imported `.atlas` texture atlas data (uses normal map prefix `n_` by default) | +| `SpineSkeletonDataResource` | Combines a `SpineSkeletonFileResource` + `SpineAtlasResource` + animation mix times. Shared across SpineSprite instances. | + +## Asset Management + +### Exporting from Spine Editor + +Export these files from the Spine Editor: +1. **Skeleton data:** `skeleton-name.spine-json` or `skeleton-name.skel` (binary, smaller/faster — preferred). +2. **Texture atlas:** `skeleton-name.atlas` +3. **Atlas pages:** One or more `.png` image files. + +> Prefer `.skel` binary exports. If using JSON, use `.spine-json` extension (not `.json`). +> Pre-multiplied alpha atlases are **not supported** — Godot's default bleed handles artifacts. + +### Importing into Godot + +1. Drag `.skel`/`.spine-json`, `.atlas`, and `.png` files into a Godot project folder. +2. The importer creates a `SpineSkeletonFileResource`, a `SpineAtlasResource`, and Godot texture resources. +3. Create a `SpineSkeletonDataResource`: Right-click folder → `New Resource...` → `SpineSkeletonDataResource`. +4. Assign the atlas resource and skeleton file resource in the inspector. Set animation mix times here. + +> A `SpineSkeletonDataResource` should be **shared** by all `SpineSprite` instances displaying the same skeleton. Do not use inline resources. + +### Updating Assets During Development + +Overwrite the source files re-exported from Spine. Godot auto-detects changes and re-imports. If it doesn't, force re-import in the import settings panel. + +### Runtime Loading from Disk (modding) + +Load skeleton and atlas files from arbitrary paths at runtime: + +```gdscript +# Load skeleton file +var skeleton_file_res = SpineSkeletonFileResource.new() +skeleton_file_res.load_from_file("/path/to/skeleton.skel") + +# Load atlas file +var atlas_res = SpineAtlasResource.new() +atlas_res.load_from_atlas_file("/path/to/skeleton.atlas") + +# Create shared skeleton data resource +var skeleton_data_res = SpineSkeletonDataResource.new() +skeleton_data_res.skeleton_file_res = skeleton_file_res +skeleton_data_res.atlas_res = atlas_res + +# Create sprite from data +var sprite = SpineSprite.new() +sprite.skeleton_data_res = skeleton_data_res +sprite.position = Vector2(200, 200) +sprite.get_animation_state().set_animation("animation", true, 0) +add_child(sprite) +``` + +## Nodes + +### SpineSprite Node + +Displays skeleton data from a `SkeletonDataResource`. Provides access to the animation state and skeleton instance. + +#### Creating a SpineSprite + +1. Add a `SpineSprite` node via the `+` button in the scene panel. +2. Assign a `SkeletonDataResource` in the inspector. +3. Transform the node freely in the editor viewport. + +#### Editor Features + +- **Debug view:** Check bones, slots, attachments in the `Debug` property section. Hover over parts in the viewport to see names. +- **Animation preview:** Use the `Preview` section to set an animation and scrub through time with the slider. +- **Custom materials:** Set per-blend-mode materials in the `Materials` section (applied to all slots). Use `SpineSlotNode` to override per-slot. +- **Update Mode:** `Process` (default, every frame), `Physics` (fixed interval, 60fps), `Manual` (call `update_skeleton()` yourself in `_process`/`_physics_process`). + +#### Animating a SpineSprite + +```gdscript +extends SpineSprite + +func _ready(): + var animation_state = get_animation_state() + + # Set an animation on track 0, looping + animation_state.set_animation("walk", true, 0) + + # Queue an animation after a delay (mix duration implied by mix times) + animation_state.add_animation("idle", 0.5, true, 0) + + # Set a reversed animation + var track_entry = animation_state.set_animation("walk", true, 0) + track_entry.set_reverse(true) + + # Mix to an empty animation (reset to setup pose) + animation_state.set_empty_animation(0, 0.5) + animation_state.add_empty_animation(0, 0.5, 0.5) + + # Clear tracks + animation_state.clear_track(0) # clear one track + animation_state.clear_tracks() # clear all tracks + + # Reset skeleton poses + get_skeleton().set_to_setup_pose() # bones + slots + get_skeleton().set_slots_to_setup_pose() # slots only +``` + +#### Animation State API + +| Method | Purpose | +|--------|---------| +| `set_animation(name, loop, track)` | Play animation. Returns `SpineTrackEntry`. | +| `add_animation(name, delay, loop, track)` | Queue animation after delay. Returns `SpineTrackEntry`. | +| `set_empty_animation(track, mix_duration)` | Mix out to empty pose immediately. | +| `add_empty_animation(track, mix_duration, delay)` | Queue empty animation mix-out. | +| `clear_track(track)` | Immediately clear one track. | +| `clear_tracks()` | Immediately clear all tracks. | + +> **Important:** Do not hold `SpineTrackEntry` references across frames. They are reused internally and become invalid once the animation completes. + +#### SpineTrackEntry Properties + +| Method | Purpose | +|--------|---------| +| `set_reverse(true)` | Play animation in reverse. | +| `set_mix_duration(seconds)` | Override mix duration. | +| `set_alpha(value)` | Mix alpha for blending (0-1). | +| `set_time_scale(scale)` | Animation speed multiplier. | +| `set_track_time(time)` | Jump to specific time in seconds. | + +#### SpineSprite Signals + +**Animation state signals:** +- `animation_started(track_entry)` — animation started playing. +- `animation_interrupted(track_entry)` — track cleared or new animation set. +- `animation_completed(track_entry)` — animation finished one full loop. +- `animation_ended(track_entry)` — animation will never be applied again. +- `animation_disposed(track_entry)` — track entry disposed. +- `animation_event(track_entry, event)` — user-defined Spine event triggered. + +**Lifecycle signals:** +- `before_animation_state_update` — before animation state is updated with delta. +- `before_animation_state_apply` — before animation state is applied to skeleton pose. +- `before_world_transforms_change` — before bone world transforms update. Use to set bone transforms manually. +- `world_transforms_changed` — after bone world transforms update. + +#### Mix-and-Match Skins + +Combine multiple skins to create custom avatars: + +```gdscript +var custom_skin = new_skin("custom-skin") +var data = get_skeleton().get_data() + +custom_skin.add_skin(data.find_skin("skin-base")) +custom_skin.add_skin(data.find_skin("nose/short")) +custom_skin.add_skin(data.find_skin("eyes/violet")) +custom_skin.add_skin(data.find_skin("hair/brown")) +custom_skin.add_skin(data.find_skin("clothes/hoodie-orange")) + +get_skeleton().set_skin(custom_skin) +get_skeleton().set_slots_to_setup_pose() +``` + +#### Getting and Setting Bone Transforms + +```gdscript +# Get a bone's global transform in Godot canvas space +var transform = get_global_bone_transform("bone_name") + +# Set a bone's transform (must be called before world transforms update) +set_global_bone_transform("bone_name", Transform2D(0, Vector2(100, 200))) +``` + +When manually setting bone transforms, connect to `before_world_transforms_change` signal. Alternatively, use `SpineBoneNode` for simpler bone manipulation. + +### SpineBoneNode + +A child node of `SpineSprite` that either **follows** or **drives** a bone. + +> Must be a **direct child** of a `SpineSprite`. + +**Inspector properties:** +- `Bone Name` — dropdown of all available bones. +- `Bone Mode` — `Follow` (node moves with bone) or `Drive` (node controls bone position). +- `Enabled` — toggle following/driving on/off. + +**Use cases:** +- **Drive mode:** Control a bone via mouse position or other input (e.g., aiming). +- **Follow mode:** Attach other nodes (CollisionShape, Marker2D) to follow a bone. + +See examples: `example/05-mouse-following` (drive), `example/06-bone-following` (follow). + +### SpineSlotNode + +A child node of `SpineSprite` that inserts children into the skeleton's drawing order or overrides a slot's materials. + +> Must be a **direct child** of a `SpineSprite`. + +**Inspector properties:** +- `Slot Name` — dropdown of all available slots. +- `Materials` — custom materials that override the slot's default materials. + +**Use cases:** +- Attach particle systems, custom sprites, or other `SpineSprite` nodes on top of a slot. +- Override materials per-slot (overrides `SpineSprite`'s global materials). + +See examples: `example/07-slot-node` (drawing order), `example/09-custom-material` (material override). + +### SpineAnimationTrack (C++ Engine Module Only) + +Animates a `SpineSprite` via Godot's `AnimationPlayer` and animation editor. Ideal for cutscenes. + +> **Experimental.** Not available in the GDExtension. + +> Must be a **direct child** of a `SpineSprite`. + +- Creates a child `AnimationPlayer` automatically. +- Key animations on the child `AnimationPlayer`; key animation properties (loop, reverse, etc.) on the `SpineAnimationTrack`. +- Multiple `SpineAnimationTrack` nodes on one `SpineSprite` enable multi-track layering. +- Each track gets a unique **track index** — higher tracks override lower tracks' skeleton properties. + +> Mix times from `SpineSkeletonDataResource` cannot be previewed in the editor. + +See example: `example/08-animation-player`. + +## 2D Lighting + +spine-godot integrates with Godot's 2D lighting system. + +### Setup + +1. Provide normal map `.png` images alongside atlas page images with prefix `n_` (default): e.g., `raptor.png` → `n_raptor.png`. +2. The normal map prefix is configurable in the atlas import settings. +3. After importing, apply Godot 2D lights to `SpineSprite` nodes. + +See example: `example/10-2d-lighting`. + +## Spine Runtimes API Access + +Almost all spine-cpp API is mapped 1:1 to GDScript. Objects from `SpineSprite` (via `get_skeleton()`, `get_animation_state()`) mirror the C++ API. + +### GDScript Limitations + +- Returned arrays/maps are **copies** — mutations don't affect internals. +- Cannot set per-track-entry listeners — use `SpineSprite` signals instead. +- Cannot create/add/remove bones, slots, or other Spine objects directly. +- C++ class hierarchies of attachments and timelines are not exposed. diff --git a/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd b/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd new file mode 100644 index 0000000..82b3556 --- /dev/null +++ b/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd @@ -0,0 +1,202 @@ +# ============================================================ +# Gym 09 — Minigame 1: Animation Speed Challenge +# ============================================================ +# A SpineSprite plays an animation at Normal, Fast, or Hard +# speed. The player must click the button that matches the +# *current* speed. Correct answers advance a progress bar; +# reach the threshold to win. Run out of time and you lose. +# The speed changes to a new random value after *every* press. +# ============================================================ +extends Node2D + + +# ---------- Configurable multipliers ---------- +@export var normal_speed: float = 1.0 +@export var fast_speed: float = 2.0 +@export var hard_speed: float = 3.0 + +# ---------- Timing & progression ---------- +@export var time_limit: float = 30.0 ## total seconds before auto-loss +@export var progress_per_click: float = 20.0 ## progress added on correct answer +@export var win_threshold: float = 100.0 ## progress value that triggers a win +@export var penalty_per_miss: float = 10.0 ## progress lost on wrong press + +# ---------- Animation ---------- +@export var animation_name: String = "roar" ## which animation to play on the SpineSprite + + +enum Speed { NORMAL, FAST, HARD } + +var _current_speed: Speed = Speed.NORMAL +var _progress: float = 0.0 +var _time_remaining: float = 0.0 +var _game_active: bool = false +var _result_pending: bool = false +var _track_entry = null ## SpineTrackEntry — stored reference for time-scale changes + + +@onready var _spine_sprite: SpineSprite = %SpineSprite +@onready var _progress_bar: ProgressBar = %ProgressBar +@onready var _current_speed_label: Label = %CurrentSpeedLabel +@onready var _timer_label: Label = %TimerLabel +@onready var _status_label: Label = %StatusLabel +@onready var _normal_button: Button = %NormalButton +@onready var _fast_button: Button = %FastButton +@onready var _hard_button: Button = %HardButton + + +# ============================================================ +# Lifecycle +# ============================================================ + +func _ready() -> void: + print("[Gym09 Minigame 1] Ready! Match the animation speed — click the correct button.") + _start_game() + + +func _process(delta: float) -> void: + if not _game_active: + return + + _time_remaining -= delta + + if _time_remaining <= 0.0: + _time_remaining = 0.0 + _on_time_up() + + _update_ui() + + +# ============================================================ +# Game state +# ============================================================ + +func _start_game() -> void: + _progress = 0.0 + _time_remaining = time_limit + _game_active = true + _result_pending = false + _pick_random_speed() + _update_ui() + + +func _pick_random_speed() -> void: + _current_speed = randi() % 3 as Speed + _apply_speed() + + +func _apply_speed() -> void: + _current_speed_label.text = "Press: " + Speed.keys()[_current_speed].capitalize() + + if _track_entry == null: + return + + match _current_speed: + Speed.NORMAL: + _track_entry.set_time_scale(normal_speed) + Speed.FAST: + _track_entry.set_time_scale(fast_speed) + Speed.HARD: + _track_entry.set_time_scale(hard_speed) + + +# ============================================================ +# Animation +# ============================================================ + +func _play_animation() -> void: + _track_entry = _spine_sprite.get_animation_state().set_animation(animation_name, false, 0) + + +# ============================================================ +# Button handlers +# ============================================================ + +func _on_normal_pressed() -> void: + _evaluate_answer(Speed.NORMAL) + + +func _on_fast_pressed() -> void: + _evaluate_answer(Speed.FAST) + + +func _on_hard_pressed() -> void: + _evaluate_answer(Speed.HARD) + + +func _evaluate_answer(chosen: Speed) -> void: + if not _game_active or _result_pending: + return + + # Restart animation on each press + _play_animation() + _apply_speed() + + if chosen == _current_speed: + _progress += progress_per_click + print("[Gym09 Minigame 1] Correct! Progress: %.0f / %.0f" % [_progress, win_threshold]) + + if _progress >= win_threshold: + _on_win() + return + else: + _progress = maxf(0.0, _progress - penalty_per_miss) + print("[Gym09 Minigame 1] Wrong button! Current speed is ", Speed.keys()[_current_speed]) + + # Speed changes after every button press + _pick_random_speed() + _update_ui() + + +# ============================================================ +# End conditions +# ============================================================ + +func _on_win() -> void: + _game_active = false + _result_pending = true + print("[Gym09 Minigame 1] WIN!") + _status_label.text = "YOU WIN!" + _disable_buttons() + + Dialogic.VAR.Gym.minigame_done = true + + await get_tree().create_timer(1.5).timeout + _return_to_main() + + +func _on_time_up() -> void: + _game_active = false + _result_pending = true + print("[Gym09 Minigame 1] TIME'S UP — You lose!") + _status_label.text = "TIME'S UP!" + _disable_buttons() + + # Do NOT set minigame_done — the controller will re-show the intro + await get_tree().create_timer(1.5).timeout + _return_to_main() + + +func _return_to_main() -> void: + var return_path := "res://docs/gyms/gym09_scene_switching/gym09_main.tscn" + if Dialogic.VAR.Gym.minigame_return == "hub": + return_path = "res://docs/gyms/cross-timeline-interactions/gym_controller.tscn" + + print("[Gym09 Minigame 1] Returning to: ", return_path) + get_tree().change_scene_to_file(return_path) + + +# ============================================================ +# UI helpers +# ============================================================ + +func _disable_buttons() -> void: + _normal_button.disabled = true + _fast_button.disabled = true + _hard_button.disabled = true + + +func _update_ui() -> void: + _progress_bar.max_value = win_threshold + _progress_bar.value = clampf(_progress, 0.0, win_threshold) + _timer_label.text = "Time: %.1f" % _time_remaining diff --git a/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd.uid b/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd.uid new file mode 100644 index 0000000..1d48ee3 --- /dev/null +++ b/docs/gyms/gym09_scene_switching/gym09_minigame_1.gd.uid @@ -0,0 +1 @@ +uid://dhdwyav6b2ur3 diff --git a/docs/gyms/gym09_scene_switching/gym09_minigame_1.tscn b/docs/gyms/gym09_scene_switching/gym09_minigame_1.tscn new file mode 100644 index 0000000..b7240eb --- /dev/null +++ b/docs/gyms/gym09_scene_switching/gym09_minigame_1.tscn @@ -0,0 +1,101 @@ +[gd_scene format=3 uid="uid://6uporiru4ny1"] + +[ext_resource type="SpineSkeletonDataResource" uid="uid://c53t4xfil8g8g" path="res://docs/museums/spine/assets/raptor/raptor-data.tres" id="1_irao8"] +[ext_resource type="Script" uid="uid://dhdwyav6b2ur3" path="res://docs/gyms/gym09_scene_switching/gym09_minigame_1.gd" id="2_script"] + +[node name="Gym09Minigame1" type="Node2D" unique_id=26511728] +script = ExtResource("2_script") +progress_per_click = 5.0 + +[node name="SpineSprite" type="SpineSprite" parent="." unique_id=1663671115] +skeleton_data_res = ExtResource("1_irao8") +preview_skin = "Default" +preview_animation = "roar" +preview_frame = false +preview_time = 0.0 +unique_name_in_owner = true +position = Vector2(578, 390) +scale = Vector2(0.3, 0.3) + +[node name="UILayer" type="Control" parent="." unique_id=-522947854] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 578.0 +offset_top = 566.0 +offset_right = 578.0 +offset_bottom = 566.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="UILayer" unique_id=-1400444195] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -120.0 +offset_right = 200.0 +offset_bottom = 120.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="TitleLabel" type="Label" parent="UILayer/VBoxContainer" unique_id=1058329447] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "ANIMATION SPEED CHALLENGE" +horizontal_alignment = 1 + +[node name="ProgressBar" type="ProgressBar" parent="UILayer/VBoxContainer" unique_id=-1376495673] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="CurrentSpeedLabel" type="Label" parent="UILayer/VBoxContainer" unique_id=-1544803351] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Press: ???" +horizontal_alignment = 1 + +[node name="TimerLabel" type="Label" parent="UILayer/VBoxContainer" unique_id=1334428778] +unique_name_in_owner = true +layout_mode = 2 +text = "Time: 30.0" +horizontal_alignment = 1 + +[node name="HBoxContainer" type="HBoxContainer" parent="UILayer/VBoxContainer" unique_id=-1138046855] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="NormalButton" type="Button" parent="UILayer/VBoxContainer/HBoxContainer" unique_id=-2107521662] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Normal" + +[node name="FastButton" type="Button" parent="UILayer/VBoxContainer/HBoxContainer" unique_id=929410871] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Fast" + +[node name="HardButton" type="Button" parent="UILayer/VBoxContainer/HBoxContainer" unique_id=-727767392] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +text = "Hard" + +[node name="StatusLabel" type="Label" parent="UILayer/VBoxContainer" unique_id=-246134786] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +horizontal_alignment = 1 + +[connection signal="pressed" from="UILayer/VBoxContainer/HBoxContainer/NormalButton" to="." method="_on_normal_pressed"] +[connection signal="pressed" from="UILayer/VBoxContainer/HBoxContainer/FastButton" to="." method="_on_fast_pressed"] +[connection signal="pressed" from="UILayer/VBoxContainer/HBoxContainer/HardButton" to="." method="_on_hard_pressed"]