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:
parent
1d9e0a6532
commit
1cc8aa9b9d
507 changed files with 12991 additions and 579 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
228
scripts/tools/generate_voice_dialogs.py
Normal file
228
scripts/tools/generate_voice_dialogs.py
Normal 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())
|
||||
312
scripts/tools/organize_3d_models.py
Normal file
312
scripts/tools/organize_3d_models.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue