Add village 3D wiring, voice lines, and 1.74 m world scale.

Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-09-15 12:02:57 +02:00
parent 1d9e0a6532
commit 1cc8aa9b9d
507 changed files with 12991 additions and 579 deletions

View file

@ -421,6 +421,10 @@ def main() -> None:
write_png(sprites / "burger.png", 16, 16, solid(180, 120, 40))
write_png(sprites / "disturbed.png", 16, 16, face(solid(180, 180, 180)))
write_png(sprites / "shoe.png", 16, 16, solid(90, 50, 20))
write_png(sprites / "reif.png", 16, 16, face(solid(120, 80, 40)))
write_png(sprites / "mark.png", 16, 16, face(solid(100, 70, 35)))
write_png(sprites / "stick.png", 16, 16, solid(90, 55, 20))
write_png(sprites / "tiramisu.png", 16, 16, solid(90, 55, 30))
write_png(sprites / "glitzo.png", 16, 16, checker((255, 0, 255, 255), (0, 255, 255, 255), 2))
write_png(sprites / "bear.png", 16, 16, solid(240, 240, 255))
write_png(sprites / "snake.png", 16, 16, solid(40, 180, 60))
@ -435,6 +439,35 @@ def main() -> None:
write_png(sprites / "house.png", 16, 16, solid(140, 90, 50))
write_png(sprites / "skull.png", 16, 16, solid(240, 240, 240))
write_png(sprites / "quirxel.png", 8, 8, solid(255, 255, 0))
# DESIGN inventory / props (stubs — do not clobber large user art in repo by hand)
for name, rgb in {
"tooth.png": (250, 250, 245),
"milk.png": (245, 245, 250),
"banana.png": (240, 210, 40),
"wartchen.png": (220, 80, 140),
"sunglasses.png": (20, 20, 20),
"death_ticket.png": (180, 160, 80),
"moon_sword.png": (180, 200, 255),
"chicken.png": (240, 220, 180),
"pig.png": (240, 160, 180),
"lava_chicken.png": (220, 60, 20),
"wedding_egg.png": (255, 240, 200),
"dressler_apple.png": (180, 20, 20),
"burger_cham.png": (180, 100, 40),
"snake_mower.png": (40, 180, 60),
"mower.png": (100, 100, 110),
"cheese_seller.png": (240, 210, 80),
"glitschkoenig.png": (255, 0, 255),
"button.png": (200, 40, 40),
"table.png": (120, 80, 40),
"dentist.png": (220, 220, 230),
"shorts_stim.png": (80, 200, 120),
"hammer.png": (140, 100, 60),
"whiff.png": (120, 200, 255),
"arrow_left.png": (255, 220, 40),
"moon_researcher.png": (100, 100, 140),
}.items():
write_png(sprites / name, 16, 16, solid(*rgb))
write_tone_wav(audio / "music_title.wav", 220.0, 1.2, 0.45)
write_tone_wav(audio / "sfx_explosion.wav", 60.0, 0.5, 0.5)
@ -489,7 +522,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -10)
[node name="Reif" parent="." instance=ExtResource("5")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -2)
max_hp = 5
texture_path = "res://assets/sprites/placeholders/shoe.png"
texture_path = "res://assets/sprites/placeholders/reif.png"
defeat_flag = "reif_defeated"
achievement_on_defeat = "dressler_apples"

View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Generate high-quality Piper TTS for dialog_* / lang_voice_* from locales.
Uses large/high Piper models from data/voice_cast.json.
Until real voice takes exist, run --watch to auto-rebuild when scripts change.
Usage:
python3 scripts/tools/generate_voice_dialogs.py --force
python3 scripts/tools/generate_voice_dialogs.py --watch
python3 scripts/tools/generate_voice_dialogs.py --lang de --force
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CAST_PATH = ROOT / "data" / "voice_cast.json"
LOCALES = ROOT / "data" / "locales"
OUT_ROOT = ROOT / "assets" / "audio" / "voice"
MANIFEST = OUT_ROOT / "_manifest.json"
PIPER = os.path.expanduser("~/.local/bin/piper")
DEFAULT_VOICES = Path(os.path.expanduser("~/.local/share/piper/voices"))
STAGE_DIR = re.compile(r"\*[^*]+\*")
EMOJI_ETC = re.compile(r"[⬜]+")
def clean_tts(text: str) -> str:
text = STAGE_DIR.sub(" ", text)
text = EMOJI_ETC.sub(" ", text)
text = text.replace("…", "...")
text = text.replace("—", " - ")
text = text.replace("–", " - ")
text = re.sub(r"\s+", " ", text).strip()
return text
def load_cast() -> dict:
return json.loads(CAST_PATH.read_text(encoding="utf-8"))
def voices_dir(cast: dict) -> Path:
return Path(os.path.expanduser(cast.get("voices_dir", str(DEFAULT_VOICES))))
def resolve_model(cast: dict, model_key: str) -> Path:
models = cast["models"]
if model_key not in models:
raise KeyError(f"unknown model key {model_key}")
path = voices_dir(cast) / models[model_key]
return path
def npc_params(cast: dict, npc_id: str, lang: str) -> dict:
npc = cast["npcs"][npc_id]
# New format: per-lang dict; legacy: flat speaker ints
entry = npc.get(lang, npc)
if isinstance(entry, dict) and ("model" in entry or "speaker" in entry):
params = dict(entry)
else:
defaults = cast.get("lang_defaults", {})
params = {
"model": defaults.get(lang, "de_high"),
"speaker": int(entry) if not isinstance(entry, dict) else 0,
"length_scale": float(npc.get("length_scale", 1.0)),
}
params.setdefault("speaker", 0)
params.setdefault("length_scale", 1.0)
params.setdefault("noise_scale", 0.667)
params.setdefault("noise_w", 0.8)
params.setdefault("sentence_silence", 0.35)
return params
def fingerprint(text: str, params: dict, model_path: Path) -> str:
payload = {
"text": text,
"model": model_path.name,
"speaker": int(params.get("speaker", 0)),
"length_scale": float(params.get("length_scale", 1.0)),
"noise_scale": float(params.get("noise_scale", 0.667)),
"noise_w": float(params.get("noise_w", 0.8)),
"sentence_silence": float(params.get("sentence_silence", 0.35)),
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def synthesize(model: Path, text: str, out_wav: Path, params: dict) -> None:
out_wav.parent.mkdir(parents=True, exist_ok=True)
tmp = out_wav.with_suffix(".tmp.wav")
cmd = [
PIPER,
"-m", str(model),
"-s", str(int(params.get("speaker", 0))),
"--length_scale", str(float(params.get("length_scale", 1.0))),
"--noise_scale", str(float(params.get("noise_scale", 0.667))),
"--noise_w", str(float(params.get("noise_w", 0.8))),
"--sentence_silence", str(float(params.get("sentence_silence", 0.35))),
"-f", str(tmp),
]
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size < 44:
err = proc.stderr.decode("utf-8", errors="replace")
if tmp.exists():
tmp.unlink(missing_ok=True)
raise RuntimeError(f"piper failed for {out_wav.name}: {err[-500:]}")
tmp.replace(out_wav)
def load_manifest() -> dict:
if MANIFEST.exists():
try:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
return {}
def save_manifest(data: dict) -> None:
OUT_ROOT.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def generate(force: bool = False, langs: list[str] | None = None) -> tuple[int, int]:
if not Path(PIPER).exists():
raise SystemExit(f"piper not found at {PIPER}")
cast = load_cast()
lines = cast["lines"]
all_langs = langs or ["de", "en", "by"]
locale_data = {}
for lang in all_langs:
locale_data[lang] = json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8"))
manifest = {} if force else load_manifest()
done = 0
skipped = 0
for lang in all_langs:
print(f"== {lang}", flush=True)
for key, npc_id in lines.items():
text_raw = locale_data[lang].get(key)
if not text_raw:
print(f" skip missing {key}")
skipped += 1
continue
tts = clean_tts(str(text_raw))
if not tts:
skipped += 1
continue
params = npc_params(cast, npc_id, lang)
model_path = resolve_model(cast, str(params["model"]))
if not model_path.exists():
raise SystemExit(f"MISSING model {model_path}")
fp = fingerprint(tts, params, model_path)
out = OUT_ROOT / lang / f"{key}.wav"
mkey = f"{lang}/{key}.wav"
if (
not force
and out.exists()
and out.stat().st_size > 1000
and manifest.get(mkey) == fp
):
skipped += 1
continue
print(f" {key} npc={npc_id} model={model_path.name} spk={params.get('speaker')}")
synthesize(model_path, tts, out, params)
manifest[mkey] = fp
done += 1
save_manifest(manifest)
print(f"generated={done} skipped={skipped} out={OUT_ROOT}", flush=True)
return done, skipped
def watch_sources_mtime() -> float:
mtimes = [CAST_PATH.stat().st_mtime]
for p in LOCALES.glob("*.json"):
mtimes.append(p.stat().st_mtime)
return max(mtimes)
def watch_loop(interval: float = 2.0) -> int:
print(f"watching {LOCALES} + {CAST_PATH} (interval={interval}s) — Ctrl+C to stop", flush=True)
print("placeholder TTS only; replace WAVs later with real takes (same paths).", flush=True)
last = 0.0
# initial sync
generate(force=False)
last = watch_sources_mtime()
try:
while True:
time.sleep(interval)
now = watch_sources_mtime()
if now > last:
print("\n[watch] locale/cast changed — regenerating…")
generate(force=False)
last = watch_sources_mtime()
except KeyboardInterrupt:
print("\n[watch] stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--force", action="store_true", help="Rebuild all WAVs")
ap.add_argument("--lang", action="append", help="Only these langs (de/en/by)")
ap.add_argument("--watch", action="store_true", help="Auto-regenerate when locales/cast change")
ap.add_argument("--interval", type=float, default=2.0, help="Watch poll seconds")
args = ap.parse_args()
if args.watch:
return watch_loop(args.interval)
generate(force=args.force, langs=args.lang)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Auto-organize GLBs dropped into assets/3DModelle/.
Drop/rename freely in the folder root or `_inbox/`. After files sit unchanged
for `--stable-secs` (default 45), they get classified into props/houses/animals,
related textures move along, and `.import` source paths are fixed.
Usage:
python3 scripts/tools/organize_3d_models.py --watch
python3 scripts/tools/organize_3d_models.py --force --import
python3 scripts/tools/organize_3d_models.py --dry-run
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODELS = ROOT / "assets" / "3DModelle"
INBOX = MODELS / "_inbox"
ANIMAL_WORDS = (
"cat", "chimp", "chimpanzee", "ape", "gorilla", "giraffe", "snail",
"spider", "cow", "pig", "bear", "dog", "chicken", "rooster", "snake",
"horse", "sheep", "goat", "duck", "fish", "bird", "monkey",
)
def log(msg: str) -> None:
print(msg, flush=True)
def classify(glb_filename: str) -> tuple[Path, str]:
"""Return (dest_dir relative to MODELS, target_filename)."""
stem = Path(glb_filename).stem
low = stem.lower()
if "massour" in low or "masseur" in low:
return Path("houses/massour"), "massourhaus.glb"
if "broken" in low and ("house" in low or "haus" in low or low == "brokenhouse"):
return Path("houses/broken"), "BrokenHouse.glb"
if "house" in low or "haus" in low:
# house_b / house-c / houseB
m = re.search(r"house[_\-]?([a-z0-9]{1,4})$", low)
if m and m.group(1) not in ("glb", "pbr"):
folder = f"house_{m.group(1)}"
return Path("houses") / folder, f"{folder}.glb"
if re.fullmatch(r"house", low) or low.startswith("house_texture"):
return Path("houses/house"), "house.glb"
# unknown house-like → own folder from cleaned stem
clean = re.sub(r"[^a-z0-9_]+", "_", low).strip("_")
return Path("houses") / clean, f"{clean}.glb"
for key, folder, fname in (
("button", "button", "Button.glb"),
("knopf", "button", "Button.glb"),
("table", "table", "table.glb"),
("tisch", "table", "table.glb"),
("door", "door", "door.glb"),
("stuhl", "chair", "chair.glb"),
("chair", "chair", "chair.glb"),
):
if key in low:
return Path("props") / folder, fname
for w in ANIMAL_WORDS:
if w in low:
folder = {
"chimpanzee": "chimp",
"ape": "chimp",
"monkey": "chimp",
}.get(w, w)
# prefer explicit chimp/gorilla in name
if "gorilla" in low:
folder = "gorilla"
elif "chimp" in low or "chimpanzee" in low:
folder = "chimp"
# cow/ → Masseur only (village skips this folder)
return Path("animals") / folder, f"{folder}.glb"
# vendor-long name → last alpha token
clean = stem
if len(stem) > 24 and ("-" in stem or "_" in stem):
parts = re.split(r"[-_]+", stem)
for part in reversed(parts):
if part.isalpha() and len(part) >= 3:
clean = part.lower()
break
clean = re.sub(r"[^a-zA-Z0-9_]+", "_", clean).strip("_") or "model"
return Path("props") / clean.lower(), f"{clean}.glb"
def related_files(glb: Path) -> list[Path]:
parent = glb.parent
stem = glb.stem
prefixes = {stem, stem.lower()}
# base house textures: house_Fence, house_texture…
if stem.lower() == "house":
prefixes.add("house_")
out: list[Path] = []
for p in parent.iterdir():
if not p.is_file() or p == glb:
continue
if p.suffix.lower() == ".glb":
continue
if p.name == glb.name + ".import":
out.append(p)
continue
low = p.name.lower()
for pref in prefixes:
pl = pref.lower()
if low.startswith(pl):
# don't steal house_b_* for house.glb
if pl == "house_" and low.startswith("house_b"):
continue
out.append(p)
break
return out
def fix_import_paths(folder: Path) -> None:
for imp in folder.rglob("*.import"):
src = Path(str(imp)[:-7])
if not src.exists():
continue
rel = "res://" + str(src.relative_to(ROOT)).replace("\\", "/")
text = imp.read_text(encoding="utf-8")
lines: list[str] = []
changed = False
for line in text.splitlines(True):
if line.startswith("source_file="):
new = f'source_file="{rel}"\n'
changed = changed or line != new
lines.append(new)
else:
lines.append(line)
if changed:
imp.write_text("".join(lines), encoding="utf-8")
def move_into(src: Path, dest_dir: Path, dest_name: str | None = None) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / (dest_name or src.name)
if dest.exists() and src.resolve() == dest.resolve():
return dest
if dest.exists():
log(f" keep existing {dest.relative_to(MODELS)}")
if src.exists() and src.resolve() != dest.resolve():
# leave duplicate source — user can delete
log(f" (source still at {src.relative_to(MODELS)})")
return dest
shutil.move(str(src), str(dest))
return dest
def organize_glb(glb: Path, dry_run: bool = False) -> Path | None:
rel_dir, target_name = classify(glb.name)
dest_dir = MODELS / rel_dir
log(f"organize {glb.name} → {rel_dir}/{target_name}")
if dry_run:
return dest_dir / target_name
extras = related_files(glb)
dest_glb = move_into(glb, dest_dir, target_name)
# imports / textures that were next to old location
for extra in extras:
if not extra.exists():
continue
if extra.name.endswith(".import") and extra.name.startswith(Path(glb.name).stem):
# rename import to match new glb name if needed
if extra.name == glb.name + ".import" and target_name != glb.name:
move_into(extra, dest_dir, target_name + ".import")
continue
move_into(extra, dest_dir)
# stray import at old path
old_imp = Path(str(glb) + ".import")
if old_imp.exists():
move_into(old_imp, dest_dir, target_name + ".import")
fix_import_paths(dest_dir)
return dest_glb
def root_glbs() -> list[Path]:
out: list[Path] = []
for base in (MODELS, INBOX):
if not base.is_dir():
continue
for p in base.iterdir():
if p.is_file() and p.suffix.lower() == ".glb":
out.append(p)
return out
def bundle_mtime(glb: Path) -> float:
mt = glb.stat().st_mtime
for p in related_files(glb):
try:
mt = max(mt, p.stat().st_mtime)
except OSError:
pass
return mt
def organize_orphans(dry_run: bool = False) -> int:
"""Loose textures at MODELS root → matching model folder or props/_loose."""
n = 0
for p in list(MODELS.iterdir()):
if not p.is_file():
continue
if p.suffix.lower() == ".glb" or p.name.endswith(".import") or p.name == "README.md":
continue
low = p.name.lower()
target: Path | None = None
for glb in MODELS.rglob("*.glb"):
stem = glb.stem.lower()
if low.startswith(stem):
if stem == "house" and low.startswith("house_b"):
continue
target = glb.parent
break
if target is None:
target = MODELS / "props" / "_loose"
log(f"orphan {p.name} → {target.relative_to(MODELS)}")
if not dry_run:
move_into(p, target)
imp = MODELS / (p.name + ".import")
if imp.exists():
move_into(imp, target)
fix_import_paths(target)
n += 1
return n
def organize_stable(stable_secs: float, dry_run: bool = False) -> int:
now = time.time()
n = 0
for glb in root_glbs():
age = now - bundle_mtime(glb)
if age < stable_secs:
log(f"wait {glb.name} ({stable_secs - age:.0f}s left)")
continue
organize_glb(glb, dry_run=dry_run)
n += 1
n += organize_orphans(dry_run=dry_run)
return n
def run_godot_import() -> None:
godot = Path(os.path.expanduser("~/.local/bin/godot"))
if not godot.exists():
which = shutil.which("godot")
godot = Path(which) if which else Path()
if not godot.exists():
log("godot not found — skip import")
return
log("godot --import …")
subprocess.run(
[str(godot), "--headless", "--path", str(ROOT), "--import"],
check=False,
)
def watch_loop(stable_secs: float, interval: float, do_import: bool) -> int:
INBOX.mkdir(parents=True, exist_ok=True)
log(f"watching {MODELS} + _inbox/ — organize after {stable_secs:.0f}s idle")
try:
while True:
n = organize_stable(stable_secs, dry_run=False)
if n > 0 and do_import:
run_godot_import()
time.sleep(interval)
except KeyboardInterrupt:
log("stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--watch", action="store_true")
ap.add_argument("--stable-secs", type=float, default=45.0)
ap.add_argument("--interval", type=float, default=5.0)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--import", dest="do_import", action="store_true")
ap.add_argument("--force", action="store_true", help="Ignore debounce (stable-secs=0)")
args = ap.parse_args()
if not MODELS.is_dir():
log(f"missing {MODELS}")
return 1
stable = 0.0 if args.force else args.stable_secs
if args.watch:
# watch always uses configured debounce (force only for one-shot)
return watch_loop(args.stable_secs, args.interval, args.do_import)
n = organize_stable(stable, dry_run=args.dry_run)
log(f"organized={n}")
if n > 0 and args.do_import and not args.dry_run:
run_godot_import()
return 0
if __name__ == "__main__":
raise SystemExit(main())