squad-proto/tools/tests/test_monster_assets.py
z.kirill 8d35f4c2ec Конвейер ассетов: манифесты, риги, каталоги, инструменты
Всё, из чего собираются спрайты, и ничего собранного. Источник истины —
assets/manifests и assets/*/catalog.yaml; PNG, .sfa и contact sheet остаются
за бортом, потому что воспроизводятся командой (docs/12-assets.md).

  assets/manifests   что и как рендерить: окружение, твари, агенты, предметы
  assets/agents      паперdoll-риг восьми направлений, каталог снаряжения
  assets/monsters    пять морфологий, каждая своим ригом (docs/14-monsters.md)
  assets/sources     исходные развёртки и импорты
  assets/art_masters мастер-развёртки, по которым сверяется стиль
  tools/             сборка ассетов, покрытие, генераторы, blender-скрипты
  generated/         заголовки с константами SF_ASSET_*: без них игра не
                     соберётся, поэтому они в репозитории, хотя и производные

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:09 +03:00

428 lines
21 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.

"""Тесты конвейера тварей: упаковка, манифест, проверки, покрытие.
Blender здесь не запускается. Проверяется ровно то, что ломается молча:
кадры, разложенные не в том порядке; тварь, вылезшая за кромку клетки; sync,
затирающий ручную спеку; и — главное для ЭТОГО конвейера — расхождение имён
между манифестом и tools/asset_coverage.py.
python -m unittest discover -s tools/tests
"""
from __future__ import annotations
import importlib.util
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import yaml
ROOT = Path(__file__).resolve().parents[2]
def _load(name: str, relative: str):
spec = importlib.util.spec_from_file_location(name, ROOT / relative)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
ma = _load("monster_assets", "tools/monster_assets.py")
coverage = _load("asset_coverage", "tools/asset_coverage.py")
class Sandbox(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp(prefix="monster_assets_"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.ws = ma.Workspace(self.dir)
self.ws.catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / "assets/monsters/catalog.yaml", self.ws.catalog)
self.cfg = ma.load_config(self.ws)
def render_frame(self, marks, supersample=1):
"""Кадр Blender: непрозрачные квадраты в координатах кадра рендера."""
size = (self.cfg.render_width * supersample, self.cfg.render_height * supersample)
image = Image.new("RGBA", size, (0, 0, 0, 0))
for x, y, w, h in marks:
block = Image.new("RGBA", (w * supersample, h * supersample), (150, 40, 40, 255))
image.paste(block, (x * supersample, y * supersample))
return image
def sheet_from_cells(self, animation, boxes):
cfg = self.cfg
sheet = Image.new("RGBA", cfg.sheet_size(animation), (0, 0, 0, 0))
for index, cell_boxes in enumerate(boxes):
layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0))
for x, y, w, h in cell_boxes:
layer.paste(Image.new("RGBA", (w, h), (150, 40, 40, 255)), (x, y))
ma.paste_cell(cfg, sheet, index, layer)
return sheet
def save(self, kind, animation, sheet):
path = self.ws.source_path(kind, animation)
path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(path)
return path
def creature(self, animation, width=20, height=30, lean=None):
"""Клип: зеркальные пары совпадают точно, соседние — заметно разные.
Снос по X берётся от запаса до кромки: чем уже тварь, тем сильнее её
разносит по направлениям. Иначе прямоугольники соседей перекрываются
настолько, что перестановка направлений проходит проверку незамеченной.
"""
cfg = self.cfg
lean = (cfg.width - width) // 6 if lean is None else lean
# (ширина, снос от pivot_x) по каталожному порядку направлений
shape = [(width, 0), (width - 2, -lean), (width - 8, -3 * lean), (width - 2, -lean),
(width - 2, 0), (width - 2, lean), (width - 8, 3 * lean), (width - 2, lean)]
cells = []
for w, dx in shape:
for _ in range(cfg.frames(animation)):
cells.append([(cfg.pivot_x - w // 2 + dx, cfg.pivot_y - height, w, height)])
return self.sheet_from_cells(animation, cells)
# ---------------------------------------------------------------------------
# Геометрия каталога и упаковка кадров Blender
# ---------------------------------------------------------------------------
class TestCatalog(Sandbox):
def test_render_frame_is_symmetric_about_the_pivot(self):
cfg = self.cfg
self.assertEqual((cfg.width, cfg.height), (96, 112))
self.assertEqual(cfg.render_width, 96)
self.assertEqual(cfg.render_height, 176)
self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0))
self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y)
def test_pivot_leaves_room_for_the_ground_plane_behind_the_creature(self):
"""Под точкой опоры обязан быть запас: хвост и задние лапы уходят ВНИЗ."""
self.assertGreaterEqual(self.cfg.height - self.cfg.pivot_y, 8)
def test_camera_matches_the_agent_and_the_game_tile_projection(self):
import math
agent = yaml.safe_load((ROOT / "assets/agents/catalog.yaml").read_text(encoding="utf-8"))
self.assertAlmostEqual(self.cfg.camera["elevation_deg"],
agent["camera"]["elevation_deg"], places=3)
self.assertAlmostEqual(self.cfg.camera["elevation_deg"],
math.degrees(math.atan(16 / 32)), places=2)
def test_every_kind_declares_all_four_clips(self):
self.assertEqual(set(self.cfg.animations), {"walk", "attack", "hit", "death"})
self.assertEqual(list(self.cfg.kinds),
["shambler", "rusher", "spitter", "brute", "lurker"])
def test_fps_follows_the_creature_tempo(self):
# Бегун частит, громила волочится: одинаковое число кадров, разный fps.
self.assertGreater(self.cfg.fps("rusher", "walk"), self.cfg.fps("brute", "walk"))
self.assertEqual(self.cfg.fps("shambler", "walk"), 8)
class TestPacking(Sandbox):
def test_sheet_is_direction_major(self):
cfg = self.cfg
frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)])
for _ in range(cfg.cells("walk"))]
sheet = ma.pack_render_frames(cfg, "walk", frames, 1)
self.assertEqual(cfg.cells("walk"), 64)
self.assertEqual(sheet.size, (cfg.width * 64, cfg.height))
def test_origin_lands_exactly_on_the_pivot(self):
cfg = self.cfg
centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1)
sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([centre])] * cfg.cells("hit"), 1)
for index in range(cfg.cells("hit")):
box = ma.bbox_of(ma.cell_at(cfg, sheet, index))
self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y),
f"cell {index} origin moved off the pivot")
def test_every_cell_uses_the_same_crop_regardless_of_content(self):
"""Общий pivot: мелкая тварь не растягивается на клетку, крупная не жмётся."""
cfg = self.cfg
small = (cfg.crop_x + 20, cfg.crop_y + 30, 6, 5)
large = (cfg.crop_x + 4, cfg.crop_y + 6, 40, 46)
cells = cfg.cells("hit")
small_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([small])] * cells, 1)
large_sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([large])] * cells, 1)
self.assertEqual(ma.bbox_of(ma.cell_at(cfg, small_sheet, 0)), (20, 30, 26, 35))
self.assertEqual(ma.bbox_of(ma.cell_at(cfg, large_sheet, 0)), (4, 6, 44, 52))
def test_supersampled_crop_is_an_exact_integer_reduction(self):
cfg = self.cfg
mark = (cfg.crop_x + 10, cfg.crop_y + 12, 8, 8)
sheet = ma.pack_render_frames(cfg, "hit", [self.render_frame([mark], 4)] * cfg.cells("hit"), 4)
self.assertEqual(ma.bbox_of(ma.cell_at(cfg, sheet, 0)), (10, 12, 18, 20))
def test_wrong_render_resolution_is_refused(self):
cfg = self.cfg
frames = [self.render_frame([(0, 0, 2, 2)])] * cfg.cells("hit")
with self.assertRaises(ValueError):
ma.pack_render_frames(cfg, "hit", frames, 2)
def test_wrong_cell_count_is_refused(self):
with self.assertRaises(ValueError):
ma.pack_render_frames(self.cfg, "hit", [self.render_frame([(0, 0, 2, 2)])] * 7, 1)
# ---------------------------------------------------------------------------
# Манифест и заголовок
# ---------------------------------------------------------------------------
class TestSync(Sandbox):
def put_sheet(self, kind, animation):
path = self.ws.source_path(kind, animation)
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGBA", self.cfg.sheet_size(animation), (0, 0, 0, 0)).save(path)
return path
def manifest(self):
return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8"))
def test_asset_id_is_kind_underscore_animation(self):
self.put_sheet("shambler", "walk")
ma.sync(self.ws, self.cfg)
raw = self.manifest()
self.assertIn("shambler_walk", raw)
self.assertNotIn("monster_shambler_walk", raw)
self.assertNotIn("enemy_shambler_walk", raw)
def test_rendered_png_becomes_an_import_asset(self):
self.put_sheet("brute", "attack")
ma.sync(self.ws, self.cfg)
spec = self.manifest()["brute_attack"]
self.assertEqual(spec["backend"], "import")
self.assertEqual(spec["source"], "../sources/monsters/brute_attack.png")
self.assertEqual(spec["params"],
{"frame_width": 96, "frame_height": 112, "frames": 48})
self.assertEqual(self.manifest()["defaults"], {"dirs": 8, "pivot": "48,88"})
def test_frames_count_every_direction_and_frame(self):
self.put_sheet("lurker", "death")
ma.sync(self.ws, self.cfg)
self.assertEqual(self.manifest()["lurker_death"]["params"]["frames"], 64)
def test_fps_is_scaled_per_kind(self):
self.put_sheet("rusher", "walk")
self.put_sheet("brute", "walk")
ma.sync(self.ws, self.cfg)
raw = self.manifest()
self.assertEqual(raw["rusher_walk"]["fps"], self.cfg.fps("rusher", "walk"))
self.assertGreater(raw["rusher_walk"]["fps"], raw["brute_walk"]["fps"])
def test_hand_written_spec_survives_sync(self):
self.put_sheet("spitter", "hit")
ma.sync(self.ws, self.cfg)
text = self.ws.manifest.read_text(encoding="utf-8")
self.ws.manifest.write_text(text.replace(
"spitter_hit:\n backend: import\n"
" source: ../sources/monsters/spitter_hit.png\n",
"spitter_hit:\n backend: blender\n"
" source: ../monsters/monster_rig.blend\n scene: SPITTER\n"),
encoding="utf-8", newline="\n")
ma.sync(self.ws, self.cfg)
spec = self.manifest()["spitter_hit"]
self.assertEqual(spec["backend"], "blender")
self.assertEqual(spec["scene"], "SPITTER")
def test_import_specs_are_regenerated_not_preserved(self):
self.put_sheet("shambler", "walk")
ma.sync(self.ws, self.cfg)
self.ws.manifest.write_text(
self.ws.manifest.read_text(encoding="utf-8").replace("fps: 8", "fps: 999"),
encoding="utf-8", newline="\n")
ma.sync(self.ws, self.cfg)
self.assertEqual(self.manifest()["shambler_walk"]["fps"], 8)
def test_removed_png_drops_out_of_the_manifest(self):
path = self.put_sheet("shambler", "walk")
ma.sync(self.ws, self.cfg)
path.unlink()
ma.sync(self.ws, self.cfg)
self.assertNotIn("shambler_walk", self.manifest())
def test_header_hashes_match_the_manifest_names(self):
self.put_sheet("shambler", "walk")
ma.sync(self.ws, self.cfg)
header = self.ws.header.read_text(encoding="utf-8")
for kind in self.cfg.kinds:
for animation in self.cfg.animations:
aid = ma.asset_id(kind, animation)
self.assertIn(f"MONSTER_ASSET_{ma.symbol(aid)} = "
f"UINT32_C(0x{ma.fnv1a(aid):08X});", header)
def test_header_maps_engine_enums_onto_the_clips(self):
self.put_sheet("shambler", "walk")
ma.sync(self.ws, self.cfg)
header = self.ws.header.read_text(encoding="utf-8")
# AnimKind у движка: HURT/DIE, а клипы каталога — hit/death.
self.assertIn("case AnimKind::HURT: return MONSTER_ASSET_SHAMBLER_HIT;", header)
self.assertIn("case AnimKind::DIE: return MONSTER_ASSET_SHAMBLER_DEATH;", header)
self.assertIn("case EnemyKind::LURKER:", header)
self.assertNotIn("EnemyKind::DUMMY", header)
def test_hash_uses_the_same_fnv1a_constants_as_the_engine(self):
"""Разойдись хоть один бит — ассет молча не найдётся, без ошибки в логе."""
source = (ROOT / "src/render/anim.h").read_text(encoding="utf-8")
self.assertIn("0x811C9DC5u", source)
self.assertIn("0x01000193u", source)
self.assertEqual(ma.fnv1a(""), 0x811C9DC5)
self.assertEqual(ma.fnv1a("a"), ((0x811C9DC5 ^ 0x61) * 0x01000193) & 0xFFFFFFFF)
def test_collision_with_the_legacy_manifest_is_reported(self):
self.put_sheet("shambler", "walk")
legacy = self.ws.manifest_dir / "enemies.yaml"
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text("version: 1\nshambler_walk: {backend: import, source: x.png}\n",
encoding="utf-8")
ma.sync(self.ws, self.cfg)
report = ma.Report([], [])
ma.check_manifest(self.ws, self.cfg, report)
self.assertTrue(any("enemies.yaml" in w for w in report.warnings))
# ---------------------------------------------------------------------------
# Покрытие: имена конвейера обязаны совпасть с тем, что требует гейт
# ---------------------------------------------------------------------------
class TestCoverage(unittest.TestCase):
def setUp(self) -> None:
self.spec = yaml.safe_load(
(ROOT / "assets/content_coverage.yaml").read_text(encoding="utf-8"))
self.cfg = ma.load_config(ma.Workspace(ROOT))
def test_pipeline_ids_are_exactly_what_the_gate_asks_for(self):
wanted = set(coverage.requirements(self.spec)["enemy"])
produced = {ma.asset_id(kind, animation)
for kind in self.cfg.kinds for animation in self.cfg.animations}
self.assertEqual(produced, wanted)
def test_catalog_kinds_match_the_coverage_spec(self):
self.assertEqual(list(self.cfg.kinds), list(self.spec["enemies"]["kinds"]))
self.assertEqual(set(self.cfg.animations), set(self.spec["enemies"]["animations"]))
def test_the_gate_reads_the_manifest_the_pipeline_writes(self):
"""Гейт собирает ID из assets/manifests/*.yaml — sync пишет ровно туда."""
ws = ma.Workspace(ROOT)
self.assertEqual(ws.manifest.parent, coverage.MANIFESTS)
self.assertTrue(ws.manifest.name.endswith(".yaml"))
self.assertEqual(len(coverage.requirements(self.spec)["enemy"]),
len(self.cfg.kinds) * len(self.cfg.animations))
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
class TestValidate(Sandbox):
def test_clean_sheet_passes(self):
self.save("shambler", "walk", self.creature("walk"))
self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0)
def test_canvas_mismatch_is_an_error(self):
path = self.save("shambler", "walk", self.creature("walk"))
Image.open(path).resize((100, 100)).save(path)
self.assertEqual(ma.validate(self.ws, self.cfg), 1)
def test_empty_cell_is_an_error(self):
cfg = self.cfg
sheet = self.creature("hit")
ma.paste_cell(cfg, sheet, 5, Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0)))
self.save("rusher", "hit", sheet)
self.assertEqual(ma.validate(self.ws, self.cfg), 1)
def test_creature_floating_above_the_ground_line_is_an_error(self):
cfg = self.cfg
cells = [[(cfg.pivot_x - 8, 4, 16, 20)] for _ in range(cfg.cells("walk"))]
self.save("lurker", "walk", self.sheet_from_cells("walk", cells))
self.assertEqual(ma.validate(self.ws, self.cfg), 1)
def test_origin_off_the_turn_axis_is_an_error(self):
cfg = self.cfg
cells = [[(cfg.pivot_x + 12, cfg.pivot_y - 30, 14, 30)]
for _ in range(cfg.cells("walk"))]
self.save("lurker", "walk", self.sheet_from_cells("walk", cells))
self.assertEqual(ma.validate(self.ws, self.cfg), 1)
def test_silhouette_clipped_by_the_cell_edge_warns(self):
"""Замах, не влезший в клетку, в игре выглядит как отрубленная лапа."""
cfg = self.cfg
sheet = self.creature("attack")
layer = Image.new("RGBA", (cfg.width, cfg.height), (0, 0, 0, 0))
layer.paste(Image.new("RGBA", (cfg.width, 30), (150, 40, 40, 255)), (0, cfg.pivot_y - 30))
ma.paste_cell(cfg, sheet, 2, layer)
self.save("brute", "attack", sheet)
report = ma.Report([], [])
ma.check_framing(cfg, "brute_attack.png", sheet, "attack", report)
self.assertTrue(any("clipped" in w for w in report.warnings))
self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1)
def test_death_is_allowed_to_leave_the_ground_line(self):
cfg = self.cfg
# Осевшая туша: низкая, широкая, не достаёт до линии пола — это норма.
cells = [[(cfg.pivot_x - 14, cfg.pivot_y - 12, 28, 8)]
for _ in range(cfg.cells("death"))]
self.save("brute", "death", self.sheet_from_cells("death", cells))
report = ma.Report([], [])
masks = ma.union_masks(cfg, self.sheet_from_cells("death", cells), "death")
ma.check_ground(cfg, "brute_death.png", masks, "death", report)
self.assertFalse(report.errors)
def test_shuffled_direction_order_warns(self):
cfg = self.cfg
frames = cfg.frames("walk")
good = self.creature("walk")
cells = [ma.cell_at(cfg, good, i) for i in range(cfg.cells("walk"))]
# Меняем местами юго-запад и запад целиком.
a, b = slice(frames, 2 * frames), slice(2 * frames, 3 * frames)
cells[a], cells[b] = cells[b], cells[a]
sheet = Image.new("RGBA", cfg.sheet_size("walk"), (0, 0, 0, 0))
for index, cell in enumerate(cells):
ma.paste_cell(cfg, sheet, index, cell)
report = ma.Report([], [])
ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report)
self.assertTrue(report.warnings)
def test_one_direction_repeated_eight_times_warns(self):
cfg = self.cfg
cells = [[(cfg.pivot_x - 10, cfg.pivot_y - 30, 20, 30)]
for _ in range(cfg.cells("walk"))]
sheet = self.sheet_from_cells("walk", cells)
report = ma.Report([], [])
ma.check_direction_order(cfg, "shambler_walk.png", ma.union_masks(cfg, sheet, "walk"), report)
self.assertTrue(any("same silhouette" in w for w in report.warnings))
def test_two_kinds_with_the_same_silhouette_warn(self):
"""Пять видов обязаны различаться пятном, иначе это пять капсул."""
self.save("shambler", "walk", self.creature("walk"))
self.save("brute", "walk", self.creature("walk"))
self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 1)
def test_different_morphologies_pass(self):
# Узкий и высокий, широкий и высокий, широкий и плоский — ровно то, чем
# бредущий, громила и затаившийся обязаны различаться в игре.
self.save("shambler", "walk", self.creature("walk", width=10, height=40))
self.save("brute", "walk", self.creature("walk", width=34, height=44))
self.save("lurker", "walk", self.creature("walk", width=32, height=10))
self.assertEqual(ma.validate(self.ws, self.cfg, strict=True), 0)
def test_complete_flag_reports_missing_sheets(self):
self.save("shambler", "walk", self.creature("walk"))
self.assertEqual(ma.validate(self.ws, self.cfg, complete=True), 1)
def test_manifest_pivot_drift_is_an_error(self):
self.save("shambler", "walk", self.creature("walk"))
ma.sync(self.ws, self.cfg)
self.ws.manifest.write_text(
self.ws.manifest.read_text(encoding="utf-8").replace('"48,88"', '"48,80"'),
encoding="utf-8", newline="\n")
self.assertEqual(ma.validate(self.ws, self.cfg), 1)
if __name__ == "__main__":
unittest.main()