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