Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04679fa897 | ||
|
|
331caf417a | ||
|
|
f51f4e83fd | ||
|
|
737a297436 | ||
|
|
b72b90c50d | ||
|
|
fc3494466e | ||
|
|
937fbd6ede | ||
|
|
307b52ae1f | ||
|
|
6e84fe9b1b | ||
|
|
236a643d04 | ||
|
|
247f7ef9fe |
@@ -18,6 +18,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -410,6 +416,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -496,12 +511,32 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -967,6 +1002,26 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "29.0.3"
|
||||
@@ -1436,6 +1491,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1792,6 +1860,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.1.1"
|
||||
@@ -2321,6 +2395,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bladeink",
|
||||
"bytemuck",
|
||||
"png",
|
||||
"pollster",
|
||||
"serde_json",
|
||||
"tinyaudio",
|
||||
@@ -2986,6 +3061,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
bladeink = "1.2.5"
|
||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||
png = "0.18.1"
|
||||
pollster = "0.4.0"
|
||||
serde_json = "1"
|
||||
tinyaudio = { version = "2", default-features = false, features = ["pulse"] }
|
||||
|
||||
@@ -15,3 +15,10 @@ Toolchain (Rust ≥ 1.85). Das `cargo` aus den Distributions-Paketquellen
|
||||
- Anwendung bauen und ausführen: `cargo run`
|
||||
|
||||
Alternativ ohne Fenster/GPU in der Konsole: `cargo run -- --cli`
|
||||
|
||||
Ein anderes Welt-Verzeichnis laden (Default `assets/maps/props/`):
|
||||
`cargo run -- --maps <ordner>` (Pfad relativ zum aktuellen Verzeichnis).
|
||||
|
||||
Assets (Ink-Dialoge, Blender-Welten) baut `cargo build` bei Bedarf selbst,
|
||||
sofern die Werkzeuge da sind (`inklecate` liegt bei, Blender über `$BLENDER`
|
||||
oder `PATH`). Ohne sie werden die eingecheckten `.ink.json` / `.glb` benutzt.
|
||||
|
||||
@@ -5,15 +5,22 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
|
||||
|
||||
## 3D / Blender
|
||||
|
||||
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
|
||||
dort werden beim Start geladen.
|
||||
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
|
||||
keine der Properties unten an!).
|
||||
- **Ablage:** die `.blend` in ihren Map-Ordner legen —
|
||||
`assets/maps/<map>/<name>.blend`. `cargo build` (oder
|
||||
`./tools/build-models.sh`) exportiert sie zu `<name>.glb` daneben; von
|
||||
Hand exportieren musst du nichts. Die Welt sind alle `.glb` unter
|
||||
`assets/maps/props/`; ein anderer Ordner per `cargo run -- --maps <dir>`.
|
||||
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
|
||||
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
|
||||
- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`.
|
||||
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
|
||||
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
|
||||
- **Texturen:** Bild im Material als Base Color zuweisen (PNG) — es wird
|
||||
beim Export in die `.glb` eingebettet. Damit die Datei self-contained
|
||||
bleibt: „File → External Data → Pack Resources". Fehlt das Bild,
|
||||
erscheint die Platzhalter-Textur.
|
||||
- **Kacheln / Skalieren:** über einen **Mapping-Node** vor dem
|
||||
Image-Texture-Node (Location/Scale). Die Engine backt das
|
||||
(`KHR_texture_transform`) beim Laden in die UVs ein.
|
||||
- **Custom Properties** (unten) kommen automatisch mit; Modifier werden
|
||||
beim Export angewandt.
|
||||
|
||||
### Custom Properties auf Mesh-Objekten
|
||||
|
||||
@@ -67,8 +74,10 @@ Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
|
||||
|
||||
## Testen
|
||||
|
||||
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
|
||||
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
|
||||
- `cargo run` — baut geänderte `.blend` neu und zeigt beim Start, was
|
||||
ankam: Objekte, Dreiecke, Empties und **jede Custom Property**. Fehlt
|
||||
eine, sitzt sie in Blender nicht am Objekt (Object Properties → Custom
|
||||
Properties), nicht am Mesh-Data.
|
||||
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
|
||||
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
|
||||
Signal von Hand, `reload` lädt signals.toml neu.
|
||||
|
||||
@@ -9,44 +9,14 @@
|
||||
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
|
||||
# clear <name> KV-Eintrag entfernen
|
||||
# play_sound <name> deferred (Frontend): SFX aus assets/sounds/{name}.wav
|
||||
# mode <play|free|menu> deferred (Frontend): Anzeige-/Eingabemodus
|
||||
# post <name> <on|off> deferred (Frontend): Renderer-Post-Effekt schalten
|
||||
#
|
||||
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
|
||||
# idealer Ort für KV-Defaults.
|
||||
|
||||
[init]
|
||||
actions = [
|
||||
"set cube_clicks 0",
|
||||
"set mushrooms_collected 0",
|
||||
"set quest_mushroom_active false",
|
||||
"set quest_mushroom_done false",
|
||||
]
|
||||
|
||||
[Mushroom]
|
||||
actions = [
|
||||
"inc mushrooms_collected",
|
||||
"play_sound pickup",
|
||||
]
|
||||
|
||||
[Person]
|
||||
actions = [
|
||||
"play_sound greet",
|
||||
"start_ink person.ink.json",
|
||||
]
|
||||
|
||||
[Suzanne]
|
||||
actions = [
|
||||
"start_ink suzanne.ink.json",
|
||||
]
|
||||
|
||||
[Cube]
|
||||
actions = [
|
||||
"inc cube_clicks",
|
||||
"start_ink cube.ink.json",
|
||||
]
|
||||
|
||||
[person_farewell]
|
||||
actions = [
|
||||
"debug_log Person hat sich verabschiedet",
|
||||
]
|
||||
|
||||
[tiffany]
|
||||
|
||||
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,59 +1,82 @@
|
||||
//! Build-Hook: kompiliert geänderte Ink-Dialoge automatisch beim Cargo-Build.
|
||||
//! Build-Hook: baut geänderte Assets automatisch beim Cargo-Build.
|
||||
//!
|
||||
//! Reines „Wann"-Stück — das eigentliche Bauen macht `tools/build-dialogues.sh`
|
||||
//! (und bleibt von Hand aufrufbar). Hier melden wir Cargo nur, wann es uns
|
||||
//! erneut ausführen soll, und stoßen das Skript an.
|
||||
//! Reines „Wann"-Stück — das eigentliche Bauen machen die Skripte unter
|
||||
//! `tools/` (und bleiben von Hand aufrufbar). Hier melden wir Cargo nur, wann
|
||||
//! es uns erneut ausführen soll, und stoßen die Skripte an:
|
||||
//!
|
||||
//! * `build-dialogues.sh` — `.ink` → `.ink.json` (inklecate)
|
||||
//! * `build-models.sh` — `assets/maps/**/*.blend` → `.glb` (Blender)
|
||||
//!
|
||||
//! Cargo führt build.rs ausschließlich dann erneut aus, wenn eine der unten
|
||||
//! via `rerun-if-changed` gemeldeten Dateien sich ändert. Unveränderte
|
||||
//! Rebuilds kosten also nichts; nur nach einer `.ink`-Änderung läuft
|
||||
//! inklecate (inkrementell nur für die betroffene Datei).
|
||||
//! Rebuilds kosten also nichts; nur nach einer Quell-Änderung läuft das
|
||||
//! jeweilige Werkzeug (beide Skripte bauen intern inkrementell).
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let root = env!("CARGO_MANIFEST_DIR");
|
||||
let script = Path::new(root).join("tools/build-dialogues.sh");
|
||||
let ink_dir = Path::new(root).join("assets/interactions");
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// Wann soll Cargo build.rs erneut ausführen?
|
||||
// --- Ink-Dialoge ------------------------------------------------------
|
||||
println!("cargo:rerun-if-changed=tools/build-dialogues.sh");
|
||||
println!("cargo:rerun-if-changed=tools/inklecate");
|
||||
// Verzeichnis-mtime fängt neu hinzugekommene/gelöschte .ink-Dateien.
|
||||
println!("cargo:rerun-if-changed=assets/interactions");
|
||||
// Inhaltsänderungen einzelner .ink-Dateien einzeln melden.
|
||||
if let Ok(entries) = std::fs::read_dir(&ink_dir) {
|
||||
watch_ext(&root.join("assets/interactions"), "ink");
|
||||
run_script(&root, "tools/build-dialogues.sh", "Dialog");
|
||||
|
||||
// --- Blender-Welten -------------------------------------------------
|
||||
println!("cargo:rerun-if-changed=tools/build-models.sh");
|
||||
// Ordner-mtime fängt neue/gelöschte Maps; die .blend selbst einzeln.
|
||||
// Die erzeugten .glb werden NICHT beobachtet (das Skript schreibt sie).
|
||||
watch_dirs_and_ext(&root.join("assets/maps"), "blend");
|
||||
run_script(&root, "tools/build-models.sh", "Modell");
|
||||
}
|
||||
|
||||
/// Jede Datei mit Endung `ext` direkt in `dir` einzeln bei Cargo anmelden.
|
||||
fn watch_ext(dir: &Path, ext: &str) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|s| s.to_str()) == Some("ink") {
|
||||
if p.extension().and_then(|s| s.to_str()) == Some(ext) {
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skript ausführen. Ausgabe einsammeln, damit ein Dialog-Fehler sichtbar
|
||||
// wird (Cargo blendet Build-Script-stdout sonst aus).
|
||||
/// Wie [`watch_ext`], aber rekursiv — und jedes Unterverzeichnis wird selbst
|
||||
/// beobachtet (fängt neu angelegte/entfernte Maps).
|
||||
fn watch_dirs_and_ext(dir: &Path, ext: &str) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
println!("cargo:rerun-if-changed={}", dir.display());
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
watch_dirs_and_ext(&p, ext);
|
||||
} else if p.extension().and_then(|s| s.to_str()) == Some(ext) {
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein `tools/`-Skript laufen lassen. Ausgabe einsammeln (Cargo blendet
|
||||
/// Build-Script-stdout sonst aus); Fehlschlag ist fatal, ein nicht startbares
|
||||
/// bash nur eine Warnung — die committeten Artefakte reichen dann.
|
||||
fn run_script(root: &Path, rel: &str, label: &str) {
|
||||
let script = root.join(rel);
|
||||
match Command::new("bash").arg(&script).output() {
|
||||
Ok(out) if out.status.success() => { /* still bei Erfolg */ }
|
||||
Ok(out) if out.status.success() => { /* still bei Erfolg — Detail via Hand-Aufruf */ }
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
for line in stdout.lines().chain(stderr.lines()) {
|
||||
println!("cargo:warning={line}");
|
||||
}
|
||||
panic!(
|
||||
"Dialog-Build fehlgeschlagen — siehe Warnungen oben. \
|
||||
Einzeln debuggen: ./tools/build-dialogues.sh"
|
||||
);
|
||||
panic!("{label}-Build fehlgeschlagen — siehe Warnungen oben. Einzeln debuggen: ./{rel}");
|
||||
}
|
||||
Err(e) => {
|
||||
// bash/Skript nicht startbar (z.B. fremdes OS): den Build nicht
|
||||
// blockieren, nur warnen — die committeten .ink.json reichen.
|
||||
println!(
|
||||
"cargo:warning=Dialog-Build übersprungen ({e}). \
|
||||
Bei Bedarf von Hand: ./tools/build-dialogues.sh"
|
||||
);
|
||||
println!("cargo:warning={label}-Build übersprungen ({e}). Bei Bedarf von Hand: ./{rel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
Alles Inhaltliche kommt aus vier Quellen:
|
||||
**Blender** (Welt),
|
||||
**Ink** (Dialoge/Story),
|
||||
**signals.toml** (Verdrahtung),
|
||||
**Assets-Ordner** (Texturen/Sounds). Fehler in Inhalten stürzen nie ab — sie werden beim
|
||||
|
||||
Start in der Konsole gemeldet (`[model]`, `[audio]`, `[tex]`, …).
|
||||
|
||||
## 3D / Blender
|
||||
|
||||
- **Format:** glTF Binary (`.glb`) nach `assets/maps/props/` — alle Dateien
|
||||
dort werden beim Start geladen.
|
||||
- **Beim Export anhaken:** „Include → Custom Properties" (sonst kommen
|
||||
keine der Properties unten an!).
|
||||
- **Maßstab:** 1 Blender-Meter = 1 Engine-Meter. Augenhöhe 1,6 m,
|
||||
Türen/Durchgänge ≥ 2 m hoch und ≥ 0,8 m breit denken.
|
||||
- **Material-Name = Textur-Name:** Material `holz` → `assets/textures/holz.tga`.
|
||||
Fehlt die Textur (oder das Material), erscheint die Platzhalter-Textur.
|
||||
- **Texturen:** TGA, unkomprimiert oder RLE, 24/32 bit.
|
||||
|
||||
### Custom Properties auf Mesh-Objekten
|
||||
|
||||
| Property | Wirkung |
|
||||
|---|---|
|
||||
| *(keine)* | nur sichtbar, kein Anfassen |
|
||||
| `collide = 1` | sichtbar **und** solide (Spieler kollidiert mit der konvexen Hülle des Meshes — Dellen/Löcher werden überspannt) |
|
||||
| `collide = proxy` | unsichtbares Kollisionsvolumen (für Fein-Collider neben Sichtgeometrie) |
|
||||
| `signal = <name>` **mit** collide | Klick-Ziel: Anklicken feuert das Signal `<name>` |
|
||||
| `signal = <name>` **ohne** collide | unsichtbare Trigger-Zone: Betreten feuert `<name>` einmal (erneut erst nach Verlassen; „nur einmal überhaupt" selbst per KV-Flag bauen) |
|
||||
|
||||
- `collide = 0` schaltet ab, ohne die Property zu löschen.
|
||||
- Objektname ist der Interact-Key: Blender-Suffixe (`Ding.001`) werden beim
|
||||
Signal-Lookup automatisch gestrippt — `[Ding]` in signals.toml trifft alle.
|
||||
|
||||
### Custom Properties auf Empties
|
||||
|
||||
| Property | Wirkung |
|
||||
|---|---|
|
||||
| `role = spawn` | Spieler-Startpunkt (Fußpunkt = Empty-Position) |
|
||||
| `sound = <name>` | Ambient-Loop `assets/sounds/<name>.wav` an dieser Position |
|
||||
| `radius = <m>` | Hörradius des Sounds (linear leiser bis Stille; Default 8) |
|
||||
|
||||
## Audio
|
||||
|
||||
- **Format:** WAV, **mono, 16 bit, 44100 Hz** — nichts anderes. Ablage in
|
||||
`assets/sounds/`, angesprochen ohne Endung (`pickup` → `pickup.wav`).
|
||||
- **Ambient:** per Empty (siehe oben), loopt immer — Loop-Punkt sauber schneiden.
|
||||
- **Einmal-SFX:** per Action `play_sound <name>` (aus signals.toml oder Ink-Tag).
|
||||
|
||||
## Dialoge / Ink
|
||||
|
||||
- `.ink` nach `assets/interactions/` — wird beim Build automatisch
|
||||
kompiliert (bei Bedarf von Hand: `./tools/build-dialogues.sh`).
|
||||
- Gestartet per Action `start_ink <datei>.ink.json`.
|
||||
- **Variablen:** alle Ink-`VAR` landen automatisch im Spiel-KV und synchen
|
||||
in beide Richtungen — `_`-Prefix (`VAR _tmp = 0`) bleibt Ink-lokal.
|
||||
- **Tags feuern Actions:** `# set has_key true`, `# play_sound klick`,
|
||||
`# mode play` … (gleiche Verben wie signals.toml). Unbekannte Tags sind
|
||||
erlaubt (reine Metadaten).
|
||||
|
||||
## signals.toml (`assets/signals.toml`)
|
||||
|
||||
- Verdrahtet Signal → Aktionen. Signal-Namen: Objektname (Klick), Wert der
|
||||
`signal`-Property (Zonen/Klick-Ziele), oder frei (aus Ink).
|
||||
- Verben: `start_ink`, `set`, `inc`, `clear`, `debug_log`, `play_sound`,
|
||||
`mode play|free|menu`.
|
||||
- `$self` = auslösender Objektname (für generische Einträge wie
|
||||
`set $self true`).
|
||||
- `[init]` feuert einmal beim Start — Ort für KV-Defaults.
|
||||
|
||||
## Testen
|
||||
|
||||
- `cargo run` — Konsole zeigt beim Start, was ankam: Objekte, Dreiecke,
|
||||
Empties und **jede Custom Property**. Fehlt eine → Export-Haken prüfen.
|
||||
- Im Spiel (Konsole = das Terminal, aus dem gestartet wurde): `noclip` zum
|
||||
freien Fliegen, `kv` zeigt alle Variablen, `signal <name>` feuert jedes
|
||||
Signal von Hand, `reload` lädt signals.toml neu.
|
||||
@@ -79,7 +79,7 @@ mod tests {
|
||||
use crate::engine::model::{Empty, Props};
|
||||
|
||||
fn model(empties: Vec<Empty>) -> Model {
|
||||
Model { objects: Vec::new(), empties, materials: Vec::new() }
|
||||
Model { objects: Vec::new(), empties, textures: Vec::new() }
|
||||
}
|
||||
|
||||
fn empty(name: &str, pos: [f32; 3], props: &[(&str, &str)]) -> Empty {
|
||||
|
||||
@@ -380,7 +380,7 @@ mod tests {
|
||||
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
|
||||
],
|
||||
empties: Vec::new(),
|
||||
materials: vec![String::new()],
|
||||
textures: Vec::new(),
|
||||
};
|
||||
let mut w = CollisionWorld::empty();
|
||||
w.add_model(&model);
|
||||
|
||||
@@ -29,6 +29,10 @@ pub enum Action {
|
||||
/// Anzeige-/Eingabemodus wechseln (Spiel, Noclip, Menü). Der Dialog-Modus
|
||||
/// ist bewusst nicht wählbar — er entsteht nur aus dem Story-Ablauf.
|
||||
SetMode(ModeTarget),
|
||||
/// Renderer-Post-Effekt schalten. Der Kern kennt weder die Kette noch die
|
||||
/// Effektnamen — das Fenster löst `name` gegen seine Kette auf (unbekannt
|
||||
/// → Meldung), die CLI reicht nur die `[action]`-Zeile.
|
||||
SetPost { name: String, on: bool },
|
||||
}
|
||||
|
||||
/// Frontend-neutrales Ziel eines Moduswechsels: die nicht-Dialog-Varianten von
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
||||
//! glTF-Binary-Loader (.glb, Blender-Export) → geparstes [`Model`].
|
||||
//!
|
||||
//! Die tragenden Kanäle: **Custom Properties** (in Blender beim Export
|
||||
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||||
@@ -10,14 +10,21 @@
|
||||
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||||
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||||
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken.
|
||||
//! Material-*Name* = Texturname; glTF-eigene Texturen/PBR bleiben ungenutzt.
|
||||
//!
|
||||
//! Texturen: die `baseColorTexture` jedes Materials wird aus dem BIN-Chunk
|
||||
//! geholt und dekodiert (PNG, wie Blender es einbettet) → `model::Texture`.
|
||||
//! `KHR_texture_transform` (Blenders Mapping-Node) wird beim Laden in die UVs
|
||||
//! eingebacken. Der Rest von glTFs PBR-Modell bleibt ungenutzt.
|
||||
//!
|
||||
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||||
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::engine::model::{apply_props, fill_missing_normals, norm, Empty, Model, Object, Props};
|
||||
use crate::engine::image::{self, Image};
|
||||
use crate::engine::model::{apply_props, fill_missing_normals, norm, Empty, Model, Object, Props, Texture};
|
||||
|
||||
pub fn load(path: &str) -> Result<Model, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||||
@@ -52,7 +59,7 @@ pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
|
||||
}
|
||||
|
||||
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||||
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
|
||||
let mut model = Model { objects: Vec::new(), empties: Vec::new(), textures: Vec::new() };
|
||||
|
||||
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
||||
// Welt-Transform wächst dabei Parent → Kind.
|
||||
@@ -62,9 +69,109 @@ fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||||
let Some(i) = r.as_u64() else { continue; };
|
||||
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
||||
}
|
||||
resolve_textures(doc, bin, &mut model);
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Nach dem Walk tragen die `tri_mats` rohe glTF-Material-Indizes
|
||||
/// (`usize::MAX` = kein Material). Daraus die deduplizierte Texturliste bauen
|
||||
/// (erste-Sicht-Reihenfolge) und die `tri_mats` auf ihre Indizes umschreiben.
|
||||
fn resolve_textures(doc: &Value, bin: &[u8], model: &mut Model) {
|
||||
let mut order: Vec<usize> = Vec::new();
|
||||
let mut local: HashMap<usize, usize> = HashMap::new();
|
||||
for o in &model.objects {
|
||||
for &m in &o.tri_mats {
|
||||
local.entry(m).or_insert_with(|| { order.push(m); order.len() - 1 });
|
||||
}
|
||||
}
|
||||
model.textures = order.iter().map(|&raw| build_texture(doc, bin, raw)).collect();
|
||||
for o in &mut model.objects {
|
||||
for m in &mut o.tri_mats { *m = local[m]; }
|
||||
}
|
||||
}
|
||||
|
||||
/// Das `model::Texture` für einen rohen glTF-Material-Index:
|
||||
/// `material → pbrMetallicRoughness.baseColorTexture → texture → image`, Bild
|
||||
/// aus dem BIN-Chunk dekodiert. Fehlt ein Glied, bleibt `image: None` (der
|
||||
/// Konsument nimmt seinen Platzhalter), der Schlüssel wird trotzdem gesetzt.
|
||||
fn build_texture(doc: &Value, bin: &[u8], raw_mat: usize) -> Texture {
|
||||
if raw_mat == usize::MAX {
|
||||
return Texture { key: String::new(), image: None };
|
||||
}
|
||||
let mat = &doc["materials"][raw_mat];
|
||||
let mat_name = mat["name"].as_str().unwrap_or("");
|
||||
let tex_ref = &mat["pbrMetallicRoughness"]["baseColorTexture"];
|
||||
let img_node = tex_ref["index"].as_u64()
|
||||
.and_then(|t| doc["textures"][t as usize]["source"].as_u64())
|
||||
.map(|s| &doc["images"][s as usize]);
|
||||
let Some(img_node) = img_node else {
|
||||
return Texture { key: mat_name.to_string(), image: None };
|
||||
};
|
||||
let key = img_node["name"].as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(mat_name)
|
||||
.to_string();
|
||||
let image = decode_embedded_image(doc, bin, img_node, &key);
|
||||
Texture { key, image }
|
||||
}
|
||||
|
||||
/// Ein `images[]`-Node aus dem BIN-Chunk dekodieren. Externe Dateien und
|
||||
/// Daten-URIs unterstützt der GLB-Pfad nicht — sie werden gemeldet.
|
||||
fn decode_embedded_image(doc: &Value, bin: &[u8], node: &Value, key: &str) -> Option<Image> {
|
||||
let Some(view) = node["bufferView"].as_u64() else {
|
||||
eprintln!("[tex] {key}: Bild ohne bufferView (externe Datei/Daten-URI wird nicht gelesen)");
|
||||
return None;
|
||||
};
|
||||
let bytes = match buffer_view_bytes(doc, bin, view as usize) {
|
||||
Ok(b) => b,
|
||||
Err(e) => { eprintln!("[tex] {key}: {e}"); return None; }
|
||||
};
|
||||
match image::decode(bytes, node["mimeType"].as_str().unwrap_or("")) {
|
||||
Ok(img) => Some(img),
|
||||
Err(e) => { eprintln!("[tex] {key}: {e}"); None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rohe Bytes eines bufferViews (ohne Accessor-Stride) aus dem BIN-Chunk.
|
||||
fn buffer_view_bytes<'a>(doc: &Value, bin: &'a [u8], view: usize) -> Result<&'a [u8], String> {
|
||||
let v = &doc["bufferViews"][view];
|
||||
if v.is_null() { return Err(format!("bufferView {view} fehlt")); }
|
||||
let off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||
let len = v["byteLength"].as_u64().ok_or("bufferView ohne byteLength")? as usize;
|
||||
bin.get(off..off + len).ok_or_else(|| "bufferView ragt aus dem BIN-Chunk".into())
|
||||
}
|
||||
|
||||
/// KHR_texture_transform einer Textur-Referenz — hier landet Blenders
|
||||
/// Mapping-Node. Fehlt die Extension: `None`.
|
||||
struct UvTransform {
|
||||
offset: [f32; 2],
|
||||
scale: [f32; 2],
|
||||
rotation: f32,
|
||||
}
|
||||
|
||||
fn uv_transform(tex_ref: &Value) -> Option<UvTransform> {
|
||||
let x = &tex_ref["extensions"]["KHR_texture_transform"];
|
||||
if x.is_null() { return None; }
|
||||
Some(UvTransform {
|
||||
offset: vecn::<2>(&x["offset"], [0.0, 0.0]),
|
||||
scale: vecn::<2>(&x["scale"], [1.0, 1.0]),
|
||||
rotation: x["rotation"].as_f64().unwrap_or(0.0) as f32,
|
||||
})
|
||||
}
|
||||
|
||||
impl UvTransform {
|
||||
/// `uv' = translation · rotation · scale · uv` (KHR-Referenz: die
|
||||
/// Rotationsmatrix dreht mit negativem Winkel).
|
||||
fn apply(&self, uv: [f32; 2]) -> [f32; 2] {
|
||||
let (a, b) = (uv[0] * self.scale[0], uv[1] * self.scale[1]);
|
||||
let (s, c) = self.rotation.sin_cos();
|
||||
[
|
||||
c * a + s * b + self.offset[0],
|
||||
-s * a + c * b + self.offset[1],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
||||
let node = &doc["nodes"][idx];
|
||||
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
||||
@@ -80,7 +187,7 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
|
||||
verts: Vec::new(), uvs: Vec::new(), normals: Vec::new(),
|
||||
tris: Vec::new(), tri_mats: Vec::new(),
|
||||
};
|
||||
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
||||
append_mesh(doc, bin, mesh as usize, world, &mut o)?;
|
||||
fill_missing_normals(&mut o); // Primitives ohne NORMAL
|
||||
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
||||
if !o.tris.is_empty() { model.objects.push(o); }
|
||||
@@ -104,8 +211,7 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
|
||||
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
||||
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
||||
fn append_mesh(
|
||||
doc: &Value, bin: &[u8], mesh: usize, world: M4,
|
||||
o: &mut Object, materials: &mut Vec<String>,
|
||||
doc: &Value, bin: &[u8], mesh: usize, world: M4, o: &mut Object,
|
||||
) -> Result<(), String> {
|
||||
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
||||
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
||||
@@ -125,14 +231,10 @@ fn append_mesh(
|
||||
None => vec![[0.0, 0.0, 0.0]; positions.len()],
|
||||
};
|
||||
|
||||
// Material-Name → geteilte Namensliste; ohne Material "".
|
||||
let mat_name = prim["material"].as_u64()
|
||||
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
||||
.unwrap_or("");
|
||||
let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
|
||||
materials.push(mat_name.to_string());
|
||||
materials.len() - 1
|
||||
});
|
||||
// Rohen glTF-Material-Index behalten; resolve_textures baut daraus
|
||||
// nach dem Walk die deduplizierte Texturliste und schreibt die
|
||||
// tri_mats auf deren Indizes um. usize::MAX = kein Material.
|
||||
let raw_mat = prim["material"].as_u64().map_or(usize::MAX, |m| m as usize);
|
||||
|
||||
let base = o.verts.len();
|
||||
let nm = normal_matrix(world);
|
||||
@@ -141,6 +243,14 @@ fn append_mesh(
|
||||
o.uvs.push(*uv);
|
||||
o.normals.push(norm(transform3(nm, *n)));
|
||||
}
|
||||
// KHR_texture_transform (Blenders Mapping-Node) in die UVs dieses
|
||||
// Primitives einbacken — der Shader bleibt UV-agnostisch.
|
||||
if raw_mat != usize::MAX {
|
||||
let tex_ref = &doc["materials"][raw_mat]["pbrMetallicRoughness"]["baseColorTexture"];
|
||||
if let Some(xf) = uv_transform(tex_ref) {
|
||||
for uv in &mut o.uvs[base..] { *uv = xf.apply(*uv); }
|
||||
}
|
||||
}
|
||||
|
||||
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
||||
Some(a) => read_indices(doc, bin, a as usize)?,
|
||||
@@ -151,7 +261,7 @@ fn append_mesh(
|
||||
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
||||
}
|
||||
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
||||
o.tri_mats.push(mi);
|
||||
o.tri_mats.push(raw_mat);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -431,7 +541,10 @@ mod tests {
|
||||
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
||||
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
||||
assert!(!d.visible && !d.collider);
|
||||
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
||||
// Material "carpet" ohne baseColorTexture → Schlüssel gesetzt, kein Bild.
|
||||
assert_eq!(m.textures.len(), 1);
|
||||
assert_eq!(m.textures[0].key, "carpet");
|
||||
assert!(m.textures[0].image.is_none());
|
||||
|
||||
// Kind erbt die Parent-Transform (10, 0, 0);
|
||||
// collide="proxy" → unsichtbarer Collider.
|
||||
@@ -491,4 +604,66 @@ mod tests {
|
||||
v1[4] = 1; // Version patchen
|
||||
assert!(parse_glb(&v1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_png_is_decoded_and_uv_transform_baked_in() {
|
||||
// 2×2-RGB-PNG (schwarz, rot, grün, blau) in den BIN-Chunk legen.
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png_bytes, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
enc.write_header().unwrap()
|
||||
.write_image_data(&[0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255])
|
||||
.unwrap();
|
||||
}
|
||||
let mut bin = sample_bin();
|
||||
while !bin.len().is_multiple_of(4) { bin.push(0); }
|
||||
let img_off = bin.len();
|
||||
bin.extend_from_slice(&png_bytes);
|
||||
|
||||
let json = format!(r#"{{
|
||||
"asset": {{"version": "2.0"}},
|
||||
"scene": 0,
|
||||
"scenes": [{{"nodes": [0]}}],
|
||||
"nodes": [{{"name": "Boden", "mesh": 0}}],
|
||||
"meshes": [{{"primitives": [{{
|
||||
"attributes": {{"POSITION": 0, "TEXCOORD_0": 1, "NORMAL": 2}},
|
||||
"indices": 3, "material": 0
|
||||
}}]}}],
|
||||
"materials": [{{"name": "Bodenmat", "pbrMetallicRoughness": {{"baseColorTexture": {{
|
||||
"index": 0,
|
||||
"extensions": {{"KHR_texture_transform": {{"scale": [2.0, 4.0], "offset": [0.5, 0.0]}}}}
|
||||
}}}}}}],
|
||||
"textures": [{{"source": 0}}],
|
||||
"images": [{{"name": "boden_tex", "mimeType": "image/png", "bufferView": 4}}],
|
||||
"accessors": [
|
||||
{{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}},
|
||||
{{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}},
|
||||
{{"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"}},
|
||||
{{"bufferView": 3, "componentType": 5123, "count": 3, "type": "SCALAR"}}
|
||||
],
|
||||
"bufferViews": [
|
||||
{{"buffer": 0, "byteOffset": 0, "byteLength": 36}},
|
||||
{{"buffer": 0, "byteOffset": 36, "byteLength": 24}},
|
||||
{{"buffer": 0, "byteOffset": 60, "byteLength": 36}},
|
||||
{{"buffer": 0, "byteOffset": 96, "byteLength": 6}},
|
||||
{{"buffer": 0, "byteOffset": {img_off}, "byteLength": {png_len}}}
|
||||
],
|
||||
"buffers": [{{"byteLength": {bin_len}}}]
|
||||
}}"#, img_off = img_off, png_len = png_bytes.len(), bin_len = bin.len());
|
||||
|
||||
let m = parse_glb(&glb(&json, &bin)).unwrap();
|
||||
|
||||
assert_eq!(m.textures.len(), 1);
|
||||
let t = &m.textures[0];
|
||||
assert_eq!(t.key, "boden_tex"); // glTF-Bildname, nicht Materialname
|
||||
let img = t.image.as_ref().expect("PNG dekodiert");
|
||||
assert_eq!((img.width, img.height), (2, 2));
|
||||
assert_eq!(&img.rgba[0..8], &[0, 0, 0, 255, 255, 0, 0, 255]);
|
||||
|
||||
// KHR_texture_transform eingebacken: UV (1,1) → (1·2 + 0.5, 1·4) = (2.5, 4.0).
|
||||
let uv = m.objects[0].uvs[2];
|
||||
assert!((uv[0] - 2.5).abs() < 1e-5 && (uv[1] - 4.0).abs() < 1e-5, "{uv:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! RGBA8-`Image` — der geteilte Pixel-Container der Engine, dazu der Decoder
|
||||
//! für eingebettete glTF-Bilder. Immer 32 bit, Ursprung oben-links, fertig
|
||||
//! konvertiert; der Aufrufer macht nur noch das Sampling (und damit die
|
||||
//! UV-Konvention) selbst.
|
||||
//!
|
||||
//! [`decode`] liest die Bilder, die Blender beim glTF-Export in die `.glb`
|
||||
//! packt; [`load`] dieselbe Sorte Bild von der Platte (UI, Fonts,
|
||||
//! Platzhalter). Beides PNG — ein Decoder für alles.
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `width * height * 4`, Ursprung oben-links.
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Eine PNG-Datei von der Platte laden.
|
||||
pub fn load(path: &str) -> Result<Image, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("image load {path}: {e}"))?;
|
||||
decode(&bytes, "").map_err(|e| format!("image {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Ein eingebettetes glTF-Bild dekodieren. `mime` ist der glTF-`mimeType`
|
||||
/// (darf leer sein — dann entscheidet die Datei-Signatur). Unterstützt wird
|
||||
/// nur PNG; JPEG/WebP werden gemeldet, nicht dekodiert.
|
||||
pub fn decode(bytes: &[u8], mime: &str) -> Result<Image, String> {
|
||||
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
let is_png = mime == "image/png" || (mime.is_empty() && bytes.starts_with(PNG_MAGIC));
|
||||
if !is_png {
|
||||
let what = if mime.is_empty() { "unbekanntes Format" } else { mime };
|
||||
return Err(format!("{what} — nur PNG (in Blender „Images: PNG“ beim glTF-Export)"));
|
||||
}
|
||||
decode_png(bytes)
|
||||
}
|
||||
|
||||
fn decode_png(bytes: &[u8]) -> Result<Image, String> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
// EXPAND hebt Palette/Low-Bit-Grau auf 8 bit, ALPHA erzwingt einen
|
||||
// Alpha-Kanal, STRIP_16 wirft die zweite Byte-Hälfte weg. Damit bleiben
|
||||
// als Ausgabe nur noch RGBA8 und Grau+Alpha8.
|
||||
decoder.set_transformations(
|
||||
png::Transformations::EXPAND | png::Transformations::ALPHA | png::Transformations::STRIP_16,
|
||||
);
|
||||
let mut reader = decoder.read_info().map_err(|e| format!("PNG: {e}"))?;
|
||||
let mut buf = vec![0u8; reader.output_buffer_size().ok_or("PNG: Bild zu groß")?];
|
||||
let info = reader.next_frame(&mut buf).map_err(|e| format!("PNG: {e}"))?;
|
||||
let (width, height) = (info.width, info.height);
|
||||
let src = &buf[..info.buffer_size()];
|
||||
let n = (width as usize) * (height as usize);
|
||||
|
||||
let rgba = match info.color_type {
|
||||
png::ColorType::Rgba => src.to_vec(),
|
||||
png::ColorType::GrayscaleAlpha => {
|
||||
let mut out = vec![0u8; n * 4];
|
||||
for i in 0..n {
|
||||
let (v, a) = (src[2 * i], src[2 * i + 1]);
|
||||
let o = i * 4;
|
||||
out[o] = v; out[o + 1] = v; out[o + 2] = v; out[o + 3] = a;
|
||||
}
|
||||
out
|
||||
}
|
||||
other => return Err(format!("PNG: unerwarteter Ausgabetyp {other:?}")),
|
||||
};
|
||||
Ok(Image { width, height, rgba })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ein `w`×`h`-PNG mit gegebenem Farbtyp im Speicher erzeugen.
|
||||
fn png(w: u32, h: u32, color: png::ColorType, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, w, h);
|
||||
enc.set_color(color);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
enc.write_header().unwrap().write_image_data(data).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_png_gets_opaque_alpha() {
|
||||
// 2×1, zwei RGB-Pixel.
|
||||
let bytes = png(2, 1, png::ColorType::Rgb, &[10, 20, 30, 40, 50, 60]);
|
||||
let img = decode(&bytes, "image/png").unwrap();
|
||||
assert_eq!((img.width, img.height), (2, 1));
|
||||
assert_eq!(img.rgba, vec![10, 20, 30, 255, 40, 50, 60, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_png_expands_to_rgba() {
|
||||
let bytes = png(2, 1, png::ColorType::Grayscale, &[128, 255]);
|
||||
let img = decode(&bytes, "").unwrap(); // mime leer → Signatur greift
|
||||
assert_eq!(img.rgba, vec![128, 128, 128, 255, 255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_png_is_reported_not_decoded() {
|
||||
assert!(decode(b"\xff\xd8\xff\xe0junk", "image/jpeg").is_err());
|
||||
assert!(decode(b"whatever", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_real_project_assets() {
|
||||
for (path, w, h) in [
|
||||
("assets/textures/placeholder.png", 256, 256),
|
||||
("assets/textures/fonts/oldschool-ega-8x14.png", 256, 112),
|
||||
("assets/textures/fonts/oldschool-cga-8x8.png", 256, 64),
|
||||
("assets/textures/ui/cursors.png", 32, 32),
|
||||
("assets/textures/ui/panel_ornaments.png", 32, 32),
|
||||
] {
|
||||
let img = load(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||
assert_eq!((img.width, img.height), (w, h), "{path}");
|
||||
assert_eq!(img.rgba.len(), (w * h * 4) as usize, "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
|
||||
//! Dünner Adapter um die `bladeink`-Crate — und die einzige Stelle im Kern,
|
||||
//! die sie kennt. `to_ink`/`from_ink` rechnen zwischen `kv::Value` und Inks
|
||||
//! `ValueType` um; alles andere spricht nur den neutralen KV-Typ.
|
||||
//!
|
||||
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
||||
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
||||
@@ -20,8 +22,9 @@
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use bladeink::story::Story as BladeStory;
|
||||
use bladeink::value_type::ValueType;
|
||||
|
||||
use crate::engine::kv::Store;
|
||||
use crate::engine::kv::{Store, Value};
|
||||
|
||||
pub enum StoryState {
|
||||
Text(String),
|
||||
@@ -140,7 +143,7 @@ impl Story {
|
||||
if name.starts_with('_') { continue; } // skript-lokal
|
||||
// set_variable schlägt fehl wenn der Name kein deklariertes
|
||||
// VAR im Skript ist — still ignorieren.
|
||||
let _ = self.inner.set_variable(name, value);
|
||||
let _ = self.inner.set_variable(name, &to_ink(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,17 +152,39 @@ impl Story {
|
||||
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte.
|
||||
fn sync_to_kv(&self, kv: &mut Store) {
|
||||
for name in &self.vars {
|
||||
if let Some(v) = self.inner.get_variable(name) {
|
||||
if let Some(v) = self.inner.get_variable(name).as_ref().and_then(from_ink) {
|
||||
kv.insert(name.clone(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Value`] → Inks `ValueType`. Zusammen mit [`from_ink`] die einzige Stelle,
|
||||
/// an der der Kern-KV die Story-Crate berührt.
|
||||
fn to_ink(v: &Value) -> ValueType {
|
||||
match v {
|
||||
Value::Bool(b) => ValueType::Bool(*b),
|
||||
Value::Int(i) => ValueType::Int(*i),
|
||||
Value::Float(f) => ValueType::Float(*f),
|
||||
Value::Str(s) => ValueType::new(s.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inks `ValueType` → [`Value`]. `None` für Ink-eigene Typen (Listen, Divert-
|
||||
/// und Variablen-Referenzen), die im KV nichts verloren haben.
|
||||
fn from_ink(v: &ValueType) -> Option<Value> {
|
||||
Some(match v {
|
||||
ValueType::Bool(b) => Value::Bool(*b),
|
||||
ValueType::Int(i) => Value::Int(*i),
|
||||
ValueType::Float(f) => Value::Float(*f),
|
||||
ValueType::String(s) => Value::Str(s.string.clone()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bladeink::value_type::ValueType;
|
||||
|
||||
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
|
||||
// VAR snaks = 0
|
||||
@@ -183,7 +208,7 @@ mod tests {
|
||||
let mut kv = Store::new();
|
||||
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||
assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
|
||||
assert!(matches!(kv["snaks"], ValueType::Int(1)));
|
||||
assert!(matches!(kv["snaks"], Value::Int(1)));
|
||||
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
|
||||
}
|
||||
|
||||
@@ -191,7 +216,7 @@ mod tests {
|
||||
fn kv_values_flow_into_the_script() {
|
||||
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
|
||||
let mut kv = Store::new();
|
||||
kv.insert("snaks".into(), ValueType::Int(7));
|
||||
kv.insert("snaks".into(), Value::Int(7));
|
||||
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
|
||||
match s.cont(&mut kv) {
|
||||
StoryState::Text(t) => assert_eq!(t, "S=7"),
|
||||
|
||||
@@ -3,41 +3,68 @@
|
||||
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
|
||||
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
|
||||
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
|
||||
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions
|
||||
//! [`crate::engine::ink`]). Damit der KV autoritativ bleibt: externe Actions
|
||||
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
|
||||
//!
|
||||
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
|
||||
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
|
||||
//! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
|
||||
//! auf `bladeink::ValueType` passiert allein an der Ink-Grenze in
|
||||
//! [`crate::engine::ink`] — so hängt der Kern-KV nicht an der Story-Crate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use bladeink::value_type::ValueType;
|
||||
pub type Store = HashMap<String, Value>;
|
||||
|
||||
pub type Store = HashMap<String, ValueType>;
|
||||
/// Ein KV-Wert. Deckt die Skalare ab, die signals.toml-Actions und Ink-`VAR`s
|
||||
/// austauschen; Ink-eigene Typen (Listen, Divert-Referenzen) haben hier
|
||||
/// bewusst keinen Platz.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Bool(bool),
|
||||
Int(i32),
|
||||
Float(f32),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge:
|
||||
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
||||
pub fn parse_value(s: &str) -> ValueType {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
"true" => return ValueType::Bool(true),
|
||||
"false" => return ValueType::Bool(false),
|
||||
_ => {}
|
||||
impl Value {
|
||||
/// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
|
||||
/// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
|
||||
/// bladeink lieferte.
|
||||
pub fn as_int(&self) -> Option<i32> {
|
||||
match self {
|
||||
Value::Bool(b) => Some(*b as i32),
|
||||
Value::Int(i) => Some(*i),
|
||||
Value::Float(f) => Some(*f as i32),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
|
||||
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
|
||||
ValueType::new(s)
|
||||
}
|
||||
|
||||
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
|
||||
pub fn format_value(v: &ValueType) -> String {
|
||||
match v {
|
||||
ValueType::Bool(b) => b.to_string(),
|
||||
ValueType::Int(i) => i.to_string(),
|
||||
ValueType::Float(f) => f.to_string(),
|
||||
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
|
||||
impl fmt::Display for Value {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Value::Bool(b) => write!(f, "{b}"),
|
||||
Value::Int(i) => write!(f, "{i}"),
|
||||
Value::Float(x) => write!(f, "{x}"),
|
||||
Value::Str(s) => write!(f, "{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parst einen Action-Arg-String zu einem [`Value`]. Reihenfolge:
|
||||
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
||||
pub fn parse_value(s: &str) -> Value {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
"true" => return Value::Bool(true),
|
||||
"false" => return Value::Bool(false),
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(i) = s.parse::<i32>() { return Value::Int(i); }
|
||||
if let Ok(x) = s.parse::<f32>() { return Value::Float(x); }
|
||||
Value::Str(s.to_string())
|
||||
}
|
||||
|
||||
pub fn apply_set(args: &str, store: &mut Store) {
|
||||
let Some((name, value)) = args.split_once(char::is_whitespace) else {
|
||||
@@ -51,8 +78,8 @@ pub fn apply_inc(args: &str, store: &mut Store) {
|
||||
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
|
||||
let name = name.trim();
|
||||
let delta = delta.trim().parse::<i32>().unwrap_or(1);
|
||||
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0);
|
||||
store.insert(name.to_string(), ValueType::Int(cur + delta));
|
||||
let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
|
||||
store.insert(name.to_string(), Value::Int(cur + delta));
|
||||
}
|
||||
|
||||
pub fn apply_clear(args: &str, store: &mut Store) {
|
||||
@@ -65,29 +92,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_value_types() {
|
||||
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
|
||||
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
|
||||
assert!(matches!(parse_value("42"), ValueType::Int(42)));
|
||||
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
|
||||
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
|
||||
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
|
||||
assert!(matches!(parse_value("true"), Value::Bool(true)));
|
||||
assert!(matches!(parse_value("false"), Value::Bool(false)));
|
||||
assert!(matches!(parse_value("42"), Value::Int(42)));
|
||||
assert!(matches!(parse_value("-7"), Value::Int(-7)));
|
||||
assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
|
||||
assert!(matches!(parse_value("hallo"), Value::Str(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_int_coerces_like_ink_did() {
|
||||
assert_eq!(Value::Bool(true).as_int(), Some(1));
|
||||
assert_eq!(Value::Int(-7).as_int(), Some(-7));
|
||||
assert_eq!(Value::Float(2.9).as_int(), Some(2));
|
||||
assert_eq!(Value::Str("x".into()).as_int(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_inc_clear_roundtrip() {
|
||||
let mut s = Store::new();
|
||||
apply_set("clicks 3", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3);
|
||||
assert_eq!(s["clicks"].as_int().unwrap(), 3);
|
||||
|
||||
apply_inc("clicks", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
|
||||
assert_eq!(s["clicks"].as_int().unwrap(), 4);
|
||||
|
||||
apply_inc("clicks -2", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
|
||||
assert_eq!(s["clicks"].as_int().unwrap(), 2);
|
||||
|
||||
// inc auf unbekannten Key startet bei 0
|
||||
apply_inc("fresh 5", &mut s);
|
||||
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5);
|
||||
assert_eq!(s["fresh"].as_int().unwrap(), 5);
|
||||
|
||||
apply_clear("clicks", &mut s);
|
||||
assert!(!s.contains_key("clicks"));
|
||||
@@ -99,4 +134,11 @@ mod tests {
|
||||
apply_set("only_name", &mut s);
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_plain_scalar() {
|
||||
assert_eq!(Value::Bool(true).to_string(), "true");
|
||||
assert_eq!(Value::Int(-3).to_string(), "-3");
|
||||
assert_eq!(Value::Str("hi".into()).to_string(), "hi");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,24 +14,27 @@
|
||||
//! so wie story_ctrl Tags zurückgibt, statt selbst `signals::dispatch` zu
|
||||
//! rufen.
|
||||
//!
|
||||
//! `gltf`, `tga` und `wav` sind reine Decoder (Bytes → owned Daten); `gltf`
|
||||
//! ist DER 3D-Pfad und produziert das neutrale `model::Model`. `audio` ist
|
||||
//! die Emitter-Logik zum Frontend-Treiber render::audio, `post` analog die
|
||||
//! Effekt-Schalter zu render::post, `player` die First-Person-Physik auf der
|
||||
//! Welt aus `collision`.
|
||||
//! `gltf`, `image` und `wav` sind reine Decoder (Bytes → owned Daten);
|
||||
//! `gltf` ist DER 3D-Pfad und produziert das `model::Model`, den Vertrag
|
||||
//! zwischen Loader und seinen Konsumenten (collision, trigger, audio,
|
||||
//! render::props) — samt der Welt-Texturen, die als PNG in der `.glb` liegen.
|
||||
//! `image` dekodiert PNG (eingebettet wie von der Platte) und stellt den
|
||||
//! RGBA8-`Image`-Typ für alle.
|
||||
//! `audio` ist die Emitter-Logik zum Frontend-Treiber render::audio, `player`
|
||||
//! die First-Person-Physik auf der Welt aus `collision`. Post-Effekte kennt
|
||||
//! der Kern nur als `game::Action::SetPost` — die Kette lebt in render::post.
|
||||
|
||||
pub mod assets;
|
||||
pub mod audio;
|
||||
pub mod collision;
|
||||
pub mod game;
|
||||
pub mod gltf;
|
||||
pub mod image;
|
||||
pub mod ink;
|
||||
pub mod kv;
|
||||
pub mod model;
|
||||
pub mod player;
|
||||
pub mod post;
|
||||
pub mod signals;
|
||||
pub mod story_ctrl;
|
||||
pub mod tga;
|
||||
pub mod trigger;
|
||||
pub mod wav;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Neutrales 3D-Modell — das gemeinsame Ergebnis des Blender-Loaders
|
||||
//! (engine::gltf). Konsumenten (render::props, collision, trigger) kennen
|
||||
//! nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen egal — so
|
||||
//! bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
|
||||
//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der
|
||||
//! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger,
|
||||
//! audio, render::props und session::load_props arbeiten alle auf dieser
|
||||
//! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze
|
||||
//! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion:
|
||||
//! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind
|
||||
//! Geschichte), `gltf` der einzige Produzent.
|
||||
//!
|
||||
//! Texturen kommen als Pixel mit — der Loader zieht sie aus der `.glb`
|
||||
//! (baseColorTexture, PNG-Chunk im BIN), siehe [`Texture`]. Keine externe
|
||||
//! Namenskonvention mehr.
|
||||
//!
|
||||
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
||||
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
||||
@@ -15,10 +22,12 @@
|
||||
//!
|
||||
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
||||
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
||||
//! die Loader bleiben frei von Signal-Wissen.
|
||||
//! der Loader bleibt frei von Signal-Wissen.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::engine::image::Image;
|
||||
|
||||
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
|
||||
/// Werte zu Strings vereinheitlicht.
|
||||
pub type Props = HashMap<String, String>;
|
||||
@@ -28,10 +37,22 @@ pub struct Model {
|
||||
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
|
||||
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
|
||||
pub empties: Vec<Empty>,
|
||||
/// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats`
|
||||
/// indiziert hierhin. `""` steht für „Faces ohne Material" — der
|
||||
/// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur).
|
||||
pub materials: Vec<String>,
|
||||
/// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats`
|
||||
/// indiziert hierhin.
|
||||
pub textures: Vec<Texture>,
|
||||
}
|
||||
|
||||
/// Eine baseColorTexture aus der `.glb` (bzw. ein Material ohne Bild).
|
||||
pub struct Texture {
|
||||
/// Dedup-/Log-Schlüssel: der glTF-Bildname (Blender: Name des
|
||||
/// Bild-Datablocks), sonst der Materialname, sonst `""`. Texturen mit
|
||||
/// gleichem Schlüssel — auch über mehrere `.glb` — teilen sich auf der
|
||||
/// GPU eine Textur.
|
||||
pub key: String,
|
||||
/// Dekodierte Pixel (RGBA8, Ursprung oben links). `None`: Material ohne
|
||||
/// baseColorTexture oder unlesbares Bild → der Konsument nimmt seine
|
||||
/// Platzhalter-Textur.
|
||||
pub image: Option<Image>,
|
||||
}
|
||||
|
||||
pub struct Object {
|
||||
@@ -40,13 +61,13 @@ pub struct Object {
|
||||
/// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit).
|
||||
pub verts: Vec<[f32; 3]>,
|
||||
/// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich
|
||||
/// `tga::Image`-Konvention, direkt sampelbar ohne Flip.
|
||||
/// `image::Image`-Konvention, direkt sampelbar ohne Flip.
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
/// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im
|
||||
/// Modell, rechnet der Loader sie aus den Flächen.
|
||||
pub normals: Vec<[f32; 3]>,
|
||||
pub tris: Vec<[usize; 3]>,
|
||||
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
|
||||
/// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`).
|
||||
pub tri_mats: Vec<usize>,
|
||||
pub visible: bool,
|
||||
pub collider: bool,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! Post-Effekt-Schalter: welches Kettenglied des Renderers ist an?
|
||||
//!
|
||||
//! Headless-Gegenstück zu `render::post`, so wie `audio` zum Audio-Treiber.
|
||||
//! Hier liegen nur die *Namen* und ihr An/Aus-Zustand — damit die Konsole sie
|
||||
//! durch denselben `exec`-Trichter schalten kann wie alles andere, ohne dass
|
||||
//! der Kern eine GPU kennt. Was ein Effekt tut und in welcher Reihenfolge er
|
||||
//! läuft, weiß allein das Render-Frontend; es meldet seine Effekte beim Start
|
||||
//! per [`PostChain::register`] an. Ohne Fenster-Frontend bleibt die Liste leer.
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PostChain {
|
||||
/// In Ketten-Reihenfolge, wie vom Frontend angemeldet.
|
||||
effects: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
impl PostChain {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
/// Einen Effekt anmelden (Frontend beim Start, in Ketten-Reihenfolge).
|
||||
pub fn register(&mut self, name: &str, on: bool) {
|
||||
self.effects.push((name.to_string(), on));
|
||||
}
|
||||
|
||||
pub fn enabled(&self, name: &str) -> bool {
|
||||
self.effects.iter().any(|(n, on)| n == name && *on)
|
||||
}
|
||||
|
||||
/// Schalten; `on = None` togglet. Gibt den neuen Zustand zurück, `None`
|
||||
/// bei unbekanntem Namen.
|
||||
pub fn set(&mut self, name: &str, on: Option<bool>) -> Option<bool> {
|
||||
let e = self.effects.iter_mut().find(|(n, _)| n == name)?;
|
||||
e.1 = on.unwrap_or(!e.1);
|
||||
Some(e.1)
|
||||
}
|
||||
|
||||
/// Alle Effekte in Ketten-Reihenfolge.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, bool)> {
|
||||
self.effects.iter().map(|(n, on)| (n.as_str(), *on))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chain() -> PostChain {
|
||||
let mut c = PostChain::new();
|
||||
c.register("cel", false);
|
||||
c.register("dither", true);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_registration_order() {
|
||||
let c = chain();
|
||||
let names: Vec<&str> = c.list().map(|(n, _)| n).collect();
|
||||
assert_eq!(names, vec!["cel", "dither"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_toggles_and_reports_unknown() {
|
||||
let mut c = chain();
|
||||
assert_eq!(c.set("cel", Some(true)), Some(true));
|
||||
assert!(c.enabled("cel"));
|
||||
assert_eq!(c.set("cel", None), Some(false), "ohne Argument togglen");
|
||||
assert!(!c.enabled("cel"));
|
||||
assert_eq!(c.set("bloom", Some(true)), None, "unbekannt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_effect_is_off() {
|
||||
assert!(!PostChain::new().enabled("dither"));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
//! `assets/sounds/{name}.wav`
|
||||
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
||||
//! Eingabemodus wechseln
|
||||
//! `post <name> <on|off>` → Action::SetPost (deferred): Renderer-
|
||||
//! Post-Effekt schalten (Name = Kettenglied
|
||||
//! im Fenster-Frontend)
|
||||
//!
|
||||
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
||||
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
||||
@@ -101,11 +104,29 @@ fn execute(cmd: &str, ctx: &mut ActionCtx) -> bool {
|
||||
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
||||
ctx.actions.push(Action::SetMode(t));
|
||||
},
|
||||
"post" => if let Some((name, on)) = parse_post_arg(args) {
|
||||
ctx.actions.push(Action::SetPost { name, on });
|
||||
},
|
||||
_ => return false, // Unbekanntes Verb — der Aufrufer meldet ggf.
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `"<name> on"` / `"<name> off"` → `(name, bool)`; sonst `None`. Kein
|
||||
/// nackter Toggle — eine geskriptete Schaltung soll lesbar sagen, wohin sie
|
||||
/// schaltet. Geteilt von der Dispatch-Pipeline (`post`-Verb) und dem
|
||||
/// `post`-Konsolenbefehl in `session`.
|
||||
pub fn parse_post_arg(args: &str) -> Option<(String, bool)> {
|
||||
let (name, state) = args.trim().split_once(char::is_whitespace)?;
|
||||
let on = match state.trim() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let name = name.trim();
|
||||
(!name.is_empty()).then(|| (name.to_string(), on))
|
||||
}
|
||||
|
||||
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
|
||||
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
|
||||
/// das Format ist trivial und die Engine bleibt dependency-arm.
|
||||
@@ -188,7 +209,7 @@ mod tests {
|
||||
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
|
||||
dispatch("pickup", &mut ctx);
|
||||
|
||||
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
|
||||
assert_eq!(game.kv["items"].as_int().unwrap(), 1);
|
||||
assert_eq!(game.actions, vec![
|
||||
Action::PlaySound("Mushroom.005".into()),
|
||||
]);
|
||||
@@ -208,6 +229,23 @@ mod tests {
|
||||
assert!(game.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_verb_queues_setpost_action() {
|
||||
let mut game = Game::new(Signals::new());
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("post outline off", &mut ctx);
|
||||
assert_eq!(game.actions, vec![
|
||||
Action::SetPost { name: "outline".into(), on: false },
|
||||
]);
|
||||
|
||||
// Fehlendes/ungültiges on|off → kein Action (stilles No-Op wie `mode`).
|
||||
game.actions.clear();
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("post outline", &mut ctx);
|
||||
dispatch("post outline wat", &mut ctx);
|
||||
assert!(game.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typo_verb_in_table_is_tolerated_and_reported() {
|
||||
// Ein Tippfehler-Verb in einer Table-Action wird gemeldet (stderr) und
|
||||
@@ -221,7 +259,7 @@ mod tests {
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("thing", &mut ctx);
|
||||
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
|
||||
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter");
|
||||
assert!(matches!(game.kv["ok"], kv::Value::Bool(true)), "gültige Action wirkt weiter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -230,7 +268,7 @@ mod tests {
|
||||
let mut ctx = game.action_ctx(None);
|
||||
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
|
||||
dispatch("set has_key true", &mut ctx);
|
||||
assert!(game.kv["has_key"].coerce_to_bool().unwrap());
|
||||
assert!(matches!(game.kv["has_key"], kv::Value::Bool(true)));
|
||||
|
||||
// Unbekanntes Verb ist ein stilles No-Op.
|
||||
let mut ctx = game.action_ctx(None);
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
|
||||
//!
|
||||
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
|
||||
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
|
||||
//! Nachbearbeitung, nur das Sampling (und damit die UV-Konvention) gehört ihm.
|
||||
//!
|
||||
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
|
||||
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
|
||||
//! Font-Maps. Paletten-TGAs gibt es nicht mehr (wds ist 15-bit True-Color).
|
||||
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `width * height * 4`, Ursprung oben-links.
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Result<Image, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("tga load {path}: {e}"))?;
|
||||
decode(&bytes).map_err(|e| format!("tga {path}: {e}"))
|
||||
}
|
||||
|
||||
pub fn decode(d: &[u8]) -> Result<Image, String> {
|
||||
if d.len() < 18 { return Err("Header zu kurz".into()); }
|
||||
let id_len = d[0] as usize;
|
||||
let cmap_type = d[1];
|
||||
let image_type = d[2];
|
||||
let width = u16::from_le_bytes([d[12], d[13]]) as u32;
|
||||
let height = u16::from_le_bytes([d[14], d[15]]) as u32;
|
||||
let depth = d[16];
|
||||
let descriptor = d[17];
|
||||
|
||||
if cmap_type != 0 {
|
||||
return Err("Color-Map-TGAs nicht unterstützt (True-Color only)".into());
|
||||
}
|
||||
// Pixeldaten beginnen nach Header + ID-Feld (Color-Map ist leer).
|
||||
let off = 18 + id_len;
|
||||
|
||||
let channels = match (image_type, depth) {
|
||||
(2 | 10, 24) => 3,
|
||||
(2 | 10, 32) => 4,
|
||||
(3 | 11, 8) => 1,
|
||||
(t, b) => return Err(format!("nicht unterstützt: Typ {t}, {b} bpp")),
|
||||
};
|
||||
let n = (width as usize) * (height as usize);
|
||||
if n == 0 { return Err("Nullgröße".into()); }
|
||||
|
||||
// Rohpixel (channels Bytes je Pixel), bei RLE entpackt.
|
||||
let raw_len = n * channels;
|
||||
let raw: Vec<u8> = match image_type {
|
||||
2 | 3 => d.get(off..off + raw_len).ok_or("Pixeldaten zu kurz")?.to_vec(),
|
||||
10 | 11 => decode_rle(d.get(off..).ok_or("RLE-Daten fehlen")?, raw_len, channels)?,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// → RGBA8. True-Color liegt im TGA als BGR(A) vor; Graustufen replizieren.
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
for i in 0..n {
|
||||
let (r, g, b, a) = match channels {
|
||||
1 => { let v = raw[i]; (v, v, v, 255) }
|
||||
3 => (raw[i * 3 + 2], raw[i * 3 + 1], raw[i * 3], 255),
|
||||
4 => (raw[i * 4 + 2], raw[i * 4 + 1], raw[i * 4], raw[i * 4 + 3]),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let o = i * 4;
|
||||
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
|
||||
}
|
||||
|
||||
// Ursprung oben-links erzwingen. Descriptor-Bit 5 gesetzt = top-origin.
|
||||
if (descriptor >> 5) & 1 == 0 {
|
||||
flip_vertical(&mut rgba, width as usize, height as usize);
|
||||
}
|
||||
Ok(Image { width, height, rgba })
|
||||
}
|
||||
|
||||
/// TGA-RLE: Pakete operieren auf ganzen Pixeln (`ch` Bytes).
|
||||
fn decode_rle(src: &[u8], raw_len: usize, ch: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::with_capacity(raw_len);
|
||||
let mut si = 0;
|
||||
while out.len() < raw_len {
|
||||
let header = *src.get(si).ok_or("RLE: Quelle zu kurz")?;
|
||||
si += 1;
|
||||
let count = (header & 0x7f) as usize + 1;
|
||||
if header & 0x80 != 0 {
|
||||
// RLE-Paket: ein Pixel count-mal.
|
||||
let px = src.get(si..si + ch).ok_or("RLE: Pixel zu kurz")?;
|
||||
si += ch;
|
||||
for _ in 0..count { out.extend_from_slice(px); }
|
||||
} else {
|
||||
// Raw-Paket: count Pixel am Stück.
|
||||
let bytes = count * ch;
|
||||
let chunk = src.get(si..si + bytes).ok_or("RLE: Chunk zu kurz")?;
|
||||
si += bytes;
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
out.truncate(raw_len);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn flip_vertical(rgba: &mut [u8], w: usize, h: usize) {
|
||||
let row = w * 4;
|
||||
for y in 0..h / 2 {
|
||||
let (a, b) = (y * row, (h - 1 - y) * row);
|
||||
for x in 0..row { rgba.swap(a + x, b + x); }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bgr_to_rgba_uncompressed() {
|
||||
// Minimal-TGA: Typ 2, 2×1, 24 bpp, bottom-origin (descriptor 0).
|
||||
// Zwei Pixel BGR (10,20,30) und (40,50,60).
|
||||
let mut d = vec![0u8; 18];
|
||||
d[2] = 2; d[12] = 2; d[14] = 1; d[16] = 24;
|
||||
d.extend_from_slice(&[10, 20, 30, 40, 50, 60]);
|
||||
let img = decode(&d).unwrap();
|
||||
assert_eq!((img.width, img.height), (2, 1));
|
||||
assert_eq!(&img.rgba[0..4], &[30, 20, 10, 255]);
|
||||
assert_eq!(&img.rgba[4..8], &[60, 50, 40, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_truecolor_expands() {
|
||||
// Typ 10, 4×1, 24 bpp: ein RLE-Paket (4× BGR 1,2,3).
|
||||
let mut d = vec![0u8; 18];
|
||||
d[2] = 10; d[12] = 4; d[14] = 1; d[16] = 24;
|
||||
d.push(0x80 | 3); // RLE, count = 4
|
||||
d.extend_from_slice(&[1, 2, 3]); // ein Pixel
|
||||
let img = decode(&d).unwrap();
|
||||
assert_eq!(img.rgba.len(), 4 * 4);
|
||||
assert!(img.rgba.chunks(4).all(|p| p == [3, 2, 1, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_real_placeholder() {
|
||||
let bytes = std::fs::read("assets/textures/placeholder.tga").unwrap();
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height), (256, 256));
|
||||
assert_eq!(img.rgba.len(), 256 * 256 * 4);
|
||||
assert!(img.rgba.chunks(4).all(|p| p[3] == 255)); // 24bpp → Alpha 255
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ mod tests {
|
||||
use crate::engine::model::{test_cube as cube, Object};
|
||||
|
||||
fn model(objects: Vec<Object>) -> Model {
|
||||
Model { objects, empties: Vec::new(), materials: vec![String::new()] }
|
||||
Model { objects, empties: Vec::new(), textures: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
|
||||
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
|
||||
//! Konsolen-REPL (`cli`, via `--cli`).
|
||||
//!
|
||||
//! Flags: `--cli` startet die Konsolen-REPL statt des Fensters; `--maps <dir>`
|
||||
//! überschreibt das Verzeichnis der Welt-`.glb` (Pfad wie angegeben, also
|
||||
//! relativ zum CWD).
|
||||
|
||||
mod cli;
|
||||
mod engine;
|
||||
@@ -15,11 +19,22 @@ use engine::assets;
|
||||
use session::Session;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let signals_path = assets::path("assets/signals.toml");
|
||||
let session = Session::new(signals_path.clone());
|
||||
if std::env::args().any(|a| a == "--cli") {
|
||||
if args.iter().any(|a| a == "--cli") {
|
||||
cli::run(session, &signals_path);
|
||||
} else {
|
||||
render::run(session);
|
||||
let maps_dir = arg_value(&args, "--maps")
|
||||
.unwrap_or_else(|| assets::path(render::DEFAULT_MAPS_DIR));
|
||||
render::run(session, &maps_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wert eines `--flag <wert>`-Paars aus `args`, falls das Flag vorkommt und
|
||||
/// ein Wort folgt.
|
||||
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == flag)
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit
|
||||
//! zeichnen kann.
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::image::Image;
|
||||
|
||||
const COLS: u32 = 32;
|
||||
|
||||
@@ -47,11 +47,11 @@ pub(crate) struct Fonts {
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.tga`, 256×112).
|
||||
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.png`, 256×112).
|
||||
pub(crate) fn ega(tex: usize, atlas: &Image) -> Font {
|
||||
Font::measure(8.0, 14.0, 256.0, 112.0, tex, atlas)
|
||||
}
|
||||
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.tga`, 256×64).
|
||||
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.png`, 256×64).
|
||||
pub(crate) fn cga(tex: usize, atlas: &Image) -> Font {
|
||||
Font::measure(8.0, 8.0, 256.0, 64.0, tex, atlas)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,9 @@ use std::sync::Arc;
|
||||
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::engine::post::PostChain;
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::image::Image;
|
||||
use crate::render::math::Mat4;
|
||||
use crate::render::post::Post;
|
||||
use crate::render::post::{Post, PostChain};
|
||||
use crate::render::scene::{Mesh, ScenePass};
|
||||
use crate::render::sprite::SpritePass;
|
||||
use crate::render::ui::Ui;
|
||||
|
||||
@@ -29,9 +29,9 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{CursorGrabMode, Window, WindowId};
|
||||
|
||||
use crate::engine::image::{self, Image};
|
||||
use crate::engine::player;
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::{assets, gltf, model, tga, wav};
|
||||
use crate::engine::{assets, gltf, model, wav};
|
||||
use crate::session::{FrameInput, Mode, Session};
|
||||
use gpu::Gpu;
|
||||
use math::Mat4;
|
||||
@@ -44,26 +44,40 @@ const EYE_SMOOTH_TAU: f32 = 0.06;
|
||||
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
||||
const EYE_MAX_LAG: f32 = 0.6;
|
||||
|
||||
pub fn run(mut session: Session) {
|
||||
// Post-Effekte anmelden: die Kette gehört dem Renderer, geschaltet wird
|
||||
// sie über denselben exec-Trichter wie alles andere (`post`).
|
||||
for (name, on) in post::effects() { session.post.register(name, on); }
|
||||
/// Default-Verzeichnis der Welt-`.glb` (relativ zum Asset-Basisverzeichnis).
|
||||
/// `main` löst es auf und lässt `--maps <dir>` es überschreiben.
|
||||
pub const DEFAULT_MAPS_DIR: &str = "assets/maps/props";
|
||||
|
||||
pub fn run(mut session: Session, maps_dir: &str) {
|
||||
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||
for line in session.start() { println!("{line}"); }
|
||||
|
||||
// Die Welt sind alle .glb unter assets/maps/props/. Decode (CPU) hier,
|
||||
// Upload (GPU) später in `Gpu` — die beiden bleiben getrennt.
|
||||
let models = load_models(&assets::path("assets/maps/props"));
|
||||
// Die Welt sind alle .glb unter `maps_dir` (Default DEFAULT_MAPS_DIR, per
|
||||
// `--maps <dir>` überschrieben). Decode (CPU) hier, Upload (GPU) später in
|
||||
// `Gpu` — die beiden bleiben getrennt.
|
||||
let models = load_models(maps_dir);
|
||||
|
||||
// Eine geteilte Texturliste über alle Modelle, damit ihre Meshes
|
||||
// denselben Index-Raum benutzen und zu einem Buffer verschmelzen können.
|
||||
// Eine geteilte Textur-Schlüsselliste über alle Modelle, damit ihre
|
||||
// Meshes denselben Index-Raum benutzen und zu einem Buffer verschmelzen.
|
||||
let mut tex_names: Vec<String> = Vec::new();
|
||||
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
||||
for (_, m) in &models { tex_names.extend(props::texture_keys(m)); }
|
||||
tex_names.sort_unstable();
|
||||
tex_names.dedup();
|
||||
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
||||
|
||||
// Die Pixel liegen in den `.glb` selbst; gleicher Schlüssel = eine Textur
|
||||
// (erstes Vorkommen gewinnt). Schlüssel ohne Bild treffen den Platzhalter
|
||||
// (Projekt-Asset, darf hart scheitern).
|
||||
let mut by_key: HashMap<&str, &Image> = HashMap::new();
|
||||
for (_, m) in &models {
|
||||
for t in &m.textures {
|
||||
if let Some(img) = &t.image { by_key.entry(props::texture_key(t)).or_insert(img); }
|
||||
}
|
||||
}
|
||||
let placeholder = placeholder();
|
||||
let images: Vec<Image> = tex_names.iter()
|
||||
.map(|k| by_key.get(k.as_str()).map_or_else(|| placeholder.clone(), |i| (*i).clone()))
|
||||
.collect();
|
||||
|
||||
let mut mesh = Mesh::default();
|
||||
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
||||
@@ -92,12 +106,12 @@ pub fn run(mut session: Session) {
|
||||
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
|
||||
// Installation kaputt und es darf scheitern.
|
||||
let load_keyed = |p: &str| {
|
||||
ui::key_luminance(&tga::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
||||
ui::key_luminance(&image::load(&assets::path(p)).expect("UI-Textur (Projekt-Asset)"))
|
||||
};
|
||||
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
||||
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
||||
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
|
||||
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga");
|
||||
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.png");
|
||||
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.png");
|
||||
let fonts = font::Fonts {
|
||||
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
|
||||
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
|
||||
@@ -106,8 +120,8 @@ pub fn run(mut session: Session) {
|
||||
ui::white_pixel(),
|
||||
ega_img,
|
||||
cga_img,
|
||||
load_keyed("assets/textures/ui/cursors.tga"),
|
||||
load_keyed("assets/textures/ui/panel_ornaments.tga"),
|
||||
load_keyed("assets/textures/ui/cursors.png"),
|
||||
load_keyed("assets/textures/ui/panel_ornaments.png"),
|
||||
];
|
||||
|
||||
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
|
||||
@@ -185,19 +199,13 @@ fn load_sound(name: &str) -> Option<audio::Sound> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Welt-Textur laden. Autorinnen-Content: fehlt oder klemmt die Datei, wird
|
||||
/// gemeldet und der Platzhalter genommen — der selbst ist Projekt-Asset und
|
||||
/// darf hart scheitern.
|
||||
fn load_texture(name: &str) -> Image {
|
||||
match tga::load(&assets::path(&format!("assets/textures/{name}.tga"))) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
eprintln!("[tex] {e} — Platzhalter");
|
||||
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
|
||||
/// Die Platzhalter-Textur für Schlüssel ohne eigenes Bild in der `.glb`.
|
||||
/// Projekt-Asset: fehlt sie, ist die Installation kaputt und es darf scheitern.
|
||||
fn placeholder() -> Image {
|
||||
let name = props::FALLBACK_TEXTURE;
|
||||
image::load(&assets::path(&format!("assets/textures/{name}.png")))
|
||||
.expect("Platzhalter-Textur (Projekt-Asset)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
||||
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
|
||||
@@ -242,6 +250,9 @@ struct App {
|
||||
/// melden, nicht jeden Klick).
|
||||
audio: Option<audio::Audio>,
|
||||
sfx_cache: HashMap<String, Option<audio::Sound>>,
|
||||
/// Post-Effekt-Kette (Renderer-Zustand, nicht im Kern). Pro Frame aus
|
||||
/// `session.take_post()` nachgeführt, dann an `gpu.frame`.
|
||||
post: post::PostChain,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -271,6 +282,10 @@ impl App {
|
||||
fonts,
|
||||
audio,
|
||||
sfx_cache: HashMap::new(),
|
||||
// Die Post-Kette gehört dem Renderer, nicht dem Kern: geschaltet
|
||||
// wird sie über `Action::SetPost` (Trigger/Ink/Konsole), die die
|
||||
// Session als `take_post`-Queue durchreicht.
|
||||
post: post::PostChain::defaults(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +452,15 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// Post-Effekt-Schaltungen (SetPost-Actions von Trigger/Ink/Konsole)
|
||||
// auf die eigene Kette anwenden; unbekannter Name wird gemeldet, wie
|
||||
// ein fehlendes WAV bei play_sound.
|
||||
for (name, on) in self.session.take_post() {
|
||||
if !self.post.set(&name, on) {
|
||||
eprintln!("[post] unbekannter Effekt {name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Am Boden gleitet die Augenhöhe sanft nach (siehe EYE_SMOOTH_TAU); in
|
||||
// der Luft und außerhalb des Spiels folgt sie exakt.
|
||||
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
|
||||
@@ -494,7 +518,7 @@ impl App {
|
||||
}
|
||||
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.frame(&view, &screen.ui, &self.session.post);
|
||||
gpu.frame(&view, &screen.ui, &self.post);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,14 @@
|
||||
//! Effekt über den Frame wissen kann; beide darf ein Effekt ignorieren. Kein
|
||||
//! Effekt kennt einen anderen, keiner darf einen anderen voraussetzen. Ein
|
||||
//! Effekt wird hinzugefügt, indem er in [`EFFECTS`] auftaucht; geschaltet
|
||||
//! wird er über `engine::post` (Konsole: `post`).
|
||||
//! wird er über [`PostChain`], die Trigger, Ink-Tags und die Konsole per
|
||||
//! `game::Action::SetPost` erreichen. Der Kern kennt die Kette nicht.
|
||||
//!
|
||||
//! Gerechnet wird auf zwei Ping-Pong-Targets in interner Auflösung. Bei
|
||||
//! 640×480 kostet ein zusätzlicher Fullscreen-Pass praktisch nichts —
|
||||
//! deshalb darf jeder Effekt seinen eigenen behalten, statt sie zu einem
|
||||
//! Über-Shader zu verschmelzen.
|
||||
|
||||
use crate::engine::post::PostChain;
|
||||
|
||||
/// Die Kette in Ausführungsreihenfolge, mit ihrem Startzustand. `dither`
|
||||
/// quantisiert die Farbtiefe und muss deshalb zuletzt laufen.
|
||||
const EFFECTS: &[(&str, bool, &str)] = &[
|
||||
@@ -30,10 +29,34 @@ const EFFECTS: &[(&str, bool, &str)] = &[
|
||||
("dither", true, include_str!("dither.wgsl")),
|
||||
];
|
||||
|
||||
/// Effektnamen samt Startzustand — das Frontend meldet sie beim Start in der
|
||||
/// Session an.
|
||||
pub fn effects() -> impl Iterator<Item = (&'static str, bool)> {
|
||||
EFFECTS.iter().map(|(n, on, _)| (*n, *on))
|
||||
/// An/Aus je Post-Effekt, in Ketten-Reihenfolge. Das Fenster hält sie als
|
||||
/// eigenen Zustand (nicht der Kern), wendet die per `Session::take_post`
|
||||
/// aufgelaufenen `SetPost`-Schaltungen jeden Frame an und gibt die Kette an
|
||||
/// [`Post::run`].
|
||||
pub struct PostChain {
|
||||
effects: Vec<(String, bool)>,
|
||||
}
|
||||
|
||||
impl PostChain {
|
||||
/// Die Kette aus [`EFFECTS`] mit ihren Startzuständen.
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
effects: EFFECTS.iter().map(|(n, on, _)| ((*n).to_string(), *on)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(&self, name: &str) -> bool {
|
||||
self.effects.iter().any(|(n, on)| n == name && *on)
|
||||
}
|
||||
|
||||
/// Effekt `name` auf `on` setzen. Rückgabe: `false`, wenn kein Effekt so
|
||||
/// heißt — der Aufrufer meldet das (wie ein fehlendes WAV bei `play_sound`).
|
||||
pub fn set(&mut self, name: &str, on: bool) -> bool {
|
||||
match self.effects.iter_mut().find(|(n, _)| n == name) {
|
||||
Some(e) => { e.1 = on; true }
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spiegelt `Uniforms` in den Effekt-Shadern. `near`/`far` müssen zur
|
||||
@@ -304,3 +327,32 @@ fn make_bind(
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_follow_effects_table() {
|
||||
let c = PostChain::defaults();
|
||||
// Startzustände aus EFFECTS.
|
||||
assert!(!c.enabled("normals"));
|
||||
assert!(c.enabled("outline"));
|
||||
assert!(c.enabled("dither"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_switches_and_reports_unknown() {
|
||||
let mut c = PostChain::defaults();
|
||||
assert!(c.set("outline", false));
|
||||
assert!(!c.enabled("outline"));
|
||||
assert!(c.set("outline", true));
|
||||
assert!(c.enabled("outline"));
|
||||
assert!(!c.set("bloom", true), "unbekannter Effekt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_effect_is_off() {
|
||||
assert!(!PostChain::defaults().enabled("bloom"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
//! Blender-Modell → Render-Geometrie.
|
||||
//!
|
||||
//! Nimmt das neutrale [`Model`] aus engine::gltf und baut daraus ein
|
||||
//! Nimmt das geparste [`Model`] aus engine::gltf und baut daraus ein
|
||||
//! [`Mesh`] (Vertices + nach Textur gruppierte Index-Batches); mehrere
|
||||
//! Modelle verschmelzen über `Mesh::append` zu einem Buffer.
|
||||
//!
|
||||
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst zu
|
||||
//! `assets/textures/{name}.tga` (macht der Aufrufer). Faces ohne Material
|
||||
//! (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
||||
//! Texturen liegen als Pixel im Modell (`model::Texture`, aus der `.glb`).
|
||||
//! Hier wird nur nach ihrem Schlüssel gruppiert; Texturen ohne Bild —
|
||||
//! Material ohne baseColorTexture oder unlesbar — landen unter
|
||||
//! [`FALLBACK_TEXTURE`], dessen Pixel der Aufrufer stellt.
|
||||
//!
|
||||
//! UVs kommen unverändert durch (Modell und `tga::Image` teilen den
|
||||
//! Oben-links-Ursprung). Nur `visible`-Objekte werden ausgegeben;
|
||||
//! UVs kommen unverändert durch (Modell und `Image` teilen den
|
||||
//! Oben-links-Ursprung; `KHR_texture_transform` hat der Loader schon
|
||||
//! eingebacken). Nur `visible`-Objekte werden ausgegeben;
|
||||
//! Collider-/Zonen-Volumen sind Sache von engine::collision und
|
||||
//! engine::trigger.
|
||||
|
||||
use crate::engine::model::Model;
|
||||
use crate::engine::model::{Model, Texture};
|
||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||
|
||||
/// Texturname für Faces ohne Material (`""`).
|
||||
/// Schlüssel für Texturen ohne eigenes Bild; der Aufrufer lädt die Pixel
|
||||
/// dazu (`assets/textures/placeholder.png`).
|
||||
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||
|
||||
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
||||
/// Fallback gemappt.
|
||||
pub fn texture_names(model: &Model) -> Vec<String> {
|
||||
let mut names: Vec<String> = model.materials.iter()
|
||||
.map(|m| resolve(m).to_string())
|
||||
/// Distinkte Textur-Schlüssel des Modells (sortiert), bildlose bereits auf
|
||||
/// den Fallback gemappt.
|
||||
pub fn texture_keys(model: &Model) -> Vec<String> {
|
||||
let mut keys: Vec<String> = model.textures.iter()
|
||||
.map(|t| texture_key(t).to_string())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
keys
|
||||
}
|
||||
|
||||
fn resolve(material: &str) -> &str {
|
||||
if material.is_empty() { FALLBACK_TEXTURE } else { material }
|
||||
/// Gruppierungs-Schlüssel einer Textur: ihr eigener, sofern sie Pixel hat —
|
||||
/// sonst der Fallback. Der Renderer sammelt darüber die Pixel je Schlüssel.
|
||||
pub fn texture_key(t: &Texture) -> &str {
|
||||
match &t.image {
|
||||
Some(_) if !t.key.is_empty() => &t.key,
|
||||
_ => FALLBACK_TEXTURE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt die Textur-
|
||||
@@ -53,7 +61,7 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
||||
}
|
||||
id += 1;
|
||||
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
|
||||
let name = resolve(&model.materials[m]);
|
||||
let name = texture_key(&model.textures[m]);
|
||||
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
|
||||
per_tex[ti].extend(t.map(|i| base + i as u32));
|
||||
}
|
||||
@@ -73,8 +81,19 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::image::Image;
|
||||
use crate::engine::model::{Model, Object, Props};
|
||||
|
||||
/// 1×1-Platzhalterbild, damit eine Textur „Pixel hat".
|
||||
fn pixel() -> Image {
|
||||
Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] }
|
||||
}
|
||||
|
||||
/// Textur mit Bild und Schlüssel `key`.
|
||||
fn tex(key: &str) -> Texture {
|
||||
Texture { key: key.into(), image: Some(pixel()) }
|
||||
}
|
||||
|
||||
/// Ein Dreieck mit UVs; `visible` steuert, ob es gerendert werden darf.
|
||||
fn tri(name: &str, visible: bool) -> Object {
|
||||
Object {
|
||||
@@ -93,14 +112,14 @@ mod tests {
|
||||
Model {
|
||||
objects: vec![tri("Ding", true), tri("Proxy", false)],
|
||||
empties: Vec::new(),
|
||||
materials: vec!["carpet".to_string()],
|
||||
textures: vec![tex("carpet")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_only_visible_objects() {
|
||||
let m = sample();
|
||||
let names = texture_names(&m);
|
||||
let names = texture_keys(&m);
|
||||
assert_eq!(names, vec!["carpet".to_string()]);
|
||||
let mesh = build(&m, &names);
|
||||
|
||||
@@ -114,7 +133,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn visible_objects_get_distinct_ids_across_models() {
|
||||
let names = texture_names(&sample());
|
||||
let names = texture_keys(&sample());
|
||||
// Zwei sichtbare Objekte + ein unsichtbarer Proxy je Modell.
|
||||
let mut m = sample();
|
||||
m.objects.insert(1, tri("Zweites", true));
|
||||
@@ -133,11 +152,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_material_maps_to_fallback() {
|
||||
fn imageless_texture_maps_to_fallback() {
|
||||
let mut m = sample();
|
||||
m.materials = vec![String::new()]; // Faces ohne Material
|
||||
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
||||
let mesh = build(&m, &texture_names(&m));
|
||||
// Material ohne baseColorTexture: Schlüssel gesetzt, aber kein Bild.
|
||||
m.textures = vec![Texture { key: "nackt".into(), image: None }];
|
||||
assert_eq!(texture_keys(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
||||
let mesh = build(&m, &texture_keys(&m));
|
||||
assert_eq!(mesh.batches.len(), 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||
//!
|
||||
//! Geometrie + Texturen kommen vom Aufrufer (Blender-Modelle via
|
||||
//! render::props; Bilder aus engine::tga). Pro Textur eine Bind-Group +
|
||||
//! ein Draw-Batch (Material-Batching „pro Textur ein Draw" wie im Plan).
|
||||
//! render::props; Bilder als RGBA8-`Image`, aus der `.glb` dekodiert). Pro
|
||||
//! Textur eine Bind-Group + ein Draw-Batch („pro Textur ein Draw" wie im Plan).
|
||||
//! Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::image::Image;
|
||||
use crate::render::math::Mat4;
|
||||
|
||||
#[repr(C)]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU), und über
|
||||
//! dieselbe `scene::upload_texture`, nur ohne Mipchain.
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::image::Image;
|
||||
use crate::render::scene::upload_texture;
|
||||
|
||||
#[repr(C)]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
||||
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::image::Image;
|
||||
use crate::render::font::{self, Font, Fonts};
|
||||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||||
use crate::session::{Dialog, Mode, Session};
|
||||
|
||||
@@ -3,9 +3,8 @@ use crate::engine::collision::CollisionWorld;
|
||||
use crate::engine::game::{Action, Game, ModeTarget};
|
||||
use crate::engine::ink::StoryState;
|
||||
use crate::engine::player::{self, Player};
|
||||
use crate::engine::post::PostChain;
|
||||
use crate::engine::trigger::Triggers;
|
||||
use crate::engine::{kv, model, signals, story_ctrl};
|
||||
use crate::engine::{model, signals, story_ctrl};
|
||||
|
||||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
|
||||
const LOOK_SENS: f32 = 0.0025;
|
||||
@@ -44,13 +43,14 @@ pub struct Session {
|
||||
/// Ambient-Emitter aus den Blender-Modellen (`sound`-Property auf
|
||||
/// Empties, siehe engine::audio).
|
||||
emitters: Emitters,
|
||||
/// An/Aus der Render-Post-Effekte. Das Fenster-Frontend meldet seine Kette
|
||||
/// beim Start an und liest sie jeden Frame; die CLI lässt sie leer.
|
||||
pub post: PostChain,
|
||||
/// Von `Action::PlaySound` aufgelaufene Einmal-SFX — das Fenster-Frontend
|
||||
/// zieht sie per [`Session::take_sounds`]; der CLI reicht die
|
||||
/// `[action]`-Textzeile.
|
||||
pending_sounds: Vec<String>,
|
||||
/// Von `Action::SetPost` aufgelaufene Post-Effekt-Schaltungen (Name,
|
||||
/// Zielzustand) — das Fenster zieht sie per [`Session::take_post`] und
|
||||
/// wendet sie auf seine Kette an; der CLI reicht die `[action]`-Textzeile.
|
||||
pending_post: Vec<(String, bool)>,
|
||||
signals_path: String,
|
||||
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
||||
sim_accum: f32,
|
||||
@@ -97,8 +97,8 @@ impl Session {
|
||||
collision: CollisionWorld::empty(),
|
||||
triggers: Triggers::new(),
|
||||
emitters: Emitters::new(),
|
||||
post: PostChain::new(),
|
||||
pending_sounds: Vec::new(),
|
||||
pending_post: Vec::new(),
|
||||
signals_path,
|
||||
sim_accum: 0.0,
|
||||
pending_jump: false,
|
||||
@@ -138,6 +138,13 @@ impl Session {
|
||||
std::mem::take(&mut self.pending_sounds)
|
||||
}
|
||||
|
||||
/// Aufgelaufene Post-Effekt-Schaltungen (`SetPost`-Actions) abholen — jeden
|
||||
/// Frame vom Fenster-Frontend, das sie auf seine Kette anwendet. Headless
|
||||
/// bleibt die Queue liegen (die CLI hat keine Kette) und wird nie gezogen.
|
||||
pub fn take_post(&mut self) -> Vec<(String, bool)> {
|
||||
std::mem::take(&mut self.pending_post)
|
||||
}
|
||||
|
||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||
/// `exec`. Im Spielmodus läuft die Physik, im Noclip fliegt derselbe
|
||||
/// Spieler frei; Menü und Dialog pausieren die Welt. Rückgabe:
|
||||
@@ -255,7 +262,16 @@ impl Session {
|
||||
}
|
||||
None => ExecResult::lines(vec!["Modus: play | free | menu".into()]),
|
||||
},
|
||||
("post", args) => ExecResult::lines(self.post_cmd(args)),
|
||||
// Post-Effekt über dieselbe Action wie das `post`-Verb aus
|
||||
// signals.toml/Ink-Tags. Das Fenster löst den Namen gegen seine
|
||||
// Kette auf; headless bleibt es bei der `[action]`-Zeile.
|
||||
("post", args) => match signals::parse_post_arg(args) {
|
||||
Some((name, on)) => {
|
||||
self.game.actions.push(Action::SetPost { name, on });
|
||||
ExecResult::lines(self.drain_actions())
|
||||
}
|
||||
None => ExecResult::lines(vec!["post <name> on|off".into()]),
|
||||
},
|
||||
("signal", sig) => ExecResult::lines(self.fire(sig, None)),
|
||||
// Objekt-Interaktion (Klick wie Konsole): Klick-Ziele feuern ihre
|
||||
// `signal`-Property, sonst den suffix-befreiten Objektnamen.
|
||||
@@ -272,32 +288,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// `post` listet die Kette, `post <name> [on|off]` schaltet sie (ohne
|
||||
/// Zustand: toggle). Die Namen kommen vom Frontend — die Session validiert
|
||||
/// nur gegen das, was angemeldet wurde.
|
||||
fn post_cmd(&mut self, args: &str) -> Vec<String> {
|
||||
if args.is_empty() {
|
||||
let lines: Vec<String> = self.post.list()
|
||||
.map(|(n, on)| format!(" {n:<10} {}", on_off(on)))
|
||||
.collect();
|
||||
return match lines.is_empty() {
|
||||
true => vec!["keine Post-Effekte (nur im Fenster-Frontend)".into()],
|
||||
false => lines,
|
||||
};
|
||||
}
|
||||
let (name, state) = args.split_once(char::is_whitespace).unwrap_or((args, ""));
|
||||
let on = match state.trim() {
|
||||
"" => None,
|
||||
"on" | "an" => Some(true),
|
||||
"off" | "aus" => Some(false),
|
||||
_ => return vec!["post <name> [on|off]".into()],
|
||||
};
|
||||
match self.post.set(name, on) {
|
||||
Some(now) => vec![format!("{name}: {}", on_off(now))],
|
||||
None => vec![format!("unbekannter Effekt: {name:?} — `post` listet sie")],
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_input(&mut self, line: &str) -> ExecResult {
|
||||
let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
|
||||
let sel = if d.choices.is_empty() {
|
||||
@@ -378,6 +368,10 @@ impl Session {
|
||||
self.set_mode(t);
|
||||
out.push(format!("[action] mode {}", t.label()));
|
||||
}
|
||||
Action::SetPost { name, on } => {
|
||||
out.push(format!("[action] post {name} {}", if on { "on" } else { "off" }));
|
||||
self.pending_post.push((name, on));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -399,15 +393,11 @@ impl Session {
|
||||
let mut keys: Vec<&String> = self.game.kv.keys().collect();
|
||||
keys.sort();
|
||||
keys.into_iter()
|
||||
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
|
||||
.map(|k| format!(" {k} = {}", self.game.kv[k]))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "an" } else { "aus" }
|
||||
}
|
||||
|
||||
fn help() -> Vec<String> {
|
||||
vec![
|
||||
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||
@@ -417,7 +407,7 @@ fn help() -> Vec<String> {
|
||||
" kv KV-Store anzeigen".into(),
|
||||
" menu Menü öffnen / ins Spiel zurück".into(),
|
||||
" mode <m> Modus setzen: play | free (Noclip) | menu".into(),
|
||||
" post [<e> on|off] Post-Effekte anzeigen / schalten".into(),
|
||||
" post <e> on|off Post-Effekt schalten (Fenster-Frontend)".into(),
|
||||
" reload signals.toml neu laden".into(),
|
||||
" quit beenden".into(),
|
||||
]
|
||||
@@ -426,6 +416,7 @@ fn help() -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::kv::Value;
|
||||
|
||||
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
|
||||
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
|
||||
@@ -450,7 +441,7 @@ mod tests {
|
||||
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
|
||||
let r = s.exec("signal set has_key true");
|
||||
assert!(!r.quit);
|
||||
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
|
||||
assert!(matches!(s.game.kv["has_key"], Value::Bool(true)));
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
}
|
||||
|
||||
@@ -466,7 +457,7 @@ mod tests {
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
|
||||
s.exec("signal set in_menu true");
|
||||
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
||||
assert!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -567,7 +558,7 @@ mod tests {
|
||||
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
|
||||
],
|
||||
empties: Vec::new(),
|
||||
materials: vec![String::new()],
|
||||
textures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +572,7 @@ mod tests {
|
||||
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
|
||||
// Flanke genau einmal (danach: drin = still).
|
||||
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
|
||||
assert!(matches!(s.game.kv["zone_hit"], Value::Bool(true)), "Zone sollte gefeuert haben");
|
||||
|
||||
// Weiterlaufen in der Zone darf nicht erneut feuern.
|
||||
s.game.kv.remove("zone_hit");
|
||||
@@ -596,7 +587,7 @@ mod tests {
|
||||
s.mode = Mode::FirstPerson;
|
||||
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
|
||||
s.exec("use Kiste");
|
||||
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
||||
assert!(matches!(s.game.kv["kiste_geklickt"], Value::Bool(true)));
|
||||
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
||||
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
||||
let r = s.exec("use Irgendwas.003");
|
||||
@@ -637,27 +628,35 @@ mod tests {
|
||||
props: crate::engine::model::Props::new(),
|
||||
},
|
||||
],
|
||||
materials: Vec::new(),
|
||||
textures: Vec::new(),
|
||||
});
|
||||
assert_eq!(s.player.pos, [3.0, 0.5, -2.0], "role=spawn setzt den Fußpunkt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_command_switches_registered_effects() {
|
||||
fn post_command_queues_setpost_for_the_frontend() {
|
||||
let mut s = empty_session();
|
||||
// Ohne Frontend ist die Kette leer — der Befehl sagt das, statt zu lügen.
|
||||
assert_eq!(s.exec("post").output.len(), 1);
|
||||
assert!(!s.post.enabled("dither"));
|
||||
s.mode = Mode::Free;
|
||||
// Der Befehl kennt die Kette nicht — er reiht die Schaltung ein, das
|
||||
// Fenster zieht sie per take_post().
|
||||
let r = s.exec("post outline off");
|
||||
assert_eq!(r.output, vec!["[action] post outline off"]);
|
||||
assert_eq!(s.take_post(), vec![("outline".to_string(), false)]);
|
||||
assert!(s.take_post().is_empty(), "abgeholt = geleert");
|
||||
|
||||
s.post.register("dither", true);
|
||||
assert_eq!(s.exec("post").output.len(), 1, "eine Zeile je Effekt");
|
||||
s.exec("post dither off");
|
||||
assert!(!s.post.enabled("dither"));
|
||||
s.exec("post dither");
|
||||
assert!(s.post.enabled("dither"), "ohne Zustand togglet es");
|
||||
// Unbekannter Effekt meldet sich und lässt die Kette in Ruhe.
|
||||
assert_eq!(s.exec("post bloom on").output.len(), 1);
|
||||
assert!(s.post.enabled("dither"));
|
||||
// Fehlendes/ungültiges on|off → Nutzungshinweis, nichts eingereiht.
|
||||
assert_eq!(s.exec("post outline").output, vec!["post <name> on|off"]);
|
||||
assert_eq!(s.exec("post outline wat").output, vec!["post <name> on|off"]);
|
||||
assert!(s.take_post().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_signal_from_table_queues_setpost() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::Free;
|
||||
// Ink-Tag-/signals.toml-Pfad: `post`-Verb direkt als Action.
|
||||
s.exec("signal post dither on");
|
||||
assert_eq!(s.take_post(), vec![("dither".to_string(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,3 +27,29 @@ Das Skript sucht den Ink-Compiler in dieser Reihenfolge:
|
||||
1. `$INKLECATE` (Pfad zum Binary)
|
||||
2. `tools/inklecate` (mitgeliefert)
|
||||
3. `inklecate` im `PATH`
|
||||
|
||||
## build-models.sh
|
||||
|
||||
Exportiert die Blender-Welten zu den `.glb`, die das Spiel lädt.
|
||||
|
||||
```sh
|
||||
./tools/build-models.sh # nur Geändertes neu bauen
|
||||
./tools/build-models.sh -f # alles neu bauen
|
||||
```
|
||||
|
||||
Workflow für die Autorin: `.blend` in den jeweiligen Map-Ordner legen —
|
||||
`assets/maps/<map>/<name>.blend` — Skript laufen lassen, fertig. Die
|
||||
`.glb` landen direkt daneben (`assets/maps/<map>/<name>.glb`) und werden
|
||||
mit ins Repo eingecheckt, damit ein frischer Clone ohne Blender spielbar
|
||||
ist. Ein `.glb` ohne `.blend` daneben (z.B. `props/testscene.glb`) bleibt
|
||||
unangetastet.
|
||||
|
||||
**Automatisch beim Build:** `cargo build`/`cargo run` ruft dieses Skript
|
||||
über `build.rs` selbst auf, sobald sich eine `.blend` geändert hat.
|
||||
|
||||
Blender wird über `$BLENDER` oder den `PATH` gefunden. Fehlt es, wird der
|
||||
Schritt übersprungen (kein Fehler — die committeten `.glb` reichen).
|
||||
Bundeln geht nicht, dafür ist Blender zu groß.
|
||||
|
||||
Tipp: in Blender „File → External Data → Pack Resources", dann liegen die
|
||||
Texturen in der `.blend` und der Export ist self-contained.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Exportiert alle Blender-Welten unter assets/maps/ zu den .glb, die das
|
||||
# Spiel lädt.
|
||||
#
|
||||
# Für die Autorin: Leg deine .blend in den jeweiligen Map-Ordner
|
||||
# assets/maps/<map>/<name>.blend
|
||||
# und führ danach dieses Skript aus:
|
||||
# ./tools/build-models.sh
|
||||
# Fertig — die .glb landen direkt daneben (assets/maps/<map>/<name>.glb).
|
||||
#
|
||||
# Standardmäßig wird nur exportiert, was sich seit dem letzten Mal geändert
|
||||
# hat. Mit -f / --force wird alles neu gebaut.
|
||||
#
|
||||
# Tipp: in Blender „File -> External Data -> Pack Resources", dann sind die
|
||||
# Texturen in der .blend und der Export ist self-contained.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Repo-Wurzel relativ zum Skript bestimmen — egal, von wo aufgerufen.
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$script_dir/.." && pwd)"
|
||||
maps_dir="$root/assets/maps"
|
||||
|
||||
force=0
|
||||
case "${1:-}" in
|
||||
-f|--force) force=1 ;;
|
||||
"") ;;
|
||||
*) echo "Unbekannte Option: $1 (nur -f/--force)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# blender finden: $BLENDER -> PATH. Bundeln geht nicht (zu groß) — fehlt es,
|
||||
# ist das kein Fehler: die committeten .glb reichen zum Bauen und Spielen.
|
||||
if [ -n "${BLENDER:-}" ] && [ -x "$BLENDER" ]; then
|
||||
blender="$BLENDER"
|
||||
elif command -v blender >/dev/null 2>&1; then
|
||||
blender="$(command -v blender)"
|
||||
else
|
||||
echo " übersprungen blender nicht gefunden (\$BLENDER oder PATH) — .glb bleiben wie committet" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$maps_dir" ]; then
|
||||
echo "Kein Map-Ordner: $maps_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Der Export als Python-Einzeiler. filepath ohne Endung — export_format='GLB'
|
||||
# hängt .glb an. export_extras trägt collide/signal/role mit.
|
||||
gltf_expr='
|
||||
import bpy, sys
|
||||
out = sys.argv[-1]
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=out,
|
||||
export_format="GLB",
|
||||
export_extras=True,
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_image_format="AUTO",
|
||||
)'
|
||||
|
||||
built=0 skipped=0 failed=0
|
||||
while IFS= read -r -d '' src; do
|
||||
out="${src%.blend}.glb"
|
||||
rel="${src#"$root"/}"
|
||||
|
||||
# Inkrementell: überspringen, wenn .glb existiert und neuer ist.
|
||||
if [ "$force" -eq 0 ] && [ -f "$out" ] && [ "$out" -nt "$src" ]; then
|
||||
printf ' aktuell %s\n' "$rel"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if log="$("$blender" -b --factory-startup "$src" \
|
||||
--python-expr "$gltf_expr" -- "${out%.glb}" 2>&1)"; then
|
||||
printf ' gebaut %s\n' "$rel"
|
||||
built=$((built + 1))
|
||||
else
|
||||
printf ' FEHLER %s\n' "$rel"
|
||||
printf '%s\n' "$log" | sed 's/^/ /'
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done < <(find "$maps_dir" -type f -name '*.blend' -print0 | sort -z)
|
||||
|
||||
echo "—— $built gebaut, $skipped aktuell, $failed fehlgeschlagen ——"
|
||||
[ "$failed" -eq 0 ]
|
||||