saumwacht-godot/tools/build_standalone_model_scripts.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

132 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Bundle the modular bpy sources into nine genuinely self-contained scripts.
No Blender dependency for this build step. Source comments and shape functions
are preserved; only imports of our own modules and the batch dispatcher vanish.
"""
import ast
from pathlib import Path
from pprint import pformat
import sys
PROJECT = Path(__file__).resolve().parents[1]
SOURCE = PROJECT / 'assets-source' / 'phase4'
MODULES = SOURCE / 'blender'
OUTPUT = SOURCE / 'standalone'
sys.path.insert(0, str(MODULES))
from catalog import specs
ENTRIES = [
('melee', 'frontline', 'g01', 'Nahkämpfer'),
('tank', 'frontline', 'g03', 'Tank'),
('ranged', 'backline', 'g05', 'Fernkämpfer'),
('artillery', 'backline', 'g07', 'Flächenkämpfer'),
('support', 'backline', 'g09', 'Unterstützer'),
('mercenary', 'environment', 's01', 'Söldner'),
('worker', 'environment', 'worker', 'Funkenleser'),
('lane', 'environment', 'map_lane_segment', 'Lane-Segment'),
('core', 'environment', 'core_team0', 'Teamkerne'),
]
def bundled_source(filename, skip_functions=()):
source = (MODULES / filename).read_text(encoding='utf-8')
lines = source.splitlines(keepends=True)
skipped = set()
for node in ast.parse(source).body:
own_import = isinstance(node, ast.ImportFrom) and (node.module in ('common', 'catalog') or node.module.startswith('reference_'))
omitted_function = isinstance(node, ast.FunctionDef) and node.name in skip_functions
docstring = isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
if own_import or omitted_function or docstring:
skipped.update(range(node.lineno - 1, node.end_lineno))
return ''.join(line for i, line in enumerate(lines) if i not in skipped).strip()
CONFIG = '''
from pathlib import Path
import sys
import bpy
# === ANPASSBARE PARAMETER ================================================
ASSET_ID = {asset_id!r} # Verfügbare IDs stehen unmittelbar unter diesem Block.
EXPORT_DIR = None # Optional: r"C:/Spiel/assets/models" oder "/home/.../models".
BLEND_DIR = None # Optionaler Ordner für die bearbeitbare .blend-Datei.
HEIGHT = None # None = Katalogmaß; sonst Zielhöhe in Metern, z. B. 1.5.
PALETTE_OVERRIDES = {{}} # Beispiel: {{'accent': 'D9A441'}}; RGB-Hex ohne Alpha.
KEEP_SCENE = True # Generiertes Modell nach Ausführung in Blender anzeigen.
# Formteile und Proportionen sind weiter unten als kleine Funktionen editierbar.
# Bei großen Waffen begrenzt der Zell-Footprint ggf. die endgültige Höhe.
# Dieses Skript braucht nur Blender/bpy, keine Projektmodule oder JSON-Dateien.
def _script_directory():
candidate = globals().get('__file__', '')
if candidate and Path(candidate).is_file():
return Path(candidate).resolve().parent
text = getattr(getattr(bpy.context, 'space_data', None), 'text', None)
if text is not None and text.filepath:
return Path(bpy.path.abspath(text.filepath)).resolve().parent
return Path.cwd()
SCRIPT_DIR = _script_directory()
PROJECT = next((p for p in [SCRIPT_DIR, *SCRIPT_DIR.parents]
if (p / 'project.godot').is_file()), SCRIPT_DIR)
IN_PROJECT = (PROJECT / 'project.godot').is_file()
SOURCE = PROJECT / 'assets-source' / 'phase4' if IN_PROJECT else SCRIPT_DIR / 'saumwacht-source'
DEFAULT_EXPORT_DIR = PROJECT / 'assets' / 'models' if IN_PROJECT else SCRIPT_DIR / 'saumwacht-export'
DEFAULT_BLEND_DIR = SOURCE / 'blend'
'''
MAIN = '''
def main():
# CLI-Optionen sind optional; im Blender-Scripting-Tab genügt "Run Script".
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--id', default=ASSET_ID)
parser.add_argument('--export-dir', default=EXPORT_DIR)
parser.add_argument('--blend-dir', default=BLEND_DIR)
parser.add_argument('--height', type=float, default=HEIGHT)
parser.add_argument('--no-keep-scene', action='store_true')
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [])
build_one(args.id, export_dir=args.export_dir, blend_dir=args.blend_dir,
height=args.height, palette_overrides=PALETTE_OVERRIDES,
keep_scene=KEEP_SCENE and not args.no_keep_scene)
print('STANDALONE_PASS:', args.id)
if __name__ == '__main__':
main()
'''
def main():
OUTPUT.mkdir(parents=True, exist_ok=True)
for role, module, asset_id, label in ENTRIES:
selected = [s for s in specs() if s['role'] == role]
header = f'"""Saumwacht · {label} · eigenständiges Blender-Python-Skript.\n\n'
header += 'In Blender: Scripting > Text > Open > diese Datei > Run Script.\n'
header += 'Vorhandene Szenen bleiben erhalten. GLB und .blend werden automatisch exportiert.\n'
header += 'Generiert aus den modularen Quellen mit tools/build_standalone_model_scripts.py.\n'
header += 'Getestet mit Blender 5.2.1; verwendet grundlegende bpy/glTF-APIs.\n"""\n'
data = '\n# === VERFÜGBARE VARIANTEN ===============================================\n'
data += 'ASSET_SPECS = ' + pformat(selected, width=105, sort_dicts=False) + '\n'
data += '\ndef get_spec(asset_id):\n for spec in ASSET_SPECS:\n if spec["id"] == asset_id:\n return dict(spec)\n raise ValueError("Unbekannte ID für dieses Skript: " + asset_id)\n'
dispatcher = '\ndef _builder(role):\n return globals()["build_" + role]\n'
body = '\n\n'.join([
bundled_source('common.py'), bundled_source('reference_geometry.py'),
bundled_source('reference_high_detail.py'), bundled_source('reference_living_detail.py'), bundled_source('reference_sculpt_detail.py'),
bundled_source('reference_glutpakt.py'), bundled_source('reference_nebelchor.py'),
bundled_source('reference_nietenkonvent.py'), bundled_source('reference_wurzelbund.py'),
bundled_source('reference_neutral.py'),
bundled_source(module + '.py'), dispatcher,
bundled_source('pipeline.py', ('_builder', 'write_manifest')),
])
content = header + CONFIG.format(asset_id=asset_id) + data + '\n' + body + MAIN
path = OUTPUT / ('create_' + role + '.py')
compile(content, str(path), 'exec')
path.write_text(content, encoding='utf-8')
print(path.name, '·', len(selected), 'Varianten')
if __name__ == '__main__':
main()