#!/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()