Der einzige verbliebene TGA-Nutzen — UI, Fonts, Platzhalter — läuft jetzt über engine::image::load (derselbe PNG-Decoder wie für die .glb). engine::tga ist damit weg. - image::load(path): PNG von der Platte, Format per Signatur. - Fonts/Cursor/Ornamente/Platzhalter als .png (Fonts + UI neu aus dem Quellmaterial exportiert, placeholder aus der alten .tga konvertiert). - Test loads_the_real_project_assets deckt die fünf Projekt-Assets ab. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
530 lines
22 KiB
Rust
530 lines
22 KiB
Rust
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||
//!
|
||
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
||
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
||
//! Welt — so sieht das Fenster nie einen anderen Zustand als die Konsole.
|
||
//!
|
||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||
//! deren Schnittstellen — nie umgekehrt.
|
||
|
||
mod audio;
|
||
mod font;
|
||
mod gpu;
|
||
mod math;
|
||
mod post;
|
||
mod props;
|
||
mod scene;
|
||
mod sprite;
|
||
mod ui;
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::mpsc::{self, Receiver};
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
|
||
use winit::application::ApplicationHandler;
|
||
use winit::event::{DeviceEvent, DeviceId, ElementState, KeyEvent, MouseButton, WindowEvent};
|
||
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::{assets, gltf, model, wav};
|
||
use crate::session::{FrameInput, Mode, Session};
|
||
use gpu::Gpu;
|
||
use math::Mat4;
|
||
use scene::Mesh;
|
||
|
||
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
||
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
||
/// folgen. Größer = weicher, aber träger.
|
||
const EYE_SMOOTH_TAU: f32 = 0.06;
|
||
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
||
const EYE_MAX_LAG: f32 = 0.6;
|
||
|
||
/// 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 `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 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_keys(m)); }
|
||
tex_names.sort_unstable();
|
||
tex_names.dedup();
|
||
|
||
// 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)); }
|
||
println!("[scene] {} Modelle, Texturen {:?} → {} Vertices, {} Dreiecke",
|
||
models.len(), tex_names, mesh.verts.len(), mesh.indices.len() / 3);
|
||
|
||
// Sim-Welt (Collider, Trigger, Emitter, Spawn-Empty) in die Session
|
||
// einspielen — die Simulation gehört der Session, nicht dem
|
||
// Render-Frontend.
|
||
for (_, m) in &models { session.load_props(m); }
|
||
|
||
// Audio-Treiber öffnen (vor dem stdin-Thread, siehe Audio::open) und je
|
||
// Ambient-Emitter eine Loop-Voice anlegen; die Gains zieht redraw()
|
||
// dann jeden Frame aus der Session. Ohne Gerät läuft das Spiel stumm.
|
||
let audio = audio::Audio::open();
|
||
if let Some(a) = &audio {
|
||
let sounds: Vec<Option<audio::Sound>> = session.emitters().iter()
|
||
.map(|e| load_sound(&e.sound))
|
||
.collect();
|
||
a.set_ambients(&sounds);
|
||
}
|
||
|
||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||
// FONT_CGA, CURSORS, ORN). Fonts, Cursor und Ornament sind weiß-auf-schwarz
|
||
// und bekommen ihre Alpha-Maske aus der Luminanz. Anders als
|
||
// Autorinnen-Content sind sie Projekt-Assets: fehlen sie, ist die
|
||
// Installation kaputt und es darf scheitern.
|
||
let load_keyed = |p: &str| {
|
||
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.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),
|
||
};
|
||
let ui_textures: Vec<Image> = vec![
|
||
ui::white_pixel(),
|
||
ega_img,
|
||
cga_img,
|
||
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
|
||
// (winit) nicht. Es queren nur Strings die Thread-Grenze, kein Zustand
|
||
// — der bleibt allein auf dem Main-Thread.
|
||
let (tx, rx) = mpsc::channel::<String>();
|
||
std::thread::spawn(move || {
|
||
let stdin = std::io::stdin();
|
||
let mut buf = String::new();
|
||
loop {
|
||
buf.clear();
|
||
match stdin.read_line(&mut buf) {
|
||
Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
|
||
Ok(_) => {
|
||
if tx.send(buf.trim_end().to_string()).is_err() { break; }
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
let event_loop = EventLoop::new().expect("winit: Event-Loop");
|
||
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
|
||
// pollen nebenbei den Befehls-Channel.
|
||
event_loop.set_control_flow(ControlFlow::Poll);
|
||
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts, audio);
|
||
event_loop.run_app(&mut app).expect("winit: run");
|
||
}
|
||
|
||
/// Alle Blender-Modelle (`.glb`) eines Verzeichnisses laden (sortiert →
|
||
/// deterministische Reihenfolge). Fehlende Verzeichnisse sind kein Fehler
|
||
/// (kein Props-Ordner = keine Props); kaputte Dateien werden gemeldet und
|
||
/// übersprungen.
|
||
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
|
||
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
|
||
.map(|e| e.path())
|
||
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("glb"))
|
||
.collect();
|
||
paths.sort();
|
||
let mut models = Vec::new();
|
||
for p in paths {
|
||
let path = p.to_string_lossy();
|
||
match gltf::load(&path) {
|
||
Ok(m) => {
|
||
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
|
||
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
||
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
||
m.objects.len(), m.empties.len());
|
||
// Properties mitloggen: so sieht die Autorin, ob ihr Export
|
||
// `collide`, `signal` und `role` mitgenommen hat.
|
||
for o in &m.objects {
|
||
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
||
}
|
||
for e in &m.empties {
|
||
println!("[model] Empty {} @ {:?}", e.name, e.pos);
|
||
for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); }
|
||
}
|
||
models.push((name, m));
|
||
}
|
||
Err(e) => eprintln!("[model] {e}"),
|
||
}
|
||
}
|
||
models
|
||
}
|
||
|
||
/// Sound laden: `name` → `assets/sounds/{name}.wav`. Autorinnen-Content —
|
||
/// Fehler werden gemeldet und ergeben Stille (`None`), nie einen Absturz.
|
||
fn load_sound(name: &str) -> Option<audio::Sound> {
|
||
match wav::load(&assets::path(&format!("assets/sounds/{name}.wav"))) {
|
||
Ok(w) => Some(audio::Sound::new(w)),
|
||
Err(e) => {
|
||
eprintln!("[audio] {e}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// den Frames akkumuliert und nach dem Anwenden genullt.
|
||
#[derive(Default)]
|
||
struct Input {
|
||
fwd: bool,
|
||
back: bool,
|
||
left: bool,
|
||
right: bool,
|
||
up: bool,
|
||
down: bool,
|
||
/// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht.
|
||
jump: bool,
|
||
mouse_dx: f32,
|
||
mouse_dy: f32,
|
||
/// Cursor gefangen → Maus-Look aktiv.
|
||
grabbed: bool,
|
||
}
|
||
|
||
struct App {
|
||
window: Option<Arc<Window>>,
|
||
gpu: Option<Gpu>,
|
||
session: Session,
|
||
rx: Receiver<String>,
|
||
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
||
eye_y: f32,
|
||
input: Input,
|
||
last: Option<Instant>,
|
||
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
|
||
cursor_win: [f32; 2],
|
||
/// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst.
|
||
pending_click: bool,
|
||
/// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die
|
||
/// GPU geladen.
|
||
mesh: Mesh,
|
||
images: Vec<Image>,
|
||
ui_textures: Vec<Image>,
|
||
fonts: font::Fonts,
|
||
/// Audio-Treiber (`None` = kein Gerät, Spiel läuft stumm) und der
|
||
/// Lazy-Cache der Einmal-SFX (`None` = Laden schlug fehl, nur einmal
|
||
/// 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 {
|
||
fn new(
|
||
session: Session,
|
||
rx: Receiver<String>,
|
||
mesh: Mesh,
|
||
images: Vec<Image>,
|
||
ui_textures: Vec<Image>,
|
||
fonts: font::Fonts,
|
||
audio: Option<audio::Audio>,
|
||
) -> Self {
|
||
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
||
Self {
|
||
window: None,
|
||
gpu: None,
|
||
session,
|
||
rx,
|
||
eye_y,
|
||
input: Input::default(),
|
||
last: None,
|
||
cursor_win: [0.0, 0.0],
|
||
pending_click: false,
|
||
mesh,
|
||
images,
|
||
ui_textures,
|
||
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(),
|
||
}
|
||
}
|
||
|
||
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
||
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
||
// Greif-Art: Locked (Spiel/Noclip, relativer Blick) ↔ None (freie Maus, UI).
|
||
fn set_grab(&mut self, grab: bool) {
|
||
let Some(window) = &self.window else { return; };
|
||
if grab {
|
||
// Locked (Cursor fixiert) ist am angenehmsten, wird aber nicht
|
||
// überall unterstützt → auf Confined zurückfallen.
|
||
let ok = window.set_cursor_grab(CursorGrabMode::Locked)
|
||
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
|
||
.is_ok();
|
||
if ok { self.input.grabbed = true; }
|
||
} else {
|
||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||
self.input.grabbed = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ApplicationHandler for App {
|
||
/// winit liefert das Fenster erst hier, nicht beim Loop-Start —
|
||
/// deshalb sind `window`/`gpu` Options statt Konstruktor-Felder.
|
||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||
if self.window.is_some() { return; }
|
||
let attrs = Window::default_attributes()
|
||
.with_title("wds")
|
||
// 2× interne Auflösung als Startgröße; frei resizebar.
|
||
.with_inner_size(winit::dpi::LogicalSize::new(
|
||
gpu::INTERNAL_W * 2, gpu::INTERNAL_H * 2));
|
||
let window = Arc::new(event_loop.create_window(attrs).expect("winit: Fenster"));
|
||
// OS-Cursor dauerhaft aus — das Overlay zeichnet immer einen eigenen.
|
||
window.set_cursor_visible(false);
|
||
self.gpu = Some(Gpu::new(
|
||
window.clone(),
|
||
event_loop.owned_display_handle(),
|
||
&self.mesh,
|
||
&self.images,
|
||
&self.ui_textures,
|
||
));
|
||
self.window = Some(window);
|
||
}
|
||
|
||
/// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll,
|
||
/// solange der Cursor gefangen ist.
|
||
fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
|
||
if let DeviceEvent::MouseMotion { delta } = event
|
||
&& self.input.grabbed {
|
||
self.input.mouse_dx += delta.0 as f32;
|
||
self.input.mouse_dy += delta.1 as f32;
|
||
}
|
||
}
|
||
|
||
/// Läuft einmal pro Loop-Durchlauf, nachdem die Events abgearbeitet
|
||
/// sind: Konsolen-Befehle einspielen und den nächsten Frame anfordern.
|
||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||
while let Ok(line) = self.rx.try_recv() {
|
||
let r = self.session.exec(&line);
|
||
for out in r.output { println!("{out}"); }
|
||
if r.quit { event_loop.exit(); }
|
||
}
|
||
if let Some(w) = &self.window { w.request_redraw(); }
|
||
}
|
||
|
||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||
match event {
|
||
WindowEvent::CloseRequested => event_loop.exit(),
|
||
WindowEvent::CursorMoved { position, .. } => {
|
||
self.cursor_win = [position.x as f32, position.y as f32];
|
||
}
|
||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
||
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
||
// Maus-Greifen.
|
||
self.pending_click = true;
|
||
}
|
||
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
||
WindowEvent::Resized(size) => {
|
||
if let Some(gpu) = &mut self.gpu {
|
||
gpu.resize(size.width, size.height);
|
||
}
|
||
}
|
||
WindowEvent::RedrawRequested => self.redraw(event_loop),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
fn on_key(&mut self, event_loop: &ActiveEventLoop, key: KeyEvent) {
|
||
let PhysicalKey::Code(code) = key.physical_key else { return; };
|
||
let down = key.state == ElementState::Pressed;
|
||
|
||
// Escape togglet das Menü über denselben exec-Trichter wie Konsole
|
||
// und Klick. Der Lock-Zustand folgt danach automatisch dem Modus
|
||
// (siehe redraw). Im Dialog tut Escape nichts (Dialog endet über
|
||
// seine Choices).
|
||
if code == KeyCode::Escape && down {
|
||
if !matches!(self.session.mode, Mode::Dialog(_)) {
|
||
let r = self.session.exec("menu");
|
||
for out in r.output { println!("{out}"); }
|
||
if r.quit { event_loop.exit(); }
|
||
}
|
||
return;
|
||
}
|
||
|
||
match code {
|
||
KeyCode::KeyW => self.input.fwd = down,
|
||
KeyCode::KeyS => self.input.back = down,
|
||
KeyCode::KeyA => self.input.left = down,
|
||
KeyCode::KeyD => self.input.right = down,
|
||
// Space: im Noclip „hoch" (gehalten), im Spiel Sprung (Flanke,
|
||
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
||
KeyCode::Space => {
|
||
self.input.up = down;
|
||
if down && !key.repeat { self.input.jump = true; }
|
||
}
|
||
KeyCode::ShiftLeft => self.input.down = down,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||
let now = Instant::now();
|
||
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||
|
||
// Lock-Zustand folgt dem Modus: Spiel und Noclip fangen die Maus
|
||
// (relativer Blick), Menü/Dialog geben sie frei fürs UI.
|
||
let fly = matches!(self.session.mode, Mode::Free);
|
||
let play = matches!(self.session.mode, Mode::FirstPerson);
|
||
let grab = fly || play;
|
||
if grab != self.input.grabbed { self.set_grab(grab); }
|
||
|
||
// Eingaben einsammeln; ob daraus Physik oder Flug wird, entscheidet
|
||
// die Session an ihrem Modus. Betretens-Zonen melden ihre
|
||
// Signal-Ausgaben wie exec-Befehle.
|
||
let input = FrameInput {
|
||
fwd: axis(self.input.fwd, self.input.back),
|
||
right: axis(self.input.right, self.input.left),
|
||
up: axis(self.input.up, self.input.down),
|
||
jump: self.input.jump,
|
||
look_dx: self.input.mouse_dx,
|
||
look_dy: self.input.mouse_dy,
|
||
};
|
||
for out in self.session.tick(&input, dt) { println!("{out}"); }
|
||
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||
self.input.mouse_dx = 0.0;
|
||
self.input.mouse_dy = 0.0;
|
||
self.input.jump = false;
|
||
|
||
// Audio: Distance ramps der Ambient-Emitter nachführen und
|
||
// aufgelaufene Einmal-SFX (play_sound-Actions) starten. Die Queue
|
||
// immer leeren, auch ohne Gerät — sonst wüchse sie unbegrenzt.
|
||
let sfx = self.session.take_sounds();
|
||
if let Some(a) = &self.audio {
|
||
a.set_ambient_gains(&self.session.emitter_gains());
|
||
for name in sfx {
|
||
let sound = self.sfx_cache.entry(name.clone())
|
||
.or_insert_with(|| load_sound(&name));
|
||
if let Some(s) = sound { a.play(s); }
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
if play && self.session.player.grounded {
|
||
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
||
self.eye_y += (target_eye - self.eye_y) * a;
|
||
self.eye_y = self.eye_y.clamp(target_eye - EYE_MAX_LAG, target_eye + EYE_MAX_LAG);
|
||
} else {
|
||
self.eye_y = target_eye;
|
||
}
|
||
|
||
// Alle Modi sehen durch die Augen des Players; Menü und Dialog frieren
|
||
// nur die Sim ein, nicht die Sicht.
|
||
let p = self.session.player.pos;
|
||
let view = Mat4::view(
|
||
[p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch);
|
||
|
||
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
|
||
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
||
// löst beim Klick unten das `use` aus.
|
||
let aim: Option<String> = if play {
|
||
let eye = self.session.player.eye();
|
||
let dir = self.session.player.look_dir();
|
||
self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Mausposition über die Letterbox in interne Pixel mappen.
|
||
let cursor = ui::Cursor {
|
||
pos: self.gpu.as_ref()
|
||
.map(|g| g.map_cursor(self.cursor_win))
|
||
.unwrap_or([0.0, 0.0]),
|
||
grabbed: self.input.grabbed,
|
||
hud_interact: aim.is_some(),
|
||
};
|
||
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
||
// Hover-Aktion kommt aus der Maus-Position.
|
||
let screen = ui::layout(
|
||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
||
);
|
||
|
||
// Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
|
||
// wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
|
||
// Spielmodus das Fadenkreuz-Ziel als `use <name>`.
|
||
if self.pending_click {
|
||
self.pending_click = false;
|
||
let action = screen.hover_action.clone()
|
||
.or_else(|| aim.map(|name| format!("use {name}")));
|
||
if let Some(action) = action {
|
||
let r = self.session.exec(&action);
|
||
for out in r.output { println!("{out}"); }
|
||
if r.quit { event_loop.exit(); }
|
||
}
|
||
}
|
||
|
||
if let Some(gpu) = &mut self.gpu {
|
||
gpu.frame(&view, &screen.ui, &self.post);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Zwei gegensätzliche Tasten zu einer Achse in {-1, 0, 1}.
|
||
fn axis(positive: bool, negative: bool) -> f32 {
|
||
(positive as i32 - negative as i32) as f32
|
||
}
|