Separate authored assets and install generated output transactionally (#92)
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared safety helpers for AirDef's generated asset tree."""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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: зелёный, коричневый, чёрный
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user