First Person Controller

This commit is contained in:
2026-06-14 16:20:47 +02:00
parent 902da64588
commit a8bfaaee36
9 changed files with 921 additions and 38 deletions
+2 -8
View File
@@ -23,11 +23,9 @@
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
//! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`.
use crate::engine::map::{Face, Map};
use crate::engine::map::{self, Face, Map};
use crate::render::scene::{Batch, Mesh, Vertex};
/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme).
const MAP_SCALE: f32 = 1.0 / 32.0;
/// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units).
const ON_EPS: f32 = 1e-2;
@@ -207,7 +205,7 @@ fn emit_face(
// UV in Texeln, dann auf 0..1 normalisiert.
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
let v = (dot(*p, sv) + fp.face.offset[1]) / th as f32;
verts.push(Vertex { pos: to_engine(*p), uv: [u, v] });
verts.push(Vertex { pos: map::to_engine(*p), uv: [u, v] });
}
for t in 1..(fp.poly.len() as u32 - 1) {
per_tex[ti].extend([base, base + t, base + t + 1]);
@@ -273,10 +271,6 @@ fn base_axes(n: [f32; 3]) -> ([f32; 3], [f32; 3]) {
(BA[bi][1], BA[bi][2])
}
fn to_engine(p: [f32; 3]) -> [f32; 3] {
[p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE]
}
// --- kleine Vektor-Helfer auf [f32; 3] ---------------------------------------
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
+70 -10
View File
@@ -33,17 +33,25 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{CursorGrabMode, Window, WindowId};
use crate::engine::player;
use crate::engine::tga::Image;
use crate::engine::{assets, map, tga};
use crate::session::{Mode, Session};
use crate::session::{FrameInput, Mode, Session};
use camera::Camera;
use gpu::Gpu;
use math::Mat4;
use scene::Mesh;
/// Radiant pro Maus-Pixel.
const MOUSE_SENS: f32 = 0.0025;
/// Welt-Einheiten pro Sekunde.
const MOVE_SPEED: f32 = 4.0;
/// 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 (wie Source/HL2). 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;
pub fn run(mut session: Session) {
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
@@ -62,6 +70,10 @@ pub fn run(mut session: Session) {
let mesh = brush::build(&world, &tex_names, &dims);
report_map(&world, &tex_names, &mesh);
// Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die
// Simulation gehört der Session, nicht dem Render-Frontend.
session.load_world(&world);
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
@@ -134,6 +146,8 @@ struct Input {
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.
@@ -145,7 +159,10 @@ struct App {
gpu: Option<Gpu>,
session: Session,
rx: Receiver<String>,
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
camera: Camera,
/// 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).
@@ -169,6 +186,8 @@ impl App {
ui_textures: Vec<Image>,
fonts: font::Fonts,
) -> Self {
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
Self {
window: None,
gpu: None,
@@ -176,6 +195,7 @@ impl App {
rx,
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
camera: Camera::new([0.0, 1.0, 4.0]),
eye_y,
input: Input::default(),
last: None,
cursor_win: [0.0, 0.0],
@@ -298,7 +318,12 @@ impl App {
KeyCode::KeyS => self.input.back = down,
KeyCode::KeyA => self.input.left = down,
KeyCode::KeyD => self.input.right = down,
KeyCode::Space => self.input.up = down,
// Space: in der Flycam „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,
_ => {}
}
@@ -310,25 +335,60 @@ impl App {
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: nur in Free ist die Flycam aktiv
// (Maus gefangen, relativer Blick). Menü/Dialog geben die Maus frei
// fürs UI. So gibt es keine parallele Lock-Logik mehr.
let fly = matches!(self.session.mode, Mode::Free);
if fly != self.input.grabbed { self.set_grab(fly); }
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
// gibt es keine parallele Lock-Logik.
let fly = matches!(self.session.mode, Mode::Free);
let play = matches!(self.session.mode, Mode::Play);
let grab = fly || play;
if grab != self.input.grabbed { self.set_grab(grab); }
if fly {
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
let fwd = axis(self.input.fwd, self.input.back);
let right = axis(self.input.right, self.input.left);
let up = axis(self.input.up, self.input.down);
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
} else if play {
// Eingaben einsammeln und die Simulation in der Session treiben.
let input = FrameInput {
fwd: axis(self.input.fwd, self.input.back),
right: axis(self.input.right, self.input.left),
jump: self.input.jump,
look_dx: self.input.mouse_dx,
look_dy: self.input.mouse_dy,
};
self.session.tick(&input, dt);
}
// Delta immer verwerfen, auch im Dialog — sonst springt der Blick
// beim Fortsetzen um die aufgestaute Bewegung.
// 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;
let view = self.camera.view();
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
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;
}
// View: im Spiel aus dem Player der Session (geglättete Augenhöhe),
// sonst aus der Flycam.
let view = if play {
let p = self.session.player.pos;
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
} else {
self.camera.view()
};
// Mausposition über die Letterbox in interne Pixel mappen.
let cursor = ui::Cursor {
pos: self.gpu.as_ref()
+4 -2
View File
@@ -226,7 +226,9 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
if !cur.grabbed {
match &session.mode {
Mode::Free => {} // freie Welt: nur HUD/Cursor, kein Panel
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
Mode::Play | Mode::Free => {}
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover),
}
@@ -247,7 +249,7 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
/// Menu-Modus: das (noch demohafte) Menü, zentriert.
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
let items = [
("Fortsetzen", "menu"), // schließt das Menü (Toggle zurück zu Free)
("Fortsetzen", "menu"), // schließt das Menü → ins Spiel (Play)
("Hilfe", "help"),
("KV anzeigen", "kv"),
("Beenden", "quit"),