spriteforge/tests/test_sequence.py

222 lines
10 KiB
Python
Raw Permalink Normal View History

"""Раскладка кадров многонаправленных ассетов и путь генератора-последовательности."""
import json
from pathlib import Path
import numpy as np
import pytest
from typer.testing import CliRunner
from spriteforge import pixel
from spriteforge.cli import app
from spriteforge.backends import BackendContext, RawFrame, create_backend
from spriteforge.backends.procedural import (register_generator, register_sequence_generator,
sequence_generator_names)
from spriteforge.backends.sequence import (build_sequence_asset, direction_centidegrees, duration_from_fps,
frame_index, resolve_pivot)
from spriteforge.format import Animation, encode_sfa
from spriteforge.manifest import AssetSpec
from spriteforge.postprocess import build_palette, encode_frames, normalize_asset
from spriteforge.reader import decode_sfa
CONTEXT = BackendContext(Path.cwd(), "walker")
ANIMS = (("idle", 2), ("walk", 3))
def marker(duration: int) -> RawFrame:
"""Кадр-метка: содержимое неважно, опознаётся по длительности."""
rgba = pixel.canvas(4, 4)
pixel.draw_rect(rgba, 1, 1, 2, 2, "#C87840")
return RawFrame(rgba, 1, 3, duration)
def stamp(animation: int, direction: int, frame: int) -> int:
return 1 + 100 * animation + 10 * direction + frame
def sample_frames(direction_count: int) -> dict[str, list[list[RawFrame]]]:
return {name: [[marker(stamp(animation, direction, frame)) for frame in range(count)]
for direction in range(direction_count)]
for animation, (name, count) in enumerate(ANIMS)}
def test_direction_angles_split_the_circle():
assert direction_centidegrees(1) == (0,)
assert direction_centidegrees(8) == (0, 4500, 9000, 13500, 18000, 22500, 27000, 31500)
assert direction_centidegrees(3) == (0, 12000, 24000)
with pytest.raises(ValueError):
direction_centidegrees(0)
def test_frame_index_is_direction_major_inside_each_animation():
assert frame_index(ANIMS, 4, "idle", 0, 0) == 0
assert frame_index(ANIMS, 4, "idle", 1, 1) == 3
assert frame_index(ANIMS, 4, "walk", 0, 0) == 8
assert frame_index(ANIMS, 4, "walk", 3, 2) == 8 + 3 * 3 + 2
covered = [frame_index(ANIMS, 4, name, direction, frame)
for name, count in ANIMS for direction in range(4) for frame in range(count)]
assert sorted(covered) == list(range(20))
def test_frame_index_rejects_impossible_coordinates():
with pytest.raises(ValueError, match="unknown animation"):
frame_index(ANIMS, 4, "fly", 0, 0)
with pytest.raises(ValueError, match="direction 4"):
frame_index(ANIMS, 4, "idle", 4, 0)
with pytest.raises(ValueError, match="frame 2"):
frame_index(ANIMS, 4, "idle", 0, 2)
def test_layout_survives_encoding_and_matches_the_reader():
directions = direction_centidegrees(4)
asset = build_sequence_asset(sample_frames(4), directions)
assert asset.animations == ANIMS and asset.animation == "idle"
assert len(asset.frames) == sum(count for _, count in ANIMS) * 4
normalized = normalize_asset(asset, None)
blob = encode_sfa(encode_frames(normalized, build_palette([normalized])), animation=asset.animation,
directions=asset.directions_centidegrees, layer_count=asset.layer_count,
animations=tuple(Animation(name, count) for name, count in asset.animations))
sfa = decode_sfa(blob)
assert sfa.directions == directions
assert tuple((name, per_direction) for name, _, per_direction in sfa.animations) == ANIMS
for animation, (name, first, per_direction) in enumerate(sfa.animations):
for direction in range(len(directions)):
for frame in range(per_direction):
index = first + direction * per_direction + frame
assert index == frame_index(asset.animations, len(directions), name, direction, frame)
assert sfa.frames[index].duration_ms == stamp(animation, direction, frame)
def test_build_sequence_asset_rejects_ragged_input():
directions = direction_centidegrees(4)
with pytest.raises(ValueError, match="direction"):
build_sequence_asset(sample_frames(3), directions)
ragged = sample_frames(4)
ragged["walk"][2] = ragged["walk"][2][:1]
with pytest.raises(ValueError, match="same frame count"):
build_sequence_asset(ragged, directions)
empty = sample_frames(4)
empty["idle"] = [[] for _ in directions]
with pytest.raises(ValueError, match="no frames"):
build_sequence_asset(empty, directions)
with pytest.raises(ValueError, match="at least one animation"):
build_sequence_asset({}, directions)
with pytest.raises(ValueError, match="at least one direction"):
build_sequence_asset(sample_frames(0), ())
with pytest.raises(TypeError):
build_sequence_asset({"idle": [[object()]]}, (0,))
with pytest.raises(ValueError, match="layer_orders"):
build_sequence_asset({"idle": [[marker(1)]]}, (0,), layer_count=2, layer_orders=[(0, 1), (1, 0)])
def test_resolve_pivot_and_duration():
assert resolve_pivot("bottom_center", 8, 12) == (4, 11)
assert resolve_pivot("center", 8, 12) == (4, 6)
assert resolve_pivot("3,7", 8, 12) == (3, 7)
with pytest.raises(ValueError):
resolve_pivot("middle", 8, 12)
assert duration_from_fps(10) == 100 and duration_from_fps(12) == 83
with pytest.raises(ValueError):
duration_from_fps(0)
def walker(spec, rng):
"""Демонстрационный генератор-последовательность: столбик с шумом."""
directions = direction_centidegrees(spec.dirs)
duration = duration_from_fps(spec.fps)
animations = {}
for name in spec.anims or ["idle"]:
rows = []
for direction in range(len(directions)):
frames = []
for number in range(2):
image = pixel.canvas(8, 12)
pixel.draw_capsule(image, 4, 3 + number, 4, 9, 3.0, "#8090A0")
pixel.add_noise(image, rng, 6)
pivot = resolve_pivot(spec.pivot, image.shape[1], image.shape[0])
frames.append(RawFrame(image, pivot[0], pivot[1], duration))
rows.append(frames)
animations[name] = rows
return build_sequence_asset(animations, directions)
def only_idle(spec, rng):
directions = direction_centidegrees(spec.dirs)
return build_sequence_asset({"idle": [[marker(1)] for _ in directions]}, directions)
def one_direction(spec, rng):
return build_sequence_asset({"idle": [[marker(1)]]}, (0,))
register_sequence_generator("test_walker", walker, replace=True)
register_sequence_generator("test_only_idle", only_idle, replace=True)
register_sequence_generator("test_one_direction", one_direction, replace=True)
def walker_spec(**changes) -> AssetSpec:
values = {"backend": "procedural", "generator": "test_walker", "dirs": 8,
"anims": ["idle", "walk"], "seed": 5, "fps": 10}
values.update(changes)
return AssetSpec(**values)
def test_backend_runs_a_sequence_generator_with_eight_directions():
asset = create_backend("procedural").generate(walker_spec(), CONTEXT)
assert asset.directions_centidegrees == direction_centidegrees(8)
assert asset.animations == (("idle", 2), ("walk", 2)) and asset.animation == "idle"
assert len(asset.frames) == 2 * 2 * 8
assert all(frame.duration_ms == 100 for frame in asset.frames)
assert all(frame.pivot_x == 4 and frame.pivot_y == 11 for frame in asset.frames)
def test_sequence_generator_output_depends_only_on_the_seed():
backend = create_backend("procedural")
first = backend.generate(walker_spec(), CONTEXT)
again = backend.generate(walker_spec(), CONTEXT)
other = backend.generate(walker_spec(seed=6), CONTEXT)
assert all(np.array_equal(a.rgba, b.rgba) for a, b in zip(first.frames, again.frames))
assert not np.array_equal(first.frames[0].rgba, other.frames[0].rgba)
def test_sequence_asset_passes_the_normal_postprocess():
asset = normalize_asset(create_backend("procedural").generate(walker_spec(dirs=4), CONTEXT), "8x8")
frames = encode_frames(asset, build_palette([asset]))
assert len(frames) == 2 * 2 * 4
assert all(frame.width <= 8 and frame.height <= 8 for frame in frames)
def test_sequence_generator_is_checked_against_the_manifest():
backend = create_backend("procedural")
with pytest.raises(ValueError, match="dirs: 4"):
backend.generate(walker_spec(generator="test_one_direction", dirs=4, anims=[]), CONTEXT)
with pytest.raises(ValueError, match="attack"):
backend.generate(walker_spec(generator="test_only_idle", dirs=2, anims=["idle", "attack"]), CONTEXT)
def test_cli_builds_a_multi_direction_asset(tmp_path):
manifest = tmp_path / "assets.yaml"
manifest.write_text("hero:\n backend: procedural\n generator: test_walker\n"
" dirs: 8\n fps: 10\n seed: 3\n anims: [idle, walk]\n", encoding="utf-8")
cache = tmp_path / "cache"
first, second = tmp_path / "out", tmp_path / "out2"
runner = CliRunner()
for output in (first, second):
result = runner.invoke(app, ["build", str(manifest), "--output", str(output), "--cache-dir", str(cache)])
assert result.exit_code == 0, result.output
sfa = decode_sfa((first / "hero.sfa").read_bytes())
assert len(sfa.directions) == 8 and len(sfa.frames) == 2 * 2 * 8
assert sfa.animations == (("idle", 0, 2), ("walk", 16, 2))
index = json.loads((first / "index.json").read_text())
assert index["assets"][0]["animations"] == [{"name": "idle", "first_frame": 0, "frames_per_direction": 2},
{"name": "walk", "first_frame": 16, "frames_per_direction": 2}]
# Кеш сериализует направления и таблицу анимаций: пересборка обязана дать те же байты.
assert (first / "hero.sfa").read_bytes() == (second / "hero.sfa").read_bytes()
def test_names_cannot_collide_across_the_two_registries():
assert "test_walker" in sequence_generator_names()
with pytest.raises(ValueError):
register_sequence_generator("floor_tile", walker)
with pytest.raises(ValueError):
register_generator("test_walker", lambda params, rng: None)