a6a13003c2
The mod (airdef-mod/), its generated models and sounds, the fork's patches over upstream Horizon (horizon-patches/, HORIZON_COMMIT), a cross-platform build/install/run script (tools/airdef.py), CI that builds and releases on every push (.gitea/workflows/build.yml) and a setup guide (docs/INSTALL.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
13 KiB
Python
362 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""Генерирует контент-мод airdef-content: текстуры, модели и блоки с вариантами поворота.
|
||
|
||
Horizon не рисует display-сущности, но рисует falling_block с моделью любого блока.
|
||
Поэтому каждый поворот дрона/ракеты/башни — отдельный блок: модель повёрнута элементами
|
||
(rotation x/y), а блокстейт facing докручивает по 90°. Итог: 16 курсов × N углов тангажа.
|
||
|
||
Запуск: python3 tools/gen_content.py
|
||
"""
|
||
import json
|
||
import os
|
||
import random
|
||
import shutil
|
||
|
||
from PIL import Image
|
||
|
||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "airdef-content"))
|
||
NS = "airdef"
|
||
|
||
# Знаки поворота элементов подобраны по скриншотам в игре.
|
||
PITCH_SIGN = 1
|
||
YAW_SIGN = -1
|
||
|
||
YAW_OFFS = [0, 22.5, 45, 67.5]
|
||
DRONE_PITCH = [20, 0, -20, -40, -60, -80]
|
||
MISSILE_PITCH = [90, 67.5, 45, 22.5, 0, -22.5, -45, -67.5, -90]
|
||
GEPARD_ELEV = [75, 50, 25, 0]
|
||
PATRIOT_ELEV = 38
|
||
|
||
FACINGS = [("north", 0), ("east", 90), ("south", 180), ("west", 270)]
|
||
|
||
|
||
# ---------------------------------------------------------------- текстуры
|
||
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(max(0, min(255, c + d)) for c in base) + (255,))
|
||
return im
|
||
|
||
|
||
def tex_panels(base, seed):
|
||
im = noise(base, 5, seed)
|
||
line = tuple(max(0, c - 18) for c in base) + (255,)
|
||
for i in range(16):
|
||
im.putpixel((i, 5), line)
|
||
im.putpixel((11, i), line)
|
||
return im
|
||
|
||
|
||
def tex_camo(colors, seed):
|
||
rnd = random.Random(seed)
|
||
im = noise(colors[0], 4, seed)
|
||
for col in colors[1:]:
|
||
for _ in range(5):
|
||
cx, cy, r = rnd.randint(0, 15), rnd.randint(0, 15), rnd.randint(1, 3)
|
||
for y in range(cy - r, cy + r + 1):
|
||
for x in range(cx - r - 1, cx + r + 2):
|
||
if (x - cx) ** 2 / 1.6 + (y - cy) ** 2 <= r * r:
|
||
im.putpixel((x % 16, y % 16), tuple(max(0, min(255, c + rnd.randint(-4, 4))) for c in col) + (255,))
|
||
return im
|
||
|
||
|
||
def tex_track():
|
||
im = noise((38, 38, 36), 4, 7)
|
||
for y in range(0, 16, 3):
|
||
for x in range(16):
|
||
im.putpixel((x, y), (70, 70, 66, 255))
|
||
return im
|
||
|
||
|
||
def tex_canister_front():
|
||
im = noise((96, 104, 74), 4, 11)
|
||
for cx in (4, 11):
|
||
for cy in (4, 11):
|
||
for y in range(cy - 3, cy + 3):
|
||
for x in range(cx - 3, cx + 3):
|
||
im.putpixel((x, y), (40, 42, 34, 255))
|
||
return im
|
||
|
||
|
||
def tex_flame():
|
||
im = Image.new("RGBA", (16, 16))
|
||
for y in range(16):
|
||
for x in range(16):
|
||
t = y / 15
|
||
im.putpixel((x, y), (255, int(230 - 120 * t), int(120 - 100 * t), 255))
|
||
return im
|
||
|
||
|
||
def tex_missile():
|
||
im = noise((228, 228, 222), 3, 21)
|
||
for x in range(16):
|
||
im.putpixel((x, 2), (180, 40, 40, 255))
|
||
im.putpixel((x, 3), (180, 40, 40, 255))
|
||
return im
|
||
|
||
|
||
def tex_glass():
|
||
im = noise((44, 62, 82), 6, 31)
|
||
for i in range(16):
|
||
im.putpixel((i, 15 - i), (120, 150, 170, 255))
|
||
return im
|
||
|
||
|
||
TEXTURES = {
|
||
"shahed": lambda: tex_panels((64, 66, 68), 1),
|
||
"shahed_nose": lambda: noise((96, 98, 100), 4, 2),
|
||
"geran": lambda: tex_panels((30, 30, 33), 3),
|
||
"geran_engine": lambda: noise((52, 52, 56), 5, 4),
|
||
"prop": lambda: noise((18, 18, 18), 3, 5),
|
||
"missile": tex_missile,
|
||
"flame": tex_flame,
|
||
"nozzle": lambda: noise((40, 40, 40), 4, 6),
|
||
"nato_green": lambda: tex_camo([(76, 84, 58), (58, 66, 44), (40, 44, 32)], 8),
|
||
"flecktarn": lambda: tex_camo([(92, 98, 62), (64, 74, 44), (100, 82, 54), (32, 34, 28)], 9),
|
||
"track": tex_track,
|
||
"tire": lambda: noise((26, 26, 26), 3, 10),
|
||
"canister": lambda: tex_panels((96, 104, 74), 12),
|
||
"canister_front": tex_canister_front,
|
||
"glass": tex_glass,
|
||
"metal": lambda: noise((122, 124, 126), 6, 13),
|
||
"radar": lambda: noise((160, 162, 160), 5, 14),
|
||
"barrel": lambda: noise((44, 46, 42), 3, 15),
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- модели
|
||
def el(frm, to, tex, group="body"):
|
||
return {"from": list(frm), "to": list(to), "tex": tex, "group": group}
|
||
|
||
|
||
def mirror_x(e):
|
||
"""Зеркальная копия элемента относительно центра блока (x=8)."""
|
||
return {**e, "from": [16 - e["to"][0], e["from"][1], e["from"][2]], "to": [16 - e["from"][0], e["to"][1], e["to"][2]]}
|
||
|
||
|
||
def face_uv(frm, to, face):
|
||
dx, dy, dz = (abs(to[i] - frm[i]) for i in range(3))
|
||
w, h = {"north": (dx, dy), "south": (dx, dy), "east": (dz, dy), "west": (dz, dy), "up": (dx, dz), "down": (dx, dz)}[face]
|
||
return [0, 0, max(0.5, min(16, w)), max(0.5, min(16, h))]
|
||
|
||
|
||
def build_model(parts, rot_for_group, origin=(8, 8, 8)):
|
||
textures = {}
|
||
elements = []
|
||
for p in parts:
|
||
textures[p["tex"]] = f"{NS}:block/{p['tex']}"
|
||
faces = {f: {"uv": face_uv(p["from"], p["to"], f), "texture": "#" + p["tex"]}
|
||
for f in ("north", "south", "east", "west", "up", "down")}
|
||
e = {"from": p["from"], "to": p["to"], "faces": faces}
|
||
rx, ry = rot_for_group(p["group"])
|
||
if rx or ry:
|
||
rot = {"origin": list(origin)}
|
||
if rx:
|
||
rot["x"] = rx
|
||
if ry:
|
||
rot["y"] = ry
|
||
e["rotation"] = rot
|
||
elements.append(e)
|
||
textures["particle"] = f"{NS}:block/{parts[0]['tex']}"
|
||
return {"textures": textures, "elements": elements}
|
||
|
||
|
||
def delta_wing(tex, z_lead, z_tip, z_trail, half_span, steps=6, y0=7.4, y1=8.2):
|
||
"""Дельта-крыло лесенкой: передняя кромка от (8, z_lead) до законцовки half_span на z_tip."""
|
||
parts = []
|
||
for i in range(steps):
|
||
za = z_lead + (z_tip - z_lead) * i / steps
|
||
zb = z_lead + (z_tip - z_lead) * (i + 1) / steps
|
||
s = half_span * (i + 1) / steps
|
||
parts.append(el((8 - s, y0, za), (8 + s, y1, zb), tex))
|
||
parts.append(el((8 - half_span, y0, z_tip), (8 + half_span, y1, z_trail), tex))
|
||
return parts
|
||
|
||
|
||
def shahed_parts():
|
||
p = [
|
||
el((6.5, 6.5, -11), (9.5, 9.5, 21), "shahed"),
|
||
el((7, 7, -14), (9, 9, -11), "shahed_nose"),
|
||
el((6.8, 6.8, 21), (9.2, 9.2, 24), "prop"),
|
||
el((7.6, 2.5, 24), (8.4, 13.5, 24.6), "prop"),
|
||
el((2.5, 7.6, 24), (13.5, 8.4, 24.6), "prop"),
|
||
]
|
||
p += delta_wing("shahed", -3, 15, 21, 19)
|
||
fin = el((-11.5, 7, 13), (-10.5, 12, 21), "shahed")
|
||
p += [fin, mirror_x(fin)]
|
||
return p
|
||
|
||
|
||
def geran3_parts():
|
||
p = [
|
||
el((6.5, 6.5, -12), (9.5, 9.5, 20), "geran"),
|
||
el((7, 7, -15), (9, 9, -12), "geran_engine"),
|
||
el((6, 9.5, 6), (10, 12.5, 22), "geran_engine"),
|
||
el((6.5, 10, 22), (9.5, 12, 23), "nozzle"),
|
||
]
|
||
p += delta_wing("geran", -2, 13, 19, 15, steps=5)
|
||
fin = el((-7.5, 7, 12), (-6.5, 11, 19), "geran")
|
||
p += [fin, mirror_x(fin)]
|
||
return p
|
||
|
||
|
||
def pac3_parts():
|
||
p = [
|
||
el((6.75, 6.75, -13), (9.25, 9.25, 14), "missile"),
|
||
el((7.25, 7.25, -16), (8.75, 8.75, -13), "missile"),
|
||
el((7.1, 7.1, 14), (8.9, 8.9, 16), "nozzle"),
|
||
el((7.3, 7.3, 16), (8.7, 8.7, 23), "flame"),
|
||
el((3.5, 7.75, 9), (12.5, 8.25, 14), "missile"),
|
||
el((7.75, 3.5, 9), (8.25, 12.5, 14), "missile"),
|
||
]
|
||
return p
|
||
|
||
|
||
def patriot_box_parts():
|
||
"""Пакет из 4 ТПК: ось вращения — центр блока, наклон задаётся тангажом."""
|
||
return [
|
||
el((-3, 2, -14), (19, 22, 28), "canister"),
|
||
el((-3.2, 1.8, -14.4), (19.2, 22.2, -14), "canister_front"),
|
||
el((-4, 11.5, -14), (20, 12.5, 28), "metal"),
|
||
]
|
||
|
||
|
||
def gepard_turret_parts():
|
||
turret = [
|
||
el((-3, 2, -2), (19, 14, 20), "flecktarn"),
|
||
el((1, 14, 4), (15, 16, 16), "flecktarn"),
|
||
el((4, 6, -6), (12, 13, -2), "radar", "turret"),
|
||
el((7.5, 16, 15), (8.5, 22, 16), "metal"),
|
||
el((1, 21, 14), (15, 24, 16), "radar"),
|
||
]
|
||
for e in turret:
|
||
e["group"] = "turret"
|
||
gun = el((-7, 7, -16), (-4.5, 9.5, 10), "barrel", "gun")
|
||
mount = el((-8, 5.5, 2), (-3, 11, 12), "flecktarn", "gun")
|
||
return turret + [gun, mirror_x(gun), mount, mirror_x(mount)]
|
||
|
||
|
||
def patriot_truck_parts():
|
||
p = [
|
||
el((-6, 5, -16), (22, 9, 32), "nato_green"),
|
||
el((-6, 9, -15.5), (22, 24, -2), "nato_green"),
|
||
el((-5, 15, -16), (21, 22, -15.5), "glass"),
|
||
el((-6.2, 15, -14), (-5.8, 22, -6), "glass"),
|
||
el((21.8, 15, -14), (22.2, 22, -6), "glass"),
|
||
el((-4, 9, 0), (20, 16, 10), "nato_green"),
|
||
]
|
||
for z in (-12, 16, 25):
|
||
w = el((-8, 0, z), (-5, 8, z + 7), "tire")
|
||
p += [w, mirror_x(w)]
|
||
return p
|
||
|
||
|
||
def patriot_trailer_parts():
|
||
p = [
|
||
el((-6, 5, -16), (22, 10, 32), "nato_green"),
|
||
el((2, 10, 10), (14, 14, 22), "metal"),
|
||
el((-6, 2, -16), (22, 6, -12), "metal"),
|
||
]
|
||
for z in (14, 23):
|
||
w = el((-8, 0, z), (-5, 8, z + 7), "tire")
|
||
p += [w, mirror_x(w)]
|
||
return p
|
||
|
||
|
||
def gepard_front_parts():
|
||
p = [
|
||
el((-12, 4, -12), (28, 18, 32), "flecktarn"),
|
||
el((-12, 4, -16), (28, 13, -12), "flecktarn"),
|
||
el((-10, 18, 20), (26, 19, 32), "metal"),
|
||
]
|
||
t = el((-16, 0, -14), (-12, 11, 32), "track")
|
||
return p + [t, mirror_x(t)]
|
||
|
||
|
||
def gepard_rear_parts():
|
||
p = [
|
||
el((-12, 4, -16), (28, 18, 26), "flecktarn"),
|
||
el((-10, 18, -16), (26, 19, 20), "flecktarn"),
|
||
el((-6, 18, 4), (22, 19.5, 18), "metal"),
|
||
]
|
||
t = el((-16, 0, -16), (-12, 11, 28), "track")
|
||
return p + [t, mirror_x(t)]
|
||
|
||
|
||
# ---------------------------------------------------------------- запись
|
||
blocks = {} # имя -> (model, rotate_with_facing)
|
||
names = {}
|
||
|
||
|
||
def rot_all(pitch, yaw):
|
||
return lambda group: (PITCH_SIGN * pitch, YAW_SIGN * yaw)
|
||
|
||
|
||
def add_rotating(prefix, parts, pitches, title, origin=(8, 8, 8), pitch_groups=None):
|
||
"""Блоки prefix_p{i}_y{j}: тангаж pitches[i], доворот курса YAW_OFFS[j]."""
|
||
for i, pitch in enumerate(pitches):
|
||
for j, yaw in enumerate(YAW_OFFS):
|
||
if pitch_groups is None:
|
||
rot = rot_all(pitch, yaw)
|
||
else:
|
||
rot = (lambda pi, ya: lambda g: (PITCH_SIGN * pi if g in pitch_groups else 0, YAW_SIGN * ya))(pitch, yaw)
|
||
name = f"{prefix}_p{i}_y{j}"
|
||
blocks[name] = build_model(parts, rot, origin)
|
||
names[name] = title
|
||
|
||
|
||
def add_static(name, parts, title):
|
||
blocks[name] = build_model(parts, lambda g: (0, 0))
|
||
names[name] = title
|
||
|
||
|
||
add_rotating("shahed", shahed_parts(), DRONE_PITCH, "Шахед-136")
|
||
add_rotating("geran3", geran3_parts(), DRONE_PITCH, "Герань-3")
|
||
add_rotating("pac3", pac3_parts(), MISSILE_PITCH, "Ракета PAC-3")
|
||
add_rotating("patriot_box", patriot_box_parts(), [PATRIOT_ELEV], "ПУ Patriot")
|
||
add_rotating("gepard_turret", gepard_turret_parts(), GEPARD_ELEV, "Башня Гепарда", pitch_groups={"gun"})
|
||
add_static("patriot_truck", patriot_truck_parts(), "Тягач Patriot")
|
||
add_static("patriot_trailer", patriot_trailer_parts(), "Полуприцеп Patriot")
|
||
add_static("gepard_front", gepard_front_parts(), "Корпус Гепарда (нос)")
|
||
add_static("gepard_rear", gepard_rear_parts(), "Корпус Гепарда (корма)")
|
||
|
||
|
||
def write_json(path, obj):
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(obj, f, ensure_ascii=False, separators=(",", ":"))
|
||
|
||
|
||
def main():
|
||
for sub in ("assets", "data"):
|
||
shutil.rmtree(os.path.join(ROOT, sub), ignore_errors=True)
|
||
tex_dir = os.path.join(ROOT, "assets", NS, "textures", "block")
|
||
os.makedirs(tex_dir, exist_ok=True)
|
||
for name, fn in TEXTURES.items():
|
||
fn().save(os.path.join(tex_dir, name + ".png"))
|
||
|
||
lang = {}
|
||
for name, model in blocks.items():
|
||
write_json(os.path.join(ROOT, "assets", NS, "models", "block", name + ".json"), model)
|
||
variants = {}
|
||
for facing, y in FACINGS:
|
||
v = {"model": f"{NS}:block/{name}"}
|
||
if y:
|
||
v["y"] = y
|
||
variants[f"facing={facing}"] = v
|
||
write_json(os.path.join(ROOT, "assets", NS, "blockstates", name + ".json"), {"variants": variants})
|
||
write_json(os.path.join(ROOT, "data", NS, "horizon", "block", name + ".json"),
|
||
{"template": "minecraft:carved_pumpkin", "item": False,
|
||
"properties": {"hardness": 2.0, "sounds": "minecraft:iron_block"}})
|
||
lang[f"block.{NS}.{name}"] = names[name]
|
||
write_json(os.path.join(ROOT, "assets", NS, "lang", "en_us.json"), lang)
|
||
write_json(os.path.join(ROOT, "assets", NS, "lang", "ru_ru.json"), lang)
|
||
print(f"{len(blocks)} блоков, {len(TEXTURES)} текстур -> {ROOT}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|