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>
227 lines
14 KiB
Python
227 lines
14 KiB
Python
#!/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()
|