Separate authored assets and install generated output transactionally (#92)

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-02 01:19:51 +03:00
parent e7c00267b1
commit 657d8f16c6
9 changed files with 1090 additions and 766 deletions
+11
View File
@@ -0,0 +1,11 @@
# Authored source assets
Place artist-authored overrides under `assets/airdef/` in this directory, preserving the runtime-relative path.
For example, an authored icon at `source-assets/assets/airdef/textures/item/deploy_gepard.png` replaces the
procedural icon at the generated runtime path while the source copy remains untouched. The asset generator
copies this tree into its temporary staging tree, validates the merged runtime assets, then installs them using
the generated manifest. Do not edit generated files under `airdef-mod/assets/` directly; an untracked or
hand-edited output is treated as a conflict and left untouched.
This directory is intentionally kept separate from runtime and generated output. It currently contains no
asset payloads.
+12
View File
@@ -40,3 +40,15 @@
Первая версия мода: датапак на командах (`legacy/airdef-datapack/`) и контент-мод (`legacy/airdef-content/`) с генератором `legacy/gen_content.py`. Ничего из текущей сборки на него не ссылается: ни скрипты в `tools/`, ни `install.sh`, ни `Cargo.toml`, ни исходники мода. Не собирается и не устанавливается. Оставлен как история. Не редактировать и не удалять без отдельного решения.
`legacy/airdef-content/data/` сейчас не отслеживается git и не входит в эту карту.
## Авторские и сгенерированные ресурсы
- `tools/gen_models.py` генерирует геометрию моделей, блоковые состояния, item registry, локализацию и data JSON.
- `tools/gen_tex.py` отвечает за вывод процедурных текстур и иконок; переиспользуемые примитивы палитры также находятся здесь.
- `tools/airdef_assets/texture_catalog.py` связывает стабильную палитру и процедурные функции с текстурами моделей, не смешивая каталог с геометрией.
- `tools/airdef_assets/icons.py` содержит каталог предметов и чистую функцию рендера иконки с явными параметрами `(name, color, seed)`.
- `tools/airdef_assets/pipeline.py` проверяет manifest, рассчитывает безопасный план обновления и откатывает записи при исключении.
- `airdef-mod/source-assets/assets/airdef/` — источник авторских переопределений. Генератор объединяет его с процедурными ресурсами во временном дереве, проверяет ссылки/JSON/PNG, затем устанавливает runtime-файлы.
- `airdef-mod/assets/` и `airdef-mod/data/` — упакованные generated output. Не редактируйте их вручную; добавляйте авторские исходники в `source-assets`.
- `airdef-mod/generated-manifest.txt` хранит SHA-256 только для управляемого результата. Неизменённые старые outputs можно заменить или удалить; посторонние и вручную правленные файлы блокируют конфликтующий запуск.
- `python3 tools/test_gen_assets.py` проверяет manifest, защиту ручных файлов, откат при ошибке записи, детерминизм и полный цикл authored icon override/validation.
+1
View File
@@ -0,0 +1 @@
"""Shared safety helpers for AirDef's generated asset tree."""
+393
View File
@@ -0,0 +1,393 @@
"""Procedural item icon catalog and 16x16 raster renderer for AirDef."""
import math
import random
from PIL import Image
from .texture_catalog import clamp
# Предметы: (id, название, цвет иконки)
ITEMS = [
("designator", "Целеуказатель", (220, 50, 50)),
("launch_shahed", "Пуск: Шахед-136", (90, 92, 96)),
("launch_geran3", "Пуск: Герань-3", (30, 30, 34)),
("deploy_patriot", "Развернуть Patriot", (76, 86, 58)),
("deploy_gepard", "Развернуть Гепард", (104, 84, 54)),
("deploy_pantsir", "Развернуть Панцирь-С1", (82, 96, 60)),
("deploy_s400", "Развернуть С-400", (60, 80, 50)),
("deploy_s500", "Развернуть С-500", (108, 110, 80)),
("launch_lancet", "Пуск: Ланцет-3", (70, 78, 66)),
("launch_kh101", "Пуск: Х-101", (84, 88, 92)),
("launch_kalibr", "Пуск: Калибр", (150, 154, 156)),
("launch_kh22", "Пуск: Х-22", (226, 228, 222)),
("launch_zircon", "Пуск: Циркон", (52, 54, 52)),
("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)),
("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)),
("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)),
("deploy_irist", "Развернуть IRIS-T SLM", (76, 86, 58)),
("deploy_nasams", "Развернуть NASAMS", (76, 86, 58)),
("deploy_buk", "Развернуть Бук-М2", (82, 96, 60)),
("deploy_tor", "Развернуть Тор-М2", (108, 110, 80)),
("deploy_p1sun", "Развернуть P1-SUN", (52, 54, 52)),
("deploy_radar", "Развернуть РЛС дальнего обнаружения", (108, 110, 80)),
("deploy_depot", "Развернуть склад боеприпасов", (92, 78, 48)),
("deploy_tzm", "Развернуть ТЗМ", (82, 96, 60)),
("deploy_ew", "Развернуть станцию РЭБ", (108, 110, 80)),
("deploy_light", "Развернуть прожектор", (52, 54, 52)),
("mover", "Пульт перемещения", (60, 140, 220)),
("flare_gun", "Осветительная ракета", (255, 240, 180)),
("remover", "Свернуть ПВО", (200, 200, 200)),
]
# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу
# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание),
# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы.
class _Icon:
def __init__(self, color, seed):
self.rnd = random.Random(seed)
self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
self.pal = {
"b": tuple(color),
"d": tuple(clamp(v * 0.6) for v in color),
"l": tuple(clamp(v + 45) for v in color),
"k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40),
"y": (255, 232, 120), "w": (236, 236, 236),
}
def px(self, x, y, key):
if 0 <= x < 16 and 0 <= y < 16:
c = self.pal[key]
if key in ("b", "d", "l"):
c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c)
self.im.putpixel((x, y), c + (255,))
def rect(self, x0, y0, x1, y1, key):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.px(x, y, key)
def line(self, x0, y0, x1, y1, key):
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
self.px(x0, y0, key)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def disc(self, cx, cy, r, key):
for y in range(cy - r - 1, cy + r + 2):
for x in range(cx - r - 1, cx + r + 2):
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r:
self.px(x, y, key)
def ring(self, cx, cy, lo, hi, key):
for y in range(16):
for x in range(16):
if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi:
self.px(x, y, key)
def poly(self, pts, key):
# Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер.
n = len(pts)
for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1):
xs = []
for i in range(n):
(x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n]
if y1 == y2:
if y1 == y:
xs += [x1, x2]
elif min(y1, y2) <= y <= max(y1, y2):
xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
if xs:
for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1):
self.px(x, y, key)
def _sam_base(c):
c.rect(2, 8, 13, 11, "b")
c.rect(3, 12, 4, 13, "k")
c.rect(11, 12, 12, 13, "k")
# Развёртывание ПВО: машина + пусковая установка.
def _gepard(c): # ствольная турель, два длинных ствола
_sam_base(c)
c.disc(7, 6, 2, "b")
c.line(9, 5, 15, 5, "d")
c.line(9, 7, 15, 7, "d")
def _pantsir(c): # ствольная турель с ракетными контейнерами сверху
_sam_base(c)
c.disc(7, 7, 2, "b")
c.rect(4, 3, 5, 5, "l")
c.rect(9, 3, 10, 5, "l")
c.line(9, 6, 11, 6, "d")
def _s400(c): # три вертикальных контейнера
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _s500(c): # четыре вертикальных контейнера
_sam_base(c)
for x in (2, 5, 8, 11):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _patriot(c): # наклонный пусковой короб
_sam_base(c)
c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b")
c.line(7, 7, 9, 5, "d")
def _buk(c): # наклонные направляющие с ракетой
_sam_base(c)
c.line(3, 8, 10, 2, "d")
c.line(5, 9, 12, 3, "d")
c.disc(12, 2, 1, "w")
def _irist(c): # два наклонных контейнера
_sam_base(c)
c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b")
c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b")
def _nasams(c): # два горизонтальных контейнера
_sam_base(c)
c.rect(3, 2, 12, 3, "b")
c.rect(3, 5, 12, 6, "b")
c.line(3, 4, 12, 4, "d")
def _tor(c): # сетка коротких вертикальных ячеек
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 3, "b")
c.rect(x, 5, x + 1, 6, "b")
def _p1sun(c): # короб с мачтой
_sam_base(c)
c.rect(3, 5, 12, 7, "b")
c.line(7, 1, 7, 4, "d")
c.px(7, 1, "r")
# Радиолокация и РЭБ.
def _radar(c): # параболическая антенна на стойке
c.disc(8, 5, 3, "l")
c.line(8, 8, 8, 12, "d")
c.rect(5, 13, 11, 14, "d")
def _ew(c): # штырь-антенна с сигнальными дугами
c.line(8, 6, 8, 14, "d")
c.disc(8, 4, 1, "b")
c.line(5, 2, 4, 4, "l")
c.line(4, 4, 4, 6, "l")
c.line(4, 6, 5, 8, "l")
c.line(11, 2, 12, 4, "l")
c.line(12, 4, 12, 6, "l")
c.line(12, 6, 11, 8, "l")
# Снабжение.
def _depot(c): # штабель ящиков на поддоне
c.rect(3, 8, 12, 13, "b")
c.line(3, 10, 12, 10, "d")
c.line(3, 12, 12, 12, "d")
c.rect(2, 14, 13, 14, "d")
c.rect(4, 4, 9, 7, "l")
c.line(4, 6, 9, 6, "d")
def _tzm(c): # грузовик с кабиной и кузовом
c.rect(2, 7, 5, 11, "b")
c.rect(3, 8, 4, 9, "w")
c.rect(6, 4, 14, 11, "l")
c.rect(3, 12, 4, 13, "k")
c.rect(10, 12, 11, 13, "k")
c.rect(13, 12, 14, 13, "k")
# Прожектор.
def _searchlight(c):
c.rect(3, 4, 10, 8, "b")
c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y")
c.line(6, 9, 6, 13, "d")
c.rect(3, 14, 9, 14, "d")
# Целеуказатель: кольцо с засечками.
def _designator(c):
c.ring(8, 8, 16, 26, "r")
c.rect(7, 1, 8, 2, "r")
c.rect(7, 13, 8, 14, "r")
c.rect(1, 7, 2, 8, "r")
c.rect(13, 7, 14, 8, "r")
c.rect(7, 7, 8, 8, "r")
# Пуски: огонь сзади, силуэт типа спереди.
def _launch_base(c):
c.rect(0, 7, 1, 8, "o")
c.px(0, 7, "y")
def _shahed(c): # треугольное крыло
_launch_base(c)
c.poly([(5, 2), (5, 13), (13, 7)], "b")
def _geran3(c): # реактивный самолёт со стреловидным крылом
_launch_base(c)
c.rect(3, 7, 13, 8, "b")
c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d")
c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d")
c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d")
def _lancet(c): # малый дрон с прямым крылом
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.rect(7, 2, 8, 13, "d")
c.rect(3, 6, 4, 9, "d")
def _kh101(c): # крылатая ракета: стреловидные крылья и хвост
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b")
c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d")
c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d")
c.rect(2, 5, 3, 10, "d")
def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля
_launch_base(c)
c.rect(2, 7, 13, 8, "b")
c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b")
c.rect(2, 4, 3, 5, "d")
c.rect(2, 10, 3, 11, "d")
def _kh22(c): # толстый корпус с большими дельта-крыльями
_launch_base(c)
c.rect(3, 6, 11, 9, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b")
c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d")
c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d")
def _zircon(c): # гиперзвук: корпус с одним передним стрейком
_launch_base(c)
c.rect(2, 7, 12, 8, "b")
c.poly([(12, 7), (15, 7), (12, 8)], "b")
c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d")
def _iskander(c): # баллистическая: длинный корпус, конус, сопло
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l")
c.rect(2, 6, 2, 9, "d")
def _oreshnik(c): # баллистическая с тремя головными частями
_launch_base(c)
c.rect(2, 6, 10, 9, "b")
c.poly([(10, 5), (13, 6), (10, 7)], "l")
c.poly([(10, 8), (13, 9), (10, 10)], "l")
c.poly([(10, 6), (15, 7), (10, 8)], "b")
def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (11, 9)], "b")
c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d")
c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d")
def _mover(c): # четыре стрелки перемещения
c.poly([(8, 0), (4, 5), (12, 5)], "b")
c.rect(7, 4, 8, 7, "b")
c.poly([(8, 15), (4, 10), (12, 10)], "b")
c.rect(7, 8, 8, 11, "b")
c.poly([(0, 8), (5, 4), (5, 12)], "b")
c.rect(4, 7, 7, 8, "b")
c.poly([(15, 8), (10, 4), (10, 12)], "b")
c.rect(8, 7, 11, 8, "b")
def _remover(c): # стрелка вниз над лотком: свёртывание
c.poly([(8, 9), (3, 4), (13, 4)], "b")
c.rect(7, 1, 8, 4, "b")
c.rect(2, 11, 13, 13, "b")
c.line(2, 11, 13, 11, "l")
def _flare(c): # звезда осветительной ракеты на стержне
c.disc(8, 7, 1, "y")
c.line(8, 2, 8, 4, "y")
c.line(8, 10, 8, 12, "y")
c.line(3, 7, 5, 7, "y")
c.line(11, 7, 13, 7, "y")
c.line(4, 4, 5, 5, "w")
c.line(11, 4, 10, 5, "w")
c.line(4, 10, 5, 9, "w")
c.line(11, 10, 10, 9, "w")
c.rect(7, 12, 8, 15, "k")
ICON_DRAW = {
"designator": _designator,
"launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet,
"launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22,
"launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik,
"launch_kinzhal": _kinzhal,
"deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir,
"deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist,
"deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun,
"deploy_radar": _radar, "deploy_ew": _ew,
"deploy_depot": _depot, "deploy_tzm": _tzm,
"deploy_light": _searchlight,
"mover": _mover, "remover": _remover, "flare_gun": _flare,
}
def render_icon(name, color, seed):
c = _Icon(color, seed)
ICON_DRAW[name](c)
# Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах.
# Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели.
lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]
edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None
if edge:
src = c.im.copy()
def opaque(x, y):
return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0
for y in range(16):
for x in range(16):
if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))):
c.im.putpixel((x, y), edge + (255,))
return c.im
+146
View File
@@ -0,0 +1,146 @@
"""Manifest and transactional install helpers for generated AirDef assets."""
from __future__ import annotations
import hashlib
import os
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
def sha256(path: str | os.PathLike[str]) -> str:
digest = hashlib.sha256()
with open(path, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_manifest(path: str | os.PathLike[str]) -> dict[str, str]:
"""Read sha256sum-format entries and reject malformed or unsafe paths."""
manifest = Path(path)
if not manifest.exists():
return {}
entries: dict[str, str] = {}
for line_no, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1):
try:
digest, rel = line.split(" ", 1)
except ValueError as exc:
raise ValueError(f"{manifest}:{line_no}: invalid manifest row") from exc
parts = Path(rel).parts
if (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest)
or not rel or Path(rel).is_absolute() or ".." in parts or rel in entries):
raise ValueError(f"{manifest}:{line_no}: invalid manifest entry {rel!r}")
entries[rel] = digest
return entries
@dataclass
class Plan:
create: list[str]
update: list[str]
same: list[str]
conflict: list[str]
remove: list[str]
keep: list[str]
def make_plan(root: str | os.PathLike[str], old: dict[str, str], new: dict[str, str]) -> Plan:
root = Path(root)
create: list[str] = []
update: list[str] = []
same: list[str] = []
conflict: list[str] = []
remove: list[str] = []
keep: list[str] = []
for rel, digest in sorted(new.items()):
target = root / rel
if not target.exists():
create.append(rel)
elif not target.is_file():
conflict.append(rel)
else:
current = sha256(target)
if current == digest:
same.append(rel)
elif old.get(rel) == current:
update.append(rel)
else:
conflict.append(rel)
for rel, digest in sorted(old.items()):
target = root / rel
if rel in new or not target.is_file():
continue
(remove if sha256(target) == digest else keep).append(rel)
return Plan(create, update, same, conflict, remove, keep)
def manifest_text(entries: dict[str, str]) -> str:
return "".join(f"{entries[rel]} {rel}\n" for rel in sorted(entries))
def overlay_sources(stage: str | os.PathLike[str], source_root: str | os.PathLike[str]) -> None:
"""Merge authored runtime-relative files into staging without changing source files."""
stage, source_root = Path(stage), Path(source_root)
asset_root = source_root / "assets"
if not asset_root.is_dir():
return
for source in sorted(p for p in asset_root.rglob("*") if p.is_file()):
rel = source.relative_to(source_root)
target = stage / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
def install(
root: str | os.PathLike[str], stage: str | os.PathLike[str],
manifest: str | os.PathLike[str], plan: Plan, entries: dict[str, str],
*, replace: Callable[[str, str], None] = os.replace,
) -> None:
"""Install a validated staged tree; roll back all touched paths on exceptions."""
root, stage, manifest = Path(root), Path(stage), Path(manifest)
touched = plan.create + plan.update + plan.remove
before = {rel: (root / rel).read_bytes() if (root / rel).is_file() else None for rel in touched}
had_manifest = manifest.exists()
old_manifest = manifest.read_bytes() if had_manifest else None
try:
for rel in plan.create + plan.update:
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
os.close(fd)
try:
shutil.copyfile(stage / rel, temp_name)
replace(temp_name, str(target))
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
for rel in plan.remove:
(root / rel).unlink()
manifest.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{manifest.name}.", dir=manifest.parent)
os.close(fd)
try:
with open(temp_name, "w", encoding="utf-8", newline="\n") as stream:
stream.write(manifest_text(entries))
replace(temp_name, str(manifest))
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
except Exception:
for rel, data in before.items():
target = root / rel
if data is None:
if target.exists():
target.unlink()
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
if had_manifest:
manifest.write_bytes(old_manifest or b"")
elif manifest.exists():
manifest.unlink()
raise
+272
View File
@@ -0,0 +1,272 @@
"""AirDef's deterministic model texture catalog and generated variants."""
import random
import zlib
from PIL import Image
import gen_tex as GT
# ---------------------------------------------------------------- текстуры
def clamp(v):
return max(0, min(255, int(v)))
def noise(base, amp, seed, size=16):
rnd = random.Random(seed)
im = Image.new("RGBA", (size, size))
for y in range(size):
for x in range(size):
d = rnd.randint(-amp, amp)
im.putpixel((x, y), tuple(clamp(c + d) for c in base) + (255,))
return im
def panels(base, seed, size=16, every=8):
im = noise(base, 4, seed, size)
line = tuple(clamp(c - 16) for c in base) + (255,)
lite = tuple(clamp(c + 10) for c in base) + (255,)
for i in range(size):
for k in range(0, size, every):
im.putpixel((i, k), line)
im.putpixel((k, i), line)
if k + 1 < size:
im.putpixel((i, k + 1), lite)
rnd = random.Random(seed + 99)
for _ in range(size // 2): # заклёпки
x, y = rnd.randrange(size), rnd.randrange(size)
im.putpixel((x, y), lite)
return im
def camo(colors, seed, size=32, blobs=7):
rnd = random.Random(seed)
im = noise(colors[0], 4, seed, size)
for col in colors[1:]:
for _ in range(blobs):
cx, cy = rnd.randrange(size), rnd.randrange(size)
rx, ry = rnd.randint(2, 6), rnd.randint(1, 4)
for y in range(cy - ry, cy + ry + 1):
for x in range(cx - rx, cx + rx + 1):
if ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1 + rnd.random() * 0.3:
im.putpixel((x % size, y % size), tuple(clamp(c + rnd.randint(-5, 5)) for c in col) + (255,))
return im
def stripes(base, line, step, seed, size=16):
im = noise(base, 3, seed, size)
for y in range(0, size, step):
for x in range(size):
im.putpixel((x, y), line + (255,))
return im
def gradient(top, bottom, size=16):
im = Image.new("RGBA", (size, size))
for y in range(size):
t = y / (size - 1)
c = tuple(clamp(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
for x in range(size):
im.putpixel((x, y), c + (255,))
return im
def canister_front():
im = noise((92, 100, 72), 3, 11)
for cx in (4, 11):
for cy in (4, 11):
for y in range(16):
for x in range(16):
d = (x - cx + 0.5) ** 2 + (y - cy + 0.5) ** 2
if d <= 9:
im.putpixel((x, y), (36, 38, 30, 255))
elif d <= 12:
im.putpixel((x, y), (120, 124, 100, 255))
return im
def radar_face():
im = noise((70, 76, 56), 3, 17)
for y in range(1, 15):
for x in range(1, 15):
if (x + y) % 3 == 0:
im.putpixel((x, y), (54, 58, 44, 255))
return im
def glass():
im = noise((40, 58, 78), 5, 31)
for i in range(16):
im.putpixel((i, 15 - i), (130, 160, 180, 255))
if i < 15:
im.putpixel((i + 1, 15 - i), (100, 130, 150, 255))
return im
def missile():
im = noise((232, 232, 226), 3, 21)
for x in range(16):
for y in (3, 4):
im.putpixel((x, y), (40, 40, 40, 255))
return im
def target():
im = Image.new("RGBA", (16, 16))
for y in range(16):
for x in range(16):
d = abs(x - 7.5) + abs(y - 7.5)
im.putpixel((x, y), (255, clamp(40 + d * 12), 40, 255))
return im
def fire(seed):
rnd = random.Random(seed)
im = Image.new("RGBA", (16, 16))
for y in range(16):
for x in range(16):
t = rnd.random()
im.putpixel((x, y), (255, clamp(140 + 110 * t), clamp(30 + 120 * t * t), 255))
return im
def missile_ru():
im = noise((226, 228, 222), 3, 49)
for x in range(16):
im.putpixel((x, 5), (180, 40, 40, 255))
im.putpixel((x, 11), (40, 40, 40, 255))
return im
def tube_cap():
im = noise((80, 88, 60), 3, 50)
for y in range(16):
for x in range(16):
d = (x - 7.5) ** 2 + (y - 7.5) ** 2
if d <= 36:
im.putpixel((x, y), (44, 48, 36, 255))
elif d <= 48:
im.putpixel((x, y), (120, 128, 96, 255))
return im
def tubes6():
im = noise((82, 96, 60), 3, 51)
for cx in (3, 8, 13):
for cy in (4, 11):
for y in range(16):
for x in range(16):
if (x - cx) ** 2 + (y - cy) ** 2 <= 4:
im.putpixel((x, y), (30, 32, 26, 255))
return im
def radar_big():
im = noise((92, 100, 70), 3, 52)
for y in range(16):
for x in range(16):
if x % 2 == 0 and y % 2 == 0:
im.putpixel((x, y), (60, 66, 46, 255))
return im
TEXTURES = {
"shahed": lambda: panels((66, 68, 70), 1),
"shahed_nose": lambda: noise((104, 106, 108), 4, 2),
"geran": lambda: panels((28, 28, 31), 3),
"geran_engine": lambda: panels((60, 60, 64), 4),
"intake": lambda: noise((14, 14, 14), 2, 5),
"prop": lambda: noise((22, 22, 22), 3, 6),
"missile": missile,
"nozzle": lambda: noise((44, 42, 40), 4, 7),
"flame": lambda: gradient((255, 250, 210), (255, 120, 20)),
"fire": lambda: fire(8),
"nato_green": lambda: camo([(76, 86, 58), (58, 68, 44), (44, 48, 34)], 9),
"flecktarn": lambda: camo([(92, 100, 62), (62, 74, 44), (104, 84, 54), (30, 32, 26)], 10),
"track": lambda: stripes((36, 36, 34), (74, 74, 70), 3, 11),
"tire": lambda: stripes((24, 24, 24), (40, 40, 40), 4, 12),
"canister": lambda: panels((94, 102, 74), 13),
"canister_front": canister_front,
"radar_face": radar_face,
"glass": glass,
"metal": lambda: panels((118, 120, 122), 14),
"dark_metal": lambda: panels((52, 54, 52), 15),
"barrel": lambda: noise((40, 42, 38), 3, 16),
"dish": lambda: noise((176, 178, 174), 5, 18),
"target": target,
"foam": lambda: panels((196, 180, 150), 40),
"kh101": lambda: panels((84, 88, 92), 41),
"kh101_belly": lambda: noise((150, 152, 150), 4, 42),
"lancet": lambda: panels((70, 78, 66), 43),
"ru_green": lambda: camo([(82, 96, 60), (54, 70, 40), (110, 96, 64), (34, 36, 28)], 44, blobs=6),
"ru_khaki": lambda: camo([(108, 110, 80), (84, 90, 60), (60, 64, 44)], 45, blobs=5),
"missile_ru": missile_ru,
"missile_grey": lambda: panels((150, 154, 156), 46),
"booster": lambda: noise((60, 62, 58), 3, 47),
"tube": lambda: stripes((96, 106, 74), (70, 78, 54), 4, 48),
"tube_cap": tube_cap,
"tubes6": tubes6,
"radar_big": radar_big,
"tracer": lambda: noise((255, 40, 20), 6, 60),
"tracer_hot": lambda: noise((255, 190, 150), 6, 61),
"white": lambda: noise((250, 250, 246), 2, 19),
"nato_tan": lambda: camo([(150, 130, 96), (124, 106, 76), (96, 84, 62)], 62, blobs=5),
"crate": lambda: stripes((92, 78, 48), (70, 58, 34), 5, 63),
"concrete": lambda: panels((132, 130, 124), 64),
"lamp": lambda: gradient((255, 255, 240), (255, 230, 170)),
"beam": lambda: gradient((255, 250, 225), (255, 240, 200)),
"antenna": lambda: noise((70, 72, 70), 3, 65),
"nose_dark": lambda: noise((58, 60, 62), 3, 66),
}
RINGED = set()
def _ring(name, base, bands, seed, text=None):
RINGED.add(name)
TEXTURES[name] = lambda: GT.band_tex(base, bands, seed, text=text)
TEXTURES[name + "_t"] = lambda: GT.band_tex(base, bands, seed, transpose=True, text=text)
STRETCH_FN = {}
for _i, _k in enumerate(("nato_green", "flecktarn", "ru_green", "ru_khaki", "nato_tan")):
STRETCH_FN[_k] = (lambda sx, sy, k=_k, i=_i: GT.camo(k, 100 + i, 64, sx, sy))
TEXTURES[_k] = (lambda k=_k, i=_i: GT.camo(k, 100 + i, 64))
STRETCH_FN["track_side"] = lambda sx, sy: GT.track_side(7, 32, sx, sy)
PAL_RU, PAL_RU2 = GT.PAL["ru_green"][0], GT.PAL["ru_khaki"][0]
TEXTURES.update({
"track_side": lambda: GT.track_side(7),
"track": lambda: GT.track_tex(8),
"tire": lambda: GT.tire_tex(9),
"hub": lambda: GT.hub_tex(10),
"glass_cab": lambda: GT.glass_tex(11),
"bare_lamp": lambda: GT.glass_tex(12, tint=(200, 205, 190), refl=False),
"bare_barrel": lambda: GT.metal_tex((44, 46, 44), 13),
"bare_nozzle": lambda: GT.metal_tex((66, 58, 52), 14),
"bare_dish": lambda: GT.metal_tex((178, 180, 176), 15),
"bare_antenna": lambda: GT.metal_tex((112, 114, 112), 16),
"canister_front": lambda: GT.cap_tex(28),
"mk_ru": lambda: GT.marking("digits", PAL_RU, 40, "312"),
"mk_ru2": lambda: GT.marking("digits", PAL_RU2, 41, "507"),
"mk_us": lambda: GT.marking("digits", (76, 86, 58), 42, "1-7"),
"mk_star": lambda: GT.marking("star", PAL_RU, 43),
"mk_de": lambda: GT.marking("cross", (88, 98, 62), 44),
"glow_ember": lambda: GT.Canvas(16, 1, 1, (255, 170, 80), 17).out(),
"metal": lambda: GT.painted((118, 120, 122), 18, lift=1.15),
"dark_metal": lambda: GT.painted((52, 54, 52), 19, lift=1.1),
"canister": lambda: GT.painted((92, 102, 74), 20),
"shahed": lambda: GT.painted((92, 96, 98), 21, panel=(20, 10), lift=1.2),
"geran": lambda: GT.painted((28, 28, 31), 22, panel=(20, 10), lift=1.0),
"kh101": lambda: GT.painted((84, 88, 92), 23, panel=(18, 10)),
"lancet": lambda: GT.painted((74, 82, 70), 24),
"missile_grey": lambda: GT.painted((150, 154, 156), 25, lift=1.0),
"radar_face": lambda: GT.lattice(26, 32, (96, 104, 76), (170, 176, 150)),
"radar_big": lambda: GT.lattice(27, 32, (112, 124, 92), (190, 196, 168)),
})
_ring("rm_pac3", (228, 228, 222), [(0.12, 2, (30, 30, 30)), (0.78, 1.5, (200, 170, 40)), (0.9, 1, (30, 30, 30))], 30, [(8, 14, 10)])
_ring("rm_white", (232, 232, 226), [(0.1, 2, (200, 40, 36)), (0.82, 1.5, (40, 40, 44))], 31, [(10, 12, 8)])
_ring("rm_ru", (226, 228, 222), [(0.15, 2, (170, 40, 36)), (0.55, 1, (40, 40, 40)), (0.85, 2, (170, 40, 36))], 32, [(9, 20, 10)])
_ring("rm_grey", (148, 152, 154), [(0.2, 1.5, (210, 210, 205)), (0.8, 1.5, (210, 210, 205))], 33, [(6, 14, 12)])
_ring("rm_khaki", (110, 112, 84), [(0.25, 2, (60, 62, 48)), (0.7, 1.5, (200, 190, 150))], 34, [(8, 18, 8)])
_ring("rm_dark", (58, 60, 62), [(0.3, 1.5, (150, 150, 146)), (0.85, 1.5, (150, 150, 146))], 35)
+75 -766
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Генерирует контент мода AirDef (airdef-mod/assets и data): текстуры, модели частей, предметы.
"""Генерирует модели и registries AirDef, делегируя raster output `gen_tex.py`.
Каждая часть — блок со своей моделью в одной ориентации: нос смотрит на север (-z), центр — (8, 8, 8).
Поворачивает и масштабирует их движок форка (render.set-models), так что ракурсы больше не нужны.
@@ -7,296 +7,29 @@
Запуск: python3 tools/gen_models.py [--check]
Скрипт пишет во временный каталог, проверяет набор (validate: ссылки моделей и текстур, JSON, PNG) и только потом
переносит результат в airdef-mod/; при ошибке проверки ничего не записывает. Список созданных им файлов
Скрипт строит набор во временном каталоге, накладывает `airdef-mod/source-assets/assets/`, проверяет ссылки
моделей и текстур, JSON и PNG, затем транзакционно переносит результат в airdef-mod/. Список управляемых файлов
с sha256 лежит в airdef-mod/generated-manifest.txt (формат sha256sum). Удаляются и перезаписываются только файлы
из этого списка и только если их не правили вручную; чужой файл на пути генерируемого останавливает запуск.
--check ничего не пишет и выходит с кодом 1, если airdef-mod/ отличается от того, что дал бы запуск.
"""
import hashlib
import json
import math
import os
import sys
import random
import shutil
import tempfile
import zlib
from PIL import Image
import gen_tex as GT
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PIL import Image
import gen_tex as GT
from airdef_assets import pipeline
from airdef_assets.texture_catalog import RINGED, STRETCH_FN, TEXTURES
from airdef_assets.icons import ITEMS, render_icon
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-mod"))
NS = "airdef"
# ---------------------------------------------------------------- текстуры
def clamp(v):
return max(0, min(255, int(v)))
def noise(base, amp, seed, size=16):
rnd = random.Random(seed)
im = Image.new("RGBA", (size, size))
for y in range(size):
for x in range(size):
d = rnd.randint(-amp, amp)
im.putpixel((x, y), tuple(clamp(c + d) for c in base) + (255,))
return im
def panels(base, seed, size=16, every=8):
im = noise(base, 4, seed, size)
line = tuple(clamp(c - 16) for c in base) + (255,)
lite = tuple(clamp(c + 10) for c in base) + (255,)
for i in range(size):
for k in range(0, size, every):
im.putpixel((i, k), line)
im.putpixel((k, i), line)
if k + 1 < size:
im.putpixel((i, k + 1), lite)
rnd = random.Random(seed + 99)
for _ in range(size // 2): # заклёпки
x, y = rnd.randrange(size), rnd.randrange(size)
im.putpixel((x, y), lite)
return im
def camo(colors, seed, size=32, blobs=7):
rnd = random.Random(seed)
im = noise(colors[0], 4, seed, size)
for col in colors[1:]:
for _ in range(blobs):
cx, cy = rnd.randrange(size), rnd.randrange(size)
rx, ry = rnd.randint(2, 6), rnd.randint(1, 4)
for y in range(cy - ry, cy + ry + 1):
for x in range(cx - rx, cx + rx + 1):
if ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1 + rnd.random() * 0.3:
im.putpixel((x % size, y % size), tuple(clamp(c + rnd.randint(-5, 5)) for c in col) + (255,))
return im
def stripes(base, line, step, seed, size=16):
im = noise(base, 3, seed, size)
for y in range(0, size, step):
for x in range(size):
im.putpixel((x, y), line + (255,))
return im
def gradient(top, bottom, size=16):
im = Image.new("RGBA", (size, size))
for y in range(size):
t = y / (size - 1)
c = tuple(clamp(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
for x in range(size):
im.putpixel((x, y), c + (255,))
return im
def canister_front():
im = noise((92, 100, 72), 3, 11)
for cx in (4, 11):
for cy in (4, 11):
for y in range(16):
for x in range(16):
d = (x - cx + 0.5) ** 2 + (y - cy + 0.5) ** 2
if d <= 9:
im.putpixel((x, y), (36, 38, 30, 255))
elif d <= 12:
im.putpixel((x, y), (120, 124, 100, 255))
return im
def radar_face():
im = noise((70, 76, 56), 3, 17)
for y in range(1, 15):
for x in range(1, 15):
if (x + y) % 3 == 0:
im.putpixel((x, y), (54, 58, 44, 255))
return im
def glass():
im = noise((40, 58, 78), 5, 31)
for i in range(16):
im.putpixel((i, 15 - i), (130, 160, 180, 255))
if i < 15:
im.putpixel((i + 1, 15 - i), (100, 130, 150, 255))
return im
def missile():
im = noise((232, 232, 226), 3, 21)
for x in range(16):
for y in (3, 4):
im.putpixel((x, y), (40, 40, 40, 255))
return im
def target():
im = Image.new("RGBA", (16, 16))
for y in range(16):
for x in range(16):
d = abs(x - 7.5) + abs(y - 7.5)
im.putpixel((x, y), (255, clamp(40 + d * 12), 40, 255))
return im
def fire(seed):
rnd = random.Random(seed)
im = Image.new("RGBA", (16, 16))
for y in range(16):
for x in range(16):
t = rnd.random()
im.putpixel((x, y), (255, clamp(140 + 110 * t), clamp(30 + 120 * t * t), 255))
return im
def missile_ru():
im = noise((226, 228, 222), 3, 49)
for x in range(16):
im.putpixel((x, 5), (180, 40, 40, 255))
im.putpixel((x, 11), (40, 40, 40, 255))
return im
def tube_cap():
im = noise((80, 88, 60), 3, 50)
for y in range(16):
for x in range(16):
d = (x - 7.5) ** 2 + (y - 7.5) ** 2
if d <= 36:
im.putpixel((x, y), (44, 48, 36, 255))
elif d <= 48:
im.putpixel((x, y), (120, 128, 96, 255))
return im
def tubes6():
im = noise((82, 96, 60), 3, 51)
for cx in (3, 8, 13):
for cy in (4, 11):
for y in range(16):
for x in range(16):
if (x - cx) ** 2 + (y - cy) ** 2 <= 4:
im.putpixel((x, y), (30, 32, 26, 255))
return im
def radar_big():
im = noise((92, 100, 70), 3, 52)
for y in range(16):
for x in range(16):
if x % 2 == 0 and y % 2 == 0:
im.putpixel((x, y), (60, 66, 46, 255))
return im
TEXTURES = {
"shahed": lambda: panels((66, 68, 70), 1),
"shahed_nose": lambda: noise((104, 106, 108), 4, 2),
"geran": lambda: panels((28, 28, 31), 3),
"geran_engine": lambda: panels((60, 60, 64), 4),
"intake": lambda: noise((14, 14, 14), 2, 5),
"prop": lambda: noise((22, 22, 22), 3, 6),
"missile": missile,
"nozzle": lambda: noise((44, 42, 40), 4, 7),
"flame": lambda: gradient((255, 250, 210), (255, 120, 20)),
"fire": lambda: fire(8),
"nato_green": lambda: camo([(76, 86, 58), (58, 68, 44), (44, 48, 34)], 9),
"flecktarn": lambda: camo([(92, 100, 62), (62, 74, 44), (104, 84, 54), (30, 32, 26)], 10),
"track": lambda: stripes((36, 36, 34), (74, 74, 70), 3, 11),
"tire": lambda: stripes((24, 24, 24), (40, 40, 40), 4, 12),
"canister": lambda: panels((94, 102, 74), 13),
"canister_front": canister_front,
"radar_face": radar_face,
"glass": glass,
"metal": lambda: panels((118, 120, 122), 14),
"dark_metal": lambda: panels((52, 54, 52), 15),
"barrel": lambda: noise((40, 42, 38), 3, 16),
"dish": lambda: noise((176, 178, 174), 5, 18),
"target": target,
"foam": lambda: panels((196, 180, 150), 40),
"kh101": lambda: panels((84, 88, 92), 41),
"kh101_belly": lambda: noise((150, 152, 150), 4, 42),
"lancet": lambda: panels((70, 78, 66), 43),
"ru_green": lambda: camo([(82, 96, 60), (54, 70, 40), (110, 96, 64), (34, 36, 28)], 44, blobs=6),
"ru_khaki": lambda: camo([(108, 110, 80), (84, 90, 60), (60, 64, 44)], 45, blobs=5),
"missile_ru": missile_ru,
"missile_grey": lambda: panels((150, 154, 156), 46),
"booster": lambda: noise((60, 62, 58), 3, 47),
"tube": lambda: stripes((96, 106, 74), (70, 78, 54), 4, 48),
"tube_cap": tube_cap,
"tubes6": tubes6,
"radar_big": radar_big,
"tracer": lambda: noise((255, 40, 20), 6, 60),
"tracer_hot": lambda: noise((255, 190, 150), 6, 61),
"white": lambda: noise((250, 250, 246), 2, 19),
"nato_tan": lambda: camo([(150, 130, 96), (124, 106, 76), (96, 84, 62)], 62, blobs=5),
"crate": lambda: stripes((92, 78, 48), (70, 58, 34), 5, 63),
"concrete": lambda: panels((132, 130, 124), 64),
"lamp": lambda: gradient((255, 255, 240), (255, 230, 170)),
"beam": lambda: gradient((255, 250, 225), (255, 240, 200)),
"antenna": lambda: noise((70, 72, 70), 3, 65),
"nose_dark": lambda: noise((58, 60, 62), 3, 66),
}
RINGED = set()
def _ring(name, base, bands, seed, text=None):
RINGED.add(name)
TEXTURES[name] = lambda: GT.band_tex(base, bands, seed, text=text)
TEXTURES[name + "_t"] = lambda: GT.band_tex(base, bands, seed, transpose=True, text=text)
STRETCH_FN = {}
for _i, _k in enumerate(("nato_green", "flecktarn", "ru_green", "ru_khaki", "nato_tan")):
STRETCH_FN[_k] = (lambda sx, sy, k=_k, i=_i: GT.camo(k, 100 + i, 64, sx, sy))
TEXTURES[_k] = (lambda k=_k, i=_i: GT.camo(k, 100 + i, 64))
STRETCH_FN["track_side"] = lambda sx, sy: GT.track_side(7, 32, sx, sy)
PAL_RU, PAL_RU2 = GT.PAL["ru_green"][0], GT.PAL["ru_khaki"][0]
TEXTURES.update({
"track_side": lambda: GT.track_side(7),
"track": lambda: GT.track_tex(8),
"tire": lambda: GT.tire_tex(9),
"hub": lambda: GT.hub_tex(10),
"glass_cab": lambda: GT.glass_tex(11),
"bare_lamp": lambda: GT.glass_tex(12, tint=(200, 205, 190), refl=False),
"bare_barrel": lambda: GT.metal_tex((44, 46, 44), 13),
"bare_nozzle": lambda: GT.metal_tex((66, 58, 52), 14),
"bare_dish": lambda: GT.metal_tex((178, 180, 176), 15),
"bare_antenna": lambda: GT.metal_tex((112, 114, 112), 16),
"canister_front": lambda: GT.cap_tex(28),
"mk_ru": lambda: GT.marking("digits", PAL_RU, 40, "312"),
"mk_ru2": lambda: GT.marking("digits", PAL_RU2, 41, "507"),
"mk_us": lambda: GT.marking("digits", (76, 86, 58), 42, "1-7"),
"mk_star": lambda: GT.marking("star", PAL_RU, 43),
"mk_de": lambda: GT.marking("cross", (88, 98, 62), 44),
"glow_ember": lambda: GT.Canvas(16, 1, 1, (255, 170, 80), 17).out(),
"metal": lambda: GT.painted((118, 120, 122), 18, lift=1.15),
"dark_metal": lambda: GT.painted((52, 54, 52), 19, lift=1.1),
"canister": lambda: GT.painted((92, 102, 74), 20),
"shahed": lambda: GT.painted((92, 96, 98), 21, panel=(20, 10), lift=1.2),
"geran": lambda: GT.painted((28, 28, 31), 22, panel=(20, 10), lift=1.0),
"kh101": lambda: GT.painted((84, 88, 92), 23, panel=(18, 10)),
"lancet": lambda: GT.painted((74, 82, 70), 24),
"missile_grey": lambda: GT.painted((150, 154, 156), 25, lift=1.0),
"radar_face": lambda: GT.lattice(26, 32, (96, 104, 76), (170, 176, 150)),
"radar_big": lambda: GT.lattice(27, 32, (112, 124, 92), (190, 196, 168)),
})
_ring("rm_pac3", (228, 228, 222), [(0.12, 2, (30, 30, 30)), (0.78, 1.5, (200, 170, 40)), (0.9, 1, (30, 30, 30))], 30, [(8, 14, 10)])
_ring("rm_white", (232, 232, 226), [(0.1, 2, (200, 40, 36)), (0.82, 1.5, (40, 40, 44))], 31, [(10, 12, 8)])
_ring("rm_ru", (226, 228, 222), [(0.15, 2, (170, 40, 36)), (0.55, 1, (40, 40, 40)), (0.85, 2, (170, 40, 36))], 32, [(9, 20, 10)])
_ring("rm_grey", (148, 152, 154), [(0.2, 1.5, (210, 210, 205)), (0.8, 1.5, (210, 210, 205))], 33, [(6, 14, 12)])
_ring("rm_khaki", (110, 112, 84), [(0.25, 2, (60, 62, 48)), (0.7, 1.5, (200, 190, 150))], 34, [(8, 18, 8)])
_ring("rm_dark", (58, 60, 62), [(0.3, 1.5, (150, 150, 146)), (0.85, 1.5, (150, 150, 146))], 35)
# ---------------------------------------------------------------- модели
def el(frm, to, tex, faces=None, rot=None):
"""Элемент; rot = (ось, угол) — поворот вокруг центра элемента."""
@@ -1309,391 +1042,6 @@ WRECKS = ["patriot_trailer", "patriot_launcher", "gepard_hull", "gepard_turret",
CAM_MODELS = ["shahed", "geran3", "lancet", "gerbera", "kh101", "kalibr", "kh22", "zircon", "iskander", "kinzhal", "reentry"]
# Предметы: (id, название, цвет иконки)
ITEMS = [
("designator", "Целеуказатель", (220, 50, 50)),
("launch_shahed", "Пуск: Шахед-136", (90, 92, 96)),
("launch_geran3", "Пуск: Герань-3", (30, 30, 34)),
("deploy_patriot", "Развернуть Patriot", (76, 86, 58)),
("deploy_gepard", "Развернуть Гепард", (104, 84, 54)),
("deploy_pantsir", "Развернуть Панцирь-С1", (82, 96, 60)),
("deploy_s400", "Развернуть С-400", (60, 80, 50)),
("deploy_s500", "Развернуть С-500", (108, 110, 80)),
("launch_lancet", "Пуск: Ланцет-3", (70, 78, 66)),
("launch_kh101", "Пуск: Х-101", (84, 88, 92)),
("launch_kalibr", "Пуск: Калибр", (150, 154, 156)),
("launch_kh22", "Пуск: Х-22", (226, 228, 222)),
("launch_zircon", "Пуск: Циркон", (52, 54, 52)),
("launch_iskander", "Пуск: Искандер-М", (108, 110, 80)),
("launch_kinzhal", "Пуск: Кинжал", (60, 62, 60)),
("launch_oreshnik", "Пуск: Орешник", (230, 120, 40)),
("deploy_irist", "Развернуть IRIS-T SLM", (76, 86, 58)),
("deploy_nasams", "Развернуть NASAMS", (76, 86, 58)),
("deploy_buk", "Развернуть Бук-М2", (82, 96, 60)),
("deploy_tor", "Развернуть Тор-М2", (108, 110, 80)),
("deploy_p1sun", "Развернуть P1-SUN", (52, 54, 52)),
("deploy_radar", "Развернуть РЛС дальнего обнаружения", (108, 110, 80)),
("deploy_depot", "Развернуть склад боеприпасов", (92, 78, 48)),
("deploy_tzm", "Развернуть ТЗМ", (82, 96, 60)),
("deploy_ew", "Развернуть станцию РЭБ", (108, 110, 80)),
("deploy_light", "Развернуть прожектор", (52, 54, 52)),
("mover", "Пульт перемещения", (60, 140, 220)),
("flare_gun", "Осветительная ракета", (255, 240, 180)),
("remover", "Свернуть ПВО", (200, 200, 200)),
]
# Иконки 16×16 без сглаживания. Каждый предмет рисуется своей функцией: категория задаёт основу
# (машина ПВО, пуск с пламенем, РЛС, склад, прожектор, целеуказатель, стрелки, сворачивание),
# внутри категории — мини-силуэт типа. Цвет предмета — только заливка, форма различает предметы.
class _Icon:
def __init__(self, color, seed):
self.rnd = random.Random(seed)
self.im = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
self.pal = {
"b": tuple(color),
"d": tuple(clamp(v * 0.6) for v in color),
"l": tuple(clamp(v + 45) for v in color),
"k": (30, 30, 34), "r": (255, 60, 60), "o": (255, 150, 40),
"y": (255, 232, 120), "w": (236, 236, 236),
}
def px(self, x, y, key):
if 0 <= x < 16 and 0 <= y < 16:
c = self.pal[key]
if key in ("b", "d", "l"):
c = tuple(clamp(v + self.rnd.randint(-6, 6)) for v in c)
self.im.putpixel((x, y), c + (255,))
def rect(self, x0, y0, x1, y1, key):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.px(x, y, key)
def line(self, x0, y0, x1, y1, key):
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
self.px(x0, y0, key)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def disc(self, cx, cy, r, key):
for y in range(cy - r - 1, cy + r + 2):
for x in range(cx - r - 1, cx + r + 2):
if (x - cx) ** 2 + (y - cy) ** 2 <= r * r + 0.5 * r:
self.px(x, y, key)
def ring(self, cx, cy, lo, hi, key):
for y in range(16):
for x in range(16):
if lo <= (x - cx) ** 2 + (y - cy) ** 2 <= hi:
self.px(x, y, key)
def poly(self, pts, key):
# Выпуклый многоугольник: заливка по строкам между крайними пересечениями рёбер.
n = len(pts)
for y in range(min(p[1] for p in pts), max(p[1] for p in pts) + 1):
xs = []
for i in range(n):
(x1, y1), (x2, y2) = pts[i], pts[(i + 1) % n]
if y1 == y2:
if y1 == y:
xs += [x1, x2]
elif min(y1, y2) <= y <= max(y1, y2):
xs.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
if xs:
for x in range(math.ceil(min(xs) - 1e-9), math.floor(max(xs) + 1e-9) + 1):
self.px(x, y, key)
def _sam_base(c):
c.rect(2, 8, 13, 11, "b")
c.rect(3, 12, 4, 13, "k")
c.rect(11, 12, 12, 13, "k")
# Развёртывание ПВО: машина + пусковая установка.
def _gepard(c): # ствольная турель, два длинных ствола
_sam_base(c)
c.disc(7, 6, 2, "b")
c.line(9, 5, 15, 5, "d")
c.line(9, 7, 15, 7, "d")
def _pantsir(c): # ствольная турель с ракетными контейнерами сверху
_sam_base(c)
c.disc(7, 7, 2, "b")
c.rect(4, 3, 5, 5, "l")
c.rect(9, 3, 10, 5, "l")
c.line(9, 6, 11, 6, "d")
def _s400(c): # три вертикальных контейнера
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _s500(c): # четыре вертикальных контейнера
_sam_base(c)
for x in (2, 5, 8, 11):
c.rect(x, 2, x + 1, 7, "b")
c.rect(x, 2, x + 1, 2, "l")
def _patriot(c): # наклонный пусковой короб
_sam_base(c)
c.poly([(3, 8), (11, 3), (13, 5), (5, 10)], "b")
c.line(7, 7, 9, 5, "d")
def _buk(c): # наклонные направляющие с ракетой
_sam_base(c)
c.line(3, 8, 10, 2, "d")
c.line(5, 9, 12, 3, "d")
c.disc(12, 2, 1, "w")
def _irist(c): # два наклонных контейнера
_sam_base(c)
c.poly([(3, 8), (7, 3), (9, 4), (5, 9)], "b")
c.poly([(7, 9), (11, 3), (13, 4), (9, 10)], "b")
def _nasams(c): # два горизонтальных контейнера
_sam_base(c)
c.rect(3, 2, 12, 3, "b")
c.rect(3, 5, 12, 6, "b")
c.line(3, 4, 12, 4, "d")
def _tor(c): # сетка коротких вертикальных ячеек
_sam_base(c)
for x in (3, 6, 9):
c.rect(x, 2, x + 1, 3, "b")
c.rect(x, 5, x + 1, 6, "b")
def _p1sun(c): # короб с мачтой
_sam_base(c)
c.rect(3, 5, 12, 7, "b")
c.line(7, 1, 7, 4, "d")
c.px(7, 1, "r")
# Радиолокация и РЭБ.
def _radar(c): # параболическая антенна на стойке
c.disc(8, 5, 3, "l")
c.line(8, 8, 8, 12, "d")
c.rect(5, 13, 11, 14, "d")
def _ew(c): # штырь-антенна с сигнальными дугами
c.line(8, 6, 8, 14, "d")
c.disc(8, 4, 1, "b")
c.line(5, 2, 4, 4, "l")
c.line(4, 4, 4, 6, "l")
c.line(4, 6, 5, 8, "l")
c.line(11, 2, 12, 4, "l")
c.line(12, 4, 12, 6, "l")
c.line(12, 6, 11, 8, "l")
# Снабжение.
def _depot(c): # штабель ящиков на поддоне
c.rect(3, 8, 12, 13, "b")
c.line(3, 10, 12, 10, "d")
c.line(3, 12, 12, 12, "d")
c.rect(2, 14, 13, 14, "d")
c.rect(4, 4, 9, 7, "l")
c.line(4, 6, 9, 6, "d")
def _tzm(c): # грузовик с кабиной и кузовом
c.rect(2, 7, 5, 11, "b")
c.rect(3, 8, 4, 9, "w")
c.rect(6, 4, 14, 11, "l")
c.rect(3, 12, 4, 13, "k")
c.rect(10, 12, 11, 13, "k")
c.rect(13, 12, 14, 13, "k")
# Прожектор.
def _searchlight(c):
c.rect(3, 4, 10, 8, "b")
c.poly([(10, 4), (15, 1), (15, 11), (10, 8)], "y")
c.line(6, 9, 6, 13, "d")
c.rect(3, 14, 9, 14, "d")
# Целеуказатель: кольцо с засечками.
def _designator(c):
c.ring(8, 8, 16, 26, "r")
c.rect(7, 1, 8, 2, "r")
c.rect(7, 13, 8, 14, "r")
c.rect(1, 7, 2, 8, "r")
c.rect(13, 7, 14, 8, "r")
c.rect(7, 7, 8, 8, "r")
# Пуски: огонь сзади, силуэт типа спереди.
def _launch_base(c):
c.rect(0, 7, 1, 8, "o")
c.px(0, 7, "y")
def _shahed(c): # треугольное крыло
_launch_base(c)
c.poly([(5, 2), (5, 13), (13, 7)], "b")
def _geran3(c): # реактивный самолёт со стреловидным крылом
_launch_base(c)
c.rect(3, 7, 13, 8, "b")
c.poly([(6, 7), (9, 2), (11, 2), (9, 7)], "d")
c.poly([(6, 8), (9, 13), (11, 13), (9, 8)], "d")
c.poly([(3, 7), (4, 3), (5, 3), (5, 7)], "d")
def _lancet(c): # малый дрон с прямым крылом
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.rect(7, 2, 8, 13, "d")
c.rect(3, 6, 4, 9, "d")
def _kh101(c): # крылатая ракета: стреловидные крылья и хвост
_launch_base(c)
c.rect(3, 7, 12, 8, "b")
c.poly([(12, 6), (15, 7), (15, 8), (12, 9)], "b")
c.poly([(6, 7), (9, 3), (10, 3), (8, 7)], "d")
c.poly([(6, 8), (9, 12), (10, 12), (8, 8)], "d")
c.rect(2, 5, 3, 10, "d")
def _kalibr(c): # крылатая ракета: тонкий корпус и четыре хвостовых киля
_launch_base(c)
c.rect(2, 7, 13, 8, "b")
c.poly([(13, 6), (15, 7), (15, 8), (13, 9)], "b")
c.rect(2, 4, 3, 5, "d")
c.rect(2, 10, 3, 11, "d")
def _kh22(c): # толстый корпус с большими дельта-крыльями
_launch_base(c)
c.rect(3, 6, 11, 9, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "b")
c.poly([(4, 6), (8, 2), (9, 2), (7, 6)], "d")
c.poly([(4, 9), (8, 13), (9, 13), (7, 9)], "d")
def _zircon(c): # гиперзвук: корпус с одним передним стрейком
_launch_base(c)
c.rect(2, 7, 12, 8, "b")
c.poly([(12, 7), (15, 7), (12, 8)], "b")
c.poly([(8, 7), (12, 3), (13, 3), (11, 7)], "d")
def _iskander(c): # баллистическая: длинный корпус, конус, сопло
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (15, 8), (11, 9)], "l")
c.rect(2, 6, 2, 9, "d")
def _oreshnik(c): # баллистическая с тремя головными частями
_launch_base(c)
c.rect(2, 6, 10, 9, "b")
c.poly([(10, 5), (13, 6), (10, 7)], "l")
c.poly([(10, 8), (13, 9), (10, 10)], "l")
c.poly([(10, 6), (15, 7), (10, 8)], "b")
def _kinzhal(c): # аэробаллистическая: корпус с треугольными килями
_launch_base(c)
c.rect(3, 7, 11, 8, "b")
c.poly([(11, 6), (15, 7), (11, 9)], "b")
c.poly([(4, 7), (7, 3), (8, 3), (6, 7)], "d")
c.poly([(4, 8), (7, 12), (8, 12), (6, 8)], "d")
def _mover(c): # четыре стрелки перемещения
c.poly([(8, 0), (4, 5), (12, 5)], "b")
c.rect(7, 4, 8, 7, "b")
c.poly([(8, 15), (4, 10), (12, 10)], "b")
c.rect(7, 8, 8, 11, "b")
c.poly([(0, 8), (5, 4), (5, 12)], "b")
c.rect(4, 7, 7, 8, "b")
c.poly([(15, 8), (10, 4), (10, 12)], "b")
c.rect(8, 7, 11, 8, "b")
def _remover(c): # стрелка вниз над лотком: свёртывание
c.poly([(8, 9), (3, 4), (13, 4)], "b")
c.rect(7, 1, 8, 4, "b")
c.rect(2, 11, 13, 13, "b")
c.line(2, 11, 13, 11, "l")
def _flare(c): # звезда осветительной ракеты на стержне
c.disc(8, 7, 1, "y")
c.line(8, 2, 8, 4, "y")
c.line(8, 10, 8, 12, "y")
c.line(3, 7, 5, 7, "y")
c.line(11, 7, 13, 7, "y")
c.line(4, 4, 5, 5, "w")
c.line(11, 4, 10, 5, "w")
c.line(4, 10, 5, 9, "w")
c.line(11, 10, 10, 9, "w")
c.rect(7, 12, 8, 15, "k")
ICON_DRAW = {
"designator": _designator,
"launch_shahed": _shahed, "launch_geran3": _geran3, "launch_lancet": _lancet,
"launch_kh101": _kh101, "launch_kalibr": _kalibr, "launch_kh22": _kh22,
"launch_zircon": _zircon, "launch_iskander": _iskander, "launch_oreshnik": _oreshnik,
"launch_kinzhal": _kinzhal,
"deploy_patriot": _patriot, "deploy_gepard": _gepard, "deploy_pantsir": _pantsir,
"deploy_s400": _s400, "deploy_s500": _s500, "deploy_irist": _irist,
"deploy_nasams": _nasams, "deploy_buk": _buk, "deploy_tor": _tor, "deploy_p1sun": _p1sun,
"deploy_radar": _radar, "deploy_ew": _ew,
"deploy_depot": _depot, "deploy_tzm": _tzm,
"deploy_light": _searchlight,
"mover": _mover, "remover": _remover, "flare_gun": _flare,
}
def item_icon(name, color, seed):
c = _Icon(color, seed)
ICON_DRAW[name](c)
# Контур по краю силуэта: тёмные предметы — светлым, светлые — тёмным, чтобы читались на обоих фонах.
# Альфа-маска не меняется: контур перекрашивает только уже нарисованные пиксели.
lum = 0.3 * color[0] + 0.59 * color[1] + 0.11 * color[2]
edge = (160, 160, 160) if lum < 100 else (70, 70, 70) if lum > 190 else None
if edge:
src = c.im.copy()
def opaque(x, y):
return 0 <= x < 16 and 0 <= y < 16 and src.getpixel((x, y))[3] > 0
for y in range(16):
for x in range(16):
if opaque(x, y) and not all(opaque(x + dx, y + dy) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))):
c.im.putpixel((x, y), edge + (255,))
return c.im
def write_json(path, obj):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
@@ -1716,15 +1064,9 @@ def generate(out):
write_json(os.path.join(d, "block", name + ".json"),
{"template": "minecraft:stone", "item": False})
lang[f"block.{NS}.{name}"] = title
for name, fn in TEXTURES.items():
fn().save(os.path.join(a, "textures", "block", name + ".png"))
for name, (base, qx, qy, burnt) in VARIANTS.items():
im = STRETCH_FN[base](qx, qy) if (base in STRETCH_FN and (qx > 1 or qy > 1)) else TEXTURES[base]()
if burnt:
im = GT.burn(im, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint")
im.save(os.path.join(a, "textures", "block", name + ".png"))
GT.write_block_textures(a, TEXTURES, VARIANTS, STRETCH_FN)
for i, (name, title, color) in enumerate(ITEMS):
item_icon(name, color, zlib.crc32(name.encode())).save(os.path.join(a, "textures", "item", name + ".png"))
GT.write_item_icon(a, name, render_icon(name, color, zlib.crc32(name.encode())))
write_json(os.path.join(a, "models", "item", name + ".json"),
{"parent": "minecraft:item/generated", "textures": {"layer0": f"{NS}:item/{name}"}})
write_json(os.path.join(a, "items", name + ".json"),
@@ -1845,114 +1187,81 @@ def validate(stage):
return errors
MANIFEST = os.path.join(ROOT, "generated-manifest.txt")
def file_sha(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def read_manifest():
# Строки "<sha256> <путь от airdef-mod>", как у sha256sum. Только файлы, которые записал этот скрипт.
if not os.path.exists(MANIFEST):
return {}
entries = {}
with open(MANIFEST, encoding="utf-8") as f:
for line in f:
digest, rel = line.rstrip("\n").split(" ", 1)
if rel.startswith("/") or ".." in rel.split("/"):
sys.exit(f"{MANIFEST}: недопустимый путь {rel}")
entries[rel] = digest
return entries
def prune_empty(path):
def prune_empty(root, path):
d = os.path.dirname(path)
while d != ROOT and os.path.isdir(d) and not os.listdir(d):
while d != root and os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
d = os.path.dirname(d)
def regenerate(root=ROOT, source_root=None, manifest=None, *, check=False, report=False):
"""Build, validate, plan and install one complete generated runtime tree."""
root = os.path.abspath(root)
source_root = source_root or os.path.join(root, "source-assets")
manifest = manifest or os.path.join(root, "generated-manifest.txt")
try:
old = pipeline.read_manifest(manifest)
except ValueError as error:
raise ValueError(str(error)) from error
with tempfile.TemporaryDirectory() as stage:
counts = generate(stage)
pipeline.overlay_sources(stage, source_root)
errors = validate(stage)
if errors:
details = "\n".join(f" - {err}" for err in errors)
raise ValueError(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {root}.\n{details}")
new = {}
for dirpath, dirs, names in os.walk(stage):
dirs.sort()
for name in sorted(names):
path = os.path.join(dirpath, name)
new[os.path.relpath(path, stage).replace(os.sep, "/")] = pipeline.sha256(path)
plan = pipeline.make_plan(root, old, new)
create, update, same = plan.create, plan.update, plan.same
conflict, remove, keep = plan.conflict, plan.remove, plan.keep
if conflict:
names = "\n".join(f" - {rel}" for rel in conflict)
raise RuntimeError(f"КОНФЛИКТ: файлы заняты или правлены вручную:\n{names}\nНичего не записано.")
manifest_text = pipeline.manifest_text(new)
if os.path.exists(manifest):
with open(manifest, encoding="utf-8") as stream:
manifest_old = stream.read()
else:
manifest_old = None
manifest_changed = manifest_text != manifest_old
differs = bool(create or update or remove or manifest_changed)
if report:
for rel in keep:
print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран")
for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)):
for rel in rels:
print(f"{label}: {rel}")
if manifest_changed:
print("manifest: обновить")
if check:
print("различий нет" if not differs else "различия с генератором: см. выше")
if not check and differs:
pipeline.install(root, stage, manifest, plan, new)
for rel in remove:
prune_empty(root, os.path.join(root, rel))
models, items, textures = counts
if report and not check:
print(f"{models} моделей, {items} предметов, {textures} текстур -> {root}: "
f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}")
return differs
def main():
args = sys.argv[1:]
if any(arg != "--check" for arg in args):
sys.exit("Использование: python3 tools/gen_models.py [--check]")
check = "--check" in args
old = read_manifest()
with tempfile.TemporaryDirectory() as stage:
counts = generate(stage)
errors = validate(stage)
if errors:
print(f"ПРОВЕРКА НАБОРА НЕ ПРОЙДЕНА, ошибок: {len(errors)}. Ничего не записано в {ROOT}.")
for err in errors:
print(f" - {err}")
sys.exit(1)
new = {}
for dirpath, dirs, names in os.walk(stage):
dirs.sort()
for name in sorted(names):
path = os.path.join(dirpath, name)
new[os.path.relpath(path, stage).replace(os.sep, "/")] = file_sha(path)
# create: файла нет; update: файл записан генератором и с тех пор не менялся; same: уже совпадает;
# conflict: на пути лежит чужой или вручную правленный файл, поэтому ничего не пишем.
create, update, same, conflict = [], [], [], []
for rel in sorted(new):
target = os.path.join(ROOT, rel)
if not os.path.exists(target):
create.append(rel)
elif not os.path.isfile(target):
conflict.append(rel)
else:
cur = file_sha(target)
if cur == new[rel]:
same.append(rel)
elif rel in old and old[rel] == cur:
update.append(rel)
else:
conflict.append(rel)
# remove: генератор больше не создаёт файл и он не менялся; keep: не создаёт, но правлен вручную, оставляем.
remove, keep = [], []
for rel in sorted(old):
target = os.path.join(ROOT, rel)
if rel in new or not os.path.isfile(target):
continue
(remove if file_sha(target) == old[rel] else keep).append(rel)
if conflict:
for rel in conflict:
print(f"КОНФЛИКТ: {rel} занят файлом, которого нет в manifest (или он правлен вручную)")
sys.exit("Ничего не записано. Уберите или переименуйте эти файлы и запустите снова.")
manifest_text = "".join(f"{new[rel]} {rel}\n" for rel in sorted(new))
manifest_old = None
if os.path.exists(MANIFEST):
with open(MANIFEST, encoding="utf-8") as f:
manifest_old = f.read()
manifest_changed = manifest_text != manifest_old
for rel in keep:
print(f"ВНИМАНИЕ: {rel} больше не генерируется, но правлен вручную: оставлен, из manifest убран")
for label, rels in (("новый", create), ("обновить", update), ("удалить", remove)):
for rel in rels:
print(f"{label}: {rel}")
if manifest_changed:
print("manifest: обновить")
if check:
differs = create or update or remove or manifest_changed
print("различий нет" if not differs else "различия с генератором: см. выше")
sys.exit(1 if differs else 0)
for rel in create + update:
dst = os.path.join(ROOT, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(os.path.join(stage, rel), dst)
for rel in remove:
path = os.path.join(ROOT, rel)
os.remove(path)
prune_empty(path)
if manifest_changed:
with open(MANIFEST, "w", encoding="utf-8", newline="\n") as f:
f.write(manifest_text)
models, items, textures = counts
print(f"{models} моделей, {items} предметов, {textures} текстур -> {ROOT}: "
f"создано {len(create)}, обновлено {len(update)}, удалено {len(remove)}, без изменений {len(same)}")
try:
differs = regenerate(check=check, report=True)
except (ValueError, RuntimeError) as error:
sys.exit(str(error))
if check and differs:
sys.exit(1)
if __name__ == "__main__":
+24
View File
@@ -8,9 +8,33 @@
(в моделях грань длиннее 16 единиц тянет всю текстуру), картина заранее сжата, чтобы пятна камуфляжа не вытягивались.
"""
import random
import os
import zlib
from PIL import Image
def write_block_textures(asset_root, textures, variants, stretch_functions):
"""Render the palette's block textures into a staged `assets/<namespace>` tree."""
block_dir = os.path.join(asset_root, "textures", "block")
os.makedirs(block_dir, exist_ok=True)
for name, render in textures.items():
render().save(os.path.join(block_dir, name + ".png"))
for name, (base, qx, qy, burnt) in variants.items():
image = (stretch_functions[base](qx, qy)
if base in stretch_functions and (qx > 1 or qy > 1)
else textures[base]())
if burnt:
image = burn(image, zlib.crc32(name.encode()), "glass" if base.startswith("glass") else "paint")
image.save(os.path.join(block_dir, name + ".png"))
def write_item_icon(asset_root, name, image):
"""Write one generated item icon under a staged assets namespace."""
item_dir = os.path.join(asset_root, "textures", "item")
os.makedirs(item_dir, exist_ok=True)
image.save(os.path.join(item_dir, name + ".png"))
# ------------------------------------------------------------------ палитра
PAL = {
"nato_green": [(70, 80, 54), (86, 74, 50), (34, 38, 30)], # NATO: зелёный, коричневый, чёрный
+156
View File
@@ -0,0 +1,156 @@
"""Safety and reproducibility tests for AirDef's asset generation pipeline."""
import hashlib
import importlib.util
import os
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
TOOLS = Path(__file__).resolve().parent
sys.path.insert(0, str(TOOLS))
from airdef_assets import pipeline
from airdef_assets import icons
import gen_tex
SPEC = importlib.util.spec_from_file_location("gen_models", TOOLS / "gen_models.py")
gen_models = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(gen_models)
class ManifestTests(unittest.TestCase):
def test_manifest_rejects_traversal_and_duplicate_paths(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "manifest.txt"
path.write_text("0" * 64 + " ../outside\n", encoding="utf-8")
with self.assertRaises(ValueError):
pipeline.read_manifest(path)
path.write_text("0" * 64 + " safe.txt\n" + "1" * 64 + " safe.txt\n", encoding="utf-8")
with self.assertRaises(ValueError):
pipeline.read_manifest(path)
def test_plan_protects_untracked_and_modified_files_and_keeps_edited_obsolete_file(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "tracked.txt").write_text("edited", encoding="utf-8")
(root / "obsolete.txt").write_text("artist edit", encoding="utf-8")
(root / "untracked.txt").write_text("user", encoding="utf-8")
old = {
"tracked.txt": hashlib.sha256(b"old generated").hexdigest(),
"obsolete.txt": hashlib.sha256(b"old generated").hexdigest(),
}
plan = pipeline.make_plan(root, old, {"tracked.txt": hashlib.sha256(b"new").hexdigest(),
"untracked.txt": hashlib.sha256(b"output").hexdigest()})
self.assertEqual(plan.conflict, ["tracked.txt", "untracked.txt"])
self.assertEqual(plan.keep, ["obsolete.txt"])
self.assertEqual(plan.remove, [])
def test_install_rolls_back_files_and_manifest_after_replace_error(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "runtime"
stage = Path(tmp) / "stage"
root.mkdir()
stage.mkdir()
manifest = root / "generated-manifest.txt"
manifest.write_text("old manifest\n", encoding="utf-8")
for name in ("a.txt", "b.txt"):
(root / name).write_text("old " + name, encoding="utf-8")
(stage / name).write_text("new " + name, encoding="utf-8")
old = {name: pipeline.sha256(root / name) for name in ("a.txt", "b.txt")}
new = {name: pipeline.sha256(stage / name) for name in ("a.txt", "b.txt")}
plan = pipeline.make_plan(root, old, new)
real_replace = os.replace
calls = 0
def fail_once(src, dst):
nonlocal calls
calls += 1
if calls == 3:
raise OSError("simulated write failure")
real_replace(src, dst)
with self.assertRaisesRegex(OSError, "simulated"):
pipeline.install(root, stage, manifest, plan, new, replace=fail_once)
self.assertEqual((root / "a.txt").read_text(), "old a.txt")
self.assertEqual((root / "b.txt").read_text(), "old b.txt")
self.assertEqual(manifest.read_text(), "old manifest\n")
class ReproducibilityTests(unittest.TestCase):
def test_texture_renders_are_seed_deterministic(self):
first = gen_tex.camo("nato_green", 1234, 32)
second = gen_tex.camo("nato_green", 1234, 32)
self.assertEqual(first.tobytes(), second.tobytes())
def test_authored_override_is_merged_without_changing_source(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "source-assets"
stage = root / "stage"
authored = source / "assets/airdef/textures/item/custom.png"
authored.parent.mkdir(parents=True)
authored.write_bytes(b"authored")
stage.mkdir()
pipeline.overlay_sources(stage, source)
self.assertEqual((stage / "assets/airdef/textures/item/custom.png").read_bytes(), b"authored")
self.assertEqual(authored.read_bytes(), b"authored")
def test_full_pipeline_installs_valid_icon_override_and_rejects_bad_one_without_mutation(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "mod"
root.mkdir()
source = root / "source-assets"
manifest = root / "generated-manifest.txt"
icon_source = source / "assets/airdef/textures/item/deploy_gepard.png"
icon_source.parent.mkdir(parents=True)
self.assertTrue(gen_models.regenerate(root, source, manifest))
previous_icon = (root / "assets/airdef/textures/item/deploy_gepard.png").read_bytes()
previous_manifest = manifest.read_bytes()
self.assertFalse(gen_models.regenerate(root, source, manifest))
Image.new("RGBA", (16, 16), (12, 34, 56, 255)).save(icon_source)
authored_bytes = icon_source.read_bytes()
self.assertTrue(gen_models.regenerate(root, source, manifest))
installed = root / "assets/airdef/textures/item/deploy_gepard.png"
self.assertEqual(installed.read_bytes(), authored_bytes)
self.assertEqual(icon_source.read_bytes(), authored_bytes)
self.assertNotEqual(installed.read_bytes(), previous_icon)
installed_before_bad_override = installed.read_bytes()
manifest_before_bad_override = manifest.read_bytes()
Image.new("RGBA", (20, 16), (255, 0, 0, 255)).save(icon_source)
invalid_source = icon_source.read_bytes()
with self.assertRaisesRegex(ValueError, "не квадратная"):
gen_models.regenerate(root, source, manifest)
self.assertEqual(installed.read_bytes(), installed_before_bad_override)
self.assertEqual(manifest.read_bytes(), manifest_before_bad_override)
self.assertEqual(icon_source.read_bytes(), invalid_source)
self.assertNotEqual(previous_manifest, manifest_before_bad_override)
def test_icon_catalog_has_stable_explicit_renderer_api(self):
entry = next(item for item in icons.ITEMS if item[0] == "deploy_gepard")
first = icons.render_icon(entry[0], entry[2], 1234)
second = icons.render_icon(entry[0], entry[2], 1234)
self.assertEqual(first.tobytes(), second.tobytes())
def test_full_generated_tree_is_reproducible(self):
with tempfile.TemporaryDirectory() as tmp:
outputs = []
for name in ("first", "second"):
root = Path(tmp) / name
root.mkdir()
gen_models.generate(str(root))
outputs.append({
str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(root.rglob("*")) if path.is_file()
})
self.assertEqual(outputs[0], outputs[1])
if __name__ == "__main__":
unittest.main()