Pawn reskin Slice 1 — peasant sprites replace coloured disc
Pawns now render as AnimatedSprite2D children sourced from ElvGames "Farming Characters Pack" atlases (Pack 1, characters 001-015). Each pawn picks one of 15 peasants deterministically from name hash: Bram=004, Cora=013, Edda=001. Animations: idle_down/left/right/up + walk_down/left/right/up (4 fps idle, 8 fps walk, looped) + dead (single frame, no loop). Pawn picks animation each _process tick from (is_downed, is_walking, facing). Facing is now a Vector2i field updated in _advance_walk; round-trips through save/load. Sprite mounting is deferred from _ready() to setup() / from_dict() because the atlas pick depends on pawn_name, which isn't assigned at _ready time. _mount_sprite() is idempotent for the save-load chain. _atlas_for_pawn(pawn) is the single Slice-2 extension point — swapping atlases based on equipped armor in a future sprint is a one-function change. _draw() stripped of body disc + downed-rotation; now overlay-only (selection ring + carry indicator). AnimatedSprite2D child uses z_index=-1 so the overlays stay on top. 45 PNGs copied into art/sprites/characters/ + 45 .import companions.
This commit is contained in:
parent
da55bf312c
commit
b4c9541eae
94 changed files with 1960 additions and 19 deletions
|
|
@ -26,6 +26,10 @@ class_name Pawn
|
|||
## Phase 14 — corpse scene instantiated on death.
|
||||
const CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
|
||||
|
||||
## Slice-1 pawn sprite helper. Preloaded (not class_name) because the global
|
||||
## class registry isn't reliable during cold-start parse for sibling scripts.
|
||||
const _PAWN_SPRITE_FRAMES: Script = preload("res://scenes/pawn/pawn_sprite_frames.gd")
|
||||
|
||||
const STEP_TICKS: int = 10
|
||||
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
|
||||
|
||||
|
|
@ -79,6 +83,11 @@ signal arrived_at_destination(tile: Vector2i)
|
|||
|
||||
@export var pawn_name: String = ""
|
||||
|
||||
## Last-known facing direction as a unit Vector2i (one of (0,1) down, (-1,0)
|
||||
## left, (1,0) right, (0,-1) up). Updated when walking; persists when idle
|
||||
## so the idle sprite faces the last direction walked. Defaults to down.
|
||||
var facing: Vector2i = Vector2i(0, 1)
|
||||
|
||||
var tile: Vector2i = Vector2i.ZERO
|
||||
|
||||
# Phase 7 — hunger need (design.md "Hungry" status). Full at spawn.
|
||||
|
|
@ -180,6 +189,10 @@ var _cold_accum: float = 0.0
|
|||
const SHELTER_DEBUG: bool = false
|
||||
var _shelter_prev: bool = false
|
||||
|
||||
## AnimatedSprite2D child painted with peasant atlases. Built in _ready();
|
||||
## animation switched by _update_anim() each _process tick.
|
||||
var _sprite: AnimatedSprite2D = null
|
||||
|
||||
var _path: Array[Vector2i] = []
|
||||
var _step_progress: float = 0.0
|
||||
var _selected: bool = false
|
||||
|
|
@ -204,6 +217,10 @@ func _ready() -> void:
|
|||
if EventBus.has_signal("corpse_cremated"):
|
||||
EventBus.corpse_cremated.connect(_on_corpse_cremated)
|
||||
|
||||
# Sprite mount is deferred to setup() / from_dict() because the atlas pick
|
||||
# depends on pawn_name (deterministic name-hash), which isn't assigned yet
|
||||
# when _ready() fires. See _mount_sprite() below.
|
||||
|
||||
|
||||
func setup(p_name: String, start_tile: Vector2i) -> void:
|
||||
pawn_name = p_name
|
||||
|
|
@ -215,9 +232,31 @@ func setup(p_name: String, start_tile: Vector2i) -> void:
|
|||
# Same formula as _draw() body disc: deterministic hue from name hash.
|
||||
var hue := float(pawn_name.hash() % 360) / 360.0
|
||||
portrait_color = Color.from_hsv(hue, 0.7, 0.85)
|
||||
# Slice-1 character sprite: depends on pawn_name, so mount here (not _ready).
|
||||
_mount_sprite()
|
||||
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
|
||||
|
||||
|
||||
## Build the AnimatedSprite2D child from the peasant atlas trio picked
|
||||
## deterministically from pawn_name. Idempotent — safe to call from setup()
|
||||
## AND from_dict() (the save-load path also re-enters setup).
|
||||
func _mount_sprite() -> void:
|
||||
if _sprite != null:
|
||||
_sprite.queue_free()
|
||||
_sprite = null
|
||||
var atlases := _atlas_for_pawn(self)
|
||||
var sf: SpriteFrames = _PAWN_SPRITE_FRAMES.build(atlases)
|
||||
_sprite = AnimatedSprite2D.new()
|
||||
_sprite.name = "Sprite"
|
||||
_sprite.sprite_frames = sf
|
||||
_sprite.centered = true
|
||||
_sprite.offset = Vector2(0, -8) # bottom-anchor: feet ≈ tile bottom edge
|
||||
_sprite.z_index = -1
|
||||
_sprite.play(&"idle_down")
|
||||
add_child(_sprite)
|
||||
Audit.log("pawn_sprite", "%s → atlas idx %d" % [pawn_name, (absi(pawn_name.hash()) % _PEASANT_COUNT) + 1])
|
||||
|
||||
|
||||
# ── public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
func walk_along_path(new_path: Array[Vector2i]) -> void:
|
||||
|
|
@ -919,6 +958,8 @@ func to_dict() -> Dictionary:
|
|||
# Phase 14 — bleed-out timeout counter. Default 0 for pre-Phase-14 saves.
|
||||
"bleed_ticks": _bleed_ticks,
|
||||
"last_damage_source": String(_last_damage_source),
|
||||
"facing_x": facing.x,
|
||||
"facing_y": facing.y,
|
||||
# Phase 17 — per-pawn work-priority matrix. Keys stored as plain Strings for
|
||||
# JSON round-trip safety (StringName keys survive the cast back via StringName()).
|
||||
"work_priorities": _serialise_work_priorities(),
|
||||
|
|
@ -928,6 +969,9 @@ func to_dict() -> Dictionary:
|
|||
func from_dict(d: Dictionary) -> void:
|
||||
pawn_name = d.get("name", "")
|
||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
facing = Vector2i(int(d.get("facing_x", 0)), int(d.get("facing_y", 1)))
|
||||
# Re-mount sprite now that pawn_name is set (atlas pick is name-hash driven).
|
||||
_mount_sprite()
|
||||
|
||||
_path.clear()
|
||||
for entry in d.get("path", []):
|
||||
|
|
@ -1070,6 +1114,9 @@ func _advance_walk() -> void:
|
|||
job_runner.cancel_job()
|
||||
Audit.log("pawn", "%s walk aborted: %s became impassable" % [pawn_name, next_tile])
|
||||
return
|
||||
var delta := next_tile - tile
|
||||
if delta != Vector2i.ZERO:
|
||||
facing = _canonical_facing(delta)
|
||||
tile = next_tile
|
||||
_path.remove_at(0)
|
||||
_step_progress = 0.0
|
||||
|
|
@ -1087,28 +1134,42 @@ func _process(_delta: float) -> void:
|
|||
var next := _path[0] if is_walking() else tile
|
||||
var to_world := _tile_to_world(next)
|
||||
position = from_world.lerp(to_world, _step_progress)
|
||||
_update_anim()
|
||||
|
||||
|
||||
## Pick the right animation each tick based on walk state, facing, and downed.
|
||||
## Called from _process(). Cheap: only calls _sprite.play() when the target
|
||||
## animation differs from the current one (AnimatedSprite2D restarts from
|
||||
## frame 0 on every play() call, so we must guard).
|
||||
func _update_anim() -> void:
|
||||
if _sprite == null:
|
||||
return
|
||||
if is_downed():
|
||||
if _sprite.animation != &"dead":
|
||||
_sprite.play(&"dead")
|
||||
return
|
||||
var prefix: StringName = &"walk" if is_walking() else &"idle"
|
||||
var dir_suffix: StringName = _facing_suffix()
|
||||
var target: StringName = StringName("%s_%s" % [prefix, dir_suffix])
|
||||
if _sprite.animation != target:
|
||||
_sprite.play(target)
|
||||
|
||||
|
||||
## Map `facing` Vector2i to the animation-name suffix (down/left/right/up).
|
||||
func _facing_suffix() -> StringName:
|
||||
if facing == Vector2i(0, -1):
|
||||
return &"up"
|
||||
if facing == Vector2i(-1, 0):
|
||||
return &"left"
|
||||
if facing == Vector2i(1, 0):
|
||||
return &"right"
|
||||
return &"down"
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Phase 14 — use the stored portrait_color (computed once in setup()/from_dict()).
|
||||
# This is the same formula as the old inline hue derivation; consolidating here
|
||||
# removes the duplication and ensures the corpse head-dot matches exactly.
|
||||
var body_colour := portrait_color
|
||||
|
||||
if is_downed():
|
||||
# Phase 9 — Downed pawn: rotated 90° (lying on ground) + desaturated.
|
||||
# draw_set_transform applies to all subsequent draw_* calls in this _draw.
|
||||
draw_set_transform(Vector2.ZERO, PI / 2.0, Vector2.ONE)
|
||||
draw_circle(Vector2.ZERO, 6.0, body_colour.lerp(Color(0.5, 0.5, 0.5), 0.6))
|
||||
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.4), 1.0)
|
||||
# Reset transform so selection ring and carry indicator render upright.
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
else:
|
||||
draw_circle(Vector2.ZERO, 6.0, body_colour)
|
||||
# Dark outline ring.
|
||||
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0)
|
||||
|
||||
# Selection ring — drawn after body regardless of downed state.
|
||||
# Body is the AnimatedSprite2D child (see _ready). _draw() is now overlay-only.
|
||||
# Selection ring — drawn on top of the sprite (parent _draw runs after the
|
||||
# child's z_index=-1 sprite draws).
|
||||
if _selected:
|
||||
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)
|
||||
|
||||
|
|
@ -1127,3 +1188,30 @@ func _tile_to_world(t: Vector2i) -> Vector2:
|
|||
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||||
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
|
||||
)
|
||||
|
||||
|
||||
## Maps any tile-delta to a cardinal Vector2i facing direction. Prefers the
|
||||
## axis with larger absolute magnitude; ties favor horizontal. Returns down
|
||||
## (0, 1) for zero delta as a safe default.
|
||||
static func _canonical_facing(delta: Vector2i) -> Vector2i:
|
||||
if delta == Vector2i.ZERO:
|
||||
return Vector2i(0, 1)
|
||||
if abs(delta.x) >= abs(delta.y):
|
||||
return Vector2i(sign(delta.x), 0) if delta.x != 0 else Vector2i(0, 1)
|
||||
return Vector2i(0, sign(delta.y))
|
||||
|
||||
|
||||
## Returns the {idle, walk, dead} atlas trio for a pawn. Slice 1: always
|
||||
## peasant, picked deterministically from name hash (mod 15, +1 for 001-015
|
||||
## naming). Slice 2 will branch on equipped armor (helm + cuirass + boots →
|
||||
## knight atlas, etc.) at this single extension point.
|
||||
const _PEASANT_COUNT: int = 15
|
||||
|
||||
static func _atlas_for_pawn(pawn) -> Dictionary:
|
||||
var idx: int = (absi(pawn.pawn_name.hash()) % _PEASANT_COUNT) + 1
|
||||
var n: String = "%03d" % idx
|
||||
return {
|
||||
"idle": load("res://art/sprites/characters/Character_%s_Idle.png" % n),
|
||||
"walk": load("res://art/sprites/characters/Character_%s_Walk.png" % n),
|
||||
"dead": load("res://art/sprites/characters/Character_%s_Dead.png" % n),
|
||||
}
|
||||
|
|
|
|||
51
scenes/pawn/pawn_sprite_frames.gd
Normal file
51
scenes/pawn/pawn_sprite_frames.gd
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
class_name PawnSpriteFrames extends RefCounted
|
||||
## Builds a SpriteFrames resource from a {idle, walk, dead} atlas trio for a
|
||||
## peasant character. Atlases are 128×128 with 4 rows (down/left/right/up)
|
||||
## × 4 frames (32×32 cells). Idle + Walk produce 4 directional animations
|
||||
## each (loop=true); Dead is a single frame (loop=false) from row 0.
|
||||
##
|
||||
## Created via PawnSpriteFrames.build(atlases) from Pawn._ready(). The
|
||||
## returned SpriteFrames is assigned to an AnimatedSprite2D's sprite_frames.
|
||||
|
||||
const CELL: int = 32
|
||||
const DIRS: Array[StringName] = [&"down", &"left", &"right", &"up"]
|
||||
|
||||
## Build a SpriteFrames containing:
|
||||
## idle_down/left/right/up — 4 frames each, loop, 4 fps
|
||||
## walk_down/left/right/up — 4 frames each, loop, 8 fps
|
||||
## dead — 1 frame, no loop (from idle/dead row 0 col 0)
|
||||
##
|
||||
## `atlases` is a Dictionary with three Texture2D values keyed by "idle",
|
||||
## "walk", "dead". Each texture is 128×128.
|
||||
static func build(atlases: Dictionary) -> SpriteFrames:
|
||||
var sf := SpriteFrames.new()
|
||||
# AnimatedSprite2D auto-creates a `default` animation; remove it so the
|
||||
# scene doesn't render an empty placeholder if a caller mistypes an anim name.
|
||||
if sf.has_animation(&"default"):
|
||||
sf.remove_animation(&"default")
|
||||
|
||||
_add_directional(sf, &"idle", atlases["idle"], true, 4.0)
|
||||
_add_directional(sf, &"walk", atlases["walk"], true, 8.0)
|
||||
|
||||
# Dead — single 32×32 frame from row 0 (down-facing) of the dead atlas.
|
||||
sf.add_animation(&"dead")
|
||||
sf.set_animation_loop(&"dead", false)
|
||||
var dead_at := AtlasTexture.new()
|
||||
dead_at.atlas = atlases["dead"]
|
||||
dead_at.region = Rect2(0, 0, CELL, CELL)
|
||||
sf.add_frame(&"dead", dead_at)
|
||||
|
||||
return sf
|
||||
|
||||
|
||||
static func _add_directional(sf: SpriteFrames, prefix: StringName, tex: Texture2D, loop: bool, fps: float) -> void:
|
||||
for row in 4:
|
||||
var anim_name := StringName("%s_%s" % [prefix, DIRS[row]])
|
||||
sf.add_animation(anim_name)
|
||||
sf.set_animation_loop(anim_name, loop)
|
||||
sf.set_animation_speed(anim_name, fps)
|
||||
for col in 4:
|
||||
var at := AtlasTexture.new()
|
||||
at.atlas = tex
|
||||
at.region = Rect2(col * CELL, row * CELL, CELL, CELL)
|
||||
sf.add_frame(anim_name, at)
|
||||
1
scenes/pawn/pawn_sprite_frames.gd.uid
Normal file
1
scenes/pawn/pawn_sprite_frames.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c4otxk5jg0kxr
|
||||
Loading…
Add table
Add a link
Reference in a new issue