# Godot 4.6 — SpriteFrames for Po Runner boars **Audience:** Helios / any later agent building boar (or other 76×34 obstacle) animation for Po Runner. **Researched:** 2026-09-07 against official Godot **4.6** class docs + the live `po-runner-2026` tree. **Live links + context (HTML):** https://gallery.smokehounds.store/po-runner/boars/godot-4.6-context.html **Live station (albino):** https://gallery.smokehounds.store/po-runner/boars/albino-32.html **Charcoal station (archive):** https://gallery.smokehounds.store/po-runner/boars/pixen-32.html **Park SSOT:** `~/Documents/pixellab-aseprite/po-runner-boars/GODOT_4_6_ANIMATION_STATION.md` Do not use Godot 3 recipes (`AnimatedSprite`, `frames=`, old tscn). Po Runner is **4.6**. --- ## What Po Runner already does | Fact | Value | |---|---| | Engine | Godot **4.6** (`config/features=PackedStringArray("4.6", "GL Compatibility")`) | | Viewport | **480×270**, `stretch/mode=canvas_items` | | Player animation | `AnimatedSprite2D` + `SpriteFrames` sub-resource in `main.tscn` | | Player play call | `$AnimatedSprite2D.play(current_anim)` in `player.gd` | | Finished signal | `$AnimatedSprite2D.animation_finished.connect(_on_animation_finished)` — used for **land** and **run kick** (non-looping one-shots) | | Run cycle | animation `&"run"`, `"loop": true`, `"speed": 17.0`, each frame `"duration": 1.0` | | Obstacle rocks | `Sprite2D` at **native pixel size**, no extra scale. Collision is a **handmade** `RectangleShape2D`, not the PNG bounds | | Wildboar PNGs | Exist at `res://assets/obstacles/wildboar.png` (+ `_01` / `_02`) but are **not** wired in `ObstacleSpawner.gd` yet | | Do not ship until named | Mitchell names winners before anything is dropped into `assets/` for a commit | Player `AnimatedSprite2D` has `scale = Vector2(2.84375, 2.84375)` under a `0.5` player scale — that is **Po**, not obstacles. Boar obstacles stay **76×34 native**, same as rocks. In-game sprite law: refs are 75×34; PixelLab needs even width → pad **1 transparent column** → **76×34**. Never scale. **STILL 256×256 does not go in this viewport.** It would fill most of 270px. PIXEN 76×34 is the in-game language. --- ## Official 4.6 references (use these, not 3.x / 4.0 blogs) - Tutorial (individual images → AnimatedSprite2D): https://docs.godotengine.org/en/4.6/tutorials/2d/2d_sprite_animation.html - `AnimatedSprite2D`: https://docs.godotengine.org/en/4.6/classes/class_animatedsprite2d.html - `SpriteFrames`: https://docs.godotengine.org/en/4.6/classes/class_spriteframes.html XML sources (engine): `doc/classes/AnimatedSprite2D.xml`, `doc/classes/SpriteFrames.xml` on the 4.6 docs branch. --- ## SpriteFrames API that the station emits (4.6) ``` add_animation(anim: StringName) add_frame(anim: StringName, texture: Texture2D, duration: float = 1.0, at_position: int = -1) set_animation_speed(anim: StringName, fps: float) set_animation_loop(anim: StringName, loop: bool) ``` `duration` is **relative**. Absolute seconds for a frame: ``` absolute_duration = relative_duration / (animation_fps * abs(playing_speed)) ``` Po’s `.tscn` uses `"duration": 1.0` on every frame and puts FPS on `"speed"`. Match that unless a hold-frame is intentional (`duration: 2.0`). There is **no ping-pong flag** on SpriteFrames. --- ## AnimatedSprite2D API (4.6) ``` play(name: StringName = &"", custom_speed: float = 1.0, from_end: bool = false) play_backwards(name: StringName = &"") # play(..., custom_speed = -1.0, from_end = true) pause() stop() # resets frame to 0 and custom_speed to 1.0 is_playing() -> bool ``` Signals: | Signal | When | |---|---| | `animation_finished` | Reaches the end (or start if reverse). **Not emitted if the animation loops.** | | `animation_looped` | Fired on wrap when looping | | `frame_changed` | Frame index changed | StringName literals in GDScript 4: `&"boar"` (Po also uses `&"run"`, `&"double jump"`). Nearest filter: `CanvasItem.TEXTURE_FILTER_NEAREST` (= **1** in `.tscn`). Po does not set a project-wide `default_texture_filter`; set it on the node so 76×34 does not bilinear-blur. ```gdscript texture_filter = TEXTURE_FILTER_NEAREST ``` ``` [node name="Boar" type="AnimatedSprite2D"] texture_filter = 1 ``` --- ## How the gallery station maps “how it plays” | Station mode | Strip | SpriteFrames `loop` | Extra | |---|---|---|---| | Loop | as ordered | true | — | | Reverse loop | reversed | true | baked into PATHS | | Ping-pong | `A B C D C B` | true | **baked** (no engine flag) | | Hold last | as ordered | false | engine pauses on last frame | | Once (reset) | as ordered | false | `animation_finished` → `frame = 0; stop()` | Ping-pong alternative (not baked): `loop = false` + `animation_finished` → toggle `play()` / `play_backwards()`. If you leave `loop = true`, `animation_finished` never fires and ping-pong script cannot work. **Total frames:** if N < strip length, **trim**; if N > strip length, **cycle**. Duplicates in the strip are holds. --- ## Path convention - Gallery PNGs are review files. Godot must `load("res://...")` from the game project. - Default prefix the station writes: `res://assets/obstacles/boar/` - Copy **named** 76×34 files there after Mitchell names winners. Do not point the game at `gallery.smokehounds.store`. - Existing in-game files stay `res://assets/obstacles/wildboar.png` (and `_01` / `_02`) until a rename is decided. --- ## Concrete GDScript (Godot 4.6) This is the same shape the station copies. Attach to an `AnimatedSprite2D`. ```gdscript extends AnimatedSprite2D ## Godot 4.6 — AnimatedSprite2D + SpriteFrames (same stack as Po Runner player). const ANIM := &"boar" const FPS := 8.0 const LOOP := true const PATHS: PackedStringArray = [ "res://assets/obstacles/boar/p32-01-contact.png", "res://assets/obstacles/boar/p32-02-gather.png", "res://assets/obstacles/boar/p32-03-push.png", ] func _ready() -> void: texture_filter = TEXTURE_FILTER_NEAREST var frames := SpriteFrames.new() frames.add_animation(ANIM) frames.set_animation_speed(ANIM, FPS) frames.set_animation_loop(ANIM, LOOP) for path in PATHS: var tex := load(path) as Texture2D if tex == null: push_error("Missing boar frame: %s" % path) continue frames.add_frame(ANIM, tex, 1.0) sprite_frames = frames play(ANIM) ``` Play from another node the way `player.gd` already does: ```gdscript $AnimatedSprite2D.play(&"boar") ``` --- ## Concrete `.tscn` SpriteFrames shape (matches `main.tscn`) ``` [sub_resource type="SpriteFrames" id="SpriteFrames_boar"] animations = [{ "frames": [{ "duration": 1.0, "texture": ExtResource("1_boar") }, { "duration": 1.0, "texture": ExtResource("2_boar") }], "loop": true, "name": &"boar", "speed": 8.0 }] [node name="Boar" type="AnimatedSprite2D"] texture_filter = 1 sprite_frames = SubResource("SpriteFrames_boar") animation = &"boar" autoplay = "boar" ``` `autoplay` is a **String** (`""` default), while `animation` is a **StringName**. That is how 4.6 declares them — do not “fix” autoplay to `&"boar"`. --- ## Ping-pong via `play_backwards` (loop must be false) ```gdscript extends AnimatedSprite2D var _forward := true func _ready() -> void: # SpriteFrames.loop for this anim is false. animation_finished.connect(_on_animation_finished) play(&"boar") func _on_animation_finished() -> void: _forward = not _forward if _forward: play(&"boar") else: play_backwards(&"boar") ``` --- ## Collision reminder Rocks: `RectangleShape2D` size `Vector2(8, 14)`, not the stone PNG. A boar `AnimatedSprite2D` still needs a small handmade box (feet / tusks), not 76×34 AABB. --- ## Station files (gallery-site) | File | Role | |---|---| | `po-runner/boars/pixen-32.html` | UI: pick frames, order, FPS, total, play mode, preview | | `po-runner/boars/pixen-32-station-lib.js` | expand / bake / emit GDScript + tscn + JSON | | `po-runner/boars/pixen-32-station.js` | DOM | | `po-runner/boars/godot-4.6-context.html` | live links + context page (official 4.6 docs first) | | `po-runner/boars/godot-4.6-spriteframes.md` | this document, raw Markdown |