squad-proto/tools/tests/test_agent_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

435 lines
21 KiB
Python
Raw 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 здесь не запускается. Проверяется ровно то, что ломается молча:
кадры, разложенные не в том порядке; экипировка, подогнанная по собственному
bbox; sync, затирающий ручную blender-спеку.
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
ROOT = Path(__file__).resolve().parents[2]
_spec = importlib.util.spec_from_file_location("agent_assets", ROOT / "tools/agent_assets.py")
aa = importlib.util.module_from_spec(_spec)
sys.modules["agent_assets"] = aa
_spec.loader.exec_module(aa)
# ---------------------------------------------------------------------------
# Каркас: временный воркспейс с настоящим каталогом
# ---------------------------------------------------------------------------
class Sandbox(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp(prefix="agent_assets_"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.ws = aa.Workspace(self.dir)
self.ws.catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / "assets/agents/catalog.yaml", self.ws.catalog)
self.cfg = aa.load_config(self.ws)
# -- синтетические кадры Blender -------------------------------------
def render_frame(self, marks, supersample=1):
"""Кадр рендера: непрозрачные квадраты по координатам кадра рендера."""
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), (200, 60, 40, 255))
image.paste(block, (x * supersample, y * supersample))
return image
# -- синтетические доски 4x2 -----------------------------------------
def board(self, cell_size, shapes, key=(255, 0, 255)):
"""Доска 4 колонки x 2 строки; shapes[i] — список (x, y, w, h) внутри ячейки."""
cw, ch = cell_size
board = Image.new("RGB", (cw * 4, ch * 2), key)
for index, boxes in enumerate(shapes):
ox, oy = (index % 4) * cw, (index // 4) * ch
for x, y, w, h in boxes:
board.paste(Image.new("RGB", (w, h), (30, 40, 60)), (ox + x, oy + y))
return board
def write_board(self, name, image):
path = self.dir / name
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
return path
def humanoid(self, cell_size, lean=0):
"""Восемь ячеек «тела»: одинаковый прямоугольник, слегка гуляющий по X."""
cw, ch = cell_size
w, h = cw // 6, int(ch * 0.6)
top = int(ch * 0.2)
return [[(cw // 2 - w // 2 + lean * (i - 4), top, w, h)] for i in range(8)]
# ---------------------------------------------------------------------------
# Упаковка кадров Blender
# ---------------------------------------------------------------------------
class TestPacking(Sandbox):
def test_sheet_is_direction_major_with_catalog_geometry(self):
cfg = self.cfg
frames = [self.render_frame([(cfg.crop_x, cfg.crop_y, 1, 1)])
for _ in range(cfg.cells("walk"))]
sheet = aa.pack_render_frames(cfg, "walk", frames, 1)
self.assertEqual(sheet.size, (cfg.width * 8 * 8, cfg.height))
# колонка = направление * кадров + кадр
self.assertEqual(cfg.cells("walk"), 64)
def test_origin_lands_exactly_on_the_pivot(self):
cfg = self.cfg
# Пиксель в центре кадра рендера — это начало координат рига.
centre = (cfg.render_width // 2, cfg.render_height // 2, 1, 1)
sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([centre])] * 8, 1)
for direction in range(8):
box = aa.bbox_of(aa.cell_at(cfg, sheet, direction))
self.assertEqual((box[0], box[1]), (cfg.pivot_x, cfg.pivot_y),
f"direction {direction} origin moved off the pivot")
def test_every_cell_uses_the_same_crop_regardless_of_content(self):
"""Гарантия бумажной куклы: маленькое оружие не растягивается на клетку."""
cfg = self.cfg
gear = (cfg.crop_x + 40, cfg.crop_y + 20, 6, 4)
body = (cfg.crop_x + 10, cfg.crop_y + 5, 50, 80)
gear_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([gear])] * 8, 1)
body_sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([body])] * 8, 1)
self.assertEqual(aa.bbox_of(aa.cell_at(cfg, gear_sheet, 0)), (40, 20, 46, 24))
self.assertEqual(aa.bbox_of(aa.cell_at(cfg, body_sheet, 0)), (10, 5, 60, 85))
def test_supersampled_crop_is_an_exact_integer_reduction(self):
cfg = self.cfg
mark = (cfg.crop_x + 12, cfg.crop_y + 30, 8, 8)
sheet = aa.pack_render_frames(cfg, "idle", [self.render_frame([mark], 4)] * 8, 4)
self.assertEqual(aa.bbox_of(aa.cell_at(cfg, sheet, 0)), (12, 30, 20, 38))
def test_wrong_render_resolution_is_refused(self):
cfg = self.cfg
frames = [self.render_frame([(0, 0, 2, 2)])] * 8
with self.assertRaises(ValueError):
aa.pack_render_frames(cfg, "idle", frames, 2)
def test_wrong_cell_count_is_refused(self):
with self.assertRaises(ValueError):
aa.pack_render_frames(self.cfg, "idle", [self.render_frame([(0, 0, 2, 2)])] * 7, 1)
# ---------------------------------------------------------------------------
# Растровый ingest
# ---------------------------------------------------------------------------
class TestIngest(Sandbox):
CELL = (200, 240)
def ingest_body(self, lean=0):
board = self.board(self.CELL, self.humanoid(self.CELL, lean))
path = self.write_board("body.png", board)
return aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path])
def test_body_ingest_writes_calibration_and_sits_on_the_pivot(self):
self.ingest_body()
cal = aa.load_calibration(self.ws)
self.assertIsNotNone(cal)
self.assertEqual(cal.source, "body_sleek/idle")
sheet = Image.open(self.ws.source_path("body_sleek", "idle"))
box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0))
self.assertAlmostEqual(box[3], self.cfg.pivot_y, delta=1)
self.assertAlmostEqual((box[0] + box[2]) / 2, self.cfg.pivot_x, delta=1)
def test_equipment_keeps_its_position_relative_to_the_full_cell(self):
"""Кинжал в руке обязан остаться в руке, а не съехать к стопам."""
self.ingest_body()
cw, ch = self.CELL
# Маленький блок высоко в ячейке — «оружие у пояса».
gear_x, gear_y, gear_w, gear_h = cw // 2 + 14, int(ch * 0.42), 10, 12
gear = self.board(self.CELL, [[(gear_x, gear_y, gear_w, gear_h)] for _ in range(8)])
path = self.write_board("gear.png", gear)
aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path])
cal = aa.load_calibration(self.ws)
scale, ox, oy = aa.cell_transform(self.cfg, cal, self.CELL)
sheet = Image.open(self.ws.source_path("weapon_dagger", "idle"))
box = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0))
self.assertAlmostEqual(box[0], ox + gear_x * scale, delta=1.5)
self.assertAlmostEqual(box[1], oy + gear_y * scale, delta=1.5)
self.assertAlmostEqual(box[2] - box[0], gear_w * scale, delta=1.5)
self.assertAlmostEqual(box[3] - box[1], gear_h * scale, delta=1.5)
# И главное: НЕ прижат к pivot_y и НЕ растянут на клетку.
self.assertLess(box[3], self.cfg.pivot_y - 10)
self.assertLess(box[3] - box[1], (self.cfg.height - aa.FIT_MARGIN_Y) // 2)
def test_equipment_before_body_is_refused(self):
gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)])
path = self.write_board("gear.png", gear)
with self.assertRaises(ValueError):
aa.ingest(self.ws, self.cfg, "weapon_dagger", "idle", [path])
def test_calibrate_flag_is_body_only(self):
self.ingest_body()
gear = self.board(self.CELL, [[(10, 10, 8, 8)] for _ in range(8)])
path = self.write_board("gear.png", gear)
with self.assertRaises(ValueError):
aa.ingest(self.ws, self.cfg, "armor_vest", "idle", [path], calibrate=True)
def test_one_scale_for_every_direction(self):
"""Направление с более коротким силуэтом обязано остаться короче."""
cw, ch = self.CELL
cells = self.humanoid(self.CELL)
cells[4] = [(cw // 2 - 8, int(ch * 0.2), 16, int(ch * 0.45))] # север ниже прочих
path = self.write_board("body.png", self.board(self.CELL, cells))
aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path])
sheet = Image.open(self.ws.source_path("body_sleek", "idle"))
south = aa.bbox_of(aa.cell_at(self.cfg, sheet, 0))
north = aa.bbox_of(aa.cell_at(self.cfg, sheet, 4))
ratio = (north[3] - north[1]) / (south[3] - south[1])
self.assertAlmostEqual(ratio, 0.45 / 0.6, delta=0.05)
def test_boards_of_different_resolutions_share_one_transform(self):
"""Калибровка нормирована на ячейку, значит разрешение доски не важно."""
self.ingest_body()
cal = aa.load_calibration(self.ws)
small = aa.cell_transform(self.cfg, cal, (100, 120))
large = aa.cell_transform(self.cfg, cal, (400, 480))
self.assertAlmostEqual(small[0] * 100, large[0] * 400, places=6)
self.assertAlmostEqual(small[1], large[1], places=6)
self.assertAlmostEqual(small[2], large[2], places=6)
def test_frame_count_must_match_the_catalog(self):
path = self.write_board("body.png", self.board(self.CELL, self.humanoid(self.CELL)))
with self.assertRaises(ValueError):
aa.ingest(self.ws, self.cfg, "body_sleek", "walk", [path])
def test_empty_cell_is_refused(self):
cells = self.humanoid(self.CELL)
cells[3] = []
path = self.write_board("body.png", self.board(self.CELL, cells))
with self.assertRaises(ValueError):
aa.ingest(self.ws, self.cfg, "body_sleek", "idle", [path])
# ---------------------------------------------------------------------------
# Манифест
# ---------------------------------------------------------------------------
class TestSync(Sandbox):
def put_sheet(self, component, animation):
path = self.ws.source_path(component, 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):
import yaml
return yaml.safe_load(self.ws.manifest.read_text(encoding="utf-8"))
def test_rendered_png_becomes_an_import_asset(self):
self.put_sheet("body_sleek", "idle")
aa.sync(self.ws, self.cfg)
raw = self.manifest()
spec = raw["agent_body_sleek_idle"]
self.assertEqual(spec["backend"], "import")
self.assertEqual(spec["source"], "../sources/agents/sleek/body_sleek_idle.png")
self.assertEqual(spec["params"], {"frame_width": 70, "frame_height": 90, "frames": 8})
self.assertEqual(raw["defaults"], {"dirs": 8, "pivot": "35,87"})
self.assertNotIn("agent_armor_vest_idle", raw)
def test_walk_asset_counts_every_direction_and_frame(self):
self.put_sheet("body_sleek", "walk")
aa.sync(self.ws, self.cfg)
self.assertEqual(self.manifest()["agent_body_sleek_walk"]["params"]["frames"], 64)
def test_hand_written_blender_spec_survives_sync(self):
self.put_sheet("weapon_rifle", "idle")
aa.sync(self.ws, self.cfg)
text = self.ws.manifest.read_text(encoding="utf-8")
self.ws.manifest.write_text(text.replace(
"agent_weapon_rifle_idle:\n backend: import\n"
" source: ../sources/agents/sleek/weapon_rifle_idle.png\n",
"agent_weapon_rifle_idle:\n backend: blender\n"
" source: ../sources/agents/sleek/rig.blend\n scene: WEAPON\n"),
encoding="utf-8", newline="\n")
aa.sync(self.ws, self.cfg)
spec = self.manifest()["agent_weapon_rifle_idle"]
self.assertEqual(spec["backend"], "blender")
self.assertEqual(spec["scene"], "WEAPON")
def test_import_specs_are_regenerated_not_preserved(self):
self.put_sheet("body_sleek", "idle")
aa.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")
aa.sync(self.ws, self.cfg)
self.assertEqual(self.manifest()["agent_body_sleek_idle"]["fps"], 8)
def test_removed_png_drops_out_of_the_manifest(self):
path = self.put_sheet("body_sleek", "idle")
aa.sync(self.ws, self.cfg)
path.unlink()
aa.sync(self.ws, self.cfg)
self.assertNotIn("agent_body_sleek_idle", self.manifest())
def test_header_ids_match_the_manifest_names(self):
self.put_sheet("body_sleek", "idle")
aa.sync(self.ws, self.cfg)
header = self.ws.header.read_text(encoding="utf-8")
self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_body_sleek_idle'):08X})", header)
self.assertIn(f"UINT32_C(0x{aa.fnv1a('agent_weapon_rifle_idle'):08X})", header)
# ---------------------------------------------------------------------------
# validate
# ---------------------------------------------------------------------------
class TestValidate(Sandbox):
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), (180, 60, 40, 255)), (x, y))
aa.paste_cell(cfg, sheet, index, layer)
return sheet
def save(self, component, animation, sheet):
path = self.ws.source_path(component, animation)
path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(path)
return path
def good_body(self):
"""Восемь направлений: зеркальные пары совпадают, юг и север различны."""
cfg = self.cfg
wide, narrow = 22, 12
# (ширина, сдвиг от pivot) по каталожному порядку
shape = [(wide, 0), (18, -4), (narrow, -7), (16, -4),
(wide - 2, 0), (16, 4), (narrow, 7), (18, 4)]
cells = []
for width, dx in shape:
x = cfg.pivot_x - width // 2 + dx
cells.append([(x, 20, width, cfg.pivot_y - 20)])
return self.sheet_from_cells("idle", cells)
def test_clean_body_passes(self):
self.save("body_sleek", "idle", self.good_body())
self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0)
def test_canvas_mismatch_is_an_error(self):
path = self.save("body_sleek", "idle", self.good_body())
Image.open(path).resize((100, 100)).save(path)
self.assertEqual(aa.validate(self.ws, self.cfg), 1)
def test_body_off_the_pivot_is_an_error(self):
cfg = self.cfg
cells = [[(cfg.pivot_x - 10, 5, 20, 40)] for _ in range(8)]
self.save("body_sleek", "idle", self.sheet_from_cells("idle", cells))
self.assertEqual(aa.validate(self.ws, self.cfg), 1)
def test_equipment_fitted_on_its_own_is_an_error(self):
self.save("body_sleek", "idle", self.good_body())
cfg = self.cfg
# Классическая поломка: кинжал растянули на всю клетку и посадили на стопы.
stretched = [[(2, 3, cfg.width - 4, cfg.pivot_y - 3)] for _ in range(8)]
self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", stretched))
self.assertEqual(aa.validate(self.ws, self.cfg), 1)
def test_equipment_on_the_body_passes(self):
self.save("body_sleek", "idle", self.good_body())
cfg = self.cfg
gear = [[(cfg.pivot_x - 6, 40, 12, 14)] for _ in range(8)]
self.save("weapon_dagger", "idle", self.sheet_from_cells("idle", gear))
self.assertEqual(aa.validate(self.ws, self.cfg), 0)
def test_shuffled_direction_order_warns(self):
cells = [aa.cell_at(self.cfg, self.good_body(), i) for i in range(8)]
swapped = cells[:1] + [cells[2], cells[1]] + cells[3:] # ЮЗ и З местами
sheet = Image.new("RGBA", self.cfg.sheet_size("idle"), (0, 0, 0, 0))
for index, cell in enumerate(swapped):
aa.paste_cell(self.cfg, sheet, index, cell)
self.save("body_sleek", "idle", sheet)
report = aa.Report([], [])
aa.check_direction_order(self.cfg, "body", sheet, "idle", report)
self.assertTrue(report.warnings)
self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 1)
def test_one_direction_repeated_eight_times_warns(self):
cfg = self.cfg
cells = [[(cfg.pivot_x - 11, 20, 22, cfg.pivot_y - 20)] for _ in range(8)]
sheet = self.sheet_from_cells("idle", cells)
report = aa.Report([], [])
aa.check_direction_order(cfg, "body", sheet, "idle", report)
self.assertTrue(any("same silhouette" in w for w in report.warnings))
def test_walk_mirror_check_uses_the_whole_cycle(self):
"""Зеркальным парам разрешено расходиться внутри шага, но не в сумме."""
cfg = self.cfg
frames = cfg.frames("walk")
cells = []
for direction in range(8):
for frame in range(frames):
lead = 6 if (direction in (1, 2, 3) and frame < frames // 2) else -6
if direction in (5, 6, 7):
lead = -lead
x = cfg.pivot_x - 9 + (lead if direction not in (0, 4) else 0)
cells.append([(x, 20, 18, cfg.pivot_y - 20)])
self.save("body_sleek", "walk", self.sheet_from_cells("walk", cells))
self.assertEqual(aa.validate(self.ws, self.cfg, strict=True), 0)
def test_complete_flag_reports_missing_layers(self):
self.save("body_sleek", "idle", self.good_body())
self.assertEqual(aa.validate(self.ws, self.cfg, complete=True), 1)
def test_manifest_pivot_drift_is_an_error(self):
self.save("body_sleek", "idle", self.good_body())
aa.sync(self.ws, self.cfg)
self.ws.manifest.write_text(
self.ws.manifest.read_text(encoding="utf-8").replace('"35,87"', '"35,80"'),
encoding="utf-8", newline="\n")
self.assertEqual(aa.validate(self.ws, self.cfg), 1)
# ---------------------------------------------------------------------------
# Каталог и брифы
# ---------------------------------------------------------------------------
class TestCatalog(Sandbox):
def test_render_frame_is_symmetric_about_the_pivot(self):
cfg = self.cfg
self.assertEqual(cfg.render_width, 70)
self.assertEqual(cfg.render_height, 174)
self.assertEqual((cfg.crop_x, cfg.crop_y), (0, 0))
self.assertEqual(cfg.render_height // 2 - cfg.crop_y, cfg.pivot_y)
def test_camera_matches_the_game_tile_projection(self):
import math
self.assertAlmostEqual(self.cfg.camera["elevation_deg"],
math.degrees(math.atan(16 / 32)), places=2)
def test_prompt_names_the_catalog_geometry(self):
text = aa.prompt_for(self.cfg, "armor_vest", "walk")
self.assertIn("70x90", text)
self.assertIn("(35,87)", text)
self.assertIn("8 frame(s)", text)
self.assertIn("south, southwest, west", text)
def test_prompt_rejects_unknown_names(self):
with self.assertRaises(ValueError):
aa.prompt_for(self.cfg, "armor_nope", "idle")
with self.assertRaises(ValueError):
aa.prompt_for(self.cfg, "armor_vest", "dance")
if __name__ == "__main__":
unittest.main()