saumwacht-godot/tools/build_demo.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

227 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Export both release programs and package them with instructions and licenses."""
from pathlib import Path
import argparse, configparser, fnmatch, hashlib, json, re, shutil, subprocess, zipfile
PROJECT = Path(__file__).resolve().parents[1]
DIST = PROJECT.parent / 'dist'
VERSION = '0.15.0'
SKELETAL_RELEASE_MODELS = {
'w04': ('assets/phase11_manifest.json', 'res://assets/characters/ringborke.glb'),
'worker': ('assets/phase12_manifest.json', 'res://assets/characters/arbeiter.glb'),
'g04': ('assets/phase15_manifest.json', 'res://assets/characters/basaltbastion.glb'),
'w10': ('assets/phase16_manifest.json', 'res://assets/characters/quellgeflecht.glb'),
'g05': ('assets/phase18_manifest.json', 'res://assets/characters/funkenlanze.glb'),
'g06': ('assets/phase19_manifest.json', 'res://assets/characters/glasballiste.glb'),
}
INTRO = '''SAUMWACHT – DIE GRÜNE KLUFT · DEMO {version}
START
{start}
Godot und Blender werden zum Spielen nicht benötigt.
Die Spieldaten sind in das Programm eingebettet. Das ganze ZIP zunächst entpacken.
HAUPTMENÜ 0.15
Neuer Spieleinstieg vor einer 3D-Bühne an der Grünen Kluft: Glutpakt und
Wurzelbund stehen mit echten Spielmodellen an ihren Saumkernen.
Bundwahl über gestaltete Karten mit Emblem und Spielweise; Partielänge,
Rivalenbund, KI-Stufe und Bauzeit bleiben wählbar (letztere unter
Weitere Einstellungen). Optionen: Bewegung reduzieren schaltet Kamerafahrten,
Partikel und Überblendungen im Menü ab. Das Bild füllt jetzt auch 16:9.
GRAFIK UND MENÜ
Cineastische Beleuchtung, Farbabstimmung, HUD und Optionsdialog.
Der Funkenleser zeigt Werkzeugkontakt, Aufnehmen, Einfüllen und Rückkehr.
Die Ringborke behält ihren Körperbau und ihre neun Bewegungen.
Basaltbastion und Quellgeflecht besitzen ebenfalls gewichtete Skelette
mit je neun Bewegungen für Gang, Angriff, Fähigkeit, Treffer und Tod.
Der Saumkern reagiert auf Aktivierung und Schaden.
ANIMATIONEN 0.14
Die Schutzhaltung der Skelettwächter folgt ihrer ausgelösten Fähigkeit.
Eine empfangene Heilung löst beim Empfänger keine eigene Fähigkeitsbewegung aus.
Arbeiter melden den Werkzeugkontakt genau einmal pro Schlag.
Dünne Blätter werden von beiden Seiten dargestellt.
GRAFIKUPDATE
Die grüne Kluft: bewachsene Ruinen, modellierte Felswände, Brücken und Wasser.
Überarbeitete Wächter, Arbeiter und Kristalle; native 4K-Holzmaterialien.
Das Baumenü klappt während des Kampfes ein und öffnet sich zur nächsten Bauphase.
ERSTE PARTIE
Neue Partie öffnet die Bundwahl: Wurzelbund oder Glutpakt und die Partielänge.
Rivalenbund, KI-Stufe und Bauzeit stehen unter Weitere Einstellungen.
Partie beginnen und dann Startformation klicken: 185 Gold beim Wurzelbund,
180 Gold beim Glutpakt. Tank und Nahkämpfer stehen vorn, der Schütze dahinter.
Standardmäßig bleibt die Bauphase offen, bis du Kampf starten / Leertaste drückst.
Auch die Abrechnung wartet auf dich. Das Zeitlimit ist im Menü zuschaltbar.
Die Wurzelbund-Heiler brauchen verwundete Verbündete in 4–4,5 m Reichweite.
Wächter werden nach dem Kampf wiederhergestellt. Gold kauft Wächter und Arbeiter.
Jeder Arbeiter liefert 10 Funken pro Welle; neue Arbeiter erst ab der nächsten.
Ab Welle 3 kannst du Funken für Söldner ausgeben. Diese greifen den Rivalen an
und erhöhen dauerhaft dein Gold-Einkommen ab der nächsten Abrechnung.
Mit C oder Basis ansehen siehst du Arbeiter und Kristall, mit C kehrst du zurück.
Schütze deinen Kristall: durchgebrochene Gegner verursachen dort Schaden.
Die Expedition hat 20 Wellen und vier Bosse, die Kurzpartie acht Wellen und zwei.
Ein zerstörter Kern beendet das Duell früher. Nach der letzten Welle entscheidet
der höhere Kern-HP-Wert. Bei Gleichstand ist die Partie unentschieden.
STEUERUNG
1–5: Wächter wählen. Linksklick: Bauen / Auswählen. Shift + Klick: mehrfach bauen.
Rechtsklick / Esc: Bauauftrag abbrechen. Leertaste: Bereit / nächste Welle.
P: Pause. Tab: Front wechseln. C: Arbeiter & Kristall. Mausrad: Zoom.
Q / E: Kamera drehen. Mittlere Maustaste ziehen: Kamera verschieben.
R: Kamera zurücksetzen. F1: Optionen und Hilfe. M: Ton stumm. F11: Vollbild.
V: 3D-Modelle groß ansehen, auswählen, drehen und Bewegungen abspielen.
Das Optionsmenü pausiert die Partie. Lautstärke, Stummschaltung,
Kamerafolge, Vollbild und Bewegung reduzieren werden lokal gespeichert.
Kein Spielstandspeichern.
SYSTEM UND PRÜFUNG
64-Bit-System mit Vulkan-fähiger Grafik und aktuellem Grafiktreiber.
Die Standarddarstellung nutzt Forward+ mit Umgebungsverdeckung und Glühen.
Bei Start- oder Grafikproblemen: Kompatibilitaet.bat (Windows) bzw.
./Kompatibilitaet.sh (Linux) startet mit OpenGL 3.3 ohne diese Lichteffekte.
Linux: mit Godot 4.7.2, NVIDIA RTX 5060 Ti unter Fedora gestartet und geprüft.
Windows: x86-64-Release für Windows 10/11; kein echter Windows-Lauf durchgeführt.
Das eingebettete Windows-Spieldatenpaket wurde unter Linux in Kurzpartie und vollständiger Expedition geprüft.
Die EXE ist nicht signiert. Keine Installation, Online-Anmeldung oder Netzwerkpartie.
CREDITS
Spiel, Fraktionsdesigns und prozedurale 3D-Modelle entstanden für dieses Projekt.
Stein-Albedo: generiert mit dem eingebauten Bildwerkzeug. Holzmaterial: native
4096-Pixel-Bakes aus eigenen periodischen Blender-Materialien.
Schriften: Cantarell, Cinzel und Alegreya, jeweils SIL Open Font License 1.1
(siehe licenses/OFL-Cantarell.txt, OFL-Cinzel.txt, OFL-Alegreya.txt).
Embleme und Menü-Diorama: eigene Vektorgrafiken bzw. prozedurale Blender-Szene.
Icons: generierte Projektillustrationen. Soundeffekte: eigene synthetische Klänge,
keine fremden Samples. Zwanzig spielbare Wächter und zehn Wellengegner besitzen
modellierte Panzer, Borke, Wurzeln, Gelenke und Ornamente. Arbeiter und Kristalle
wurden ebenfalls neu aufgebaut. Ringborke, Funkenleser, Basaltbastion,
Quellgeflecht, Funkenlanze und Glasballiste nutzen gewichtete Skelette;
die anderen Figuren verwenden weiterhin starre Gelenke.
Engine: Godot, https://godotengine.org – MIT-Lizenz; Lizenztexte im Ordner licenses.
'''
def check_release_inputs(project=PROJECT):
"""Fail before export if versions or explicitly packaged runtime data diverge."""
problems = []
version = re.search(r'^config/version="([^"]+)"$',
(project/'project.godot').read_text(), re.MULTILINE)
if not version or version[1] != VERSION:
problems.append(f'project.godot muss config/version="{VERSION}" enthalten.')
# Discover every literal manifest reference in runtime code, including future phases.
manifests = {'assets/audio/manifest.json', 'assets/icons/manifest.json'}
for script in (project/'scripts').rglob('*.gd'):
manifests.update(re.findall(r'res://(assets/[^"\s]+manifest\.json)', script.read_text()))
manifests.update(path for path, _ in SKELETAL_RELEASE_MODELS.values())
records = {}
for path in sorted(manifests):
try:
document = json.loads((project/path).read_text())
if not isinstance(document, dict):
raise ValueError('JSON-Objekt erwartet')
assets = document.get('assets', [])
if not isinstance(assets, list) or any(not isinstance(asset, dict) for asset in assets):
raise ValueError('assets muss eine Liste von Objekten sein')
records[path] = document
for asset in assets:
resource = asset.get('resource_path', '')
if not isinstance(resource, str) or not resource.startswith('res://') or not (project/resource[6:]).is_file():
problems.append(f'{path}: fehlende Ressource {resource!r}.')
except (OSError, ValueError, AttributeError, TypeError) as error:
problems.append(f'{path}: ungültiges oder fehlendes Manifest ({error}).')
for asset_id, (manifest, resource) in SKELETAL_RELEASE_MODELS.items():
entries = records.get(manifest, {}).get('assets', [])
matches = [entry for entry in entries if isinstance(entry, dict) and entry.get('id') == asset_id]
if len(matches) != 1 or matches[0].get('resource_path') != resource or not matches[0].get('skeletal'):
problems.append(f'{manifest}: aktuelles Skelettmodell {asset_id} fehlt.')
continue
model = project/resource[6:]
if model.is_file() and hashlib.sha256(model.read_bytes()).hexdigest() != matches[0].get('sha256'):
problems.append(f'{manifest}: SHA-256 für {asset_id} stimmt nicht mit dem GLB überein.')
presets = configparser.ConfigParser(interpolation=None)
presets.read(project/'export_presets.cfg')
for index, name in enumerate(('Linux', 'Windows')):
section = f'preset.{index}'
if section not in presets or section+'.options' not in presets:
problems.append(f'Export-Preset {name} fehlt.')
continue
preset = presets[section]
if json.loads(preset.get('name', '""')) != name:
problems.append(f'{section}: Name muss {name} sein.')
if json.loads(preset.get('export_filter', '""')) != 'all_resources':
problems.append(f'{name}: Dynamisch geladene Ressourcen benötigen export_filter="all_resources".')
included = {item.strip() for item in json.loads(preset.get('include_filter', '""')).split(',')}
excluded = [item.strip() for item in json.loads(preset.get('exclude_filter', '""')).split(',') if item.strip()]
for path in sorted(manifests):
if path not in included or any(fnmatch.fnmatchcase(path, pattern) for pattern in excluded):
problems.append(f'{name}: Laufzeitmanifest {path} wird nicht explizit exportiert.')
for asset_id, (_, resource) in SKELETAL_RELEASE_MODELS.items():
if any(fnmatch.fnmatchcase(resource[6:], pattern) for pattern in excluded):
problems.append(f'{name}: Skelettmodell {asset_id} ist vom Export ausgeschlossen.')
options = presets[section+'.options']
for key in ('application/file_version', 'application/product_version'):
if json.loads(options.get(key, '""')) != VERSION+'.0':
problems.append(f'{name}: {key} muss {VERSION}.0 sein.')
if problems:
raise SystemExit('Release-Prüfung fehlgeschlagen:\n- ' + '\n- '.join(problems))
print(f'RELEASE_INPUTS_PASS: Version {VERSION}, {len(manifests)} Manifeste, {len(SKELETAL_RELEASE_MODELS)} Skelettmodelle.', flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check-only', action='store_true', help='Versionen und Manifeste ohne Export prüfen.')
args = parser.parse_args()
check_release_inputs()
if args.check_only:
return
godot = shutil.which('godot') or shutil.which('godot4')
if not godot:
raise SystemExit('Godot 4.7.2 ist zum Erstellen erforderlich.')
template_dir = PROJECT/'assets-source/phase6/export-templates'
if not all((template_dir/p).exists() for p in ['linux_release.x86_64','windows_release_x86_64.exe']):
raise SystemExit('Offizielle 4.7.2-Templates fehlen. Siehe docs/phase6-builds.md.')
records=[]
for name, platform, filename in [('Linux','linux','Saumwacht.x86_64'),('Windows','windows','Saumwacht.exe')]:
out = DIST/platform
out.mkdir(parents=True,exist_ok=True)
log = DIST/(platform+'-export.log')
with log.open('w') as stream:
subprocess.run([godot,'--headless','--path',str(PROJECT),'--export-release',name],stdout=stream,stderr=subprocess.STDOUT,check=True)
exe=out/filename
if not exe.is_file() or exe.stat().st_size < 1000000:
raise RuntimeError('Fehlender oder ungültiger Export: '+str(exe))
start='Saumwacht.exe doppelt anklicken.' if platform=='windows' else './Saumwacht.x86_64 starten (alternativ ./Spielen.sh).'
(out/'LIESMICH.txt').write_text(INTRO.format(start=start,version=VERSION),encoding='utf-8')
if platform=='linux':
exe.chmod(0o755)
launcher=out/'Spielen.sh'
launcher.write_text('#!/usr/bin/env bash\nset -e\ncd -- "$(dirname -- "${BASH_SOURCE[0]}")"\nexec ./Saumwacht.x86_64 "$@"\n')
launcher.chmod(0o755)
fallback=out/'Kompatibilitaet.sh'
fallback.write_text('#!/usr/bin/env bash\nset -e\ncd -- "$(dirname -- "${BASH_SOURCE[0]}")"\nexec ./Saumwacht.x86_64 --rendering-method gl_compatibility "$@"\n')
fallback.chmod(0o755)
else:
(out/'Kompatibilitaet.bat').write_bytes(b'@echo off\r\ncd /d "%~dp0"\r\nSaumwacht.exe --rendering-method gl_compatibility %*\r\n')
license_source=PROJECT/'assets-source/phase6/licenses'
shutil.copytree(license_source,out/'licenses',dirs_exist_ok=True)
for font_license in ('OFL-Cantarell.txt', 'OFL-Cinzel.txt', 'OFL-Alegreya.txt'):
shutil.copy2(PROJECT/'assets/fonts'/font_license, out/'licenses'/font_license)
archive=DIST/f'Saumwacht-{name}-x64-{VERSION}.zip'
with zipfile.ZipFile(archive,'w',zipfile.ZIP_DEFLATED,compresslevel=6) as z:
for file in sorted(out.rglob('*')):
if file.is_file():
z.write(file, f'Saumwacht-{name}/'+str(file.relative_to(out)))
records.append({'platform':name,'executable':str(exe.relative_to(DIST)), 'exe_bytes':exe.stat().st_size,
'archive':archive.name,'archive_bytes':archive.stat().st_size,
'sha256':hashlib.sha256(archive.read_bytes()).hexdigest(),
'exe_sha256':hashlib.sha256(exe.read_bytes()).hexdigest()})
print(f'{name}: {archive.name} ({archive.stat().st_size/1024/1024:.1f} MiB)',flush=True)
(DIST/'build-manifest.json').write_text(json.dumps({'version':VERSION,'engine':'4.7.2.stable','builds':records},indent=2)+'\n')
(DIST/'SHA256SUMS.txt').write_text(''.join(r['sha256']+' '+r['archive']+'\n' for r in records))
if __name__=='__main__':
main()