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