Skip to content

Commit cf305fa

Browse files
authored
fix(app): locomotive height, pause menu teardown, and quality settings (#66)
- Fix locomotive floating by translating mesh geometry instead of transform - Add "Back to menu" option in pause overlay with proper world teardown - Extend graphics settings: texture quality, shadow/mist/AA quality, window modes, FPS cap - Apply anti-aliasing live; destroy run world when leaving to menu (cleanup: resources, observers, persistent entities) - Localize all new setting keys in en/de
1 parent 1712963 commit cf305fa

14 files changed

Lines changed: 865 additions & 98 deletions

File tree

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,26 @@ the next run. View distance moves the streamer's load radius while tiles are in
100100
bloom is added to and taken off the live camera, and the rest is re-read where it is used.
101101

102102
`Esc` during a run raises the **pause overlay** — the world stands still under it — with
103-
**Resume**, **Settings** and **Quit**. Its settings page is the same one, minus the language
104-
(not a driving decision) and the reset (too blunt to have under the cursor while a train is
105-
standing on a gradient); everything on it takes effect while you watch. `Esc` on the overlay
106-
resumes.
103+
**Resume**, **Settings**, **Back to the main menu** and **Quit**. Its settings page is the
104+
same one, minus the language (not a driving decision) and the reset (too blunt to have under
105+
the cursor while a train is standing on a gradient); everything on it takes effect while you
106+
watch. `Esc` on the overlay resumes; going back to the main menu ends the run and takes the
107+
built world down with it, so the next one starts from an empty world.
107108

108109
| Section | Setting | Effect |
109110
|---|---|---|
110111
| `[graphics]` | `view_distance` | How far terrain is built and drawn [m], 1000 … 12000. The biggest single cost. |
111112
| | `shadows` | Shadow maps of the sun. |
112113
| | `bloom` | Glow around lamps and signals after dark. |
113-
| | `fullscreen` | Borderless, on the monitor the window is on. |
114+
| | `shadow_quality` | Edge length of the sun's shadow map: `Low` 1024, `Medium` 2048, `High` 4096 texels. |
115+
| | `mist` | Ground mist as a volume, with the sun's shafts through it. |
116+
| | `mist_quality` | Steps of the raymarch through it: `Low` 16, `Medium` 32, `High` 64. |
117+
| | `texture_quality` | Size and filtering of the generated ground textures: `Low` 128², `Medium` 256², `High` 512². |
118+
| | `anti_aliasing` | How the edges are smoothed: `Off`, `Fxaa`, `Smaa` or `Msaa`. |
119+
| | `aa_quality` | How hard that works: `Low`, `Medium` or `High` — 2×/4×/8× for MSAA, the preset for the other two. |
120+
| | `window` | `Windowed`, `Borderless` over the whole monitor, or exclusive `Fullscreen`. |
114121
| | `vsync` | Caps the frame rate at the monitor's. |
122+
| | `max_fps` | Frames a second the simulator holds itself to, 30 … 240; the top step (250) is no cap at all. |
115123
| `[audio]` | `master` | Linear master volume, 0 … 1. |
116124
| `[gameplay]` | `language` | `en`, `de`, or empty for the system's. |
117125
| | `hud` | How much of the display is drawn: `Full`, `Reduced` or `Off` (`F7` walks the three). |
@@ -370,7 +378,7 @@ any other (`[gameplay] hud` — `Full`, `Reduced`, `Off`), so it survives the ru
370378
| `6` / `7` / `8` | AFB on/off / dial down / dial up (in 10 km/h steps) |
371379
| `9` / `0` | Headlights / cab light |
372380
| `,` / `.` | Instrument backlighting dimmer down / up |
373-
| `Esc` | Pause: resume, settings, quit — the world stands still under the overlay |
381+
| `Esc` | Pause: resume, settings, back to the main menu, quit — the world stands still under the overlay |
374382
| `F1``F4` | Camera: driver's seat / external / lineside / first person |
375383
| `F5` / `F6` | Keyboard sheet / diagnostics overlay |
376384
| `F7` | Display: full → reduced → off, and round again |

STATUS.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -545,14 +545,32 @@ As of 2026-08-19 · `cargo test --workspace`: **524 tests green** · clippy and
545545
streamer's load radius and the view distance while tiles are in the air and adds or
546546
removes `Bloom` on the live camera, `apply_window` carries fullscreen and vertical sync
547547
onto the window, and language, volume, HUD and look sensitivity are re-read where they
548-
are used. Nothing waits for a restart; a setting that needs one is an excuse. **Esc
548+
are used. Anti-aliasing is two rows of its own — the technique (off, FXAA, SMAA, MSAA)
549+
and how hard it works (2×/4×/8× for MSAA, the preset for the other two) — swapped on the
550+
live camera as three different components, MSAA off wherever a post pass does the job.
551+
Shadows, mist and ground textures carry a quality of their own on the same Low/Medium/
552+
High scale: the sun's shadow map at 1024/2048/4096 texels, the raymarch through the mist
553+
at 16/32/64 steps, and the generated ground textures at 128²/256²/512² with 1×/4×/16×
554+
anisotropy — the last of them written back into the handles the terrain material already
555+
holds, so it reaches the tiles standing on screen and not only the ones built after it.
556+
The window is a three-way choice (windowed, borderless, exclusive fullscreen; the
557+
exclusive one names the primary monitor, because a window that does not exist yet is on
558+
none and Bevy panics rather than guessing), and a frame cap holds the program to a rate
559+
of its own — a slot-based sleep in `Last` that keeps its rhythm while it is met and
560+
starts afresh when it is missed, with the slider's top step meaning no cap at all.
561+
Nothing waits for a restart; a setting that needs one is an excuse. **Esc
549562
during a run raises the same menu as an overlay** (`GameState::Paused`, `spawn_pause`):
550563
no camera of its own — the cab's draws the UI — no wallpaper, a thinner scrim so the
551-
world stays recognisable, and Resume / Settings / Quit. Every driving system is gated on
552-
`Driving`, so the pause freezes simulation, clock and camera by itself. The overlay's
553-
settings page is the front end's minus the language and the reset. Going back to the
554-
title screen is **not** offered there: the world `setup` builds carries no despawn
555-
marker, so tearing it down again is its own piece of work.
564+
world stays recognisable, and Resume / Settings / Back to the main menu / Quit. Every
565+
driving system is gated on `Driving`, so the pause freezes simulation, clock and camera
566+
by itself. The overlay's settings page is the front end's minus the language and the
567+
reset. **Going back to the title screen tears the built world down** (`tear_down_run`):
568+
the run carries no despawn marker, so what is dropped is decided by a snapshot of the
569+
entities that existed *before* `setup` — everything newer goes, except resources (which
570+
are entities of their own in Bevy 0.19), observers, and what a plugin put up once at
571+
startup and marked `world_render::Persistent` (the cloud dome, the mist volume). The
572+
mixer's tracks are dropped with it, so the loops of the run stop, and walker and camera
573+
state — both of which point into the world that has just gone — go back to default.
556574
Any run flag on the command line (`--line`, `--frames`, …) skips the menu, so CLI and CI
557575
invocations stay non-interactive, and a flag beats the menu's choice where both are set;
558576
`--menu` puts the menu back in front, which is the only way to photograph it.

crates/app/src/audio.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ impl Audio {
226226
.main_track()
227227
.set_volume(decibels(master), Tween::default());
228228
}
229+
230+
/// Stops everything the run had playing. Dropping a track handle takes the track and
231+
/// the loops on it with it — the same tear-down `setup_audio` does before it builds
232+
/// the next run's, only this time nothing is built afterwards.
233+
pub fn silence(&mut self) {
234+
self.loops.clear();
235+
self.emitters.clear();
236+
self.previous.clear();
237+
}
229238
}
230239

231240
/// Opens the output device and inserts the mixer.

crates/app/src/main.rs

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod walk;
2222
mod world;
2323

2424
use ai_driver::AiDriver;
25+
use bevy::ecs::resource::IsResource;
2526
use bevy::mesh::{Indices, PrimitiveTopology};
2627
use bevy::pbr::{DistanceFog, FogFalloff};
2728
use bevy::picking::mesh_picking::{MeshPickingCamera, MeshPickingPlugin, MeshPickingSettings};
@@ -264,7 +265,12 @@ fn main() {
264265
GameState::Menu
265266
})
266267
.add_systems(Startup, log_mods)
267-
.add_systems(OnEnter(GameState::Menu), menu::spawn_menu)
268+
// The world the last run built goes first — otherwise the next `setup` would put a
269+
// second one on top of it.
270+
.add_systems(
271+
OnEnter(GameState::Menu),
272+
(tear_down_run, menu::spawn_menu).chain(),
273+
)
268274
// The same menu, as an overlay over the standing world.
269275
.add_systems(OnEnter(GameState::Paused), menu::spawn_pause)
270276
.add_systems(
@@ -279,7 +285,13 @@ fn main() {
279285
// creates when its commands are applied — the chain inserts that sync point.
280286
.add_systems(
281287
OnEnter(GameState::Driving),
282-
(setup, audio::setup_audio, displays::setup_displays).chain(),
288+
(
289+
remember_before_run,
290+
setup,
291+
audio::setup_audio,
292+
displays::setup_displays,
293+
)
294+
.chain(),
283295
)
284296
.add_systems(
285297
Update,
@@ -400,6 +412,73 @@ fn exit_after_frames(
400412
}
401413
}
402414

415+
/// Everything that was alive before the run was built: the window, the picking pointers,
416+
/// the menu, the cloud dome. What is not in here is the run.
417+
#[derive(Resource)]
418+
struct BeforeRun(std::collections::HashSet<Entity>);
419+
420+
/// What a run may have built, and nothing a despawn has any business touching:
421+
///
422+
/// * a **resource** is an entity of its own in Bevy 0.19, and despawning one would
423+
/// *remove* the resource — the run's are replaced by the next `setup`, and the rest
424+
/// are not the run's to take;
425+
/// * an **observer** belongs to no world at all — Bevy drops the ones whose watched
426+
/// entity has gone and keeps the rest, which is exactly right here;
427+
/// * [`world_render::Persistent`] is what a plugin puts up once at startup, and a run it
428+
/// was never part of must not be able to take it down.
429+
type RunRoots<'w, 's> = Query<
430+
'w,
431+
's,
432+
Entity,
433+
(
434+
Without<ChildOf>,
435+
Without<world_render::Persistent>,
436+
Without<IsResource>,
437+
Without<Observer>,
438+
),
439+
>;
440+
441+
/// Takes that snapshot — chained in front of `setup`, so it sees the world without one.
442+
fn remember_before_run(mut commands: Commands, entities: Query<Entity>) {
443+
commands.insert_resource(BeforeRun(entities.iter().collect()));
444+
}
445+
446+
/// Drops the built world when the player leaves a run for the title screen, so the next
447+
/// `setup` builds into an empty world rather than beside the old one.
448+
///
449+
/// Roots only — `despawn` takes the children with it — and only the ones [`RunRoots`]
450+
/// lets through. An entity id carries a generation, so an id the run reused for something
451+
/// of its own is a *different* [`Entity`] than the one remembered and is dropped as it
452+
/// should be.
453+
fn tear_down_run(
454+
mut commands: Commands,
455+
before: Option<Res<BeforeRun>>,
456+
roots: RunRoots,
457+
mixer: Option<ResMut<audio::Audio>>,
458+
mut walker: ResMut<walk::Walker>,
459+
mut camera: ResMut<ui::CameraState>,
460+
) {
461+
// No run has been built yet — this is the title screen the program starts on.
462+
let Some(before) = before else {
463+
return;
464+
};
465+
for entity in &roots {
466+
if !before.0.contains(&entity) {
467+
commands.entity(entity).despawn();
468+
}
469+
}
470+
// A mixer track outlives the entity it followed: dropping the tracks is what stops
471+
// the loops of a run the player has just left.
472+
if let Some(mut mixer) = mixer {
473+
mixer.silence();
474+
}
475+
// Both of these point into the world that has just gone: the walker at a vehicle or
476+
// at a place on the earth, the camera at a wayside spot beside it.
477+
*walker = default();
478+
*camera = default();
479+
commands.remove_resource::<BeforeRun>();
480+
}
481+
403482
/// Esc during a run raises the pause overlay, which also holds the settings. Leaving it
404483
/// again is the overlay's own job — this system only runs while `Driving`, so the Esc that
405484
/// resumes cannot bounce straight back into the pause.
@@ -655,7 +734,12 @@ fn setup(
655734
);
656735
let streamer = streaming::TerrainStreamer::new(
657736
terrain_builder,
658-
render::terrain_material(&mut images, &mut terrain_materials, season),
737+
render::terrain_material(
738+
&mut images,
739+
&mut terrain_materials,
740+
season,
741+
settings::ground_quality(&graphics),
742+
),
659743
tree_catalog,
660744
f64::from(graphics.view_distance),
661745
);
@@ -696,7 +780,10 @@ fn setup(
696780
models::spawn(&mut commands, &assets, entity, &view, &file);
697781
entity
698782
} else {
699-
let mesh = meshes.add(Cuboid::new(3.0, 3.8, v.spec.length as f32));
783+
let mesh = meshes.add(
784+
Mesh::from(Cuboid::new(3.0, 3.8, v.spec.length as f32))
785+
.translated_by(Vec3::Y * 2.2),
786+
);
700787
commands
701788
.spawn((
702789
Mesh3d(mesh),
@@ -733,17 +820,17 @@ fn setup(
733820
outer_angle: 0.32,
734821
..default()
735822
},
736-
// The vehicle origin sits 2.2 m above the rail; the lamp
737-
// below it at the end, aimed a touch onto the track.
738-
Transform::from_xyz(0.0, -0.6, end)
823+
// Buffer height above the rail, at the end of the vehicle,
824+
// aimed a touch onto the track.
825+
Transform::from_xyz(0.0, 1.6, end)
739826
.looking_to(Vec3::new(0.0, -0.06, dir).normalize(), Vec3::Y),
740827
));
741828
for x in [-1.0, 1.0] {
742829
parent.spawn((
743830
TailLamp { train, reverse },
744831
Mesh3d(tail_mesh.clone()),
745832
MeshMaterial3d(tail_material.clone()),
746-
Transform::from_xyz(x, -0.6, end),
833+
Transform::from_xyz(x, 1.6, end),
747834
Visibility::Hidden,
748835
));
749836
}
@@ -760,7 +847,7 @@ fn setup(
760847
range: 4.0,
761848
..default()
762849
},
763-
Transform::from_xyz(0.0, 0.4, -(v.spec.length as f32) / 2.0 + 1.8),
850+
Transform::from_xyz(0.0, 2.6, -(v.spec.length as f32) / 2.0 + 1.8),
764851
));
765852
});
766853
}
@@ -810,6 +897,9 @@ fn setup(
810897
if graphics.bloom {
811898
commands.entity(camera).insert(Bloom::NATURAL);
812899
}
900+
// `apply_scene` only fires on a changed setting, and starting a run does not change
901+
// one — so the camera is dressed here as well as there.
902+
settings::apply_anti_aliasing(&mut commands.entity(camera), &graphics);
813903

814904
// Rain and snow: a particle column of crossed quads that follows the camera
815905
// and scrolls downwards (`update_precipitation`). Both fields exist from the
@@ -1054,6 +1144,11 @@ fn rebase_origin(
10541144
}
10551145

10561146
/// Mirror vehicle poses from the simulation into transforms.
1147+
///
1148+
/// The view sits on the rail head, which is where a vehicle model's own origin is:
1149+
/// the BR 101's wheels touch y = 0 and its cab eye is 2.55 m above it. Everything
1150+
/// hung on a vehicle — lamps, the eye point (`ui::follow`), the walker's frame
1151+
/// (`walk::frame`) — measures from there.
10571152
fn sync_vehicles(
10581153
sim: Res<SimResource>,
10591154
origin: Res<Origin>,
@@ -1067,8 +1162,7 @@ fn sync_vehicles(
10671162
continue;
10681163
};
10691164
let pose = vehicle.pos.pose(&sim.0.net);
1070-
let up = origin.0.dir_to_render(pose.up);
1071-
transform.translation = origin.0.to_render(pose.pos) + up * 2.2;
1165+
transform.translation = origin.0.to_render(pose.pos);
10721166
transform.rotation = origin.0.look_rotation(pose.tangent, pose.up);
10731167
}
10741168
}
@@ -1352,6 +1446,61 @@ fn update_precipitation(
13521446
mod tests {
13531447
use super::*;
13541448

1449+
/// Leaving a run for the title screen has to take the world with it and nothing else
1450+
/// — the window, the picking pointers and the cloud dome are all older than the run
1451+
/// and all still needed by the next one.
1452+
#[test]
1453+
fn going_back_to_the_menu_drops_the_run_and_only_the_run() {
1454+
let mut world = World::new();
1455+
world.init_resource::<walk::Walker>();
1456+
world.init_resource::<ui::CameraState>();
1457+
let mut snapshot = Schedule::default();
1458+
snapshot.add_systems(remember_before_run);
1459+
let mut leave = Schedule::default();
1460+
leave.add_systems(tear_down_run);
1461+
1462+
// Before the run: something the program put up at startup, with a child of its
1463+
// own, and the cloud dome.
1464+
let older = world.spawn_empty().id();
1465+
world.spawn(ChildOf(older));
1466+
let dome = world.spawn(world_render::Persistent).id();
1467+
snapshot.run(&mut world);
1468+
1469+
// The run: a root with a child, and one more persistent entity made after the
1470+
// snapshot — the marker is what saves it, not the moment it was made.
1471+
let train = world.spawn_empty().id();
1472+
world.spawn(ChildOf(train));
1473+
let late_dome = world.spawn(world_render::Persistent).id();
1474+
// A resource inserted by the run is an entity like any other in Bevy 0.19, and
1475+
// despawning it would take the resource with it.
1476+
world.insert_resource(ViewDistance(4_000.0));
1477+
1478+
leave.run(&mut world);
1479+
assert!(
1480+
world.get_resource::<ViewDistance>().is_some(),
1481+
"lost a resource"
1482+
);
1483+
assert!(world.get_entity(older).is_ok(), "dropped the older entity");
1484+
assert!(world.get_entity(dome).is_ok(), "dropped the cloud dome");
1485+
assert!(
1486+
world.get_entity(late_dome).is_ok(),
1487+
"dropped a persistent one"
1488+
);
1489+
assert!(
1490+
world.get_entity(train).is_err(),
1491+
"the run is still standing"
1492+
);
1493+
let left = world.iter_entities().count();
1494+
1495+
// A second visit to the title screen, with no run behind it, does nothing.
1496+
leave.run(&mut world);
1497+
assert_eq!(
1498+
world.iter_entities().count(),
1499+
left,
1500+
"a second visit took more"
1501+
);
1502+
}
1503+
13551504
#[test]
13561505
fn fall_offset_wraps_within_one_period() {
13571506
for t in [0.0, 1.7, 100.0, 86_400.0] {

0 commit comments

Comments
 (0)