spriteforge/tests/test_sequence.py

222 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Раскладка кадров многонаправленных ассетов и путь генератора-последовательности."""
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)